Completed
Push — master ( 03a881...28de70 )
by Damian
09:16
created

ClassManifest::getImplementors()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
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\ParserFactory;
10
use SilverStripe\Control\Director;
11
12
/**
13
 * A utility class which builds a manifest of all classes, interfaces and some
14
 * additional items present in a directory, and caches it.
15
 *
16
 * It finds the following information:
17
 *   - Class and interface names and paths.
18
 *   - All direct and indirect descendants of a class.
19
 *   - All implementors of an interface.
20
 *   - All module configuration files.
21
 */
22
class ClassManifest
23
{
24
25
    const CONF_FILE = '_config.php';
26
    const CONF_DIR = '_config';
27
28
    protected $base;
29
    protected $tests;
30
31
    /**
32
     * @var ManifestCache
33
     */
34
    protected $cache;
35
36
    /**
37
     * @var string
38
     */
39
    protected $cacheKey;
40
41
    protected $classes      = array();
42
    protected $roots        = array();
43
    protected $children     = array();
44
    protected $descendants  = array();
45
    protected $interfaces   = array();
46
    protected $implementors = array();
47
    protected $configs      = array();
48
    protected $configDirs   = array();
49
    protected $traits       = array();
50
51
    /**
52
     * @var \PhpParser\Parser
53
     */
54
    private $parser;
55
    /**
56
     * @var NodeTraverser
57
     */
58
    private $traverser;
59
    /**
60
     * @var ClassManifestVisitor
61
     */
62
    private $visitor;
63
64
    /**
65
     * Constructs and initialises a new class manifest, either loading the data
66
     * from the cache or re-scanning for classes.
67
     *
68
     * @param string $base The manifest base path.
69
     * @param bool   $includeTests Include the contents of "tests" directories.
70
     * @param bool   $forceRegen Force the manifest to be regenerated.
71
     * @param bool   $cache If the manifest is regenerated, cache it.
72
     */
73
    public function __construct($base, $includeTests = false, $forceRegen = false, $cache = true)
74
    {
75
        $this->base  = $base;
76
        $this->tests = $includeTests;
77
78
        $cacheClass = getenv('SS_MANIFESTCACHE') ?: 'SilverStripe\\Core\\Manifest\\ManifestCache_File';
79
80
        $this->cache = new $cacheClass('classmanifest'.($includeTests ? '_tests' : ''));
81
        $this->cacheKey = 'manifest';
82
83
        if (!$forceRegen && $data = $this->cache->load($this->cacheKey)) {
84
            $this->classes      = $data['classes'];
85
            $this->descendants  = $data['descendants'];
86
            $this->interfaces   = $data['interfaces'];
87
            $this->implementors = $data['implementors'];
88
            $this->configs      = $data['configs'];
89
            $this->configDirs   = $data['configDirs'];
90
            $this->traits       = $data['traits'];
91
        } else {
92
            $this->regenerate($cache);
93
        }
94
    }
95
96
    public function getParser()
97
    {
98
        if (!$this->parser) {
99
            $this->parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
100
        }
101
102
        return $this->parser;
103
    }
104
105
    public function getTraverser()
106
    {
107
        if (!$this->traverser) {
108
            $this->traverser = new NodeTraverser;
109
            $this->traverser->addVisitor(new NameResolver);
110
            $this->traverser->addVisitor($this->getVisitor());
111
        }
112
113
        return $this->traverser;
114
    }
115
116
    public function getVisitor()
117
    {
118
        if (!$this->visitor) {
119
            $this->visitor = new ClassManifestVisitor;
120
        }
121
122
        return $this->visitor;
123
    }
124
125
    /**
126
     * Returns the file path to a class or interface if it exists in the
127
     * manifest.
128
     *
129
     * @param  string $name
130
     * @return string|null
131
     */
132
    public function getItemPath($name)
133
    {
134
        $name = strtolower($name);
135
136
        foreach ([
137
            $this->classes,
138
            $this->interfaces,
139
            $this->traits
140
        ] as $source) {
141
            if (isset($source[$name]) && file_exists($source[$name])) {
142
                return $source[$name];
143
            }
144
        }
145
        return null;
146
    }
147
148
    /**
149
     * Returns a map of lowercased class names to file paths.
150
     *
151
     * @return array
152
     */
153
    public function getClasses()
154
    {
155
        return $this->classes;
156
    }
157
158
    /**
159
     * Returns a lowercase array of all the class names in the manifest.
160
     *
161
     * @return array
162
     */
163
    public function getClassNames()
164
    {
165
        return array_keys($this->classes);
166
    }
167
168
    /**
169
     * Returns a lowercase array of all trait names in the manifest
170
     *
171
     * @return array
172
     */
173
    public function getTraitNames()
174
    {
175
        return array_keys($this->traits);
176
    }
177
178
    /**
179
     * Returns an array of all the descendant data.
180
     *
181
     * @return array
182
     */
183
    public function getDescendants()
184
    {
185
        return $this->descendants;
186
    }
187
188
    /**
189
     * Returns an array containing all the descendants (direct and indirect)
190
     * of a class.
191
     *
192
     * @param  string|object $class
193
     * @return array
194
     */
195
    public function getDescendantsOf($class)
196
    {
197
        if (is_object($class)) {
198
            $class = get_class($class);
199
        }
200
201
        $lClass = strtolower($class);
202
203
        if (array_key_exists($lClass, $this->descendants)) {
204
            return $this->descendants[$lClass];
205
        } else {
206
            return array();
207
        }
208
    }
209
210
    /**
211
     * Returns a map of lowercased interface names to file locations.
212
     *
213
     * @return array
214
     */
215
    public function getInterfaces()
216
    {
217
        return $this->interfaces;
218
    }
219
220
    /**
221
     * Returns a map of lowercased interface names to the classes the implement
222
     * them.
223
     *
224
     * @return array
225
     */
226
    public function getImplementors()
227
    {
228
        return $this->implementors;
229
    }
230
231
    /**
232
     * Returns an array containing the class names that implement a certain
233
     * interface.
234
     *
235
     * @param  string $interface
236
     * @return array
237
     */
238
    public function getImplementorsOf($interface)
239
    {
240
        $interface = strtolower($interface);
241
242
        if (array_key_exists($interface, $this->implementors)) {
243
            return $this->implementors[$interface];
244
        } else {
245
            return array();
246
        }
247
    }
248
249
    /**
250
     * Returns an array of paths to module config files.
251
     *
252
     * @return array
253
     */
254
    public function getConfigs()
255
    {
256
        return $this->configs;
257
    }
258
259
    /**
260
     * Returns an array of module names mapped to their paths.
261
     *
262
     * "Modules" in SilverStripe are simply directories with a _config.php
263
     * file.
264
     *
265
     * @return array
266
     */
267
    public function getModules()
268
    {
269
        $modules = array();
270
271
        if ($this->configs) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->configs 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...
272
            foreach ($this->configs as $configPath) {
273
                $modules[basename(dirname($configPath))] = dirname($configPath);
274
            }
275
        }
276
277
        if ($this->configDirs) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->configDirs 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...
278
            foreach ($this->configDirs as $configDir) {
279
                $path = preg_replace('/\/_config$/', '', dirname($configDir));
280
                $modules[basename($path)] = $path;
281
            }
282
        }
