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