• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 27466302243

13 Jun 2026 12:06PM UTC coverage: 89.097% (+0.003%) from 89.094%
27466302243

push

github

web-flow
refactor: migrate `lang:*` commands as modern commands (#10306)

109 of 122 new or added lines in 2 files covered. (89.34%)

1 existing line in 1 file now uncovered.

24956 of 28010 relevant lines covered (89.1%)

224.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.87
/system/Commands/Translation/LocalizationFinder.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Commands\Translation;
15

16
use CodeIgniter\CLI\AbstractCommand;
17
use CodeIgniter\CLI\Attributes\Command;
18
use CodeIgniter\CLI\CLI;
19
use CodeIgniter\CLI\Input\Option;
20
use CodeIgniter\Helpers\Array\ArrayHelper;
21
use Config\App;
22
use Locale;
23
use RecursiveDirectoryIterator;
24
use RecursiveIteratorIterator;
25
use SplFileInfo;
26

27
/**
28
 * Finds and saves available phrases to translate.
29
 */
30
#[Command(
31
    name: 'lang:find',
32
    description: 'Find and save available phrases to translate.',
33
    group: 'Translation',
34
)]
35
class LocalizationFinder extends AbstractCommand
36
{
37
    private string $languagePath;
38

39
    protected function configure(): void
40
    {
41
        $this
9✔
42
            ->addOption(new Option(
9✔
43
                name: 'locale',
9✔
44
                description: 'Specify locale (en, ru, etc.) to save files.',
9✔
45
                requiresValue: true,
9✔
46
                default: '',
9✔
47
            ))
9✔
48
            ->addOption(new Option(
9✔
49
                name: 'dir',
9✔
50
                description: 'Directory to search for translations relative to APPPATH.',
9✔
51
                requiresValue: true,
9✔
52
                default: '',
9✔
53
            ))
9✔
54
            ->addOption(new Option(
9✔
55
                name: 'show-new',
9✔
56
                description: 'Show only new translations in table. Does not write to files.',
9✔
57
            ))
9✔
58
            ->addOption(new Option(
9✔
59
                name: 'verbose',
9✔
60
                description: 'Output detailed information.',
9✔
61
            ));
9✔
62
    }
63

64
    protected function execute(array $arguments, array $options): int
65
    {
66
        $locale = $options['locale'];
9✔
67
        assert(is_string($locale));
68

69
        $dir = $options['dir'];
9✔
70
        assert(is_string($dir));
71

72
        $currentLocale = Locale::getDefault();
9✔
73

74
        ['currentDir' => $currentDir, 'languagePath' => $this->languagePath] = $this->resolvePaths();
9✔
75

76
        if ($locale !== '') {
9✔
77
            $supportedLocales = config(App::class)->supportedLocales;
2✔
78

79
            if (! in_array($locale, $supportedLocales, true)) {
2✔
80
                CLI::error(
1✔
81
                    sprintf(
1✔
82
                        'Error: "%s" is not supported. Supported locales: %s',
1✔
83
                        $locale,
1✔
84
                        implode(', ', $supportedLocales),
1✔
85
                    ),
1✔
86
                    'light_gray',
1✔
87
                    'red',
1✔
88
                );
1✔
89

90
                return EXIT_USER_INPUT;
1✔
91
            }
92

93
            $currentLocale = $locale;
1✔
94
        }
95

96
        if ($dir !== '') {
8✔
97
            $tempCurrentDir = realpath($currentDir . $dir);
7✔
98

99
            if ($tempCurrentDir === false) {
7✔
100
                CLI::error(sprintf('Error: Directory must be located in "%s"', $currentDir), 'light_gray', 'red');
1✔
101

102
                return EXIT_USER_INPUT;
1✔
103
            }
104

105
            if ($this->isSubdirectory($tempCurrentDir, $this->languagePath)) {
6✔
NEW
106
                CLI::error(sprintf('Error: Directory "%s" restricted to scan.', $this->languagePath), 'light_gray', 'red');
×
107

108
                return EXIT_USER_INPUT;
×
109
            }
110

111
            $currentDir = $tempCurrentDir;
6✔
112
        }
113

114
        $this->process($currentDir, $currentLocale);
7✔
115

116
        CLI::write('All operations done!', 'green');
7✔
117

118
        return EXIT_SUCCESS;
7✔
119
    }
120

121
    /**
122
     * Resolves the directory to scan and the directory that holds the language
123
     * files, swapping in the test fixtures under the testing environment.
124
     *
125
     * @return array{currentDir: string, languagePath: string}
126
     */
127
    private function resolvePaths(): array
128
    {
129
        if (service('environment')->isTesting()) {
9✔
130
            return [
9✔
131
                'currentDir'   => SUPPORTPATH . 'Services' . DIRECTORY_SEPARATOR,
9✔
132
                'languagePath' => SUPPORTPATH . 'Language',
9✔
133
            ];
9✔
134
        }
135

NEW
136
        return [
×
NEW
137
            'currentDir'   => APPPATH,
×
NEW
138
            'languagePath' => APPPATH . 'Language',
×
NEW
139
        ];
×
140
    }
141

142
    private function process(string $currentDir, string $currentLocale): void
143
    {
144
        $showNew = $this->getValidatedOption('show-new') === true;
7✔
145
        $verbose = $this->getValidatedOption('verbose') === true;
7✔
146

147
        $tableRows    = [];
7✔
148
        $countNewKeys = 0;
7✔
149

150
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($currentDir));
7✔
151
        $files    = iterator_to_array($iterator);
7✔
152
        ksort($files);
7✔
153

154
        [
7✔
155
            'foundLanguageKeys' => $foundLanguageKeys,
7✔
156
            'badLanguageKeys'   => $badLanguageKeys,
7✔
157
            'countFiles'        => $countFiles,
7✔
158
        ] = $this->findLanguageKeysInFiles($files);
7✔
159

160
        ksort($foundLanguageKeys);
7✔
161

162
        foreach ($foundLanguageKeys as $langFileName => $foundKeys) {
7✔
163
            $languageStoredKeys = [];
7✔
164
            $languageFilePath   = $this->languagePath . DIRECTORY_SEPARATOR . $currentLocale . DIRECTORY_SEPARATOR . $langFileName . '.php';
7✔
165

166
            if (is_file($languageFilePath)) {
7✔
167
                // Load old localization
168
                $languageStoredKeys = require $languageFilePath;
×
169
            }
170

171
            // Keys already resolvable from any namespace's language file (framework, packages, or app)
172
            // are not new and must not be re-reported or written.
173
            $resolvedKeys = $this->findResolvedTranslations($langFileName, $currentLocale);
7✔
174

175
            $languageDiff = ArrayHelper::recursiveDiff($foundKeys, $resolvedKeys);
7✔
176
            $countNewKeys += ArrayHelper::recursiveCount($languageDiff);
7✔
177

178
            if ($showNew) {
7✔
179
                $tableRows = array_merge($this->arrayToTableRows($langFileName, $languageDiff), $tableRows);
2✔
180
            } else {
181
                $newLanguageKeys = array_replace_recursive($languageDiff, $languageStoredKeys);
5✔
182

183
                if ($languageDiff !== []) {
5✔
184
                    if (file_put_contents($languageFilePath, $this->templateFile($newLanguageKeys)) === false) {
5✔
NEW
185
                        $this->writeIsVerbose(sprintf('Lang file %s (error write).', $langFileName), 'red');
×
186
                    } else {
187
                        $this->writeIsVerbose(sprintf('Lang file "%s" successful updated!', $langFileName), 'green');
5✔
188
                    }
189
                }
190
            }
191
        }
192

193
        if ($showNew && $tableRows !== []) {
7✔
194
            sort($tableRows);
2✔
195
            CLI::table($tableRows, ['File', 'Key']);
2✔
196
        }
197

198
        if (! $showNew && $countNewKeys > 0) {
7✔
199
            CLI::write('Note: You need to run your linting tool to fix coding standards issues.', 'white', 'red');
5✔
200
        }
201

202
        $this->writeIsVerbose(sprintf('Files found: %d', $countFiles));
7✔
203
        $this->writeIsVerbose(sprintf('New translates found: %d', $countNewKeys));
7✔
204
        $this->writeIsVerbose(sprintf('Bad translates found: %d', count($badLanguageKeys)));
7✔
205

206
        if ($verbose && $badLanguageKeys !== []) {
7✔
207
            $tableBadRows = [];
1✔
208

209
            foreach ($badLanguageKeys as $value) {
1✔
210
                $tableBadRows[] = [$value[1], $value[0]];
1✔
211
            }
212

213
            ArrayHelper::sortValuesByNatural($tableBadRows, 0);
1✔
214

215
            CLI::table($tableBadRows, ['Bad Key', 'Filepath']);
1✔
216
        }
217
    }
