Passed
Push — main ( 42d171...ad805a )
by Dimitri
05:59 queued 01:56
created

TranslationsFinder::isIgnoredFile()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 3
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 7
ccs 2
cts 2
cp 1
crap 3
rs 10
1
<?php
2
3
/**
4
 * This file is part of Blitz PHP framework.
5
 *
6
 * (c) 2022 Dimitri Sitchet Tomkeu <[email protected]>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11
12
namespace BlitzPHP\Cli\Commands\Translation;
13
14
use Ahc\Cli\Output\Color;
15
use BlitzPHP\Cli\Console\Command;
16
use BlitzPHP\Utilities\Iterable\Arr;
17
use Locale;
18
use RecursiveDirectoryIterator;
19
use RecursiveIteratorIterator;
20
use SplFileInfo;
21
22
/**
23
 * @credit <a href="https://codeigniter.com">CodeIgniter 4 - \CodeIgniter\Commands\Translation\LocalizationFinder</a>
24
 */
25
class TranslationsFinder extends Command
26
{
27
    protected $group       = 'Translation';
28
    protected $name        = 'translations:find';
29
    protected $description = 'Trouver et sauvegarder les phrases disponibles à traduire';
30
    protected $options     = [
31
        '--locale'   => 'Spécifier la locale (en, ru, etc.) pour enregistrer les fichiers',
32
        '--dir'      => 'Répertoire de recherche des traductions relatif à APP_PATH.',
33
        '--show-new' => 'N\'affiche que les nouvelles traductions dans le tableau. N\'écrit pas dans les fichiers.',
34
        '--verbose'  => 'Affiche des informations détaillées',
35
    ];
36
37
    /**
38
     * Indicateur pour afficher des informations détaillées
39
     */
40
    private bool $verbose = false;
41
42
    /**
43
     * Indicateur pour afficher uniquement les traductions, sans sauvegarde
44
     */
45
    private bool $showNew = false;
46
47
    private string $languagePath;
48
49
    /**
50
     * {@inheritDoc}
51
     */
52
    public function execute(array $params)
53
    {
54 2
        $this->verbose      = $this->option('verbose', false);
55 2
        $this->showNew      = $this->option('show-new', false);
56 2
        $optionLocale       = $params['locale'] ?? null;
57 2
        $optionDir          = $params['dir'] ?? null;
58 2
        $currentLocale      = Locale::getDefault();
59 2
        $currentDir         = APP_PATH;
60 2
        $this->languagePath = $currentDir . 'Translations';
61
62
        if (on_test()) {
63 2
            $currentDir         = ROOTPATH . 'Services' . DS;
64 2
            $this->languagePath = ROOTPATH . 'Translations';
65
        }
66
67
        if (is_string($optionLocale)) {
68
            if (! in_array($optionLocale, config('app.supported_locales'), true)) {
0 ignored issues
show
Bug introduced by
config('app.supported_locales') of type BlitzPHP\Config\Config|null is incompatible with the type array expected by parameter $haystack of in_array(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

68
            if (! in_array($optionLocale, /** @scrutinizer ignore-type */ config('app.supported_locales'), true)) {
Loading history...
69
                $this->error(
70
                    $this->color->error('"' . $optionLocale . '" n\'est pas supporté. Les langues supportées sont: '
71
                    . implode(', ', config('app.supported_locales')))
0 ignored issues
show
Bug introduced by
config('app.supported_locales') of type BlitzPHP\Config\Config|null is incompatible with the type array expected by parameter $pieces of implode(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

71
                    . implode(', ', /** @scrutinizer ignore-type */ config('app.supported_locales')))
Loading history...
72 2
                );
73
74 2
                return EXIT_USER_INPUT;
75
            }
76
77
            $currentLocale = $optionLocale;
78
        }
79
80
        if (is_string($optionDir)) {
81 2
            $tempCurrentDir = realpath($currentDir . $optionDir);
82
83
            if ($tempCurrentDir === false) {
84 2
                $this->error($this->color->error('Le dossier doit se trouvé dans "' . $currentDir . '"'));
85
86 2
                return EXIT_USER_INPUT;
87
            }
88
89
            if ($this->isSubDirectory($tempCurrentDir, $this->languagePath)) {
90
                $this->error($this->color->error('Le dossier "' . $this->languagePath . '" est restreint à l\'analyse.'));
91
92
                return EXIT_USER_INPUT;
93
            }
94
95
            $currentDir = $tempCurrentDir;
96
        }
97
98 2
        $this->process($currentDir, $currentLocale);
99
100 2
        $this->eol()->ok('Opérations terminées!');
101
102 2
        return EXIT_SUCCESS;
103
    }
104
105
    private function process(string $currentDir, string $currentLocale): void
106
    {
107 2
        $tableRows    = [];
108 2
        $countNewKeys = 0;
109
110 2
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($currentDir));
111 2
        $files    = iterator_to_array($iterator, true);
112 2
        ksort($files);
113
114
        [
115
            'foundLanguageKeys' => $foundLanguageKeys,
116
            'badLanguageKeys'   => $badLanguageKeys,
117
            'countFiles'        => $countFiles
118 2
        ] = $this->findLanguageKeysInFiles($files);
119
120 2
        ksort($foundLanguageKeys);
121
122 2
        $languageDiff        = [];
123 2
        $languageFoundGroups = array_unique(array_keys($foundLanguageKeys));
124
125
        foreach ($languageFoundGroups as $langFileName) {
126 2
            $languageStoredKeys = [];
127 2
            $languageFilePath   = $this->languagePath . DIRECTORY_SEPARATOR . $currentLocale . DIRECTORY_SEPARATOR . $langFileName . '.php';
128
129
            if (is_file($languageFilePath)) {
130
                // Charge les anciennes traductions
131
                $languageStoredKeys = require $languageFilePath;
132
            } elseif (! is_dir($dir = dirname($languageFilePath))) {
133
                // Si le dossier n'existe pas, on le cree
134
                @mkdir($dir, 0777, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for mkdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

134
                /** @scrutinizer ignore-unhandled */ @mkdir($dir, 0777, true);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
135
            }
136
137 2
            $languageDiff = Arr::diffRecursive($foundLanguageKeys[$langFileName], $languageStoredKeys);
138 2
            $countNewKeys += Arr::countRecursive($languageDiff);
139
140
            if ($this->showNew) {
141 2
                $tableRows = array_merge($this->arrayToTableRows($langFileName, $languageDiff), $tableRows);
142
            } else {
143 2
                $newLanguageKeys = array_replace_recursive($foundLanguageKeys[$langFileName], $languageStoredKeys);
144
145
                if ($languageDiff !== []) {
146
                    if (file_put_contents($languageFilePath, $this->templateFile($newLanguageKeys)) === false) {
147
                        if ($this->verbose) {
148
                            $this->justify('Fichier de traduction "' . $langFileName . '"', 'Erreur lors de la modification', [
149
                                'second' => ['fg' => Color::RED],
150
                            ]);
151
                        }
152
                    } else {
153
                        if ($this->verbose) {
154
                            $this->justify('Fichier de traduction "' . $langFileName . '"', 'Modification éffectuée avec succès', [
155
                                'second' => ['fg' => Color::GREEN],
156 2
                            ]);
157
                        }
158
                    }
159
                }
160
            }
161
        }
162
163
        if ($this->showNew && $tableRows !== []) {
164 2
            sort($tableRows);
165 2
            $table = [];
166
167
            foreach ($tableRows as $body) {
168 2
                $table[] = array_combine(['Fichier', 'Clé'], $body);
169
            }
170 2
            $this->table($table);
171
        }
172
173
        if ($this->verbose) {
174 2
            $this->eol()->border(char: '*');
175
        }
176
177 2
        $this->justify('Fichiers analysés', $countFiles, ['second' => ['fg' => Color::GREEN]]);
178 2
        $this->justify('Nouvelles traductions trouvées', $countNewKeys, ['second' => ['fg' => Color::GREEN]]);
179 2
        $this->justify('Mauvaises traductions trouvées', count($badLanguageKeys), ['second' => ['fg' => Color::RED]]);
180
181
        if ($this->verbose && $badLanguageKeys !== []) {
182 2
            $tableBadRows = [];
183
184
            foreach ($badLanguageKeys as $value) {
185 2
                $tableBadRows[] = [$value[1], $value[0]];
186
            }
187
188 2
            usort($tableBadRows, static fn ($currentValue, $nextValue): int => strnatcmp((string) $currentValue[0], (string) $nextValue[0]));
189
190 2
            $table = [];
191
192
            foreach ($tableBadRows as $body) {
193 2
                $table[] = array_combine(['Mauvaise clé', 'Fichier'], $body);
194
            }
195
196 2
            $this->table($table);
197
        }
198
199
        if (! $this->showNew && $countNewKeys > 0) {
200 2
            $this->eol()->writer->bgRed('Note: Vous devez utiliser votre outil de linting pour résoudre les problèmes liés aux normes de codage.', true);
201
        }
202
    }
203
204
    /**
205
     * @param SplFileInfo|string $file
206
     *
207
     * @return array<string, array>
208
     */
209
    private function findTranslationsInFile($file): array
210
    {
211 2
        $foundLanguageKeys = [];
212 2
        $badLanguageKeys   = [];
213
214
        if (is_string($file) && is_file($file)) {
215 2
            $file = new SplFileInfo($file);
216
        }
217
218 2
        $fileContent = file_get_contents($file->getRealPath());
219
220 2
        preg_match_all('/\_\_\(\'([_a-z0-9À-ÿ\-]+)\'\)/ui', $fileContent, $matches);
221
222
        if ($matches[1] !== []) {
223 2
            $fileContent = str_replace($matches[0], array_map(static fn ($val) => "lang('App.{$val}')", $matches[1]), $fileContent);
224
        }
225
226 2
        preg_match_all('/lang\(\'([._a-z0-9À-ÿ\-]+)\'\)/ui', $fileContent, $matches);
227
228
        if ($matches[1] === []) {
229 2
            return compact('foundLanguageKeys', 'badLanguageKeys');
230
        }
231
232
        foreach ($matches[1] as $phraseKey) {
233 2
            $phraseKeys = explode('.', $phraseKey);
234
235
            // Le code langue n'a pas de nom de fichier ou de code langue.
236
            if (count($phraseKeys) < 2) {
237 2
                $badLanguageKeys[] = [mb_substr($file->getRealPath(), mb_strlen(ROOTPATH)), $phraseKey];
238
239 2
                continue;
240
            }
241
242 2
            $languageFileName   = array_shift($phraseKeys);
243
            $isEmptyNestedArray = ($languageFileName !== '' && $phraseKeys[0] === '')
244
                || ($languageFileName === '' && $phraseKeys[0] !== '')
245 2
                || ($languageFileName === '' && $phraseKeys[0] === '');
246
247
            if ($isEmptyNestedArray) {
248 2
                $badLanguageKeys[] = [mb_substr($file->getRealPath(), mb_strlen(ROOTPATH)), $phraseKey];
249
250 2
                continue;
251
            }
252
253
            if (count($phraseKeys) === 1) {
254 2
                $foundLanguageKeys[$languageFileName][$phraseKeys[0]] = $phraseKey;
255
            } else {
256 2
                $childKeys = $this->buildMultiArray($phraseKeys, $phraseKey);
257
258 2
                $foundLanguageKeys[$languageFileName] = array_replace_recursive($foundLanguageKeys[$languageFileName] ?? [], $childKeys);
259
            }
260
        }
261
262 2
        return compact('foundLanguageKeys', 'badLanguageKeys');
263
    }
264
265
    private function isIgnoredFile(SplFileInfo $file): bool
266
    {
267
        if ($file->isDir() || $this->isSubDirectory($file->getRealPath(), $this->languagePath)) {
268 2
            return true;
269
        }
270
271 2
        return $file->getExtension() !== 'php';
272
    }
273
274
    private function templateFile(array $language = []): string
275
    {
276
        if ($language !== []) {
277 2
            $languageArrayString = var_export($language, true);
278
279
            $code = <<<PHP
280
                <?php
281
282
                return {$languageArrayString};
283
284
                PHP;
285
286 2
            return $this->replaceArraySyntax($code);
287
        }
288
289
        return <<<'PHP'
290
            <?php
291
292
            return [];
293
294
            PHP;
295
    }
296
297
    private function replaceArraySyntax(string $code): string
298
    {
299 2
        $tokens    = token_get_all($code);
300 2
        $newTokens = $tokens;
301
302
        foreach ($tokens as $i => $token) {
303
            if (is_array($token)) {
304 2
                [$tokenId, $tokenValue] = $token;
305
306
                // Remplace "array (" par "["
307
                if (
308
                    $tokenId === T_ARRAY
309
                    && $tokens[$i + 1][0] === T_WHITESPACE
310
                    && $tokens[$i + 2] === '('
311
                ) {
312 2
                    $newTokens[$i][1]     = '[';
313 2
                    $newTokens[$i + 1][1] = '';
314 2
                    $newTokens[$i + 2]    = '';
315
                }
316
317
                // Remplace les indentations
318
                if ($tokenId === T_WHITESPACE && preg_match('/\n([ ]+)/u', $tokenValue, $matches)) {
319 2
                    $newTokens[$i][1] = "\n{$matches[1]}{$matches[1]}";
320
                }
321
            } // Remplace ")"
322
            elseif ($token === ')') {
323 2
                $newTokens[$i] = ']';
324
            }
325
        }
326
327 2
        $output = '';
328
329
        foreach ($newTokens as $token) {
330 2
            $output .= $token[1] ?? $token;
331
        }
332
333 2
        return $output;
334
    }
335
336
    /**
337
     * Crée un tableau multidimensionnel à partir d'autres clés
338
     */
339
    private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): array
340
    {
341 2
        $newArray  = [];
342 2
        $lastIndex = array_pop($fromKeys);
343 2
        $current   = &$newArray;
344
345
        foreach ($fromKeys as $value) {
346 2
            $current[$value] = [];
347 2
            $current         = &$current[$value];
348
        }
349
350 2
        $current[$lastIndex] = $lastArrayValue;
351
352 2
        return $newArray;
353
    }
354
355
    /**
356
     * Convertit les tableaux multidimensionnels en lignes de table CLI spécifiques (tableau plat)
357
     */
358
    private function arrayToTableRows(string $langFileName, array $array): array
359
    {
360 2
        $rows = [];
361
362
        foreach ($array as $value) {
363
            if (is_array($value)) {
364 2
                $rows = array_merge($rows, $this->arrayToTableRows($langFileName, $value));
365
366 2
                continue;
367
            }
368
369
            if (is_string($value)) {
370 2
                $rows[] = [$langFileName, $value];
371
            }
372
        }
373
374 2
        return $rows;
375
    }
376
377
    private function isSubDirectory(string $directory, string $rootDirectory): bool
378
    {
379 2
        return 0 === strncmp($directory, $rootDirectory, strlen($directory));
380
    }
381
382
    /**
383
     * @param list<SplFileInfo> $files
0 ignored issues
show
Bug introduced by
The type BlitzPHP\Cli\Commands\Translation\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
384
     *
385
     * @return         array<string, array|int>
386
     * @phpstan-return array{'foundLanguageKeys': array<string, array<string, string>>, 'badLanguageKeys': array<int, array<int, string>>, 'countFiles': int}
387
     */
388
    private function findLanguageKeysInFiles(array $files): array
389
    {
390 2
        $foundLanguageKeys = [];
391 2
        $badLanguageKeys   = [];
392 2
        $countFiles        = 0;
393
394
        foreach ($files as $file) {
395
            if ($this->isIgnoredFile($file)) {
396 2
                continue;
397
            }
398
399
            if ($this->verbose) {
400
                $this->justify(mb_substr($file->getRealPath(), mb_strlen(APP_PATH)), 'Analysé', [
401
                    'second' => ['fg' => Color::YELLOW],
402 2
                ]);
403
            }
404 2
            $countFiles++;
405
406 2
            $findInFile = $this->findTranslationsInFile($file);
407
408 2
            $foundLanguageKeys = array_replace_recursive($findInFile['foundLanguageKeys'], $foundLanguageKeys);
409 2
            $badLanguageKeys   = array_merge($findInFile['badLanguageKeys'], $badLanguageKeys);
410
        }
411
412 2
        return compact('foundLanguageKeys', 'badLanguageKeys', 'countFiles');
413
    }
414
}
415