MessageController::getLine()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 3
nop 1
dl 0
loc 9
ccs 0
cts 5
cp 0
crap 12
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * @link https://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license https://www.yiiframework.com/license/
6
 */
7
8
namespace yii\console\controllers;
9
10
use Yii;
11
use yii\console\Exception;
12
use yii\console\ExitCode;
13
use yii\db\Connection;
14
use yii\db\Query;
15
use yii\di\Instance;
16
use yii\helpers\Console;
17
use yii\helpers\FileHelper;
18
use yii\helpers\VarDumper;
19
use yii\i18n\GettextPoFile;
20
21
/**
22
 * Extracts messages to be translated from source files.
23
 *
24
 * The extracted messages can be saved the following depending on `format`
25
 * setting in config file:
26
 *
27
 * - PHP message source files.
28
 * - ".po" files.
29
 * - Database.
30
 *
31
 * Usage:
32
 * 1. Create a configuration file using the 'message/config' command:
33
 *    yii message/config /path/to/myapp/messages/config.php
34
 * 2. Edit the created config file, adjusting it for your web application needs.
35
 * 3. Run the 'message/extract' command, using created config:
36
 *    yii message /path/to/myapp/messages/config.php
37
 *
38
 * @author Qiang Xue <[email protected]>
39
 * @since 2.0
40
 */
