i18nTextCollector::collectFromCode()   F
last analyzed

Complexity

Conditions 51
Paths 280

Size

Total Lines 215
Code Lines 134

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 51
eloc 134
nc 280
nop 3
dl 0
loc 215
rs 1.8666
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace SilverStripe\i18n\TextCollection;
4
5
use LogicException;
6
use SilverStripe\Core\ClassInfo;
7
use SilverStripe\Core\Injector\Injectable;
8
use SilverStripe\Core\Manifest\ClassLoader;
9
use SilverStripe\Core\Manifest\Module;
10
use SilverStripe\Core\Manifest\ModuleLoader;
11
use SilverStripe\Dev\Debug;
12
use SilverStripe\Control\Director;
13
use ReflectionClass;
14
use SilverStripe\Dev\Deprecation;
15
use SilverStripe\i18n\i18n;
16
use SilverStripe\i18n\i18nEntityProvider;
17
use SilverStripe\i18n\Messages\Reader;
18
use SilverStripe\i18n\Messages\Writer;
19
20
/**
21
 * SilverStripe-variant of the "gettext" tool:
22
 * Parses the string content of all PHP-files and SilverStripe templates
23
 * for ocurrences of the _t() translation method. Also uses the {@link i18nEntityProvider}
24
 * interface to get dynamically defined entities by executing the
25
 * {@link provideI18nEntities()} method on all implementors of this interface.
26
 *
27
 * Collects all found entities (and their natural language text for the default locale)
28
 * into language-files for each module in an array notation. Creates or overwrites these files,
29
 * e.g. framework/lang/en.yml.
30
 *
31
 * The collector needs to be run whenever you make new translatable
32
 * entities available. Please don't alter the arrays in language tables manually.
33
 *
34
 * Usage through URL: http://localhost/dev/tasks/i18nTextCollectorTask
35
 * Usage through URL (module-specific): http://localhost/dev/tasks/i18nTextCollectorTask/?module=mymodule
36
 * Usage on CLI: sake dev/tasks/i18nTextCollectorTask
37
 * Usage on CLI (module-specific): sake dev/tasks/i18nTextCollectorTask module=mymodule
38
 *
39
 * @author Bernat Foj Capell <[email protected]>
40
 * @author Ingo Schommer <[email protected]>
41
 * @uses i18nEntityProvider
42
 * @uses i18n
43
 */
