Completed
Push — master ( 1efa22...cfa6a3 )
by Sam
22s
created

i18nTextCollector   F

Complexity

Total Complexity 139

Size/Duplication

Total Lines 879
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 17

Importance

Changes 0
Metric Value
dl 0
loc 879
rs 1.263
c 0
b 0
f 0
wmc 139
lcom 1
cbo 17

25 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 9 2
A setWriter() 0 5 1
A getWriter() 0 4 1
A getReader() 0 4 1
A setReader() 0 5 1
A run() 0 20 4
B collect() 0 26 5
B resolveDuplicateConflicts() 0 20 6
A getConflicts() 0 15 3
B getBestModuleForKey() 0 34 5
B findModuleForClass() 0 35 6
A mergeWithExisting() 0 18 3
C getEntitiesByModule() 0 52 11
A write() 0 9 1
B processModule() 0 30 4
B getFileListForModule() 0 30 6
F collectFromCode() 0 180 45
B collectFromTemplate() 0 24 5
C collectFromEntityProviders() 0 44 12
B normalizeEntity() 0 25 4
D getFilesRecursive() 0 30 9
A getDefaultLocale() 0 4 1
A setDefaultLocale() 0 4 1
A getWarnOnEmptyDefault() 0 4 1
A setWarnOnEmptyDefault() 0 5 1

How to fix   Complexity   

Complex Class

Complex classes like i18nTextCollector 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 i18nTextCollector, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\i18n\TextCollection;
4
5
use SilverStripe\Core\ClassInfo;
6
use SilverStripe\Core\Injector\Injectable;
7
use SilverStripe\Core\Manifest\ClassLoader;
8
use SilverStripe\Core\Manifest\Module;
9
use SilverStripe\Core\Manifest\ModuleLoader;
10
use SilverStripe\Dev\Debug;
11
use SilverStripe\Control\Director;
12
use ReflectionClass;
13
use SilverStripe\Dev\Deprecation;
14
use SilverStripe\i18n\i18n;
15
use SilverStripe\i18n\i18nEntityProvider;
16
use SilverStripe\i18n\Messages\Reader;
17
use SilverStripe\i18n\Messages\Writer;
18
19
/**
20
 * SilverStripe-variant of the "gettext" tool:
21
 * Parses the string content of all PHP-files and SilverStripe templates
22
 * for ocurrences of the _t() translation method. Also uses the {@link i18nEntityProvider}
23
 * interface to get dynamically defined entities by executing the
24
 * {@link provideI18nEntities()} method on all implementors of this interface.
25
 *
26
 * Collects all found entities (and their natural language text for the default locale)
27
 * into language-files for each module in an array notation. Creates or overwrites these files,
28
 * e.g. framework/lang/en.yml.
29
 *
30
 * The collector needs to be run whenever you make new translatable
31
 * entities available. Please don't alter the arrays in language tables manually.
32
 *
33
 * Usage through URL: http://localhost/dev/tasks/i18nTextCollectorTask
34
 * Usage through URL (module-specific): http://localhost/dev/tasks/i18nTextCollectorTask/?module=mymodule
35
 * Usage on CLI: sake dev/tasks/i18nTextCollectorTask
36
 * Usage on CLI (module-specific): sake dev/tasks/i18nTextCollectorTask module=mymodule
37
 *
38
 * @author Bernat Foj Capell <[email protected]>
39
 * @author Ingo Schommer <[email protected]>
40
 * @uses i18nEntityProvider
41
 * @uses i18n
42
 */