218

219
    /**
220
     * Loads the translations already resolvable for the given file and locale
221
     * from every registered namespace (framework, packages, and app).
222
     *
223
     * @return array<string, mixed>
224
     */
225
    private function findResolvedTranslations(string $langFileName, string $currentLocale): array
226
    {
227
        $translations = [];
7✔
228

229
        foreach (service('locator')->search("Language/{$currentLocale}/{$langFileName}.php", 'php', false) as $file) {
7✔
230
            if (! is_file($file)) {
2✔
231
                continue;
×
232
            }
233

234
            $keys = require $file;
2✔
235

236
            if (is_array($keys)) {
2✔
237
                $translations[] = $keys;
2✔
238
            }
239
        }
240

241
        if ($translations === []) {
7✔
242
            return [];
5✔
243
        }
244

245
        return array_replace_recursive(...$translations);
2✔
246
    }
247

248
    /**
249
     * @return array{foundLanguageKeys: array<string, mixed>, badLanguageKeys: list<array{string, string}>}
250
     */
251
    private function findTranslationsInFile(SplFileInfo $file): array
252
    {
253
        $foundLanguageKeys = [];
7✔
254
        $badLanguageKeys   = [];
7✔
255

256
        $fileContent = file_get_contents($file->getRealPath());
7✔
257
        preg_match_all('/lang\(\'([._a-z0-9\-]+)\'\)/ui', $fileContent, $matches);
7✔
258

259
        if ($matches[1] === []) {
7✔
260
            return compact('foundLanguageKeys', 'badLanguageKeys');
×
261
        }
262

263
        foreach ($matches[1] as $phraseKey) {
7✔
264
            $phraseKeys = explode('.', $phraseKey);
7✔
265

266
            // Language key not have Filename or Lang key
267
            if (count($phraseKeys) < 2) {
7✔
268
                $badLanguageKeys[] = [mb_substr($file->getRealPath(), mb_strlen(ROOTPATH)), $phraseKey];
5✔
269

270
                continue;
5✔
271
            }
272

273
            $languageFileName   = array_shift($phraseKeys);
7✔
274
            $isEmptyNestedArray = ($languageFileName !== '' && $phraseKeys[0] === '')
7✔
275
                || ($languageFileName === '' && $phraseKeys[0] !== '')
7✔
276
                || ($languageFileName === '' && $phraseKeys[0] === '');
7✔
277

278
            if ($isEmptyNestedArray) {
7✔
279
                $badLanguageKeys[] = [mb_substr($file->getRealPath(), mb_strlen(ROOTPATH)), $phraseKey];
5✔
280

281
                continue;
5✔
282
            }
283

284
            if (count($phraseKeys) === 1) {
7✔
285
                $foundLanguageKeys[$languageFileName][$phraseKeys[0]] = $phraseKey;
7✔
286
            } else {
287
                $childKeys = $this->buildMultiArray($phraseKeys, $phraseKey);
5✔
288

289
                $foundLanguageKeys[$languageFileName] = array_replace_recursive($foundLanguageKeys[$languageFileName] ?? [], $childKeys);
5✔
290
            }
291
        }
292

293
        return compact('foundLanguageKeys', 'badLanguageKeys');
7✔
294
    }