44
class i18nTextCollector
45
{
46
    use Injectable;
47
48
    /**
49
     * Default (master) locale
50
     *
51
     * @var string
52
     */
53
    protected $defaultLocale;
54
55
    /**
56
     * Trigger if warnings should be shown if default is omitted
57
     *
58
     * @var bool
59
     */
60
    protected $warnOnEmptyDefault = false;
61
62
    /**
63
     * The directory base on which the collector should act.
64
     * Usually the webroot set through {@link Director::baseFolder()}.
65
     *
66
     * @todo Fully support changing of basePath through {@link SSViewer} and {@link ManifestBuilder}
67
     *
68
     * @var string
69
     */
70
    public $basePath;
71
72
    /**
73
     * Save path
74
     *
75
     * @var string
76
     */
77
    public $baseSavePath;
78
79
    /**
80
     * @var Writer
81
     */
82
    protected $writer;
83
84
    /**
85
     * Translation reader
86
     *
87
     * @var Reader
88
     */
89
    protected $reader;
90
91
    /**
92
     * List of file extensions to parse
93
     *
94
     * @var array
95
     */
96
    protected $fileExtensions = array('php', 'ss');
97
98
    /**
99
     * @param $locale
100
     */
101
    public function __construct($locale = null)
102
    {
103
        $this->defaultLocale = $locale
104
            ? $locale
105
            : i18n::getData()->langFromLocale(i18n::config()->uninherited('default_locale'));
106
        $this->basePath = Director::baseFolder();
107
        $this->baseSavePath = Director::baseFolder();
108
        $this->setWarnOnEmptyDefault(i18n::config()->uninherited('missing_default_warning'));
109
    }
110
111
    /**
112
     * Assign a writer
113
     *
114
     * @param Writer $writer
115
     * @return $this
116
     */
117
    public function setWriter($writer)
118
    {
119
        $this->writer = $writer;
120
        return $this;
121
    }
122
123
    /**
124
     * Gets the currently assigned writer, or the default if none is specified.
125
     *
126
     * @return Writer
127
     */
128
    public function getWriter()
129
    {
130
        return $this->writer;
131
    }
132
133
    /**
134
     * Get reader
135
     *
136
     * @return Reader
137
     */
138
    public function getReader()
139
    {
140
        return $this->reader;
141
    }
142
143
    /**
144
     * Set reader
145
     *
146
     * @param Reader $reader
147
     * @return $this
148
     */
149
    public function setReader(Reader $reader)
150
    {
151
        $this->reader = $reader;
152
        return $this;
153
    }
154
155
    /**
156
     * This is the main method to build the master string tables with the
157
     * original strings. It will search for existent modules that use the
158
     * i18n feature, parse the _t() calls and write the resultant files
159
     * in the lang folder of each module.
160
     *
161
     * @uses DataObject->collectI18nStatics()
162
     *
163
     * @param array $restrictToModules
164
     * @param bool $mergeWithExisting Merge new master strings with existing
165
     * ones already defined in language files, rather than replacing them.
166
     * This can be useful for long-term maintenance of translations across
167
     * releases, because it allows "translation backports" to older releases
168
     * without removing strings these older releases still rely on.
169
     */
170
    public function run($restrictToModules = null, $mergeWithExisting = false)
171
    {
172
        $entitiesByModule = $this->collect($restrictToModules, $mergeWithExisting);
173
        if (empty($entitiesByModule)) {
174
            return;
175
        }
176
177
        // Write each module language file
178
        foreach ($entitiesByModule as $moduleName => $entities) {
179
            // Skip empty translations
180
            if (empty($entities)) {
181
                continue;
182
            }
183
184
            // Clean sorting prior to writing
185
            ksort($entities);
186
            $module = ModuleLoader::inst()->getManifest()->getModule($moduleName);
187
            $this->write($module, $entities);
188
        }
189
    }
190
191
    /**
192
     * Extract all strings from modules and return these grouped by module name
193
     *
194
     * @param array $restrictToModules
195
     * @param bool $mergeWithExisting
196
     * @return array
197
     */
198
    public function collect($restrictToModules = array(), $mergeWithExisting = false)
199
    {
200
        $entitiesByModule = $this->getEntitiesByModule();
201
202
        // Resolve conflicts between duplicate keys across modules
203
        $entitiesByModule = $this->resolveDuplicateConflicts($entitiesByModule);
204
205
        // Optionally merge with existing master strings
206
        if ($mergeWithExisting) {
207
            $entitiesByModule = $this->mergeWithExisting($entitiesByModule);
208
        }
209
210
        // Restrict modules we update to just the specified ones (if any passed)
211
        if (!empty($restrictToModules)) {
212
            // Normalise module names
213
            $modules = array_filter(array_map(function ($name) {
214
                $module = ModuleLoader::inst()->getManifest()->getModule($name);
215
                return $module ? $module->getName() : null;
0 ignored issues
show
introduced by
$module is of type SilverStripe\Core\Manifest\Module, thus it always evaluated to true.
Loading history...
216
            }, $restrictToModules));
217
            // Remove modules
218
            foreach (array_diff(array_keys($entitiesByModule), $modules) as $module) {
219
                unset($entitiesByModule[$module]);
220
            }
221
        }
222
        return $entitiesByModule;
223
    }
224
225
    /**
226
     * Resolve conflicts between duplicate keys across modules
227
     *
228
     * @param array $entitiesByModule List of all modules with keys
229
     * @return array Filtered listo of modules with duplicate keys unassigned
230
     */
231
    protected function resolveDuplicateConflicts($entitiesByModule)
232
    {
233
        // Find all keys that exist across multiple modules
234
        $conflicts = $this->getConflicts($entitiesByModule);
235
        foreach ($conflicts as $conflict) {
236
            // Determine if we can narrow down the ownership
237
            $bestModule = $this->getBestModuleForKey($entitiesByModule, $conflict);
238
            if (!$bestModule || !isset($entitiesByModule[$bestModule])) {
239
                continue;
240
            }
241
242
            // Remove foreign duplicates
243
            foreach ($entitiesByModule as $module => $entities) {
244
                if ($module !== $bestModule) {
245
                    unset($entitiesByModule[$module][$conflict]);
246
                }
247
            }
248
        }
249
        return $entitiesByModule;
250
    }
251
252
    /**
253
     * Find all keys in the entity list that are duplicated across modules
254
     *
255
     * @param array $entitiesByModule
256
     * @return array List of keys
257
     */
258
    protected function getConflicts($entitiesByModule)
259
    {
260
        $modules = array_keys($entitiesByModule);
261
        $allConflicts = array();
262
        // bubble-compare each group of modules
263
        for ($i = 0; $i < count($modules) - 1; $i++) {
264
            $left = array_keys($entitiesByModule[$modules[$i]]);
265
            for ($j = $i+1; $j < count($modules); $j++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
266
                $right = array_keys($entitiesByModule[$modules[$j]]);
267
                $conflicts = array_intersect($left, $right);
268
                $allConflicts = array_merge($allConflicts, $conflicts);
269
            }
270
        }
271
        return array_unique($allConflicts);
272
    }
273
274
    /**
275
     * Map of translation keys => module names
276
     * @var array
277
     */
278
    protected $classModuleCache = [];
279
280
    /**
281
     * Determine the best module to be given ownership over this key
282
     *
283
     * @param array $entitiesByModule
284
     * @param string $key
285
     * @return string Best module, if found
286
     */
287
    protected function getBestModuleForKey($entitiesByModule, $key)
288
    {
289
        // Check classes
290
        $class = current(explode('.', $key));
291
        if (array_key_exists($class, $this->classModuleCache)) {
292
            return $this->classModuleCache[$class];
293
        }
294
        $owner = $this->findModuleForClass($class);
295
        if ($owner) {
296
            $this->classModuleCache[$class] = $owner;
297
            return $owner;
298
        }
299
300
        // @todo - How to determine ownership of templates? Templates can
301
        // exist in multiple locations with the same name.
302
303
        // Display notice if not found
304
        Debug::message(
305
            "Duplicate key {$key} detected in no / multiple modules with no obvious owner",
306
            false
307
        );
308
309
        // Fall back to framework then cms modules
310
        foreach (array('framework', 'cms') as $module) {
311
            if (isset($entitiesByModule[$module][$key])) {
312
                $this->classModuleCache[$class] = $module;
313
                return $module;
314
            }
315
        }
316
317
        // Do nothing
318
        $this->classModuleCache[$class] = null;
319
        return null;
320
    }
321
322
    /**
323
     * Given a partial class name, attempt to determine the best module to assign strings to.
324
     *
325
     * @param string $class Either a FQN class name, or a non-qualified class name.
326
     * @return string Name of module
327
     */
328
    protected function findModuleForClass($class)
329
    {
330
        if (ClassInfo::exists($class)) {
331
            $module = ClassLoader::inst()
332
                ->getManifest()
333
                ->getOwnerModule($class);
334
            if ($module) {
0 ignored issues
show
introduced by
$module is of type SilverStripe\Core\Manifest\Module, thus it always evaluated to true.
Loading history...
335
                return $module->getName();
336
            }
337
        }
338
339
        // If we can't find a class, see if it needs to be fully qualified
340
        if (strpos($class, '\\') !== false) {
341
            return null;
342
        }
343
344
        // Find FQN that ends with $class
345
        $classes = preg_grep(
346
            '/' . preg_quote("\\{$class}", '\/') . '$/i',
347
            ClassLoader::inst()->getManifest()->getClassNames()
348
        );
349
350
        // Find all modules for candidate classes
351
        $modules = array_unique(array_map(function ($class) {
352
            $module = ClassLoader::inst()->getManifest()->getOwnerModule($class);
353
            return $module ? $module->getName() : null;
0 ignored issues
show
introduced by
$module is of type SilverStripe\Core\Manifest\Module, thus it always evaluated to true.
Loading history...
354
        }, $classes));
355
356
        if (count($modules) === 1) {
357
            return reset($modules);
358
        }
359
360
        // Couldn't find it! Exists in none, or multiple modules.
361
        return null;
362
    }
363
364
    /**
365
     * Merge all entities with existing strings
366
     *
367
     * @param array $entitiesByModule
368
     * @return array
369
     */
370
    protected function mergeWithExisting($entitiesByModule)
371
    {
372
        // For each module do a simple merge of the default yml with these strings
373
        foreach ($entitiesByModule as $module => $messages) {
374
            // Load existing localisations
375
            $masterFile = "{$this->basePath}/{$module}/lang/{$this->defaultLocale}.yml";
376
            $existingMessages = $this->getReader()->read($this->defaultLocale, $masterFile);
377
378
            // Merge
379
            if ($existingMessages) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $existingMessages of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
380
                $entitiesByModule[$module] = array_merge(
381
                    $messages,
382
                    $existingMessages
383
                );
384
            }
385
        }
386
        return $entitiesByModule;
387
    }
