Passed
Push — 4.2 ( 7f69cc...ca56e8 )
by
unknown
10:42
created

ClassManifest::getImplementors()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

This check looks for imports that have been defined, but are not used in the scope.

Loading history...
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

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

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...
516
                $this->handleFile($basename, $pathname, $includeTests);
517
            },
518
        ));
519
        $finder->find($this->base);
520
521
        foreach ($this->roots as $root) {
522
            $this->coalesceDescendants($root);
523
        }
524
525
        if ($this->cache) {
526
            $data = $this->getState();
527
            $this->cache->set($this->cacheKey, $data);
528
            $this->cache->set('generated_at', time());
529
            $this->cache->delete('regenerate');
530
        }
531
532
        $this->cacheRegenerated = true;
533
    }
534
535
    /**
536
     * Visit a file to inspect for classes, interfaces and traits
537
     *
538
     * @param string $basename
539
     * @param string $pathname
540
     * @param bool $includeTests
541
     * @throws Exception
542
     */
543
    public function handleFile($basename, $pathname, $includeTests)
544
    {
545
        // The results of individual file parses are cached, since only a few
546
        // files will have changed and TokenisedRegularExpression is quite
547
        // slow. A combination of the file name and file contents hash are used,
548
        // since just using the datetime lead to problems with upgrading.
549
        $key = preg_replace('/[^a-zA-Z0-9_]/', '_', $basename) . '_' . md5_file($pathname);
550
551
        // Attempt to load from cache
552
        // Note: $classes, $interfaces and $traits arrays have correct-case keys, not lowercase
553
        $changed = false;
554
        if ($this->cache
555
            && ($data = $this->cache->get($key))
556
            && $this->validateItemCache($data)
557
        ) {
558
            $classes = $data['classes'];
559
            $interfaces = $data['interfaces'];
560
            $traits = $data['traits'];
561
        } else {
562
            $changed = true;
563
            // Build from php file parser
564
            $fileContents = ClassContentRemover::remove_class_content($pathname);
565
            try {
566
                $stmts = $this->getParser()->parse($fileContents);
567
            } catch (Error $e) {
568
                // if our mangled contents breaks, try again with the proper file contents
569
                $stmts = $this->getParser()->parse(file_get_contents($pathname));
570
            }
571
            $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

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