diff --git a/manifest/ClassManifest.php b/manifest/ClassManifest.php new file mode 100644 index 000000000..c45c71fc5 --- /dev/null +++ b/manifest/ClassManifest.php @@ -0,0 +1,360 @@ + 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), + 13 => array(T_WHITESPACE, 'can_jump_to' => 10), + 14 => array(T_WHITESPACE, 'optional' => true), + 15 => '{', + )); + } + + /** + * @return TokenisedRegularExpression + */ + public static function get_interface_parser() { + return new TokenisedRegularExpression(array( + 0 => T_INTERFACE, + 1 => T_WHITESPACE, + 2 => array(T_STRING, 'can_jump_to' => 7, 'save_to' => 'interfaceName'), + 3 => T_WHITESPACE, + 4 => T_EXTENDS, + 5 => T_WHITESPACE, + 6 => array(T_STRING, 'save_to' => 'extends'), + 7 => array(T_WHITESPACE, 'optional' => true), + 8 => '{', + )); + } + + /** + * 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. + */ + public function __construct($base, $includeTests = false, $forceRegen = false) { + $this->base = $base; + $this->tests = $includeTests; + + $this->cache = SS_Cache::factory('SS_ClassManifest', 'Core', array( + 'automatic_serialization' => true, + 'lifetime' => null + )); + $this->cacheKey = $this->tests ? 'manifest_tests' : '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']; + } else { + $this->regenerate(); + } + } + + /** + * 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; + } + + /** + * 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' + ); + + // Reset the manifest so stale info doesn't cause errors. + foreach ($reset as $reset) { + $this->$reset = array(); + } + + $finder = new ManifestFileFinder(); + $finder->setOptions(array( + 'name_regex' => '/\.php$/', + 'ignore_files' => array('index.php', 'main.php', 'cli-script.php'), + 'ignore_tests' => !$this->tests, + 'file_callback' => array($this, 'handleFile') + )); + $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 + ); + $this->cache->save($data, $this->cacheKey); + } + } + + public function handleFile($basename, $pathname, $depth) { + if ($depth == 1 && $basename == self::CONF_FILE) { + $this->configs[] = $pathname; + return; + } + + $classes = null; + $interfaces = 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. + $file = file_get_contents($pathname); + $key = preg_replace('/[^a-zA-Z0-9_]/', '_', $basename) . '_' . md5($file); + + if ($data = $this->cache->load($key)) { + $valid = ( + isset($data['classes']) && isset($data['interfaces']) + && is_array($data['classes']) && is_array($data['interfaces']) + ); + + if ($valid) { + $classes = $data['classes']; + $interfaces = $data['interfaces']; + } + } + + if (!$classes) { + $tokens = token_get_all($file); + $classes = self::get_class_parser()->findAll($tokens); + $interfaces = self::get_interface_parser()->findAll($tokens); + + $cache = array('classes' => $classes, 'interfaces' => $interfaces); + $this->cache->save($cache, $key, array('fileparse')); + } + + foreach ($classes as $class) { + $name = $class['className']; + $extends = isset($class['extends']) ? $class['extends'] : null; + $implements = isset($class['interfaces']) ? $class['interfaces'] : null; + + if (array_key_exists($name, $this->classes)) { + throw new Exception(sprintf( + 'There are two files containing the "%s" class: "%s" and "%s"', + $name, $this->classes[$name], $pathname + )); + } + + $this->classes[strtolower($name)] = $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; + } + } + } + + foreach ($interfaces as $interface) { + $this->interfaces[strtolower($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(); + } + } + +} \ No newline at end of file diff --git a/tests/manifest/ClassManifestTest.php b/tests/manifest/ClassManifestTest.php new file mode 100644 index 000000000..ac65467c7 --- /dev/null +++ b/tests/manifest/ClassManifestTest.php @@ -0,0 +1,114 @@ +base = dirname(__FILE__) . '/fixtures/classmanifest'; + $this->manifest = new SS_ClassManifest($this->base, false, true); + $this->manifestTests = new SS_ClassManifest($this->base, true, true); + } + + public function testGetItemPath() { + $expect = array( + 'CLASSA' => 'module/classes/ClassA.php', + 'ClassA' => 'module/classes/ClassA.php', + 'classa' => 'module/classes/ClassA.php', + 'INTERFACEA' => 'module/interfaces/InterfaceA.php', + 'InterfaceA' => 'module/interfaces/InterfaceA.php', + 'interfacea' => 'module/interfaces/InterfaceA.php' + ); + + foreach ($expect as $name => $path) { + $this->assertEquals("{$this->base}/$path", $this->manifest->getItemPath($name)); + } + } + + public function testGetClasses() { + $expect = array( + 'classa' => "{$this->base}/module/classes/ClassA.php", + 'classb' => "{$this->base}/module/classes/ClassB.php", + 'classc' => "{$this->base}/module/classes/ClassC.php", + 'classd' => "{$this->base}/module/classes/ClassD.php" + ); + $this->assertEquals($expect, $this->manifest->getClasses()); + } + + public function testGetClassNames() { + $this->assertEquals( + array('classa', 'classb', 'classc', 'classd'), + $this->manifest->getClassNames()); + } + + public function testGetDescendants() { + $expect = array( + 'classa' => array('ClassC', 'ClassD'), + 'classc' => array('ClassD') + ); + $this->assertEquals($expect, $this->manifest->getDescendants()); + } + + public function testGetDescendantsOf() { + $expect = array( + 'CLASSA' => array('ClassC', 'ClassD'), + 'classa' => array('ClassC', 'ClassD'), + 'CLASSC' => array('ClassD'), + 'classc' => array('ClassD') + ); + + foreach ($expect as $class => $desc) { + $this->assertEquals($desc, $this->manifest->getDescendantsOf($class)); + } + } + + public function testGetInterfaces() { + $expect = array( + 'interfacea' => "{$this->base}/module/interfaces/InterfaceA.php", + 'interfaceb' => "{$this->base}/module/interfaces/InterfaceB.php" + ); + $this->assertEquals($expect, $this->manifest->getInterfaces()); + } + + public function testGetImplementors() { + $expect = array( + 'interfacea' => array('ClassB'), + 'interfaceb' => array('ClassC') + ); + $this->assertEquals($expect, $this->manifest->getImplementors()); + } + + public function testGetImplementorsOf() { + $expect = array( + 'INTERFACEA' => array('ClassB'), + 'interfacea' => array('ClassB'), + 'INTERFACEB' => array('ClassC'), + 'interfaceb' => array('ClassC') + ); + + foreach ($expect as $interface => $impl) { + $this->assertEquals($impl, $this->manifest->getImplementorsOf($interface)); + } + } + + public function testGetConfigs() { + $expect = array("{$this->base}/module/_config.php"); + $this->assertEquals($expect, $this->manifest->getConfigs()); + $this->assertEquals($expect, $this->manifestTests->getConfigs()); + } + + public function testTestManifestIncludesTestClasses() { + $this->assertNotContains('testclassa', array_keys($this->manifest->getClasses())); + $this->assertContains('testclassa', array_keys($this->manifestTests->getClasses())); + } + +} \ No newline at end of file diff --git a/tests/manifest/fixtures/classmanifest/module/_config.php b/tests/manifest/fixtures/classmanifest/module/_config.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/manifest/fixtures/classmanifest/module/classes/ClassA.php b/tests/manifest/fixtures/classmanifest/module/classes/ClassA.php new file mode 100644 index 000000000..375e6bb56 --- /dev/null +++ b/tests/manifest/fixtures/classmanifest/module/classes/ClassA.php @@ -0,0 +1,5 @@ +