43
class i18nTextCollector
44
{
45
    use Injectable;
46
47
    /**
48
     * Default (master) locale
49
     *
50
     * @var string
51
     */
52
    protected $defaultLocale;
53
54
    /**
55
     * Trigger if warnings should be shown if default is omitted
56
     *
57
     * @var bool
58
     */
59
    protected $warnOnEmptyDefault = false;
60
61
    /**
62
     * The directory base on which the collector should act.
63
     * Usually the webroot set through {@link Director::baseFolder()}.
64
     *
65
     * @todo Fully support changing of basePath through {@link SSViewer} and {@link ManifestBuilder}
66
     *
67
     * @var string
68
     */
69
    public $basePath;
70
71
    /**
72
     * Save path
73
     *
74
     * @var string
75
     */
76
    public $baseSavePath;
77
78
    /**
79
     * @var Writer
80
     */
81
    protected $writer;
82
83
    /**
84
     * Translation reader
85
     *
86
     * @var Reader
87
     */
88
    protected $reader;
89
90
    /**
91
     * List of file extensions to parse
92
     *
93
     * @var array
94
     */
95
    protected $fileExtensions = array('php', 'ss');
96
97
    /**
98
     * @param $locale
99
     */
100
    public function __construct($locale = null)
101
    {
102
        $this->defaultLocale = $locale
103
            ? $locale
104
            : i18n::getData()->langFromLocale(i18n::config()->uninherited('default_locale'));
105
        $this->basePath = Director::baseFolder();
106
        $this->baseSavePath = Director::baseFolder();
107
        $this->setWarnOnEmptyDefault(i18n::config()->uninherited('missing_default_warning'));
108
    }
109
110
    /**
111
     * Assign a writer
112
     *
113
     * @param Writer $writer
114
     * @return $this
115
     */
116
    public function setWriter($writer)
117
    {
118
        $this->writer = $writer;
119
        return $this;
120
    }
121
122
    /**
123
     * Gets the currently assigned writer, or the default if none is specified.
124
     *
125
     * @return Writer
126
     */
127
    public function getWriter()
128
    {
129
        return $this->writer;
130
    }
131
132
    /**
133
     * Get reader
134
     *
135
     * @return Reader
136
     */
137
    public function getReader()
138
    {
139
        return $this->reader;
140
    }
141
142
    /**
143
     * Set reader
144
     *
145
     * @param Reader $reader
146
     * @return $this
147
     */
148
    public function setReader(Reader $reader)
149
    {
150
        $this->reader = $reader;
151
        return $this;
152
    }
153
154
    /**
155
     * This is the main method to build the master string tables with the
156
     * original strings. It will search for existent modules that use the
157
     * i18n feature, parse the _t() calls and write the resultant files
158
     * in the lang folder of each module.
159
     *
160
     * @uses DataObject->collectI18nStatics()
161
     *
162
     * @param array $restrictToModules
163
     * @param bool $mergeWithExisting Merge new master strings with existing
164
     * ones already defined in language files, rather than replacing them.
165
     * This can be useful for long-term maintenance of translations across
166
     * releases, because it allows "translation backports" to older releases
167
     * without removing strings these older releases still rely on.
168
     */
169
    public function run($restrictToModules = null, $mergeWithExisting = false)
170
    {
171
        $entitiesByModule = $this->collect($restrictToModules, $mergeWithExisting);
0 ignored issues
show
Bug introduced by
It seems like $restrictToModules defined by parameter $restrictToModules on line 169 can also be of type null; however, SilverStripe\i18n\TextCo...extCollector::collect() does only seem to accept array, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
172
        if (empty($entitiesByModule)) {
173
            return;
174
        }
175
176
        // Write each module language file
177
        foreach ($entitiesByModule as $moduleName => $entities) {
178
            // Skip empty translations
179
            if (empty($entities)) {
180
                continue;
181
            }
182
183
            // Clean sorting prior to writing
184
            ksort($entities);
185
            $module = ModuleLoader::instance()->getManifest()->getModule($moduleName);
186
            $this->write($module, $entities);
0 ignored issues
show
Bug introduced by
It seems like $module defined by \SilverStripe\Core\Manif...>getModule($moduleName) on line 185 can be null; however, SilverStripe\i18n\TextCo...nTextCollector::write() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
187
        }
188
    }
189
190
    /**
191
     * Extract all strings from modules and return these grouped by module name
192
     *
193
     * @param array $restrictToModules
194
     * @param bool $mergeWithExisting
195
     * @return array
196
     */
197
    public function collect($restrictToModules = array(), $mergeWithExisting = false)
198
    {
199
        $entitiesByModule = $this->getEntitiesByModule();
200
201
        // Resolve conflicts between duplicate keys across modules
202
        $entitiesByModule = $this->resolveDuplicateConflicts($entitiesByModule);
203
204
        // Optionally merge with existing master strings
205
        if ($mergeWithExisting) {
206
            $entitiesByModule = $this->mergeWithExisting($entitiesByModule);
207
        }
208
209
        // Restrict modules we update to just the specified ones (if any passed)
210
        if (!empty($restrictToModules)) {
211
            // Normalise module names
212
            $modules = array_filter(array_map(function ($name) {
213
                $module = ModuleLoader::instance()->getManifest()->getModule($name);
214
                return $module ? $module->getName() : null;
215
            }, $restrictToModules));
216
            // Remove modules
217
            foreach (array_diff(array_keys($entitiesByModule), $modules) as $module) {
218
                unset($entitiesByModule[$module]);
219
            }
220
        }
221
        return $entitiesByModule;
222
    }
223
224
    /**
225
     * Resolve conflicts between duplicate keys across modules
226
     *
227
     * @param array $entitiesByModule List of all modules with keys
228
     * @return array Filtered listo of modules with duplicate keys unassigned
229
     */
230
    protected function resolveDuplicateConflicts($entitiesByModule)
231
    {
232
        // Find all keys that exist across multiple modules
233
        $conflicts = $this->getConflicts($entitiesByModule);
234
        foreach ($conflicts as $conflict) {
235
            // Determine if we can narrow down the ownership
236
            $bestModule = $this->getBestModuleForKey($entitiesByModule, $conflict);
237
            if (!$bestModule || !isset($entitiesByModule[$bestModule])) {
238
                continue;
239
            }
240
241
            // Remove foreign duplicates
242
            foreach ($entitiesByModule as $module => $entities) {
243
                if ($module !== $bestModule) {
244
                    unset($entitiesByModule[$module][$conflict]);
245
                }
246
            }
247
        }
248
        return $entitiesByModule;
249
    }
250
251
    /**
252
     * Find all keys in the entity list that are duplicated across modules
253
     *
254
     * @param array $entitiesByModule
255
     * @return array List of keys
256
     */
257
    protected function getConflicts($entitiesByModule)
258
    {
259
        $modules = array_keys($entitiesByModule);
260
        $allConflicts = array();
261
        // bubble-compare each group of modules
262
        for ($i = 0; $i < count($modules) - 1; $i++) {
263
            $left = array_keys($entitiesByModule[$modules[$i]]);
264
            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...
265
                $right = array_keys($entitiesByModule[$modules[$j]]);
266
                $conflicts = array_intersect($left, $right);
267
                $allConflicts = array_merge($allConflicts, $conflicts);
268
            }
269
        }
270
        return array_unique($allConflicts);
271
    }