388
389
    /**
390
     * Collect all entities grouped by module
391
     *
392
     * @return array
393
     */
394
    protected function getEntitiesByModule()
395
    {
396
        // A master string tables array (one mst per module)
397
        $entitiesByModule = array();
398
        $modules = ModuleLoader::inst()->getManifest()->getModules();
399
        foreach ($modules as $module) {
400
            // we store the master string tables
401
            $processedEntities = $this->processModule($module);
402
            $moduleName = $module->getName();
403
            if (isset($entitiesByModule[$moduleName])) {
404
                $entitiesByModule[$moduleName] = array_merge_recursive(
405
                    $entitiesByModule[$moduleName],
406
                    $processedEntities
407
                );
408
            } else {
409
                $entitiesByModule[$moduleName] = $processedEntities;
410
            }
411
412
            // Extract all entities for "foreign" modules ('module' key in array form)
413
            // @see CMSMenu::provideI18nEntities for an example usage
414
            foreach ($entitiesByModule[$moduleName] as $fullName => $spec) {
415
                $specModuleName = $moduleName;
416
417
                // Rewrite spec if module is specified
418
                if (is_array($spec) && isset($spec['module'])) {
419
                    // Normalise name (in case non-composer name is specified)
420
                    $specModule = ModuleLoader::inst()->getManifest()->getModule($spec['module']);
421
                    if ($specModule) {
422
                        $specModuleName = $specModule->getName();
423
                    }
424
                    unset($spec['module']);
425
426
                    // If only element is defalt, simplify
427
                    if (count($spec) === 1 && !empty($spec['default'])) {
428
                        $spec = $spec['default'];
429
                    }
430
                }
431
432
                // Remove from source module
433
                if ($specModuleName !== $moduleName) {
434
                    unset($entitiesByModule[$moduleName][$fullName]);
435
                }
436
437
                // Write to target module
438
                if (!isset($entitiesByModule[$specModuleName])) {
439
                    $entitiesByModule[$specModuleName] = [];
440
                }
441
                $entitiesByModule[$specModuleName][$fullName] = $spec;
442
            }
443
        }
444
        return $entitiesByModule;
445
    }