41
class MessageController extends \yii\console\Controller
42
{
43
    /**
44
     * @var string controller default action ID.
45
     */
46
    public $defaultAction = 'extract';
47
    /**
48
     * @var string required, root directory of all source files.
49
     */
50
    public $sourcePath = '@yii';
51
    /**
52
     * @var string required, root directory containing message translations.
53
     */
54
    public $messagePath = '@yii/messages';
55
    /**
56
     * @var array required, list of language codes that the extracted messages
57
     * should be translated to. For example, ['zh-CN', 'de'].
58
     */
59
    public $languages = [];
60
    /**
61
     * @var string|string[] the name of the function for translating messages.
62
     * This is used as a mark to find the messages to be translated.
63
     * You may use a string for single function name or an array for multiple function names.
64
     */
65
    public $translator = ['Yii::t', '\Yii::t'];
66
    /**
67
     * @var bool whether to sort messages by keys when merging new messages
68
     * with the existing ones. Defaults to false, which means the new (untranslated)
69
     * messages will be separated from the old (translated) ones.
70
     */
71
    public $sort = false;
72
    /**
73
     * @var bool whether the message file should be overwritten with the merged messages
74
     */
75
    public $overwrite = true;
76
    /**
77
     * @var bool whether to remove messages that no longer appear in the source code.
78
     * Defaults to false, which means these messages will NOT be removed.
79
     */
80
    public $removeUnused = false;
81
    /**
82
     * @var bool whether to mark messages that no longer appear in the source code.
83
     * Defaults to true, which means each of these messages will be enclosed with a pair of '@@' marks.
84
     */
85
    public $markUnused = true;
86
    /**
87
     * @var array|null list of patterns that specify which files/directories should NOT be processed.
88
     * If empty or not set, all files/directories will be processed.
89
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
90
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
91
     */
92
    public $except = [
93
        '.*',
94
        '/.*',
95
        '/messages',
96
        '/tests',
97
        '/runtime',
98
        '/vendor',
99
        '/BaseYii.php', // contains examples about Yii::t()
100
    ];
101
    /**
102
     * @var array|null list of patterns that specify which files (not directories) should be processed.
103
     * If empty or not set, all files will be processed.
104
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
105
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
106
     */
107
    public $only = ['*.php'];
108
    /**
109
     * @var string generated file format. Can be "php", "db", "po" or "pot".
110
     */
111
    public $format = 'php';
112
    /**
113
     * @var string connection component ID for "db" format.
114
     */
115
    public $db = 'db';
116
    /**
117
     * @var string custom name for source message table for "db" format.
118
     */
119
    public $sourceMessageTable = '{{%source_message}}';
120
    /**
121
     * @var string custom name for translation message table for "db" format.
122
     */
123
    public $messageTable = '{{%message}}';
124
    /**
125
     * @var string name of the file that will be used for translations for "po" format.
126
     */
127
    public $catalog = 'messages';
128
    /**
129
     * @var array message categories to ignore. For example, 'yii', 'app*', 'widgets/menu', etc.
130
     * @see isCategoryIgnored
131
     */
132
    public $ignoreCategories = [];
133
    /**
134
     * @var string File header in generated PHP file with messages. This property is used only if [[$format]] is "php".
135
     * @since 2.0.13
136
     */
137
    public $phpFileHeader = '';
138
    /**
139
     * @var string|null DocBlock used for messages array in generated PHP file. If `null`, default DocBlock will be used.
140
     * This property is used only if [[$format]] is "php".
141
     * @since 2.0.13
142
     */
143
    public $phpDocBlock;
144
145
    /**
146
     * @var array Config for messages extraction.
147
     * @see actionExtract()
148
     * @see initConfig()
149
     * @since 2.0.13
150
     */
151
    protected $config;
152
153
154
    /**
155
     * {@inheritdoc}
156
     */
157 51
    public function options($actionID)
158
    {
159 51
        return array_merge(parent::options($actionID), [
160 51
            'sourcePath',
161 51
            'messagePath',
162 51
            'languages',
163 51
            'translator',
164 51
            'sort',
165 51
            'overwrite',
166 51
            'removeUnused',
167 51
            'markUnused',
168 51
            'except',
169 51
            'only',
170 51
            'format',
171 51
            'db',
172 51
            'sourceMessageTable',
173 51
            'messageTable',
174 51
            'catalog',
175 51
            'ignoreCategories',
176 51
            'phpFileHeader',
177 51
            'phpDocBlock',
178 51
        ]);
179
    }
180
181
    /**
182
     * {@inheritdoc}
183
     * @since 2.0.8
184
     */
185
    public function optionAliases()
186
    {
187
        return array_merge(parent::optionAliases(), [
188
            'c' => 'catalog',
189
            'e' => 'except',
190
            'f' => 'format',
191
            'i' => 'ignoreCategories',
192
            'l' => 'languages',
193
            'u' => 'markUnused',
194
            'p' => 'messagePath',
195
            'o' => 'only',
196
            'w' => 'overwrite',
197
            'S' => 'sort',
198
            't' => 'translator',
199
            'm' => 'sourceMessageTable',
200
            's' => 'sourcePath',
201
            'r' => 'removeUnused',
202
        ]);
203
    }
204
205
    /**
206
     * Creates a configuration file for the "extract" command using command line options specified.
207
     *
208
     * The generated configuration file contains parameters required
209
     * for source code messages extraction.
210
     * You may use this configuration file with the "extract" command.
211
     *
212
     * @param string $filePath output file name or alias.
213
     * @return int CLI exit code
214
     * @throws Exception on failure.
215
     */
216 4
    public function actionConfig($filePath)
217
    {
218 4
        $filePath = Yii::getAlias($filePath);
219 4
        $dir = dirname($filePath);
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $path of dirname() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

219
        $dir = dirname(/** @scrutinizer ignore-type */ $filePath);
Loading history...
220
221 4
        if (file_exists($filePath)) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_exists() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

221
        if (file_exists(/** @scrutinizer ignore-type */ $filePath)) {
Loading history...
222
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
223
                return ExitCode::OK;
224
            }
225
        }
226
227 4
        $array = VarDumper::export($this->getOptionValues($this->action->id));
228 4
        $content = <<<EOD
229 4
<?php
230
/**
231 4
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
232
 *
233 4
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
234
 * It contains parameters for source code messages extraction.
235
 * You may modify this file to suit your needs.
236
 *
237 4
 * You can use 'yii {$this->id}/{$this->action->id}-template' command to create
238
 * template configuration file with detailed description for each parameter.
239
 */
240 4
return $array;
241
242 4
EOD;
243
244 4
        if (FileHelper::createDirectory($dir) === false || file_put_contents($filePath, $content, LOCK_EX) === false) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_put_contents() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

244
        if (FileHelper::createDirectory($dir) === false || file_put_contents(/** @scrutinizer ignore-type */ $filePath, $content, LOCK_EX) === false) {
Loading history...
245
            $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED);
246
            return ExitCode::UNSPECIFIED_ERROR;
247
        }
248
249 4
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
250 4
        return ExitCode::OK;
251
    }
252
253
    /**
254
     * Creates a configuration file template for the "extract" command.
255
     *
256
     * The created configuration file contains detailed instructions on
257
     * how to customize it to fit for your needs. After customization,
258
     * you may use this configuration file with the "extract" command.
259
     *
260
     * @param string $filePath output file name or alias.
261
     * @return int CLI exit code
262
     * @throws Exception on failure.
263
     */
264
    public function actionConfigTemplate($filePath)
265
    {
266
        $filePath = Yii::getAlias($filePath);
267
268
        if (file_exists($filePath)) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_exists() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

268
        if (file_exists(/** @scrutinizer ignore-type */ $filePath)) {
Loading history...
269
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
270
                return ExitCode::OK;
271
            }
272
        }
273
274
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $to of copy() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