295

296
    private function isIgnoredFile(SplFileInfo $file): bool
297
    {
298
        if ($file->isDir() || $this->isSubdirectory($file->getRealPath(), $this->languagePath)) {
7✔
299
            return true;
7✔
300
        }
301

302
        return $file->getExtension() !== 'php';
7✔
303
    }
304

305
    /**
306
     * @param array<array-key, mixed> $language
307
     */
308
    private function templateFile(array $language = []): string
309
    {
310
        if ($language !== []) {
5✔
311
            $languageArrayString = var_export($language, true);
5✔
312

313
            $code = <<<PHP
5✔
314
                <?php
5✔
315

316
                return {$languageArrayString};
5✔
317

318
                PHP;
5✔
319

320
            return $this->replaceArraySyntax($code);
5✔
321
        }
322

323
        return <<<'PHP'
324
            <?php
325

326
            return [];
327

328
            PHP;
329
    }
330

331
    private function replaceArraySyntax(string $code): string
332
    {
333
        $tokens    = token_get_all($code);
5✔
334
        $newTokens = $tokens;
5✔
335

336
        foreach ($tokens as $i => $token) {
5✔
337
            if (is_array($token)) {
5✔
338
                [$tokenId, $tokenValue] = $token;
5✔
339

340
                // Replace "array ("
341
                if (
342
                    $tokenId === T_ARRAY
5✔
343
                    && $tokens[$i + 1][0] === T_WHITESPACE
5✔
344
                    && $tokens[$i + 2] === '('
5✔
345
                ) {
346
                    $newTokens[$i][1]     = '[';
5✔
347
                    $newTokens[$i + 1][1] = '';
5✔
348
                    $newTokens[$i + 2]    = '';
5✔
349
                }
350

351
                // Replace indent
352
                if ($tokenId === T_WHITESPACE && preg_match('/\n([ ]+)/u', $tokenValue, $matches)) {
5✔
353
                    $newTokens[$i][1] = "\n{$matches[1]}{$matches[1]}";
5✔
354
                }
355
            } // Replace ")"
356
            elseif ($token === ')') {
5✔
357
                $newTokens[$i] = ']';
5✔
358
            }
359
        }
360

361
        $output = '';
5✔
362

363
        foreach ($newTokens as $token) {
5✔
364
            $output .= $token[1] ?? $token;
5✔
365
        }
366

367
        return $output;
5✔
368
    }
