Aplikasi Keuangan yang mengatur lalu lintas transaksi antara bank dan aplikasi internal Kampus
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1620 lines
54 KiB

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Finder\Finder;
class CheckMissingTranslations extends Command
{
protected $signature = 'lang:check
{locale=en : The locale to check against}
{--skip=* : Additional paths, files, or globs to exclude}
{--ai : Output a structured prompt for AI to generate missing keys}
{--unused : Check for defined translation keys that are never used in codebase}
{--no-implicit : Disable heuristic detection of key-like strings in Mail/Notification classes}
{--fix : Add missing keys to the language files using placeholder values}
{--purge : Remove unused keys from the language files (implies --unused)}
{--dry-run : Preview what --fix or --purge would change without writing anything}
{--force : Do not ask for confirmation before writing files}
{--mark= : Prefix for placeholder values created by --fix, for example TODO}
{--all-locales : With --purge, purge every locale instead of only the given one}
{--include-json : With --purge, also remove unused JSON keys (risky, strings used by vendor packages are not detected)}';
protected $description = 'Check for translation keys used in the application that are missing or defined keys that are unused';
/**
* Penanda wildcard internal untuk bagian key yang dinamis.
*/
private const WILDCARD = "\x1A";
/**
* Pemanggil fungsi translasi yang dikenali:
* - __('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'
~
(?:
(?<![\w$>])(?:trans\(\)|app\(\s*['"]translator['"]\s*\))\s*->\s*(?:get|choice|has)
| (?<![\w$>:])(?:__|trans_choice|trans)
| @(?:lang|choice)
| (?<![\w$>])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<int, string> 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<int, string>
*/
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<string, array>, patterns: array<string, 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<int, array{value: string, dynamic: bool}>
*/
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(
'/\{\$(?:[^{}]|\{[^{}]*\})*\}|(?<!\\\\)\$[A-Za-z_]\w*(?:->\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('/(?<!\\\\)\$|\{\$/', $m[1]) ? null : stripcslashes($m[1]);
}
return null;
}
/**
* Mem-parse literal array ([...] atau array(...)) menjadi entri.
*
* @return array<int, array{0: ?string, 1: int, 2: string, 3: int}> [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<int, array{0: string, 1: int}> [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, "<?php\n\nreturn " . $this->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('===============================================================');
}
}