diff --git a/.styleci.yml b/.styleci.yml index 1ab379b..17f8dc4 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -7,6 +7,8 @@ finder: exclude: - docs - vendor + not-name: + - wrong_file.php enabled: - alpha_ordered_traits diff --git a/README.md b/README.md index 2cd7a45..f366b58 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ [![Latest Stable Version](https://poser.pugx.org/yiisoft/classifier/v/stable.png)](https://packagist.org/packages/yiisoft/classifier) [![Total Downloads](https://poser.pugx.org/yiisoft/classifier/downloads.png)](https://packagist.org/packages/yiisoft/classifier) [![Build status](https://github.com/yiisoft/classifier/workflows/build/badge.svg)](https://github.com/yiisoft/classifier/actions?query=workflow%3Abuild) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/yiisoft/classifier/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/classifier/?branch=master) -[![Code Coverage](https://scrutinizer-ci.com/g/yiisoft/classifier/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/classifier/?branch=master) +[![Code Coverage](https://codecov.io/gh/yiisoft/classifier/branch/master/graph/badge.svg)](https://codecov.io/gh/yiisoft/classifier) [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fclassifier%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/classifier/master) [![static analysis](https://github.com/yiisoft/classifier/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/classifier/actions?query=workflow%3A%22static+analysis%22) [![type-coverage](https://shepherd.dev/github/yiisoft/classifier/coverage.svg)](https://shepherd.dev/github/yiisoft/classifier) +[![psalm-level](https://shepherd.dev/github/yiisoft/classifier/level.svg)](https://shepherd.dev/github/yiisoft/classifier) Classifier traverses file system to find classes by a certain criteria. diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..0809396 --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,11 @@ +{ + "symbol-whitelist": [ + "PhpParser\\Node", + "PhpParser\\NodeTraverser", + "PhpParser\\NodeVisitorAbstract", + "PhpParser\\NodeVisitor\\NameResolver", + "PhpParser\\Node\\Stmt\\Class_", + "PhpParser\\Parser", + "PhpParser\\ParserFactory" + ] +} diff --git a/composer.json b/composer.json index 1dafe1e..fa126d8 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ }, "require-dev": { "maglnet/composer-require-checker": "^4.2", + "nikic/php-parser": "^4.17", "phpunit/phpunit": "^9.5", "rector/rector": "^1.0.0", "roave/infection-static-analysis-plugin": "^1.16", @@ -51,6 +52,9 @@ "Yiisoft\\Classifier\\Tests\\": "tests" } }, + "suggest": { + "nikic/php-parser": "Need for ParserClassifier implementation" + }, "config": { "sort-packages": true, "allow-plugins": { diff --git a/rector.php b/rector.php index 63713ce..531f028 100644 --- a/rector.php +++ b/rector.php @@ -12,6 +12,10 @@ __DIR__ . '/tests', ]); + $rectorConfig->skip([ + __DIR__ . '/tests/Support/wrong_file.php', + ]); + // register a single rule $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); diff --git a/src/AbstractClassifier.php b/src/AbstractClassifier.php new file mode 100644 index 0000000..5ad78d8 --- /dev/null +++ b/src/AbstractClassifier.php @@ -0,0 +1,93 @@ +directories = [$directory, ...array_values($directories)]; + } + + /** + * @psalm-param class-string ...$interfaces + */ + public function withInterface(string ...$interfaces): self + { + $new = clone $this; + array_push($new->interfaces, ...array_values($interfaces)); + + return $new; + } + + /** + * @psalm-param class-string $parentClass + */ + public function withParentClass(string $parentClass): self + { + $new = clone $this; + $new->parentClass = $parentClass; + return $new; + } + + /** + * @psalm-param class-string ...$attributes + */ + public function withAttribute(string ...$attributes): self + { + $new = clone $this; + array_push($new->attributes, ...array_values($attributes)); + + return $new; + } + + /** + * @psalm-return iterable + */ + public function find(): iterable + { + if (empty($this->interfaces) && empty($this->attributes) && $this->parentClass === null) { + return []; + } + + yield from $this->getAvailableClasses(); + } + + protected function getFiles(): Finder + { + return (new Finder()) + ->in($this->directories) + ->name('*.php') + ->sortByName() + ->files(); + } + + /** + * @return iterable + */ + abstract protected function getAvailableClasses(): iterable; +} diff --git a/src/Classifier.php b/src/Classifier.php deleted file mode 100644 index 65f3f07..0000000 --- a/src/Classifier.php +++ /dev/null @@ -1,163 +0,0 @@ -directories = [$directory, ...array_values($directories)]; - } - - /** - * @param string ...$interfaces Interfaces to search for. - * @psalm-param class-string ...$interfaces - */ - public function withInterface(string ...$interfaces): self - { - $new = clone $this; - array_push($new->interfaces, ...array_values($interfaces)); - - return $new; - } - - /** - * @param string $parentClass Parent class to search for. - * @psalm-param class-string $parentClass - */ - public function withParentClass(string $parentClass): self - { - $new = clone $this; - $new->parentClass = $parentClass; - return $new; - } - - /** - * @para string ...$attributes Attributes to search for. - * @psalm-param class-string ...$attributes - */ - public function withAttribute(string ...$attributes): self - { - $new = clone $this; - array_push($new->attributes, ...array_values($attributes)); - - return $new; - } - - /** - * @return string[] Classes found. - * @psalm-return iterable - */ - public function find(): iterable - { - $countInterfaces = count($this->interfaces); - $countAttributes = count($this->attributes); - - if ($countInterfaces === 0 && $countAttributes === 0 && $this->parentClass === null) { - return []; - } - - $this->scanFiles(); - - $classesToFind = get_declared_classes(); - $isWindows = DIRECTORY_SEPARATOR === '\\'; - $directories = $this->directories; - - if ($isWindows) { - /** @var string[] $directories */ - $directories = str_replace('/', '\\', $directories); - } - - foreach ($classesToFind as $className) { - $reflection = new ReflectionClass($className); - - if (!$reflection->isUserDefined()) { - continue; - } - - $matchedDirs = array_filter( - $directories, - static fn($directory) => str_starts_with($reflection->getFileName(), $directory) - ); - - if (count($matchedDirs) === 0) { - continue; - } - - if ($countInterfaces > 0) { - $interfaces = $reflection->getInterfaces(); - $interfaces = array_map(static fn(ReflectionClass $class) => $class->getName(), $interfaces); - - if (count(array_intersect($this->interfaces, $interfaces)) !== $countInterfaces) { - continue; - } - } - - if ($countAttributes > 0) { - $attributes = $reflection->getAttributes(); - $attributes = array_map( - static fn(ReflectionAttribute $attribute) => $attribute->getName(), - $attributes - ); - - if (count(array_intersect($this->attributes, $attributes)) !== $countAttributes) { - continue; - } - } - - if (($this->parentClass !== null) && !is_subclass_of($className, $this->parentClass)) { - continue; - } - - yield $className; - } - } - - /** - * Find all PHP files and require each one so these could be further analyzed via reflection. - * @psalm-suppress UnresolvableInclude - */ - private function scanFiles(): void - { - $files = (new Finder()) - ->in($this->directories) - ->name('*.php') - ->sortByName() - ->files(); - - foreach ($files as $file) { - require_once $file; - } - } -} diff --git a/src/ClassifierInterface.php b/src/ClassifierInterface.php new file mode 100644 index 0000000..4e03824 --- /dev/null +++ b/src/ClassifierInterface.php @@ -0,0 +1,19 @@ + + */ + public function find(): iterable; +} diff --git a/src/NativeClassifier.php b/src/NativeClassifier.php new file mode 100644 index 0000000..22ed6bf --- /dev/null +++ b/src/NativeClassifier.php @@ -0,0 +1,98 @@ + + */ + private static array $reflectionsCache = []; + + /** + * @psalm-suppress UnresolvableInclude + */ + protected function getAvailableClasses(): iterable + { + $files = $this->getFiles(); + + foreach ($files as $file) { + try { + require_once $file; + } catch (\Throwable) { + // Ignore syntax errors + } + } + + foreach (get_declared_classes() as $className) { + if ($this->skipClass($className)) { + continue; + } + + yield $className; + } + } + + /** + * @psalm-param class-string $className + */ + private function skipClass(string $className): bool + { + $reflectionClass = self::$reflectionsCache[$className] ??= new ReflectionClass($className); + + if ($reflectionClass->isInternal() || $reflectionClass->isAnonymous()) { + return true; + } + $directories = $this->directories; + $isWindows = DIRECTORY_SEPARATOR === '\\'; + + if ($isWindows) { + /** + * @psalm-var string[] $directories + */ + // @codeCoverageIgnoreStart + $directories = str_replace('/', '\\', $directories); + // @codeCoverageIgnoreEnd + } + + $matchedDirs = array_filter( + $directories, + static fn($directory) => str_starts_with($reflectionClass->getFileName(), $directory) + ); + + if (count($matchedDirs) === 0) { + return true; + } + + if (!empty($this->interfaces)) { + $interfaces = $reflectionClass->getInterfaces(); + $interfaces = array_map(static fn(ReflectionClass $class) => $class->getName(), $interfaces); + + if (count(array_intersect($this->interfaces, $interfaces)) !== count($this->interfaces)) { + return true; + } + } + + if (!empty($this->attributes)) { + $attributes = $reflectionClass->getAttributes(); + $attributes = array_map( + static fn(ReflectionAttribute $attribute) => $attribute->getName(), + $attributes + ); + + if (count(array_intersect($this->attributes, $attributes)) !== count($this->attributes)) { + return true; + } + } + + return ($this->parentClass !== null) && !is_subclass_of($reflectionClass->getName(), $this->parentClass); + } +} diff --git a/src/ParserClassifier.php b/src/ParserClassifier.php new file mode 100644 index 0000000..13a8b19 --- /dev/null +++ b/src/ParserClassifier.php @@ -0,0 +1,55 @@ +parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7); + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver()); + + $this->nodeTraverser = $traverser; + } + + /** + * @return iterable + */ + protected function getAvailableClasses(): iterable + { + $files = $this->getFiles(); + $visitor = new ParserVisitor($this->interfaces, $this->attributes, $this->parentClass); + $this->nodeTraverser->addVisitor($visitor); + + foreach ($files as $file) { + try { + $nodes = $this->parser->parse($file->getContents()); + if ($nodes !== null) { + $this->nodeTraverser->traverse($nodes); + } + } catch (\Throwable) { + // Ignore broken files or parsing errors + } + } + + return $visitor->getClassNames(); + } +} diff --git a/src/ParserVisitor.php b/src/ParserVisitor.php new file mode 100644 index 0000000..6344a87 --- /dev/null +++ b/src/ParserVisitor.php @@ -0,0 +1,86 @@ + + */ + private array $classNames = []; + + /** + * @psalm-param class-string $allowedParentClass + */ + public function __construct( + private array $allowedInterfaces, + private array $allowedAttributes, + private ?string $allowedParentClass = null + ) { + } + + public function enterNode(Node $node) + { + if (!($node instanceof Class_)) { + return parent::enterNode($node); + } + + if (!$this->skipClass($node)) { + /** + * @var class-string $className + * @psalm-suppress PossiblyNullReference checked in {@see skipClass} method. + */ + $className = $node->namespacedName->toString(); + $this->classNames[] = $className; + } + + return NodeTraverser::DONT_TRAVERSE_CHILDREN; + } + + /** + * @return array + */ + public function getClassNames(): array + { + return $this->classNames; + } + + private function skipClass(Class_ $class): bool + { + if ($class->namespacedName === null || $class->isAnonymous()) { + return true; + } + $className = $class->namespacedName->toString(); + $interfacesNames = class_implements($className); + if ( + $interfacesNames !== false && + count(array_intersect($this->allowedInterfaces, $interfacesNames)) !== count($this->allowedInterfaces) + ) { + return true; + } + $attributesNames = []; + foreach ($class->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $attributesNames[] = $attr->name->toString(); + } + } + if (count(array_intersect($this->allowedAttributes, $attributesNames)) !== count($this->allowedAttributes)) { + return true; + } + + $classParents = class_parents($className); + + return ($this->allowedParentClass !== null && $classParents !== false) && + !in_array($this->allowedParentClass, $classParents, true); + } +} diff --git a/tests/ClassifierTest.php b/tests/BaseClassifierTest.php similarity index 79% rename from tests/ClassifierTest.php rename to tests/BaseClassifierTest.php index 3db3823..bf9fd5e 100644 --- a/tests/ClassifierTest.php +++ b/tests/BaseClassifierTest.php @@ -5,7 +5,7 @@ namespace Yiisoft\Classifier\Tests; use PHPUnit\Framework\TestCase; -use Yiisoft\Classifier\Classifier; +use Yiisoft\Classifier\ClassifierInterface; use Yiisoft\Classifier\Tests\Support\Attributes\AuthorAttribute; use Yiisoft\Classifier\Tests\Support\Author; use Yiisoft\Classifier\Tests\Support\AuthorPost; @@ -20,12 +20,23 @@ use Yiisoft\Classifier\Tests\Support\User; use Yiisoft\Classifier\Tests\Support\UserSubclass; -final class ClassifierTest extends TestCase +abstract class BaseClassifierTest extends TestCase { - public function testMultipleDirectories() + public function testMultipleUse(): void { $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; - $finder = new Classifier(...$dirs); + $finder = $this->createClassifier(...$dirs); + $finder = $finder->withInterface(UserInterface::class); + + $result = $finder->find(); + + $this->assertEqualsCanonicalizing(iterator_to_array($finder->find()), iterator_to_array($result)); + } + + public function testMultipleDirectories(): void + { + $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; + $finder = $this->createClassifier(...$dirs); $finder = $finder->withInterface(UserInterface::class); $result = $finder->find(); @@ -38,7 +49,7 @@ public function testMultipleDirectories() */ public function testInterfaces(string $directory, array $interfaces, array $expectedClasses): void { - $finder = new Classifier($directory); + $finder = $this->createClassifier($directory); $finder = $finder->withInterface(...$interfaces); $result = $finder->find(); @@ -46,7 +57,7 @@ public function testInterfaces(string $directory, array $interfaces, array $expe $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); } - public function interfacesDataProvider(): array + public static function interfacesDataProvider(): array { return [ [ @@ -92,7 +103,7 @@ public function interfacesDataProvider(): array */ public function testAttributes(array $attributes, array $expectedClasses): void { - $finder = new Classifier(__DIR__); + $finder = $this->createClassifier(__DIR__); $finder = $finder->withAttribute(...$attributes); $result = $finder->find(); @@ -105,7 +116,7 @@ public function testAttributes(array $attributes, array $expectedClasses): void */ public function testParentClass(string $parent, array $expectedClasses): void { - $finder = new Classifier(__DIR__); + $finder = $this->createClassifier(__DIR__); $finder = $finder->withParentClass($parent); $result = $finder->find(); @@ -113,7 +124,7 @@ public function testParentClass(string $parent, array $expectedClasses): void $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); } - public function attributesDataProvider(): array + public static function attributesDataProvider(): array { return [ [ @@ -132,7 +143,7 @@ public function attributesDataProvider(): array */ public function testMixed(array $attributes, array $interfaces, array $expectedClasses): void { - $finder = new Classifier(__DIR__); + $finder = $this->createClassifier(__DIR__); $finder = $finder ->withAttribute(...$attributes) ->withInterface(...$interfaces); @@ -142,7 +153,7 @@ public function testMixed(array $attributes, array $interfaces, array $expectedC $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); } - public function mixedDataProvider(): array + public static function mixedDataProvider(): array { return [ [ @@ -158,7 +169,7 @@ public function mixedDataProvider(): array ]; } - public function parentClassDataProvider(): array + public static function parentClassDataProvider(): array { return [ [ @@ -167,4 +178,6 @@ public function parentClassDataProvider(): array ], ]; } + + abstract protected function createClassifier(string ...$dirs): ClassifierInterface; } diff --git a/tests/NativeClassifierTest.php b/tests/NativeClassifierTest.php new file mode 100644 index 0000000..372ffdb --- /dev/null +++ b/tests/NativeClassifierTest.php @@ -0,0 +1,16 @@ +