272
273
    /**
274
     * Map of translation keys => module names
275
     * @var array
276
     */
277
    protected $classModuleCache = [];
278
279
    /**
280
     * Determine the best module to be given ownership over this key
281
     *
282
     * @param array $entitiesByModule
283
     * @param string $key
284
     * @return string Best module, if found
285
     */
286
    protected function getBestModuleForKey($entitiesByModule, $key)
287
    {
288
        // Check classes
289
        $class = current(explode('.', $key));
290
        if (array_key_exists($class, $this->classModuleCache)) {
291
            return $this->classModuleCache[$class];
292
        }
293
        $owner = $this->findModuleForClass($class);
294
        if ($owner) {
295
            $this->classModuleCache[$class] = $owner;
296
            return $owner;
297
        }
298
299
        // @todo - How to determine ownership of templates? Templates can
300
        // exist in multiple locations with the same name.
301
302
        // Display notice if not found
303
        Debug::message(
304
            "Duplicate key {$key} detected in no / multiple modules with no obvious owner",
305
            false
306
        );
307
308
        // Fall back to framework then cms modules
309
        foreach (array('framework', 'cms') as $module) {
310
            if (isset($entitiesByModule[$module][$key])) {
311
                $this->classModuleCache[$class] = $module;
312
                return $module;
313
            }
314
        }
315
316
        // Do nothing
317
        $this->classModuleCache[$class] = null;
318
        return null;
319
    }
