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.
 
 
 
 
 

1260 lines
40 KiB

<?php
namespace App\Console\Commands;
use App\Console\Commands\Contracts\AttributeScanHandler;
use App\Console\Commands\Handlers\GenericSeoAttributeHandler;
use App\Console\Commands\Handlers\LayoutAttributeHandler;
use App\Console\Commands\Handlers\LayoutDataAttributeHandler;
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';
private const WILDCARD = "\x1A";
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;
private const LITERAL_PATTERN = '/(?|\'((?:[^\'\\\\\r\n]|\\\\.)*)\'|"((?:[^"\\\\\r\n]|\\\\.)*)")/';
private const KEY_SHAPE = '/^[A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*(?:\.[A-Za-z0-9_\-]+)+$/';
protected array $scanPaths = [
'app',
'routes',
'config',
'database',
'resources/views',
];
protected array $defaultExclusions = [
'vendor',
'storage',
'bootstrap/cache',
'node_modules',
'resources/views/vendor',
'.git',
];
protected array $implicitKeySegments = [
'Notifications',
'Notification',
'Mail',
'Mails',
'Mailables',
];
protected array $permissionKeySuffixes = [
'group-name',
'viewAny',
'view',
'create',
'update',
'delete',
];
protected array $ignoredKeys = ['key', 'user', 'admissionSchedule'];
protected array $ignoredUnusedFiles = [
'validation',
'auth',
'pagination',
'passwords',
];
protected array $literalKeys = [];
protected int $scannedFiles = 0;
/** @var array<string, AttributeScanHandler> */
protected array $attributeHandlers = [];
public function __construct()
{
parent::__construct();
$this->registerAttributeHandler(new LayoutDataAttributeHandler());
$this->registerAttributeHandler(new LayoutAttributeHandler());
$this->registerAttributeHandler(new GenericSeoAttributeHandler('Seo'));
$this->registerAttributeHandler(new GenericSeoAttributeHandler('Title'));
}
public function registerAttributeHandler(AttributeScanHandler $handler): void
{
$this->attributeHandlers[$handler->attributeClass()] = $handler;
}
public function handle(): int
{
$locale = $this->argument('locale');
$purge = (bool) $this->option('purge');
$fix = (bool) $this->option('fix');
if ($purge && $fix) {
$this->error('--fix and --purge cannot be combined. Run them separately.');
return Command::FAILURE;
}
if (($this->option('all-locales') || $this->option('include-json')) && ! $purge) {
$this->error('--all-locales and --include-json can only be used together with --purge.');
return Command::FAILURE;
}
$this->info("Checking translation keys against locale: [{$locale}]...");
$langData = $this->loadLanguageData($locale);
$scan = $this->scanCodebase($langData);
$this->line("Scanned {$this->scannedFiles} file(s), found " . count($scan['keys']) . ' static key(s) and ' . count($scan['patterns']) . ' dynamic pattern(s).');
$this->newLine();
if ($this->option('unused') || $purge) {
return $this->checkUnusedKeys($locale, $langData, $scan);
}
$missingKeys = [];
foreach ($scan['keys'] as $key => $occurrences) {
if (! $this->hasKey($langData, $key)) {
$missingKeys[$key] = $occurrences;
}
}
if (empty($missingKeys)) {
$this->info('All translation keys are properly indexed!');
return Command::SUCCESS;
}
$this->error('Found ' . count($missingKeys) . ' missing translation key(s):');
$this->newLine();
$tableData = [];
foreach ($missingKeys as $key => $occurrences) {
foreach ($occurrences as $occ) {
$tableData[] = [$key, $occ['file'] . ':' . $occ['line']];
}
}
$this->table(['Missing Key', 'Used In'], $tableData);
if ($fix) {
return $this->fixMissingKeys($locale, $langData, $missingKeys);
}
if ($this->option('ai')) {
$this->outputAiPrompt($locale, $missingKeys);
} else {
$this->newLine();
$this->comment('Tip: Run with --ai to generate a copyable prompt for AI to fix these keys:');
$this->line(" php artisan lang:check {$locale} --ai");
$this->comment('Or add placeholders to the language files (preview first with --dry-run):');
$this->line(" php artisan lang:check {$locale} --fix --dry-run");
}
return Command::FAILURE;
}
private function checkUnusedKeys(string $locale, array $langData, array $scan): int
{
$unusedKeys = $this->findUnusedKeys($langData, $scan);
if (empty($unusedKeys)) {
$this->info('No unused translation keys found! All defined keys are actively used.');
return Command::SUCCESS;
}
$this->warn('Found ' . count($unusedKeys) . ' unused translation key(s) in language files:');
$this->newLine();
$this->table(['Unused Defined Key'], array_map(fn (string $key) => [$key], $unusedKeys));
if ($this->option('purge')) {
return $this->purgeUnusedKeys($locale, $langData, $scan, $unusedKeys);
}
$this->newLine();
$this->comment('Tip: remove them from the language files (preview first with --dry-run):');
$this->line(" php artisan lang:check {$locale} --purge --dry-run");
return Command::FAILURE;
}
private function findUnusedKeys(array $langData, array $scan): array
{
$allDefinedKeys = $this->flattenLangData($langData);
$unusedKeys = [];
$usedKeys = [];
$markUsed = function (string $key) use (&$usedKeys): void {
$usedKeys[$key] = true;
$usedKeys[str_replace('/', '.', $key)] = true;
};
foreach (array_keys($scan['keys']) as $key) {
$markUsed($key);
}
foreach (array_keys($this->literalKeys) as $key) {
$markUsed($key);
}
$patterns = [];
foreach (array_keys($scan['patterns']) as $pattern) {
$patterns[] = $pattern;
$patterns[] = str_replace('/', '.', $pattern);
}
$patterns = array_unique($patterns);
foreach ($allDefinedKeys as $definedKey) {
if ($this->isIgnoredUnusedKey($definedKey)) {
continue;
}
$normalizedDefinedKey = str_replace('/', '.', $definedKey);
if (isset($usedKeys[$definedKey]) || isset($usedKeys[$normalizedDefinedKey])) {
continue;
}
$segments = explode('.', $definedKey);
for ($length = count($segments) - 1; $length >= 2; $length--) {
if (isset($usedKeys[implode('.', array_slice($segments, 0, $length))])) {
continue 2;
}
}
foreach ($patterns as $pattern) {
if (Str::is($pattern, $definedKey) || Str::is($pattern, $normalizedDefinedKey)) {
continue 2;
}
}
$unusedKeys[] = $definedKey;
}
return $unusedKeys;
}
private function isIgnoredUnusedKey(string $key): bool
{
foreach ($this->ignoredUnusedFiles as $ignored) {
if (
str_starts_with($key, "{$ignored}.")
|| str_contains($key, "/{$ignored}.")
|| str_contains($key, ".{$ignored}.")
) {
return true;
}
}
return false;
}
private function flattenLangData(array $langData): array
{
$flattened = [];
foreach ($langData as $prefix => $data) {
if ($prefix === '__json') {
foreach (array_keys($data) as $jsonKey) {
$flattened[] = (string) $jsonKey;
}
continue;
}
array_push($flattened, ...$this->flattenArray($data, (string) $prefix));
}
return $flattened;
}
private function flattenArray(array $data, string $prefix): array
{
$keys = [];
foreach ($data as $key => $value) {
$path = "{$prefix}.{$key}";
if (is_array($value) && $value !== [] && array_keys($value) !== range(0, count($value) - 1)) {
array_push($keys, ...$this->flattenArray($value, $path));
} else {
$keys[] = $path;
}
}
return $keys;
}
private function scanCodebase(array $langData): array
{
$keys = [];
$patterns = [];
$this->literalKeys = [];
$this->scannedFiles = 0;
$cliSkips = array_filter((array) ($this->option('skip') ?? []));
$basePath = rtrim(str_replace('\\', '/', base_path()), '/');
$selfPath = str_replace('\\', '/', __FILE__);
foreach ($this->scanPaths as $path) {
$dir = base_path($path);
if (! File::isDirectory($dir)) {
continue;
}
$finder = Finder::create()->files()->in($dir)->name('*.php');
foreach ($finder as $file) {
$absolutePath = str_replace('\\', '/', $file->getPathname());
if ($absolutePath === $selfPath) {
continue;
}
$relativePath = ltrim(substr($absolutePath, strlen($basePath)), '/');
if ($this->isExcluded($relativePath, $cliSkips)) {
continue;
}
$this->scannedFiles++;
$this->scanFile($file->getContents(), $relativePath, $langData, $keys, $patterns);
}
}
return ['keys' => $keys, 'patterns' => $patterns];
}
private function isExcluded(string $relativePath, array $cliSkips): bool
{
foreach ($this->defaultExclusions as $excluded) {
$excluded = trim(str_replace('\\', '/', $excluded), '/');
if ($relativePath === $excluded || str_starts_with($relativePath, $excluded . '/')) {
return true;
}
}
foreach ($cliSkips as $skip) {
$skip = trim(str_replace('\\', '/', (string) $skip), '/');
if ($skip === '') {
continue;
}
if (str_contains($skip, '*')) {
if (Str::is($skip, $relativePath) || Str::is('*/' . $skip, $relativePath)) {
return true;
}
continue;
}
if (
$relativePath === $skip
|| str_starts_with($relativePath, $skip . '/')
|| str_contains($relativePath, '/' . $skip . '/')
|| str_ends_with($relativePath, '/' . $skip)
) {
return true;
}
}
return false;
}
private function scanFile(string $content, string $file, array $langData, array &$keys, array &$patterns): void
{
$content = $this->stripComments($content);
$this->scanTranslationCalls($content, $file, $keys, $patterns);
$this->scanAttributes($content, $file, $keys);
$this->scanLiteralKeys($content, $file, $langData, $keys);
if (str_contains($content, 'InteractsWithLabels') && str_contains($content, 'enum ')) {
foreach ($this->extractEnumTranslationKeys($content) as $info) {
$this->addOccurrence($keys, $info['key'], $file, $info['line']);
}
}
if (str_contains($content, 'function permissions(')) {
foreach ($this->extractPermissionTranslationKeys($content) as $info) {
if ($info['dynamic'] ?? false) {
$this->addOccurrence($patterns, $info['key'], $file, $info['line']);
} else {
$this->addOccurrence($keys, $info['key'], $file, $info['line']);
}
}
}
}
private function scanTranslationCalls(string $content, string $file, array &$keys, array &$patterns): void
{
if (! preg_match_all(self::CALL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) {
return;
}
foreach ($matches[0] as [$text, $offset]) {
$line = $this->lineAt($content, $offset);
foreach ($this->parseFirstArgument($content, $offset + strlen($text)) as $result) {
if ($result['dynamic']) {
$this->addOccurrence($patterns, $result['value'], $file, $line);
continue;
}
if (! $this->isInvalidKey($result['value'])) {
$this->addOccurrence($keys, $result['value'], $file, $line);
}
}
}
}
private function parseFirstArgument(string $content, int $pos): array
{
$len = strlen($content);
$operands = [];
$buffer = '';
$depth = 0;
for ($i = $pos; $i < $len; $i++) {
$char = $content[$i];
if ($char === "'" || $char === '"') {
$start = $i;
for ($i++; $i < $len && $content[$i] !== $char; $i++) {
if ($content[$i] === '\\') {
$i++;
}
}
$buffer .= substr($content, $start, $i - $start + 1);
continue;
}
if ($char === '(' || $char === '[' || $char === '{') {
$depth++;
} elseif ($char === ')' || $char === ']' || $char === '}') {
if ($depth === 0) {
break;
}
$depth--;
} elseif ($depth === 0 && $char === ',') {
break;
} elseif ($depth === 0 && $char === '.') {
$prev = $content[$i - 1] ?? '';
$next = $content[$i + 1] ?? '';
$isSpread = $prev === '.' || $next === '.';
$isDecimal = ctype_digit($prev) && ctype_digit($next);
if (! $isSpread && ! $isDecimal) {
$operands[] = trim($buffer);
$buffer = '';
continue;
}
}
$buffer .= $char;
}
$operands[] = trim($buffer);
if ($operands[0] === '') {
return [];
}
$value = implode('', array_map(fn (string $op) => $this->decodeOperand($op), $operands));
$value = preg_replace('/' . self::WILDCARD . '+/', self::WILDCARD, $value);
if ($value === self::WILDCARD) {
$results = [];
foreach ($this->extractLiterals(implode(' ', $operands)) as [$literal]) {
if (preg_match(self::KEY_SHAPE, $literal)) {
$results[] = ['value' => $literal, 'dynamic' => false];
}
}
return $results;
}
if (str_contains($value, self::WILDCARD)) {
return [['value' => str_replace(self::WILDCARD, '*', $value), 'dynamic' => true]];
}
return [['value' => $value, 'dynamic' => false]];
}
private function decodeOperand(string $operand): string
{
if (preg_match('/^\'((?:[^\'\\\\]|\\\\.)*)\'$/s', $operand, $m)) {
return str_replace(["\\'", '\\\\'], ["'", '\\'], $m[1]);
}
if (preg_match('/^"((?:[^"\\\\]|\\\\.)*)"$/s', $operand, $m)) {
$inner = preg_replace(
'/\{\$(?:[^{}]|\{[^{}]*\})*\}|(?<!\\\\)\$[A-Za-z_]\w*(?:->\w+|\[[^\]]*\])*/',
self::WILDCARD,
$m[1]
);
return stripcslashes($inner);
}
return self::WILDCARD;
}
private function scanAttributes(string $content, string $file, array &$keys): void
{
if (empty($this->attributeHandlers)) {
return;
}
$classes = implode('|', array_map('preg_quote', array_keys($this->attributeHandlers)));
$pattern = '/#\[(' . $classes . ')\s*\(((?:[^)]|\n)*?)\)\]/';
if (! preg_match_all($pattern, $content, $blocks, PREG_OFFSET_CAPTURE)) {
return;
}
foreach ($blocks[0] as $index => [$fullBlock, $blockOffset]) {
$attributeName = $blocks[1][$index][0];
$body = $blocks[2][$index][0];
if (! isset($this->attributeHandlers[$attributeName])) {
continue;
}
$parsedArguments = $this->parseAttributeArguments($body);
$extractedKeys = $this->attributeHandlers[$attributeName]->extractTranslationKeys($parsedArguments);
foreach ($extractedKeys as $key) {
if (! $this->isInvalidKey($key)) {
$line = $this->lineAt($content, $blockOffset);
$this->addOccurrence($keys, $key, $file, $line);
}
}
}
}
private function parseAttributeArguments(string $body): array
{
try {
$evaluable = preg_replace('/([a-zA-Z0-9_]+)\s*:/', "'$1' =>", $body);
return eval("return [{$evaluable}];") ?? [];
} catch (\Throwable) {
return [];
}
}
private function scanLiteralKeys(string $content, string $file, array $langData, array &$keys): void
{
$isImplicitFile = ! $this->option('no-implicit') && $this->isImplicitFile($file);
foreach ($this->extractLiterals($content) as [$literal, $offset]) {
if (! preg_match(self::KEY_SHAPE, $literal)) {
continue;
}
$this->literalKeys[$literal] = true;
if (! $isImplicitFile || ! $this->groupExists($langData, $literal)) {
continue;
}
$ctxStart = max(0, $offset - 41);
$context = substr($content, $ctxStart, max(0, $offset - 1 - $ctxStart));
if (preg_match('/(?:view|markdown|html|text|route|config|queue|connection|disk)\s*(?:\(|:|=>)\s*$/i', $context)) {
continue;
}
if (! $this->isInvalidKey($literal)) {
$this->addOccurrence($keys, $literal, $file, $this->lineAt($content, $offset));
}
}
}
private function isImplicitFile(string $file): bool
{
foreach ($this->implicitKeySegments as $segment) {
if (preg_match('~(?:^|/)' . preg_quote($segment, '~') . '/~', $file)) {
return true;
}
}
return false;
}
private function groupExists(array $langData, string $key): bool
{
$group = explode('.', $key, 2)[0];
return $group !== '' && $group !== '__json' && isset($langData[$group]);
}
private function extractLiterals(string $content): array
{
if (! preg_match_all(self::LITERAL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) {
return [];
}
return $matches[1];
}
private function stripComments(string $content): string
{
$blank = static fn (array $m): string => str_repeat("\n", substr_count($m[0], "\n"));
$content = preg_replace_callback('/\{\{--.*?--\}\}/s', $blank, $content) ?? $content;
$content = preg_replace_callback('~^[ \t]*/\*.*?\*/~ms', $blank, $content) ?? $content;
return preg_replace('~^[ \t]*//.*$~m', '', $content) ?? $content;
}
private function lineAt(string $content, int $offset): int
{
return substr_count($content, "\n", 0, $offset) + 1;
}
private function addOccurrence(array &$bucket, string $key, string $file, int $line): void
{
$bucket[$key]["{$file}:{$line}"] = ['file' => $file, 'line' => $line];
}
private function extractPermissionTranslationKeys(string $content): array
{
$keys = [];
foreach (explode("\n", $content) as $lineNum => $line) {
if (preg_match('/[\'"]description[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/', $line, $matches)) {
if (! $this->isInvalidKey($matches[1])) {
$keys[] = ['key' => $matches[1], 'line' => $lineNum + 1];
}
}
}
if (preg_match_all('/generatePolicy\(\s*[\'"]([^\'"]+)[\'"]/', $content, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[1] as [$modelName, $offset]) {
$line = $this->lineAt($content, $offset);
foreach ($this->permissionKeySuffixes as $suffix) {
$keys[] = ['key' => "permissions.{$modelName}.{$suffix}", 'line' => $line];
}
$keys[] = ['key' => "permissions.{$modelName}.*", 'line' => $line, 'dynamic' => true];
}
}
return $keys;
}
private function extractEnumTranslationKeys(string $content): array
{
if (! preg_match('/namespace\s+([^;]+);/', $content, $namespaceMatch)) {
return [];
}
if (! preg_match('/enum\s+([^\s:]+)/', $content, $enumMatch)) {
return [];
}
$fqcn = trim($namespaceMatch[1]) . '\\' . trim($enumMatch[1]);
$enumSlug = Str::snake(trim($enumMatch[1]));
if (Str::startsWith($fqcn, 'App\\Domains\\')) {
$domain = Str::between($fqcn, 'App\\Domains\\', '\\Enums\\');
$keyPrefix = 'domains/' . Str::kebab($domain) . "/enum.{$enumSlug}";
} elseif (Str::startsWith($fqcn, 'App\\UI\\Enums\\')) {
$keyPrefix = "ui/enum.{$enumSlug}";
} else {
$keyPrefix = "enum.{$enumSlug}";
}
$keys = [];
foreach (explode("\n", $content) as $lineNum => $line) {
if (preg_match('/case\s+[A-Za-z0-9_]+\s*=\s*[\'"]([^\'"]+)[\'"]\s*;/', $line, $caseMatch)) {
$keys[] = ['key' => "{$keyPrefix}.{$caseMatch[1]}", 'line' => $lineNum + 1];
}
}
return $keys;
}
private function loadLanguageData(string $locale): array
{
$langData = [];
$langPath = lang_path($locale);
$jsonPath = lang_path("{$locale}.json");
if (File::exists($jsonPath)) {
$langData['__json'] = json_decode(File::get($jsonPath), true) ?? [];
}
if (File::isDirectory($langPath)) {
foreach (File::allFiles($langPath) as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$relativePath = str_replace('\\', '/', $file->getRelativePathname());
$content = require $file->getPathname();
if (is_array($content)) {
$langData[substr($relativePath, 0, -4)] = $content;
}
}
}
return $langData;
}
private function hasKey(array $langData, string $key): bool
{
if (isset($langData['__json']) && array_key_exists($key, $langData['__json'])) {
return true;
}
if (! str_contains($key, '.')) {
return false;
}
foreach ($langData as $prefix => $data) {
if ($prefix === '__json') {
continue;
}
if (str_starts_with($key, $prefix . '.')) {
$item = substr($key, strlen($prefix) + 1);
if (Arr::has($data, $item)) {
return true;
}
}
$normalizedPrefix = str_replace('/', '.', $prefix);
$normalizedKey = str_replace('/', '.', $key);
if (str_starts_with($normalizedKey, $normalizedPrefix . '.')) {
$item = substr($normalizedKey, strlen($normalizedPrefix) + 1);
if (Arr::has($data, $item)) {
return true;
}
}
}
return false;
}
private function isInvalidKey(string $key): bool
{
return $key === ''
|| str_contains($key, '::')
|| preg_match('/^\$\w+$/', $key) === 1
|| preg_match('/^\{[A-Za-z0-9_]+\}$/', $key) === 1
|| str_contains($key, '...')
|| str_contains($key, ')]')
|| in_array($key, $this->ignoredKeys, true);
}
private function fixMissingKeys(string $locale, array $langData, array $missingKeys): int
{
ksort($missingKeys);
$mark = (string) ($this->option('mark') ?? '');
$mark = $mark === '' ? '' : rtrim($mark) . ' ';
$data = $langData;
$dirty = [];
$added = [];
$conflicts = [];
foreach (array_keys($missingKeys) as $key) {
[$bucket, $item] = $this->resolveTarget($key);
if ($bucket === '__json') {
$value = $mark . $key;
$data['__json'][$key] = $value;
} else {
$value = $mark . $this->humanize($item);
$data[$bucket] ??= [];
if (! $this->setPath($data[$bucket], $item, $value)) {
$conflicts[] = $key;
continue;
}
}
$dirty[$bucket] = true;
$added[$key] = ['bucket' => $bucket, 'value' => $value];
}
$skippedFiles = [];
foreach (array_keys($dirty) as $bucket) {
if ($bucket === '__json') {
continue;
}
$path = lang_path("{$locale}/{$bucket}.php");
if (! $this->isSafelyRewritable($path)) {
$skippedFiles[$bucket] = $path;
unset($dirty[$bucket]);
}
}
$rows = [];
foreach ($added as $key => $info) {
if (isset($skippedFiles[$info['bucket']])) {
continue;
}
$rows[] = [$key, $info['value'], $this->langFileLabel($locale, $info['bucket'])];
}
$this->newLine();
if (! empty($rows)) {
$this->info('The following keys will be added:');
$this->table(['Key', 'Placeholder Value', 'File'], $rows);
}
foreach ($conflicts as $key) {
$this->error("Cannot add [{$key}]: a parent segment already holds a plain string value. Resolve it manually.");
}
foreach ($skippedFiles as $bucket => $path) {
$this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Add its keys manually.');
}
if (empty($dirty)) {
return Command::FAILURE;
}
if (! $this->shouldWrite('Write ' . count($rows) . ' key(s) into ' . count($dirty) . ' file(s)? Comments and custom formatting in those files will be lost.')) {
return Command::FAILURE;
}
foreach (array_keys($dirty) as $bucket) {
$this->writeLangFile($locale, $bucket, $data[$bucket]);
}
$this->info('Added ' . count($rows) . ' placeholder key(s) to ' . count($dirty) . ' file(s). Replace the placeholder values with real translations.');
return (empty($conflicts) && empty($skippedFiles)) ? Command::SUCCESS : Command::FAILURE;
}
private function purgeUnusedKeys(string $locale, array $langData, array $scan, array $unusedKeys): int
{
$includeJson = (bool) $this->option('include-json');
$locales = $this->option('all-locales') ? $this->discoverLocales() : [$locale];
$plans = [];
$skippedJson = 0;
$skippedFiles = [];
foreach ($locales as $loc) {
$data = $loc === $locale ? $langData : $this->loadLanguageData($loc);
$unused = $loc === $locale ? $unusedKeys : $this->findUnusedKeys($data, $scan);
$removed = [];
foreach ($unused as $key) {
$bucket = $this->locateKey($data, $key);
if ($bucket === null) {
continue;
}
if ($bucket === '__json') {
if (! $includeJson) {
$skippedJson++;
continue;
}
unset($data['__json'][$key]);
} else {
$this->forgetPath($data[$bucket], substr($key, strlen($bucket) + 1));
}
$removed[$bucket][] = $key;
}
foreach (array_keys($removed) as $bucket) {
if ($bucket === '__json') {
continue;
}
$path = lang_path("{$loc}/{$bucket}.php");
if (! $this->isSafelyRewritable($path)) {
$skippedFiles[$path] = true;
unset($removed[$bucket]);
}
}
if (! empty($removed)) {
$plans[$loc] = ['data' => $data, 'removed' => $removed];
}
}
$this->newLine();
foreach (array_keys($skippedFiles) as $path) {
$this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Edit it manually.');
}
if ($skippedJson > 0) {
$this->comment("{$skippedJson} unused JSON key(s) were left untouched. Use --include-json to remove them as well.");
}
if (empty($plans)) {
$this->warn('Nothing can be purged automatically.');
return Command::FAILURE;
}
$rows = [];
$total = 0;
$fileCount = 0;
foreach ($plans as $loc => $plan) {
foreach ($plan['removed'] as $bucket => $keys) {
$rows[] = [$loc, $this->langFileLabel($loc, $bucket), count($keys)];
$total += count($keys);
$fileCount++;
}
}
$this->info('The following keys will be removed:');
$this->table(['Locale', 'File', 'Keys to Remove'], $rows);
if (! $this->shouldWrite("Remove {$total} key(s) from {$fileCount} file(s)? Comments and custom formatting in those files will be lost.")) {
return Command::FAILURE;
}
foreach ($plans as $loc => $plan) {
foreach (array_keys($plan['removed']) as $bucket) {
$this->writeLangFile($loc, $bucket, $plan['data'][$bucket] ?? []);
}
}
$this->info("Removed {$total} unused key(s) from {$fileCount} file(s).");
return empty($skippedFiles) ? Command::SUCCESS : Command::FAILURE;
}
private function shouldWrite(string $question): bool
{
if ($this->option('dry-run')) {
$this->comment('Dry run: no files were written.');
return false;
}
if ($this->option('force')) {
return true;
}
if (! $this->confirm($question, false)) {
$this->comment('Aborted. No files were written.');
return false;
}
return true;
}
private function resolveTarget(string $key): array
{
if (preg_match(self::KEY_SHAPE, $key)) {
[$group, $item] = explode('.', $key, 2);
return [$group, $item];
}
return ['__json', $key];
}
private function locateKey(array $data, string $key): ?string
{
if (isset($data['__json']) && array_key_exists($key, $data['__json'])) {
return '__json';
}
$group = explode('.', $key, 2)[0];
return ($group !== '__json' && isset($data[$group])) ? $group : null;
}
private function humanize(string $item): string
{
$segments = explode('.', $item);
$last = (string) end($segments);
$words = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $last);
$words = trim(str_replace(['-', '_'], ' ', $words));
return ucfirst(mb_strtolower($words));
}
private function setPath(array &$data, string $path, string $value): bool
{
$segments = explode('.', $path);
$last = count($segments) - 1;
$ref = &$data;
foreach ($segments as $index => $segment) {
if ($index === $last) {
if (array_key_exists($segment, $ref)) {
return false;
}
$ref[$segment] = $value;
return true;
}
if (! array_key_exists($segment, $ref)) {
$ref[$segment] = [];
}
if (! is_array($ref[$segment])) {
return false;
}
$ref = &$ref[$segment];
}
return false;
}
private function forgetPath(array &$data, string $path): void
{
if (array_key_exists($path, $data)) {
unset($data[$path]);
return;
}
$this->forgetSegments($data, explode('.', $path));
}
private function forgetSegments(array &$data, array $segments): void
{
$first = array_shift($segments);
if (! array_key_exists($first, $data)) {
return;
}
if ($segments === []) {
unset($data[$first]);
return;
}
if (! is_array($data[$first])) {
return;
}
$this->forgetSegments($data[$first], $segments);
if ($data[$first] === []) {
unset($data[$first]);
}
}
private function writeLangFile(string $locale, string $bucket, array $data): void
{
if ($bucket === '__json') {
$json = json_encode(
$data === [] ? new \stdClass : $data,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT
);
File::put(lang_path("{$locale}.json"), $json . "\n");
return;
}
$path = lang_path("{$locale}/{$bucket}.php");
File::ensureDirectoryExists(dirname($path));
File::put($path, "<?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),
};
}
private function isSafelyRewritable(string $path): bool
{
if (! File::exists($path)) {
return true;
}
$allowedTokens = [
T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_RETURN, T_ARRAY,
T_DOUBLE_ARROW, T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_DNUMBER, T_DECLARE,
];
foreach (token_get_all(File::get($path)) as$token) {
if (is_string($token)) {
if (! str_contains('[],();-+=', $token)) {
return false;
}
continue;
}
[$id, $text] =$token;
if (in_array($id,$allowedTokens, true)) {
continue;
}
if ($id === T_STRING && in_array(strtolower($text), ['true', 'false', 'null', 'strict_types'], true)) {
continue;
}
return false;
}
return true;
}
private function discoverLocales(): array
{
$locales = [];
foreach (File::directories(lang_path()) as $dir) {
if (basename($dir) !== 'vendor') {
$locales[] = basename($dir);
}
}
foreach (File::glob(lang_path('*.json')) ?: [] as $jsonFile) {
$locales[] = basename($jsonFile, '.json');
}
return array_values(array_unique($locales));
}
private function langFileLabel(string $locale, string$bucket): string
{
return $this->relativePath(
$bucket === '__json' ? lang_path("{$locale}.json") : lang_path("{$locale}/{$bucket}.php")
);
}
private function relativePath(string $path): string
{
$base = rtrim(str_replace('\\', '/', base_path()), '/');
return ltrim(str_replace($base, '', str_replace('\\', '/',$path)), '/');
}
private function outputAiPrompt(string $locale, array$missingKeys): void
{
$this->newLine();$this->info('==================== AI TRANSLATION PROMPT ====================');
$this->comment('Copy the block below and paste it into ChatGPT, Claude, Gemini, or Cursor:');$this->newLine();
$groupedByDomain = [];
foreach ($missingKeys as$key => $occurrences) {$parts = explode('.', $key);$domain = count($parts) > 1 ? array_shift($parts) : 'general';
$groupedByDomain[$domain][$key] = array_column($occurrences, 'file');
}
$prompt = "I have missing translation keys in my Laravel project for locale [{$locale}].\n";
$prompt .= "Please generate the corresponding PHP array files or JSON entries for these keys.\n\n";
$prompt .= "### Expected Structure:\n";
$prompt .= "- For domain-prefixed keys (e.g. `domains/identity.field.user.name`), create or update `lang/{$locale}/domains/identity.php`.\n";
$prompt .= "- Keys without a dot (e.g. `You're receiving this email`) belong in `lang/{$locale}.json`.\n";
$prompt .= "- Use proper nested PHP arrays representing dot-notation keys.\n";
$prompt .= "- Translate values naturally for target locale [{$locale}].\n\n";
$prompt .= "### Missing Keys to Index:\n\n";
foreach ($groupedByDomain as $domain => $keys) {
$prompt .= "#### Domain/File: {$domain}\n";
foreach ($keys as $key => $files) {
$uniqueFiles = array_unique($files);
$prompt .= "- `{$key}` (Used in: " . implode(', ', array_slice($uniqueFiles, 0, 2)) . ")\n";
}
$prompt .= "\n";
}
$prompt .= 'Please output the ready-to-use PHP array code for each file.';
$this->line($prompt);
$this->newLine();
$this->info('===============================================================');
}
}