From f4c4defffa66717651afdad47443d8c5340d908e Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 12 Jun 2026 17:35:45 +0800 Subject: [PATCH] refactor: migrate `lang:*` commands as modern commands --- .../Translation/LocalizationFinder.php | 188 +++++++++++------- .../Commands/Translation/LocalizationSync.php | 127 +++++++----- .../Translation/LocalizationFinderTest.php | 13 +- .../Translation/LocalizationSyncTest.php | 2 +- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 47 +---- 6 files changed, 210 insertions(+), 169 deletions(-) diff --git a/system/Commands/Translation/LocalizationFinder.php b/system/Commands/Translation/LocalizationFinder.php index 84b741faff3f..2bcc2b60c377 100644 --- a/system/Commands/Translation/LocalizationFinder.php +++ b/system/Commands/Translation/LocalizationFinder.php @@ -13,8 +13,10 @@ namespace CodeIgniter\Commands\Translation; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Helpers\Array\ArrayHelper; use Config\App; use Locale; @@ -23,73 +25,85 @@ use SplFileInfo; /** - * @see \CodeIgniter\Commands\Translation\LocalizationFinderTest + * Finds and saves available phrases to translate. */ -class LocalizationFinder extends BaseCommand +#[Command( + name: 'lang:find', + description: 'Find and save available phrases to translate.', + group: 'Translation', +)] +class LocalizationFinder extends AbstractCommand { - protected $group = 'Translation'; - protected $name = 'lang:find'; - protected $description = 'Find and save available phrases to translate.'; - protected $usage = 'lang:find [options]'; - protected $arguments = []; - protected $options = [ - '--locale' => 'Specify locale (en, ru, etc.) to save files.', - '--dir' => 'Directory to search for translations relative to APPPATH.', - '--show-new' => 'Show only new translations in table. Does not write to files.', - '--verbose' => 'Output detailed information.', - ]; + private string $languagePath; - /** - * Flag for output detailed information - */ - private bool $verbose = false; + protected function configure(): void + { + $this + ->addOption(new Option( + name: 'locale', + description: 'Specify locale (en, ru, etc.) to save files.', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'dir', + description: 'Directory to search for translations relative to APPPATH.', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'show-new', + description: 'Show only new translations in table. Does not write to files.', + )) + ->addOption(new Option( + name: 'verbose', + description: 'Output detailed information.', + )); + } - /** - * Flag for showing only translations, without saving - */ - private bool $showNew = false; + protected function execute(array $arguments, array $options): int + { + $locale = $options['locale']; + assert(is_string($locale)); - private string $languagePath; + $dir = $options['dir']; + assert(is_string($dir)); - public function run(array $params) - { - $this->verbose = array_key_exists('verbose', $params); - $this->showNew = array_key_exists('show-new', $params); - $optionLocale = $params['locale'] ?? null; - $optionDir = $params['dir'] ?? null; - $currentLocale = Locale::getDefault(); - $currentDir = APPPATH; - $this->languagePath = $currentDir . 'Language'; + $currentLocale = Locale::getDefault(); - if (service('environment')->isTesting()) { - $currentDir = SUPPORTPATH . 'Services' . DIRECTORY_SEPARATOR; - $this->languagePath = SUPPORTPATH . 'Language'; - } + ['currentDir' => $currentDir, 'languagePath' => $this->languagePath] = $this->resolvePaths(); + + if ($locale !== '') { + $supportedLocales = config(App::class)->supportedLocales; - if (is_string($optionLocale)) { - if (! in_array($optionLocale, config(App::class)->supportedLocales, true)) { + if (! in_array($locale, $supportedLocales, true)) { CLI::error( - 'Error: "' . $optionLocale . '" is not supported. Supported locales: ' - . implode(', ', config(App::class)->supportedLocales), + sprintf( + 'Error: "%s" is not supported. Supported locales: %s', + $locale, + implode(', ', $supportedLocales), + ), + 'light_gray', + 'red', ); return EXIT_USER_INPUT; } - $currentLocale = $optionLocale; + $currentLocale = $locale; } - if (is_string($optionDir)) { - $tempCurrentDir = realpath($currentDir . $optionDir); + if ($dir !== '') { + $tempCurrentDir = realpath($currentDir . $dir); if ($tempCurrentDir === false) { - CLI::error('Error: Directory must be located in "' . $currentDir . '"'); + CLI::error(sprintf('Error: Directory must be located in "%s"', $currentDir), 'light_gray', 'red'); return EXIT_USER_INPUT; } - if ($this->isSubDirectory($tempCurrentDir, $this->languagePath)) { - CLI::error('Error: Directory "' . $this->languagePath . '" restricted to scan.'); + if ($this->isSubdirectory($tempCurrentDir, $this->languagePath)) { + CLI::error(sprintf('Error: Directory "%s" restricted to scan.', $this->languagePath), 'light_gray', 'red'); return EXIT_USER_INPUT; } @@ -99,18 +113,42 @@ public function run(array $params) $this->process($currentDir, $currentLocale); - CLI::write('All operations done!'); + CLI::write('All operations done!', 'green'); return EXIT_SUCCESS; } + /** + * Resolves the directory to scan and the directory that holds the language + * files, swapping in the test fixtures under the testing environment. + * + * @return array{currentDir: string, languagePath: string} + */ + private function resolvePaths(): array + { + if (service('environment')->isTesting()) { + return [ + 'currentDir' => SUPPORTPATH . 'Services' . DIRECTORY_SEPARATOR, + 'languagePath' => SUPPORTPATH . 'Language', + ]; + } + + return [ + 'currentDir' => APPPATH, + 'languagePath' => APPPATH . 'Language', + ]; + } + private function process(string $currentDir, string $currentLocale): void { + $showNew = $this->getValidatedOption('show-new') === true; + $verbose = $this->getValidatedOption('verbose') === true; + $tableRows = []; $countNewKeys = 0; $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($currentDir)); - $files = iterator_to_array($iterator, true); + $files = iterator_to_array($iterator); ksort($files); [ @@ -121,10 +159,7 @@ private function process(string $currentDir, string $currentLocale): void ksort($foundLanguageKeys); - $languageDiff = []; - $languageFoundGroups = array_unique(array_keys($foundLanguageKeys)); - - foreach ($languageFoundGroups as $langFileName) { + foreach ($foundLanguageKeys as $langFileName => $foundKeys) { $languageStoredKeys = []; $languageFilePath = $this->languagePath . DIRECTORY_SEPARATOR . $currentLocale . DIRECTORY_SEPARATOR . $langFileName . '.php'; @@ -137,38 +172,38 @@ private function process(string $currentDir, string $currentLocale): void // are not new and must not be re-reported or written. $resolvedKeys = $this->findResolvedTranslations($langFileName, $currentLocale); - $languageDiff = ArrayHelper::recursiveDiff($foundLanguageKeys[$langFileName], $resolvedKeys); + $languageDiff = ArrayHelper::recursiveDiff($foundKeys, $resolvedKeys); $countNewKeys += ArrayHelper::recursiveCount($languageDiff); - if ($this->showNew) { + if ($showNew) { $tableRows = array_merge($this->arrayToTableRows($langFileName, $languageDiff), $tableRows); } else { $newLanguageKeys = array_replace_recursive($languageDiff, $languageStoredKeys); if ($languageDiff !== []) { if (file_put_contents($languageFilePath, $this->templateFile($newLanguageKeys)) === false) { - $this->writeIsVerbose('Lang file ' . $langFileName . ' (error write).', 'red'); + $this->writeIsVerbose(sprintf('Lang file %s (error write).', $langFileName), 'red'); } else { - $this->writeIsVerbose('Lang file "' . $langFileName . '" successful updated!', 'green'); + $this->writeIsVerbose(sprintf('Lang file "%s" successful updated!', $langFileName), 'green'); } } } } - if ($this->showNew && $tableRows !== []) { + if ($showNew && $tableRows !== []) { sort($tableRows); CLI::table($tableRows, ['File', 'Key']); } - if (! $this->showNew && $countNewKeys > 0) { + if (! $showNew && $countNewKeys > 0) { CLI::write('Note: You need to run your linting tool to fix coding standards issues.', 'white', 'red'); } - $this->writeIsVerbose('Files found: ' . $countFiles); - $this->writeIsVerbose('New translates found: ' . $countNewKeys); - $this->writeIsVerbose('Bad translates found: ' . count($badLanguageKeys)); + $this->writeIsVerbose(sprintf('Files found: %d', $countFiles)); + $this->writeIsVerbose(sprintf('New translates found: %d', $countNewKeys)); + $this->writeIsVerbose(sprintf('Bad translates found: %d', count($badLanguageKeys))); - if ($this->verbose && $badLanguageKeys !== []) { + if ($verbose && $badLanguageKeys !== []) { $tableBadRows = []; foreach ($badLanguageKeys as $value) { @@ -211,19 +246,13 @@ private function findResolvedTranslations(string $langFileName, string $currentL } /** - * @param SplFileInfo|string $file - * - * @return array + * @return array{foundLanguageKeys: array, badLanguageKeys: list} */ - private function findTranslationsInFile($file): array + private function findTranslationsInFile(SplFileInfo $file): array { $foundLanguageKeys = []; $badLanguageKeys = []; - if (is_string($file) && is_file($file)) { - $file = new SplFileInfo($file); - } - $fileContent = file_get_contents($file->getRealPath()); preg_match_all('/lang\(\'([._a-z0-9\-]+)\'\)/ui', $fileContent, $matches); @@ -266,13 +295,16 @@ private function findTranslationsInFile($file): array private function isIgnoredFile(SplFileInfo $file): bool { - if ($file->isDir() || $this->isSubDirectory($file->getRealPath(), $this->languagePath)) { + if ($file->isDir() || $this->isSubdirectory($file->getRealPath(), $this->languagePath)) { return true; } return $file->getExtension() !== 'php'; } + /** + * @param array $language + */ private function templateFile(array $language = []): string { if ($language !== []) { @@ -337,6 +369,10 @@ private function replaceArraySyntax(string $code): string /** * Create multidimensional array from another keys + * + * @param list $fromKeys + * + * @return array */ private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): array { @@ -356,6 +392,10 @@ private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): /** * Convert multi arrays to specific CLI table rows (flat array) + * + * @param array $array + * + * @return list */ private function arrayToTableRows(string $langFileName, array $array): array { @@ -381,12 +421,12 @@ private function arrayToTableRows(string $langFileName, array $array): array */ private function writeIsVerbose(string $text = '', ?string $foreground = null, ?string $background = null): void { - if ($this->verbose) { + if ($this->getValidatedOption('verbose') === true) { CLI::write($text, $foreground, $background); } } - private function isSubDirectory(string $directory, string $rootDirectory): bool + private function isSubdirectory(string $directory, string $rootDirectory): bool { return 0 === strncmp($directory, $rootDirectory, strlen($directory)); } @@ -407,7 +447,7 @@ private function findLanguageKeysInFiles(array $files): array continue; } - $this->writeIsVerbose('File found: ' . mb_substr($file->getRealPath(), mb_strlen(APPPATH))); + $this->writeIsVerbose(sprintf('File found: %s', mb_substr($file->getRealPath(), mb_strlen(APPPATH)))); $countFiles++; $findInFile = $this->findTranslationsInFile($file); diff --git a/system/Commands/Translation/LocalizationSync.php b/system/Commands/Translation/LocalizationSync.php index 98fc68a190ba..68dd1c09ccc9 100644 --- a/system/Commands/Translation/LocalizationSync.php +++ b/system/Commands/Translation/LocalizationSync.php @@ -13,8 +13,10 @@ namespace CodeIgniter\Commands\Translation; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Exceptions\LogicException; use Config\App; use ErrorException; @@ -25,62 +27,72 @@ use SplFileInfo; /** - * @see \CodeIgniter\Commands\Translation\LocalizationSyncTest + * Synchronizes translation files from one language to another. */ -class LocalizationSync extends BaseCommand +#[Command( + name: 'lang:sync', + description: 'Synchronize translation files from one language to another.', + group: 'Translation', +)] +class LocalizationSync extends AbstractCommand { - protected $group = 'Translation'; - protected $name = 'lang:sync'; - protected $description = 'Synchronize translation files from one language to another.'; - protected $usage = 'lang:sync [options]'; - protected $arguments = []; - protected $options = [ - '--locale' => 'The original locale (en, ru, etc.).', - '--target' => 'Target locale (en, ru, etc.).', - ]; private string $languagePath; - public function run(array $params) + protected function configure(): void { - $optionTargetLocale = ''; - $optionLocale = $params['locale'] ?? Locale::getDefault(); + $this + ->addOption(new Option( + name: 'locale', + description: 'The original locale (en, ru, etc.).', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'target', + description: 'Target locale (en, ru, etc.).', + requiresValue: true, + default: '', + )); + } + + protected function execute(array $arguments, array $options): int + { + $locale = $options['locale']; + assert(is_string($locale)); + + $target = $options['target']; + assert(is_string($target)); + $this->languagePath = APPPATH . 'Language'; + $supportedLocales = config(App::class)->supportedLocales; - if (isset($params['target']) && $params['target'] !== '') { - $optionTargetLocale = $params['target']; + if ($locale === '') { + $locale = Locale::getDefault(); } - if (! in_array($optionLocale, config(App::class)->supportedLocales, true)) { - CLI::error( - 'Error: "' . $optionLocale . '" is not supported. Supported locales: ' - . implode(', ', config(App::class)->supportedLocales), - ); - - return EXIT_USER_INPUT; + if (! in_array($locale, $supportedLocales, true)) { + return $this->errorUnsupportedLocale($locale); } - if ($optionTargetLocale === '') { + if ($target === '') { CLI::error( - 'Error: "--target" is not configured. Supported locales: ' - . implode(', ', config(App::class)->supportedLocales), + sprintf( + 'Error: "--target" is not configured. Supported locales: %s', + implode(', ', $supportedLocales), + ), + 'light_gray', + 'red', ); return EXIT_USER_INPUT; } - if (! in_array($optionTargetLocale, config(App::class)->supportedLocales, true)) { - CLI::error( - 'Error: "' . $optionTargetLocale . '" is not supported. Supported locales: ' - . implode(', ', config(App::class)->supportedLocales), - ); - - return EXIT_USER_INPUT; + if (! in_array($target, $supportedLocales, true)) { + return $this->errorUnsupportedLocale($target); } - if ($optionTargetLocale === $optionLocale) { - CLI::error( - 'Error: You cannot have the same values for "--target" and "--locale".', - ); + if ($target === $locale) { + CLI::error('Error: You cannot have the same values for "--target" and "--locale".', 'light_gray', 'red'); return EXIT_USER_INPUT; } @@ -89,15 +101,33 @@ public function run(array $params) $this->languagePath = SUPPORTPATH . 'Language'; } - if ($this->process($optionLocale, $optionTargetLocale) === EXIT_ERROR) { + if ($this->process($locale, $target) === EXIT_ERROR) { return EXIT_ERROR; } - CLI::write('All operations done!'); + CLI::write('All operations done!', 'green'); return EXIT_SUCCESS; } + /** + * Writes the unsupported-locale error and returns the user-input exit code. + */ + private function errorUnsupportedLocale(string $locale): int + { + CLI::error( + sprintf( + 'Error: "%s" is not supported. Supported locales: %s', + $locale, + implode(', ', config(App::class)->supportedLocales), + ), + 'light_gray', + 'red', + ); + + return EXIT_USER_INPUT; + } + private function process(string $originalLocale, string $targetLocale): int { $originalLocaleDir = $this->languagePath . DIRECTORY_SEPARATOR . $originalLocale; @@ -105,7 +135,9 @@ private function process(string $originalLocale, string $targetLocale): int if (! is_dir($originalLocaleDir)) { CLI::error( - 'Error: The "' . clean_path($originalLocaleDir) . '" directory was not found.', + sprintf('Error: The "%s" directory was not found.', clean_path($originalLocaleDir)), + 'light_gray', + 'red', ); return EXIT_ERROR; @@ -118,7 +150,9 @@ private function process(string $originalLocale, string $targetLocale): int } } catch (ErrorException $e) { CLI::error( - 'Error: The target directory "' . clean_path($targetLocaleDir) . '" cannot be accessed.', + sprintf('Error: The target directory "%s" cannot be accessed.', clean_path($targetLocaleDir)), + 'light_gray', + 'red', ); return EXIT_ERROR; @@ -131,10 +165,10 @@ private function process(string $originalLocale, string $targetLocale): int ), ); - /** @var array $files */ - $files = iterator_to_array($iterator, true); + $files = iterator_to_array($iterator); ksort($files); + /** @var SplFileInfo $originalLanguageFile */ foreach ($files as $originalLanguageFile) { if ($originalLanguageFile->getExtension() !== 'php') { continue; @@ -191,7 +225,10 @@ private function mergeLanguageKeys(array $originalLanguageKeys, array $targetLan $mergedLanguageKeys[$key] = $this->mergeLanguageKeys($value, $targetLanguageKeys[$key], $placeholderValue); } else { - throw new LogicException('Value for the key "' . $placeholderValue . '" is of the wrong type. Only "array" or "string" is allowed.'); + throw new LogicException(sprintf( + 'Value for the key "%s" is of the wrong type. Only "array" or "string" is allowed.', + $placeholderValue, + )); } } diff --git a/tests/system/Commands/Translation/LocalizationFinderTest.php b/tests/system/Commands/Translation/LocalizationFinderTest.php index c452e5750156..30670336f6e0 100644 --- a/tests/system/Commands/Translation/LocalizationFinderTest.php +++ b/tests/system/Commands/Translation/LocalizationFinderTest.php @@ -87,7 +87,7 @@ public function testUpdateWithIncorrectLocaleOption(): void self::$locale = 'test_locale_incorrect'; $this->makeLocaleDirectory(); - $status = service('commands')->runLegacy('lang:find', [ + $status = service('commands')->runCommand('lang:find', [], [ 'dir' => 'Translation', 'locale' => self::$locale, ]); @@ -108,7 +108,7 @@ public function testUpdateWithIncorrectDirOption(): void { $this->makeLocaleDirectory(); - $status = service('commands')->runLegacy('lang:find', [ + $status = service('commands')->runCommand('lang:find', [], [ 'dir' => 'Translation/NotExistFolder', ]); @@ -166,6 +166,9 @@ public function testWriteSkipsKeysAlreadyTranslatedByFramework(): void $this->assertArrayNotHasKey('pageNotFound', $generatedKeys); } + /** + * @return array + */ private function getActualTranslationOneKeys(): array { return [ @@ -179,6 +182,9 @@ private function getActualTranslationOneKeys(): array ]; } + /** + * @return array|string>> + */ private function getActualTranslationThreeKeys(): array { return [ @@ -212,6 +218,9 @@ private function getActualTranslationThreeKeys(): array ]; } + /** + * @return array> + */ private function getActualTranslationFourKeys(): array { return [ diff --git a/tests/system/Commands/Translation/LocalizationSyncTest.php b/tests/system/Commands/Translation/LocalizationSyncTest.php index a64105163b1c..515c42ac0f19 100644 --- a/tests/system/Commands/Translation/LocalizationSyncTest.php +++ b/tests/system/Commands/Translation/LocalizationSyncTest.php @@ -222,7 +222,7 @@ public function testSyncWithIncorrectTargetOption(): void public function testProcessWithInvalidOption(): void { $langPath = SUPPORTPATH . 'Language'; - $command = new LocalizationSync(service('logger'), service('commands')); + $command = new LocalizationSync(service('commands')); $this->setPrivateProperty($command, 'languagePath', $langPath); $runner = self::getPrivateMethodInvoker($command, 'process'); diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 29a83b578073..6b947f2541ac 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 1972 errors +# total 1960 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 8eb1a514c395..719a1bc90cae 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1216 errors +# total 1204 errors parameters: ignoreErrors: @@ -62,36 +62,6 @@ parameters: count: 1 path: ../../system/CodeIgniter.php - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:arrayToTableRows\(\) has parameter \$array with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:arrayToTableRows\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:buildMultiArray\(\) has parameter \$fromKeys with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:buildMultiArray\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:findTranslationsInFile\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:templateFile\(\) has parameter \$language with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - message: '#^Method CodeIgniter\\Commands\\Utilities\\Namespaces\:\:outputAllNamespaces\(\) has parameter \$params with no value type specified in iterable type array\.$#' count: 1 @@ -4607,21 +4577,6 @@ parameters: count: 1 path: ../../tests/system/CodeIgniterTest.php - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinderTest\:\:getActualTranslationFourKeys\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/Commands/Translation/LocalizationFinderTest.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinderTest\:\:getActualTranslationOneKeys\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/Commands/Translation/LocalizationFinderTest.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinderTest\:\:getActualTranslationThreeKeys\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/Commands/Translation/LocalizationFinderTest.php - - message: '#^Method CodeIgniter\\Commands\\Utilities\\Routes\\AutoRouterImproved\\AutoRouteCollectorTest\:\:createAutoRouteCollector\(\) has parameter \$filterConfigFilters with no value type specified in iterable type array\.$#' count: 1