274
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), /** @scrutinizer ignore-type */ $filePath)) {
Loading history...
Bug introduced by
It seems like Yii::getAlias('@yii/views/messageConfig.php') can also be of type false; however, parameter $from of copy() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

274
        if (!copy(/** @scrutinizer ignore-type */ Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
Loading history...
275
            $this->stdout("Configuration file template was NOT created at '{$filePath}'.\n\n", Console::FG_RED);
276
            return ExitCode::UNSPECIFIED_ERROR;
277
        }
278
279
        $this->stdout("Configuration file template created at '{$filePath}'.\n\n", Console::FG_GREEN);
280
        return ExitCode::OK;
281
    }
282
283
    /**
284
     * Extracts messages to be translated from source code.
285
     *
286
     * This command will search through source code files and extract
287
     * messages that need to be translated in different languages.
288
     *
289
     * @param string|null $configFile the path or alias of the configuration file.
290
     * You may use the "yii message/config" command to generate
291
     * this file and then customize it for your needs.
292
     * @throws Exception on failure.
293
     */
294 47
    public function actionExtract($configFile = null)
295
    {
296 47
        $this->initConfig($configFile);
297
298 45
        $files = FileHelper::findFiles(realpath($this->config['sourcePath']), $this->config);
299
300 45
        $messages = [];
301 45
        foreach ($files as $file) {
302 39
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $this->config['translator'], $this->config['ignoreCategories']));
303
        }
304
305 45
        $catalog = isset($this->config['catalog']) ? $this->config['catalog'] : 'messages';
306
307 45
        if (in_array($this->config['format'], ['php', 'po'])) {
308 45
            foreach ($this->config['languages'] as $language) {
309 45
                $dir = $this->config['messagePath'] . DIRECTORY_SEPARATOR . $language;
310 45
                if (!is_dir($dir) && !@mkdir($dir)) {
311
                    throw new Exception("Directory '{$dir}' can not be created.");
312
                }
313 45
                if ($this->config['format'] === 'po') {
314 16
                    $this->saveMessagesToPO($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $catalog, $this->config['markUnused']);
315
                } else {
316 29
                    $this->saveMessagesToPHP($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $this->config['markUnused']);
317
                }
318
            }
319
        } elseif ($this->config['format'] === 'db') {
320
            /** @var Connection $db */
321
            $db = Instance::ensure($this->config['db'], Connection::class);
322
            $sourceMessageTable = isset($this->config['sourceMessageTable']) ? $this->config['sourceMessageTable'] : '{{%source_message}}';
323
            $messageTable = isset($this->config['messageTable']) ? $this->config['messageTable'] : '{{%message}}';
324
            $this->saveMessagesToDb(
325
                $messages,
326
                $db,
327
                $sourceMessageTable,
328
                $messageTable,
329
                $this->config['removeUnused'],
330
                $this->config['languages'],
331
                $this->config['markUnused']
332
            );
333
        } elseif ($this->config['format'] === 'pot') {
334
            $this->saveMessagesToPOT($messages, $this->config['messagePath'], $catalog);
335
        }
336
    }
337
338
    /**
339
     * Saves messages to database.
340
     *
341
     * @param array $messages
342
     * @param Connection $db
343
     * @param string $sourceMessageTable
344
     * @param string $messageTable
345
     * @param bool $removeUnused
346
     * @param array $languages
347
     * @param bool $markUnused
348
     */
349
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
350
    {
351
        $currentMessages = [];
352
        $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
353
        foreach ($rows as $row) {
354
            $currentMessages[$row['category']][$row['id']] = $row['message'];
355
        }
356
357
        $new = [];
358
        $obsolete = [];
359
360
        foreach ($messages as $category => $msgs) {
361
            $msgs = array_unique($msgs);
362
363
            if (isset($currentMessages[$category])) {
364
                $new[$category] = array_diff($msgs, $currentMessages[$category]);
365
                // obsolete messages per category
366
                $obsolete += array_diff($currentMessages[$category], $msgs);
367
            } else {
368
                $new[$category] = $msgs;
369
            }
370
        }
371
372
        // obsolete categories
373
        foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) {
374
            $obsolete += $currentMessages[$category];
375
        }
376
377
        if (!$removeUnused) {
378
            foreach ($obsolete as $pk => $msg) {
379
                // skip already marked unused
380
                if (strncmp($msg, '@@', 2) === 0 && substr($msg, -2) === '@@') {
381
                    unset($obsolete[$pk]);
382
                }
383
            }
384
        }
385
386
        $this->stdout('Inserting new messages...');
387
        $insertCount = 0;
388
389
        foreach ($new as $category => $msgs) {
390
            foreach ($msgs as $msg) {
391
                $insertCount++;
392
                $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]);
393
            }
394
        }
395
396
        $this->stdout($insertCount ? "{$insertCount} saved.\n" : "Nothing to save.\n");
397
398
        $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...');
