diff --git a/.travis.yml b/.travis.yml index 7007162..6361108 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,4 +11,4 @@ before_script: - composer install --prefer-dist script: - composer lint - #- composer test + - composer test diff --git a/Classes/Utility/ComponentLoader.php b/Classes/Utility/ComponentLoader.php index 8dc1e0d..e556120 100644 --- a/Classes/Utility/ComponentLoader.php +++ b/Classes/Utility/ComponentLoader.php @@ -37,6 +37,10 @@ public function __construct() */ public function addNamespace(string $namespace, string $path): self { + // Sanitize namespace data + $namespace = $this->sanitizeNamespace($namespace); + $path = $this->sanitizePath($path); + $this->namespaces[$namespace] = $path; return $this; } @@ -61,9 +65,15 @@ public function removeNamespace(string $namespace): self */ public function setNamespaces(array $namespaces): self { + // Make sure that namespaces are sanitized + $this->namespaces = []; + foreach ($namespaces as $namespace => $path) { + $this->addNamespace($namespace, $path); + } + // Order by namespace specificity - arsort($namespaces); - $this->namespaces = $namespaces; + krsort($this->namespaces); + return $this; } @@ -87,32 +97,122 @@ public function getNamespaces(): array public function findComponent(string $class, string $ext = '.html') { // Try cache first - if (isset($this->componentsCache[$class])) { - return $this->componentsCache[$class] . $ext; + $cacheIdentifier = $class . '|' . $ext; + if (isset($this->componentsCache[$cacheIdentifier])) { + return $this->componentsCache[$cacheIdentifier]; } // Walk through available namespaces, ordered from specific to unspecific $class = ltrim($class, '\\'); foreach ($this->namespaces as $namespace => $path) { - $namespace = ltrim($namespace, '\\'); - // No match, skip to next - if (strpos($class, $namespace) !== 0) { + if (strpos($class, $namespace . '\\') !== 0) { continue; } $componentParts = explode('\\', trim(substr($class, strlen($namespace)), '\\')); - $componentPath = rtrim($path, '/') . '/' . implode('/', $componentParts) . '/' . end($componentParts); - $componentFile = $componentPath . $ext; + $componentPath = $path . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $componentParts); + $componentFile = $componentPath . DIRECTORY_SEPARATOR . end($componentParts) . $ext; // Check if component file exists if (file_exists($componentFile)) { - $this->componentsCache[$class] = $componentPath; + $this->componentsCache[$cacheIdentifier] = $componentFile; return $componentFile; } } return null; } + + /** + * Provides a list of all components that are available in the specified component namespace + * + * @param string $namespace + * @param string $ext + * @return array Array of components where the keys contain the component identifier (FQCN) + * and the values contain the path to the component + */ + public function findComponentsInNamespace(string $namespace, string $ext = '.html'): array + { + if (!isset($this->namespaces[$namespace])) { + return []; + } + + $scannedPaths = []; + return $this->scanForComponents( + $this->namespaces[$namespace], + $ext, + $namespace, + $scannedPaths + ); + } + + /** + * Searches for component files in a directory and maps them to their namespace + * + * @param string $path + * @param string $ext + * @param string $namespace + * @param array $scannedPaths Collection of paths that have already been scanned for components; + * this prevents infinite loops caused by circular symlinks + * @return array + */ + protected function scanForComponents(string $path, string $ext, string $namespace, array &$scannedPaths): array + { + $components = []; + + $componentCandidates = scandir($path); + foreach ($componentCandidates as $componentName) { + // Skip relative links + if ($componentName === '.' || $componentName === '..') { + continue; + } + + // Only search for directories and prevent infinite loops + $componentPath = realpath($path . DIRECTORY_SEPARATOR . $componentName); + if (!is_dir($componentPath) || isset($scannedPaths[$componentPath])) { + continue; + } + $scannedPaths[$componentPath] = true; + + $componentNamespace = $namespace . '\\' . $componentName; + $componentFile = $componentPath . DIRECTORY_SEPARATOR . $componentName . $ext; + + // Only match folders that contain a component file + if (file_exists($componentFile)) { + $components[$componentNamespace] = $componentFile; + } + + // Continue recursively + $components = array_merge( + $components, + $this->scanForComponents($componentPath, $ext, $componentNamespace, $scannedPaths) + ); + } + + return $components; + } + + /** + * Sanitizes a PHP namespace for use in the component loader + * + * @param string $namespace + * @return string + */ + protected function sanitizeNamespace(string $namespace): string + { + return trim($namespace, '\\'); + } + + /** + * Sanitizes a path for use in the component loader + * + * @param string $path + * @return string + */ + protected function sanitizePath(string $path): string + { + return rtrim($path, DIRECTORY_SEPARATOR); + } } diff --git a/Tests/Fixtures/ComponentLoader/Atom/Button/Button.html b/Tests/Fixtures/ComponentLoader/Atom/Button/Button.html new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Fixtures/ComponentLoader/Atom/Button/Button.test b/Tests/Fixtures/ComponentLoader/Atom/Button/Button.test new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Fixtures/ComponentLoader/Atom/ComponentLoaderSymlink b/Tests/Fixtures/ComponentLoader/Atom/ComponentLoaderSymlink new file mode 120000 index 0000000..93df1d4 --- /dev/null +++ b/Tests/Fixtures/ComponentLoader/Atom/ComponentLoaderSymlink @@ -0,0 +1 @@ +../../ComponentLoaderSymlink/ \ No newline at end of file diff --git a/Tests/Fixtures/ComponentLoader/Atom/Link/Link.html b/Tests/Fixtures/ComponentLoader/Atom/Link/Link.html new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Fixtures/ComponentLoader/Atom/Link/Link.test b/Tests/Fixtures/ComponentLoader/Atom/Link/Link.test new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Fixtures/ComponentLoader/Example/Example.html b/Tests/Fixtures/ComponentLoader/Example/Example.html new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Fixtures/ComponentLoader/Molecule/Circular b/Tests/Fixtures/ComponentLoader/Molecule/Circular new file mode 120000 index 0000000..b870225 --- /dev/null +++ b/Tests/Fixtures/ComponentLoader/Molecule/Circular @@ -0,0 +1 @@ +../ \ No newline at end of file diff --git a/Tests/Fixtures/ComponentLoader/Molecule/Example b/Tests/Fixtures/ComponentLoader/Molecule/Example new file mode 120000 index 0000000..6b784d7 --- /dev/null +++ b/Tests/Fixtures/ComponentLoader/Molecule/Example @@ -0,0 +1 @@ +../Example/ \ No newline at end of file diff --git a/Tests/Fixtures/ComponentLoader/Molecule/Teaser/Headline/Headline.html b/Tests/Fixtures/ComponentLoader/Molecule/Teaser/Headline/Headline.html new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Fixtures/ComponentLoader/Molecule/Teaser/Teaser.html b/Tests/Fixtures/ComponentLoader/Molecule/Teaser/Teaser.html new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Fixtures/ComponentLoaderSymlink/ComponentLoaderSymlink.html b/Tests/Fixtures/ComponentLoaderSymlink/ComponentLoaderSymlink.html new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Unit/ComponentLoaderTest.php b/Tests/Unit/ComponentLoaderTest.php new file mode 100644 index 0000000..2ee8604 --- /dev/null +++ b/Tests/Unit/ComponentLoaderTest.php @@ -0,0 +1,215 @@ +loader = new ComponentLoader(); + } + + protected function getFixturePath($fixtureName) + { + return realpath(dirname(__FILE__) . '/../Fixtures/' . $fixtureName); + } + + public function addNamespaceProvider() + { + return [ + 'namespaceWithLeadingTrailingBackslash' => [ + '\\Vendor\\Extension\\Category\\', + '/path/to/components', + [ + 'Vendor\\Extension\\Category' => '/path/to/components' + ] + ], + 'pathWithTrailingSlash' => [ + 'Vendor\\Extension\\Category\\', + '/path/to/components/', + [ + 'Vendor\\Extension\\Category' => '/path/to/components' + ] + ] + ]; + } + + /** + * @test + * @dataProvider addNamespaceProvider + */ + public function addNamespace(string $namespace, string $path, array $namespaces) + { + $this->loader->addNamespace($namespace, $path); + $this->assertEquals( + $namespaces, + $this->loader->getNamespaces() + ); + } + + /** + * @test + * @depends addNamespace + */ + public function removeNamespace() + { + $namespace = 'Vendor\\Extension\\Category'; + $this->loader + ->addNamespace($namespace, 'some/path') + ->removeNamespace($namespace); + + $this->assertEquals( + [], + $this->loader->getNamespaces() + ); + } + + public function setNamespacesProvider() + { + return [ + 'case1' => [ + [ + 'Sitegeist\\Fixtures\\ComponentLoader' => '/case1/path1', + 'Sitegeist\\Fixtures\\Component\\Loader' => '/case1/path2', + 'Sitegeist\\Fixtures\\ComponentLoader\\Test' => '/case1/path3', + 'Vendor\\Test\\Namespace' => '/case1/path4', + '\\Sitegeist\\Fixtures\\ComponentLoader' => '/case1/path5', + '\\Sitegeist\\Fixtures\\ComponentLoader\\' => '/case1/path6', + '\\Sitegeist\\Fixtures\\AnotherTest\\' => '/case1/path7', + '\\Sitegeist\\Fixtures\\Test\\' => '/case1/path8' + ], + [ + 'Vendor\\Test\\Namespace' => '/case1/path4', + 'Sitegeist\\Fixtures\\Test' => '/case1/path8', + 'Sitegeist\\Fixtures\\AnotherTest' => '/case1/path7', + 'Sitegeist\\Fixtures\\ComponentLoader\\Test' => '/case1/path3', + 'Sitegeist\\Fixtures\\ComponentLoader' => '/case1/path6', + 'Sitegeist\\Fixtures\\Component\\Loader' => '/case1/path2' + ], + ] + ]; + } + + /** + * @test + * @dataProvider setNamespacesProvider + */ + public function setNamespaces(array $namespaces, array $sortedNamespaces) + { + $this->loader->setNamespaces($namespaces); + $this->assertEquals($sortedNamespaces, $this->loader->getNamespaces()); + } + + public function findComponentProvider() + { + return [ + 'existingComponent' => [ + 'Sitegeist\\Fixtures\\ComponentLoader\\Atom\\Button', + '.html', + $this->getFixturePath('ComponentLoader/Atom/Button/Button.html') + ], + 'existingComponentFileExtension' => [ + 'Sitegeist\\Fixtures\\ComponentLoader\\Atom\\Button', + '.test', + $this->getFixturePath('ComponentLoader/Atom/Button/Button.test') + ], + 'existingComponentFirstLevel' => [ + 'Sitegeist\\Fixtures\\ComponentLoader\\Example', + '.html', + $this->getFixturePath('ComponentLoader/Example/Example.html') + ], + 'existingComponentThirdLevel' => [ + 'Sitegeist\\Fixtures\\ComponentLoader\\Molecule\\Teaser\\Headline', + '.html', + $this->getFixturePath('ComponentLoader/Molecule/Teaser/Headline/Headline.html') + ], + 'nonexistingComponent' => [ + 'Sitegeist\\Fixtures\\ComponentLoader\\Atom\\Label', + '.html', + null + ], + 'nonexistingComponentFileExtension' => [ + 'Sitegeist\\Fixtures\\ComponentLoader\\Atom\\Button', + '.nonexisting', + null + ], + 'nonexistingComponentNamespace' => [ + 'Sitegeist\\Fixtures\\Nonexisting\\Component', + '.html', + null + ], + ]; + } + + /** + * @test + * @depends addNamespace + * @dataProvider findComponentProvider + */ + public function findComponent(string $componentIdentifier, string $fileExtension, $result) + { + $this->loader->addNamespace( + 'Sitegeist\\Fixtures\\ComponentLoader', + $this->getFixturePath('ComponentLoader') + ); + + // Test uncached version + $this->assertEquals( + $result, + $this->loader->findComponent($componentIdentifier, $fileExtension) + ); + + // Test cached version + $this->assertEquals( + $result, + $this->loader->findComponent($componentIdentifier, $fileExtension) + ); + } + + public function findComponentsInNamespaceProvider() + { + return [ + 'html' => [ + '.html', + [ + 'Sitegeist\\Fixtures\\ComponentLoader\\Atom\\Button' => $this->getFixturePath('ComponentLoader/Atom/Button/Button.html'), + 'Sitegeist\\Fixtures\\ComponentLoader\\Atom\\ComponentLoaderSymlink' => $this->getFixturePath('ComponentLoader/Atom/ComponentLoaderSymlink/ComponentLoaderSymlink.html'), + 'Sitegeist\\Fixtures\\ComponentLoader\\Atom\\Link' => $this->getFixturePath('ComponentLoader/Atom/Link/Link.html'), + 'Sitegeist\\Fixtures\\ComponentLoader\\Example' => $this->getFixturePath('ComponentLoader/Example/Example.html'), + 'Sitegeist\\Fixtures\\ComponentLoader\\Molecule\\Teaser' => $this->getFixturePath('ComponentLoader/Molecule/Teaser/Teaser.html'), + 'Sitegeist\\Fixtures\\ComponentLoader\\Molecule\\Teaser\\Headline' => $this->getFixturePath('ComponentLoader/Molecule/Teaser/Headline/Headline.html') + ] + ], + 'test' => [ + '.test', + [ + 'Sitegeist\\Fixtures\\ComponentLoader\\Atom\\Button' => $this->getFixturePath('ComponentLoader/Atom/Button/Button.test'), + 'Sitegeist\\Fixtures\\ComponentLoader\\Atom\\Link' => $this->getFixturePath('ComponentLoader/Atom/Link/Link.test') + ] + ] + ]; + } + + /** + * @test + * @depends addNamespace + * @dataProvider findComponentsInNamespaceProvider + */ + public function findComponentsInNamespace(string $fileExtension, array $result) + { + $namespace = 'Sitegeist\\Fixtures\\ComponentLoader'; + $this->loader->addNamespace( + $namespace, + $this->getFixturePath('ComponentLoader') + ); + + $this->assertEquals( + $result, + $this->loader->findComponentsInNamespace($namespace, $fileExtension) + ); + } +}