Completed
Push — master ( a3501e...fddb34 )
by Carsten
10:29
created

MessageController   F

Complexity

Total Complexity 138

Size/Duplication

Total Lines 861
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 13

Test Coverage

Coverage 80.62%

Importance

Changes 0
Metric Value
wmc 138
lcom 1
cbo 13
dl 0
loc 861
ccs 287
cts 356
cp 0.8062
rs 2.6666
c 0
b 0
f 0

15 Methods

Rating   Name   Duplication   Size   Complexity  
A actionConfigTemplate() 0 18 4
A options() 0 21 1
B actionConfig() 0 34 4
A optionAliases() 0 19 1
D actionExtract() 0 79 22
F saveMessagesToDb() 0 110 25
A extractMessages() 0 19 2
C extractMessagesFromTokens() 0 71 17
B isCategoryIgnored() 0 15 6
B tokensEqual() 0 10 5
A getLine() 0 9 3
A saveMessagesToPHP() 0 12 2
D saveMessagesCategoryToPHP() 0 85 21
D saveMessagesToPO() 0 84 21
B saveMessagesToPOT() 0 29 4

How to fix   Complexity   

Complex Class

Complex classes like MessageController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MessageController, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license http://www.yiiframework.com/license/
6
 */
7
8
namespace yii\console\controllers;
9
10
use Yii;
11
use yii\console\Exception;
12
use yii\db\Connection;
13
use yii\db\Query;
14
use yii\di\Instance;
15
use yii\helpers\Console;
16
use yii\helpers\FileHelper;
17
use yii\helpers\VarDumper;
18
use yii\i18n\GettextPoFile;
19
20
/**
21
 * Extracts messages to be translated from source files.
22
 *
23
 * The extracted messages can be saved the following depending on `format`
24
 * setting in config file:
25
 *
26
 * - PHP message source files.
27
 * - ".po" files.
28
 * - Database.
29
 *
30
 * Usage:
31
 * 1. Create a configuration file using the 'message/config' command:
32
 *    yii message/config /path/to/myapp/messages/config.php
33
 * 2. Edit the created config file, adjusting it for your web application needs.
34
 * 3. Run the 'message/extract' command, using created config:
35
 *    yii message /path/to/myapp/messages/config.php
36
 *
37
 * @author Qiang Xue <[email protected]>
38
 * @since 2.0
39
 */
40
class MessageController extends \yii\console\Controller
41
{
42
    /**
43
     * @var string controller default action ID.
44
     */
45
    public $defaultAction = 'extract';
46
    /**
47
     * @var string required, root directory of all source files.
48
     */
49
    public $sourcePath = '@yii';
50
    /**
51
     * @var string required, root directory containing message translations.
52
     */
53
    public $messagePath = '@yii/messages';
54
    /**
55
     * @var array required, list of language codes that the extracted messages
56
     * should be translated to. For example, ['zh-CN', 'de'].
57
     */
58
    public $languages = [];
59
    /**
60
     * @var string the name of the function for translating messages.
61
     * Defaults to 'Yii::t'. This is used as a mark to find the messages to be
62
     * translated. You may use a string for single function name or an array for
63
     * multiple function names.
64
     */
65
    public $translator = '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 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
        '.svn',
94
        '.git',
95
        '.gitignore',
96
        '.gitkeep',
97
        '.hgignore',
98
        '.hgkeep',
99
        '/messages',
100
        '/BaseYii.php', // contains examples about Yii:t()
101
    ];
102
    /**
103
     * @var array list of patterns that specify which files (not directories) should be processed.
104
     * If empty or not set, all files will be processed.
105
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
106
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
107
     */
108
    public $only = ['*.php'];
109
    /**
110
     * @var string generated file format. Can be "php", "db", "po" or "pot".
111
     */
112
    public $format = 'php';
113
    /**
114
     * @var string connection component ID for "db" format.
115
     */
116
    public $db = 'db';
