analyze(); * * // Scan specific paths * $report = $coverage->analyze([ * 'code_paths' => [app_path(), resource_path('views')], * 'lang_path' => lang_path(), * ]); * * Configuration in config/core.php: * 'lang' => [ * 'coverage' => [ * 'enabled' => true, * 'code_paths' => null, // null = auto-detect * 'exclude_paths' => ['vendor', 'node_modules'], * 'exclude_patterns' => [], * ], * ] */ class TranslationCoverage { /** * Regular expressions to find translation function calls. * Captures the translation key from various formats. * * @var array */ protected const PATTERNS = [ // PHP: __('key'), trans('key'), trans_choice('key', n) 'php_double' => '/__\s*\(\s*["\']([^"\']+)["\']/u', 'trans_double' => '/\btrans\s*\(\s*["\']([^"\']+)["\']/u', 'trans_choice' => '/trans_choice\s*\(\s*["\']([^"\']+)["\']/u', 'lang_get' => '/Lang::get\s*\(\s*["\']([^"\']+)["\']/u', 'lang_has' => '/Lang::has\s*\(\s*["\']([^"\']+)["\']/u', 'lang_choice' => '/Lang::choice\s*\(\s*["\']([^"\']+)["\']/u', // Blade: @lang('key'), {{ __('key') }} 'blade_lang' => '/@lang\s*\(\s*["\']([^"\']+)["\']\s*\)/u', // JavaScript/Vue: $t('key'), this.$t('key'), t('key') 'js_t' => '/\$t\s*\(\s*["\']([^"\']+)["\']/u', 'js_t_plain' => '/\bt\s*\(\s*["\']([^"\']+)["\']/u', ]; /** * File extensions to scan. * * @var array */ protected const EXTENSIONS = ['php', 'blade.php', 'js', 'vue', 'ts', 'tsx']; /** * Paths to exclude from scanning. * * @var array */ protected array $excludePaths; /** * Key patterns to exclude from reporting. * * @var array */ protected array $excludePatterns; public function __construct() { $this->excludePaths = config('core.lang.coverage.exclude_paths', [ 'vendor', 'node_modules', 'storage', '.git', ]); $this->excludePatterns = config('core.lang.coverage.exclude_patterns', []); } /** * Analyze translation coverage. * * @param array{code_paths?: array, lang_path?: string, locales?: array, namespace?: string|null} $options */ public function analyze(array $options = []): TranslationCoverageReport { $codePaths = $options['code_paths'] ?? $this->getDefaultCodePaths(); $langPath = $options['lang_path'] ?? lang_path(); $locales = $options['locales'] ?? $this->detectLocales($langPath); $namespace = $options['namespace'] ?? null; // 1. Scan code for used translation keys $usedKeys = $this->scanCodeForKeys($codePaths); // 2. Load defined keys from translation files $definedKeys = $this->loadDefinedKeys($langPath, $locales, $namespace); // 3. Compare and generate report return $this->generateReport($usedKeys, $definedKeys, $locales); } /** * Scan code paths for translation key usage. * * @param array $paths * @return Collection */ public function scanCodeForKeys(array $paths): Collection { $keys = collect(); foreach ($paths as $path) { if (! is_dir($path)) { continue; } $files = $this->getFilesToScan($path); foreach ($files as $file) { $fileKeys = $this->scanFile($file); $keys = $keys->merge($fileKeys); } } return $keys; } /** * Get files to scan from a directory. * * @return array */ protected function getFilesToScan(string $path): array { $files = []; try { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { $filePath = $file->getPathname(); // Skip excluded paths if ($this->shouldExcludePath($filePath)) { continue; } // Check extension if ($this->hasValidExtension($filePath)) { $files[] = $filePath; } } } catch (\Exception $e) { // Directory not readable, skip } return $files; } /** * Scan a single file for translation keys. * * @return Collection */ protected function scanFile(string $filePath): Collection { $keys = collect(); try { $content = File::get($filePath); $lines = explode("\n", $content); foreach (self::PATTERNS as $name => $pattern) { if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[1] as $match) { $key = $match[0]; $offset = $match[1]; // Skip dynamic keys (containing variables) if (str_contains($key, '$') || str_contains($key, '{')) { continue; } // Skip if matches exclude pattern if ($this->shouldExcludeKey($key)) { continue; } // Calculate line number $lineNumber = substr_count(substr($content, 0, $offset), "\n") + 1; $context = trim($lines[$lineNumber - 1] ?? ''); // Store with usage information if (! $keys->has($key)) { $keys[$key] = []; } $current = $keys[$key]; $current[] = [ 'file' => $filePath, 'line' => $lineNumber, 'context' => $this->truncateContext($context), ]; $keys[$key] = $current; } } } } catch (\Exception $e) { // File not readable, skip } return $keys; } /** * Load defined translation keys from language files. * * @param string $langPath Path to language directory * @param array $locales Locales to scan * @param string|null $namespace Optional namespace filter * @return array>> [locale => [key => [file]]] */ public function loadDefinedKeys(string $langPath, array $locales, ?string $namespace = null): array { $defined = []; foreach ($locales as $locale) { $localePath = $langPath.'/'.$locale; $defined[$locale] = []; if (! is_dir($localePath)) { continue; } // Load PHP translation files foreach (File::glob($localePath.'/*.php') as $file) { $filename = pathinfo($file, PATHINFO_FILENAME); $prefix = $namespace ? "{$namespace}::{$filename}" : $filename; try { $translations = require $file; if (is_array($translations)) { $keys = $this->flattenTranslations($translations, $prefix); foreach ($keys as $key) { if (! isset($defined[$locale][$key])) { $defined[$locale][$key] = []; } $defined[$locale][$key][] = $file; } } } catch (\Exception $e) { // Invalid translation file, skip } } // Load JSON translation files foreach (File::glob($localePath.'/*.json') as $file) { try { $translations = json_decode(File::get($file), true); if (is_array($translations)) { foreach (array_keys($translations) as $key) { $fullKey = $namespace ? "{$namespace}::{$key}" : $key; if (! isset($defined[$locale][$fullKey])) { $defined[$locale][$fullKey] = []; } $defined[$locale][$fullKey][] = $file; } } } catch (\Exception $e) { // Invalid JSON file, skip } } } return $defined; } /** * Flatten nested translation array to dot-notation keys. * * @param array $translations * @return array */ protected function flattenTranslations(array $translations, string $prefix = ''): array { $keys = []; foreach ($translations as $key => $value) { $fullKey = $prefix ? "{$prefix}.{$key}" : $key; if (is_array($value)) { $keys = array_merge($keys, $this->flattenTranslations($value, $fullKey)); } else { $keys[] = $fullKey; } } return $keys; } /** * Generate the coverage report. * * @param Collection $usedKeys * @param array>> $definedKeys * @param array $locales */ protected function generateReport(Collection $usedKeys, array $definedKeys, array $locales): TranslationCoverageReport { $report = new TranslationCoverageReport; // Get all used key names $usedKeyNames = $usedKeys->keys()->all(); foreach ($locales as $locale) { $localeDefined = $definedKeys[$locale] ?? []; $definedKeyNames = array_keys($localeDefined); // Missing keys: used in code but not defined $missing = array_diff($usedKeyNames, $definedKeyNames); foreach ($missing as $key) { $report->addMissing($locale, $key, $usedKeys[$key] ?? []); } // Unused keys: defined but not used in code $unused = array_diff($definedKeyNames, $usedKeyNames); foreach ($unused as $key) { $report->addUnused($locale, $key, $localeDefined[$key] ?? []); } // Coverage stats $totalDefined = count($definedKeyNames); $totalUsed = count(array_intersect($definedKeyNames, $usedKeyNames)); $report->setStats($locale, [ 'total_defined' => $totalDefined, 'total_used' => $totalUsed, 'total_missing' => count($missing), 'total_unused' => count($unused), 'coverage' => $totalDefined > 0 ? round(($totalUsed / $totalDefined) * 100, 2) : 100.0, ]); } // Add usage info foreach ($usedKeys as $key => $usages) { $report->addUsage($key, $usages); } return $report; } /** * Get default code paths to scan. * * @return array */ protected function getDefaultCodePaths(): array { return array_filter([ app_path(), resource_path('views'), resource_path('js'), base_path('packages'), base_path('src'), ], fn ($path) => is_dir($path)); } /** * Detect available locales from language directory. * * @return array */ protected function detectLocales(string $langPath): array { if (! is_dir($langPath)) { return []; } $locales = []; foreach (File::directories($langPath) as $dir) { $locale = basename($dir); // Skip vendor directory if ($locale !== 'vendor') { $locales[] = $locale; } } return $locales; } /** * Check if a path should be excluded from scanning. */ protected function shouldExcludePath(string $path): bool { foreach ($this->excludePaths as $exclude) { if (str_contains($path, DIRECTORY_SEPARATOR.$exclude.DIRECTORY_SEPARATOR)) { return true; } } return false; } /** * Check if a key should be excluded from reporting. */ protected function shouldExcludeKey(string $key): bool { foreach ($this->excludePatterns as $pattern) { if (preg_match($pattern, $key)) { return true; } } return false; } /** * Check if a file has a valid extension for scanning. */ protected function hasValidExtension(string $filePath): bool { foreach (self::EXTENSIONS as $ext) { if (str_ends_with(strtolower($filePath), '.'.$ext)) { return true; } } return false; } /** * Truncate context for storage. */ protected function truncateContext(string $context): string { if (strlen($context) > 200) { return substr($context, 0, 197).'...'; } return $context; } }