320
321
    /**
322
     * Given a partial class name, attempt to determine the best module to assign strings to.
323
     *
324
     * @param string $class Either a FQN class name, or a non-qualified class name.
325
     * @return string Name of module
326
     */
327
    protected function findModuleForClass($class)
328
    {
329
        if (ClassInfo::exists($class)) {
330
            $module = ClassLoader::instance()
331
                ->getManifest()
332
                ->getOwnerModule($class);
333
            if ($module) {
334
                return $module->getName();
335
            }
336
        }
337
338
        // If we can't find a class, see if it needs to be fully qualified
339
        if (strpos($class, '\\') !== false) {
340
            return null;
341
        }
342
343
        // Find FQN that ends with $class
344
        $classes = preg_grep(
345
            '/'.preg_quote("\\{$class}", '\/').'$/i',
346
            ClassLoader::instance()->getManifest()->getClassNames()
347
        );
348
349
        // Find all modules for candidate classes
350
        $modules = array_unique(array_map(function ($class) {
351
            $module = ClassLoader::instance()->getManifest()->getOwnerModule($class);
352
            return $module ? $module->getName() : null;
353
        }, $classes));
354
355
        if (count($modules) === 1) {
356
            return reset($modules);
357
        }
358
359
        // Couldn't find it! Exists in none, or multiple modules.
360
        return null;
361
    }
362
363
    /**
364
     * Merge all entities with existing strings
365
     *
366
     * @param array $entitiesByModule
367
     * @return array
368
     */
369
    protected function mergeWithExisting($entitiesByModule)
370
    {
371
        // For each module do a simple merge of the default yml with these strings
372
        foreach ($entitiesByModule as $module => $messages) {
373
            // Load existing localisations
374
            $masterFile = "{$this->basePath}/{$module}/lang/{$this->defaultLocale}.yml";
375
            $existingMessages = $this->getReader()->read($this->defaultLocale, $masterFile);
376
377
            // Merge
378
            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...
379
                $entitiesByModule[$module] = array_merge(
380
                    $existingMessages,
381
                    $messages
382
                );
383
            }
384
        }
385
        return $entitiesByModule;
386
    }
387
388
    /**
389
     * Collect all entities grouped by module
390
     *
391
     * @return array
392
     */
393
    protected function getEntitiesByModule()
394
    {
395
        // A master string tables array (one mst per module)
396
        $entitiesByModule = array();
397
        $modules = ModuleLoader::instance()->getManifest()->getModules();
398
        foreach ($modules as $module) {
399
            // we store the master string tables
400
            $processedEntities = $this->processModule($module);
401
            $moduleName = $module->getName();
402
            if (isset($entitiesByModule[$moduleName])) {
403
                $entitiesByModule[$moduleName] = array_merge_recursive(
404
                    $entitiesByModule[$moduleName],
405
                    $processedEntities
406
                );
407
            } else {
408
                $entitiesByModule[$moduleName] = $processedEntities;
409
            }
410
411
            // Extract all entities for "foreign" modules ('module' key in array form)
412
            // @see CMSMenu::provideI18nEntities for an example usage
413
            foreach ($entitiesByModule[$moduleName] as $fullName => $spec) {
414
                $specModuleName = $moduleName;
415
416
                // Rewrite spec if module is specified
417
                if (is_array($spec) && isset($spec['module'])) {
418
                    // Normalise name (in case non-composer name is specified)
419
                    $specModule = ModuleLoader::instance()->getManifest()->getModule($spec['module']);
420
                    if ($specModule) {
421
                        $specModuleName = $specModule->getName();
422
                    }
423
                    unset($spec['module']);
424
425
                    // If only element is defalt, simplify
426
                    if (count($spec) === 1 && !empty($spec['default'])) {
427
                        $spec = $spec['default'];
428
                    }
429
                }
430
431
                // Remove from source module
432
                if ($specModuleName !== $moduleName) {
433
                    unset($entitiesByModule[$moduleName][$fullName]);
434
                }
435
436
                // Write to target module
437
                if (!isset($entitiesByModule[$specModuleName])) {
438
                    $entitiesByModule[$specModuleName] = [];
439
                }
440
                $entitiesByModule[$specModuleName][$fullName] = $spec;
441
            }
442
        }
443
        return $entitiesByModule;
444
    }