446
447
    /**
448
     * Write entities to a module
449
     *
450
     * @param Module $module
451
     * @param array $entities
452
     * @return $this
453
     */
454
    public function write(Module $module, $entities)
455
    {
456
        $this->getWriter()->write(
457
            $entities,
458
            $this->defaultLocale,
459
            $this->baseSavePath . '/' . $module->getRelativePath()
460
        );
461
        return $this;
462
    }
463
464
    /**
465
     * Builds a master string table from php and .ss template files for the module passed as the $module param
466
     * @see collectFromCode() and collectFromTemplate()
467
     *
468
     * @param Module $module Module instance
469
     * @return array An array of entities found in the files that comprise the module
470
     */
471
    protected function processModule(Module $module)
472
    {
473
        $entities = array();
474
475
        // Search for calls in code files if these exists
476
        $fileList = $this->getFileListForModule($module);
477
        foreach ($fileList as $filePath) {
478
            $extension = pathinfo($filePath, PATHINFO_EXTENSION);
479
            $content = file_get_contents($filePath);
480
            // Filter based on extension
481
            if ($extension === 'php') {
482
                $entities = array_merge(
483
                    $entities,
484
                    $this->collectFromCode($content, $filePath, $module),
485
                    $this->collectFromEntityProviders($filePath, $module)
486
                );
487
            } elseif ($extension === 'ss') {
488
                // templates use their filename as a namespace
489
                $entities = array_merge(
490
                    $entities,
491
                    $this->collectFromTemplate($content, $filePath, $module)
492
                );
493
            }
494
        }
495
496
        // sort for easier lookup and comparison with translated files
497
        ksort($entities);
498
499
        return $entities;
500
    }
