diff --git a/composer.json b/composer.json index b6e5b90..cebe52b 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,10 @@ } ], "require": { - "php": ">=7.1" + "php": ">=7.1", + "roave/better-reflection": "dev-master", + "mindplay/composer-locator": "^2.1.3", + "symfony/event-dispatcher": "^2.3 || ^3 || ^4" }, "require-dev": { "php-coveralls/php-coveralls": "^2.1", diff --git a/src/BetterReflectionClassesExplorer.php b/src/BetterReflectionClassesExplorer.php new file mode 100644 index 0000000..6d858f9 --- /dev/null +++ b/src/BetterReflectionClassesExplorer.php @@ -0,0 +1,237 @@ + [ + * "full/file/name.php" => [ + * "mtime" => 25346554 + * "classes" => [] // Classes, interfaces or traits, key is class name, value is irrelevent + * ] + * ], + * "classes": [ + * "FQCN": [ + * "type": "interface|class|trait", + * "internal" : true // Present if class is a system class like \Exception + * "implements": [] // Direct level (does not contain the interfaces extended by the interface implemented) + * "extends": [""] // The parents hierarchy + * "uses": [] // Only the traits for this class, not the parent classes. + * "dependencies": [] // classes, interfaces, traits implementing/extending/using this object => key is class/interface/trait name + * "path" => "" // Relative path to the file (or absolute path if not part of project) + * ] + * ] + * ] + */ + private $data; + + /** + * @var EventDispatcher + */ + private $eventDispatcher; + + public function __construct(EventDispatcher $eventDispatcher, $data = ['files'=>[], 'classes'=>[]]) + { + $this->eventDispatcher = $eventDispatcher; + $this->data = $data; + } + + public function getClasses() + { + $rootPath = \ComposerLocator::getRootPath().'/'; + $data = $this->data; + $phpFiles = \iterator_to_array(PackageSourceLocator::getVendorPhpFiles()); + + // From the list of deleted files, let's remove the list of interfaces/classes/... that were in those files. + // Also, let's flag for reloading the classes that need to be reloaded. + $deletedFiles = $this->getDeletedFiles($phpFiles); + + $deletedClasses = []; + foreach ($deletedFiles as $path => $definition) { + $deletedClasses += $definition['classes']; + } + + $toRefreshClasses = []; + foreach ($deletedClasses as $deletedClass => $foo) { + $toRefreshClasses += $data['classes'][$deletedClass]['dependencies']; + } + + // A class cannot be "to refresh" if it is deleted also. + $toRefreshClasses = \array_diff_key($toRefreshClasses, $deletedClasses); + + $modifiedPhpFiles = $this->getModifiedFiles($phpFiles); + + // Let's reset the files datastructure for modified files: + foreach ($modifiedPhpFiles as $path => $def) { + $data['files'][$path]['classes'] = []; + $data['files'][$path]['mtime'] = \filemtime($path); + } + + $betterReflection = new BetterReflection(); + $astLocator = $betterReflection->astLocator(); + + // TODO: FileIteratorSourceLocator does not benefit from the MemoizingSourceLocator declared in $betterReflection->classReflector. + $filesSourceLocator = new FileIteratorSourceLocator(new \ArrayIterator($modifiedPhpFiles), $astLocator); + $reflector = $betterReflection->classReflector(); + /** @var ReflectionClass[] $classes */ + $classes = $filesSourceLocator->locateIdentifiersByType( + $reflector, + new IdentifierType(IdentifierType::IDENTIFIER_CLASS) + ); + + foreach ($toRefreshClasses as $toRefreshClass) { + $classes[] = $reflector->reflect($toRefreshClass); + } + + foreach ($classes as $class) { + if (!isset($data['files'][$class->getFileName()])) { + throw new ClassExplorerException('Unexpected missing key: class '.$class->getName().' is supposed to be part of file "'.$class->getFileName().'" but this file was not found in list of modified files"'); + } + try { + + $data['files'][$class->getFileName()]['classes'][$class->getName()] = true; + if ($class->isInterface()) { + $type = 'interface'; + } elseif ($class->isTrait()) { + $type = 'trait'; + } else { + $type = 'class'; + } + $def = [ + 'type' => $type + ]; + $extends = []; + $parentClass = $class; + while ($parentClass = $parentClass->getParentClass()) { + $extends[] = $parentClass->getName(); + } + $def['extends'] = $extends; + $def['implements'] = $class->getInterfaceNames(); + $def['uses'] = array_map(function($trait) { return $trait->getName(); }, $class->getTraits()); + $def['path'] = $this->getRelativePathIfAvailable($class->getFileName(), $rootPath); + } catch (\Roave\BetterReflection\Reflector\Exception\IdentifierNotFound $e) { + $this->eventDispatcher->dispatch(IdentifierNotFoundEvent::NAME, new IdentifierNotFoundEvent($e->getIdentifier()->getName())); + continue; + } + + $data['classes'][$class->getName()] = $def; + } + + $data['classes'] = $this->generateDependenciesData($data['classes']); + + $this->data = $data; + + return $data; + } + + /** + * Returns the list of files that have disappeared. + * + * @param \SplFileInfo[] $files Typically the result of a call to PackageSourceLocator::getVendorPhpFiles casted to array + * @return array[] Key: full path, Value: Same structure as $this->data['files'][] + */ + private function getDeletedFiles(array $files): array + { + return \array_diff_key($this->data['files'], $files); + } + + /** + * Returns modified files OR new files. + * + * @param \SplFileInfo[] $files + * @return \SplFileInfo[] + */ + private function getModifiedFiles(array $files): array + { + $oldFiles = $this->data['files']; + return \array_filter($files, function(\SplFileInfo $file, $path) use ($oldFiles) { + return !isset($oldFiles[$path]) || (isset($oldFiles[$path]) && $oldFiles[$path]['mtime'] !== $file->getMTime()); + }, ARRAY_FILTER_USE_BOTH); + } + + /** + * Returns the relative path to $rootPath or if $path is not part of $rootPath, the absolute path. + * + * @param string $path + * @param string $rootPath supposed to end with '/' + * @return string + */ + private function getRelativePathIfAvailable(string $path, string $rootPath): string + { + if (strpos($path, $rootPath) === 0) { + return substr($path, \strlen($rootPath)); + } + return $path; + } + + /** + * @param array[] $classes The array of $data['classes'] + * @return array[] + */ + private function generateDependenciesData(array $classes): array + { + foreach ($classes as &$class) { + $class['dependencies'] = []; + } + + foreach ($classes as $className => $class) { + if (isset($class['internal'])) { + continue; + } + foreach ($class['extends'] as $parent) { + // If we extend from a root class (like \Exception) + if (!isset($classes[$parent])) { + $classes[$parent] = [ + 'internal' => true, + ]; + } + $classes[$parent]['dependencies'][] = $className; + } + + foreach ($class['implements'] as $interface) { + // If we extend from a root class (like \Exception) + if (!isset($classes[$interface])) { + $classes[$interface] = [ + 'internal' => true, + ]; + } + $classes[$interface]['dependencies'][] = $className; + } + + foreach ($class['uses'] as $trait) { + // If we extend from a root class (like \Exception) + if (!isset($classes[$trait])) { + $classes[$trait] = [ + 'internal' => true, + ]; + } + $classes[$trait]['dependencies'][] = $className; + } + + } + + return $classes; + } +} diff --git a/src/Events/IdentifierNotFoundEvent.php b/src/Events/IdentifierNotFoundEvent.php new file mode 100644 index 0000000..8fe5789 --- /dev/null +++ b/src/Events/IdentifierNotFoundEvent.php @@ -0,0 +1,31 @@ +identifier = $identifier; + } + + /** + * @return string + */ + public function getIdentifier(): string + { + return $this->identifier; + } +} \ No newline at end of file diff --git a/src/Exceptions/ClassExplorerException.php b/src/Exceptions/ClassExplorerException.php new file mode 100644 index 0000000..4bddbd5 --- /dev/null +++ b/src/Exceptions/ClassExplorerException.php @@ -0,0 +1,10 @@ + $directories, 'files' => $files]; + } + + private static function addDirectoriesAndFiles(array $packageDef, array &$directories, array &$files, bool $canFail): void + { + if (!isset($packageDef['autoload'])) { + return; + } + + $autoload = $packageDef['autoload']; + try { + $packagePath = \ComposerLocator::getPath($packageDef['name']).'/'; + } catch (\RuntimeException $runtimeException) { + if ($canFail === true) { + return; + } else { + throw $runtimeException; + } + } + + foreach (['psr-0', 'psr-4'] as $psrNumber) { + if (isset($autoload[$psrNumber])) { + foreach ($autoload[$psrNumber] as $dir) { + if (\is_array($dir)) { + foreach ($dir as $item) { + $directories[] = $packagePath.$item; + } + } else { + $directories[] = $packagePath.$dir; + } + } + } + } + if (isset($autoload['classmap'])) { + foreach ($autoload['classmap'] as $file) { + if (\is_dir($packagePath.$file)) { + $directories[] = $packagePath.$file; + } else { + $files[] = $packagePath.$file; + } + } + } + if (isset($autoload['files'])) { + foreach ($autoload['files'] as $file) { + $files[] = $packagePath.$file; + } + } + } + + /** + * Returns a list of all vendor PHP files. + * The key of the iterator is the absolute path of the file. The value is the SplFileInfo. + * + * @return \Iterator|\SplFileInfo[] + */ + public static function getVendorPhpFiles(): \Iterator + { + $appendIterator = new \AppendIterator(); + ['directories' => $directories, 'files' => $files] = self::getVendorSources(); + + foreach ($directories as $directory) { + $appendIterator->append(self::getPhpFilesForDir($directory)); + } + + $filesInfo = []; + foreach ($files as $file) { + $filesInfo[(string) $file] = new \SplFileInfo($file); + } + + $appendIterator->append(new \ArrayIterator($filesInfo)); + //$appendIterator->append(new \ArrayIterator(\array_flip($files))); + + return $appendIterator; + } + + /** + * @param string $directory + * @return \Iterator|\SplFileInfo[] + */ + private static function getPhpFilesForDir(string $directory): \Iterator + { + // If there is an error in the directory (might happen with dependencies), let's ignore that. + if (!\is_dir($directory)) { + return new \EmptyIterator(); + } + $allFiles = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS)); + // TODO: it would be better to keep the values as a SplFileInfo since we have to: + // check modification time + // use SplFileInfo in iterator from better-reflection + return new RegexIterator($allFiles, '/\.php$/i'/*, \RecursiveRegexIterator::GET_MATCH*/); + } +} diff --git a/tests/ClassesExplorerTest.php b/tests/ClassesExplorerTest.php new file mode 100644 index 0000000..b25bb97 --- /dev/null +++ b/tests/ClassesExplorerTest.php @@ -0,0 +1,36 @@ +addListener(IdentifierNotFoundEvent::NAME, function(IdentifierNotFoundEvent $event) use (&$notFounds) { + $notFounds[] = $event->getIdentifier(); + }); + + $classes = $classExplorer->getClasses(); + var_export($classes); + + $this->assertContains('Composer\\Plugin\\PluginInterface', $notFounds); + + $this->assertSame('class', $classes['classes']['Roave\\BetterReflection\\BetterReflection']['type']); + $this->assertSame('vendor/roave/better-reflection/src/BetterReflection.php', $classes['classes']['Roave\\BetterReflection\\BetterReflection']['path']); + + $classes2 = $classExplorer->getClasses(); + + $this->assertEquals($classes, $classes2); + } +} diff --git a/tests/PackageSourceLocatorTest.php b/tests/PackageSourceLocatorTest.php new file mode 100644 index 0000000..1adbdc7 --- /dev/null +++ b/tests/PackageSourceLocatorTest.php @@ -0,0 +1,35 @@ + $directories, 'files' => $files] = $sources; + + $this->assertContains(\dirname(__DIR__).'/vendor/roave/better-reflection/src', $directories); + $this->assertContains(\dirname(__DIR__).'/vendor/mindplay/composer-locator/src/ComposerLocator.php', $files); + } + + public function testGetVendorPhpFiles(): void + { + $phpFiles = PackageSourceLocator::getVendorPhpFiles(); + + $phpFiles = iterator_to_array($phpFiles); + $files = []; + + foreach ($phpFiles as $fileInfo) { + $files[(string) $fileInfo] = true; + } + + $this->assertArrayHasKey(\dirname(__DIR__).'/vendor/mindplay/composer-locator/src/ComposerLocator.php', $files); + $this->assertArrayHasKey(\dirname(__DIR__).'/vendor/roave/better-reflection/src/SourceLocator/Type/SourceLocator.php', $files); + } +}