445
446
    /**
447
     * Write entities to a module
448
     *
449
     * @param Module $module
450
     * @param array $entities
451
     * @return $this
452
     */
453
    public function write(Module $module, $entities)
454
    {
455
        $this->getWriter()->write(
456
            $entities,
457
            $this->defaultLocale,
458
            $this->baseSavePath . '/' . $module->getRelativePath()
459
        );
460
        return $this;
461
    }
462
463
    /**
464
     * Builds a master string table from php and .ss template files for the module passed as the $module param
465
     * @see collectFromCode() and collectFromTemplate()
466
     *
467
     * @param Module $module Module instance
468
     * @return array An array of entities found in the files that comprise the module
469
     */
470
    protected function processModule(Module $module)
471
    {
472
        $entities = array();
473
474
        // Search for calls in code files if these exists
475
        $fileList = $this->getFileListForModule($module);
476
        foreach ($fileList as $filePath) {
477
            $extension = pathinfo($filePath, PATHINFO_EXTENSION);
478
            $content = file_get_contents($filePath);
479
            // Filter based on extension
480
            if ($extension === 'php') {
481
                $entities = array_merge(
482
                    $entities,
483
                    $this->collectFromCode($content, $filePath, $module),
484
                    $this->collectFromEntityProviders($filePath, $module)
485
                );
486
            } elseif ($extension === 'ss') {
487
                // templates use their filename as a namespace
488
                $entities = array_merge(
489
                    $entities,
490
                    $this->collectFromTemplate($content, $filePath, $module)
491
                );
492
            }
493
        }
494
495
        // sort for easier lookup and comparison with translated files
496
        ksort($entities);
497
498
        return $entities;
499
    }
500
501
    /**
502
     * Retrieves the list of files for this module
503
     *
504
     * @param Module $module Module instance
505
     * @return array List of files to parse
506
     */
507
    protected function getFileListForModule(Module $module)
508
    {
509
        $modulePath = $module->getPath();
510
511
        // Search all .ss files in themes
512
        if (stripos($module->getRelativePath(), 'themes/') === 0) {
513
            return $this->getFilesRecursive($modulePath, null, 'ss');
514
        }
515
516
        // If non-standard module structure, search all root files
517
        if (!is_dir("{$modulePath}/code") && !is_dir("{$modulePath}/src")) {
518
            return $this->getFilesRecursive($modulePath);
519
        }
520
521
        // Get code files
522
        if (is_dir("{$modulePath}/src")) {
523
            $files = $this->getFilesRecursive("{$modulePath}/src", null, 'php');
524
        } else {
525
            $files = $this->getFilesRecursive("{$modulePath}/code", null, 'php');
526
        }
527
528
        // Search for templates in this module
529
        if (is_dir("{$modulePath}/templates")) {
530
            $templateFiles = $this->getFilesRecursive("{$modulePath}/templates", null, 'ss');
531
        } else {
532
            $templateFiles = $this->getFilesRecursive($modulePath, null, 'ss');
533
        }
534
535
        return array_merge($files, $templateFiles);
536
    }
537
538
    /**
539
     * Extracts translatables from .php files.
540
     * Note: Translations without default values are omitted.
541
     *
542
     * @param string $content The text content of a parsed template-file
543
     * @param string $fileName Filename Optional filename
544
     * @param Module $module Module being collected
545
     * @return array Map of localised keys to default values provided for this code
546
     */
547
    public function collectFromCode($content, $fileName, Module $module)
