`. */ private const WILDCARD = "\x1A"; /** * Recognised translation calls (the first argument is parsed separately): * - __('x'), trans('x'), trans_choice('x') * - @lang('x'), @choice('x') * - Lang::get/choice/has/hasForLocale('x') * - trans()->get('x'), app('translator')->get('x') */ private const CALL_PATTERN = <<<'REGEX' ~ (?: (?])(?:trans\(\)|app\(\s*['"]translator['"]\s*\))\s*->\s*(?:get|choice|has) | (?:])(?:__|trans_choice|trans) | @(?:lang|choice) | (?])Lang::(?:get|choice|has|hasForLocale|getFromJson) ) \s*\(\s*(?:key\s*:\s*)? ~x REGEX; /** A single PHP string literal (single or double quoted, escapes supported, no line breaks). */ private const LITERAL_PATTERN = '/(?|\'((?:[^\'\\\\\r\n]|\\\\.)*)\'|"((?:[^"\\\\\r\n]|\\\\.)*)")/'; /** Shape of a dot-notation key: group[/sub].key[.nested] */ private const KEY_SHAPE = '/^[A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*(?:\.[A-Za-z0-9_\-]+)+$/'; /** Directories (relative to base_path) that are scanned for key usage. */ protected array $scanPaths = [ 'app', 'routes', 'resources/views', ]; /** Anchored to base_path(), so `vendor` only excludes the root /vendor directory. */ protected array $defaultExclusions = [ 'vendor', 'storage', 'bootstrap/cache', 'node_modules', 'resources/views/vendor', '.git', ]; /** Keys generated for every `generatePolicy('model')` call: permissions.{model}.{suffix}. */ protected array $permissionKeySuffixes = [ 'group-name', 'viewAny', 'view', 'create', 'update', 'delete', ]; /** Strings that look like keys but never are. */ protected array $ignoredKeys = ['key']; /** Framework language files that are skipped by the unused check. */ protected array $ignoredUnusedFiles = [ 'validation', 'auth', 'pagination', 'passwords', ]; protected int $scannedFiles = 0; /** @var array> Static keys found in code, with their locations. */ protected array $foundKeys = []; /** @var array> Dynamic key patterns (glob style) found in code. */ protected array $foundPatterns = []; /** @var array Attribute / `new` handlers indexed by class name. */ protected array $attributeHandlers = []; /** @var array Language data cache indexed by locale. */ private array $langCache = []; public function __construct() { parent::__construct(); $this->registerAttributeHandler(new LayoutDataAttributeHandler()); $this->registerAttributeHandler(new LayoutAttributeHandler()); $this->registerAttributeHandler(new GenericSeoAttributeHandler('Seo')); } public function registerAttributeHandler(AttributeScanHandler $handler): void { $this->attributeHandlers[$handler->attributeClass()] = $handler; } // ------------------------------------------------------------------ // Command flow // ------------------------------------------------------------------ public function handle(): int { $purge = (bool) $this->option('purge'); if ($purge && $this->option('fix')) { $this->error('--fix and --purge cannot be combined. Run them separately.'); return Command::FAILURE; } if ($this->option('include-json') && ! $purge) { $this->error('--include-json can only be used together with --purge.'); return Command::FAILURE; } $primaryLocale = $this->argument('locale'); $locales = $this->option('all-locales') ? $this->discoverLocales() : [$primaryLocale]; $this->info('Checking translation keys against locale(s): [' . implode(', ', $locales) . ']...'); $this->scanCodebase(); $this->line("Scanned {$this->scannedFiles} file(s), found " . count($this->foundKeys) . ' static key(s) and ' . count($this->foundPatterns) . ' dynamic pattern(s).'); $this->newLine(); return ($this->option('unused') || $purge) ? $this->checkUnusedKeys($primaryLocale) : $this->checkMissingKeys($primaryLocale, $locales); } /** * Reports (and optionally fixes) keys used in code that are missing from the given locales. */ private function checkMissingKeys(string $primaryLocale, array $locales): int { $missingByLocale = []; foreach ($locales as $locale) { $langData = $this->langData($locale); $missing = array_filter( $this->foundKeys, fn (array $occurrences, string $key) => ! $this->hasKey($langData, $key), ARRAY_FILTER_USE_BOTH ); if (! empty($missing)) { $missingByLocale[$locale] = $missing; } } if (empty($missingByLocale)) { $this->info('All translation keys are properly indexed across all checked locale(s)!'); return Command::SUCCESS; } foreach ($missingByLocale as $locale => $missing) { $this->printMissingTable($locale, $missing); } if ($this->option('fix')) { $status = Command::SUCCESS; foreach ($missingByLocale as $locale => $missing) { if ($this->fixMissingKeys($locale, $missing) !== Command::SUCCESS) { $status = Command::FAILURE; } } return $status; } if ($this->option('ai')) { $this->outputAiPrompt($primaryLocale, reset($missingByLocale)); } else { $this->comment('Tip: Run with --ai to generate a copyable prompt for AI to fix these keys:'); $this->line(" php artisan lang:check {$primaryLocale} --all-locales --ai"); $this->comment('Or add placeholders to the language files (preview first with --dry-run):'); $this->line(" php artisan lang:check {$primaryLocale} --all-locales --fix --dry-run"); } return Command::FAILURE; } private function printMissingTable(string $locale, array $missing): void { $this->error('Found ' . count($missing) . " missing translation key(s) in locale [{$locale}]:"); $this->newLine(); $rows = []; foreach ($missing as $key => $occurrences) { foreach ($occurrences as $occurrence) { $rows[] = [$key, "{$occurrence['file']}:{$occurrence['line']}"]; } } $this->table(['Missing Key', 'Used In'], $rows); $this->newLine(); } /** * Reports (and optionally purges) keys that are defined but never used. */ private function checkUnusedKeys(string $locale): int { $unusedKeys = $this->findUnusedKeys($this->langData($locale)); if (empty($unusedKeys)) { $this->info('No unused translation keys found! All defined keys are actively used.'); return Command::SUCCESS; } $this->warn('Found ' . count($unusedKeys) . ' unused translation key(s) in language files:'); $this->newLine(); $this->table(['Unused Defined Key'], array_map(fn (string $key) => [$key], $unusedKeys)); if ($this->option('purge')) { return $this->purgeUnusedKeys($locale); } $this->newLine(); $this->comment('Tip: remove them from the language files (preview first with --dry-run):'); $this->line(" php artisan lang:check {$locale} --purge --dry-run"); return Command::FAILURE; } // ------------------------------------------------------------------ // Unused key detection // ------------------------------------------------------------------ /** * @return array Defined keys that nothing in the codebase uses. */ private function findUnusedKeys(array $langData): array { $used = []; foreach (array_keys($this->foundKeys) as $key) { $used[$this->normalizeKey($key)] = true; } $patterns = array_unique(array_map( fn (string $pattern) => $this->normalizeKey($pattern), array_keys($this->foundPatterns) )); return array_values(array_filter( $this->flattenLangData($langData), fn (string $key) => ! $this->isIgnoredUnusedKey($key) && ! $this->isKeyCovered($this->normalizeKey($key), $used, $patterns) )); } /** * A defined key counts as used when it is referenced directly, when one of its parents is * referenced (e.g. __('group.parent') returns the whole array), or when it matches a dynamic pattern. */ private function isKeyCovered(string $key, array $used, array $patterns): bool { if (isset($used[$key])) { return true; } $segments = explode('.', $key); for ($length = count($segments) - 1; $length > 0; $length--) { if (isset($used[implode('.', array_slice($segments, 0, $length))])) { return true; } } foreach ($patterns as $pattern) { if (Str::is($pattern, $key)) { return true; } } return false; } /** * Folder separators and dots are interchangeable when matching keys (`domains/x.key` equals `domains.x.key`). */ private function normalizeKey(string $key): string { return str_replace('/', '.', $key); } private function isIgnoredUnusedKey(string $key): bool { foreach ($this->ignoredUnusedFiles as $ignored) { if ( str_starts_with($key, "{$ignored}.") || str_contains($key, "/{$ignored}.") || str_contains($key, ".{$ignored}.") ) { return true; } } return false; } /** * @return array Every defined key in dot notation (`group.nested.key` or the raw JSON key). */ private function flattenLangData(array $langData): array { $flattened = []; foreach ($langData as $prefix => $data) { if ($prefix === self::JSON) { array_push($flattened, ...array_map('strval', array_keys($data))); } else { array_push($flattened, ...$this->flattenArray($data, (string) $prefix)); } } return $flattened; } /** * Flattens an array into dot-notation keys. A list (0..n indexes) is kept as ONE key, * because it is consumed as a whole by __('group.key'), which returns the array. * * @return array */ private function flattenArray(array $data, string $prefix): array { $keys = []; foreach ($data as $key => $value) { $path = "{$prefix}.{$key}"; if (is_array($value) && $value !== [] && ! $this->isList($value)) { array_push($keys, ...$this->flattenArray($value, $path)); } else { $keys[] = $path; } } return $keys; } private function isList(array $array): bool { return array_keys($array) === array_keys(array_values($array)); } // ------------------------------------------------------------------ // Codebase scanning // ------------------------------------------------------------------ private function scanCodebase(): void { $this->foundKeys = []; $this->foundPatterns = []; $this->scannedFiles = 0; $skips = array_filter((array) ($this->option('skip') ?? [])); $selfPath = $this->slashes(__FILE__); foreach ($this->scanPaths as $path) { if (! File::isDirectory(base_path($path))) { continue; } foreach (Finder::create()->files()->in(base_path($path))->name('*.php') as $file) { $relativePath = $this->relativePath($file->getPathname()); if ($this->slashes($file->getPathname()) === $selfPath || $this->isExcluded($relativePath, $skips)) { continue; } $this->scannedFiles++; $this->scanFile($file->getContents(), $relativePath); } } } /** * Default exclusions are anchored to the project root, while --skip values match at any depth. */ private function isExcluded(string $relativePath, array $skips): bool { foreach ($this->defaultExclusions as $excluded) { $excluded = trim($this->slashes($excluded), '/'); if ($relativePath === $excluded || str_starts_with($relativePath, $excluded . '/')) { return true; } } foreach ($skips as $skip) { $skip = trim($this->slashes((string) $skip), '/'); if ($skip === '') { continue; } if (str_contains($skip, '*')) { if (Str::is($skip, $relativePath) || Str::is('*/' . $skip, $relativePath)) { return true; } continue; } if ( $relativePath === $skip || str_starts_with($relativePath, $skip . '/') || str_contains($relativePath, '/' . $skip . '/') || str_ends_with($relativePath, '/' . $skip) ) { return true; } } return false; } private function scanFile(string $content, string $file): void { $content = $this->stripComments($content); $this->scanTranslationCalls($content, $file); $this->scanAttributeHandlers($content, $file); if (str_contains($content, 'InteractsWithLabels') && str_contains($content, 'enum ')) { $this->scanEnumKeys($content, $file); } if (str_contains($content, 'function permissions(')) { $this->scanPermissionKeys($content, $file); } } /** * Stores a discovered key (or a dynamic pattern) together with its location. */ private function record(string $key, string $file, int $line, bool $dynamic = false): void { if (! $dynamic && $this->isInvalidKey($key)) { return; } $store = $dynamic ? 'foundPatterns' : 'foundKeys'; $this->{$store}[$key]["{$file}:{$line}"] = ['file' => $file, 'line' => $line]; } /** * Finds keys in translation helper calls such as __('x'), trans('x') or @lang('x'). */ private function scanTranslationCalls(string $content, string $file): void { if (! preg_match_all(self::CALL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) { return; } foreach ($matches[0] as [$text, $offset]) { $line = $this->lineAt($content, $offset); foreach ($this->parseFirstArgument($content, $offset + strlen($text)) as $result) { $this->record($result['key'], $file, $line, $result['dynamic']); } } } /** * Finds keys in `#[Name(...)]` attributes and `new Name(...)` instantiations by delegating * the interpretation of the arguments to the registered handler of that class. */ private function scanAttributeHandlers(string $content, string $file): void { if (empty($this->attributeHandlers)) { return; } $names = implode('|', array_map('preg_quote', array_keys($this->attributeHandlers))); $patterns = [ // #[Name(...)] attributes '/#\[(' . $names . ')\s*\(((?:[^)]|\n)*?)\)\]/', // new Name(...) / new \Fully\Qualified\Name(...), including multi-line bodies '/new\s+\\\\?(?:[A-Za-z0-9_]+\\\\)*(' . $names . ')\s*\(((?:[^)]|\n)*?)\)/s', ]; foreach ($patterns as $pattern) { if (! preg_match_all($pattern, $content, $blocks, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { continue; } foreach ($blocks as $block) { $handler = $this->attributeHandlers[$block[1][0]] ?? null; if ($handler === null) { continue; } $line = $this->lineAt($content, $block[0][1]); $arguments = $this->parseAttributeArguments($block[2][0]); foreach ($handler->extractTranslationKeys($arguments) as $key) { $this->record($key, $file, $line); } } } } /** * Turns the raw argument list of an attribute into an array for the handlers. * Variables and function calls are replaced by placeholders and named arguments become array keys. * * NOTE: this relies on eval(), so it must only ever run on trusted source code. */ private function parseAttributeArguments(string $body): array { try { $clean = preg_replace('/(?\w+|\[[^\]]*\])*/', "'{variable}'", $body); $clean = preg_replace('/[A-Za-z_]\w*\([^)]*\)/', "'{call}'", $clean); $evaluable = preg_replace('/([a-zA-Z0-9_]+)\s*:/', "'$1' =>", $clean); return eval("return [{$evaluable}];") ?? []; } catch (\Throwable) { return []; } } /** * Parses the first argument of a translation call. * * Supports: * - 'key' / "key" (including apostrophes and escapes, e.g. __("You're receiving this email")) * - 'prefix.' . $var . '.suffix' => dynamic pattern "prefix.*.suffix" * - "prefix.{$var}.suffix" => dynamic pattern "prefix.*.suffix" * - $cond ? 'a.b' : 'a.c' => both literals are recorded as static keys * - __('key') . ':' => not dynamic (the concatenation is outside the call) * * @return array */ private function parseFirstArgument(string $content, int $pos): array { $length = strlen($content); $operands = []; $buffer = ''; $depth = 0; // Split the argument on top-level `.` (string concatenation) until the call ends. for ($i = $pos; $i < $length; $i++) { $char = $content[$i]; if ($char === "'" || $char === '"') { $end = $this->skipString($content, $i); $buffer .= substr($content, $i, $end - $i + 1); $i = $end; continue; } if ($char === '(' || $char === '[' || $char === '{') { $depth++; } elseif ($char === ')' || $char === ']' || $char === '}') { if ($depth === 0) { break; } $depth--; } elseif ($depth === 0 && $char === ',') { break; } elseif ($depth === 0 && $char === '.') { $prev = $content[$i - 1] ?? ''; $next = $content[$i + 1] ?? ''; $isSpread = $prev === '.' || $next === '.'; $isDecimal = ctype_digit($prev) && ctype_digit($next); if (! $isSpread && ! $isDecimal) { $operands[] = trim($buffer); $buffer = ''; continue; } } $buffer .= $char; } $operands[] = trim($buffer); if ($operands[0] === '') { return []; // trans() / __() without arguments } $key = implode('', array_map(fn (string $operand) => $this->decodeOperand($operand), $operands)); $key = preg_replace('/' . self::WILDCARD . '+/', self::WILDCARD, $key); // Fully dynamic, e.g. __($key): only pick up key-shaped literals (ternary / match branches). if ($key === self::WILDCARD) { $expression = implode(' ', $operands); // Strip helper calls whose arguments are never translation keys. $expression = preg_replace('/(?:config|route|env|view|session|old)\s*\((?:[^()]+|\([^()]*\))*\)/i', '', $expression); $results = []; foreach ($this->extractLiterals($expression) as [$literal]) { if (preg_match(self::KEY_SHAPE, $literal) && ! $this->isInvalidKey($literal)) { $results[] = ['key' => $literal, 'dynamic' => false]; } } return $results; } $dynamic = str_contains($key, self::WILDCARD); return [['key' => $dynamic ? str_replace(self::WILDCARD, '*', $key) : $key, 'dynamic' => $dynamic]]; } /** * Returns the literal value of an operand, with the WILDCARD marker for anything dynamic. */ private function decodeOperand(string $operand): string { if (preg_match('/^\'((?:[^\'\\\\]|\\\\.)*)\'$/s', $operand, $m)) { return str_replace(["\\'", '\\\\'], ["'", '\\'], $m[1]); } if (preg_match('/^"((?:[^"\\\\]|\\\\.)*)"$/s', $operand, $m)) { $inner = preg_replace( '/\{\$(?:[^{}]|\{[^{}]*\})*\}|(?\w+|\[[^\]]*\])*/', self::WILDCARD, $m[1] ); return stripcslashes($inner); } return self::WILDCARD; } /** * Index of the closing quote of the string literal that starts at $i (or the last index if unterminated). */ private function skipString(string $text, int $i): int { $quote = $text[$i]; $length = strlen($text); for ($i++; $i < $length && $text[$i] !== $quote; $i++) { if ($text[$i] === '\\') { $i++; } } return min($i, $length - 1); } /** * @return array Every string literal as [content, offset]. */ private function extractLiterals(string $content): array { return preg_match_all(self::LITERAL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE) ? $matches[1] : []; } /** * Removes Blade comments, block comments that start a line and whole-line `//` comments. * Line breaks are preserved so reported line numbers stay accurate. */ private function stripComments(string $content): string { $blank = static fn (array $m): string => str_repeat("\n", substr_count($m[0], "\n")); $content = preg_replace_callback('/\{\{--.*?--\}\}/s', $blank, $content) ?? $content; $content = preg_replace_callback('~^[ \t]*/\*.*?\*/~ms', $blank, $content) ?? $content; return preg_replace('~^[ \t]*//.*$~m', '', $content) ?? $content; } private function lineAt(string $content, int $offset): int { return substr_count($content, "\n", 0, $offset) + 1; } /** * Runs a pattern against every line. * * @return array [regex matches, 1-based line number] for each matching line. */ private function matchLines(string $content, string $pattern): array { $results = []; foreach (explode("\n", $content) as $index => $line) { if (preg_match($pattern, $line, $matches)) { $results[] = [$matches, $index + 1]; } } return $results; } /** * Keys declared by `'description' => '...'` entries and by `generatePolicy('model')` calls. */ private function scanPermissionKeys(string $content, string $file): void { foreach ($this->matchLines($content, '/[\'"]description[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/') as [$match, $line]) { $this->record($match[1], $file, $line); } if (! preg_match_all('/generatePolicy\(\s*[\'"]([^\'"]+)[\'"]/', $content, $matches, PREG_OFFSET_CAPTURE)) { return; } foreach ($matches[1] as [$model, $offset]) { $line = $this->lineAt($content, $offset); foreach ($this->permissionKeySuffixes as $suffix) { $this->record("permissions.{$model}.{$suffix}", $file, $line); } // Wildcard so every permissions.{model}.* key counts as used by the unused check. $this->record("permissions.{$model}.*", $file, $line, true); } } /** * Keys derived from the cases of a backed enum that uses the InteractsWithLabels trait. * The prefix follows the enum namespace: domains/{domain}/enum.{slug}, ui/enum.{slug} or enum.{slug}. */ private function scanEnumKeys(string $content, string $file): void { if ( ! preg_match('/namespace\s+([^;]+);/', $content, $namespace) || ! preg_match('/enum\s+([^\s:]+)/', $content, $enum) ) { return; } $fqcn = trim($namespace[1]) . '\\' . trim($enum[1]); $slug = Str::snake(trim($enum[1])); if (Str::startsWith($fqcn, 'App\\Domains\\')) { $prefix = 'domains/' . Str::kebab(Str::between($fqcn, 'App\\Domains\\', '\\Enums\\')) . "/enum.{$slug}"; } elseif (Str::startsWith($fqcn, 'App\\UI\\Enums\\')) { $prefix = "ui/enum.{$slug}"; } else { $prefix = "enum.{$slug}"; } foreach ($this->matchLines($content, '/case\s+[A-Za-z0-9_]+\s*=\s*[\'"]([^\'"]+)[\'"]\s*;/') as [$match, $line]) { $this->record("{$prefix}.{$match[1]}", $file, $line); } } /** * Strings that can never be translation keys: placeholders, variables, spread / namespace artefacts. */ private function isInvalidKey(string $key): bool { return $key === '' || str_contains($key, '::') || str_contains($key, '{$') || preg_match('/^\$\w+$/', $key) === 1 || preg_match('/\{[A-Za-z0-9_\.]+\}/', $key) === 1 || preg_match('/^\{[A-Za-z0-9_]+\}$/', $key) === 1 || str_contains($key, '...') || str_contains($key, ')]') || in_array($key, $this->ignoredKeys, true); } // ------------------------------------------------------------------ // Language data // ------------------------------------------------------------------ /** * Cached language data of a locale (see loadLanguageData()). */ private function langData(string $locale): array { return $this->langCache[$locale] ??= $this->loadLanguageData($locale); } /** * Loads `lang/{locale}.json` into the JSON bucket and every `lang/{locale}/**.php` file * into a bucket named after its relative path without extension (e.g. `domains/billing/enum`). */ private function loadLanguageData(string $locale): array { $langData = []; $jsonPath = lang_path("{$locale}.json"); if (File::exists($jsonPath)) { $langData[self::JSON] = json_decode(File::get($jsonPath), true) ?? []; } if (File::isDirectory(lang_path($locale))) { foreach (File::allFiles(lang_path($locale)) as $file) { if ($file->getExtension() !== 'php') { continue; } $content = require $file->getPathname(); if (is_array($content)) { $langData[substr($this->slashes($file->getRelativePathname()), 0, -4)] = $content; } } } return $langData; } /** * Whether a key is defined: either a flat JSON key or `bucket.nested.key` inside a PHP file. * Buckets are matched both literally and with `/` treated as `.`. */ private function hasKey(array $langData, string $key): bool { if (isset($langData[self::JSON]) && array_key_exists($key, $langData[self::JSON])) { return true; } if (! str_contains($key, '.')) { return false; } foreach ($langData as $prefix => $data) { if ($prefix === self::JSON) { continue; } foreach ([[(string) $prefix, $key], [$this->normalizeKey((string) $prefix), $this->normalizeKey($key)]] as [$bucket, $candidate]) { if (str_starts_with($candidate, $bucket . '.') && Arr::has($data, substr($candidate, strlen($bucket) + 1))) { return true; } } } return false; } /** * @return array Every locale that has a directory or a JSON file in the lang path. */ private function discoverLocales(): array { $locales = []; foreach (File::directories(lang_path()) as $dir) { if (basename($dir) !== 'vendor') { $locales[] = basename($dir); } } foreach (File::glob(lang_path('*.json')) ?: [] as $jsonFile) { $locales[] = basename($jsonFile, '.json'); } return array_values(array_unique($locales)); } // ------------------------------------------------------------------ // Fix (add missing keys) // ------------------------------------------------------------------ /** * Adds placeholder values for the missing keys of one locale. */ private function fixMissingKeys(string $locale, array $missingKeys): int { ksort($missingKeys); $mark = (string) ($this->option('mark') ?? ''); $mark = $mark === '' ? '' : rtrim($mark) . ' '; $data = $this->langData($locale); $added = []; $conflicts = []; foreach (array_keys($missingKeys) as $key) { [$bucket, $item] = $this->resolveTarget($key); $value = $mark . ($bucket === self::JSON ? $key : $this->humanize($item)); if ($bucket === self::JSON) { $data[self::JSON][$key] = $value; } else { $data[$bucket] ??= []; if (! $this->setPath($data[$bucket], $item, $value)) { $conflicts[] = $key; continue; } } $added[$key] = ['bucket' => $bucket, 'value' => $value]; } [$writable, $skipped] = $this->splitRewritable($locale, array_unique(array_column($added, 'bucket'))); $rows = []; foreach ($added as $key => $info) { if (in_array($info['bucket'], $writable, true)) { $rows[] = [$key, $info['value'], $this->langFileLabel($locale, $info['bucket'])]; } } $this->newLine(); if (! empty($rows)) { $this->info('The following keys will be added:'); $this->table(['Key', 'Placeholder Value', 'File'], $rows); } foreach ($conflicts as $key) { $this->error("Cannot add [{$key}]: a parent segment already holds a plain string value. Resolve it manually."); } $this->warnSkipped($skipped); $question = 'Write ' . count($rows) . ' key(s) into ' . count($writable) . ' file(s)? Comments and custom formatting in those files will be lost.'; if (empty($writable) || ! $this->shouldWrite($question)) { return Command::FAILURE; } $this->writeBuckets($locale, $data, $writable); $this->info('Added ' . count($rows) . ' placeholder key(s) to ' . count($writable) . ' file(s). Replace the placeholder values with real translations.'); return (empty($conflicts) && empty($skipped)) ? Command::SUCCESS : Command::FAILURE; } /** * Dot-notation keys (`group.item`) go to the PHP file of their group, anything else * (sentences, spaces, trailing dots, ...) goes to the JSON file. * * @return array{0: string, 1: string} [bucket, item] */ private function resolveTarget(string $key): array { return preg_match(self::KEY_SHAPE, $key) ? explode('.', $key, 2) : [self::JSON, $key]; } /** * Turns the last key segment into a readable placeholder, e.g. `viewAny` becomes "View any". */ private function humanize(string $item): string { $segments = explode('.', $item); $words = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', (string) end($segments)); return ucfirst(mb_strtolower(trim(str_replace(['-', '_'], ' ', $words)))); } /** * Sets a dot-notation path. Returns false when the target exists or a parent holds a * non-array value, because writing would overwrite existing data. */ private function setPath(array &$data, string $path, string $value): bool { $segments = explode('.', $path); $last = count($segments) - 1; $ref = &$data; foreach ($segments as $index => $segment) { if ($index === $last) { if (array_key_exists($segment, $ref)) { return false; } $ref[$segment] = $value; return true; } $ref[$segment] ??= []; if (! is_array($ref[$segment])) { return false; } $ref = &$ref[$segment]; } return false; } // ------------------------------------------------------------------ // Purge (remove unused keys) // ------------------------------------------------------------------ /** * Removes the unused keys of the given locale (or of every locale with --all-locales). * Unused keys are recalculated per locale because each one may define different keys. */ private function purgeUnusedKeys(string $primaryLocale): int { $includeJson = (bool) $this->option('include-json'); $locales = $this->option('all-locales') ? $this->discoverLocales() : [$primaryLocale]; $plans = []; $skippedJson = 0; $skippedFiles = []; foreach ($locales as $locale) { $data = $this->langData($locale); $removed = []; foreach ($this->findUnusedKeys($data) as $key) { $bucket = $this->locateKey($data, $key); if ($bucket === null) { continue; } if ($bucket === self::JSON) { if (! $includeJson) { $skippedJson++; continue; } unset($data[self::JSON][$key]); } else { $this->forgetPath($data[$bucket], substr($key, strlen($bucket) + 1)); } $removed[$bucket][] = $key; } [$writable, $skipped] = $this->splitRewritable($locale, array_keys($removed)); array_push($skippedFiles, ...$skipped); if (! empty($writable)) { $plans[$locale] = ['data' => $data, 'removed' => array_intersect_key($removed, array_flip($writable))]; } } $this->newLine(); $this->warnSkipped($skippedFiles); if ($skippedJson > 0) { $this->comment("{$skippedJson} unused JSON key(s) were left untouched. Use --include-json to remove them as well."); } if (empty($plans)) { $this->warn('Nothing can be purged automatically.'); return Command::FAILURE; } $rows = []; $total = 0; foreach ($plans as $locale => $plan) { foreach ($plan['removed'] as $bucket => $keys) { $rows[] = [$locale, $this->langFileLabel($locale, $bucket), count($keys)]; $total += count($keys); } } $this->info('The following keys will be removed:'); $this->table(['Locale', 'File', 'Keys to Remove'], $rows); if (! $this->shouldWrite("Remove {$total} key(s) from " . count($rows) . ' file(s)? Comments and custom formatting in those files will be lost.')) { return Command::FAILURE; } foreach ($plans as $locale => $plan) { $this->writeBuckets($locale, $plan['data'], array_keys($plan['removed'])); } $this->info("Removed {$total} unused key(s) from " . count($rows) . ' file(s).'); return empty($skippedFiles) ? Command::SUCCESS : Command::FAILURE; } /** * Finds the bucket (JSON or PHP file) that defines a flattened key. */ private function locateKey(array $data, string $key): ?string { if (isset($data[self::JSON]) && array_key_exists($key, $data[self::JSON])) { return self::JSON; } foreach (array_keys($data) as $bucket) { if ($bucket !== self::JSON && str_starts_with($key, $bucket . '.')) { return $bucket; } } return null; } /** * Removes a dot-notation path and prunes parent arrays that become empty. */ private function forgetPath(array &$data, string $path): void { if (array_key_exists($path, $data)) { unset($data[$path]); return; } if (! str_contains($path, '.')) { return; } [$head, $tail] = explode('.', $path, 2); if (! is_array($data[$head] ?? null)) { return; } $this->forgetPath($data[$head], $tail); if ($data[$head] === []) { unset($data[$head]); } } // ------------------------------------------------------------------ // Writing language files (shared by --fix and --purge) // ------------------------------------------------------------------ /** * Splits buckets into files that can be rewritten and PHP files that must be left alone. * * @return array{0: array, 1: array} [writable buckets, skipped file paths] */ private function splitRewritable(string $locale, array $buckets): array { $writable = []; $skipped = []; foreach ($buckets as $bucket) { $path = lang_path("{$locale}/{$bucket}.php"); if ($bucket !== self::JSON && ! $this->isSafelyRewritable($path)) { $skipped[] = $path; } else { $writable[] = $bucket; } } return [$writable, $skipped]; } private function warnSkipped(array $paths): void { foreach ($paths as $path) { $this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Edit it manually.'); } } /** * Applies --dry-run / --force / interactive confirmation. Returns true when files may be written. */ private function shouldWrite(string $question): bool { if ($this->option('dry-run')) { $this->comment('Dry run: no files were written.'); return false; } if (! $this->option('force') && ! $this->confirm($question, false)) { $this->comment('Aborted. No files were written.'); return false; } return true; } private function writeBuckets(string $locale, array $data, array $buckets): void { foreach ($buckets as $bucket) { $this->writeLangFile($locale, $bucket, $data[$bucket] ?? []); } } private function writeLangFile(string $locale, string $bucket, array $data): void { if ($bucket === self::JSON) { $json = json_encode( $data === [] ? new \stdClass : $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT ); File::put(lang_path("{$locale}.json"), $json . "\n"); return; } $path = lang_path("{$locale}/{$bucket}.php"); File::ensureDirectoryExists(dirname($path)); File::put($path, "exportPhpArray($data) . ";\n"); } /** * Exports an array as short-syntax PHP with 4-space indentation. */ private function exportPhpArray(array $data, int $depth = 0): string { if ($data === []) { return '[]'; } $indent = str_repeat(' ', $depth + 1); $isList = $this->isList($data); $lines = []; foreach ($data as $key => $value) { $keyPart = $isList ? '' : $this->exportScalar($key) . ' => '; $valuePart = is_array($value) ? $this->exportPhpArray($value, $depth + 1) : $this->exportScalar($value); $lines[] = $indent . $keyPart . $valuePart . ','; } return "[\n" . implode("\n", $lines) . "\n" . str_repeat(' ', $depth) . ']'; } private function exportScalar(mixed $value): string { return match (true) { is_string($value) => "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'", is_bool($value) => $value ? 'true' : 'false', $value === null => 'null', default => var_export($value, true), }; } /** * A language file can only be re-exported safely when it contains pure literals * (arrays, strings, numbers, true/false/null). Closures, constants, concatenation or * function calls would be lost or changed by an export. */ private function isSafelyRewritable(string $path): bool { if (! File::exists($path)) { return true; } $allowedTokens = [ T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_RETURN, T_ARRAY, T_DOUBLE_ARROW, T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_DNUMBER, T_DECLARE, ]; foreach (token_get_all(File::get($path)) as $token) { if (is_string($token)) { if (! str_contains('[],();-+=', $token)) { return false; } continue; } [$id, $text] = $token; if (in_array($id, $allowedTokens, true)) { continue; } if ($id === T_STRING && in_array(strtolower($text), ['true', 'false', 'null', 'strict_types'], true)) { continue; } return false; } return true; } // ------------------------------------------------------------------ // Paths and output helpers // ------------------------------------------------------------------ private function slashes(string $path): string { return str_replace('\\', '/', $path); } private function relativePath(string $path): string { return ltrim(str_replace(rtrim($this->slashes(base_path()), '/'), '', $this->slashes($path)), '/'); } private function langFileLabel(string $locale, string $bucket): string { return $this->relativePath($bucket === self::JSON ? lang_path("{$locale}.json") : lang_path("{$locale}/{$bucket}.php")); } /** * Prints a ready-to-copy prompt that asks an AI assistant to write the missing translations. */ private function outputAiPrompt(string $locale, array $missingKeys): void { $this->newLine(); $this->info('==================== AI TRANSLATION PROMPT ===================='); $this->comment('Copy the block below and paste it into ChatGPT, Claude, Gemini, or Cursor:'); $this->newLine(); // Group by the first key segment, which is also the language file the key belongs to. $groupedByDomain = []; foreach ($missingKeys as $key => $occurrences) { $parts = explode('.', $key); $domain = count($parts) > 1 ? array_shift($parts) : 'general'; $groupedByDomain[$domain][$key] = array_column($occurrences, 'file'); } $prompt = "I have missing translation keys in my Laravel project for locale [{$locale}].\n"; $prompt .= "Please generate the corresponding PHP array files or JSON entries for these keys.\n\n"; $prompt .= "### Expected Structure:\n"; $prompt .= "- For domain-prefixed keys (e.g. `domains/identity.field.user.name`), create or update `lang/{$locale}/domains/identity.php`.\n"; $prompt .= "- Keys without a dot (e.g. `You're receiving this email`) belong in `lang/{$locale}.json`.\n"; $prompt .= "- Use proper nested PHP arrays representing dot-notation keys.\n"; $prompt .= "- Translate values naturally for target locale [{$locale}].\n\n"; $prompt .= "### Missing Keys to Index:\n\n"; foreach ($groupedByDomain as $domain => $keys) { $prompt .= "#### Domain/File: {$domain}\n"; foreach ($keys as $key => $files) { $prompt .= "- `{$key}` (Used in: " . implode(', ', array_slice(array_unique($files), 0, 2)) . ")\n"; } $prompt .= "\n"; } $prompt .= 'Please output the ready-to-use PHP array code for each file.'; $this->line($prompt); $this->newLine(); $this->info('==============================================================='); } }