399
400
        if (empty($obsolete)) {
401
            $this->stdout("Nothing obsoleted...skipped.\n");
402
        }
403
404
        if ($obsolete) {
405
            if ($removeUnused) {
406
                $affected = $db->createCommand()
407
                   ->delete($sourceMessageTable, ['in', 'id', array_keys($obsolete)])
408
                   ->execute();
409
                $this->stdout("{$affected} deleted.\n");
410
            } elseif ($markUnused) {
411
                $marked = 0;
412
                $rows = (new Query())
413
                    ->select(['id', 'message'])
414
                    ->from($sourceMessageTable)
415
                    ->where(['in', 'id', array_keys($obsolete)])
416
                    ->all($db);
417
418
                foreach ($rows as $row) {
419
                    $marked++;
420
                    $db->createCommand()->update(
421
                        $sourceMessageTable,
422
                        ['message' => '@@' . $row['message'] . '@@'],
423
                        ['id' => $row['id']]
424
                    )->execute();
425
                }
426
                $this->stdout("{$marked} updated.\n");
427
            } else {
428
                $this->stdout("kept untouched.\n");
429
            }
430
        }
431
432
        // get fresh message id list
433
        $freshMessagesIds = [];
434
        $rows = (new Query())->select(['id'])->from($sourceMessageTable)->all($db);
435
        foreach ($rows as $row) {
436
            $freshMessagesIds[] = $row['id'];
437
        }
438
439
        $this->stdout('Generating missing rows...');
440
        $generatedMissingRows = [];
441
442
        foreach ($languages as $language) {
443
            $count = 0;
444
445
            // get list of ids of translations for this language
446
            $msgRowsIds = [];
447
            $msgRows = (new Query())->select(['id'])->from($messageTable)->where([
448
                'language' => $language,
449
            ])->all($db);
450
            foreach ($msgRows as $row) {
451
                $msgRowsIds[] = $row['id'];
452
            }
453
454
            // insert missing
455
            foreach ($freshMessagesIds as $id) {
456
                if (!in_array($id, $msgRowsIds)) {
457
                    $db->createCommand()
458
                       ->insert($messageTable, ['id' => $id, 'language' => $language])
459
                       ->execute();
460
                    $count++;
461
                }
462
            }
463
            if ($count) {
464
                $generatedMissingRows[] = "{$count} for {$language}";
465
            }
466
        }
467
468
        $this->stdout($generatedMissingRows ? implode(', ', $generatedMissingRows) . ".\n" : "Nothing to do.\n");
469
470
        $this->stdout('Dropping unused languages...');
471
        $droppedLanguages = [];
472
473
        $currentLanguages = [];
474
        $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db);
475
        foreach ($rows as $row) {
476
            $currentLanguages[] = $row['language'];
477
        }
478
479
        foreach ($currentLanguages as $currentLanguage) {
480
            if (!in_array($currentLanguage, $languages)) {
481
                $deleted = $db->createCommand()->delete($messageTable, 'language=:language', [
482
                    'language' => $currentLanguage,
483
                ])->execute();
484
                $droppedLanguages[] = "removed {$deleted} rows for $currentLanguage";
485
            }
486
        }
487
488
        $this->stdout($droppedLanguages ? implode(', ', $droppedLanguages) . ".\n" : "Nothing to do.\n");
489
    }
490
491
    /**
492
     * Extracts messages from a file.
493
     *
494
     * @param string $fileName name of the file to extract messages from
495
     * @param string $translator name of the function used to translate messages
496
     * @param array $ignoreCategories message categories to ignore.
497
     * This parameter is available since version 2.0.4.
498
     * @return array
499
     */
500 39
    protected function extractMessages($fileName, $translator, $ignoreCategories = [])
501
    {
502 39
        $this->stdout('Extracting messages from ');
503 39
        $this->stdout($fileName, Console::FG_CYAN);
504 39
        $this->stdout("...\n");
505
506 39
        $subject = file_get_contents($fileName);
507 39
        $messages = [];
508 39
        $tokens = token_get_all($subject);
509 39
        foreach ((array) $translator as $currentTranslator) {
510 39
            $translatorTokens = token_get_all('<?php ' . $currentTranslator);
511 39
            array_shift($translatorTokens);
512 39
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($tokens, $translatorTokens, $ignoreCategories));
513
        }
514
515 39
        $this->stdout("\n");
516
517 39
        return $messages;
518
    }
519
520
    /**
521
     * Extracts messages from a parsed PHP tokens list.
522
     * @param array $tokens tokens to be processed.
523
     * @param array $translatorTokens translator tokens.
524
     * @param array $ignoreCategories message categories to ignore.
525
     * @return array messages.
526
     */