501
502
    /**
503
     * Retrieves the list of files for this module
504
     *
505
     * @param Module $module Module instance
506
     * @return array List of files to parse
507
     */
508
    protected function getFileListForModule(Module $module)
509
    {
510
        $modulePath = $module->getPath();
511
512
        // Search all .ss files in themes
513
        if (stripos($module->getRelativePath(), 'themes/') === 0) {
514
            return $this->getFilesRecursive($modulePath, null, 'ss');
515
        }
516
517
        // If non-standard module structure, search all root files
518
        if (!is_dir("{$modulePath}/code") && !is_dir("{$modulePath}/src")) {
519
            return $this->getFilesRecursive($modulePath);
520
        }
521
522
        // Get code files
523
        if (is_dir("{$modulePath}/src")) {
524
            $files = $this->getFilesRecursive("{$modulePath}/src", null, 'php');
525
        } else {
526
            $files = $this->getFilesRecursive("{$modulePath}/code", null, 'php');
527
        }
528
529
        // Search for templates in this module
530
        if (is_dir("{$modulePath}/templates")) {
531
            $templateFiles = $this->getFilesRecursive("{$modulePath}/templates", null, 'ss');
532
        } else {
533
            $templateFiles = $this->getFilesRecursive($modulePath, null, 'ss');
534
        }
535
536
        return array_merge($files, $templateFiles);
537
    }
538
539
    /**
540
     * Extracts translatables from .php files.
541
     * Note: Translations without default values are omitted.
542
     *
543
     * @param string $content The text content of a parsed template-file
544
     * @param string $fileName Filename Optional filename
545
     * @param Module $module Module being collected
546
     * @return array Map of localised keys to default values provided for this code
547
     */
548
    public function collectFromCode($content, $fileName, Module $module)
