Passed
Push — master ( ddbf8b...086098 )
by Robbie
11:17
created

ClassManifest::init()   A

Complexity

Conditions 5
Paths 2

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 7
nc 2
nop 2
dl 0
loc 15
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Core\Manifest;
4
5
use Exception;
6
use PhpParser\Error;
7
use PhpParser\NodeTraverser;
8
use PhpParser\NodeVisitor\NameResolver;
9
use PhpParser\Parser;
10
use PhpParser\ParserFactory;
11
use PhpParser\ErrorHandler\ErrorHandler;
0 ignored issues
show
Bug introduced by
The type PhpParser\ErrorHandler\ErrorHandler was not found. Maybe you did not declare it correctly or list all dependencies?

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

filter:
    dependency_paths: ["lib/*"]

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

Loading history...
12
use Psr\SimpleCache\CacheInterface;
13
use SilverStripe\Core\Cache\CacheFactory;
14
use SilverStripe\Dev\TestOnly;
15
16
/**
17
 * A utility class which builds a manifest of all classes, interfaces and caches it.
18
 *
19
 * It finds the following information:
20
 *   - Class and interface names and paths.
21
 *   - All direct and indirect descendants of a class.
22
 *   - All implementors of an interface.
23
 *
24
 * To be consistent; In general all array keys are lowercase, and array values are correct-case
25
 */