548
    {
549
        // Get namespace either from $fileName or $module fallback
550
        $namespace = $fileName ? basename($fileName) : $module->getName();
551
552
        $entities = array();
553
554
        $tokens = token_get_all("<?php\n" . $content);
555
        $inTransFn = false;
556
        $inConcat = false;
557
        $inNamespace = false;
558
        $inClass = false;
559
        $inArrayClosedBy = false; // Set to the expected closing token, or false if not in array
560
        $currentEntity = array();
561
        $currentClass = []; // Class components
562
        foreach ($tokens as $token) {
563
            if (is_array($token)) {
564
                list($id, $text) = $token;
565
566
                // Check class
567
                if ($id === T_NAMESPACE) {
568
                    $inNamespace = true;
569
                    $currentClass = [];
570
                    continue;
571
                }
572
                if ($inNamespace && $id === T_STRING) {
573
                    $currentClass[] = $text;
574
                    continue;
575
                }
576
577
                // Check class
578
                if ($id === T_CLASS) {
579
                    $inClass = true;
580
                    continue;
581
                }
582
                if ($inClass && $id === T_STRING) {
583
                    $currentClass[] = $text;
584
                    $inClass = false;
585
                    continue;
586
                }
587
588
                // Suppress tokenisation within array
589
                if ($inTransFn && !$inArrayClosedBy && $id == T_ARRAY) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inArrayClosedBy of type false|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
590
                    $inArrayClosedBy = ')'; // Array will close with this element
591
                    continue;
592
                }
593
594
                // Start definition
595
                if ($id == T_STRING && $text == '_t') {
596
                    $inTransFn = true;
597
                    continue;
598
                }
599
600
                // Skip rest of processing unless we are in a translation, and not inside a nested array
601
                if (!$inTransFn || $inArrayClosedBy) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inArrayClosedBy of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
602
                    continue;
603
                }
604
605
                // If inside this translation, some elements might be unreachable
606
                if (in_array($id, [T_VARIABLE, T_STATIC]) ||
607
                    ($id === T_STRING && in_array($text, ['self', 'static', 'parent']))
608
                ) {
609
                    // Un-collectable strings such as _t(static::class.'.KEY').
610
                    // Should be provided by i18nEntityProvider instead
611
                    $inTransFn = false;
612
                    $inArrayClosedBy = false;
613
                    $inConcat = false;
614
                    $currentEntity = array();
615
                    continue;
616
                }
617
618
                // Check text
619
                if ($id == T_CONSTANT_ENCAPSED_STRING) {
620
                    // Fixed quoting escapes, and remove leading/trailing quotes
621
                    if (preg_match('/^\'/', $text)) {
622
                        $text = str_replace("\\'", "'", $text);
623
                        $text = preg_replace('/^\'/', '', $text);
624
                        $text = preg_replace('/\'$/', '', $text);
625
                    } else {
626
                        $text = str_replace('\"', '"', $text);
627
                        $text = preg_replace('/^"/', '', $text);
628
                        $text = preg_replace('/"$/', '', $text);
629
                    }
630
                } elseif ($id === T_CLASS_C) {
631
                    // Evaluate __CLASS__ . '.KEY' concatenation
632
                    $text = implode('\\', $currentClass);
633
                } else {
634
                    continue;
635
                }
636
637
                if ($inConcat) {
638
                    // Parser error
639
                    if (empty($currentEntity)) {
640
                        user_error('Error concatenating localisation key', E_USER_WARNING);
641
                    } else {
642
                        $currentEntity[count($currentEntity) - 1] .= $text;
643
                    }
644
                } else {
645
                    $currentEntity[] = $text;
646
                }
647
                continue; // is_array
648
            }
649
650
            // Test we can close this array
651
            if ($inTransFn && $inArrayClosedBy && ($token === $inArrayClosedBy)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inArrayClosedBy of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
652
                $inArrayClosedBy = false;
653
                continue;
654
            }
655
656
            // Check if we can close the namespace
657
            if ($inNamespace && $token === ';') {
658
                $inNamespace = false;
659
                continue;
660
            }
661
662
            // Continue only if in translation and not in array
663
            if (!$inTransFn || $inArrayClosedBy) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inArrayClosedBy of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
664
                continue;
665
            }
