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
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('===============================================================');
|
|
}
|
|
}
|
|
|