283
284
        return $modules;
285
    }
286
287
    /**
288
     * Get module that owns this class
289
     *
290
     * @param string $class Class name
291
     * @return string
292
     */
293
    public function getOwnerModule($class)
294
    {
295
        $path = realpath($this->getItemPath($class));
296
        if (!$path) {
297
            return null;
298
        }
299
300
        // Find based on loaded modules
301
        foreach ($this->getModules() as $parent => $module) {
302
            if (stripos($path, realpath($parent)) === 0) {
303
                return $module;
304
            }
305
        }
306
307
        // Assume top level folder is the module name
308
        $relativePath = substr($path, strlen(realpath(Director::baseFolder())));
309
        $parts = explode('/', trim($relativePath, '/'));
310
        return array_shift($parts);
311
    }
312
313
    /**
314
     * Completely regenerates the manifest file.
315
     *
316
     * @param bool $cache Cache the result.
317
     */
318
    public function regenerate($cache = true)
319
    {
320
        $resets = array(
321
            'classes', 'roots', 'children', 'descendants', 'interfaces',
322
            'implementors', 'configs', 'configDirs', 'traits'
323
        );
324
325
        // Reset the manifest so stale info doesn't cause errors.
326
        foreach ($resets as $reset) {
327
            $this->$reset = array();
328
        }
329
330
        $finder = new ManifestFileFinder();
331
        $finder->setOptions(array(
332
            'name_regex'    => '/^((_config)|([^_].*))\\.php$/',
333
            'ignore_files'  => array('index.php', 'main.php', 'cli-script.php'),
334
            'ignore_tests'  => !$this->tests,
335
            'file_callback' => array($this, 'handleFile'),
336
            'dir_callback' => array($this, 'handleDir')
337
        ));
338
        $finder->find($this->base);
339
340
        foreach ($this->roots as $root) {
341
            $this->coalesceDescendants($root);
342
        }
343
344
        if ($cache) {
345
            $data = array(
346
                'classes'      => $this->classes,
347
                'descendants'  => $this->descendants,
348
                'interfaces'   => $this->interfaces,
349
                'implementors' => $this->implementors,
350
                'configs'      => $this->configs,
351
                'configDirs'   => $this->configDirs,
352
                'traits'       => $this->traits,
353
            );
354
            $this->cache->save($data, $this->cacheKey);
355
        }
356
    }