527 39
    protected function extractMessagesFromTokens(array $tokens, array $translatorTokens, array $ignoreCategories)
528
    {
529 39
        $messages = [];
530 39
        $translatorTokensCount = count($translatorTokens);
531 39
        $matchedTokensCount = 0;
532 39
        $buffer = [];
533 39
        $pendingParenthesisCount = 0;
534
535 39
        foreach ($tokens as $tokenIndex => $token) {
536
            // finding out translator call
537 39
            if ($matchedTokensCount < $translatorTokensCount) {
538 39
                if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
539 39
                    $matchedTokensCount++;
540
                } else {
541 39
                    $matchedTokensCount = 0;
542
                }
543 39
            } elseif ($matchedTokensCount === $translatorTokensCount) {
544
                // translator found
545
546
                // end of function call
547 39
                if ($this->tokensEqual(')', $token)) {
548 39
                    $pendingParenthesisCount--;
549
550 39
                    if ($pendingParenthesisCount === 0) {
551
                        // end of translator call or end of something that we can't extract
552 39
                        if (isset($buffer[0][0], $buffer[1], $buffer[2][0]) && $buffer[0][0] === T_CONSTANT_ENCAPSED_STRING && $buffer[1] === ',' && $buffer[2][0] === T_CONSTANT_ENCAPSED_STRING) {
553
                            // is valid call we can extract
554 39
                            $category = stripcslashes($buffer[0][1]);
555 39
                            $category = mb_substr($category, 1, -1);
556
557 39
                            if (!$this->isCategoryIgnored($category, $ignoreCategories)) {
558 39
                                $fullMessage = mb_substr($buffer[2][1], 1, -1);
559 39
                                $i = 3;
560 39
                                while ($i < count($buffer) - 1 && !is_array($buffer[$i]) && $buffer[$i] === '.') {
561 2
                                    $fullMessage .= mb_substr($buffer[$i + 1][1], 1, -1);
562 2
                                    $i += 2;
563
                                }
564
565 39
                                $message = stripcslashes($fullMessage);
566 39
                                $messages[$category][] = $message;
567
                            }
568
569 39
                            $nestedTokens = array_slice($buffer, 3);
570 39
                            if (count($nestedTokens) > $translatorTokensCount) {
571
                                // search for possible nested translator calls
572 39
                                $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($nestedTokens, $translatorTokens, $ignoreCategories));
573
                            }
574
                        } else {
575
                            // invalid call or dynamic call we can't extract
576
                            $line = Console::ansiFormat($this->getLine($buffer), [Console::FG_CYAN]);
577
                            $skipping = Console::ansiFormat('Skipping line', [Console::FG_YELLOW]);
578
                            $this->stdout("$skipping $line. Make sure both category and message are static strings.\n");
579
                        }
580
581
                        // prepare for the next match
582 39
                        $matchedTokensCount = 0;
583 39
                        $pendingParenthesisCount = 0;
584 39
                        $buffer = [];
585
                    } else {
586 39
                        $buffer[] = $token;
587
                    }
588 39
                } elseif ($this->tokensEqual('(', $token)) {
589
                    // count beginning of function call, skipping translator beginning
590
591
                    // If we are not yet inside the translator, make sure that it's beginning of the real translator.
592
                    // See https://github.com/yiisoft/yii2/issues/16828
593 39
                    if ($pendingParenthesisCount === 0) {
594 39
                        $previousTokenIndex = $tokenIndex - $matchedTokensCount - 1;
595 39
                        if (is_array($tokens[$previousTokenIndex])) {
596 39
                            $previousToken = $tokens[$previousTokenIndex][0];
597 39
                            if (in_array($previousToken, [T_OBJECT_OPERATOR, T_PAAMAYIM_NEKUDOTAYIM], true)) {
598 2
                                $matchedTokensCount = 0;
599 2
                                continue;
600
                            }
601
                        }
602
                    }
603
604 39
                    if ($pendingParenthesisCount > 0) {
605 4
                        $buffer[] = $token;
606
                    }
607 39
                    $pendingParenthesisCount++;
608 39
                } elseif (isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
609
                    // ignore comments and whitespaces
610 39
                    $buffer[] = $token;
611
                }
612
            }
613
        }
614
615 39
        return $messages;
616
    }
617
618
    /**
619
     * The method checks, whether the $category is ignored according to $ignoreCategories array.
620
     *
621
     * Examples:
622
     *
623
     * - `myapp` - will be ignored only `myapp` category;
624
     * - `myapp*` - will be ignored by all categories beginning with `myapp` (`myapp`, `myapplication`, `myapprove`, `myapp/widgets`, `myapp.widgets`, etc).
625
     *
626
     * @param string $category category that is checked
627
     * @param array $ignoreCategories message categories to ignore.
628
     * @return bool
629
     * @since 2.0.7
630
     */
