diff --git a/setup/src/Magento/Setup/Module/Di/Code/Reader/ClassesScanner.php b/setup/src/Magento/Setup/Module/Di/Code/Reader/ClassesScanner.php index 972f7570b40ab..a86558ce898ce 100644 --- a/setup/src/Magento/Setup/Module/Di/Code/Reader/ClassesScanner.php +++ b/setup/src/Magento/Setup/Module/Di/Code/Reader/ClassesScanner.php @@ -5,8 +5,9 @@ */ namespace Magento\Setup\Module\Di\Code\Reader; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\FileSystemException; -use Magento\Setup\Module\Di\Code\Reader\FileScanner; class ClassesScanner implements ClassesScannerInterface { @@ -15,12 +16,27 @@ class ClassesScanner implements ClassesScannerInterface */ protected $excludePatterns = []; + /** + * @var array + */ + private $fileResults = []; + + /** + * @var string + */ + private $generationDirectory; + /** * @param array $excludePatterns + * @param string $generationDirectory */ - public function __construct(array $excludePatterns = []) + public function __construct(array $excludePatterns = [], DirectoryList $directoryList = null) { $this->excludePatterns = $excludePatterns; + if ($directoryList === null) { + $directoryList = ObjectManager::getInstance()->get(DirectoryList::class); + } + $this->generationDirectory = $directoryList->getPath(DirectoryList::GENERATION); } /** @@ -44,7 +60,14 @@ public function addExcludePatterns(array $excludePatterns) */ public function getList($path) { + $realPath = realpath($path); + $isGeneration = strpos($realPath, $this->generationDirectory) === 0; + + // Generation folders should not have their results cached since they may actually change during compile + if (!$isGeneration && isset($this->fileResults[$realPath])) { + return $this->fileResults[$realPath]; + } if (!(bool)$realPath) { throw new FileSystemException(new \Magento\Framework\Phrase('Invalid path: %1', [$path])); } @@ -53,46 +76,71 @@ public function getList($path) \RecursiveIteratorIterator::SELF_FIRST ); + $classes = $this->extract($recursiveIterator); + if (!$isGeneration) { + $this->fileResults[$realPath] = $classes; + } + return $classes; + } + + /** + * Extracts all the classes from the recursive iterator + * + * @param \RecursiveIteratorIterator $recursiveIterator + * @return array + */ + private function extract(\RecursiveIteratorIterator $recursiveIterator) + { $classes = []; foreach ($recursiveIterator as $fileItem) { /** @var $fileItem \SplFileInfo */ if ($fileItem->isDir() || $fileItem->getExtension() !== 'php' || $fileItem->getBasename()[0] == '.') { continue; } + $fileItemPath = $fileItem->getRealPath(); foreach ($this->excludePatterns as $excludePatterns) { - if ($this->isExclude($fileItem, $excludePatterns)) { + if ($this->isExclude($fileItemPath, $excludePatterns)) { continue 2; } } - $fileScanner = new FileScanner($fileItem->getRealPath()); + $fileScanner = new FileClassScanner($fileItemPath); $classNames = $fileScanner->getClassNames(); - foreach ($classNames as $className) { - if (empty($className)) { - continue; - } - if (!class_exists($className)) { - require_once $fileItem->getRealPath(); - } - $classes[] = $className; - } + $this->includeClasses($classNames, $fileItemPath); + $classes = array_merge($classes, $classNames); } return $classes; } + /** + * @param array $classNames + * @param string $fileItemPath + * @return bool Whether the clas is included or not + */ + private function includeClasses(array $classNames, $fileItemPath) + { + foreach ($classNames as $className) { + if (!class_exists($className)) { + require_once $fileItemPath; + return true; + } + } + return false; + } + /** * Find out if file should be excluded * - * @param \SplFileInfo $fileItem + * @param string $fileItemPath * @param string $patterns * @return bool */ - private function isExclude(\SplFileInfo $fileItem, $patterns) + private function isExclude($fileItemPath, $patterns) { if (!is_array($patterns)) { $patterns = (array)$patterns; } foreach ($patterns as $pattern) { - if (preg_match($pattern, str_replace('\\', '/', $fileItem->getRealPath()))) { + if (preg_match($pattern, str_replace('\\', '/', $fileItemPath))) { return true; } } diff --git a/setup/src/Magento/Setup/Module/Di/Code/Reader/FileClassScanner.php b/setup/src/Magento/Setup/Module/Di/Code/Reader/FileClassScanner.php new file mode 100644 index 0000000000000..30dad35a2bcb9 --- /dev/null +++ b/setup/src/Magento/Setup/Module/Di/Code/Reader/FileClassScanner.php @@ -0,0 +1,171 @@ +filename = $filename; + } + + /** + * Retrieves the contents of a file. Mostly here for Mock injection + * + * @return string + */ + public function getFileContents() + { + return file_get_contents($this->filename); + } + + /** + * Extracts the fully qualified class name from a file. It only searches for the first match and stops looking + * as soon as it enters the class definition itself. + * + * Warnings are suppressed for this method due to a micro-optimization that only really shows up when this logic + * is called several millions of times, which can happen quite easily with even moderately sized codebases. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @return array + */ + private function extract() + { + $allowedOpenBraces = [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES, T_STRING_VARNAME]; + $classes = []; + $namespace = ''; + $class = ''; + $triggerClass = false; + $triggerNamespace = false; + $braceLevel = 0; + $bracedNamespace = false; + + $this->tokens = token_get_all($this->getFileContents()); + foreach ($this->tokens as $index => $token) { + // Is either a literal brace or an interpolated brace with a variable + if ($token == '{' || (is_array($token) && in_array($token[0], $allowedOpenBraces))) { + $braceLevel++; + } else if ($token == '}') { + $braceLevel--; + } + // The namespace keyword was found in the last loop + if ($triggerNamespace) { + // A string ; or a discovered namespace that looks like "namespace name { }" + if (!is_array($token) || ($namespace && $token[0] == T_WHITESPACE)) { + $triggerNamespace = false; + $namespace .= '\\'; + continue; + } + $namespace .= $token[1]; + + // The class keyword was found in the last loop + } else if ($triggerClass && $token[0] == T_STRING) { + $triggerClass = false; + $class = $token[1]; + } + + switch ($token[0]) { + case T_NAMESPACE: + // Current loop contains the namespace keyword. Between this and the semicolon is the namespace + $triggerNamespace = true; + $namespace = ''; + $bracedNamespace = $this->isBracedNamespace($index); + break; + case T_CLASS: + // Current loop contains the class keyword. Next loop will have the class name itself. + if ($braceLevel == 0 || ($bracedNamespace && $braceLevel == 1)) { + $triggerClass = true; + } + break; + } + + // We have a class name, let's concatenate and store it! + if ($class != '') { + $namespace = trim($namespace); + $fqClassName = $namespace . trim($class); + $classes[] = $fqClassName; + $class = ''; + } + } + return $classes; + } + + /** + * Looks forward from the current index to determine if the namespace is nested in {} or terminated with ; + * + * @param integer $index + * @return bool + */ + private function isBracedNamespace($index) + { + $len = count($this->tokens); + while ($index++ < $len) { + if (!is_array($this->tokens[$index])) { + if ($this->tokens[$index] == ';') { + return false; + } else if ($this->tokens[$index] == '{') { + return true; + } + continue; + } + + if (!in_array($this->tokens[$index][0], [T_WHITESPACE, T_STRING, T_NS_SEPARATOR])) { + throw new InvalidFileException('Namespace not defined properly'); + } + } + throw new InvalidFileException('Could not find namespace termination'); + } + + /** + * Retrieves the first class found in a class file. The return value is in an array format so it retains the + * same usage as the FileScanner. + * + * @return array + */ + public function getClassNames() + { + if ($this->classNames === false) { + $this->classNames = $this->extract(); + } + return $this->classNames; + } +} diff --git a/setup/src/Magento/Setup/Module/Di/Code/Reader/InvalidFileException.php b/setup/src/Magento/Setup/Module/Di/Code/Reader/InvalidFileException.php new file mode 100644 index 0000000000000..049381f907d13 --- /dev/null +++ b/setup/src/Magento/Setup/Module/Di/Code/Reader/InvalidFileException.php @@ -0,0 +1,8 @@ +model = new \Magento\Setup\Module\Di\Code\Reader\ClassesScanner(); + $this->generation = realpath(__DIR__ . '/../../_files/var/generation'); + $mock = $this->getMockBuilder(DirectoryList::class)->disableOriginalConstructor()->setMethods( + ['getPath'] + )->getMock(); + $mock->method('getPath')->willReturn($this->generation); + $this->model = new \Magento\Setup\Module\Di\Code\Reader\ClassesScanner([], $mock); } public function testGetList() diff --git a/setup/src/Magento/Setup/Test/Unit/Module/Di/Code/Reader/FileClassScannerTest.php b/setup/src/Magento/Setup/Test/Unit/Module/Di/Code/Reader/FileClassScannerTest.php new file mode 100644 index 0000000000000..e9f487c5c3b15 --- /dev/null +++ b/setup/src/Magento/Setup/Test/Unit/Module/Di/Code/Reader/FileClassScannerTest.php @@ -0,0 +1,243 @@ +setExpectedException(InvalidFileException::class); + new FileClassScanner(false); + } + + public function testEmptyArrayForFileWithoutNamespaceOrClass() + { + $scanner = $this->getMockBuilder(FileClassScanner::class)->disableOriginalConstructor()->setMethods([ + 'getFileContents' + ])->getMock(); + $scanner->expects(self::once())->method('getFileContents')->willReturn( + <<getClassNames(); + self::assertCount(0, $result); + } + + public function testGetClassName() + { + $scanner = $this->getMockBuilder(FileClassScanner::class)->disableOriginalConstructor()->setMethods([ + 'getFileContents' + ])->getMock(); + $scanner->expects(self::once())->method('getFileContents')->willReturn( + <<getClassNames(); + + self::assertCount(1, $result); + self::assertContains('ThisIsATest', $result); + } + + public function testGetClassNameAndSingleNamespace() + { + $scanner = $this->getMockBuilder(FileClassScanner::class)->disableOriginalConstructor()->setMethods([ + 'getFileContents' + ])->getMock(); + $scanner->expects(self::once())->method('getFileContents')->willReturn( + <<getClassNames(); + + self::assertCount(1, $result); + self::assertContains('NS\ThisIsMyTest', $result); + } + + public function testGetClassNameAndMultiNamespace() + { + $scanner = $this->getMockBuilder(FileClassScanner::class)->disableOriginalConstructor()->setMethods([ + 'getFileContents' + ])->getMock(); + $scanner->expects(self::once())->method('getFileContents')->willReturn( + <<getClassNames(); + + self::assertCount(1, $result); + self::assertContains('This\Is\My\Ns\ThisIsMyTest', $result); + } + + public function testGetMultiClassNameAndMultiNamespace() + { + $scanner = $this->getMockBuilder(FileClassScanner::class)->disableOriginalConstructor()->setMethods([ + 'getFileContents' + ])->getMock(); + $scanner->expects(self::once())->method('getFileContents')->willReturn( + <<get(\This\Is\Another\Ns::class)->method(); + self:: class; + } + + public function test() + { + + } +} + +class ThisIsForBreaking { + +} + +PHP + ); + /** @var $scanner FileClassScanner */ + + $result = $scanner->getClassNames(); + + self::assertCount(2, $result); + self::assertContains('This\Is\My\Ns\ThisIsMyTest', $result); + self::assertContains('This\Is\My\Ns\ThisIsForBreaking', $result); + } + + public function testBracketedNamespacesAndClasses() + { + $scanner = $this->getMockBuilder(FileClassScanner::class)->disableOriginalConstructor()->setMethods([ + 'getFileContents' + ])->getMock(); + $scanner->expects(self::once())->method('getFileContents')->willReturn( + <<getClassNames(); + + self::assertCount(3, $result); + self::assertContains('This\Is\My\Ns\ThisIsMyTest', $result); + self::assertContains('This\Is\My\Ns\ThisIsForBreaking', $result); + self::assertContains('This\Is\Not\My\Ns\ThisIsNotMyTest', $result); + } + + public function testClassKeywordInMiddleOfFile() + { + $filename = __DIR__ + . '/../../../../../../../../../..' + . '/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Eav/AbstractEav.php'; + $filename = realpath($filename); + $scanner = new FileClassScanner($filename); + $result = $scanner->getClassNames(); + + self::assertCount(1, $result); + } + + public function testInvalidPHPCodeThrowsExceptionWhenCannotDetermineBraceOrSemiColon() + { + $this->setExpectedException(InvalidFileException::class); + $scanner = $this->getMockBuilder(FileClassScanner::class)->disableOriginalConstructor()->setMethods([ + 'getFileContents' + ])->getMock(); + $scanner->expects(self::once())->method('getFileContents')->willReturn( + <<getClassNames(); + } +} diff --git a/setup/src/Magento/Setup/Test/Unit/Module/Di/_files/var/generation/.keep b/setup/src/Magento/Setup/Test/Unit/Module/Di/_files/var/generation/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d