Completed
Pull Request — master (#6706)
by Damian
09:02
created

ClassManifest::validateItemCache()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 10
nc 5
nop 1
dl 0
loc 19
rs 8.8571
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
class ClassManifest
24
{
25
    /**
26
     * base manifest directory
27
     * @var string
28
     */
29
    protected $base;
30
31
    /**
32
     * Set if including test classes
33
     *
34
     * @see TestOnly
35
     * @var bool
36
     */
37
    protected $tests;
38
39
    /**
40
     * Cache to use, if caching.
41
     * Set to null if uncached.
42
     *
43
     * @var CacheInterface|null
44
     */
45
    protected $cache;
46
47
    /**
48
     * Key to use for the top level cache of all items
49
     *
50
     * @var string
51
     */
52
    protected $cacheKey;
53
54
    /**
55
     * Map of classes to paths
56
     *
57
     * @var array
58
     */
59
    protected $classes      = array();
60
61
    /**
62
     * List of root classes with no parent class
63
     *
64
     * @var array
65
     */
66
    protected $roots = array();
67
68
    /**
69
     * List of direct children for any class
70
     *
71
     * @var array
72
     */
73
    protected $children = array();
74
75
    /**
76
     * List of descendents for any class (direct + indirect children)
77
     *
78
     * @var array
79
     */
80
    protected $descendants = array();
81
82
    /**
83
     * List of interfaces and paths to those files
84
     *
85
     * @var array
86
     */
87
    protected $interfaces = array();
88
89
    /**
90
     * List of direct implementors of any interface
91
     *
92
     * @var array
93
     */
94
    protected $implementors = array();
95
96
    /**
97
     * Map of traits to paths
98
     *
99
     * @var array
100
     */
101
    protected $traits = array();
102
103
    /**
104
     * PHP Parser for parsing found files
105
     *
106
     * @var Parser
107
     */
108
    private $parser;
109
110
    /**
111
     * @var NodeTraverser
112
     */
113
    private $traverser;
114
115
    /**
116
     * @var ClassManifestVisitor
117
     */
118
    private $visitor;
119
120
    /**
121
     * Constructs and initialises a new class manifest, either loading the data
122
     * from the cache or re-scanning for classes.
123
     *
124
     * @param string $base The manifest base path.
125
     * @param bool $includeTests Include the contents of "tests" directories.
126
     * @param bool $forceRegen Force the manifest to be regenerated.
127
     * @param CacheFactory $cacheFactory Optional cache to use. Set to null to not cache.
128
     */
129
    public function __construct(
130
        $base,
131
        $includeTests = false,
132
        $forceRegen = false,
133
        CacheFactory $cacheFactory = null
134
    ) {
135
        $this->base = $base;
136
        $this->tests = $includeTests;
137
138
        // build cache from factory
139
        if ($cacheFactory) {
140
            $this->cache = $cacheFactory->create(
141
                CacheInterface::class.'.classmanifest',
142
                [ 'namespace' => 'classmanifest' . ($includeTests ? '_tests' : '') ]
143
            );
144
        }
145
        $this->cacheKey = 'manifest';
146
147
        if (!$forceRegen && $this->cache && ($data = $this->cache->get($this->cacheKey))) {
148
            $this->classes = $data['classes'];
149
            $this->descendants = $data['descendants'];
150
            $this->interfaces = $data['interfaces'];
151
            $this->implementors = $data['implementors'];
152
            $this->traits = $data['traits'];
153
        } else {
154
            $this->regenerate();
155
        }
156
    }
157
158
    /**
159
     * Get or create active parser
160
     *
161
     * @return Parser
162
     */
163
    public function getParser()
164
    {
165
        if (!$this->parser) {
166
            $this->parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
167
        }
168
169
        return $this->parser;
170
    }
171
172
    public function getTraverser()
173
    {
174
        if (!$this->traverser) {
175
            $this->traverser = new NodeTraverser;
176
            $this->traverser->addVisitor(new NameResolver);
177
            $this->traverser->addVisitor($this->getVisitor());
178
        }
179
180
        return $this->traverser;
181
    }
182
183
    public function getVisitor()
184
    {
185
        if (!$this->visitor) {
186
            $this->visitor = new ClassManifestVisitor;
187
        }
188
189
        return $this->visitor;
190
    }
191
192
    /**
193
     * Returns the file path to a class or interface if it exists in the
194
     * manifest.
195
     *
196
     * @param  string $name
197
     * @return string|null
198
     */
199
    public function getItemPath($name)
200
    {
201
        $name = strtolower($name);
202
203
        foreach ([
204
            $this->classes,
205
            $this->interfaces,
206
            $this->traits
207
        ] as $source) {
208
            if (isset($source[$name]) && file_exists($source[$name])) {
209
                return $source[$name];
210
            }
211
        }
212
        return null;
213
    }
214
215
    /**
216
     * Returns a map of lowercased class names to file paths.
217
     *
218
     * @return array
219
     */
220
    public function getClasses()
221
    {
222
        return $this->classes;
223
    }
224
225
    /**
226
     * Returns a lowercase array of all the class names in the manifest.
227
     *
228
     * @return array
229
     */
230
    public function getClassNames()
231
    {
232
        return array_keys($this->classes);
233
    }
234
235
    /**
236
     * Returns a lowercase array of all trait names in the manifest
237
     *
238
     * @return array
239
     */
240
    public function getTraitNames()
241
    {
242
        return array_keys($this->traits);
243
    }
244
245
    /**
246
     * Returns an array of all the descendant data.
247
     *
248
     * @return array
249
     */
250
    public function getDescendants()
251
    {
252
        return $this->descendants;
253
    }
254
255
    /**
256
     * Returns an array containing all the descendants (direct and indirect)
257
     * of a class.
258
     *
259
     * @param  string|object $class
260
     * @return array
261
     */
262
    public function getDescendantsOf($class)
263
    {
264
        if (is_object($class)) {
265
            $class = get_class($class);
266
        }
267
268
        $lClass = strtolower($class);
269
270
        if (array_key_exists($lClass, $this->descendants)) {
271
            return $this->descendants[$lClass];
272
        } else {
273
            return array();
274
        }
275
    }
276
277
    /**
278
     * Returns a map of lowercased interface names to file locations.
279
     *
280
     * @return array
281
     */
282
    public function getInterfaces()
283
    {
284
        return $this->interfaces;
285
    }
286
287
    /**
288
     * Returns a map of lowercased interface names to the classes the implement
289
     * them.
290
     *
291
     * @return array
292
     */
293
    public function getImplementors()
294
    {
295
        return $this->implementors;
296
    }
297
298
    /**
299
     * Returns an array containing the class names that implement a certain
300
     * interface.
301
     *
302
     * @param  string $interface
303
     * @return array
304
     */
305
    public function getImplementorsOf($interface)
306
    {
307
        $interface = strtolower($interface);
308
309
        if (array_key_exists($interface, $this->implementors)) {
310
            return $this->implementors[$interface];
311
        } else {
312
            return array();
313
        }
314
    }
315
316
    /**
317
     * Get module that owns this class
318
     *
319
     * @param string $class Class name
320
     * @return Module
321
     */
322
    public function getOwnerModule($class)
323
    {
324
        $path = realpath($this->getItemPath($class));
325
        if (!$path) {
326
            return null;
327
        }
328
329
        /** @var Module $rootModule */
330
        $rootModule = null;
331
332
        // Find based on loaded modules
333
        $modules = ModuleLoader::instance()->getManifest()->getModules();
334
        foreach ($modules as $module) {
335
            // Leave root module as fallback
336
            if (empty($module->getRelativePath())) {
337
                $rootModule = $module;
338
            } elseif (stripos($path, realpath($module->getPath())) === 0) {
339
                return $module;
340
            }
341
        }
342
343
        // Fall back to top level module
344
        return $rootModule;
345
    }
346
347
    /**
348
     * Completely regenerates the manifest file.
349
     */
350
    public function regenerate()
351
    {
352
        $resets = array(
353
            'classes', 'roots', 'children', 'descendants', 'interfaces',
354
            'implementors', 'traits'
355
        );
356
357
        // Reset the manifest so stale info doesn't cause errors.
358
        foreach ($resets as $reset) {
359
            $this->$reset = array();
360
        }
361
362
        $finder = new ManifestFileFinder();
363
        $finder->setOptions(array(
364
            'name_regex'    => '/^[^_].*\\.php$/',
365
            'ignore_files'  => array('index.php', 'main.php', 'cli-script.php'),
366
            'ignore_tests'  => !$this->tests,
367
            'file_callback' => array($this, 'handleFile'),
368
        ));
369
        $finder->find($this->base);
370
371
        foreach ($this->roots as $root) {
372
            $this->coalesceDescendants($root);
373
        }
374
375
        if ($this->cache) {
376
            $data = array(
377
                'classes'      => $this->classes,
378
                'descendants'  => $this->descendants,
379
                'interfaces'   => $this->interfaces,
380
                'implementors' => $this->implementors,
381
                'traits'       => $this->traits,
382
            );
383
            $this->cache->set($this->cacheKey, $data);
384
        }
385
    }
386
387
    public function handleFile($basename, $pathname)
388
    {
389
        $classes    = null;
390
        $interfaces = null;
391
        $traits = null;
392
393
        // The results of individual file parses are cached, since only a few
394
        // files will have changed and TokenisedRegularExpression is quite
395
        // slow. A combination of the file name and file contents hash are used,
396
        // since just using the datetime lead to problems with upgrading.
397
        $key = preg_replace('/[^a-zA-Z0-9_]/', '_', $basename) . '_' . md5_file($pathname);
398
399
        // Attempt to load from cache
400
        if ($this->cache
401
            && ($data = $this->cache->get($key))
402
            && $this->validateItemCache($data)
403
        ) {
404
            $classes = $data['classes'];
405
            $interfaces = $data['interfaces'];
406
            $traits = $data['traits'];
407
        } else {
408
            // Build from php file parser
409
            $fileContents = ClassContentRemover::remove_class_content($pathname);
410
            try {
411
                $stmts = $this->getParser()->parse($fileContents);
412
            } catch (Error $e) {
413
                // if our mangled contents breaks, try again with the proper file contents
414
                $stmts = $this->getParser()->parse(file_get_contents($pathname));
415
            }
416
            $this->getTraverser()->traverse($stmts);
0 ignored issues
show
Bug introduced by
It seems like $stmts can also be of type null; however, PhpParser\NodeTraverser::traverse() does only seem to accept array<integer,object<PhpParser\Node>>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
417
418
            $classes = $this->getVisitor()->getClasses();
419
            $interfaces = $this->getVisitor()->getInterfaces();
420
            $traits = $this->getVisitor()->getTraits();
421
422
            // Save back to cache if configured
423
            if ($this->cache) {
424
                $cache = array(
425
                    'classes' => $classes,
426
                    'interfaces' => $interfaces,
427
                    'traits' => $traits,
428
                );
429
                $this->cache->set($key, $cache);
430
            }
431
        }
432
433
        // Merge this data into the global list
434
        foreach ($classes as $className => $classInfo) {
435
            $extends = isset($classInfo['extends']) ? $classInfo['extends'] : null;
436
            $implements = isset($classInfo['interfaces']) ? $classInfo['interfaces'] : null;
437
438
            $lowercaseName = strtolower($className);
439
            if (array_key_exists($lowercaseName, $this->classes)) {
440
                throw new Exception(sprintf(
441
                    'There are two files containing the "%s" class: "%s" and "%s"',
442
                    $className,
443
                    $this->classes[$lowercaseName],
444
                    $pathname
445
                ));
446
            }
447
448
            $this->classes[$lowercaseName] = $pathname;
449
450
            if ($extends) {
451
                foreach ($extends as $ancestor) {
452
                    $ancestor = strtolower($ancestor);
453
454
                    if (!isset($this->children[$ancestor])) {
455
                        $this->children[$ancestor] = array($className);
456
                    } else {
457
                        $this->children[$ancestor][] = $className;
458
                    }
459
                }
460
            } else {
461
                $this->roots[] = $className;
462
            }
463
464
            if ($implements) {
465
                foreach ($implements as $interface) {
466
                    $interface = strtolower($interface);
467
468
                    if (!isset($this->implementors[$interface])) {
469
                        $this->implementors[$interface] = array($className);
470
                    } else {
471
                        $this->implementors[$interface][] = $className;
472
                    }
473
                }
474
            }
475
        }
476
477
        foreach ($interfaces as $interfaceName => $interfaceInfo) {
478
            $this->interfaces[strtolower($interfaceName)] = $pathname;
479
        }
480
        foreach ($traits as $traitName => $traitInfo) {
481
            $this->traits[strtolower($traitName)] = $pathname;
482
        }
483
    }
484
485
    /**
486
     * Recursively coalesces direct child information into full descendant
487
     * information.
488
     *
489
     * @param  string $class
490
     * @return array
491
     */
492
    protected function coalesceDescendants($class)
493
    {
494
        $lClass = strtolower($class);
495
496
        if (array_key_exists($lClass, $this->children)) {
497
            $this->descendants[$lClass] = array();
498
499
            foreach ($this->children[$lClass] as $class) {
500
                $this->descendants[$lClass] = array_merge(
501
                    $this->descendants[$lClass],
502
                    array($class),
503
                    $this->coalesceDescendants($class)
504
                );
505
            }
506
507
            return $this->descendants[$lClass];
508
        } else {
509
            return array();
510
        }
511
    }
512
513
    /**
514
     * Verify that cached data is valid for a single item
515
     *
516
     * @param array $data
517
     * @return bool
518
     */
519
    protected function validateItemCache($data)
520
    {
521
        foreach (['classes', 'interfaces', 'traits'] as $key) {
522
            // Must be set
523
            if (!isset($data[$key])) {
524
                return false;
525
            }
526
            // and an array
527
            if (!is_array($data[$key])) {
528
                return false;
529
            }
530
            // Detect legacy cache keys (non-associative)
531
            $array = $data[$key];
532
            if (!empty($array) && is_numeric(key($array))) {
533
                return false;
534
            }
535
        }
536
        return true;
537
    }
538
}
539