631 39
    protected function isCategoryIgnored($category, array $ignoreCategories)
632
    {
633 39
        if (!empty($ignoreCategories)) {
634 2
            if (in_array($category, $ignoreCategories, true)) {
635 2
                return true;
636
            }
637 2
            foreach ($ignoreCategories as $pattern) {
638 2
                if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) {
639 2
                    return true;
640
                }
641
            }
642
        }
643
644 39
        return false;
645
    }
646
647
    /**
648
     * Finds out if two PHP tokens are equal.
649
     *
650
     * @param array|string $a
651
     * @param array|string $b
652
     * @return bool
653
     * @since 2.0.1
654
     */
655 39
    protected function tokensEqual($a, $b)
656
    {
657 39
        if (is_string($a) && is_string($b)) {
658 39
            return $a === $b;
659
        }
660 39
        if (isset($a[0], $a[1], $b[0], $b[1])) {
661 39
            return $a[0] === $b[0] && $a[1] == $b[1];
662
        }
663
664 39
        return false;
665
    }
666
667
    /**
668
     * Finds out a line of the first non-char PHP token found.
669
     *
670
     * @param array $tokens
671
     * @return int|string
672
     * @since 2.0.1
673
     */
674
    protected function getLine($tokens)
675
    {
676
        foreach ($tokens as $token) {
677
            if (isset($token[2])) {
678
                return $token[2];
679
            }
680
        }
681
682
        return 'unknown';
683
    }
684
685
    /**
686
     * Writes messages into PHP files.
687
     *
688
     * @param array $messages
689
     * @param string $dirName name of the directory to write to
690
     * @param bool $overwrite if existing file should be overwritten without backup
691
     * @param bool $removeUnused if obsolete translations should be removed
692
     * @param bool $sort if translations should be sorted
693
     * @param bool $markUnused if obsolete translations should be marked
694
     */
695 29
    protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort, $markUnused)
696
    {
697 29
        foreach ($messages as $category => $msgs) {
698 23
            $file = str_replace('\\', '/', "$dirName/$category.php");
699 23
            $path = dirname($file);
700 23
            FileHelper::createDirectory($path);
701 23
            $msgs = array_values(array_unique($msgs));
702 23
            $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
703 23
            $this->stdout("Saving messages to $coloredFileName...\n");
704 23
            $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category, $markUnused);
705
        }
706
707 29
        if ($removeUnused) {
708 7
            $this->deleteUnusedPhpMessageFiles(array_keys($messages), $dirName);
709
        }
710
    }
711
712
    /**
713
     * Writes category messages into PHP file.
714
     *
715
     * @param array $messages
716
     * @param string $fileName name of the file to write to
717
     * @param bool $overwrite if existing file should be overwritten without backup
718
     * @param bool $removeUnused if obsolete translations should be removed
719
     * @param bool $sort if translations should be sorted
720
     * @param string $category message category
721
     * @param bool $markUnused if obsolete translations should be marked
722
     * @return int exit code
723
     */
724 23
    protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category, $markUnused)
725
    {
726 23
        if (is_file($fileName)) {
727 16
            $rawExistingMessages = require $fileName;
728 16
            $existingMessages = $rawExistingMessages;
729 16
            sort($messages);
730 16
            ksort($existingMessages);
731 16
            if (array_keys($existingMessages) === $messages && (!$sort || array_keys($rawExistingMessages) === $messages)) {
732 10
                $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
733 10
                return ExitCode::OK;
734
            }
735 7
            unset($rawExistingMessages);
736 7
            $merged = [];
737 7
            $untranslated = [];
738 7
            foreach ($messages as $message) {
739 7
                if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
740 4
                    $merged[$message] = $existingMessages[$message];
741
                } else {
742 6
                    $untranslated[] = $message;
743
                }
744
            }
745 7
            ksort($merged);
746 7
            sort($untranslated);
747 7
            $todo = [];
748 7
            foreach ($untranslated as $message) {
749 6
                $todo[$message] = '';
750
            }
751 7
            ksort($existingMessages);
752 7
            foreach ($existingMessages as $message => $translation) {
753 7
                if (!$removeUnused && !isset($merged[$message]) && !isset($todo[$message])) {
754 3
                    if (!$markUnused || (!empty($translation) && (strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0))) {
755 2
                        $todo[$message] = $translation;
756
                    } else {
757 1
                        $todo[$message] = '@@' . $translation . '@@';
758
                    }
759
                }
760
            }
761 7
            $merged = array_merge($merged, $todo);
762 7
            if ($sort) {
763 1
                ksort($merged);
764
            }
765 7
            if (false === $overwrite) {
766
                $fileName .= '.merged';
767
            }
768 7
            $this->stdout("Translation merged.\n");
769
        } else {
770 11
            $merged = [];
771 11
            foreach ($messages as $message) {
772 11
                $merged[$message] = '';
773
            }
774 11
            ksort($merged);
775
        }