357
358
    public function handleDir($basename, $pathname, $depth)
0 ignored issues
show
Unused Code introduced by
The parameter $depth 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...
359
    {
360
        if ($basename == self::CONF_DIR) {
361
            $this->configDirs[] = $pathname;
362
        }
363
    }
364
365
    public function handleFile($basename, $pathname, $depth)
0 ignored issues
show
Unused Code introduced by
The parameter $depth 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...
366
    {
367
        if ($basename == self::CONF_FILE) {
368
            $this->configs[] = $pathname;
369
            return;
370
        }
371
372
        $classes    = null;
373
        $interfaces = null;
374
        $traits = null;
375
376
        // The results of individual file parses are cached, since only a few
377
        // files will have changed and TokenisedRegularExpression is quite
378
        // slow. A combination of the file name and file contents hash are used,
379
        // since just using the datetime lead to problems with upgrading.
380
        $key = preg_replace('/[^a-zA-Z0-9_]/', '_', $basename) . '_' . md5_file($pathname);
381
382
        $valid = false;
383
        if ($data = $this->cache->load($key)) {
384
            $valid = (
385
                isset($data['classes']) && is_array($data['classes'])
386
                && isset($data['interfaces'])
387
                && is_array($data['interfaces'])
388
                && isset($data['traits'])
389
                && is_array($data['traits'])
390
            );
391
392
            if ($valid) {
393
                $classes = $data['classes'];
394
                $interfaces = $data['interfaces'];
395
                $traits = $data['traits'];
396
            }
397
        }
398
399
        if (!$valid) {
400
            $fileContents = ClassContentRemover::remove_class_content($pathname);
401
            try {
402
                $stmts = $this->getParser()->parse($fileContents);
403
            } catch (Error $e) {
404
                // if our mangled contents breaks, try again with the proper file contents
405
                $stmts = $this->getParser()->parse(file_get_contents($pathname));
406
            }
407
            $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...
408
409
            $classes = $this->getVisitor()->getClasses();
410
            $interfaces = $this->getVisitor()->getInterfaces();
411
            $traits = $this->getVisitor()->getTraits();
412
413
            $cache = array(
414
                'classes' => $classes,
415
                'interfaces' => $interfaces,
416
                'traits' => $traits,
417
            );
418
            $this->cache->save($cache, $key);
419
        }
420
421
        foreach ($classes as $className => $classInfo) {
422
            $extends = isset($classInfo['extends']) ? $classInfo['extends'] : null;
423
            $implements = isset($classInfo['interfaces']) ? $classInfo['interfaces'] : null;
424
425
            $lowercaseName = strtolower($className);
426
            if (array_key_exists($lowercaseName, $this->classes)) {
427
                throw new Exception(sprintf(
428
                    'There are two files containing the "%s" class: "%s" and "%s"',
429
                    $className,
430
                    $this->classes[$lowercaseName],
431
                    $pathname
432
                ));
433
            }
434
435
            $this->classes[$lowercaseName] = $pathname;
436
437
            if ($extends) {
438
                foreach ($extends as $ancestor) {
439
                    $ancestor = strtolower($ancestor);
440
441
                    if (!isset($this->children[$ancestor])) {
442
                        $this->children[$ancestor] = array($className);
443
                    } else {
444
                        $this->children[$ancestor][] = $className;
445
                    }
446
                }
447
            } else {
448
                $this->roots[] = $className;
449
            }
450
451
            if ($implements) {
452
                foreach ($implements as $interface) {
453
                    $interface = strtolower($interface);
454
455
                    if (!isset($this->implementors[$interface])) {
456
                        $this->implementors[$interface] = array($className);
457
                    } else {
458
                        $this->implementors[$interface][] = $className;
459
                    }
460
                }
461
            }
462
        }
463
464
        foreach ($interfaces as $interfaceName => $interfaceInfo) {
465
            $this->interfaces[strtolower($interfaceName)] = $pathname;
466
        }
467
        foreach ($traits as $traitName => $traitInfo) {
468
            $this->traits[strtolower($traitName)] = $pathname;
469
        }
470
    }
471
472
    /**
473
     * Recursively coalesces direct child information into full descendant
474
     * information.
475
     *
476
     * @param  string $class
477
     * @return array
478
     */
479
    protected function coalesceDescendants($class)
480
    {
481
        $lClass = strtolower($class);
482
483
        if (array_key_exists($lClass, $this->children)) {
484
            $this->descendants[$lClass] = array();
485
486
            foreach ($this->children[$lClass] as $class) {
487
                $this->descendants[$lClass] = array_merge(
488
                    $this->descendants[$lClass],
489
                    array($class),
490
                    $this->coalesceDescendants($class)
491
                );
492
            }
493
494
            return $this->descendants[$lClass];
495
        } else {
496
            return array();
497
        }
498
    }
499
}
500