26
class ClassManifest
27
{
28
    /**
29
     * base manifest directory
30
     * @var string
31
     */
32
    protected $base;
33
34
    /**
35
     * Used to build cache during boot
36
     *
37
     * @var CacheFactory
38
     */
39
    protected $cacheFactory;
40
41
    /**
42
     * Cache to use, if caching.
43
     * Set to null if uncached.
44
     *
45
     * @var CacheInterface|null
46
     */
47
    protected $cache;
48
49
    /**
50
     * Key to use for the top level cache of all items
51
     *
52
     * @var string
53
     */
54
    protected $cacheKey;
55
56
    /**
57
     * Array of properties to cache
58
     *
59
     * @var array
60
     */
61
    protected $serialisedProperties = [
62
        'classes',
63
        'classNames',
64
        'descendants',
65
        'interfaces',
66
        'interfaceNames',
67
        'implementors',
68
        'traits',
69
        'traitNames',
70
    ];
71
72
    /**
73
     * Map of lowercase class names to paths
74
     *
75
     * @var array
76
     */
77
    protected $classes = array();
78
79
    /**
80
     * Map of lowercase class names to case-correct names
81
     *
82
     * @var array
83
     */
84
    protected $classNames = [];
85
86
    /**
87
     * List of root classes with no parent class
88
     * Keys are lowercase, values are correct case.
89
     *
90
     * Note: Only used while regenerating cache
91
     *
92
     * @var array
93
     */
94
    protected $roots = array();
95
96
    /**
97
     * List of direct children for any class.
98
     * Keys are lowercase, values are arrays.
99
     * Each item-value array has lowercase keys and correct case for values.
100
     *
101
     * Note: Only used while regenerating cache
102
     *
103
     * @var array
104
     */
105
    protected $children = array();
106
107
    /**
108
     * List of descendents for any class (direct + indirect children)
109
     * Keys are lowercase, values are arrays.
110
     * Each item-value array has lowercase keys and correct case for values.
111
     *
112
     * @var array
113
     */
114
    protected $descendants = array();
115
116
    /**
117
     * Map of lowercase interface name to path those files
118
     *
119
     * @var array
120
     */
121
    protected $interfaces = [];
122
123
    /**
124
     * Map of lowercase interface name to proper case
125
     *
126
     * @var array
127
     */
128
    protected $interfaceNames = [];
129
130
    /**
131
     * List of direct implementors of any interface
132
     * Keys are lowercase, values are arrays.
133
     * Each item-value array has lowercase keys and correct case for values.
134
     *
135
     * @var array
136
     */
137
    protected $implementors = array();
138
139
    /**
140
     * Map of lowercase trait names to paths
141
     *
142
     * @var array
143
     */
144
    protected $traits = [];
145
146
    /**
147
     * Map of lowercase trait names to proper case
148
     *
149
     * @var array
150
     */
151
    protected $traitNames = [];
152
153
    /**
154
     * PHP Parser for parsing found files
155
     *
156
     * @var Parser
157
     */
158
    private $parser;
159
160
    /**
161
     * @var NodeTraverser
162
     */
163
    private $traverser;
164
165
    /**
166
     * @var ClassManifestVisitor
167
     */
168
    private $visitor;
169
170
    /**
171
     * Indicates whether the cache has been
172
     * regenerated in the current process
173
     *
174
     * @var bool
175
     */
176
    private $cacheRegenerated = false;
177
178
    /**
179
     * Constructs and initialises a new class manifest, either loading the data
180
     * from the cache or re-scanning for classes.
181
     *
182
     * @param string $base The manifest base path.
183
     * @param CacheFactory $cacheFactory Optional cache to use. Set to null to not cache.
184
     */
185
    public function __construct($base, CacheFactory $cacheFactory = null)
186
    {
187
        $this->base = $base;
188
        $this->cacheFactory = $cacheFactory;
189
        $this->cacheKey = 'manifest';
190
    }
191
192
    private function buildCache($includeTests = false)
193
    {
194
        if ($this->cache) {
195
            return $this->cache;
196
        } elseif (!$this->cacheFactory) {
197
            return null;
198
        } else {
199
            return $this->cacheFactory->create(
200
                CacheInterface::class . '.classmanifest',
201
                ['namespace' => 'classmanifest' . ($includeTests ? '_tests' : '')]
202
            );
203
        }
204
    }
205
206
    /**
207
     * @internal This method is not a part of public API and will be deleted without a deprecation warning
208
     *
209
     * @return int
210
     */
211
    public function getManifestTimestamp($includeTests = false)
212
    {
213
        $cache = $this->buildCache($includeTests);
214
215
        if (!$cache) {
216
            return null;
217
        }
218
219
        return $cache->get('generated_at');
220
    }
221
222
    /**
223
     * @internal This method is not a part of public API and will be deleted without a deprecation warning
224
     */
225
    public function scheduleFlush($includeTests = false)
226
    {
227
        $cache = $this->buildCache($includeTests);
228
229
        if (!$cache) {
230
            return null;
231
        }
232
233
        $cache->set('regenerate', true);
234
    }
235
236
    /**
237
     * @internal This method is not a part of public API and will be deleted without a deprecation warning
238
     */
239
    public function isFlushScheduled($includeTests = false)
240
    {
241
        $cache = $this->buildCache($includeTests);
242
243
        if (!$cache) {
244
            return null;
245
        }
246
247
        return $cache->get('regenerate');
248
    }
249
250
    /**
251
     * @internal This method is not a part of public API and will be deleted without a deprecation warning
252
     */
253
    public function isFlushed()
254
    {
255
        return $this->cacheRegenerated;
256
    }
257
258
    /**
259
     * Initialise the class manifest
260
     *
261
     * @param bool $includeTests
262
     * @param bool $forceRegen
263
     */
264
    public function init($includeTests = false, $forceRegen = false)
265
    {
266
        $this->cache = $this->buildCache($includeTests);
267
268
        // Check if cache is safe to use
269
        if (!$forceRegen
270
            && $this->cache
271
            && ($data = $this->cache->get($this->cacheKey))
272
            && $this->loadState($data)
273
        ) {
274
            return;
275
        }
276
277
        // Build
278
        $this->regenerate($includeTests);
279
    }
280
281
    /**
282
     * Get or create active parser
283
     *
284
     * @return Parser
285
     */
286
    public function getParser()
287
    {
288
        if (!$this->parser) {
289
            $this->parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
290
        }
291
292
        return $this->parser;
293
    }
294
295
    /**
296
     * Get node traverser for parsing class files
297
     *
298
     * @return NodeTraverser
299
     */
300
    public function getTraverser()
301
    {
302
        if (!$this->traverser) {
303
            $this->traverser = new NodeTraverser;
304
            $this->traverser->addVisitor(new NameResolver);
305
            $this->traverser->addVisitor($this->getVisitor());
306
        }
307
308
        return $this->traverser;
309
    }
310
311
    /**
312
     * Get visitor for parsing class files
313
     *
314
     * @return ClassManifestVisitor
315
     */
316
    public function getVisitor()
317
    {
318
        if (!$this->visitor) {
319
            $this->visitor = new ClassManifestVisitor;
320
        }
321
322
        return $this->visitor;
323
    }
324
325
    /**
326
     * Returns the file path to a class or interface if it exists in the
327
     * manifest.
328
     *
329
     * @param  string $name
330
     * @return string|null
331
     */
332
    public function getItemPath($name)
333
    {
334
        $lowerName = strtolower($name);
335
        foreach ([
336
             $this->classes,
337
             $this->interfaces,
338
             $this->traits,
339
         ] as $source) {
340
            if (isset($source[$lowerName]) && file_exists($source[$lowerName])) {
341
                return $source[$lowerName];
342
            }
343
        }
344
        return null;
345
    }
346
347
    /**
348
     * Return correct case name
349
     *
350
     * @param string $name
351
     * @return string Correct case name
352
     */
353
    public function getItemName($name)
354
    {
355
        $lowerName = strtolower($name);
356
        foreach ([
357
             $this->classNames,
358
             $this->interfaceNames,
359
             $this->traitNames,
360
         ] as $source) {
361
            if (isset($source[$lowerName])) {
362
                return $source[$lowerName];
363
            }
364
        }
365
        return null;
366
    }
367
368
    /**
369
     * Returns a map of lowercased class names to file paths.
370
     *
371
     * @return array
372
     */
373
    public function getClasses()
374
    {
375
        return $this->classes;
376
    }
377
378
    /**
379
     * Returns a map of lowercase class names to proper class names in the manifest
380
     *
381
     * @return array
382
     */
383
    public function getClassNames()
384
    {
385
        return $this->classNames;
386
    }
387
388
    /**
389
     * Returns a map of lowercased trait names to file paths.
390
     *
391
     * @return array
392
     */
393
    public function getTraits()
394
    {
395
        return $this->traits;
396
    }
397
398
    /**
399
     * Returns a map of lowercase trait names to proper trait names in the manifest
400
     *
401
     * @return array
402
     */
403
    public function getTraitNames()
404
    {
405
        return $this->traitNames;
406
    }
407
408
    /**
409
     * Returns an array of all the descendant data.
410
     *
411
     * @return array
412
     */
413
    public function getDescendants()
414
    {
415
        return $this->descendants;
416
    }
417
418
    /**
419
     * Returns an array containing all the descendants (direct and indirect)
420
     * of a class.
421
     *
422
     * @param  string|object $class
423
     * @return array
424
     */
425
    public function getDescendantsOf($class)
426
    {
427
        if (is_object($class)) {
428
            $class = get_class($class);
429
        }
430
431
        $lClass = strtolower($class);
432
        if (array_key_exists($lClass, $this->descendants)) {
433
            return $this->descendants[$lClass];
434
        }
435
436
        return [];
437
    }
438
439
    /**
440
     * Returns a map of lowercased interface names to file locations.
441
     *
442
     * @return array
443
     */
444
    public function getInterfaces()
445
    {
446
        return $this->interfaces;
447
    }
448
449
    /**
450
     * Return map of lowercase interface names to proper case names in the manifest
451
     *
452
     * @return array
453
     */
454
    public function getInterfaceNames()
455
    {
456
        return $this->interfaceNames;
457
    }
458
459
    /**
460
     * Returns a map of lowercased interface names to the classes the implement
461
     * them.
462
     *
463
     * @return array
464
     */
465
    public function getImplementors()
466
    {
467
        return $this->implementors;
468
    }
469
470
    /**
471
     * Returns an array containing the class names that implement a certain
472
     * interface.
473
     *
474
     * @param string $interface
475
     * @return array
476
     */
477
    public function getImplementorsOf($interface)
478
    {
479
        $lowerInterface = strtolower($interface);
480
        if (array_key_exists($lowerInterface, $this->implementors)) {
481
            return $this->implementors[$lowerInterface];
482
        } else {
483
            return array();
484
        }
485
    }
486
487
    /**
488
     * Get module that owns this class
489
     *
490
     * @param string $class Class name
491
     * @return Module
492
     */
493
    public function getOwnerModule($class)
494
    {
495
        $path = $this->getItemPath($class);
496
        return ModuleLoader::inst()->getManifest()->getModuleByPath($path);
497
    }
498
499
    /**
500
     * Completely regenerates the manifest file.
501
     *
502
     * @param bool $includeTests
503
     */
504
    public function regenerate($includeTests)
505
    {
506
        // Reset the manifest so stale info doesn't cause errors.
507
        $this->loadState([]);
508
        $this->roots = [];
509
        $this->children = [];
510
511
        $finder = new ManifestFileFinder();
512
        $finder->setOptions(array(
513
            'name_regex' => '/^[^_].*\\.php$/',
514
            'ignore_files' => array('index.php', 'cli-script.php'),
515
            'ignore_tests' => !$includeTests,
516
            'file_callback' => function ($basename, $pathname, $depth) use ($includeTests) {
0 ignored issues
show
Unused Code introduced by
The parameter $depth is not used and could be removed. ( Ignorable by Annotation )

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

516
            'file_callback' => function ($basename, $pathname, /** @scrutinizer ignore-unused */ $depth) use ($includeTests) {

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

Loading history...
517
                $this->handleFile($basename, $pathname, $includeTests);
518
            },
519
        ));
520
        $finder->find($this->base);
521
522
        foreach ($this->roots as $root) {
523
            $this->coalesceDescendants($root);
524
        }
525
526
        if ($this->cache) {
527
            $data = $this->getState();
528
            $this->cache->set($this->cacheKey, $data);
529
            $this->cache->set('generated_at', time());
530
            $this->cache->delete('regenerate');
531
        }
532
533
        $this->cacheRegenerated = true;
534
    }
535
536
    /**
537
     * Visit a file to inspect for classes, interfaces and traits
538
     *
539
     * @param string $basename
540
     * @param string $pathname
541
     * @param bool $includeTests
542
     * @throws Exception
543
     */
544
    public function handleFile($basename, $pathname, $includeTests)
545
    {
546
        // The results of individual file parses are cached, since only a few
547
        // files will have changed and TokenisedRegularExpression is quite
548
        // slow. A combination of the file name and file contents hash are used,
549
        // since just using the datetime lead to problems with upgrading.
550
        $key = preg_replace('/[^a-zA-Z0-9_]/', '_', $basename) . '_' . md5_file($pathname);
551
552
        // Attempt to load from cache
553
        // Note: $classes, $interfaces and $traits arrays have correct-case keys, not lowercase
554
        $changed = false;
555
        if ($this->cache
556
            && ($data = $this->cache->get($key))
557
            && $this->validateItemCache($data)
558
        ) {
559
            $classes = $data['classes'];
560
            $interfaces = $data['interfaces'];
561
            $traits = $data['traits'];
562
        } else {
563
            $changed = true;
564
            // Build from php file parser
565
            $fileContents = ClassContentRemover::remove_class_content($pathname);
566
            // Not injectable, error handling is an implementation detail.
567
            $errorHandler = new ClassManifestErrorHandler($pathname);
568
            try {
569
                $stmts = $this->getParser()->parse($fileContents, $errorHandler);
570
            } catch (Error $e) {
571
                // if our mangled contents breaks, try again with the proper file contents
572
                $stmts = $this->getParser()->parse(file_get_contents($pathname), $errorHandler);
573
            }
574
            $this->getTraverser()->traverse($stmts);
0 ignored issues
show
Bug introduced by
It seems like $stmts can also be of type null; however, parameter $nodes of PhpParser\NodeTraverser::traverse() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

574
            $this->getTraverser()->traverse(/** @scrutinizer ignore-type */ $stmts);
Loading history...
575
576
            $classes = $this->getVisitor()->getClasses();
577
            $interfaces = $this->getVisitor()->getInterfaces();
578
            $traits = $this->getVisitor()->getTraits();
579
        }
580
581
        // Merge raw class data into global list
582
        foreach ($classes as $className => $classInfo) {
583
            $lowerClassName = strtolower($className);
584
            if (array_key_exists($lowerClassName, $this->classes)) {
585
                throw new Exception(sprintf(
586
                    'There are two files containing the "%s" class: "%s" and "%s"',
587
                    $className,
588
                    $this->classes[$lowerClassName],
589
                    $pathname
590
                ));
591
            }
592
593
            // Skip if implements TestOnly, but doesn't include tests
594
            $lowerInterfaces = array_map('strtolower', $classInfo['interfaces']);
595
            if (!$includeTests && in_array(strtolower(TestOnly::class), $lowerInterfaces)) {
596
                $changed = true;
597
                unset($classes[$className]);
598
                continue;
599
            }
600
601
            $this->classes[$lowerClassName] = $pathname;
602
            $this->classNames[$lowerClassName] = $className;
603
604
            // Add to children
605
            if ($classInfo['extends']) {
606
                foreach ($classInfo['extends'] as $ancestor) {
607
                    $lowerAncestor = strtolower($ancestor);
608
                    if (!isset($this->children[$lowerAncestor])) {
609
                        $this->children[$lowerAncestor] = [];
610
                    }
611
                    $this->children[$lowerAncestor][$lowerClassName] = $className;
612
                }
613
            } else {
614
                $this->roots[$lowerClassName] = $className;
615
            }
616
617
            // Load interfaces
618
            foreach ($classInfo['interfaces'] as $interface) {
619
                $lowerInterface = strtolower($interface);
620
                if (!isset($this->implementors[$lowerInterface])) {
621
                    $this->implementors[$lowerInterface] = [];
622
                }
623
                $this->implementors[$lowerInterface][$lowerClassName] = $className;
624
            }
625
        }
626
627
        // Merge all found interfaces into list
628
        foreach ($interfaces as $interfaceName => $interfaceInfo) {
629
            $lowerInterface = strtolower($interfaceName);
630
            $this->interfaces[$lowerInterface] = $pathname;
631
            $this->interfaceNames[$lowerInterface] = $interfaceName;
632
        }
633
634
        // Merge all traits
635
        foreach ($traits as $traitName => $traitInfo) {
636
            $lowerTrait = strtolower($traitName);
637
            $this->traits[$lowerTrait] = $pathname;
638
            $this->traitNames[$lowerTrait] = $traitName;
639
        }
640
641
        // Save back to cache if configured
642
        if ($changed && $this->cache) {
643
            $cache = array(
644
                'classes' => $classes,
645
                'interfaces' => $interfaces,
646
                'traits' => $traits,
647
            );
648
            $this->cache->set($key, $cache);
649
        }
650
    }
651
652
    /**
653
     * Recursively coalesces direct child information into full descendant
654
     * information.
655
     *
656
     * @param  string $class
657
     * @return array
658
     */
659
    protected function coalesceDescendants($class)
660
    {
661
        // Reset descendents to immediate children initially
662
        $lowerClass = strtolower($class);
663
        if (empty($this->children[$lowerClass])) {
664
            return [];
665
        }
666
667
        // Coalasce children into descendent list
668
        $this->descendants[$lowerClass] = $this->children[$lowerClass];
669
        foreach ($this->children[$lowerClass] as $childClass) {
670
            // Merge all nested descendants
671
            $this->descendants[$lowerClass] = array_merge(
672
                $this->descendants[$lowerClass],
673
                $this->coalesceDescendants($childClass)
674
            );
675
        }
676
        return $this->descendants[$lowerClass];
677
    }
678
679
    /**
680
     * Reload state from given cache data
681
     *
682
     * @param array $data
683
     * @return bool True if cache was valid and successfully loaded
684
     */
685
    protected function loadState($data)
686
    {
687
        $success = true;
688
        foreach ($this->serialisedProperties as $property) {
689
            if (!isset($data[$property]) || !is_array($data[$property])) {
690
                $success = false;
691
                $value = [];
692
            } else {
693
                $value = $data[$property];
694
            }
695
            $this->$property = $value;
696
        }
697
        return $success;
698
    }
699
700
    /**
701
     * Load current state into an array of data
702
     *
703
     * @return array
704
     */
705
    protected function getState()
706
    {
707
        $data = [];
708
        foreach ($this->serialisedProperties as $property) {
709
            $data[$property] = $this->$property;
710
        }
711
        return $data;
712
    }
713
714
    /**
715
     * Verify that cached data is valid for a single item
716
     *
717
     * @param array $data
718
     * @return bool
719
     */
720
    protected function validateItemCache($data)
721
    {
722
        if (!$data || !is_array($data)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data 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...
723
            return false;
724
        }
725
        foreach (['classes', 'interfaces', 'traits'] as $key) {
726
            // Must be set
727
            if (!isset($data[$key])) {
728
                return false;
729
            }
730
            // and an array
731
            if (!is_array($data[$key])) {
732
                return false;
733
            }
734
            // Detect legacy cache keys (non-associative)
735
            $array = $data[$key];
736
            if (!empty($array) && is_numeric(key($array))) {
737
                return false;
738
            }
739
        }
740
        return true;
741
    }
742
}
743