117
    /**
118
     * @var string custom name for source message table for "db" format.
119
     */
120
    public $sourceMessageTable = '{{%source_message}}';
121
    /**
122
     * @var string custom name for translation message table for "db" format.
123
     */
124
    public $messageTable = '{{%message}}';
125
    /**
126
     * @var string name of the file that will be used for translations for "po" format.
127
     */
128
    public $catalog = 'messages';
129
    /**
130
     * @var array message categories to ignore. For example, 'yii', 'app*', 'widgets/menu', etc.
131
     * @see isCategoryIgnored
132
     */
133
    public $ignoreCategories = [];
134
135
136
    /**
137
     * @inheritdoc
138
     */
139 35
    public function options($actionID)
140
    {
141 35
        return array_merge(parent::options($actionID), [
142 35
            'sourcePath',
143
            'messagePath',
144
            'languages',
145
            'translator',
146
            'sort',
147
            'overwrite',
148
            'removeUnused',
149
            'markUnused',
150
            'except',
151
            'only',
152
            'format',
153
            'db',
154
            'sourceMessageTable',
155
            'messageTable',
156
            'catalog',
157
            'ignoreCategories',
158
        ]);
159
    }
160
161
    /**
162
     * @inheritdoc
163
     * @since 2.0.8
164
     */
165
    public function optionAliases()
166
    {
167
        return array_merge(parent::optionAliases(), [
168
            'c' => 'catalog',
169
            'e' => 'except',
170
            'f' => 'format',
171
            'i' => 'ignoreCategories',
172
            'l' => 'languages',
173
            'u' => 'markUnused',
174
            'p' => 'messagePath',
175
            'o' => 'only',
176
            'w' => 'overwrite',
177
            'S' => 'sort',
178
            't' => 'translator',
179
            'm' => 'sourceMessageTable',
180
            's' => 'sourcePath',
181
            'r' => 'removeUnused',
182
        ]);
183
    }
184
185
    /**
186
     * Creates a configuration file for the "extract" command using command line options specified
187
     *
188
     * The generated configuration file contains parameters required
189
     * for source code messages extraction.
190
     * You may use this configuration file with the "extract" command.
191
     *
192
     * @param string $filePath output file name or alias.
193
     * @return int CLI exit code
194
     * @throws Exception on failure.
195
     */
196 3
    public function actionConfig($filePath)
197
    {
198 3
        $filePath = Yii::getAlias($filePath);
199 3
        if (file_exists($filePath)) {
200
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->confirm("File '{$...wish to overwrite it?") of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
201
                return self::EXIT_CODE_NORMAL;
202
            }
203
        }
204
205 3
        $array = VarDumper::export($this->getOptionValues($this->action->id));
206
        $content = <<<EOD
207
<?php
208
/**
209 3
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
210
 *
211 3
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
212
 * It contains parameters for source code messages extraction.
213
 * You may modify this file to suit your needs.
214
 *
215 3
 * You can use 'yii {$this->id}/{$this->action->id}-template' command to create
216
 * template configuration file with detailed description for each parameter.
217
 */
218 3
return $array;
219
220
EOD;
221
222 3
        if (file_put_contents($filePath, $content) === false) {
223
            $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED);
224
            return self::EXIT_CODE_ERROR;
225
        }
226
227 3
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
228 3
        return self::EXIT_CODE_NORMAL;
229
    }
230
231
    /**
232
     * Creates a configuration file template for the "extract" command.
233
     *
234
     * The created configuration file contains detailed instructions on
235
     * how to customize it to fit for your needs. After customization,
236
     * you may use this configuration file with the "extract" command.
237
     *
238
     * @param string $filePath output file name or alias.
239
     * @return int CLI exit code
240
     * @throws Exception on failure.
241
     */
242
    public function actionConfigTemplate($filePath)
243
    {
244
        $filePath = Yii::getAlias($filePath);
245
246
        if (file_exists($filePath)) {
247
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->confirm("File '{$...wish to overwrite it?") of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

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