776
777 17
        $array = VarDumper::export($merged);
778 17
        $content = <<<EOD
779 17
<?php
780 17
{$this->config['phpFileHeader']}{$this->config['phpDocBlock']}
781 17
return $array;
782
783 17
EOD;
784
785 17
        if (file_put_contents($fileName, $content, LOCK_EX) === false) {
786
            $this->stdout("Translation was NOT saved.\n\n", Console::FG_RED);
787
            return ExitCode::UNSPECIFIED_ERROR;
788
        }
789
790 17
        $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
791 17
        return ExitCode::OK;
792
    }
793
794
    /**
795
     * Writes messages into PO file.
796
     *
797
     * @param array $messages
798
     * @param string $dirName name of the directory to write to
799
     * @param bool $overwrite if existing file should be overwritten without backup
800
     * @param bool $removeUnused if obsolete translations should be removed
801
     * @param bool $sort if translations should be sorted
802
     * @param string $catalog message catalog
803
     * @param bool $markUnused if obsolete translations should be marked
804
     */
805 16
    protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog, $markUnused)
806
    {
807 16
        $file = str_replace('\\', '/', "$dirName/$catalog.po");
808 16
        FileHelper::createDirectory(dirname($file));
809 16
        $this->stdout("Saving messages to $file...\n");
810
811 16
        $poFile = new GettextPoFile();
812
813 16
        $merged = [];
814 16
        $todos = [];
815
816 16
        $hasSomethingToWrite = false;
817 16
        foreach ($messages as $category => $msgs) {
818 16
            $notTranslatedYet = [];
819 16
            $msgs = array_values(array_unique($msgs));
820
821 16
            if (is_file($file)) {
822 10
                $existingMessages = $poFile->load($file, $category);
823
824 10
                sort($msgs);
825 10
                ksort($existingMessages);
826 10
                if (array_keys($existingMessages) == $msgs) {
827 4
                    $this->stdout("Nothing new in \"$category\" category...\n");
828
829 4
                    sort($msgs);
830 4
                    foreach ($msgs as $message) {
831 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
832
                    }
833 4
                    ksort($merged);
834 4
                    continue;
835
                }
836
837
                // merge existing message translations with new message translations
838 7
                foreach ($msgs as $message) {
839 7
                    if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
840 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
841
                    } else {
842 6
                        $notTranslatedYet[] = $message;
843
                    }
844
                }
845 7
                ksort($merged);
846 7
                sort($notTranslatedYet);
847
848
                // collect not yet translated messages
849 7
                foreach ($notTranslatedYet as $message) {
850 6
                    $todos[$category . chr(4) . $message] = '';
851
                }
852
853
                // add obsolete unused messages
854 7
                foreach ($existingMessages as $message => $translation) {
855 7
                    if (!$removeUnused && !isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message])) {
856 3
                        if (!$markUnused || (!empty($translation) && (substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@'))) {
857 2
                            $todos[$category . chr(4) . $message] = $translation;
858
                        } else {
859 1
                            $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
860
                        }
861
                    }
862
                }
863
864 7
                $merged = array_merge($merged, $todos);
865 7
                if ($sort) {
866 1
                    ksort($merged);
867
                }
868
869 7
                if ($overwrite === false) {
870 7
                    $file .= '.merged';
871
                }
872
            } else {
873 10
                sort($msgs);
874 10
                foreach ($msgs as $message) {
875 10
                    $merged[$category . chr(4) . $message] = '';
876
                }
877 10
                ksort($merged);
878
            }
879 16
            $this->stdout("Category \"$category\" merged.\n");
880 16
            $hasSomethingToWrite = true;
881
        }
882 16
        if ($hasSomethingToWrite) {
883 16
            $poFile->save($file, $merged);
884 16
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
885
        } else {
886 3
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
887
        }
888
    }
889
890
    /**
891
     * Writes messages into POT file.
892
     *
893
     * @param array $messages
894
     * @param string $dirName name of the directory to write to
895
     * @param string $catalog message catalog
896
     * @since 2.0.6
897
     */
898
    protected function saveMessagesToPOT($messages, $dirName, $catalog)