666
667
            switch ($token) {
668
                case '.':
669
                    $inConcat = true;
670
                    break;
671
                case ',':
672
                    $inConcat = false;
673
                    break;
674
                case '[':
675
                    // Enter array
676
                    $inArrayClosedBy = ']';
677
                    break;
678
                case ')':
679
                    // finalize definition
680
                    $inTransFn = false;
681
                    $inConcat = false;
682
                    // Ensure key is valid before saving
683
                    if (!empty($currentEntity[0])) {
684
                        $key = $currentEntity[0];
685
                        $default = '';
686
                        $comment = '';
687
                        if (!empty($currentEntity[1])) {
688
                            $default = $currentEntity[1];
689
                            if (!empty($currentEntity[2])) {
690
                                $comment = $currentEntity[2];
691
                            }
692
                        }
693
                        // Save in appropriate format
694
                        if ($default) {
695
                            $plurals = i18n::parse_plurals($default);
696
                            // Use array form if either plural or metadata is provided
697
                            if ($plurals) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $plurals 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...
698
                                $entity = $plurals;
699
                            } elseif ($comment) {
700
                                $entity = ['default' => $default];
701
                            } else {
702
                                $entity = $default;
703
                            }
704
                            if ($comment) {
705
                                $entity['comment'] = $comment;
706
                            }
707
                            $entities[$key] = $entity;
708
                        } elseif ($this->getWarnOnEmptyDefault()) {
709
                            trigger_error("Missing localisation default for key " . $currentEntity[0], E_USER_NOTICE);
710
                        }
711
                    }
712
                    $currentEntity = array();
713
                    $inArrayClosedBy = false;
714
                    break;
715
            }
716
        }
717
718
        // Normalise all keys
719
        foreach ($entities as $key => $entity) {
720
            unset($entities[$key]);
721
            $entities[$this->normalizeEntity($key, $namespace)] = $entity;
722
        }
723
        ksort($entities);
724
725
        return $entities;
726
    }
727
728
    /**
729
     * Extracts translatables from .ss templates (Self referencing)
730
     *
731
     * @param string $content The text content of a parsed template-file
732
     * @param string $fileName The name of a template file when method is used in self-referencing mode
733
     * @param Module $module Module being collected
734
     * @param array $parsedFiles
735
     * @return array $entities An array of entities representing the extracted template function calls
736
     */
737
    public function collectFromTemplate($content, $fileName, Module $module, &$parsedFiles = array())
0 ignored issues
show
Unused Code introduced by
The parameter $parsedFiles is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
738
    {
739
        // Get namespace either from $fileName or $module fallback
740
        $namespace = $fileName ? basename($fileName) : $module->getName();
741
742
        // use parser to extract <%t style translatable entities
743
        $entities = Parser::getTranslatables($content, $this->getWarnOnEmptyDefault());
744
745
        // use the old method of getting _t() style translatable entities
746
        // Collect in actual template
747
        if (preg_match_all('/(_t\([^\)]*?\))/ms', $content, $matches)) {
748
            foreach ($matches[1] as $match) {
749
                $entities = array_merge($entities, $this->collectFromCode($match, $fileName, $module));
750
            }
751
        }
752
753
        foreach ($entities as $entity => $spec) {
754
            unset($entities[$entity]);
755
            $entities[$this->normalizeEntity($entity, $namespace)] = $spec;
756
        }
757
        ksort($entities);
758
759
        return $entities;
760
    }
761
762
    /**
763
     * Allows classes which implement i18nEntityProvider to provide
764
     * additional translation strings.
765
     *
766
     * Not all classes can be instanciated without mandatory arguments,
767
     * so entity collection doesn't work for all SilverStripe classes currently
768
     *
769
     * @uses i18nEntityProvider
770
     * @param string $filePath
771
     * @param Module $module
772
     * @return array
773
     */
774
    public function collectFromEntityProviders($filePath, Module $module = null)
0 ignored issues
show
Unused Code introduced by
The parameter $module is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
775
    {
776
        $entities = array();
777
        $classes = ClassInfo::classes_for_file($filePath);
778
        foreach ($classes as $class) {
779
            // Skip non-implementing classes
780
            if (!class_exists($class) || !is_a($class, i18nEntityProvider::class, true)) {
781
                continue;
782
            }
783
784
            // Skip abstract classes
785
            $reflectionClass = new ReflectionClass($class);
786
            if ($reflectionClass->isAbstract()) {
787
                continue;
788
            }
789
790
            /** @var i18nEntityProvider $obj */
791
            $obj = singleton($class);
792
            $provided = $obj->provideI18nEntities();
793
            // Handle deprecated return syntax
794
            foreach ($provided as $key => $value) {
795
                // Detect non-associative result for any key
796
                if (is_array($value) && $value === array_values($value)) {
797
                    Deprecation::notice('5.0', 'Non-associative translations from providei18nEntities is deprecated');
798
                    $entity = array_filter([
799
                        'default' => $value[0],
800
                        'comment' => isset($value[1]) ? $value[1] : null,
801
                        'module' => isset($value[2]) ? $value[2] : null,
802
                    ]);
803
                    if (count($entity) === 1) {
804
                        $provided[$key] = $value[0];
805
                    } 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...
806
                        $provided[$key] = $entity;
807
                    } else {
808
                        unset($provided[$key]);
809
                    }
810
                }
811
            }
812
            $entities = array_merge($entities, $provided);
813
        }
