])(?: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; private const LITERAL_PATTERN = '/(?|\'((?:[^\'\\\\\r\n]|\\\\.)*)\'|"((?:[^"\\\\\r\n]|\\\\.)*)")/'; private const KEY_SHAPE = '/^[A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*(?:\.[A-Za-z0-9_\-]+)+$/'; protected array $scanPaths = [ 'app', 'routes', 'config', 'database', 'resources/views', ]; protected array $defaultExclusions = [ 'vendor', 'storage', 'bootstrap/cache', 'node_modules', 'resources/views/vendor', '.git', ]; protected array $implicitKeySegments = [ 'Notifications', 'Notification', 'Mail', 'Mails', 'Mailables', ]; protected array $permissionKeySuffixes = [ 'group-name', 'viewAny', 'view', 'create', 'update', 'delete', ]; protected array $ignoredKeys = ['key', 'user', 'admissionSchedule']; protected array $ignoredUnusedFiles = [ 'validation', 'auth', 'pagination', 'passwords', ]; protected array $literalKeys = []; protected int $scannedFiles = 0; /** @var array */ protected array $attributeHandlers = []; public function __construct() { parent::__construct(); $this->registerAttributeHandler(new LayoutDataAttributeHandler()); $this->registerAttributeHandler(new LayoutAttributeHandler()); $this->registerAttributeHandler(new GenericSeoAttributeHandler('Seo')); $this->registerAttributeHandler(new GenericSeoAttributeHandler('Title')); } public function registerAttributeHandler(AttributeScanHandler $handler): void { $this->attributeHandlers[$handler->attributeClass()] = $handler; } public function handle(): int { $locale = $this->argument('locale'); $purge = (bool) $this->option('purge'); $fix = (bool) $this->option('fix'); if ($purge && $fix) { $this->error('--fix and --purge cannot be combined. Run them separately.'); return Command::FAILURE; } if (($this->option('all-locales') || $this->option('include-json')) && ! $purge) { $this->error('--all-locales and --include-json can only be used together with --purge.'); return Command::FAILURE; } $this->info("Checking translation keys against locale: [{$locale}]..."); $langData = $this->loadLanguageData($locale); $scan = $this->scanCodebase($langData); $this->line("Scanned {$this->scannedFiles} file(s), found " . count($scan['keys']) . ' static key(s) and ' . count($scan['patterns']) . ' dynamic pattern(s).'); $this->newLine(); if ($this->option('unused') || $purge) { return $this->checkUnusedKeys($locale, $langData, $scan); } $missingKeys = []; foreach ($scan['keys'] as $key => $occurrences) { if (! $this->hasKey($langData, $key)) { $missingKeys[$key] = $occurrences; } } if (empty($missingKeys)) { $this->info('All translation keys are properly indexed!'); return Command::SUCCESS; } $this->error('Found ' . count($missingKeys) . ' missing translation key(s):'); $this->newLine(); $tableData = []; foreach ($missingKeys as $key => $occurrences) { foreach ($occurrences as $occ) { $tableData[] = [$key, $occ['file'] . ':' . $occ['line']]; } } $this->table(['Missing Key', 'Used In'], $tableData); if ($fix) { return $this->fixMissingKeys($locale, $langData, $missingKeys); } if ($this->option('ai')) { $this->outputAiPrompt($locale, $missingKeys); } else { $this->newLine(); $this->comment('Tip: Run with --ai to generate a copyable prompt for AI to fix these keys:'); $this->line(" php artisan lang:check {$locale} --ai"); $this->comment('Or add placeholders to the language files (preview first with --dry-run):'); $this->line(" php artisan lang:check {$locale} --fix --dry-run"); } return Command::FAILURE; } private function checkUnusedKeys(string $locale, array $langData, array $scan): int { $unusedKeys = $this->findUnusedKeys($langData, $scan); 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, $langData, $scan, $unusedKeys); } $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; } private function findUnusedKeys(array $langData, array $scan): array { $allDefinedKeys = $this->flattenLangData($langData); $unusedKeys = []; $usedKeys = []; $markUsed = function (string $key) use (&$usedKeys): void { $usedKeys[$key] = true; $usedKeys[str_replace('/', '.', $key)] = true; }; foreach (array_keys($scan['keys']) as $key) { $markUsed($key); } foreach (array_keys($this->literalKeys) as $key) { $markUsed($key); } $patterns = []; foreach (array_keys($scan['patterns']) as $pattern) { $patterns[] = $pattern; $patterns[] = str_replace('/', '.', $pattern); } $patterns = array_unique($patterns); foreach ($allDefinedKeys as $definedKey) { if ($this->isIgnoredUnusedKey($definedKey)) { continue; } $normalizedDefinedKey = str_replace('/', '.', $definedKey); if (isset($usedKeys[$definedKey]) || isset($usedKeys[$normalizedDefinedKey])) { continue; } $segments = explode('.', $definedKey); for ($length = count($segments) - 1; $length >= 2; $length--) { if (isset($usedKeys[implode('.', array_slice($segments, 0, $length))])) { continue 2; } } foreach ($patterns as $pattern) { if (Str::is($pattern, $definedKey) || Str::is($pattern, $normalizedDefinedKey)) { continue 2; } } $unusedKeys[] = $definedKey; } return $unusedKeys; } 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; } private function flattenLangData(array $langData): array { $flattened = []; foreach ($langData as $prefix => $data) { if ($prefix === '__json') { foreach (array_keys($data) as $jsonKey) { $flattened[] = (string) $jsonKey; } continue; } array_push($flattened, ...$this->flattenArray($data, (string) $prefix)); } return $flattened; } private function flattenArray(array $data, string $prefix): array { $keys = []; foreach ($data as $key => $value) { $path = "{$prefix}.{$key}"; if (is_array($value) && $value !== [] && array_keys($value) !== range(0, count($value) - 1)) { array_push($keys, ...$this->flattenArray($value, $path)); } else { $keys[] = $path; } } return $keys; } private function scanCodebase(array $langData): array { $keys = []; $patterns = []; $this->literalKeys = []; $this->scannedFiles = 0; $cliSkips = array_filter((array) ($this->option('skip') ?? [])); $basePath = rtrim(str_replace('\\', '/', base_path()), '/'); $selfPath = str_replace('\\', '/', __FILE__); foreach ($this->scanPaths as $path) { $dir = base_path($path); if (! File::isDirectory($dir)) { continue; } $finder = Finder::create()->files()->in($dir)->name('*.php'); foreach ($finder as $file) { $absolutePath = str_replace('\\', '/', $file->getPathname()); if ($absolutePath === $selfPath) { continue; } $relativePath = ltrim(substr($absolutePath, strlen($basePath)), '/'); if ($this->isExcluded($relativePath, $cliSkips)) { continue; } $this->scannedFiles++; $this->scanFile($file->getContents(), $relativePath, $langData, $keys, $patterns); } } return ['keys' => $keys, 'patterns' => $patterns]; } private function isExcluded(string $relativePath, array $cliSkips): bool { foreach ($this->defaultExclusions as $excluded) { $excluded = trim(str_replace('\\', '/', $excluded), '/'); if ($relativePath === $excluded || str_starts_with($relativePath, $excluded . '/')) { return true; } } foreach ($cliSkips as $skip) { $skip = trim(str_replace('\\', '/', (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, array $langData, array &$keys, array &$patterns): void { $content = $this->stripComments($content); $this->scanTranslationCalls($content, $file, $keys, $patterns); $this->scanAttributes($content, $file, $keys); $this->scanLiteralKeys($content, $file, $langData, $keys); if (str_contains($content, 'InteractsWithLabels') && str_contains($content, 'enum ')) { foreach ($this->extractEnumTranslationKeys($content) as $info) { $this->addOccurrence($keys, $info['key'], $file, $info['line']); } } if (str_contains($content, 'function permissions(')) { foreach ($this->extractPermissionTranslationKeys($content) as $info) { if ($info['dynamic'] ?? false) { $this->addOccurrence($patterns, $info['key'], $file, $info['line']); } else { $this->addOccurrence($keys, $info['key'], $file, $info['line']); } } } } private function scanTranslationCalls(string $content, string $file, array &$keys, array &$patterns): 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) { if ($result['dynamic']) { $this->addOccurrence($patterns, $result['value'], $file, $line); continue; } if (! $this->isInvalidKey($result['value'])) { $this->addOccurrence($keys, $result['value'], $file, $line); } } } } private function parseFirstArgument(string $content, int $pos): array { $len = strlen($content); $operands = []; $buffer = ''; $depth = 0; for ($i = $pos; $i < $len; $i++) { $char = $content[$i]; if ($char === "'" || $char === '"') { $start = $i; for ($i++; $i < $len && $content[$i] !== $char; $i++) { if ($content[$i] === '\\') { $i++; } } $buffer .= substr($content, $start, $i - $start + 1); 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 []; } $value = implode('', array_map(fn (string $op) => $this->decodeOperand($op), $operands)); $value = preg_replace('/' . self::WILDCARD . '+/', self::WILDCARD, $value); if ($value === self::WILDCARD) { $results = []; foreach ($this->extractLiterals(implode(' ', $operands)) as [$literal]) { if (preg_match(self::KEY_SHAPE, $literal)) { $results[] = ['value' => $literal, 'dynamic' => false]; } } return $results; } if (str_contains($value, self::WILDCARD)) { return [['value' => str_replace(self::WILDCARD, '*', $value), 'dynamic' => true]]; } return [['value' => $value, 'dynamic' => false]]; } 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; } private function scanAttributes(string $content, string $file, array &$keys): void { if (empty($this->attributeHandlers)) { return; } $classes = implode('|', array_map('preg_quote', array_keys($this->attributeHandlers))); $pattern = '/#\[(' . $classes . ')\s*\(((?:[^)]|\n)*?)\)\]/'; if (! preg_match_all($pattern, $content, $blocks, PREG_OFFSET_CAPTURE)) { return; } foreach ($blocks[0] as $index => [$fullBlock, $blockOffset]) { $attributeName = $blocks[1][$index][0]; $body = $blocks[2][$index][0]; if (! isset($this->attributeHandlers[$attributeName])) { continue; } $parsedArguments = $this->parseAttributeArguments($body); $extractedKeys = $this->attributeHandlers[$attributeName]->extractTranslationKeys($parsedArguments); foreach ($extractedKeys as $key) { if (! $this->isInvalidKey($key)) { $line = $this->lineAt($content, $blockOffset); $this->addOccurrence($keys, $key, $file, $line); } } } } private function parseAttributeArguments(string $body): array { try { $evaluable = preg_replace('/([a-zA-Z0-9_]+)\s*:/', "'$1' =>", $body); return eval("return [{$evaluable}];") ?? []; } catch (\Throwable) { return []; } } private function scanLiteralKeys(string $content, string $file, array $langData, array &$keys): void { $isImplicitFile = ! $this->option('no-implicit') && $this->isImplicitFile($file); foreach ($this->extractLiterals($content) as [$literal, $offset]) { if (! preg_match(self::KEY_SHAPE, $literal)) { continue; } $this->literalKeys[$literal] = true; if (! $isImplicitFile || ! $this->groupExists($langData, $literal)) { continue; } $ctxStart = max(0, $offset - 41); $context = substr($content, $ctxStart, max(0, $offset - 1 - $ctxStart)); if (preg_match('/(?:view|markdown|html|text|route|config|queue|connection|disk)\s*(?:\(|:|=>)\s*$/i', $context)) { continue; } if (! $this->isInvalidKey($literal)) { $this->addOccurrence($keys, $literal, $file, $this->lineAt($content, $offset)); } } } private function isImplicitFile(string $file): bool { foreach ($this->implicitKeySegments as $segment) { if (preg_match('~(?:^|/)' . preg_quote($segment, '~') . '/~', $file)) { return true; } } return false; } private function groupExists(array $langData, string $key): bool { $group = explode('.', $key, 2)[0]; return $group !== '' && $group !== '__json' && isset($langData[$group]); } private function extractLiterals(string $content): array { if (! preg_match_all(self::LITERAL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) { return []; } return $matches[1]; } 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; } private function addOccurrence(array &$bucket, string $key, string $file, int $line): void { $bucket[$key]["{$file}:{$line}"] = ['file' => $file, 'line' => $line]; } private function extractPermissionTranslationKeys(string $content): array { $keys = []; foreach (explode("\n", $content) as $lineNum => $line) { if (preg_match('/[\'"]description[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/', $line, $matches)) { if (! $this->isInvalidKey($matches[1])) { $keys[] = ['key' => $matches[1], 'line' => $lineNum + 1]; } } } if (preg_match_all('/generatePolicy\(\s*[\'"]([^\'"]+)[\'"]/', $content, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[1] as [$modelName, $offset]) { $line = $this->lineAt($content, $offset); foreach ($this->permissionKeySuffixes as $suffix) { $keys[] = ['key' => "permissions.{$modelName}.{$suffix}", 'line' => $line]; } $keys[] = ['key' => "permissions.{$modelName}.*", 'line' => $line, 'dynamic' => true]; } } return $keys; } private function extractEnumTranslationKeys(string $content): array { if (! preg_match('/namespace\s+([^;]+);/', $content, $namespaceMatch)) { return []; } if (! preg_match('/enum\s+([^\s:]+)/', $content, $enumMatch)) { return []; } $fqcn = trim($namespaceMatch[1]) . '\\' . trim($enumMatch[1]); $enumSlug = Str::snake(trim($enumMatch[1])); if (Str::startsWith($fqcn, 'App\\Domains\\')) { $domain = Str::between($fqcn, 'App\\Domains\\', '\\Enums\\'); $keyPrefix = 'domains/' . Str::kebab($domain) . "/enum.{$enumSlug}"; } elseif (Str::startsWith($fqcn, 'App\\UI\\Enums\\')) { $keyPrefix = "ui/enum.{$enumSlug}"; } else { $keyPrefix = "enum.{$enumSlug}"; } $keys = []; foreach (explode("\n", $content) as $lineNum => $line) { if (preg_match('/case\s+[A-Za-z0-9_]+\s*=\s*[\'"]([^\'"]+)[\'"]\s*;/', $line, $caseMatch)) { $keys[] = ['key' => "{$keyPrefix}.{$caseMatch[1]}", 'line' => $lineNum + 1]; } } return $keys; } private function loadLanguageData(string $locale): array { $langData = []; $langPath = lang_path($locale); $jsonPath = lang_path("{$locale}.json"); if (File::exists($jsonPath)) { $langData['__json'] = json_decode(File::get($jsonPath), true) ?? []; } if (File::isDirectory($langPath)) { foreach (File::allFiles($langPath) as $file) { if ($file->getExtension() !== 'php') { continue; } $relativePath = str_replace('\\', '/', $file->getRelativePathname()); $content = require $file->getPathname(); if (is_array($content)) { $langData[substr($relativePath, 0, -4)] = $content; } } } return $langData; } private function hasKey(array $langData, string $key): bool { if (isset($langData['__json']) && array_key_exists($key, $langData['__json'])) { return true; } if (! str_contains($key, '.')) { return false; } foreach ($langData as $prefix => $data) { if ($prefix === '__json') { continue; } if (str_starts_with($key, $prefix . '.')) { $item = substr($key, strlen($prefix) + 1); if (Arr::has($data, $item)) { return true; } } $normalizedPrefix = str_replace('/', '.', $prefix); $normalizedKey = str_replace('/', '.', $key); if (str_starts_with($normalizedKey, $normalizedPrefix . '.')) { $item = substr($normalizedKey, strlen($normalizedPrefix) + 1); if (Arr::has($data, $item)) { return true; } } } return false; } private function isInvalidKey(string $key): bool { return $key === '' || str_contains($key, '::') || preg_match('/^\$\w+$/', $key) === 1 || preg_match('/^\{[A-Za-z0-9_]+\}$/', $key) === 1 || str_contains($key, '...') || str_contains($key, ')]') || in_array($key, $this->ignoredKeys, true); } private function fixMissingKeys(string $locale, array $langData, array $missingKeys): int { ksort($missingKeys); $mark = (string) ($this->option('mark') ?? ''); $mark = $mark === '' ? '' : rtrim($mark) . ' '; $data = $langData; $dirty = []; $added = []; $conflicts = []; foreach (array_keys($missingKeys) as $key) { [$bucket, $item] = $this->resolveTarget($key); if ($bucket === '__json') { $value = $mark . $key; $data['__json'][$key] = $value; } else { $value = $mark . $this->humanize($item); $data[$bucket] ??= []; if (! $this->setPath($data[$bucket], $item, $value)) { $conflicts[] = $key; continue; } } $dirty[$bucket] = true; $added[$key] = ['bucket' => $bucket, 'value' => $value]; } $skippedFiles = []; foreach (array_keys($dirty) as $bucket) { if ($bucket === '__json') { continue; } $path = lang_path("{$locale}/{$bucket}.php"); if (! $this->isSafelyRewritable($path)) { $skippedFiles[$bucket] = $path; unset($dirty[$bucket]); } } $rows = []; foreach ($added as $key => $info) { if (isset($skippedFiles[$info['bucket']])) { continue; } $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."); } foreach ($skippedFiles as $bucket => $path) { $this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Add its keys manually.'); } if (empty($dirty)) { return Command::FAILURE; } if (! $this->shouldWrite('Write ' . count($rows) . ' key(s) into ' . count($dirty) . ' file(s)? Comments and custom formatting in those files will be lost.')) { return Command::FAILURE; } foreach (array_keys($dirty) as $bucket) { $this->writeLangFile($locale, $bucket, $data[$bucket]); } $this->info('Added ' . count($rows) . ' placeholder key(s) to ' . count($dirty) . ' file(s). Replace the placeholder values with real translations.'); return (empty($conflicts) && empty($skippedFiles)) ? Command::SUCCESS : Command::FAILURE; } private function purgeUnusedKeys(string $locale, array $langData, array $scan, array $unusedKeys): int { $includeJson = (bool) $this->option('include-json'); $locales = $this->option('all-locales') ? $this->discoverLocales() : [$locale]; $plans = []; $skippedJson = 0; $skippedFiles = []; foreach ($locales as $loc) { $data = $loc === $locale ? $langData : $this->loadLanguageData($loc); $unused = $loc === $locale ? $unusedKeys : $this->findUnusedKeys($data, $scan); $removed = []; foreach ($unused as $key) { $bucket = $this->locateKey($data, $key); if ($bucket === null) { continue; } if ($bucket === '__json') { if (! $includeJson) { $skippedJson++; continue; } unset($data['__json'][$key]); } else { $this->forgetPath($data[$bucket], substr($key, strlen($bucket) + 1)); } $removed[$bucket][] = $key; } foreach (array_keys($removed) as $bucket) { if ($bucket === '__json') { continue; } $path = lang_path("{$loc}/{$bucket}.php"); if (! $this->isSafelyRewritable($path)) { $skippedFiles[$path] = true; unset($removed[$bucket]); } } if (! empty($removed)) { $plans[$loc] = ['data' => $data, 'removed' => $removed]; } } $this->newLine(); foreach (array_keys($skippedFiles) as $path) { $this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Edit it manually.'); } 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; $fileCount = 0; foreach ($plans as $loc => $plan) { foreach ($plan['removed'] as $bucket => $keys) { $rows[] = [$loc, $this->langFileLabel($loc, $bucket), count($keys)]; $total += count($keys); $fileCount++; } } $this->info('The following keys will be removed:'); $this->table(['Locale', 'File', 'Keys to Remove'], $rows); if (! $this->shouldWrite("Remove {$total} key(s) from {$fileCount} file(s)? Comments and custom formatting in those files will be lost.")) { return Command::FAILURE; } foreach ($plans as $loc => $plan) { foreach (array_keys($plan['removed']) as $bucket) { $this->writeLangFile($loc, $bucket, $plan['data'][$bucket] ?? []); } } $this->info("Removed {$total} unused key(s) from {$fileCount} file(s)."); return empty($skippedFiles) ? Command::SUCCESS : Command::FAILURE; } 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')) { return true; } if (! $this->confirm($question, false)) { $this->comment('Aborted. No files were written.'); return false; } return true; } private function resolveTarget(string $key): array { if (preg_match(self::KEY_SHAPE, $key)) { [$group, $item] = explode('.', $key, 2); return [$group, $item]; } return ['__json', $key]; } private function locateKey(array $data, string $key): ?string { if (isset($data['__json']) && array_key_exists($key, $data['__json'])) { return '__json'; } $group = explode('.', $key, 2)[0]; return ($group !== '__json' && isset($data[$group])) ? $group : null; } private function humanize(string $item): string { $segments = explode('.', $item); $last = (string) end($segments); $words = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $last); $words = trim(str_replace(['-', '_'], ' ', $words)); return ucfirst(mb_strtolower($words)); } 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; } if (! array_key_exists($segment, $ref)) { $ref[$segment] = []; } if (! is_array($ref[$segment])) { return false; } $ref = &$ref[$segment]; } return false; } private function forgetPath(array &$data, string $path): void { if (array_key_exists($path, $data)) { unset($data[$path]); return; } $this->forgetSegments($data, explode('.', $path)); } private function forgetSegments(array &$data, array $segments): void { $first = array_shift($segments); if (! array_key_exists($first, $data)) { return; } if ($segments === []) { unset($data[$first]); return; } if (! is_array($data[$first])) { return; } $this->forgetSegments($data[$first], $segments); if ($data[$first] === []) { unset($data[$first]); } } private function writeLangFile(string $locale, string $bucket, array $data): void { if ($bucket === '__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"); } private function exportPhpArray(array $data, int $depth = 0): string { if ($data === []) { return '[]'; } $indent = str_repeat(' ', $depth + 1); $isList = array_keys($data) === range(0, count($data) - 1); $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), }; } 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; } 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)); } private function langFileLabel(string $locale, string$bucket): string { return $this->relativePath( $bucket === '__json' ? lang_path("{$locale}.json") : lang_path("{$locale}/{$bucket}.php") ); } private function relativePath(string $path): string { $base = rtrim(str_replace('\\', '/', base_path()), '/'); return ltrim(str_replace($base, '', str_replace('\\', '/',$path)), '/'); } 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(); $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) { $uniqueFiles = array_unique($files); $prompt .= "- `{$key}` (Used in: " . implode(', ', array_slice($uniqueFiles, 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('==============================================================='); } }