899
    {
900
        $file = str_replace('\\', '/', "$dirName/$catalog.pot");
901
        FileHelper::createDirectory(dirname($file));
902
        $this->stdout("Saving messages to $file...\n");
903
904
        $poFile = new GettextPoFile();
905
906
        $merged = [];
907
908
        $hasSomethingToWrite = false;
909
        foreach ($messages as $category => $msgs) {
910
            $msgs = array_values(array_unique($msgs));
911
912
            sort($msgs);
913
            foreach ($msgs as $message) {
914
                $merged[$category . chr(4) . $message] = '';
915
            }
916
            $this->stdout("Category \"$category\" merged.\n");
917
            $hasSomethingToWrite = true;
918
        }
919
        if ($hasSomethingToWrite) {
920
            ksort($merged);
921
            $poFile->save($file, $merged);
922
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
923
        } else {
924
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
925
        }
926
    }
927
928 7
    private function deleteUnusedPhpMessageFiles($existingCategories, $dirName)
929
    {
930 7
        $messageFiles = FileHelper::findFiles($dirName);
931 7
        foreach ($messageFiles as $messageFile) {
932 7
            $categoryFileName = str_replace($dirName, '', $messageFile);
933 7
            $categoryFileName = ltrim($categoryFileName, DIRECTORY_SEPARATOR);
934 7
            $category = preg_replace('#\.php$#', '', $categoryFileName);
935 7
            $category = str_replace(DIRECTORY_SEPARATOR, '/', $category);
936
937 7
            if (!in_array($category, $existingCategories, true)) {
938 3
                unlink($messageFile);
939
            }
940
        }
941
    }
942
943
    /**
944
     * @param string $configFile
945
     * @throws Exception If configuration file does not exists.
946
     * @since 2.0.13
947
     */
948 47
    protected function initConfig($configFile)
949
    {
950 47
        $configFileContent = [];
951 47
        if ($configFile !== null) {
0 ignored issues
show
introduced by
The condition $configFile !== null is always true.
Loading history...
952 47
            $configFile = Yii::getAlias($configFile);
953 47
            if (!is_file($configFile)) {
0 ignored issues
show
Bug introduced by
It seems like $configFile can also be of type false; however, parameter $filename of is_file() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

953
            if (!is_file(/** @scrutinizer ignore-type */ $configFile)) {
Loading history...
954 2
                throw new Exception("The configuration file does not exist: $configFile");
955
            }
956 45
            $configFileContent = require $configFile;
957
        }
958
959 45
        $this->config = array_merge(
960 45
            $this->getOptionValues($this->action->id),
961 45
            $configFileContent,
962 45
            $this->getPassedOptionValues()
963 45
        );
964 45
        $this->config['sourcePath'] = Yii::getAlias($this->config['sourcePath']);
965 45
        $this->config['messagePath'] = Yii::getAlias($this->config['messagePath']);
966
967 45
        if (!isset($this->config['sourcePath'], $this->config['languages'])) {
968
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
969
        }
970 45
        if (!is_dir($this->config['sourcePath'])) {
0 ignored issues
show
Bug introduced by
It seems like $this->config['sourcePath'] can also be of type false; however, parameter $filename of is_dir() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

970
        if (!is_dir(/** @scrutinizer ignore-type */ $this->config['sourcePath'])) {
Loading history...
971
            throw new Exception("The source path {$this->config['sourcePath']} is not a valid directory.");
972
        }
973 45
        if (empty($this->config['format']) || !in_array($this->config['format'], ['php', 'po', 'pot', 'db'])) {
974
            throw new Exception('Format should be either "php", "po", "pot" or "db".');
975
        }
976 45
        if (in_array($this->config['format'], ['php', 'po', 'pot'])) {
977 45
            if (!isset($this->config['messagePath'])) {
978
                throw new Exception('The configuration file must specify "messagePath".');
979
            }
980 45
            if (!is_dir($this->config['messagePath'])) {
981
                throw new Exception("The message path {$this->config['messagePath']} is not a valid directory.");
982
            }
983
        }
984 45
        if (empty($this->config['languages'])) {
985
            throw new Exception('Languages cannot be empty.');
986
        }
987
988 45
        if ($this->config['format'] === 'php' && $this->config['phpDocBlock'] === null) {
989
            $this->config['phpDocBlock'] = <<<DOCBLOCK
990
/**
991
 * Message translations.
992
 *
993
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
994
 * It contains the localizable messages extracted from source code.
995
 * You may modify this file by translating the extracted messages.
996
 *
997
 * Each array element represents the translation (value) of a message (key).
998
 * If the value is empty, the message is considered as not translated.
999
 * Messages that no longer need translation will have their translations
1000
 * enclosed between a pair of '@@' marks.
1001
 *
1002
 * Message string can be used with plural forms format. Check i18n section
1003
 * of the guide for details.
1004
 *
1005
 * NOTE: this file must be saved in UTF-8 encoding.
1006
 */
1007
DOCBLOCK;
1008
        }
1009
    }
1010
}
1011