549
    {
550
        // Get namespace either from $fileName or $module fallback
551
        $namespace = $fileName ? basename($fileName) : $module->getName();
552
553
        $entities = array();
554
555
        $tokens = token_get_all("<?php\n" . $content);
556
        $inTransFn = false;
557
        $inConcat = false;
558
        $inNamespace = false;
559
        $inClass = false; // after `class` but before `{`
560
        $inArrayClosedBy = false; // Set to the expected closing token, or false if not in array
561
        $inSelf = false; // Tracks progress of collecting self::class
562
        $currentEntity = array();
563
        $currentClass = []; // Class components
564
        $previousToken = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $previousToken is dead and can be removed.
Loading history...
565
        $thisToken = null; // used to populate $previousToken on next iteration
566
        foreach ($tokens as $token) {
567
            // Shuffle last token to $lastToken
568
            $previousToken = $thisToken;
569
            $thisToken = $token;
570
            if (is_array($token)) {
571
                list($id, $text) = $token;
572
573
                // Check class
574
                if ($id === T_NAMESPACE) {
575
                    $inNamespace = true;
576
                    $currentClass = [];
577
                    continue;
578
                }
579
                if ($inNamespace && $id === T_STRING) {
580
                    $currentClass[] = $text;
581
                    continue;
582
                }
583
584
                // Check class
585
                if ($id === T_CLASS) {
586
                    // Skip if previous token was '::'. E.g. 'Object::class'
587
                    if (is_array($previousToken) && $previousToken[0] === T_DOUBLE_COLON) {
588
                        if ($inSelf) {
589
                            // Handle self::class by allowing logic further down
590
                            // for __CLASS__ to handle an array of class parts
591
                            $id = T_CLASS_C;
592
                            $inSelf = false;
593
                        } else {
594
                            // Don't handle other ::class definitions. We can't determine which
595
                            // class was invoked, so parent::class is not possible at this point.
596
                            continue;
597
                        }
598
                    } else {
599
                        $inClass = true;
600
                        continue;
601
                    }
602
                }
603
                if ($inClass && $id === T_STRING) {
604
                    $currentClass[] = $text;
605
                    $inClass = false;
606
                    continue;
607
                }
608
609
                // Suppress tokenisation within array
610
                if ($inTransFn && !$inArrayClosedBy && $id == T_ARRAY) {
611
                    $inArrayClosedBy = ')'; // Array will close with this element
612
                    continue;
613
                }
614
615
                // Start definition
616
                if ($id == T_STRING && $text == '_t') {
617
                    $inTransFn = true;
618
                    continue;
619
                }
620
621
                // Skip rest of processing unless we are in a translation, and not inside a nested array
622
                if (!$inTransFn || $inArrayClosedBy) {
623
                    continue;
624
                }
625
626
                // If inside this translation, some elements might be unreachable
627
                if (in_array($id, [T_VARIABLE, T_STATIC]) ||
628
                    ($id === T_STRING && in_array($text, ['static', 'parent']))
629
                ) {
630
                    // Un-collectable strings such as _t(static::class.'.KEY').
631
                    // Should be provided by i18nEntityProvider instead
632
                    $inTransFn = false;
633
                    $inArrayClosedBy = false;
634
                    $inConcat = false;
635
                    $currentEntity = array();
636
                    continue;
637
                }
638
639
                // Start collecting self::class declarations
640
                if ($id === T_STRING && $text === 'self') {
641
                    $inSelf = true;
642
                    continue;
643
                }
644
645
                // Check text
646
                if ($id == T_CONSTANT_ENCAPSED_STRING) {
647
                    // Fixed quoting escapes, and remove leading/trailing quotes
648
                    if (preg_match('/^\'(?<text>.*)\'$/s', $text, $matches)) {
649
                        $text = preg_replace_callback(
650
                            '/\\\\([\\\\\'])/s', // only \ and '
651
                            function ($input) {
652
                                return stripcslashes($input[0]);
653
                            },
654
                            $matches['text']
655
                        );
656
                    } elseif (preg_match('/^\"(?<text>.*)\"$/s', $text, $matches)) {
657
                        $text = preg_replace_callback(
658
                            '/\\\\([nrtvf\\\\$"]|[0-7]{1,3}|\x[0-9A-Fa-f]{1,2})/s', // rich replacement
659
                            function ($input) {
660
                                return stripcslashes($input[0]);
661
                            },
662
                            $matches['text']
663
                        );
664
                    } else {
665
                        throw new LogicException("Invalid string escape: " . $text);
666
                    }
667
                } elseif ($id === T_CLASS_C) {
668
                    // Evaluate __CLASS__ . '.KEY' and self::class concatenation
669
                    $text = implode('\\', $currentClass);
670
                } else {
671
                    continue;
672
                }
673
674
                if ($inConcat) {
675
                    // Parser error
676
                    if (empty($currentEntity)) {
677
                        user_error('Error concatenating localisation key', E_USER_WARNING);
678
                    } else {
679
                        $currentEntity[count($currentEntity) - 1] .= $text;
680
                    }
681
                } else {
682
                    $currentEntity[] = $text;
683
                }
684
                continue; // is_array
685
            }
686
687
            // Test we can close this array
688
            if ($inTransFn && $inArrayClosedBy && ($token === $inArrayClosedBy)) {
689
                $inArrayClosedBy = false;
690
                continue;
691
            }
692
693
            // Check if we can close the namespace
694
            if ($inNamespace && $token === ';') {
695
                $inNamespace = false;
696
                continue;
697
            }
698
699
            // Continue only if in translation and not in array
700
            if (!$inTransFn || $inArrayClosedBy) {
701
                continue;
702
            }
703
704
            switch ($token) {
705
                case '.':
706
                    $inConcat = true;
707
                    break;
708
                case ',':
709
                    $inConcat = false;
710
                    break;
711
                case '[':
712
                    // Enter array
713
                    $inArrayClosedBy = ']';
714
                    break;
715
                case ')':
716
                    // finalize definition
717
                    $inTransFn = false;
718
                    $inConcat = false;
719
                    // Ensure key is valid before saving
720
                    if (!empty($currentEntity[0])) {
721
                        $key = $currentEntity[0];
722
                        $default = '';
723
                        $comment = '';
724
                        if (!empty($currentEntity[1])) {
725
                            $default = $currentEntity[1];
726
                            if (!empty($currentEntity[2])) {
727
                                $comment = $currentEntity[2];
728
                            }
729
                        }
730
                        // Save in appropriate format
731
                        if ($default) {
732
                            $plurals = i18n::parse_plurals($default);
733
                            // Use array form if either plural or metadata is provided
734
                            if ($plurals) {
735
                                $entity = $plurals;
736
                            } elseif ($comment) {
737
                                $entity = ['default' => $default];
738
                            } else {
739
                                $entity = $default;
740
                            }
741
                            if ($comment) {
742
                                $entity['comment'] = $comment;
743
                            }
744
                            $entities[$key] = $entity;
745
                        } elseif ($this->getWarnOnEmptyDefault()) {
746
                            trigger_error("Missing localisation default for key " . $currentEntity[0], E_USER_NOTICE);
747
                        }
748
                    }
749
                    $currentEntity = array();
750
                    $inArrayClosedBy = false;
751
                    break;
752
            }
753
        }
754
755
        // Normalise all keys
756
        foreach ($entities as $key => $entity) {
757
            unset($entities[$key]);
758
            $entities[$this->normalizeEntity($key, $namespace)] = $entity;
759
        }
760
        ksort($entities);
761
762
        return $entities;
763
    }
