mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
639 lines
18 KiB
PHP
639 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* A utility class which builds a manifest of all classes, interfaces and some
|
|
* additional items present in a directory, and caches it.
|
|
*
|
|
* It finds the following information:
|
|
* - Class and interface names and paths.
|
|
* - All direct and indirect descendants of a class.
|
|
* - All implementors of an interface.
|
|
* - All module configuration files.
|
|
*
|
|
* @package framework
|
|
* @subpackage manifest
|
|
*/
|
|
class SS_ClassManifest {
|
|
|
|
const CONF_FILE = '_config.php';
|
|
const CONF_DIR = '_config';
|
|
|
|
protected $base;
|
|
protected $tests;
|
|
protected $cache;
|
|
protected $cacheKey;
|
|
|
|
protected $classes = array();
|
|
protected $roots = array();
|
|
protected $children = array();
|
|
protected $descendants = array();
|
|
protected $interfaces = array();
|
|
protected $implementors = array();
|
|
protected $configs = array();
|
|
protected $configDirs = array();
|
|
|
|
/**
|
|
* @return TokenisedRegularExpression
|
|
*/
|
|
public static function get_class_parser() {
|
|
return new TokenisedRegularExpression(array(
|
|
0 => T_CLASS,
|
|
1 => T_WHITESPACE,
|
|
2 => array(T_STRING, 'can_jump_to' => array(7, 14), 'save_to' => 'className'),
|
|
3 => T_WHITESPACE,
|
|
4 => T_EXTENDS,
|
|
5 => T_WHITESPACE,
|
|
6 => array(T_STRING, 'save_to' => 'extends[]', 'can_jump_to' => 14),
|
|
7 => T_WHITESPACE,
|
|
8 => T_IMPLEMENTS,
|
|
9 => T_WHITESPACE,
|
|
10 => array(T_STRING, 'can_jump_to' => 14, 'save_to' => 'interfaces[]'),
|
|
11 => array(T_WHITESPACE, 'optional' => true),
|
|
12 => array(',', 'can_jump_to' => 10, 'save_to' => 'interfaces[]'),
|
|
13 => array(T_WHITESPACE, 'can_jump_to' => 10),
|
|
14 => array(T_WHITESPACE, 'optional' => true),
|
|
15 => '{',
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return TokenisedRegularExpression
|
|
*/
|
|
public static function get_namespaced_class_parser() {
|
|
return new TokenisedRegularExpression(array(
|
|
0 => T_CLASS,
|
|
1 => array(T_WHITESPACE, 'optional' => true),
|
|
2 => array(T_STRING, 'can_jump_to' => array(8, 16), 'save_to' => 'className'),
|
|
3 => array(T_WHITESPACE, 'optional' => true),
|
|
4 => T_EXTENDS,
|
|
5 => array(T_WHITESPACE, 'optional' => true),
|
|
6 => array(T_NS_SEPARATOR, 'save_to' => 'extends[]', 'optional' => true),
|
|
7 => array(T_STRING, 'save_to' => 'extends[]', 'can_jump_to' => array(6, 16)),
|
|
8 => array(T_WHITESPACE, 'optional' => true),
|
|
9 => T_IMPLEMENTS,
|
|
10 => array(T_WHITESPACE, 'optional' => true),
|
|
11 => array(T_NS_SEPARATOR, 'save_to' => 'interfaces[]', 'optional' => true),
|
|
12 => array(T_STRING, 'can_jump_to' => array(11, 16), 'save_to' => 'interfaces[]'),
|
|
13 => array(T_WHITESPACE, 'optional' => true),
|
|
14 => array(',', 'can_jump_to' => 11, 'save_to' => 'interfaces[]'),
|
|
15 => array(T_WHITESPACE, 'can_jump_to' => 11),
|
|
16 => array(T_WHITESPACE, 'optional' => true),
|
|
17 => '{',
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return TokenisedRegularExpression
|
|
*/
|
|
public static function get_namespace_parser() {
|
|
return new TokenisedRegularExpression(array(
|
|
0 => T_NAMESPACE,
|
|
1 => array(T_WHITESPACE, 'optional' => true),
|
|
2 => array(T_NS_SEPARATOR, 'save_to' => 'namespaceName[]', 'optional' => true),
|
|
3 => array(T_STRING, 'save_to' => 'namespaceName[]', 'can_jump_to' => 2),
|
|
4 => array(T_WHITESPACE, 'optional' => true),
|
|
5 => ';',
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return TokenisedRegularExpression
|
|
*/
|
|
public static function get_interface_parser() {
|
|
return new TokenisedRegularExpression(array(
|
|
0 => T_INTERFACE,
|
|
1 => array(T_WHITESPACE, 'optional' => true),
|
|
2 => array(T_STRING, 'save_to' => 'interfaceName')
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Create a {@link TokenisedRegularExpression} that extracts the namespaces imported with the 'use' keyword
|
|
*
|
|
* This searches symbols for a `use` followed by 1 or more namespaces which are optionally aliased using the `as`
|
|
* keyword. The relevant matching tokens are added one-by-one into an array (using `save_to` param).
|
|
*
|
|
* eg: use Namespace\ClassName as Alias, OtherNamespace\ClassName;
|
|
*
|
|
* @return TokenisedRegularExpression
|
|
*/
|
|
public static function get_imported_namespace_parser() {
|
|
return new TokenisedRegularExpression(array(
|
|
0 => T_USE,
|
|
1 => array(T_WHITESPACE, 'optional' => true),
|
|
2 => array(T_NS_SEPARATOR, 'save_to' => 'importString[]', 'optional' => true),
|
|
3 => array(T_STRING, 'save_to' => 'importString[]', 'can_jump_to' => array(2, 8)),
|
|
4 => array(T_WHITESPACE, 'save_to' => 'importString[]'),
|
|
5 => array(T_AS, 'save_to' => 'importString[]'),
|
|
6 => array(T_WHITESPACE, 'save_to' => 'importString[]'),
|
|
7 => array(T_STRING, 'save_to' => 'importString[]'),
|
|
8 => array(T_WHITESPACE, 'optional' => true),
|
|
9 => array(',', 'save_to' => 'importString[]', 'optional' => true, 'can_jump_to' => 2),
|
|
10 => array(T_WHITESPACE, 'optional' => true, 'can_jump_to' => 2),
|
|
11 => ';',
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Constructs and initialises a new class manifest, either loading the data
|
|
* from the cache or re-scanning for classes.
|
|
*
|
|
* @param string $base The manifest base path.
|
|
* @param bool $includeTests Include the contents of "tests" directories.
|
|
* @param bool $forceRegen Force the manifest to be regenerated.
|
|
* @param bool $cache If the manifest is regenerated, cache it.
|
|
*/
|
|
public function __construct($base, $includeTests = false, $forceRegen = false, $cache = true) {
|
|
$this->base = $base;
|
|
$this->tests = $includeTests;
|
|
|
|
$cacheClass = defined('SS_MANIFESTCACHE') ? SS_MANIFESTCACHE : 'ManifestCache_File';
|
|
|
|
$this->cache = new $cacheClass('classmanifest'.($includeTests ? '_tests' : ''));
|
|
$this->cacheKey = 'manifest';
|
|
|
|
if (!$forceRegen && $data = $this->cache->load($this->cacheKey)) {
|
|
$this->classes = $data['classes'];
|
|
$this->descendants = $data['descendants'];
|
|
$this->interfaces = $data['interfaces'];
|
|
$this->implementors = $data['implementors'];
|
|
$this->configs = $data['configs'];
|
|
$this->configDirs = $data['configDirs'];
|
|
} else {
|
|
$this->regenerate($cache);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the file path to a class or interface if it exists in the
|
|
* manifest.
|
|
*
|
|
* @param string $name
|
|
* @return string|null
|
|
*/
|
|
public function getItemPath($name) {
|
|
$name = strtolower($name);
|
|
|
|
if (isset($this->classes[$name])) {
|
|
return $this->classes[$name];
|
|
} elseif (isset($this->interfaces[$name])) {
|
|
return $this->interfaces[$name];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a map of lowercased class names to file paths.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getClasses() {
|
|
return $this->classes;
|
|
}
|
|
|
|
/**
|
|
* Returns a lowercase array of all the class names in the manifest.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getClassNames() {
|
|
return array_keys($this->classes);
|
|
}
|
|
|
|
/**
|
|
* Returns an array of all the descendant data.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getDescendants() {
|
|
return $this->descendants;
|
|
}
|
|
|
|
/**
|
|
* Returns an array containing all the descendants (direct and indirect)
|
|
* of a class.
|
|
*
|
|
* @param string|object $class
|
|
* @return array
|
|
*/
|
|
public function getDescendantsOf($class) {
|
|
if (is_object($class)) {
|
|
$class = get_class($class);
|
|
}
|
|
|
|
$lClass = strtolower($class);
|
|
|
|
if (array_key_exists($lClass, $this->descendants)) {
|
|
return $this->descendants[$lClass];
|
|
} else {
|
|
return array();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a map of lowercased interface names to file locations.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getInterfaces() {
|
|
return $this->interfaces;
|
|
}
|
|
|
|
/**
|
|
* Returns a map of lowercased interface names to the classes the implement
|
|
* them.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getImplementors() {
|
|
return $this->implementors;
|
|
}
|
|
|
|
/**
|
|
* Returns an array containing the class names that implement a certain
|
|
* interface.
|
|
*
|
|
* @param string $interface
|
|
* @return array
|
|
*/
|
|
public function getImplementorsOf($interface) {
|
|
$interface = strtolower($interface);
|
|
|
|
if (array_key_exists($interface, $this->implementors)) {
|
|
return $this->implementors[$interface];
|
|
} else {
|
|
return array();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns an array of paths to module config files.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getConfigs() {
|
|
return $this->configs;
|
|
}
|
|
|
|
/**
|
|
* Returns an array of module names mapped to their paths.
|
|
*
|
|
* "Modules" in SilverStripe are simply directories with a _config.php
|
|
* file.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getModules() {
|
|
$modules = array();
|
|
|
|
if($this->configs) {
|
|
foreach($this->configs as $configPath) {
|
|
$modules[basename(dirname($configPath))] = dirname($configPath);
|
|
}
|
|
}
|
|
|
|
if($this->configDirs) {
|
|
foreach($this->configDirs as $configDir) {
|
|
$path = preg_replace('/\/_config$/', '', dirname($configDir));
|
|
$modules[basename($path)] = $path;
|
|
}
|
|
}
|
|
|
|
return $modules;
|
|
}
|
|
|
|
/**
|
|
* Used to set up files that we want to exclude from parsing for performance reasons.
|
|
*/
|
|
protected function setDefaults()
|
|
{
|
|
$this->classes['sstemplateparser'] = FRAMEWORK_PATH.'/view/SSTemplateParser.php';
|
|
$this->classes['sstemplateparseexception'] = FRAMEWORK_PATH.'/view/SSTemplateParser.php';
|
|
}
|
|
|
|
/**
|
|
* Completely regenerates the manifest file.
|
|
*
|
|
* @param bool $cache Cache the result.
|
|
*/
|
|
public function regenerate($cache = true) {
|
|
$reset = array(
|
|
'classes', 'roots', 'children', 'descendants', 'interfaces',
|
|
'implementors', 'configs', 'configDirs'
|
|
);
|
|
|
|
// Reset the manifest so stale info doesn't cause errors.
|
|
foreach ($reset as $reset) {
|
|
$this->$reset = array();
|
|
}
|
|
|
|
$this->setDefaults();
|
|
|
|
$finder = new ManifestFileFinder();
|
|
$finder->setOptions(array(
|
|
'name_regex' => '/^(_config.php|[^_].*\.php)$/',
|
|
'ignore_files' => array('index.php', 'main.php', 'cli-script.php', 'SSTemplateParser.php'),
|
|
'ignore_tests' => !$this->tests,
|
|
'file_callback' => array($this, 'handleFile'),
|
|
'dir_callback' => array($this, 'handleDir')
|
|
));
|
|
$finder->find($this->base);
|
|
|
|
foreach ($this->roots as $root) {
|
|
$this->coalesceDescendants($root);
|
|
}
|
|
|
|
if ($cache) {
|
|
$data = array(
|
|
'classes' => $this->classes,
|
|
'descendants' => $this->descendants,
|
|
'interfaces' => $this->interfaces,
|
|
'implementors' => $this->implementors,
|
|
'configs' => $this->configs,
|
|
'configDirs' => $this->configDirs
|
|
);
|
|
$this->cache->save($data, $this->cacheKey);
|
|
}
|
|
}
|
|
|
|
public function handleDir($basename, $pathname, $depth) {
|
|
if ($basename == self::CONF_DIR) {
|
|
$this->configDirs[] = $pathname;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find a the full namespaced declaration of a class (or interface) from a list of candidate imports
|
|
*
|
|
* This is typically used to determine the full class name in classes that have imported namesapced symbols (having
|
|
* used the `use` keyword)
|
|
*
|
|
* NB: remember the '\\' is an escaped backslash and is interpreted as a single \
|
|
*
|
|
* @param string $class The class (or interface) name to find in the candidate imports
|
|
* @param string $namespace The namespace that was declared for the classes definition (if there was one)
|
|
* @param array $imports The list of imported symbols (Classes or Interfaces) to test against
|
|
*
|
|
* @return string The fully namespaced class name
|
|
*/
|
|
protected function findClassOrInterfaceFromCandidateImports($class, $namespace = '', $imports = array()) {
|
|
|
|
//normalise the namespace
|
|
$namespace = rtrim($namespace, '\\');
|
|
|
|
//by default we'll use the $class as our candidate
|
|
$candidateClass = $class;
|
|
|
|
if (!$class) {
|
|
return $candidateClass;
|
|
}
|
|
//if the class starts with a \ then it is explicitly in the global namespace and we don't need to do
|
|
// anything else
|
|
if (substr($class, 0, 1) == '\\') {
|
|
$candidateClass = substr($class, 1);
|
|
return $candidateClass;
|
|
}
|
|
//if there's a namespace, starting assumption is the class is defined in that namespace
|
|
if ($namespace) {
|
|
$candidateClass = $namespace . '\\' . $class;
|
|
}
|
|
|
|
if (empty($imports)) {
|
|
return $candidateClass;
|
|
}
|
|
|
|
//normalised class name (PHP is case insensitive for symbols/namespaces
|
|
$lClass = strtolower($class);
|
|
|
|
//go through all the imports and see if the class exists within one of them
|
|
foreach ($imports as $alias => $import) {
|
|
//normalise import
|
|
$import = trim($import, '\\');
|
|
|
|
//if there is no string key, then there was no declared alias - we'll use the main declaration
|
|
if (is_int($alias)) {
|
|
$alias = strtolower($import);
|
|
} else {
|
|
$alias = strtolower($alias);
|
|
}
|
|
|
|
//exact match? Then it's a class in the global namespace that was imported OR it's an alias of
|
|
// another namespace
|
|
// or if it ends with the \ClassName then it's the class we are looking for
|
|
if ($lClass == $alias
|
|
|| substr_compare(
|
|
$alias,
|
|
'\\' . $lClass,
|
|
strlen($alias) - strlen($lClass) - 1,
|
|
// -1 because the $lClass length is 1 longer due to \
|
|
strlen($alias)
|
|
) === 0
|
|
) {
|
|
$candidateClass = $import;
|
|
break;
|
|
}
|
|
}
|
|
return $candidateClass;
|
|
}
|
|
|
|
/**
|
|
* Return an array of array($alias => $import) from tokenizer's tokens of a PHP file
|
|
*
|
|
* NB: If there is no alias we don't set a key to the array
|
|
*
|
|
* @param array $tokens The parsed tokens from tokenizer's parsing of a PHP file
|
|
*
|
|
* @return array The array of imports as (optional) $alias => $import
|
|
*/
|
|
protected function getImportsFromTokens($tokens) {
|
|
//parse out the imports
|
|
$imports = self::get_imported_namespace_parser()->findAll($tokens);
|
|
|
|
//if there are any imports, clean them up
|
|
// imports come to us as array('importString' => array([array of matching tokens]))
|
|
// we need to join this nested array into a string and split out the alias and the import
|
|
if (!empty($imports)) {
|
|
$cleanImports = array();
|
|
foreach ($imports as $import) {
|
|
if (!empty($import['importString'])) {
|
|
//join the array up into a string
|
|
$importString = implode('', $import['importString']);
|
|
//split at , to get each import declaration
|
|
$importSet = explode(',', $importString);
|
|
foreach ($importSet as $importDeclaration) {
|
|
//split at ' as ' (any case) to see if we are aliasing the namespace
|
|
$importDeclaration = preg_split('/\s+as\s+/i', $importDeclaration);
|
|
//shift off the fully namespaced import
|
|
$qualifiedImport = array_shift($importDeclaration);
|
|
//if there are still items in the array, it's the alias
|
|
if (!empty($importDeclaration)) {
|
|
$cleanImports[array_shift($importDeclaration)] = $qualifiedImport;
|
|
}
|
|
else {
|
|
$cleanImports[] = $qualifiedImport;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$imports = $cleanImports;
|
|
}
|
|
return $imports;
|
|
}
|
|
|
|
public function handleFile($basename, $pathname, $depth) {
|
|
if ($basename == self::CONF_FILE) {
|
|
$this->configs[] = $pathname;
|
|
return;
|
|
}
|
|
|
|
$classes = null;
|
|
$interfaces = null;
|
|
$namespace = null;
|
|
$imports = null;
|
|
|
|
// The results of individual file parses are cached, since only a few
|
|
// files will have changed and TokenisedRegularExpression is quite
|
|
// slow. A combination of the file name and file contents hash are used,
|
|
// since just using the datetime lead to problems with upgrading.
|
|
$key = preg_replace('/[^a-zA-Z0-9_]/', '_', $basename) . '_' . md5_file($pathname);
|
|
|
|
if ($data = $this->cache->load($key)) {
|
|
$valid = (
|
|
isset($data['classes']) && is_array($data['classes'])
|
|
&& isset($data['interfaces']) && is_array($data['interfaces'])
|
|
&& isset($data['namespace']) && is_string($data['namespace'])
|
|
&& isset($data['imports']) && is_array($data['imports'])
|
|
);
|
|
|
|
if ($valid) {
|
|
$classes = $data['classes'];
|
|
$interfaces = $data['interfaces'];
|
|
$namespace = $data['namespace'];
|
|
$imports = $data['imports'];
|
|
}
|
|
}
|
|
|
|
if (!$classes) {
|
|
$tokens = token_get_all(file_get_contents($pathname));
|
|
|
|
$classes = self::get_namespaced_class_parser()->findAll($tokens);
|
|
|
|
$namespace = self::get_namespace_parser()->findAll($tokens);
|
|
|
|
if($namespace) {
|
|
$namespace = implode('', $namespace[0]['namespaceName']);
|
|
} else {
|
|
$namespace = '';
|
|
}
|
|
|
|
$imports = $this->getImportsFromTokens($tokens);
|
|
|
|
$interfaces = self::get_interface_parser()->findAll($tokens);
|
|
|
|
$cache = array(
|
|
'classes' => $classes,
|
|
'interfaces' => $interfaces,
|
|
'namespace' => $namespace,
|
|
'imports' => $imports
|
|
);
|
|
$this->cache->save($cache, $key);
|
|
}
|
|
|
|
foreach ($classes as $class) {
|
|
$name = $class['className'];
|
|
if ($namespace) {
|
|
$namespace = rtrim($namespace, '\\');
|
|
$name = $namespace . '\\' . $name;
|
|
}
|
|
$extends = isset($class['extends']) ? implode('', $class['extends']) : null;
|
|
$implements = isset($class['interfaces']) ? $class['interfaces'] : null;
|
|
|
|
if ($extends) {
|
|
$extends = $this->findClassOrInterfaceFromCandidateImports($extends, $namespace, $imports);
|
|
}
|
|
|
|
if (!empty($implements)) {
|
|
//join all the tokens
|
|
$implements = implode('', $implements);
|
|
//split at comma
|
|
$implements = explode(',', $implements);
|
|
//normalise interfaces
|
|
foreach ($implements as &$interface) {
|
|
$interface = $this->findClassOrInterfaceFromCandidateImports($interface, $namespace, $imports);
|
|
}
|
|
//release the var name
|
|
unset($interface);
|
|
}
|
|
|
|
$lowercaseName = strtolower($name);
|
|
if (array_key_exists($lowercaseName, $this->classes)) {
|
|
throw new Exception(sprintf(
|
|
'There are two files containing the "%s" class: "%s" and "%s"',
|
|
$name, $this->classes[$lowercaseName], $pathname
|
|
));
|
|
}
|
|
|
|
$this->classes[$lowercaseName] = $pathname;
|
|
|
|
if ($extends) {
|
|
$extends = strtolower($extends);
|
|
|
|
if (!isset($this->children[$extends])) {
|
|
$this->children[$extends] = array($name);
|
|
} else {
|
|
$this->children[$extends][] = $name;
|
|
}
|
|
} else {
|
|
$this->roots[] = $name;
|
|
}
|
|
|
|
if ($implements) {
|
|
foreach ($implements as $interface) {
|
|
$interface = strtolower($interface);
|
|
|
|
if (!isset($this->implementors[$interface])) {
|
|
$this->implementors[$interface] = array($name);
|
|
} else {
|
|
$this->implementors[$interface][] = $name;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$interfaceBase = '';
|
|
if ($namespace) {
|
|
$interfaceBase = $namespace . '\\';
|
|
}
|
|
foreach ($interfaces as $interface) {
|
|
$this->interfaces[strtolower($interfaceBase . $interface['interfaceName'])] = $pathname;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively coalesces direct child information into full descendant
|
|
* information.
|
|
*
|
|
* @param string $class
|
|
* @return array
|
|
*/
|
|
protected function coalesceDescendants($class) {
|
|
$result = array();
|
|
$lClass = strtolower($class);
|
|
|
|
if (array_key_exists($lClass, $this->children)) {
|
|
$this->descendants[$lClass] = array();
|
|
|
|
foreach ($this->children[$lClass] as $class) {
|
|
$this->descendants[$lClass] = array_merge(
|
|
$this->descendants[$lClass],
|
|
array($class),
|
|
$this->coalesceDescendants($class)
|
|
);
|
|
}
|
|
|
|
return $this->descendants[$lClass];
|
|
} else {
|
|
return array();
|
|
}
|
|
}
|
|
|
|
}
|