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; /** String literal PHP (single/double quote, mendukung escape, tanpa newline). */ private const LITERAL_PATTERN = '/(?|\'((?:[^\'\\\\\r\n]|\\\\.)*)\'|"((?:[^"\\\\\r\n]|\\\\.)*)")/'; /** * Awal atribut yang dipindai: #[Name(, termasuk namespace penuh dan atribut tanpa argumen * di depannya (#[Foo, Layout(...)]). Penutup ')' dicari oleh findClosing(). */ private const ATTRIBUTE_PATTERN = '~#\[(?:[^\]\(]*,\s*)?(?:\\\\?[\w\\\\]*\\\\)?(Seo|LayoutData|Layout|Title)\s*\(~'; /** Bentuk key bertipe dot-notation: group[/sub].key[.nested] */ private const KEY_SHAPE = '/^[A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*(?:\.[A-Za-z0-9_\-]+)+$/'; /** * Direktori (relatif terhadap base_path) yang dipindai. * Mail & Notifications ada di app/, view mail di resources/views (termasuk views/vendor/mail). */ protected array $scanPaths = [ 'app', 'routes', 'config', 'database', 'resources/views', ]; /** * Pengecualian default. Di-anchor ke base_path(), jadi 'vendor' hanya mengecualikan * /vendor di root project, BUKAN resources/views/vendor/mail atau views/vendor/notifications. */ protected array $defaultExclusions = [ 'vendor', 'storage', 'bootstrap/cache', 'node_modules', '.git', ]; /** * Nama folder yang kelas/view-nya sering menyimpan key sebagai string biasa * (mis. 'title' => 'notifications.order.title' yang diterjemahkan belakangan). */ protected array $implicitKeySegments = [ 'Notifications', 'Notification', 'Mail', 'Mails', 'Mailables', ]; /** * Suffix yang otomatis dihasilkan untuk setiap generatePolicy('model-name'): * permissions.{model-name}.{suffix}. Tambahkan suffix lain di sini bila perlu. */ protected array $permissionKeySuffixes = [ 'group-name', 'viewAny', 'view', 'create', 'update', 'delete', ]; /** * Cara membaca argumen tiap atribut (lihat docblock scanAttributes()). * 'named' = berdasarkan nama parameter, 'positional' = berdasarkan urutan argumen. * Parameter yang tidak terdaftar diperlakukan sebagai 'guess'. */ protected array $attributeRules = [ 'Title' => [ 'named' => ['content' => 'key'], 'positional' => ['key'], ], 'Layout' => [ 'named' => ['name' => 'ignore', 'view' => 'ignore', 'params' => 'values', 'data' => 'values'], 'positional' => ['ignore', 'values'], ], 'LayoutData' => [ 'named' => ['header' => 'key', 'breadcrumbs' => 'labels', 'context' => 'ignore'], 'positional' => ['key', 'labels'], ], 'Seo' => [ 'named' => [], 'positional' => [], ], ]; protected array $ignoredKeys = ['key']; protected array $ignoredUnusedFiles = [ 'validation', 'auth', 'pagination', 'passwords', ]; /** Semua string literal berbentuk key yang ditemukan di codebase (untuk --unused). */ protected array $literalKeys = []; protected int $scannedFiles = 0; 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; } // ------------------------------------------------------------------ // Unused keys // ------------------------------------------------------------------ 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; } /** * @return array Defined keys that nothing in the codebase uses. */ 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); } // String literal berbentuk key di manapun (mis. array config, return value method, dll.) 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; } // __('group.parent') mengembalikan seluruh array, jadi semua turunannya ikut terpakai. $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; } /** * Meratakan array menjadi key dot-notation. Array list (indeks 0..n) diperlakukan * sebagai SATU key, karena dipakai utuh lewat __('group.key') yang mengembalikan 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 !== [] && array_keys($value) !== range(0, count($value) - 1)) { array_push($keys, ...$this->flattenArray($value, $path)); } else { $keys[] = $path; } } return $keys; } // ------------------------------------------------------------------ // Scanning // ------------------------------------------------------------------ /** * @return array{keys: array, patterns: array} */ 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); // 1 & 2. Pemanggilan helper translasi (statis maupun dinamis) $this->scanTranslationCalls($content, $file, $keys, $patterns); // 3. Atribut #[Seo(...)], #[LayoutData(...)], #[Layout(...)], #[Title(...)] $this->scanAttributes($content, $file, $keys); // 4. String literal berbentuk key (untuk --unused & heuristik Mail/Notifications) $this->scanLiteralKeys($content, $file, $langData, $keys); // 5. Enum dengan trait InteractsWithLabels if (str_contains($content, 'InteractsWithLabels') && str_contains($content, 'enum ')) { foreach ($this->extractEnumTranslationKeys($content) as $info) { $this->addOccurrence($keys, $info['key'], $file, $info['line']); } } // 6. Method permissions() & generatePolicy() 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); } } } } /** * Mem-parse argumen pertama dari pemanggilan translasi. * * Mendukung: * - 'key' / "key" (termasuk apostrof & escape, mis. __("You're receiving this email")) * - 'prefix.' . $var . '.suffix' => pola dinamis "prefix.*.suffix" * - "prefix.{$var}.suffix" => pola dinamis "prefix.*.suffix" * - $cond ? 'a.b' : 'a.c' => kedua literal dicatat sebagai key statis * - __('key') . ':' => bukan dinamis (konkat di luar pemanggilan) * * @return array */ 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 []; // trans() / __() tanpa argumen } $value = implode('', array_map(fn (string $op) => $this->decodeOperand($op), $operands)); $value = preg_replace('/' . self::WILDCARD . '+/', self::WILDCARD, $value); // Sepenuhnya dinamis (mis. __($key)): ambil literal berbentuk key dari ekspresi (ternary/match). 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; } /** * Memindai atribut #[Seo], #[LayoutData], #[Layout], #[Title] berdasarkan struktur argumennya: * * - 'key' : nilai string adalah translation key. * - 'labels' : KEY array adalah translation key, nilainya (URL/route) diabaikan. * - 'values' : NILAI array berbentuk key adalah translation key, key array (nama variabel) diabaikan. * - 'ignore' : diabaikan (nama view, context, dst.). * - 'guess' : parameter yang tidak dikenal; hanya string berbentuk key (group.item) yang dihitung. * * Aturan per atribut ada di $attributeRules. */ private function scanAttributes(string $content, string $file, array &$keys): void { if (! preg_match_all(self::ATTRIBUTE_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) { return; } foreach ($matches[0] as $index => [$text, $offset]) { $name = $matches[1][$index][0]; $open = $offset + strlen($text) - 1; $close = $this->findClosing($content, $open); if ($close === null) { continue; } $argsOffset = $open + 1; $argsText = $this->blankComments(substr($content, $argsOffset, $close - $open - 1)); $rules = $this->attributeRules[$name] ?? ['named' => [], 'positional' => []]; $position = 0; foreach ($this->splitTopLevel($argsText, ',', $argsOffset) as [$arg, $argOffset]) { if (trim($arg) === '') { continue; } if (preg_match('/^\s*([A-Za-z_]\w*)\s*:(?!:)\s*/', $arg, $named)) { $mode = $rules['named'][$named[1]] ?? 'guess'; $expr = substr($arg, strlen($named[0])); $exprOffset = $argOffset + strlen($named[0]); } else { $mode = $rules['positional'][$position++] ?? 'guess'; $expr = $arg; $exprOffset = $argOffset; } $this->collectAttributeKeys($content, $expr, $exprOffset, $mode, $file, $keys); } } } private function collectAttributeKeys(string $content, string $expr, int $offset, string $mode, string $file, array &$keys): void { switch ($mode) { case 'ignore': return; case 'key': $this->recordAttributeLiteral($content, $expr, $offset, false, $file, $keys); return; case 'labels': foreach ($this->parseArrayEntries($expr, $offset) as [$keyExpr, $keyOffset, $valueExpr, $valueOffset]) { // ['label' => 'url'] -> label adalah key; ['label'] (tanpa key) -> nilainya adalah label if ($keyExpr !== null) { $this->recordAttributeLiteral($content, $keyExpr, $keyOffset, false, $file, $keys); } else { $this->recordAttributeLiteral($content, $valueExpr, $valueOffset, false, $file, $keys); } } return; case 'values': foreach ($this->parseArrayEntries($expr, $offset) as [, , $valueExpr, $valueOffset]) { $this->recordAttributeLiteral($content, $valueExpr, $valueOffset, true, $file, $keys); } return; default: // 'guess' foreach ($this->extractLiterals($expr) as [$literal, $literalOffset]) { if (preg_match(self::KEY_SHAPE, $literal) && ! $this->isInvalidKey($literal)) { $this->addOccurrence($keys, $literal, $file, $this->lineAt($content, $offset + $literalOffset)); } } } } private function recordAttributeLiteral(string $content, string $expr, int $offset, bool $requireKeyShape, string $file, array &$keys): void { $value = $this->parseStringLiteral($expr); if ($value === null || $value === '' || $this->isPlaceholderLabel($value) || $this->isInvalidKey($value)) { return; } if ($requireKeyShape && ! preg_match(self::KEY_SHAPE, $value)) { return; } $lead = strlen($expr) - strlen(ltrim($expr)); $this->addOccurrence($keys, $value, $file, $this->lineAt($content, $offset + $lead)); } /** * Label yang seluruhnya berupa placeholder dari context (mis. '{name}') bukan translation key. */ private function isPlaceholderLabel(string $value): bool { return trim((string) preg_replace('/\{[^{}]*\}/', '', $value)) === ''; } /** * String literal tunggal (tanpa interpolasi) => nilainya; selain itu null. */ private function parseStringLiteral(string $expr): ?string { $expr = trim($expr); if (preg_match('/^\'((?:[^\'\\\\]|\\\\.)*)\'$/s', $expr, $m)) { return str_replace(["\\'", '\\\\'], ["'", '\\'], $m[1]); } if (preg_match('/^"((?:[^"\\\\]|\\\\.)*)"$/s', $expr, $m)) { return preg_match('/(? [keyExpr, keyOffset, valueExpr, valueOffset] */ private function parseArrayEntries(string $expr, int $offset): array { $trimmed = trim($expr); $lead = strlen($expr) - strlen(ltrim($expr)); if (str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']')) { $innerStart = 1; $inner = substr($trimmed, 1, -1); } elseif (preg_match('/^array\s*\(/i', $trimmed, $m) && str_ends_with($trimmed, ')')) { $innerStart = strlen($m[0]); $inner = substr($trimmed, $innerStart, -1); } else { return []; } $entries = []; foreach ($this->splitTopLevel($inner, ',', $offset + $lead + $innerStart) as [$entry, $entryOffset]) { if (trim($entry) === '') { continue; } $pair = $this->splitTopLevel($entry, '=>', $entryOffset); $entries[] = count($pair) >= 2 ? [$pair[0][0], $pair[0][1], $pair[1][0], $pair[1][1]] : [null, 0, $pair[0][0], $pair[0][1]]; } return $entries; } /** * Memecah teks pada $separator di kedalaman nol (sadar string & kurung). * * @return array [segmen, offset absolut] */ private function splitTopLevel(string $text, string $separator, int $baseOffset): array { $parts = []; $length = strlen($text); $separatorLength = strlen($separator); $depth = 0; $start = 0; for ($i = 0; $i < $length; $i++) { $char = $text[$i]; if ($char === "'" || $char === '"') { $i = $this->skipString($text, $i); continue; } if ($char === '(' || $char === '[' || $char === '{') { $depth++; } elseif ($char === ')' || $char === ']' || $char === '}') { $depth--; } elseif ($depth === 0 && substr($text, $i, $separatorLength) === $separator) { $parts[] = [substr($text, $start, $i - $start), $baseOffset + $start]; $start = $i + $separatorLength; $i += $separatorLength - 1; } } $parts[] = [substr($text, $start), $baseOffset + $start]; return $parts; } /** * Posisi kurung penutup yang berpasangan dengan kurung pembuka di $open. */ private function findClosing(string $content, int $open): ?int { $length = strlen($content); $depth = 0; for ($i = $open; $i < $length; $i++) { $char = $content[$i]; if ($char === "'" || $char === '"') { $i = $this->skipString($content, $i); continue; } if ($char === '(' || $char === '[' || $char === '{') { $depth++; } elseif ($char === ')' || $char === ']' || $char === '}') { if (--$depth === 0) { return $i; } } } return null; } /** * Indeks tanda kutip penutup dari string yang dibuka di $i. */ 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); } /** * Mengganti komentar (// , # , /* *​/) di luar string dengan spasi, panjang teks tetap * sehingga offset & nomor baris tidak bergeser. */ private function blankComments(string $text): string { $out = $text; $length = strlen($text); for ($i = 0; $i < $length; $i++) { $char = $text[$i]; $next = $text[$i + 1] ?? ''; if ($char === "'" || $char === '"') { $i = $this->skipString($text, $i); } elseif (($char === '/' && $next === '/') || $char === '#') { for (; $i < $length && $text[$i] !== "\n"; $i++) { $out[$i] = ' '; } } elseif ($char === '/' && $next === '*') { $end = strpos($text, '*/', $i + 2); $end = $end === false ? $length - 1 : $end + 1; for ($j = $i; $j <= $end; $j++) { if ($out[$j] !== "\n") { $out[$j] = ' '; } } $i = $end; } } return $out; } /** * Mengumpulkan semua string literal berbentuk dot-notation key. * - Selalu disimpan ke $this->literalKeys (dipakai --unused). * - Pada file Mail/Notifications, jika group-nya ada di file lang, dianggap sebagai * key yang dipakai (mis. 'title' => 'notifications.order.title'). */ 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; } // Hindari nama view/route/config yang kebetulan berbentuk key $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]; } /** * Hapus komentar tanpa menggeser nomor baris: * komentar Blade, blok komentar di awal baris, dan baris yang diawali dua slash. */ 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; // Catatan: baris diawali '#' sengaja TIDAK dihapus (heading Markdown pada template mail). 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]; } // ------------------------------------------------------------------ // Extractors khusus project // ------------------------------------------------------------------ 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); // Key yang wajib ada (dicek sebagai missing) foreach ($this->permissionKeySuffixes as $suffix) { $keys[] = ['key' => "permissions.{$modelName}.{$suffix}", 'line' => $line]; } // Pola dinamis: seluruh permissions.{model}.* dianggap terpakai (untuk --unused) $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; } // ------------------------------------------------------------------ // Language data // ------------------------------------------------------------------ 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; } /** * Resolusi sama seperti Laravel: tanpa titik => key JSON; selain itu * "group[/sub].item.nested" => file lang/{locale}/group[/sub].php. */ 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; } [$group, $item] = explode('.', $key, 2); return isset($langData[$group]) && $item !== '' && Arr::has($langData[$group], $item); } private function isInvalidKey(string $key): bool { return $key === '' || str_contains($key, '::') // key milik package (namespace::group.key) tidak bisa diverifikasi || preg_match('/\$[A-Za-z_{]/', $key) === 1 || str_contains($key, '...') || str_contains($key, ')]') || in_array($key, $this->ignoredKeys, true); } // ------------------------------------------------------------------ // Fix (add missing keys) & purge (remove unused keys) // ------------------------------------------------------------------ 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]; } // File yang berisi kode PHP (closure, konstanta, pemanggilan fungsi) tidak aman ditulis ulang. $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; } /** * Key berbentuk group.item (dot-notation) ditulis ke file PHP group-nya, * selain itu (kalimat, ada spasi, titik di akhir, dst.) ke file JSON. * * @return array{0: string, 1: string} [bucket, item] */ private function resolveTarget(string $key): array { if (preg_match(self::KEY_SHAPE, $key)) { [$group, $item] = explode('.', $key, 2); return [$group, $item]; } return ['__json', $key]; } /** * Mencari "bucket" tempat key terdefinisi: '__json' atau nama group file. */ 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)); } /** * Set nilai pada path dot-notation. Mengembalikan false jika ada segmen * induk yang sudah berisi nilai non-array (akan menimpa 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; } if (! array_key_exists($segment, $ref)) { $ref[$segment] = []; } if (! is_array($ref[$segment])) { return false; } $ref = &$ref[$segment]; } return false; } /** * Hapus key pada path dot-notation, lalu bersihkan array induk yang menjadi kosong. */ 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), }; } /** * File lang PHP hanya aman ditulis ulang bila isinya murni literal * (array, string, angka, true/false/null). Closure, konstanta, konkatenasi, * atau pemanggilan fungsi akan hilang/berubah jika di-export ulang. */ 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)), '/'); } // ------------------------------------------------------------------ // AI prompt // ------------------------------------------------------------------ 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('==============================================================='); } }