369

370
    /**
371
     * Create multidimensional array from another keys
372
     *
373
     * @param list<string> $fromKeys
374
     *
375
     * @return array<string, mixed>
376
     */
377
    private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): array
378
    {
379
        $newArray  = [];
5✔
380
        $lastIndex = array_pop($fromKeys);
5✔
381
        $current   = &$newArray;
5✔
382

383
        foreach ($fromKeys as $value) {
5✔
384
            $current[$value] = [];
5✔
385
            $current         = &$current[$value];
5✔
386
        }
387

388
        $current[$lastIndex] = $lastArrayValue;
5✔
389

390
        return $newArray;
5✔
391
    }
392

393
    /**
394
     * Convert multi arrays to specific CLI table rows (flat array)
395
     *
396
     * @param array<array-key, mixed> $array
397
     *
398
     * @return list<array{string, string}>
399
     */
400
    private function arrayToTableRows(string $langFileName, array $array): array
401
    {
402
        $rows = [];
2✔
403

404
        foreach ($array as $value) {
2✔
405
            if (is_array($value)) {
2✔
406
                $rows = array_merge($rows, $this->arrayToTableRows($langFileName, $value));
1✔
407

408
                continue;
1✔
409
            }
410

411
            if (is_string($value)) {
2✔
412
                $rows[] = [$langFileName, $value];
2✔
413
            }
414
        }
415

416
        return $rows;
2✔
417
    }
418

419
    /**
420
     * Show details in the console if the flag is set
421
     */
422
    private function writeIsVerbose(string $text = '', ?string $foreground = null, ?string $background = null): void
423
    {
424
        if ($this->getValidatedOption('verbose') === true) {
7✔
425
            CLI::write($text, $foreground, $background);
1✔
426
        }
427
    }
428

429
    private function isSubdirectory(string $directory, string $rootDirectory): bool
430
    {
431
        return 0 === strncmp($directory, $rootDirectory, strlen($directory));
7✔
432
    }
433

434
    /**
435
     * @param list<SplFileInfo> $files
436
     *
437
     * @return array{'foundLanguageKeys': array<string, array<string, string>>, 'badLanguageKeys': array<int, array<int, string>>, 'countFiles': int}
438
     */
439
    private function findLanguageKeysInFiles(array $files): array
440
    {
441
        $foundLanguageKeys = [];
7✔
442
        $badLanguageKeys   = [];
7✔
443
        $countFiles        = 0;
7✔
444

445
        foreach ($files as $file) {
7✔
446
            if ($this->isIgnoredFile($file)) {
7✔
447
                continue;
7✔
448
            }
449

450
            $this->writeIsVerbose(sprintf('File found: %s', mb_substr($file->getRealPath(), mb_strlen(APPPATH))));
7✔
451
            $countFiles++;
7✔
452

453
            $findInFile = $this->findTranslationsInFile($file);
7✔
454

455
            $foundLanguageKeys = array_replace_recursive($findInFile['foundLanguageKeys'], $foundLanguageKeys);
7✔
456
            $badLanguageKeys   = array_merge($findInFile['badLanguageKeys'], $badLanguageKeys);
7✔
457
        }
458

459
        return compact('foundLanguageKeys', 'badLanguageKeys', 'countFiles');
7✔
460
    }
461
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc