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.
1382 lines
47 KiB
1382 lines
47 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;
|
|
|
|
/**
|
|
* Scans the codebase for translation keys and compares them with the language files.
|
|
*
|
|
* - default : reports keys used in code but missing from the language files (--fix adds them)
|
|
* - --unused : reports keys defined in the language files but never used (--purge removes them)
|
|
*
|
|
* Keys are discovered from translation helper calls, PHP attributes / object instantiations
|
|
* handled by registered AttributeScanHandlers, enum cases and permission definitions.
|
|
*/
|
|
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}
|
|
{--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 or check, process 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';
|
|
|
|
/** Bucket name used for the flat `{locale}.json` file inside the language data array. */
|
|
private const JSON = '__json';
|
|
|
|
/** Internal marker for the dynamic parts of a key, e.g. `'mail.' . $type` becomes `mail.<WILDCARD>`. */
|
|
private const WILDCARD = "\x1A";
|
|
|
|
/**
|
|
* Recognised translation calls (the first argument is parsed separately):
|
|
* - __('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;
|
|
|
|
/** A single PHP string literal (single or double quoted, escapes supported, no line breaks). */
|
|
private const LITERAL_PATTERN = '/(?|\'((?:[^\'\\\\\r\n]|\\\\.)*)\'|"((?:[^"\\\\\r\n]|\\\\.)*)")/';
|
|
|
|
/** Shape of a dot-notation key: group[/sub].key[.nested] */
|
|
private const KEY_SHAPE = '/^[A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*(?:\.[A-Za-z0-9_\-]+)+$/';
|
|
|
|
/** Directories (relative to base_path) that are scanned for key usage. */
|
|
protected array $scanPaths = [
|
|
'app',
|
|
'routes',
|
|
'resources/views',
|
|
];
|
|
|
|
/** Anchored to base_path(), so `vendor` only excludes the root /vendor directory. */
|
|
protected array $defaultExclusions = [
|
|
'vendor',
|
|
'storage',
|
|
'bootstrap/cache',
|
|
'node_modules',
|
|
'resources/views/vendor',
|
|
'.git',
|
|
];
|
|
|
|
/** Keys generated for every `generatePolicy('model')` call: permissions.{model}.{suffix}. */
|
|
protected array $permissionKeySuffixes = [
|
|
'group-name',
|
|
'viewAny',
|
|
'view',
|
|
'create',
|
|
'update',
|
|
'delete',
|
|
];
|
|
|
|
/** Strings that look like keys but never are. */
|
|
protected array $ignoredKeys = ['key'];
|
|
|
|
/** Framework language files that are skipped by the unused check. */
|
|
protected array $ignoredUnusedFiles = [
|
|
'validation',
|
|
'auth',
|
|
'pagination',
|
|
'passwords',
|
|
];
|
|
|
|
protected int $scannedFiles = 0;
|
|
|
|
/** @var array<string, array<string, array{file: string, line: int}>> Static keys found in code, with their locations. */
|
|
protected array $foundKeys = [];
|
|
|
|
/** @var array<string, array<string, array{file: string, line: int}>> Dynamic key patterns (glob style) found in code. */
|
|
protected array $foundPatterns = [];
|
|
|
|
/** @var array<string, AttributeScanHandler> Attribute / `new` handlers indexed by class name. */
|
|
protected array $attributeHandlers = [];
|
|
|
|
/** @var array<string, array> Language data cache indexed by locale. */
|
|
private array $langCache = [];
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
|
|
$this->registerAttributeHandler(new LayoutDataAttributeHandler());
|
|
$this->registerAttributeHandler(new LayoutAttributeHandler());
|
|
$this->registerAttributeHandler(new GenericSeoAttributeHandler('Seo'));
|
|
}
|
|
|
|
public function registerAttributeHandler(AttributeScanHandler $handler): void
|
|
{
|
|
$this->attributeHandlers[$handler->attributeClass()] = $handler;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Command flow
|
|
// ------------------------------------------------------------------
|
|
|
|
public function handle(): int
|
|
{
|
|
$purge = (bool) $this->option('purge');
|
|
|
|
if ($purge && $this->option('fix')) {
|
|
$this->error('--fix and --purge cannot be combined. Run them separately.');
|
|
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
if ($this->option('include-json') && ! $purge) {
|
|
$this->error('--include-json can only be used together with --purge.');
|
|
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$primaryLocale = $this->argument('locale');
|
|
$locales = $this->option('all-locales') ? $this->discoverLocales() : [$primaryLocale];
|
|
|
|
$this->info('Checking translation keys against locale(s): [' . implode(', ', $locales) . ']...');
|
|
|
|
$this->scanCodebase();
|
|
|
|
$this->line("Scanned {$this->scannedFiles} file(s), found " . count($this->foundKeys) . ' static key(s) and ' . count($this->foundPatterns) . ' dynamic pattern(s).');
|
|
$this->newLine();
|
|
|
|
return ($this->option('unused') || $purge)
|
|
? $this->checkUnusedKeys($primaryLocale)
|
|
: $this->checkMissingKeys($primaryLocale, $locales);
|
|
}
|
|
|
|
/**
|
|
* Reports (and optionally fixes) keys used in code that are missing from the given locales.
|
|
*/
|
|
private function checkMissingKeys(string $primaryLocale, array $locales): int
|
|
{
|
|
$missingByLocale = [];
|
|
|
|
foreach ($locales as $locale) {
|
|
$langData = $this->langData($locale);
|
|
|
|
$missing = array_filter(
|
|
$this->foundKeys,
|
|
fn (array $occurrences, string $key) => ! $this->hasKey($langData, $key),
|
|
ARRAY_FILTER_USE_BOTH
|
|
);
|
|
|
|
if (! empty($missing)) {
|
|
$missingByLocale[$locale] = $missing;
|
|
}
|
|
}
|
|
|
|
if (empty($missingByLocale)) {
|
|
$this->info('All translation keys are properly indexed across all checked locale(s)!');
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
foreach ($missingByLocale as $locale => $missing) {
|
|
$this->printMissingTable($locale, $missing);
|
|
}
|
|
|
|
if ($this->option('fix')) {
|
|
$status = Command::SUCCESS;
|
|
|
|
foreach ($missingByLocale as $locale => $missing) {
|
|
if ($this->fixMissingKeys($locale, $missing) !== Command::SUCCESS) {
|
|
$status = Command::FAILURE;
|
|
}
|
|
}
|
|
|
|
return $status;
|
|
}
|
|
|
|
if ($this->option('ai')) {
|
|
$this->outputAiPrompt($primaryLocale, reset($missingByLocale));
|
|
} else {
|
|
$this->comment('Tip: Run with --ai to generate a copyable prompt for AI to fix these keys:');
|
|
$this->line(" php artisan lang:check {$primaryLocale} --all-locales --ai");
|
|
$this->comment('Or add placeholders to the language files (preview first with --dry-run):');
|
|
$this->line(" php artisan lang:check {$primaryLocale} --all-locales --fix --dry-run");
|
|
}
|
|
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
private function printMissingTable(string $locale, array $missing): void
|
|
{
|
|
$this->error('Found ' . count($missing) . " missing translation key(s) in locale [{$locale}]:");
|
|
$this->newLine();
|
|
|
|
$rows = [];
|
|
foreach ($missing as $key => $occurrences) {
|
|
foreach ($occurrences as $occurrence) {
|
|
$rows[] = [$key, "{$occurrence['file']}:{$occurrence['line']}"];
|
|
}
|
|
}
|
|
|
|
$this->table(['Missing Key', 'Used In'], $rows);
|
|
$this->newLine();
|
|
}
|
|
|
|
/**
|
|
* Reports (and optionally purges) keys that are defined but never used.
|
|
*/
|
|
private function checkUnusedKeys(string $locale): int
|
|
{
|
|
$unusedKeys = $this->findUnusedKeys($this->langData($locale));
|
|
|
|
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);
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Unused key detection
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* @return array<int, string> Defined keys that nothing in the codebase uses.
|
|
*/
|
|
private function findUnusedKeys(array $langData): array
|
|
{
|
|
$used = [];
|
|
foreach (array_keys($this->foundKeys) as $key) {
|
|
$used[$this->normalizeKey($key)] = true;
|
|
}
|
|
|
|
$patterns = array_unique(array_map(
|
|
fn (string $pattern) => $this->normalizeKey($pattern),
|
|
array_keys($this->foundPatterns)
|
|
));
|
|
|
|
return array_values(array_filter(
|
|
$this->flattenLangData($langData),
|
|
fn (string $key) => ! $this->isIgnoredUnusedKey($key)
|
|
&& ! $this->isKeyCovered($this->normalizeKey($key), $used, $patterns)
|
|
));
|
|
}
|
|
|
|
/**
|
|
* A defined key counts as used when it is referenced directly, when one of its parents is
|
|
* referenced (e.g. __('group.parent') returns the whole array), or when it matches a dynamic pattern.
|
|
*/
|
|
private function isKeyCovered(string $key, array $used, array $patterns): bool
|
|
{
|
|
if (isset($used[$key])) {
|
|
return true;
|
|
}
|
|
|
|
$segments = explode('.', $key);
|
|
for ($length = count($segments) - 1; $length > 0; $length--) {
|
|
if (isset($used[implode('.', array_slice($segments, 0, $length))])) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
foreach ($patterns as $pattern) {
|
|
if (Str::is($pattern, $key)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Folder separators and dots are interchangeable when matching keys (`domains/x.key` equals `domains.x.key`).
|
|
*/
|
|
private function normalizeKey(string $key): string
|
|
{
|
|
return str_replace('/', '.', $key);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string> Every defined key in dot notation (`group.nested.key` or the raw JSON key).
|
|
*/
|
|
private function flattenLangData(array $langData): array
|
|
{
|
|
$flattened = [];
|
|
|
|
foreach ($langData as $prefix => $data) {
|
|
if ($prefix === self::JSON) {
|
|
array_push($flattened, ...array_map('strval', array_keys($data)));
|
|
} else {
|
|
array_push($flattened, ...$this->flattenArray($data, (string) $prefix));
|
|
}
|
|
}
|
|
|
|
return $flattened;
|
|
}
|
|
|
|
/**
|
|
* Flattens an array into dot-notation keys. A list (0..n indexes) is kept as ONE key,
|
|
* because it is consumed as a whole by __('group.key'), which returns the 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 !== [] && ! $this->isList($value)) {
|
|
array_push($keys, ...$this->flattenArray($value, $path));
|
|
} else {
|
|
$keys[] = $path;
|
|
}
|
|
}
|
|
|
|
return $keys;
|
|
}
|
|
|
|
private function isList(array $array): bool
|
|
{
|
|
return array_keys($array) === array_keys(array_values($array));
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Codebase scanning
|
|
// ------------------------------------------------------------------
|
|
|
|
private function scanCodebase(): void
|
|
{
|
|
$this->foundKeys = [];
|
|
$this->foundPatterns = [];
|
|
$this->scannedFiles = 0;
|
|
|
|
$skips = array_filter((array) ($this->option('skip') ?? []));
|
|
$selfPath = $this->slashes(__FILE__);
|
|
|
|
foreach ($this->scanPaths as $path) {
|
|
if (! File::isDirectory(base_path($path))) {
|
|
continue;
|
|
}
|
|
|
|
foreach (Finder::create()->files()->in(base_path($path))->name('*.php') as $file) {
|
|
$relativePath = $this->relativePath($file->getPathname());
|
|
|
|
if ($this->slashes($file->getPathname()) === $selfPath || $this->isExcluded($relativePath, $skips)) {
|
|
continue;
|
|
}
|
|
|
|
$this->scannedFiles++;
|
|
$this->scanFile($file->getContents(), $relativePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default exclusions are anchored to the project root, while --skip values match at any depth.
|
|
*/
|
|
private function isExcluded(string $relativePath, array $skips): bool
|
|
{
|
|
foreach ($this->defaultExclusions as $excluded) {
|
|
$excluded = trim($this->slashes($excluded), '/');
|
|
|
|
if ($relativePath === $excluded || str_starts_with($relativePath, $excluded . '/')) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
foreach ($skips as $skip) {
|
|
$skip = trim($this->slashes((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): void
|
|
{
|
|
$content = $this->stripComments($content);
|
|
|
|
$this->scanTranslationCalls($content, $file);
|
|
$this->scanAttributeHandlers($content, $file);
|
|
|
|
if (str_contains($content, 'InteractsWithLabels') && str_contains($content, 'enum ')) {
|
|
$this->scanEnumKeys($content, $file);
|
|
}
|
|
|
|
if (str_contains($content, 'function permissions(')) {
|
|
$this->scanPermissionKeys($content, $file);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stores a discovered key (or a dynamic pattern) together with its location.
|
|
*/
|
|
private function record(string $key, string $file, int $line, bool $dynamic = false): void
|
|
{
|
|
if (! $dynamic && $this->isInvalidKey($key)) {
|
|
return;
|
|
}
|
|
|
|
$store = $dynamic ? 'foundPatterns' : 'foundKeys';
|
|
$this->{$store}[$key]["{$file}:{$line}"] = ['file' => $file, 'line' => $line];
|
|
}
|
|
|
|
/**
|
|
* Finds keys in translation helper calls such as __('x'), trans('x') or @lang('x').
|
|
*/
|
|
private function scanTranslationCalls(string $content, string $file): 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) {
|
|
$this->record($result['key'], $file, $line, $result['dynamic']);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds keys in `#[Name(...)]` attributes and `new Name(...)` instantiations by delegating
|
|
* the interpretation of the arguments to the registered handler of that class.
|
|
*/
|
|
private function scanAttributeHandlers(string $content, string $file): void
|
|
{
|
|
if (empty($this->attributeHandlers)) {
|
|
return;
|
|
}
|
|
|
|
$names = implode('|', array_map('preg_quote', array_keys($this->attributeHandlers)));
|
|
|
|
$patterns = [
|
|
// #[Name(...)] attributes
|
|
'/#\[(' . $names . ')\s*\(((?:[^)]|\n)*?)\)\]/',
|
|
// new Name(...) / new \Fully\Qualified\Name(...), including multi-line bodies
|
|
'/new\s+\\\\?(?:[A-Za-z0-9_]+\\\\)*(' . $names . ')\s*\(((?:[^)]|\n)*?)\)/s',
|
|
];
|
|
|
|
foreach ($patterns as $pattern) {
|
|
if (! preg_match_all($pattern, $content, $blocks, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($blocks as $block) {
|
|
$handler = $this->attributeHandlers[$block[1][0]] ?? null;
|
|
|
|
if ($handler === null) {
|
|
continue;
|
|
}
|
|
|
|
$line = $this->lineAt($content, $block[0][1]);
|
|
$arguments = $this->parseAttributeArguments($block[2][0]);
|
|
|
|
foreach ($handler->extractTranslationKeys($arguments) as $key) {
|
|
$this->record($key, $file, $line);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Turns the raw argument list of an attribute into an array for the handlers.
|
|
* Variables and function calls are replaced by placeholders and named arguments become array keys.
|
|
*
|
|
* NOTE: this relies on eval(), so it must only ever run on trusted source code.
|
|
*/
|
|
private function parseAttributeArguments(string $body): array
|
|
{
|
|
try {
|
|
$clean = preg_replace('/(?<!\\\\)\$[A-Za-z_]\w*(?:->\w+|\[[^\]]*\])*/', "'{variable}'", $body);
|
|
$clean = preg_replace('/[A-Za-z_]\w*\([^)]*\)/', "'{call}'", $clean);
|
|
$evaluable = preg_replace('/([a-zA-Z0-9_]+)\s*:/', "'$1' =>", $clean);
|
|
|
|
return eval("return [{$evaluable}];") ?? [];
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses the first argument of a translation call.
|
|
*
|
|
* Supports:
|
|
* - 'key' / "key" (including apostrophes and escapes, e.g. __("You're receiving this email"))
|
|
* - 'prefix.' . $var . '.suffix' => dynamic pattern "prefix.*.suffix"
|
|
* - "prefix.{$var}.suffix" => dynamic pattern "prefix.*.suffix"
|
|
* - $cond ? 'a.b' : 'a.c' => both literals are recorded as static keys
|
|
* - __('key') . ':' => not dynamic (the concatenation is outside the call)
|
|
*
|
|
* @return array<int, array{key: string, dynamic: bool}>
|
|
*/
|
|
private function parseFirstArgument(string $content, int $pos): array
|
|
{
|
|
$length = strlen($content);
|
|
$operands = [];
|
|
$buffer = '';
|
|
$depth = 0;
|
|
|
|
// Split the argument on top-level `.` (string concatenation) until the call ends.
|
|
for ($i = $pos; $i < $length; $i++) {
|
|
$char = $content[$i];
|
|
|
|
if ($char === "'" || $char === '"') {
|
|
$end = $this->skipString($content, $i);
|
|
$buffer .= substr($content, $i, $end - $i + 1);
|
|
$i = $end;
|
|
|
|
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() / __() without arguments
|
|
}
|
|
|
|
$key = implode('', array_map(fn (string $operand) => $this->decodeOperand($operand), $operands));
|
|
$key = preg_replace('/' . self::WILDCARD . '+/', self::WILDCARD, $key);
|
|
|
|
// Fully dynamic, e.g. __($key): only pick up key-shaped literals (ternary / match branches).
|
|
if ($key === self::WILDCARD) {
|
|
$expression = implode(' ', $operands);
|
|
// Strip helper calls whose arguments are never translation keys.
|
|
$expression = preg_replace('/(?:config|route|env|view|session|old)\s*\((?:[^()]+|\([^()]*\))*\)/i', '', $expression);
|
|
|
|
$results = [];
|
|
foreach ($this->extractLiterals($expression) as [$literal]) {
|
|
if (preg_match(self::KEY_SHAPE, $literal) && ! $this->isInvalidKey($literal)) {
|
|
$results[] = ['key' => $literal, 'dynamic' => false];
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
$dynamic = str_contains($key, self::WILDCARD);
|
|
|
|
return [['key' => $dynamic ? str_replace(self::WILDCARD, '*', $key) : $key, 'dynamic' => $dynamic]];
|
|
}
|
|
|
|
/**
|
|
* Returns the literal value of an operand, with the WILDCARD marker for anything dynamic.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Index of the closing quote of the string literal that starts at $i (or the last index if unterminated).
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{0: string, 1: int}> Every string literal as [content, offset].
|
|
*/
|
|
private function extractLiterals(string $content): array
|
|
{
|
|
return preg_match_all(self::LITERAL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE) ? $matches[1] : [];
|
|
}
|
|
|
|
/**
|
|
* Removes Blade comments, block comments that start a line and whole-line `//` comments.
|
|
* Line breaks are preserved so reported line numbers stay accurate.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Runs a pattern against every line.
|
|
*
|
|
* @return array<int, array{0: array, 1: int}> [regex matches, 1-based line number] for each matching line.
|
|
*/
|
|
private function matchLines(string $content, string $pattern): array
|
|
{
|
|
$results = [];
|
|
|
|
foreach (explode("\n", $content) as $index => $line) {
|
|
if (preg_match($pattern, $line, $matches)) {
|
|
$results[] = [$matches, $index + 1];
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Keys declared by `'description' => '...'` entries and by `generatePolicy('model')` calls.
|
|
*/
|
|
private function scanPermissionKeys(string $content, string $file): void
|
|
{
|
|
foreach ($this->matchLines($content, '/[\'"]description[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/') as [$match, $line]) {
|
|
$this->record($match[1], $file, $line);
|
|
}
|
|
|
|
if (! preg_match_all('/generatePolicy\(\s*[\'"]([^\'"]+)[\'"]/', $content, $matches, PREG_OFFSET_CAPTURE)) {
|
|
return;
|
|
}
|
|
|
|
foreach ($matches[1] as [$model, $offset]) {
|
|
$line = $this->lineAt($content, $offset);
|
|
|
|
foreach ($this->permissionKeySuffixes as $suffix) {
|
|
$this->record("permissions.{$model}.{$suffix}", $file, $line);
|
|
}
|
|
|
|
// Wildcard so every permissions.{model}.* key counts as used by the unused check.
|
|
$this->record("permissions.{$model}.*", $file, $line, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Keys derived from the cases of a backed enum that uses the InteractsWithLabels trait.
|
|
* The prefix follows the enum namespace: domains/{domain}/enum.{slug}, ui/enum.{slug} or enum.{slug}.
|
|
*/
|
|
private function scanEnumKeys(string $content, string $file): void
|
|
{
|
|
if (
|
|
! preg_match('/namespace\s+([^;]+);/', $content, $namespace)
|
|
|| ! preg_match('/enum\s+([^\s:]+)/', $content, $enum)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
$fqcn = trim($namespace[1]) . '\\' . trim($enum[1]);
|
|
$slug = Str::snake(trim($enum[1]));
|
|
|
|
if (Str::startsWith($fqcn, 'App\\Domains\\')) {
|
|
$prefix = 'domains/' . Str::kebab(Str::between($fqcn, 'App\\Domains\\', '\\Enums\\')) . "/enum.{$slug}";
|
|
} elseif (Str::startsWith($fqcn, 'App\\UI\\Enums\\')) {
|
|
$prefix = "ui/enum.{$slug}";
|
|
} else {
|
|
$prefix = "enum.{$slug}";
|
|
}
|
|
|
|
foreach ($this->matchLines($content, '/case\s+[A-Za-z0-9_]+\s*=\s*[\'"]([^\'"]+)[\'"]\s*;/') as [$match, $line]) {
|
|
$this->record("{$prefix}.{$match[1]}", $file, $line);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strings that can never be translation keys: placeholders, variables, spread / namespace artefacts.
|
|
*/
|
|
private function isInvalidKey(string $key): bool
|
|
{
|
|
return $key === ''
|
|
|| str_contains($key, '::')
|
|
|| str_contains($key, '{$')
|
|
|| preg_match('/^\$\w+$/', $key) === 1
|
|
|| preg_match('/\{[A-Za-z0-9_\.]+\}/', $key) === 1
|
|
|| preg_match('/^\{[A-Za-z0-9_]+\}$/', $key) === 1
|
|
|| str_contains($key, '...')
|
|
|| str_contains($key, ')]')
|
|
|| in_array($key, $this->ignoredKeys, true);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Language data
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Cached language data of a locale (see loadLanguageData()).
|
|
*/
|
|
private function langData(string $locale): array
|
|
{
|
|
return $this->langCache[$locale] ??= $this->loadLanguageData($locale);
|
|
}
|
|
|
|
/**
|
|
* Loads `lang/{locale}.json` into the JSON bucket and every `lang/{locale}/**.php` file
|
|
* into a bucket named after its relative path without extension (e.g. `domains/billing/enum`).
|
|
*/
|
|
private function loadLanguageData(string $locale): array
|
|
{
|
|
$langData = [];
|
|
$jsonPath = lang_path("{$locale}.json");
|
|
|
|
if (File::exists($jsonPath)) {
|
|
$langData[self::JSON] = json_decode(File::get($jsonPath), true) ?? [];
|
|
}
|
|
|
|
if (File::isDirectory(lang_path($locale))) {
|
|
foreach (File::allFiles(lang_path($locale)) as $file) {
|
|
if ($file->getExtension() !== 'php') {
|
|
continue;
|
|
}
|
|
|
|
$content = require $file->getPathname();
|
|
|
|
if (is_array($content)) {
|
|
$langData[substr($this->slashes($file->getRelativePathname()), 0, -4)] = $content;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $langData;
|
|
}
|
|
|
|
/**
|
|
* Whether a key is defined: either a flat JSON key or `bucket.nested.key` inside a PHP file.
|
|
* Buckets are matched both literally and with `/` treated as `.`.
|
|
*/
|
|
private function hasKey(array $langData, string $key): bool
|
|
{
|
|
if (isset($langData[self::JSON]) && array_key_exists($key, $langData[self::JSON])) {
|
|
return true;
|
|
}
|
|
|
|
if (! str_contains($key, '.')) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($langData as $prefix => $data) {
|
|
if ($prefix === self::JSON) {
|
|
continue;
|
|
}
|
|
|
|
foreach ([[(string) $prefix, $key], [$this->normalizeKey((string) $prefix), $this->normalizeKey($key)]] as [$bucket, $candidate]) {
|
|
if (str_starts_with($candidate, $bucket . '.') && Arr::has($data, substr($candidate, strlen($bucket) + 1))) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string> Every locale that has a directory or a JSON file in the lang path.
|
|
*/
|
|
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));
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Fix (add missing keys)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Adds placeholder values for the missing keys of one locale.
|
|
*/
|
|
private function fixMissingKeys(string $locale, array $missingKeys): int
|
|
{
|
|
ksort($missingKeys);
|
|
|
|
$mark = (string) ($this->option('mark') ?? '');
|
|
$mark = $mark === '' ? '' : rtrim($mark) . ' ';
|
|
|
|
$data = $this->langData($locale);
|
|
$added = [];
|
|
$conflicts = [];
|
|
|
|
foreach (array_keys($missingKeys) as $key) {
|
|
[$bucket, $item] = $this->resolveTarget($key);
|
|
$value = $mark . ($bucket === self::JSON ? $key : $this->humanize($item));
|
|
|
|
if ($bucket === self::JSON) {
|
|
$data[self::JSON][$key] = $value;
|
|
} else {
|
|
$data[$bucket] ??= [];
|
|
|
|
if (! $this->setPath($data[$bucket], $item, $value)) {
|
|
$conflicts[] = $key;
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$added[$key] = ['bucket' => $bucket, 'value' => $value];
|
|
}
|
|
|
|
[$writable, $skipped] = $this->splitRewritable($locale, array_unique(array_column($added, 'bucket')));
|
|
|
|
$rows = [];
|
|
foreach ($added as $key => $info) {
|
|
if (in_array($info['bucket'], $writable, true)) {
|
|
$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.");
|
|
}
|
|
|
|
$this->warnSkipped($skipped);
|
|
|
|
$question = 'Write ' . count($rows) . ' key(s) into ' . count($writable) . ' file(s)? Comments and custom formatting in those files will be lost.';
|
|
|
|
if (empty($writable) || ! $this->shouldWrite($question)) {
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$this->writeBuckets($locale, $data, $writable);
|
|
|
|
$this->info('Added ' . count($rows) . ' placeholder key(s) to ' . count($writable) . ' file(s). Replace the placeholder values with real translations.');
|
|
|
|
return (empty($conflicts) && empty($skipped)) ? Command::SUCCESS : Command::FAILURE;
|
|
}
|
|
|
|
/**
|
|
* Dot-notation keys (`group.item`) go to the PHP file of their group, anything else
|
|
* (sentences, spaces, trailing dots, ...) goes to the JSON file.
|
|
*
|
|
* @return array{0: string, 1: string} [bucket, item]
|
|
*/
|
|
private function resolveTarget(string $key): array
|
|
{
|
|
return preg_match(self::KEY_SHAPE, $key) ? explode('.', $key, 2) : [self::JSON, $key];
|
|
}
|
|
|
|
/**
|
|
* Turns the last key segment into a readable placeholder, e.g. `viewAny` becomes "View any".
|
|
*/
|
|
private function humanize(string $item): string
|
|
{
|
|
$segments = explode('.', $item);
|
|
$words = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', (string) end($segments));
|
|
|
|
return ucfirst(mb_strtolower(trim(str_replace(['-', '_'], ' ', $words))));
|
|
}
|
|
|
|
/**
|
|
* Sets a dot-notation path. Returns false when the target exists or a parent holds a
|
|
* non-array value, because writing would overwrite existing 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;
|
|
}
|
|
|
|
$ref[$segment] ??= [];
|
|
|
|
if (! is_array($ref[$segment])) {
|
|
return false;
|
|
}
|
|
|
|
$ref = &$ref[$segment];
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Purge (remove unused keys)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Removes the unused keys of the given locale (or of every locale with --all-locales).
|
|
* Unused keys are recalculated per locale because each one may define different keys.
|
|
*/
|
|
private function purgeUnusedKeys(string $primaryLocale): int
|
|
{
|
|
$includeJson = (bool) $this->option('include-json');
|
|
$locales = $this->option('all-locales') ? $this->discoverLocales() : [$primaryLocale];
|
|
|
|
$plans = [];
|
|
$skippedJson = 0;
|
|
$skippedFiles = [];
|
|
|
|
foreach ($locales as $locale) {
|
|
$data = $this->langData($locale);
|
|
$removed = [];
|
|
|
|
foreach ($this->findUnusedKeys($data) as $key) {
|
|
$bucket = $this->locateKey($data, $key);
|
|
|
|
if ($bucket === null) {
|
|
continue;
|
|
}
|
|
|
|
if ($bucket === self::JSON) {
|
|
if (! $includeJson) {
|
|
$skippedJson++;
|
|
|
|
continue;
|
|
}
|
|
|
|
unset($data[self::JSON][$key]);
|
|
} else {
|
|
$this->forgetPath($data[$bucket], substr($key, strlen($bucket) + 1));
|
|
}
|
|
|
|
$removed[$bucket][] = $key;
|
|
}
|
|
|
|
[$writable, $skipped] = $this->splitRewritable($locale, array_keys($removed));
|
|
array_push($skippedFiles, ...$skipped);
|
|
|
|
if (! empty($writable)) {
|
|
$plans[$locale] = ['data' => $data, 'removed' => array_intersect_key($removed, array_flip($writable))];
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->warnSkipped($skippedFiles);
|
|
|
|
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;
|
|
foreach ($plans as $locale => $plan) {
|
|
foreach ($plan['removed'] as $bucket => $keys) {
|
|
$rows[] = [$locale, $this->langFileLabel($locale, $bucket), count($keys)];
|
|
$total += count($keys);
|
|
}
|
|
}
|
|
|
|
$this->info('The following keys will be removed:');
|
|
$this->table(['Locale', 'File', 'Keys to Remove'], $rows);
|
|
|
|
if (! $this->shouldWrite("Remove {$total} key(s) from " . count($rows) . ' file(s)? Comments and custom formatting in those files will be lost.')) {
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
foreach ($plans as $locale => $plan) {
|
|
$this->writeBuckets($locale, $plan['data'], array_keys($plan['removed']));
|
|
}
|
|
|
|
$this->info("Removed {$total} unused key(s) from " . count($rows) . ' file(s).');
|
|
|
|
return empty($skippedFiles) ? Command::SUCCESS : Command::FAILURE;
|
|
}
|
|
|
|
/**
|
|
* Finds the bucket (JSON or PHP file) that defines a flattened key.
|
|
*/
|
|
private function locateKey(array $data, string $key): ?string
|
|
{
|
|
if (isset($data[self::JSON]) && array_key_exists($key, $data[self::JSON])) {
|
|
return self::JSON;
|
|
}
|
|
|
|
foreach (array_keys($data) as $bucket) {
|
|
if ($bucket !== self::JSON && str_starts_with($key, $bucket . '.')) {
|
|
return $bucket;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Removes a dot-notation path and prunes parent arrays that become empty.
|
|
*/
|
|
private function forgetPath(array &$data, string $path): void
|
|
{
|
|
if (array_key_exists($path, $data)) {
|
|
unset($data[$path]);
|
|
|
|
return;
|
|
}
|
|
|
|
if (! str_contains($path, '.')) {
|
|
return;
|
|
}
|
|
|
|
[$head, $tail] = explode('.', $path, 2);
|
|
|
|
if (! is_array($data[$head] ?? null)) {
|
|
return;
|
|
}
|
|
|
|
$this->forgetPath($data[$head], $tail);
|
|
|
|
if ($data[$head] === []) {
|
|
unset($data[$head]);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Writing language files (shared by --fix and --purge)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Splits buckets into files that can be rewritten and PHP files that must be left alone.
|
|
*
|
|
* @return array{0: array<int, string>, 1: array<int, string>} [writable buckets, skipped file paths]
|
|
*/
|
|
private function splitRewritable(string $locale, array $buckets): array
|
|
{
|
|
$writable = [];
|
|
$skipped = [];
|
|
|
|
foreach ($buckets as $bucket) {
|
|
$path = lang_path("{$locale}/{$bucket}.php");
|
|
|
|
if ($bucket !== self::JSON && ! $this->isSafelyRewritable($path)) {
|
|
$skipped[] = $path;
|
|
} else {
|
|
$writable[] = $bucket;
|
|
}
|
|
}
|
|
|
|
return [$writable, $skipped];
|
|
}
|
|
|
|
private function warnSkipped(array $paths): void
|
|
{
|
|
foreach ($paths as $path) {
|
|
$this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Edit it manually.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies --dry-run / --force / interactive confirmation. Returns true when files may be written.
|
|
*/
|
|
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') && ! $this->confirm($question, false)) {
|
|
$this->comment('Aborted. No files were written.');
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function writeBuckets(string $locale, array $data, array $buckets): void
|
|
{
|
|
foreach ($buckets as $bucket) {
|
|
$this->writeLangFile($locale, $bucket, $data[$bucket] ?? []);
|
|
}
|
|
}
|
|
|
|
private function writeLangFile(string $locale, string $bucket, array $data): void
|
|
{
|
|
if ($bucket === self::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");
|
|
}
|
|
|
|
/**
|
|
* Exports an array as short-syntax PHP with 4-space indentation.
|
|
*/
|
|
private function exportPhpArray(array $data, int $depth = 0): string
|
|
{
|
|
if ($data === []) {
|
|
return '[]';
|
|
}
|
|
|
|
$indent = str_repeat(' ', $depth + 1);
|
|
$isList = $this->isList($data);
|
|
$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),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A language file can only be re-exported safely when it contains pure literals
|
|
* (arrays, strings, numbers, true/false/null). Closures, constants, concatenation or
|
|
* function calls would be lost or changed by an export.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Paths and output helpers
|
|
// ------------------------------------------------------------------
|
|
|
|
private function slashes(string $path): string
|
|
{
|
|
return str_replace('\\', '/', $path);
|
|
}
|
|
|
|
private function relativePath(string $path): string
|
|
{
|
|
return ltrim(str_replace(rtrim($this->slashes(base_path()), '/'), '', $this->slashes($path)), '/');
|
|
}
|
|
|
|
private function langFileLabel(string $locale, string $bucket): string
|
|
{
|
|
return $this->relativePath($bucket === self::JSON ? lang_path("{$locale}.json") : lang_path("{$locale}/{$bucket}.php"));
|
|
}
|
|
|
|
/**
|
|
* Prints a ready-to-copy prompt that asks an AI assistant to write the missing translations.
|
|
*/
|
|
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();
|
|
|
|
// Group by the first key segment, which is also the language file the key belongs to.
|
|
$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) {
|
|
$prompt .= "- `{$key}` (Used in: " . implode(', ', array_slice(array_unique($files), 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('===============================================================');
|
|
}
|
|
}
|
|
|