764
765
    /**
766
     * Extracts translatables from .ss templates (Self referencing)
767
     *
768
     * @param string $content The text content of a parsed template-file
769
     * @param string $fileName The name of a template file when method is used in self-referencing mode
770
     * @param Module $module Module being collected
771
     * @param array $parsedFiles
772
     * @return array $entities An array of entities representing the extracted template function calls
773
     */
774
    public function collectFromTemplate($content, $fileName, Module $module, &$parsedFiles = array())
775
    {
776
        // Get namespace either from $fileName or $module fallback
777
        $namespace = $fileName ? basename($fileName) : $module->getName();
778
779
        // use parser to extract <%t style translatable entities
780
        $entities = Parser::getTranslatables($content, $this->getWarnOnEmptyDefault());
781
782
        // use the old method of getting _t() style translatable entities
783
        // Collect in actual template
784
        if (preg_match_all('/(_t\([^\)]*?\))/ms', $content, $matches)) {
785
            foreach ($matches[1] as $match) {
786
                $entities = array_merge($entities, $this->collectFromCode($match, $fileName, $module));
787
            }
788
        }
789
790
        foreach ($entities as $entity => $spec) {
791
            unset($entities[$entity]);
792
            $entities[$this->normalizeEntity($entity, $namespace)] = $spec;
793
        }
794
        ksort($entities);
795
796
        return $entities;
797
    }
798
799
    /**
800
     * Allows classes which implement i18nEntityProvider to provide
801
     * additional translation strings.
802
     *
803
     * Not all classes can be instanciated without mandatory arguments,
804
     * so entity collection doesn't work for all SilverStripe classes currently
805
     *
806
     * @uses i18nEntityProvider
807
     * @param string $filePath
808
     * @param Module $module
809
     * @return array
810
     */
811
    public function collectFromEntityProviders($filePath, Module $module = null)