814
815
        ksort($entities);
816
        return $entities;
817
    }
818
819
    /**
820
     * Normalizes enitities with namespaces.
821
     *
822
     * @param string $fullName
823
     * @param string $_namespace
824
     * @return string|boolean FALSE
825
     */
826
    protected function normalizeEntity($fullName, $_namespace = null)
827
    {
828
        // split fullname into entity parts
829
        $entityParts = explode('.', $fullName);
830
        if (count($entityParts) > 1) {
831
            // templates don't have a custom namespace
832
            $entity = array_pop($entityParts);
833
            // namespace might contain dots, so we explode
834
            $namespace = implode('.', $entityParts);
835
        } else {
836
            $entity = array_pop($entityParts);
837
            $namespace = $_namespace;
838
        }
839
840
        // If a dollar sign is used in the entity name,
841
        // we can't resolve without running the method,
842
        // and skip the processing. This is mostly used for
843
        // dynamically translating static properties, e.g. looping
844
        // through $db, which are detected by {@link collectFromEntityProviders}.
845
        if ($entity && strpos('$', $entity) !== false) {
846
            return false;
847
        }
848
849
        return "{$namespace}.{$entity}";
850
    }
851
852
853
854
    /**
855
     * Helper function that searches for potential files (templates and code) to be parsed
856
     *
857
     * @param string $folder base directory to scan (will scan recursively)
858
     * @param array $fileList Array to which potential files will be appended
859
     * @param string $type Optional, "php" or "ss" only
860
     * @param string $folderExclude Regular expression matching folder names to exclude
861
     * @return array $fileList An array of files
862
     */
863
    protected function getFilesRecursive($folder, $fileList = array(), $type = null, $folderExclude = '/\/(tests)$/')
864
    {
865
        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...
866
            $fileList = array();
867
        }
868
        // Skip ignored folders
869
        if (is_file("{$folder}/_manifest_exclude") || preg_match($folderExclude, $folder)) {
870
            return $fileList;
871
        }
872
873
        foreach (glob($folder.'/*') as $path) {
874
            // Recurse if directory
875
            if (is_dir($path)) {
876
                $fileList = array_merge(
877
                    $fileList,
878
                    $this->getFilesRecursive($path, $fileList, $type, $folderExclude)
879
                );
880
                continue;
881
            }
882
883
            // Check if this extension is included
884
            $extension = pathinfo($path, PATHINFO_EXTENSION);
885
            if (in_array($extension, $this->fileExtensions)
886
                && (!$type || $type === $extension)
0 ignored issues
show
Bug Best Practice introduced by
The expression $type of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
887
            ) {
888
                $fileList[$path] = $path;
889
            }
890
        }
891
        return $fileList;
892
    }
893
894
    public function getDefaultLocale()
895
    {
896
        return $this->defaultLocale;
897
    }
898
899
    public function setDefaultLocale($locale)
900
    {
901
        $this->defaultLocale = $locale;
902
    }
903
904
    /**
905
     * @return bool
906
     */
907
    public function getWarnOnEmptyDefault()
908
    {
909
        return $this->warnOnEmptyDefault;
910
    }
911
912
    /**
913
     * @param bool $warnOnEmptyDefault
914
     * @return $this
915
     */
916
    public function setWarnOnEmptyDefault($warnOnEmptyDefault)
917
    {
918
        $this->warnOnEmptyDefault = $warnOnEmptyDefault;
919
        return $this;
920
    }
921
}
922