report.json */ class TranslationCoverageCommand extends Command { /** * The name and signature of the console command. */ protected $signature = 'lang:coverage {--locale= : Analyze specific locale only} {--path= : Additional code path to scan} {--lang-path= : Custom language directory path} {--missing : Show only missing keys} {--unused : Show only unused keys} {--json : Output as JSON} {--verbose : Show detailed usage information}'; /** * The console command description. */ protected $description = 'Report translation coverage - find missing and unused translation keys'; /** * Execute the console command. */ public function handle(TranslationCoverage $coverage): int { $this->newLine(); $this->components->info('Translation Coverage Analysis'); $this->newLine(); // Build options $options = []; if ($langPath = $this->option('lang-path')) { $options['lang_path'] = $langPath; } if ($locale = $this->option('locale')) { $options['locales'] = [$locale]; } if ($additionalPath = $this->option('path')) { $options['code_paths'] = array_filter([ app_path(), resource_path('views'), resource_path('js'), base_path('packages'), base_path('src'), $additionalPath, ], fn ($p) => is_dir($p)); } // Run analysis with progress indication $report = null; $this->components->task('Scanning code for translation keys', function () use ($coverage, $options, &$report) { $report = $coverage->analyze($options); return true; }); $this->newLine(); // Output based on format if ($this->option('json')) { $this->line($report->toJson()); return $report->hasIssues() ? self::FAILURE : self::SUCCESS; } // Display summary $summary = $report->getSummary(); $this->displaySummary($summary); // Display per-locale stats $this->displayLocaleStats($report); // Display issues $showMissing = ! $this->option('unused'); $showUnused = ! $this->option('missing'); $verbose = $this->option('verbose'); if ($showMissing && $report->hasMissing()) { $this->displayMissingKeys($report, $verbose); } if ($showUnused && $report->hasUnused()) { $this->displayUnusedKeys($report, $verbose); } // Final status $this->newLine(); if ($report->hasIssues()) { $this->components->warn('Translation coverage issues found. See details above.'); return self::FAILURE; } $this->components->info('No translation coverage issues found.'); return self::SUCCESS; } /** * Display the summary section. * * @param array{locales: int, total_coverage: float, total_missing: int, total_unused: int, has_issues: bool} $summary */ protected function displaySummary(array $summary): void { $this->components->twoColumnDetail('Summary', ''); $this->components->twoColumnDetail( 'Locales analyzed', "{$summary['locales']}" ); $coverageColor = $summary['total_coverage'] >= 90 ? 'green' : ($summary['total_coverage'] >= 70 ? 'yellow' : 'red'); $this->components->twoColumnDetail( 'Overall coverage', "{$summary['total_coverage']}%" ); $missingColor = $summary['total_missing'] === 0 ? 'green' : 'yellow'; $this->components->twoColumnDetail( 'Missing keys', "{$summary['total_missing']}" ); $unusedColor = $summary['total_unused'] === 0 ? 'green' : 'yellow'; $this->components->twoColumnDetail( 'Unused keys', "{$summary['total_unused']}" ); $this->newLine(); } /** * Display per-locale statistics. * * @param TranslationCoverageReport $report */ protected function displayLocaleStats($report): void { $locales = $report->getLocales(); if (empty($locales)) { return; } $this->components->twoColumnDetail('Per-Locale Statistics', ''); foreach ($locales as $locale) { $stats = $report->getStats($locale); $coverageColor = $stats['coverage'] >= 90 ? 'green' : ($stats['coverage'] >= 70 ? 'yellow' : 'red'); $this->components->twoColumnDetail( $locale, sprintf( '%.1f%% (defined: %d, used: %d, missing: %d, unused: %d)', $coverageColor, $stats['coverage'], $stats['total_defined'], $stats['total_used'], $stats['total_missing'], $stats['total_unused'] ) ); } $this->newLine(); } /** * Display missing keys. * * @param TranslationCoverageReport $report */ protected function displayMissingKeys($report, bool $verbose): void { $this->components->twoColumnDetail('Missing Keys', ''); $this->line(' Keys used in code but not defined in translation files:'); $this->newLine(); foreach ($report->getLocales() as $locale) { $missing = $report->getMissing($locale); if (empty($missing)) { continue; } $this->line(" {$locale}:"); foreach ($missing as $key => $usages) { $this->line(" - {$key}"); if ($verbose && ! empty($usages)) { foreach (array_slice($usages, 0, 3) as $usage) { $shortPath = $this->shortenPath($usage['file']); $this->line(" Used in: {$shortPath}:{$usage['line']}"); } if (count($usages) > 3) { $remaining = count($usages) - 3; $this->line(" ... and {$remaining} more usages"); } } } $this->newLine(); } } /** * Display unused keys. * * @param TranslationCoverageReport $report */ protected function displayUnusedKeys($report, bool $verbose): void { $this->components->twoColumnDetail('Unused Keys', ''); $this->line(' Keys defined in translation files but not used in code:'); $this->newLine(); foreach ($report->getLocales() as $locale) { $unused = $report->getUnused($locale); if (empty($unused)) { continue; } $this->line(" {$locale}:"); foreach ($unused as $key => $files) { $this->line(" - {$key}"); if ($verbose && ! empty($files)) { foreach ($files as $file) { $shortPath = $this->shortenPath($file); $this->line(" Defined in: {$shortPath}"); } } } $this->newLine(); } } /** * Shorten a file path for display. */ protected function shortenPath(string $path): string { $basePath = base_path(); if (str_starts_with($path, $basePath)) { return substr($path, strlen($basePath) + 1); } return $path; } /** * Get shell completion suggestions. */ public function complete( CompletionInput $input, CompletionSuggestions $suggestions ): void { if ($input->mustSuggestOptionValuesFor('locale')) { // Suggest available locales $langPath = lang_path(); if (is_dir($langPath)) { $locales = []; foreach (scandir($langPath) as $item) { if ($item !== '.' && $item !== '..' && $item !== 'vendor' && is_dir($langPath.'/'.$item)) { $locales[] = $item; } } $suggestions->suggestValues($locales); } } if ($input->mustSuggestOptionValuesFor('path')) { // Suggest common paths $suggestions->suggestValues([ app_path(), resource_path('views'), resource_path('js'), base_path('packages'), ]); } } }