812
    {
813
        $entities = array();
814
        $classes = ClassInfo::classes_for_file($filePath);
815
        foreach ($classes as $class) {
816
            // Skip non-implementing classes
817
            if (!class_exists($class) || !is_a($class, i18nEntityProvider::class, true)) {
818
                continue;
819
            }
820
821
            // Skip abstract classes
822
            $reflectionClass = new ReflectionClass($class);
823
            if ($reflectionClass->isAbstract()) {
824
                continue;
825
            }
826
827
            /** @var i18nEntityProvider $obj */
828
            $obj = singleton($class);
829
            $provided = $obj->provideI18nEntities();
830
            // Handle deprecated return syntax
831
            foreach ($provided as $key => $value) {
832
                // Detect non-associative result for any key
833
                if (is_array($value) && $value === array_values($value)) {
834
                    Deprecation::notice('5.0', 'Non-associative translations from providei18nEntities is deprecated');
835
                    $entity = array_filter([
836
                        'default' => $value[0],
837
                        'comment' => isset($value[1]) ? $value[1] : null,
838
                        'module' => isset($value[2]) ? $value[2] : null,
839
                    ]);
840
                    if (count($entity) === 1) {
841
                        $provided[$key] = $value[0];
842
                    } elseif ($entity) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $entity of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
843
                        $provided[$key] = $entity;
844
                    } else {
845
                        unset($provided[$key]);
846
                    }
847
                }
848
            }
849
            $entities = array_merge($entities, $provided);
850
        }
851
852
        ksort($entities);
853
        return $entities;
854
    }
855
856
    /**
857
     * Normalizes enitities with namespaces.
858
     *
859
     * @param string $fullName
860
     * @param string $_namespace
861
     * @return string|boolean FALSE
862
     */
863
    protected function normalizeEntity($fullName, $_namespace = null)
864
    {
865
        // split fullname into entity parts
866
        $entityParts = explode('.', $fullName);
867
        if (count($entityParts) > 1) {
868
            // templates don't have a custom namespace
869
            $entity = array_pop($entityParts);
870
            // namespace might contain dots, so we explode
871
            $namespace = implode('.', $entityParts);
872
        } else {
873
            $entity = array_pop($entityParts);
874
            $namespace = $_namespace;
875
        }
876
877
        // If a dollar sign is used in the entity name,
878
        // we can't resolve without running the method,
879
        // and skip the processing. This is mostly used for
880
        // dynamically translating static properties, e.g. looping
881
        // through $db, which are detected by {@link collectFromEntityProviders}.
882
        if ($entity && strpos('$', $entity) !== false) {
883
            return false;
884
        }
885
886
        return "{$namespace}.{$entity}";
887
    }
888
889
890
891
    /**
892
     * Helper function that searches for potential files (templates and code) to be parsed
893
     *
894
     * @param string $folder base directory to scan (will scan recursively)
895
     * @param array $fileList Array to which potential files will be appended
896
     * @param string $type Optional, "php" or "ss" only
897
     * @param string $folderExclude Regular expression matching folder names to exclude
898
     * @return array $fileList An array of files
899
     */
900
    protected function getFilesRecursive($folder, $fileList = array(), $type = null, $folderExclude = '/\/(tests)$/')
901
    {
902
        if (!$fileList) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fileList of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
903
            $fileList = array();
904
        }
905
        // Skip ignored folders
906
        if (is_file("{$folder}/_manifest_exclude") || preg_match($folderExclude, $folder)) {
907
            return $fileList;
908
        }
909
910
        foreach (glob($folder . '/*') as $path) {
911
            // Recurse if directory
912
            if (is_dir($path)) {
913
                $fileList = array_merge(
914
                    $fileList,
915
                    $this->getFilesRecursive($path, $fileList, $type, $folderExclude)
916
                );
917
                continue;
918
            }
919
920
            // Check if this extension is included
921
            $extension = pathinfo($path, PATHINFO_EXTENSION);
922
            if (in_array($extension, $this->fileExtensions)
923
                && (!$type || $type === $extension)
924
            ) {
925
                $fileList[$path] = $path;
926
            }
927
        }
928
        return $fileList;
929
    }
930
931
    public function getDefaultLocale()
932
    {
933
        return $this->defaultLocale;
934
    }
935
936
    public function setDefaultLocale($locale)
937
    {
938
        $this->defaultLocale = $locale;
939
    }
940
941
    /**
942
     * @return bool
943
     */
944
    public function getWarnOnEmptyDefault()
945
    {
946
        return $this->warnOnEmptyDefault;
947
    }
948
949
    /**
950
     * @param bool $warnOnEmptyDefault
951
     * @return $this
952
     */
953
    public function setWarnOnEmptyDefault($warnOnEmptyDefault)
954
    {
955
        $this->warnOnEmptyDefault = $warnOnEmptyDefault;
956
        return $this;
957
    }
958
}
959