From bcb7ffcb224dd529b6cbba75c6c9efa5dd4236a4 Mon Sep 17 00:00:00 2001 From: Rustam Date: Fri, 22 Sep 2023 10:05:43 +0500 Subject: [PATCH 01/31] Refactoring & PhpParserClassifier --- README.md | 2 +- composer.json | 1 + src/AbstractClassifier.php | 144 +++++++++++++++++++++++++ src/Classifier.php | 134 ++--------------------- src/ClassifierInterface.php | 10 ++ src/ClassifierVisitor.php | 49 +++++++++ src/PhpParserClassifier.php | 49 +++++++++ tests/BaseClassifierTest.php | 173 ++++++++++++++++++++++++++++++ tests/ClassifierTest.php | 161 +-------------------------- tests/PhpParserClassifierTest.php | 17 +++ 10 files changed, 456 insertions(+), 284 deletions(-) create mode 100644 src/AbstractClassifier.php create mode 100644 src/ClassifierInterface.php create mode 100644 src/ClassifierVisitor.php create mode 100644 src/PhpParserClassifier.php create mode 100644 tests/BaseClassifierTest.php create mode 100644 tests/PhpParserClassifierTest.php diff --git a/README.md b/README.md index 351ccff..3839160 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The package ... The package could be installed with composer: ```shell -composer require yiisoft/classifier --prefer-dist +composer require yiisoft/classifier ``` ## General usage diff --git a/composer.json b/composer.json index 6e11cf7..a781ce5 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ }, "require-dev": { "maglnet/composer-require-checker": "^4.2", + "nikic/php-parser": "^4.17", "phpunit/phpunit": "^9.5", "rector/rector": "^0.18.0", "roave/infection-static-analysis-plugin": "^1.16", diff --git a/src/AbstractClassifier.php b/src/AbstractClassifier.php new file mode 100644 index 0000000..aaa7d77 --- /dev/null +++ b/src/AbstractClassifier.php @@ -0,0 +1,144 @@ + + */ + private static array $reflectionsCache = []; + + /** + * @var string[] + */ + private array $interfaces = []; + /** + * @var string[] + */ + private array $attributes = []; + /** + * @psalm-var class-string + */ + private ?string $parentClass = null; + /** + * @var string[] + */ + private array $directories; + + public function __construct(string $directory, string ...$directories) + { + $this->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 (count($this->interfaces) === 0 && count($this->attributes) === 0 && $this->parentClass === null) { + return []; + } + + yield from $this->getAvailableClasses(); + } + + protected function getFiles(): Finder + { + return (new Finder()) + ->in($this->directories) + ->name('*.php') + ->sortByName() + ->files(); + } + + /** + * @psalm-param class-string $className + */ + protected function skipClass(string $className): bool + { + $reflectionClass = self::$reflectionsCache[$className] ??= new ReflectionClass($className); + + if ($reflectionClass->isInternal()) { + return true; + } + $countInterfaces = count($this->interfaces); + $countAttributes = count($this->attributes); + $directories = $this->directories; + + $matchedDirs = array_filter( + $directories, + static fn($directory) => str_starts_with($reflectionClass->getFileName(), $directory) + ); + + if (count($matchedDirs) === 0) { + return true; + } + + if ($countInterfaces > 0) { + $interfaces = $reflectionClass->getInterfaces(); + $interfaces = array_map(static fn(ReflectionClass $class) => $class->getName(), $interfaces); + + if (count(array_intersect($this->interfaces, $interfaces)) !== $countInterfaces) { + return true; + } + } + + if ($countAttributes > 0) { + $attributes = $reflectionClass->getAttributes(); + $attributes = array_map( + static fn(ReflectionAttribute $attribute) => $attribute->getName(), + $attributes + ); + + if (count(array_intersect($this->attributes, $attributes)) !== $countAttributes) { + return true; + } + } + + return ($this->parentClass !== null) && !is_subclass_of($reflectionClass->getName(), $this->parentClass); + } + + /** + * @return iterable + */ + abstract protected function getAvailableClasses(): iterable; +} diff --git a/src/Classifier.php b/src/Classifier.php index 8fcf9ed..72cb47f 100644 --- a/src/Classifier.php +++ b/src/Classifier.php @@ -8,143 +8,25 @@ use ReflectionClass; use Symfony\Component\Finder\Finder; -final class Classifier +final class Classifier extends AbstractClassifier { /** - * @var string[] - */ - private array $interfaces = []; - /** - * @var string[] - */ - private array $attributes = []; - /** - * @psalm-var class-string - */ - private ?string $parentClass = null; - /** - * @var string[] - */ - private array $directories; - - public function __construct(string $directory, string ...$directories) - { - $this->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 + * @psalm-suppress UnresolvableInclude */ - public function find(): iterable + protected function getAvailableClasses(): iterable { - $countInterfaces = count($this->interfaces); - $countAttributes = count($this->attributes); + $files = $this->getFiles(); - 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 ($files as $file) { + require_once $file; } - 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)) { + foreach (get_declared_classes() as $className) { + if ($this->skipClass($className)) { continue; } yield $className; } } - - /** - * @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..f45ecaa --- /dev/null +++ b/src/ClassifierInterface.php @@ -0,0 +1,10 @@ + + */ + private array $classNames = []; + + /** + * @param \Closure(class-string): bool $shouldSkipClass + */ + public function __construct(private \Closure $shouldSkipClass) + { + } + + public function enterNode(Node $node) + { + if ($node instanceof Node\Stmt\Class_) { + /** + * @psalm-var class-string|null $className + */ + $className = $node->namespacedName?->toString(); + if ($className !== null && !call_user_func($this->shouldSkipClass, $className)) { + $this->classNames[] = $className; + } + } + + return parent::enterNode($node); + } + + /** + * @return array + */ + public function getClassNames(): array + { + return $this->classNames; + } +} diff --git a/src/PhpParserClassifier.php b/src/PhpParserClassifier.php new file mode 100644 index 0000000..f6cddfe --- /dev/null +++ b/src/PhpParserClassifier.php @@ -0,0 +1,49 @@ +parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + + $filter = fn(string $className): bool => /** @psalm-var class-string $className */ $this->skipClass($className); + $visitor = new ClassifierVisitor($filter); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver()); + $traverser->addVisitor($visitor); + + $this->visitor = $visitor; + $this->nodeTraverser = $traverser; + } + + /** + * @return iterable + */ + protected function getAvailableClasses(): iterable + { + $files = $this->getFiles(); + + foreach ($files as $file) { + $nodes = $this->parser->parse($file->getContents()); + if ($nodes !== null) { + $this->nodeTraverser->traverse($nodes); + } + } + + yield from new \ArrayIterator($this->visitor->getClassNames()); + } +} diff --git a/tests/BaseClassifierTest.php b/tests/BaseClassifierTest.php new file mode 100644 index 0000000..463b8a3 --- /dev/null +++ b/tests/BaseClassifierTest.php @@ -0,0 +1,173 @@ +withInterface(UserInterface::class); + + $result = $finder->find(); + + $this->assertEqualsCanonicalizing([UserInDir1::class, UserInDir2::class], iterator_to_array($result)); + } + + /** + * @dataProvider interfacesDataProvider + */ + public function testInterfaces(string $directory, array $interfaces, array $expectedClasses): void + { + $finder = new Classifier($directory); + $finder = $finder->withInterface(...$interfaces); + + $result = $finder->find(); + + $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); + } + + public function interfacesDataProvider(): array + { + return [ + [ + __DIR__, + [], + [], + ], + [ + __DIR__, + [PostInterface::class], + [AuthorPost::class, Post::class, PostUser::class], + ], + [ + __DIR__, + [PostInterface::class], + [AuthorPost::class, Post::class, PostUser::class], + ], + [ + __DIR__, + [UserInterface::class], + [UserInDir1::class, UserInDir2::class, PostUser::class, SuperSuperUser::class, SuperUser::class, User::class, UserSubclass::class], + ], + [ + __DIR__, + [PostInterface::class, UserInterface::class], + [PostUser::class], + ], + [ + __DIR__ . '/Support/Dir1', + [UserInterface::class], + [UserInDir1::class], + ], + [ + __DIR__ . '/Support/Dir2', + [UserInterface::class], + [UserInDir2::class], + ], + ]; + } + + /** + * @dataProvider attributesDataProvider + */ + public function testAttributes(array $attributes, array $expectedClasses): void + { + $finder = new Classifier(__DIR__); + $finder = $finder->withAttribute(...$attributes); + + $result = $finder->find(); + + $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); + } + + /** + * @dataProvider parentClassDataProvider + */ + public function testParentClass(string $parent, array $expectedClasses): void + { + $finder = new Classifier(__DIR__); + $finder = $finder->withParentClass($parent); + + $result = $finder->find(); + + $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); + } + + public function attributesDataProvider(): array + { + return [ + [ + [], + [], + ], + [ + [AuthorAttribute::class], + [Author::class, AuthorPost::class], + ], + ]; + } + + /** + * @dataProvider mixedDataProvider + */ + public function testMixed(array $attributes, array $interfaces, array $expectedClasses): void + { + $finder = new Classifier(__DIR__); + $finder = $finder + ->withAttribute(...$attributes) + ->withInterface(...$interfaces); + + $result = $finder->find(); + + $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); + } + + public function mixedDataProvider(): array + { + return [ + [ + [], + [], + [], + ], + [ + [AuthorAttribute::class], + [PostInterface::class], + [AuthorPost::class], + ], + ]; + } + + public function parentClassDataProvider(): array + { + return [ + [ + User::class, + [SuperSuperUser::class, SuperUser::class], + ], + ]; + } + + abstract protected function createClassifier(string ...$dirs): ClassifierInterface; +} diff --git a/tests/ClassifierTest.php b/tests/ClassifierTest.php index 3db3823..fef95d4 100644 --- a/tests/ClassifierTest.php +++ b/tests/ClassifierTest.php @@ -4,167 +4,14 @@ namespace Yiisoft\Classifier\Tests; -use PHPUnit\Framework\TestCase; use Yiisoft\Classifier\Classifier; -use Yiisoft\Classifier\Tests\Support\Attributes\AuthorAttribute; -use Yiisoft\Classifier\Tests\Support\Author; -use Yiisoft\Classifier\Tests\Support\AuthorPost; -use Yiisoft\Classifier\Tests\Support\Dir1\UserInDir1; -use Yiisoft\Classifier\Tests\Support\Dir2\UserInDir2; -use Yiisoft\Classifier\Tests\Support\Interfaces\PostInterface; -use Yiisoft\Classifier\Tests\Support\Interfaces\UserInterface; -use Yiisoft\Classifier\Tests\Support\Post; -use Yiisoft\Classifier\Tests\Support\PostUser; -use Yiisoft\Classifier\Tests\Support\SuperSuperUser; -use Yiisoft\Classifier\Tests\Support\SuperUser; -use Yiisoft\Classifier\Tests\Support\User; -use Yiisoft\Classifier\Tests\Support\UserSubclass; +use Yiisoft\Classifier\ClassifierInterface; -final class ClassifierTest extends TestCase +class ClassifierTest extends BaseClassifierTest { - public function testMultipleDirectories() - { - $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; - $finder = new Classifier(...$dirs); - $finder = $finder->withInterface(UserInterface::class); - - $result = $finder->find(); - - $this->assertEqualsCanonicalizing([UserInDir1::class, UserInDir2::class], iterator_to_array($result)); - } - - /** - * @dataProvider interfacesDataProvider - */ - public function testInterfaces(string $directory, array $interfaces, array $expectedClasses): void - { - $finder = new Classifier($directory); - $finder = $finder->withInterface(...$interfaces); - - $result = $finder->find(); - - $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); - } - - public function interfacesDataProvider(): array - { - return [ - [ - __DIR__, - [], - [], - ], - [ - __DIR__, - [PostInterface::class], - [AuthorPost::class, Post::class, PostUser::class], - ], - [ - __DIR__, - [PostInterface::class], - [AuthorPost::class, Post::class, PostUser::class], - ], - [ - __DIR__, - [UserInterface::class], - [UserInDir1::class, UserInDir2::class, PostUser::class, SuperSuperUser::class, SuperUser::class, User::class, UserSubclass::class], - ], - [ - __DIR__, - [PostInterface::class, UserInterface::class], - [PostUser::class], - ], - [ - __DIR__ . '/Support/Dir1', - [UserInterface::class], - [UserInDir1::class], - ], - [ - __DIR__ . '/Support/Dir2', - [UserInterface::class], - [UserInDir2::class], - ], - ]; - } - - /** - * @dataProvider attributesDataProvider - */ - public function testAttributes(array $attributes, array $expectedClasses): void - { - $finder = new Classifier(__DIR__); - $finder = $finder->withAttribute(...$attributes); - - $result = $finder->find(); - - $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); - } - - /** - * @dataProvider parentClassDataProvider - */ - public function testParentClass(string $parent, array $expectedClasses): void - { - $finder = new Classifier(__DIR__); - $finder = $finder->withParentClass($parent); - - $result = $finder->find(); - - $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); - } - - public function attributesDataProvider(): array - { - return [ - [ - [], - [], - ], - [ - [AuthorAttribute::class], - [Author::class, AuthorPost::class], - ], - ]; - } - - /** - * @dataProvider mixedDataProvider - */ - public function testMixed(array $attributes, array $interfaces, array $expectedClasses): void - { - $finder = new Classifier(__DIR__); - $finder = $finder - ->withAttribute(...$attributes) - ->withInterface(...$interfaces); - - $result = $finder->find(); - - $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); - } - - public function mixedDataProvider(): array - { - return [ - [ - [], - [], - [], - ], - [ - [AuthorAttribute::class], - [PostInterface::class], - [AuthorPost::class], - ], - ]; - } - public function parentClassDataProvider(): array + protected function createClassifier(string ...$dirs): ClassifierInterface { - return [ - [ - User::class, - [SuperSuperUser::class, SuperUser::class], - ], - ]; + return new Classifier(...$dirs); } } diff --git a/tests/PhpParserClassifierTest.php b/tests/PhpParserClassifierTest.php new file mode 100644 index 0000000..6c143d0 --- /dev/null +++ b/tests/PhpParserClassifierTest.php @@ -0,0 +1,17 @@ + Date: Fri, 22 Sep 2023 05:06:06 +0000 Subject: [PATCH 02/31] Apply fixes from StyleCI --- src/Classifier.php | 4 ---- src/ClassifierVisitor.php | 2 +- tests/ClassifierTest.php | 1 - tests/PhpParserClassifierTest.php | 1 - 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Classifier.php b/src/Classifier.php index 72cb47f..ced754c 100644 --- a/src/Classifier.php +++ b/src/Classifier.php @@ -4,10 +4,6 @@ namespace Yiisoft\Classifier; -use ReflectionAttribute; -use ReflectionClass; -use Symfony\Component\Finder\Finder; - final class Classifier extends AbstractClassifier { /** diff --git a/src/ClassifierVisitor.php b/src/ClassifierVisitor.php index 0caf364..87e626d 100644 --- a/src/ClassifierVisitor.php +++ b/src/ClassifierVisitor.php @@ -31,7 +31,7 @@ public function enterNode(Node $node) * @psalm-var class-string|null $className */ $className = $node->namespacedName?->toString(); - if ($className !== null && !call_user_func($this->shouldSkipClass, $className)) { + if ($className !== null && !($this->shouldSkipClass)($className)) { $this->classNames[] = $className; } } diff --git a/tests/ClassifierTest.php b/tests/ClassifierTest.php index fef95d4..5a725e7 100644 --- a/tests/ClassifierTest.php +++ b/tests/ClassifierTest.php @@ -9,7 +9,6 @@ class ClassifierTest extends BaseClassifierTest { - protected function createClassifier(string ...$dirs): ClassifierInterface { return new Classifier(...$dirs); diff --git a/tests/PhpParserClassifierTest.php b/tests/PhpParserClassifierTest.php index 6c143d0..39272f2 100644 --- a/tests/PhpParserClassifierTest.php +++ b/tests/PhpParserClassifierTest.php @@ -9,7 +9,6 @@ class PhpParserClassifierTest extends BaseClassifierTest { - protected function createClassifier(string ...$dirs): ClassifierInterface { return new PhpParserClassifier(...$dirs); From f4c00018a811a487d9a01c5d2265c4221b5b4c55 Mon Sep 17 00:00:00 2001 From: Rustam Date: Fri, 22 Sep 2023 10:11:28 +0500 Subject: [PATCH 03/31] Fix tests --- tests/BaseClassifierTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/BaseClassifierTest.php b/tests/BaseClassifierTest.php index 463b8a3..b05d7b2 100644 --- a/tests/BaseClassifierTest.php +++ b/tests/BaseClassifierTest.php @@ -26,7 +26,7 @@ abstract class BaseClassifierTest extends TestCase public function testMultipleDirectories() { $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; - $finder = new Classifier(...$dirs); + $finder = $this->createClassifier(...$dirs); $finder = $finder->withInterface(UserInterface::class); $result = $finder->find(); @@ -39,7 +39,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(); @@ -93,7 +93,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(); @@ -106,7 +106,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(); @@ -133,7 +133,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); From 9cc0ec160c979d740eaea393d3f2009ae7a6719e Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Fri, 22 Sep 2023 05:14:20 +0000 Subject: [PATCH 04/31] Apply fixes from StyleCI --- tests/BaseClassifierTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/BaseClassifierTest.php b/tests/BaseClassifierTest.php index b05d7b2..f9826a1 100644 --- a/tests/BaseClassifierTest.php +++ b/tests/BaseClassifierTest.php @@ -5,7 +5,6 @@ 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; From 4f5a06660ee9a160ed93689b04cfd4aa61033bc2 Mon Sep 17 00:00:00 2001 From: Rustam Date: Fri, 22 Sep 2023 10:37:27 +0500 Subject: [PATCH 05/31] Fix visitor registering --- src/PhpParserClassifier.php | 10 ++++------ tests/BaseClassifierTest.php | 10 +++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/PhpParserClassifier.php b/src/PhpParserClassifier.php index f6cddfe..51b9e39 100644 --- a/src/PhpParserClassifier.php +++ b/src/PhpParserClassifier.php @@ -12,7 +12,6 @@ final class PhpParserClassifier extends AbstractClassifier { private Parser $parser; - private ClassifierVisitor $visitor; private NodeTraverser $nodeTraverser; public function __construct(string $directory, string ...$directories) @@ -20,13 +19,9 @@ public function __construct(string $directory, string ...$directories) parent::__construct($directory, ...$directories); $this->parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); - $filter = fn(string $className): bool => /** @psalm-var class-string $className */ $this->skipClass($className); - $visitor = new ClassifierVisitor($filter); $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver()); - $traverser->addVisitor($visitor); - $this->visitor = $visitor; $this->nodeTraverser = $traverser; } @@ -36,6 +31,9 @@ public function __construct(string $directory, string ...$directories) protected function getAvailableClasses(): iterable { $files = $this->getFiles(); + $filter = fn(string $className): bool => /** @psalm-var class-string $className */ $this->skipClass($className); + $visitor = new ClassifierVisitor($filter); + $this->nodeTraverser->addVisitor($visitor); foreach ($files as $file) { $nodes = $this->parser->parse($file->getContents()); @@ -44,6 +42,6 @@ protected function getAvailableClasses(): iterable } } - yield from new \ArrayIterator($this->visitor->getClassNames()); + yield from new \ArrayIterator($visitor->getClassNames()); } } diff --git a/tests/BaseClassifierTest.php b/tests/BaseClassifierTest.php index f9826a1..9386edc 100644 --- a/tests/BaseClassifierTest.php +++ b/tests/BaseClassifierTest.php @@ -22,7 +22,7 @@ abstract class BaseClassifierTest extends TestCase { - public function testMultipleDirectories() + public function testMultipleDirectories(): void { $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; $finder = $this->createClassifier(...$dirs); @@ -46,7 +46,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 [ [ @@ -113,7 +113,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 [ [ @@ -142,7 +142,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 +158,7 @@ public function mixedDataProvider(): array ]; } - public function parentClassDataProvider(): array + public static function parentClassDataProvider(): array { return [ [ From 018591368ac357f2a1d7e5cc596d7165436d2628 Mon Sep 17 00:00:00 2001 From: Rustam Date: Fri, 22 Sep 2023 11:05:47 +0500 Subject: [PATCH 06/31] Minor fix --- src/AbstractClassifier.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/AbstractClassifier.php b/src/AbstractClassifier.php index aaa7d77..af8cd1a 100644 --- a/src/AbstractClassifier.php +++ b/src/AbstractClassifier.php @@ -35,6 +35,11 @@ abstract class AbstractClassifier implements ClassifierInterface public function __construct(string $directory, string ...$directories) { $this->directories = [$directory, ...array_values($directories)]; + $isWindows = DIRECTORY_SEPARATOR === '\\'; + + if ($isWindows) { + $this->directories = str_replace('/', '\\', $this->directories); + } } /** From 947ac8a3f8d073531e971039a80a60ef5490b39a Mon Sep 17 00:00:00 2001 From: Rustam Date: Fri, 22 Sep 2023 21:35:19 +0500 Subject: [PATCH 07/31] Refactor --- src/AbstractClassifier.php | 69 +++---------------------------------- src/Classifier.php | 59 +++++++++++++++++++++++++++++++ src/ClassifierVisitor.php | 52 ++++++++++++++++++++++------ src/PhpParserClassifier.php | 3 +- 4 files changed, 107 insertions(+), 76 deletions(-) diff --git a/src/AbstractClassifier.php b/src/AbstractClassifier.php index af8cd1a..47c0ec8 100644 --- a/src/AbstractClassifier.php +++ b/src/AbstractClassifier.php @@ -4,42 +4,30 @@ namespace Yiisoft\Classifier; -use ReflectionAttribute; -use ReflectionClass; use Symfony\Component\Finder\Finder; abstract class AbstractClassifier implements ClassifierInterface { - /** - * @psalm-var array - */ - private static array $reflectionsCache = []; - /** * @var string[] */ - private array $interfaces = []; + protected array $interfaces = []; /** * @var string[] */ - private array $attributes = []; + protected array $attributes = []; /** * @psalm-var class-string */ - private ?string $parentClass = null; + protected ?string $parentClass = null; /** * @var string[] */ - private array $directories; + protected array $directories; public function __construct(string $directory, string ...$directories) { $this->directories = [$directory, ...array_values($directories)]; - $isWindows = DIRECTORY_SEPARATOR === '\\'; - - if ($isWindows) { - $this->directories = str_replace('/', '\\', $this->directories); - } } /** @@ -79,7 +67,7 @@ public function withAttribute(string ...$attributes): self */ public function find(): iterable { - if (count($this->interfaces) === 0 && count($this->attributes) === 0 && $this->parentClass === null) { + if (empty($this->interfaces) && empty($this->attributes) && $this->parentClass === null) { return []; } @@ -95,53 +83,6 @@ protected function getFiles(): Finder ->files(); } - /** - * @psalm-param class-string $className - */ - protected function skipClass(string $className): bool - { - $reflectionClass = self::$reflectionsCache[$className] ??= new ReflectionClass($className); - - if ($reflectionClass->isInternal()) { - return true; - } - $countInterfaces = count($this->interfaces); - $countAttributes = count($this->attributes); - $directories = $this->directories; - - $matchedDirs = array_filter( - $directories, - static fn($directory) => str_starts_with($reflectionClass->getFileName(), $directory) - ); - - if (count($matchedDirs) === 0) { - return true; - } - - if ($countInterfaces > 0) { - $interfaces = $reflectionClass->getInterfaces(); - $interfaces = array_map(static fn(ReflectionClass $class) => $class->getName(), $interfaces); - - if (count(array_intersect($this->interfaces, $interfaces)) !== $countInterfaces) { - return true; - } - } - - if ($countAttributes > 0) { - $attributes = $reflectionClass->getAttributes(); - $attributes = array_map( - static fn(ReflectionAttribute $attribute) => $attribute->getName(), - $attributes - ); - - if (count(array_intersect($this->attributes, $attributes)) !== $countAttributes) { - return true; - } - } - - return ($this->parentClass !== null) && !is_subclass_of($reflectionClass->getName(), $this->parentClass); - } - /** * @return iterable */ diff --git a/src/Classifier.php b/src/Classifier.php index ced754c..5fb16e4 100644 --- a/src/Classifier.php +++ b/src/Classifier.php @@ -4,8 +4,16 @@ namespace Yiisoft\Classifier; +use ReflectionAttribute; +use ReflectionClass; + final class Classifier extends AbstractClassifier { + /** + * @psalm-var array + */ + private static array $reflectionsCache = []; + /** * @psalm-suppress UnresolvableInclude */ @@ -25,4 +33,55 @@ protected function getAvailableClasses(): iterable yield $className; } } + + /** + * @psalm-param class-string $className + */ + protected function skipClass(string $className): bool + { + $reflectionClass = self::$reflectionsCache[$className] ??= new ReflectionClass($className); + + if ($reflectionClass->isInternal()) { + return true; + } + $directories = $this->directories; + $isWindows = DIRECTORY_SEPARATOR === '\\'; + + if ($isWindows) { + /** @psalm-var string[] $directories */ + $directories = str_replace('/', '\\', $directories); + } + + $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/ClassifierVisitor.php b/src/ClassifierVisitor.php index 87e626d..d99ae22 100644 --- a/src/ClassifierVisitor.php +++ b/src/ClassifierVisitor.php @@ -5,10 +5,11 @@ namespace Yiisoft\Classifier; use PhpParser\Node; +use PhpParser\Node\Stmt\Class_; use PhpParser\NodeVisitorAbstract; /** - * @internal Visitor for Classifier + * @internal for PhpParserClassifier */ final class ClassifierVisitor extends NodeVisitorAbstract { @@ -18,22 +19,24 @@ final class ClassifierVisitor extends NodeVisitorAbstract private array $classNames = []; /** - * @param \Closure(class-string): bool $shouldSkipClass + * @psalm-param class-string $allowedParentClass */ - public function __construct(private \Closure $shouldSkipClass) - { + public function __construct( + private array $allowedInterfaces, + private array $allowedAttributes, + private ?string $allowedParentClass = null + ) { } public function enterNode(Node $node) { - if ($node instanceof Node\Stmt\Class_) { + if (($node instanceof Class_) && !$this->skipClass($node)) { /** - * @psalm-var class-string|null $className + * @var class-string $className + * @psalm-suppress PossiblyNullReference checked in {@see skipClass} method. */ - $className = $node->namespacedName?->toString(); - if ($className !== null && !($this->shouldSkipClass)($className)) { - $this->classNames[] = $className; - } + $className = $node->namespacedName->toString(); + $this->classNames[] = $className; } return parent::enterNode($node); @@ -46,4 +49,33 @@ public function getClassNames(): array { return $this->classNames; } + + private function skipClass(Class_ $class): bool + { + if ($class->namespacedName === null) { + 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/src/PhpParserClassifier.php b/src/PhpParserClassifier.php index 51b9e39..a1c65b7 100644 --- a/src/PhpParserClassifier.php +++ b/src/PhpParserClassifier.php @@ -31,8 +31,7 @@ public function __construct(string $directory, string ...$directories) protected function getAvailableClasses(): iterable { $files = $this->getFiles(); - $filter = fn(string $className): bool => /** @psalm-var class-string $className */ $this->skipClass($className); - $visitor = new ClassifierVisitor($filter); + $visitor = new ClassifierVisitor($this->interfaces, $this->attributes, $this->parentClass); $this->nodeTraverser->addVisitor($visitor); foreach ($files as $file) { From fb2c95e707f967ccb9abf2a556206db56e707321 Mon Sep 17 00:00:00 2001 From: Rustam Date: Sat, 23 Sep 2023 09:18:53 +0500 Subject: [PATCH 08/31] Increase code coverage --- README.md | 4 ++-- src/Classifier.php | 8 ++++++-- src/ClassifierVisitor.php | 2 +- tests/Support/test.php | 9 +++++++++ 4 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 tests/Support/test.php diff --git a/README.md b/README.md index 3839160..dfa3313 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) The package ... diff --git a/src/Classifier.php b/src/Classifier.php index 5fb16e4..0ac8d31 100644 --- a/src/Classifier.php +++ b/src/Classifier.php @@ -41,15 +41,19 @@ protected function skipClass(string $className): bool { $reflectionClass = self::$reflectionsCache[$className] ??= new ReflectionClass($className); - if ($reflectionClass->isInternal()) { + if ($reflectionClass->isInternal() || $reflectionClass->isAnonymous()) { return true; } $directories = $this->directories; $isWindows = DIRECTORY_SEPARATOR === '\\'; if ($isWindows) { - /** @psalm-var string[] $directories */ + /** + * @psalm-var string[] $directories + */ + // @codeCoverageIgnoreStart $directories = str_replace('/', '\\', $directories); + // @codeCoverageIgnoreEnd } $matchedDirs = array_filter( diff --git a/src/ClassifierVisitor.php b/src/ClassifierVisitor.php index d99ae22..dfa0059 100644 --- a/src/ClassifierVisitor.php +++ b/src/ClassifierVisitor.php @@ -52,7 +52,7 @@ public function getClassNames(): array private function skipClass(Class_ $class): bool { - if ($class->namespacedName === null) { + if ($class->namespacedName === null || $class->isAnonymous()) { return true; } $className = $class->namespacedName->toString(); diff --git a/tests/Support/test.php b/tests/Support/test.php new file mode 100644 index 0000000..c788eb2 --- /dev/null +++ b/tests/Support/test.php @@ -0,0 +1,9 @@ + Date: Sat, 23 Sep 2023 04:19:11 +0000 Subject: [PATCH 09/31] Apply fixes from StyleCI --- tests/Support/test.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Support/test.php b/tests/Support/test.php index c788eb2..5b0c65c 100644 --- a/tests/Support/test.php +++ b/tests/Support/test.php @@ -4,6 +4,5 @@ use Yiisoft\Classifier\Tests\Support\Interfaces\PostInterface; -return new class implements PostInterface -{ +return new class () implements PostInterface { }; From 33017d61d025a7885c049193259fac677f5820a2 Mon Sep 17 00:00:00 2001 From: Rustam Date: Sat, 23 Sep 2023 09:44:13 +0500 Subject: [PATCH 10/31] Fix method visibility --- src/Classifier.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Classifier.php b/src/Classifier.php index 0ac8d31..3034733 100644 --- a/src/Classifier.php +++ b/src/Classifier.php @@ -37,7 +37,7 @@ protected function getAvailableClasses(): iterable /** * @psalm-param class-string $className */ - protected function skipClass(string $className): bool + private function skipClass(string $className): bool { $reflectionClass = self::$reflectionsCache[$className] ??= new ReflectionClass($className); From 9d70f935a0a8a99cb029a0c3aebd3beb94491aea Mon Sep 17 00:00:00 2001 From: Rustam Date: Sun, 24 Sep 2023 22:51:45 +0500 Subject: [PATCH 11/31] [Skip CI] Add phpdoc --- src/PhpParserClassifier.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PhpParserClassifier.php b/src/PhpParserClassifier.php index a1c65b7..672320c 100644 --- a/src/PhpParserClassifier.php +++ b/src/PhpParserClassifier.php @@ -9,6 +9,10 @@ use PhpParser\Parser; use PhpParser\ParserFactory; +/** + * `PhpParserClassifier` finds classes using the `nikic/PHP-Parser` package and this may require performance tuning, so + * you may need follow {@see https://github.com/nikic/PHP-Parser/blob/master/doc/component/Performance.markdown} instructions. + */ final class PhpParserClassifier extends AbstractClassifier { private Parser $parser; From 8f715bba39a905d48cb609d3fd57dd3224c4786a Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 28 Sep 2023 11:34:48 +0500 Subject: [PATCH 12/31] Adjust naming & add phpdocs --- src/AbstractClassifier.php | 3 +++ src/ClassifierInterface.php | 9 ++++++++ src/{Classifier.php => NativeClassifier.php} | 2 +- ...serClassifier.php => ParserClassifier.php} | 23 +++++++++++-------- ...lassifierVisitor.php => ParserVisitor.php} | 4 ++-- ...ifierTest.php => NativeClassifierTest.php} | 6 ++--- ...ifierTest.php => ParserClassifierTest.php} | 6 ++--- 7 files changed, 35 insertions(+), 18 deletions(-) rename src/{Classifier.php => NativeClassifier.php} (97%) rename src/{PhpParserClassifier.php => ParserClassifier.php} (53%) rename src/{ClassifierVisitor.php => ParserVisitor.php} (95%) rename tests/{ClassifierTest.php => NativeClassifierTest.php} (59%) rename tests/{PhpParserClassifierTest.php => ParserClassifierTest.php} (58%) diff --git a/src/AbstractClassifier.php b/src/AbstractClassifier.php index 47c0ec8..5ad78d8 100644 --- a/src/AbstractClassifier.php +++ b/src/AbstractClassifier.php @@ -6,6 +6,9 @@ use Symfony\Component\Finder\Finder; +/** + * Base implementation for {@see ClassifierInterface} with common filters. + */ abstract class AbstractClassifier implements ClassifierInterface { /** diff --git a/src/ClassifierInterface.php b/src/ClassifierInterface.php index f45ecaa..4e03824 100644 --- a/src/ClassifierInterface.php +++ b/src/ClassifierInterface.php @@ -4,7 +4,16 @@ namespace Yiisoft\Classifier; +/** + * `Classifier` is a class finder that represents the classes found. + */ interface ClassifierInterface { + /** + * Returns all the class names found. + * + * @return iterable List of class names. + * @psalm-return iterable + */ public function find(): iterable; } diff --git a/src/Classifier.php b/src/NativeClassifier.php similarity index 97% rename from src/Classifier.php rename to src/NativeClassifier.php index 3034733..7c4795d 100644 --- a/src/Classifier.php +++ b/src/NativeClassifier.php @@ -7,7 +7,7 @@ use ReflectionAttribute; use ReflectionClass; -final class Classifier extends AbstractClassifier +final class NativeClassifier extends AbstractClassifier { /** * @psalm-var array diff --git a/src/PhpParserClassifier.php b/src/ParserClassifier.php similarity index 53% rename from src/PhpParserClassifier.php rename to src/ParserClassifier.php index 672320c..13a8b19 100644 --- a/src/PhpParserClassifier.php +++ b/src/ParserClassifier.php @@ -10,10 +10,11 @@ use PhpParser\ParserFactory; /** - * `PhpParserClassifier` finds classes using the `nikic/PHP-Parser` package and this may require performance tuning, so - * you may need follow {@see https://github.com/nikic/PHP-Parser/blob/master/doc/component/Performance.markdown} instructions. + * `ParserClassifier` finds classes using [`nikic/PHP-Parser`](https://github.com/nikic/PHP-Parser). + * This may require performance tuning, so you may need + * follow {@see https://github.com/nikic/PHP-Parser/blob/master/doc/component/Performance.markdown} instructions. */ -final class PhpParserClassifier extends AbstractClassifier +final class ParserClassifier extends AbstractClassifier { private Parser $parser; private NodeTraverser $nodeTraverser; @@ -21,7 +22,7 @@ final class PhpParserClassifier extends AbstractClassifier public function __construct(string $directory, string ...$directories) { parent::__construct($directory, ...$directories); - $this->parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + $this->parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7); $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver()); @@ -35,16 +36,20 @@ public function __construct(string $directory, string ...$directories) protected function getAvailableClasses(): iterable { $files = $this->getFiles(); - $visitor = new ClassifierVisitor($this->interfaces, $this->attributes, $this->parentClass); + $visitor = new ParserVisitor($this->interfaces, $this->attributes, $this->parentClass); $this->nodeTraverser->addVisitor($visitor); foreach ($files as $file) { - $nodes = $this->parser->parse($file->getContents()); - if ($nodes !== null) { - $this->nodeTraverser->traverse($nodes); + try { + $nodes = $this->parser->parse($file->getContents()); + if ($nodes !== null) { + $this->nodeTraverser->traverse($nodes); + } + } catch (\Throwable) { + // Ignore broken files or parsing errors } } - yield from new \ArrayIterator($visitor->getClassNames()); + return $visitor->getClassNames(); } } diff --git a/src/ClassifierVisitor.php b/src/ParserVisitor.php similarity index 95% rename from src/ClassifierVisitor.php rename to src/ParserVisitor.php index dfa0059..7f71627 100644 --- a/src/ClassifierVisitor.php +++ b/src/ParserVisitor.php @@ -9,9 +9,9 @@ use PhpParser\NodeVisitorAbstract; /** - * @internal for PhpParserClassifier + * @internal for ParserClassifier */ -final class ClassifierVisitor extends NodeVisitorAbstract +final class ParserVisitor extends NodeVisitorAbstract { /** * @var array diff --git a/tests/ClassifierTest.php b/tests/NativeClassifierTest.php similarity index 59% rename from tests/ClassifierTest.php rename to tests/NativeClassifierTest.php index 5a725e7..372ffdb 100644 --- a/tests/ClassifierTest.php +++ b/tests/NativeClassifierTest.php @@ -4,13 +4,13 @@ namespace Yiisoft\Classifier\Tests; -use Yiisoft\Classifier\Classifier; +use Yiisoft\Classifier\NativeClassifier; use Yiisoft\Classifier\ClassifierInterface; -class ClassifierTest extends BaseClassifierTest +class NativeClassifierTest extends BaseClassifierTest { protected function createClassifier(string ...$dirs): ClassifierInterface { - return new Classifier(...$dirs); + return new NativeClassifier(...$dirs); } } diff --git a/tests/PhpParserClassifierTest.php b/tests/ParserClassifierTest.php similarity index 58% rename from tests/PhpParserClassifierTest.php rename to tests/ParserClassifierTest.php index 39272f2..488355a 100644 --- a/tests/PhpParserClassifierTest.php +++ b/tests/ParserClassifierTest.php @@ -5,12 +5,12 @@ namespace Yiisoft\Classifier\Tests; use Yiisoft\Classifier\ClassifierInterface; -use Yiisoft\Classifier\PhpParserClassifier; +use Yiisoft\Classifier\ParserClassifier; -class PhpParserClassifierTest extends BaseClassifierTest +class ParserClassifierTest extends BaseClassifierTest { protected function createClassifier(string ...$dirs): ClassifierInterface { - return new PhpParserClassifier(...$dirs); + return new ParserClassifier(...$dirs); } } From 1fd7d54d13e72ef168386ad0a5a0616f0c76fd62 Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 28 Sep 2023 11:43:37 +0500 Subject: [PATCH 13/31] Add phpdoc & fix composer --- composer.json | 3 +++ src/NativeClassifier.php | 3 +++ 2 files changed, 6 insertions(+) diff --git a/composer.json b/composer.json index a781ce5..9485fda 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,9 @@ "Yiisoft\\Classifier\\Tests\\": "tests" } }, + "suggest": { + "nikic/php-parser": "Need for ParserClassifier implementation" + }, "extra": { "branch-alias": { "dev-master": "3.0.x-dev" diff --git a/src/NativeClassifier.php b/src/NativeClassifier.php index 7c4795d..2d31bd4 100644 --- a/src/NativeClassifier.php +++ b/src/NativeClassifier.php @@ -7,6 +7,9 @@ use ReflectionAttribute; use ReflectionClass; +/** + * `NativeClassifier` is a classifier that finds classes using PHP's native function {@see get_declared_classes()}. + */ final class NativeClassifier extends AbstractClassifier { /** From baa2ae486951d838afdd93c26e10e0295c2ae8d6 Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 28 Sep 2023 11:54:55 +0500 Subject: [PATCH 14/31] Fix composer checker --- composer-require-checker.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 composer-require-checker.json 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" + ] +} From 13f251df50f1c7ad49c78b9493c3e5c59dd303fc Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 28 Sep 2023 18:19:06 +0500 Subject: [PATCH 15/31] Optimize visitor --- src/NativeClassifier.php | 6 +++++- src/ParserVisitor.php | 9 +++++++-- tests/Support/wrong_file.php | 8 ++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 tests/Support/wrong_file.php diff --git a/src/NativeClassifier.php b/src/NativeClassifier.php index 2d31bd4..22ed6bf 100644 --- a/src/NativeClassifier.php +++ b/src/NativeClassifier.php @@ -25,7 +25,11 @@ protected function getAvailableClasses(): iterable $files = $this->getFiles(); foreach ($files as $file) { - require_once $file; + try { + require_once $file; + } catch (\Throwable) { + // Ignore syntax errors + } } foreach (get_declared_classes() as $className) { diff --git a/src/ParserVisitor.php b/src/ParserVisitor.php index 7f71627..6344a87 100644 --- a/src/ParserVisitor.php +++ b/src/ParserVisitor.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PhpParser\Node\Stmt\Class_; +use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; /** @@ -30,7 +31,11 @@ public function __construct( public function enterNode(Node $node) { - if (($node instanceof Class_) && !$this->skipClass($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. @@ -39,7 +44,7 @@ public function enterNode(Node $node) $this->classNames[] = $className; } - return parent::enterNode($node); + return NodeTraverser::DONT_TRAVERSE_CHILDREN; } /** diff --git a/tests/Support/wrong_file.php b/tests/Support/wrong_file.php new file mode 100644 index 0000000..33442fd --- /dev/null +++ b/tests/Support/wrong_file.php @@ -0,0 +1,8 @@ + Date: Thu, 28 Sep 2023 18:30:22 +0500 Subject: [PATCH 16/31] Ignore wrong file --- .styleci.yml | 1 + rector.php | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.styleci.yml b/.styleci.yml index 1ab379b..691bbde 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -7,6 +7,7 @@ finder: exclude: - docs - vendor + - tests/Support/wrong_file.php enabled: - alpha_ordered_traits diff --git a/rector.php b/rector.php index 63713ce..65372ac 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); From 3fe9c9c59ad175a6403b4fec14e81660f01fd01b Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Thu, 28 Sep 2023 13:30:41 +0000 Subject: [PATCH 17/31] Apply fixes from StyleCI --- rector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rector.php b/rector.php index 65372ac..531f028 100644 --- a/rector.php +++ b/rector.php @@ -13,7 +13,7 @@ ]); $rectorConfig->skip([ - __DIR__ . '/tests/Support/wrong_file.php' + __DIR__ . '/tests/Support/wrong_file.php', ]); // register a single rule From f8716a67706590de737c36539c9fa1fb8f0e1d11 Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 28 Sep 2023 18:43:01 +0500 Subject: [PATCH 18/31] Fix styleci config --- .styleci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.styleci.yml b/.styleci.yml index 691bbde..17f8dc4 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -7,7 +7,8 @@ finder: exclude: - docs - vendor - - tests/Support/wrong_file.php + not-name: + - wrong_file.php enabled: - alpha_ordered_traits From 46cf634006f617b16f778757476c7f844d024d75 Mon Sep 17 00:00:00 2001 From: Rustam Date: Sun, 15 Oct 2023 18:05:33 +0500 Subject: [PATCH 19/31] Minor test --- tests/BaseClassifierTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/BaseClassifierTest.php b/tests/BaseClassifierTest.php index 9386edc..bf9fd5e 100644 --- a/tests/BaseClassifierTest.php +++ b/tests/BaseClassifierTest.php @@ -22,6 +22,17 @@ abstract class BaseClassifierTest extends TestCase { + public function testMultipleUse(): void + { + $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; + $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']; From 2db3db719f692545e1e970bbb82cea14906e2650 Mon Sep 17 00:00:00 2001 From: Rustam Date: Sun, 15 Oct 2023 18:10:19 +0500 Subject: [PATCH 20/31] Move parser classifier into separate PR --- src/ParserClassifier.php | 55 ---------------------- src/ParserVisitor.php | 86 ---------------------------------- tests/ParserClassifierTest.php | 16 ------- 3 files changed, 157 deletions(-) delete mode 100644 src/ParserClassifier.php delete mode 100644 src/ParserVisitor.php delete mode 100644 tests/ParserClassifierTest.php diff --git a/src/ParserClassifier.php b/src/ParserClassifier.php deleted file mode 100644 index 13a8b19..0000000 --- a/src/ParserClassifier.php +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 6344a87..0000000 --- a/src/ParserVisitor.php +++ /dev/null @@ -1,86 +0,0 @@ - - */ - 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/ParserClassifierTest.php b/tests/ParserClassifierTest.php deleted file mode 100644 index 488355a..0000000 --- a/tests/ParserClassifierTest.php +++ /dev/null @@ -1,16 +0,0 @@ - Date: Thu, 26 Oct 2023 19:36:58 +0500 Subject: [PATCH 21/31] Token classifier & refactor filters (#41) --- .github/workflows/build.yml | 2 +- .../workflows/composer-require-checker.yml | 2 +- .github/workflows/mutation.yml | 2 +- .github/workflows/rector.yml | 2 +- .github/workflows/static.yml | 2 +- .styleci.yml | 1 + composer.json | 3 +- src/AbstractClassifier.php | 82 +++--- src/ClassifierInterface.php | 7 +- src/Filter/ClassAttributes.php | 33 +++ src/Filter/ClassImplements.php | 27 ++ src/Filter/FilterInterface.php | 19 ++ src/Filter/SubclassOf.php | 22 ++ src/Filter/TargetAttribute.php | 29 ++ src/NativeClassifier.php | 63 +--- src/ReflectionFile.php | 275 ++++++++++++++++++ src/TokenizerClassifier.php | 29 ++ tests/BaseClassifierTest.php | 61 +++- tests/Declarations/Car.php | 9 + tests/Declarations/ClassWithAnonymous.php | 14 + tests/Declarations/ClassWithoutNamespace.php | 7 + tests/Declarations/StatusEnum.php | 11 + tests/Declarations/namespace.php | 13 + tests/ReflectionFileTest.php | 60 ++++ tests/Support/Attributes/UserAttribute.php | 12 + tests/Support/Author.php | 2 + tests/Support/AuthorPost.php | 2 + tests/TokenizerClassifierTest.php | 16 + 28 files changed, 696 insertions(+), 111 deletions(-) create mode 100644 src/Filter/ClassAttributes.php create mode 100644 src/Filter/ClassImplements.php create mode 100644 src/Filter/FilterInterface.php create mode 100644 src/Filter/SubclassOf.php create mode 100644 src/Filter/TargetAttribute.php create mode 100644 src/ReflectionFile.php create mode 100644 src/TokenizerClassifier.php create mode 100644 tests/Declarations/Car.php create mode 100644 tests/Declarations/ClassWithAnonymous.php create mode 100644 tests/Declarations/ClassWithoutNamespace.php create mode 100644 tests/Declarations/StatusEnum.php create mode 100644 tests/Declarations/namespace.php create mode 100644 tests/ReflectionFileTest.php create mode 100644 tests/Support/Attributes/UserAttribute.php create mode 100644 tests/TokenizerClassifierTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8506ea1..f12ea5e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,4 +28,4 @@ jobs: os: >- ['ubuntu-latest', 'windows-latest'] php: >- - ['8.0', '8.1'] + ['8.1', '8.2'] diff --git a/.github/workflows/composer-require-checker.yml b/.github/workflows/composer-require-checker.yml index 6cb4099..b115ab1 100644 --- a/.github/workflows/composer-require-checker.yml +++ b/.github/workflows/composer-require-checker.yml @@ -30,4 +30,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.0'] + ['8.1'] diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index c1aca98..03b72c0 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -26,6 +26,6 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.1'] + ['8.2'] secrets: STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/.github/workflows/rector.yml b/.github/workflows/rector.yml index adacd73..c597e60 100644 --- a/.github/workflows/rector.yml +++ b/.github/workflows/rector.yml @@ -18,4 +18,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.0'] + ['8.2'] diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 96b2679..301ab7c 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -28,4 +28,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.0', '8.1'] + ['8.1', '8.2'] diff --git a/.styleci.yml b/.styleci.yml index 17f8dc4..f83bea2 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -9,6 +9,7 @@ finder: - vendor not-name: - wrong_file.php + - namespace.php enabled: - alpha_ordered_traits diff --git a/composer.json b/composer.json index 9485fda..3f3fff5 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "php": "^8.0", + "php": "^8.1", + "ext-tokenizer": "*", "symfony/finder": "^5.4|^6.0" }, "require-dev": { diff --git a/src/AbstractClassifier.php b/src/AbstractClassifier.php index 5ad78d8..ce96d1a 100644 --- a/src/AbstractClassifier.php +++ b/src/AbstractClassifier.php @@ -4,7 +4,9 @@ namespace Yiisoft\Classifier; +use ReflectionClass; use Symfony\Component\Finder\Finder; +use Yiisoft\Classifier\Filter\FilterInterface; /** * Base implementation for {@see ClassifierInterface} with common filters. @@ -12,17 +14,14 @@ abstract class AbstractClassifier implements ClassifierInterface { /** - * @var string[] + * @var array */ - protected array $interfaces = []; - /** - * @var string[] - */ - protected array $attributes = []; + protected static array $reflectionsCache = []; + /** - * @psalm-var class-string + * @var FilterInterface[] */ - protected ?string $parentClass = null; + private array $filters = []; /** * @var string[] */ @@ -33,48 +32,25 @@ public function __construct(string $directory, string ...$directories) $this->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 + public function withFilter(FilterInterface ...$filter): static { $new = clone $this; - array_push($new->attributes, ...array_values($attributes)); + array_push($new->filters, ...array_values($filter)); return $new; } /** - * @psalm-return iterable + * @return iterable */ public function find(): iterable { - if (empty($this->interfaces) && empty($this->attributes) && $this->parentClass === null) { - return []; + foreach ($this->getAvailableDeclarations() as $declaration) { + if ($this->skipDeclaration($declaration)) { + continue; + } + yield $declaration; } - - yield from $this->getAvailableClasses(); } protected function getFiles(): Finder @@ -87,7 +63,31 @@ protected function getFiles(): Finder } /** - * @return iterable + * @param class-string|trait-string $declaration + */ + private function skipDeclaration(string $declaration): bool + { + try { + $reflectionClass = self::$reflectionsCache[$declaration] ??= new ReflectionClass($declaration); + } catch (\Throwable) { + return true; + } + + if ($reflectionClass->isInternal() || $reflectionClass->isAnonymous()) { + return true; + } + + foreach ($this->filters as $filter) { + if (!$filter->match($reflectionClass)) { + return true; + } + } + + return false; + } + + /** + * @return iterable */ - abstract protected function getAvailableClasses(): iterable; + abstract protected function getAvailableDeclarations(): iterable; } diff --git a/src/ClassifierInterface.php b/src/ClassifierInterface.php index 4e03824..e4c487e 100644 --- a/src/ClassifierInterface.php +++ b/src/ClassifierInterface.php @@ -4,16 +4,19 @@ namespace Yiisoft\Classifier; +use Yiisoft\Classifier\Filter\FilterInterface; + /** * `Classifier` is a class finder that represents the classes found. */ interface ClassifierInterface { + public function withFilter(FilterInterface ...$filter): static; + /** * Returns all the class names found. * - * @return iterable List of class names. - * @psalm-return iterable + * @return iterable List of class names. */ public function find(): iterable; } diff --git a/src/Filter/ClassAttributes.php b/src/Filter/ClassAttributes.php new file mode 100644 index 0000000..53b8e1c --- /dev/null +++ b/src/Filter/ClassAttributes.php @@ -0,0 +1,33 @@ +attributes = $attributes; + } + + public function match(ReflectionClass $reflectionClass): bool + { + if (empty($this->attributes)) { + return false; + } + + $attributes = $reflectionClass->getAttributes(); + $attributeNames = array_map( + static fn(ReflectionAttribute $attribute) => $attribute->getName(), + $attributes + ); + + return count(array_intersect($this->attributes, $attributeNames)) === count($this->attributes); + } +} diff --git a/src/Filter/ClassImplements.php b/src/Filter/ClassImplements.php new file mode 100644 index 0000000..39c34e0 --- /dev/null +++ b/src/Filter/ClassImplements.php @@ -0,0 +1,27 @@ +interfaces = $interfaces; + } + + public function match(ReflectionClass $reflectionClass): bool + { + if (empty($this->interfaces) || $reflectionClass->isInterface()) { + return false; + } + $interfaces = $reflectionClass->getInterfaceNames(); + + return count(array_intersect($this->interfaces, $interfaces)) === count($this->interfaces); + } +} diff --git a/src/Filter/FilterInterface.php b/src/Filter/FilterInterface.php new file mode 100644 index 0000000..964615e --- /dev/null +++ b/src/Filter/FilterInterface.php @@ -0,0 +1,19 @@ +isSubclassOf($this->class); + } +} diff --git a/src/Filter/TargetAttribute.php b/src/Filter/TargetAttribute.php new file mode 100644 index 0000000..ed6c9d0 --- /dev/null +++ b/src/Filter/TargetAttribute.php @@ -0,0 +1,29 @@ +getAttributes($this->attribute, ReflectionAttribute::IS_INSTANCEOF); + $attributeNames = array_map( + static fn(ReflectionAttribute $attribute) => $attribute->getName(), + $attributes + ); + + return !empty($attributeNames); + } +} diff --git a/src/NativeClassifier.php b/src/NativeClassifier.php index 22ed6bf..d9a6ce0 100644 --- a/src/NativeClassifier.php +++ b/src/NativeClassifier.php @@ -4,23 +4,15 @@ namespace Yiisoft\Classifier; -use ReflectionAttribute; -use ReflectionClass; - /** * `NativeClassifier` is a classifier that finds classes using PHP's native function {@see get_declared_classes()}. */ final class NativeClassifier extends AbstractClassifier { - /** - * @psalm-var array - */ - private static array $reflectionsCache = []; - /** * @psalm-suppress UnresolvableInclude */ - protected function getAvailableClasses(): iterable + protected function getAvailableDeclarations(): iterable { $files = $this->getFiles(); @@ -32,25 +24,8 @@ protected function getAvailableClasses(): iterable } } - 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); + $declarations = [...get_declared_classes(), ...get_declared_interfaces(), ...get_declared_traits()]; - if ($reflectionClass->isInternal() || $reflectionClass->isAnonymous()) { - return true; - } $directories = $this->directories; $isWindows = DIRECTORY_SEPARATOR === '\\'; @@ -63,36 +38,18 @@ private function skipClass(string $className): bool // @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); + foreach ($declarations as $declaration) { + $reflectionClass = self::$reflectionsCache[$declaration] ??= new \ReflectionClass($declaration); - 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 + $matchedDirs = array_filter( + $directories, + static fn($directory) => $reflectionClass->getFileName() && str_starts_with($reflectionClass->getFileName(), $directory) ); - if (count(array_intersect($this->attributes, $attributes)) !== count($this->attributes)) { - return true; + if (count($matchedDirs) === 0) { + continue; } + yield $reflectionClass->getName(); } - - return ($this->parentClass !== null) && !is_subclass_of($reflectionClass->getName(), $this->parentClass); } } diff --git a/src/ReflectionFile.php b/src/ReflectionFile.php new file mode 100644 index 0000000..e5b1030 --- /dev/null +++ b/src/ReflectionFile.php @@ -0,0 +1,275 @@ + + */ + private array $tokens; + + /** + * Total tokens count. + */ + private int $countTokens; + + /** + * Namespaces used in file and their token positions. + * + * @psalm-var array + */ + private array $namespaces = []; + + /** + * Declarations of classes, interfaces and traits. + * + * @psalm-var array + */ + private array $declarations = []; + + public function __construct( + private string $filename + ) { + $this->tokens = \PhpToken::tokenize(file_get_contents($this->filename)); + $this->countTokens = \count($this->tokens); + + //Looking for declarations + $this->locateDeclarations(); + } + + /** + * List of declarations names + * + * @return array + */ + public function getDeclarations(): array + { + return \array_keys($this->declarations); + } + + /** + * Locate every class, interface, trait or enum definition. + */ + private function locateDeclarations(): void + { + foreach ($this->tokens as $tokenIndex => $token) { + if (!\in_array($token->id, self::TOKENS, true)) { + continue; + } + + switch ($token->id) { + case T_NAMESPACE: + $this->registerNamespace($tokenIndex); + break; + + case T_CLASS: + case T_TRAIT: + case T_INTERFACE: + case T_ENUM: + if ($this->isClassNameConst($tokenIndex)) { + // PHP5.5 ClassName::class constant + continue 2; + } + + if ($this->isAnonymousClass($tokenIndex)) { + // PHP7.0 Anonymous classes new class ('foo', 'bar') + continue 2; + } + + if (!$this->isCorrectDeclaration($tokenIndex)) { + // PHP8.0 Named parameters ->foo(class: 'bar') + continue 2; + } + + $this->registerDeclaration($tokenIndex); + break; + } + } + + //Dropping empty namespace + if (isset($this->namespaces[''])) { + $this->namespaces['\\'] = $this->namespaces['']; + unset($this->namespaces['']); + } + } + + /** + * Handle namespace declaration. + */ + private function registerNamespace(int $tokenIndex): void + { + $namespace = ''; + $localIndex = $tokenIndex + 1; + + do { + $token = $this->tokens[$localIndex++]; + $namespace .= $token->text; + } while ( + isset($this->tokens[$localIndex]) + && $this->tokens[$localIndex]->text !== '{' + && $this->tokens[$localIndex]->text !== ';' + ); + + //Whitespaces + $namespace = \trim($namespace); + + if ($this->tokens[$localIndex]->text === ';') { + $endingIndex = \count($this->tokens) - 1; + } else { + $endingIndex = $this->endingToken($tokenIndex); + } + + $this->namespaces[$namespace] = [ + self::O_TOKEN => $tokenIndex, + self::C_TOKEN => $endingIndex, + ]; + } + + /** + * Handle declaration of class, trait of interface. Declaration will be stored under it's token + * type in declarations array. + */ + private function registerDeclaration(int $tokenIndex): void + { + $localIndex = $tokenIndex + 1; + while ($this->tokens[$localIndex]->id !== T_STRING) { + ++$localIndex; + } + + $name = $this->tokens[$localIndex]->text; + if (!empty($namespace = $this->activeNamespace($tokenIndex))) { + $name = $namespace . self::NS_SEPARATOR . $name; + } + + /** @var class-string|trait-string $name */ + $this->declarations[$name] = [ + self::O_TOKEN => $tokenIndex, + self::C_TOKEN => $this->endingToken($tokenIndex), + ]; + } + + /** + * Check if token ID represents `ClassName::class` constant statement. + */ + private function isClassNameConst(int $tokenIndex): bool + { + return $this->tokens[$tokenIndex]->id === T_CLASS + && isset($this->tokens[$tokenIndex - 1]) + && $this->tokens[$tokenIndex - 1]->id === T_PAAMAYIM_NEKUDOTAYIM; + } + + /** + * Check if token ID represents anonymous class creation, e.g. `new class ('foo', 'bar')`. + */ + private function isAnonymousClass(int $tokenIndex): bool + { + return $this->tokens[$tokenIndex]->id === T_CLASS + && isset($this->tokens[$tokenIndex - 2]) + && $this->tokens[$tokenIndex - 2]->id === T_NEW; + } + + /** + * Check if token ID represents named parameter with name `class`, e.g. `foo(class: SomeClass::name)`. + */ + private function isCorrectDeclaration(int $tokenIndex): bool + { + return \in_array($this->tokens[$tokenIndex]->id, [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM], true) + && isset($this->tokens[$tokenIndex + 2]) + && $this->tokens[$tokenIndex + 1]->id === T_WHITESPACE + && $this->tokens[$tokenIndex + 2]->id === T_STRING; + } + + /** + * Get namespace name active at specified token position. + * + * @return array-key + */ + private function activeNamespace(int $tokenIndex): string + { + foreach ($this->namespaces as $namespace => $position) { + if ($tokenIndex >= $position[self::O_TOKEN] && $tokenIndex <= $position[self::C_TOKEN]) { + return $namespace; + } + } + + //Seems like no namespace declaration + $this->namespaces[''] = [ + self::O_TOKEN => 0, + self::C_TOKEN => \count($this->tokens), + ]; + + return ''; + } + + /** + * Find token index of ending brace. + */ + private function endingToken(int $tokenIndex): int + { + $level = 0; + $hasOpen = false; + for ($localIndex = $tokenIndex; $localIndex < $this->countTokens; ++$localIndex) { + $token = $this->tokens[$localIndex]; + if ($token->text === '{') { + ++$level; + $hasOpen = true; + continue; + } + + if ($token->text === '}') { + --$level; + } + + if ($hasOpen && $level === 0) { + break; + } + } + + return $localIndex; + } +} diff --git a/src/TokenizerClassifier.php b/src/TokenizerClassifier.php new file mode 100644 index 0000000..cb34b07 --- /dev/null +++ b/src/TokenizerClassifier.php @@ -0,0 +1,29 @@ + + */ + protected function getAvailableDeclarations(): iterable + { + $files = $this->getFiles(); + $declarations = []; + + foreach ($files as $file) { + $reflectionFile = new ReflectionFile($file->getPathname()); + array_push($declarations, ...$reflectionFile->getDeclarations()); + } + + return $declarations; + } +} diff --git a/tests/BaseClassifierTest.php b/tests/BaseClassifierTest.php index bf9fd5e..95d3d57 100644 --- a/tests/BaseClassifierTest.php +++ b/tests/BaseClassifierTest.php @@ -6,7 +6,12 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Classifier\ClassifierInterface; +use Yiisoft\Classifier\Filter\ClassAttributes; +use Yiisoft\Classifier\Filter\ClassImplements; +use Yiisoft\Classifier\Filter\SubclassOf; +use Yiisoft\Classifier\Filter\TargetAttribute; use Yiisoft\Classifier\Tests\Support\Attributes\AuthorAttribute; +use Yiisoft\Classifier\Tests\Support\Attributes\UserAttribute; use Yiisoft\Classifier\Tests\Support\Author; use Yiisoft\Classifier\Tests\Support\AuthorPost; use Yiisoft\Classifier\Tests\Support\Dir1\UserInDir1; @@ -26,7 +31,7 @@ public function testMultipleUse(): void { $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; $finder = $this->createClassifier(...$dirs); - $finder = $finder->withInterface(UserInterface::class); + $finder = $finder->withFilter(new ClassImplements(UserInterface::class)); $result = $finder->find(); @@ -37,7 +42,7 @@ public function testMultipleDirectories(): void { $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; $finder = $this->createClassifier(...$dirs); - $finder = $finder->withInterface(UserInterface::class); + $finder = $finder->withFilter(new ClassImplements(UserInterface::class)); $result = $finder->find(); @@ -50,7 +55,7 @@ public function testMultipleDirectories(): void public function testInterfaces(string $directory, array $interfaces, array $expectedClasses): void { $finder = $this->createClassifier($directory); - $finder = $finder->withInterface(...$interfaces); + $finder = $finder->withFilter(new ClassImplements(...$interfaces)); $result = $finder->find(); @@ -78,7 +83,15 @@ public static function interfacesDataProvider(): array [ __DIR__, [UserInterface::class], - [UserInDir1::class, UserInDir2::class, PostUser::class, SuperSuperUser::class, SuperUser::class, User::class, UserSubclass::class], + [ + UserInDir1::class, + UserInDir2::class, + PostUser::class, + SuperSuperUser::class, + SuperUser::class, + User::class, + UserSubclass::class, + ], ], [ __DIR__, @@ -104,7 +117,20 @@ public static function interfacesDataProvider(): array public function testAttributes(array $attributes, array $expectedClasses): void { $finder = $this->createClassifier(__DIR__); - $finder = $finder->withAttribute(...$attributes); + $finder = $finder->withFilter(new ClassAttributes(...$attributes)); + + $result = $finder->find(); + + $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); + } + + /** + * @dataProvider targetAttributeDataProvider + */ + public function testTargetAttribute(string $attribute, array $expectedClasses): void + { + $finder = $this->createClassifier(__DIR__); + $finder = $finder->withFilter(new TargetAttribute($attribute)); $result = $finder->find(); @@ -117,7 +143,7 @@ public function testAttributes(array $attributes, array $expectedClasses): void public function testParentClass(string $parent, array $expectedClasses): void { $finder = $this->createClassifier(__DIR__); - $finder = $finder->withParentClass($parent); + $finder = $finder->withFilter(new SubclassOf($parent)); $result = $finder->find(); @@ -132,7 +158,25 @@ public static function attributesDataProvider(): array [], ], [ - [AuthorAttribute::class], + [AuthorAttribute::class, UserAttribute::class], + [Author::class, AuthorPost::class], + ], + ]; + } + + public static function targetAttributeDataProvider(): array + { + return [ + [ + UserSubclass::class, + [], + ], + [ + UserAttribute::class, + [Author::class, AuthorPost::class], + ], + [ + AuthorAttribute::class, [Author::class, AuthorPost::class], ], ]; @@ -145,8 +189,7 @@ public function testMixed(array $attributes, array $interfaces, array $expectedC { $finder = $this->createClassifier(__DIR__); $finder = $finder - ->withAttribute(...$attributes) - ->withInterface(...$interfaces); + ->withFilter(new ClassAttributes(...$attributes), new ClassImplements(...$interfaces)); $result = $finder->find(); diff --git a/tests/Declarations/Car.php b/tests/Declarations/Car.php new file mode 100644 index 0000000..bd40ed0 --- /dev/null +++ b/tests/Declarations/Car.php @@ -0,0 +1,9 @@ +assertNotEmpty($reflectionFile->getDeclarations()); + $this->assertContains(User::class, $reflectionFile->getDeclarations()); + } + + public function testNamespaceDeclaration(): void + { + $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/namespace.php'); + + $this->assertCount(2, $reflectionFile->getDeclarations()); + $this->assertContains(\Yiisoft\Classifier\Tests\Declarations\Person::class, $reflectionFile->getDeclarations()); + } + + public function testEnumDeclaration(): void + { + $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/StatusEnum.php'); + + $this->assertCount(1, $reflectionFile->getDeclarations()); + $this->assertEquals(StatusEnum::class, $reflectionFile->getDeclarations()[0]); + } + + public function testWithoutNamespace(): void + { + $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/ClassWithoutNamespace.php'); + + $this->assertCount(1, $reflectionFile->getDeclarations()); + $this->assertEquals('ClassWithoutNamespace', $reflectionFile->getDeclarations()[0]); + } + + public function testContainingClassKeyword(): void + { + $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/Car.php'); + + $this->assertCount(1, $reflectionFile->getDeclarations()); + $this->assertEquals(\Car::class, $reflectionFile->getDeclarations()[0]); + } + + public function testBrokenClass(): void + { + $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/ClassWithAnonymous.php'); + + $this->assertCount(1, $reflectionFile->getDeclarations()); + } +} diff --git a/tests/Support/Attributes/UserAttribute.php b/tests/Support/Attributes/UserAttribute.php new file mode 100644 index 0000000..8f5bc09 --- /dev/null +++ b/tests/Support/Attributes/UserAttribute.php @@ -0,0 +1,12 @@ + Date: Fri, 27 Oct 2023 09:46:41 +0500 Subject: [PATCH 22/31] Add benchmark --- .github/workflows/bechmark.yml | 32 ++++++++++++++++++++++++++ .scrutinizer.yml | 2 +- composer.json | 1 + phpbench.json | 12 ++++++++++ tests/Benchmark/ClassifierBench.php | 35 +++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/bechmark.yml create mode 100644 phpbench.json create mode 100644 tests/Benchmark/ClassifierBench.php diff --git a/.github/workflows/bechmark.yml b/.github/workflows/bechmark.yml new file mode 100644 index 0000000..d2d0b5c --- /dev/null +++ b/.github/workflows/bechmark.yml @@ -0,0 +1,32 @@ +on: + pull_request: + paths-ignore: + - 'docs/**' + - 'README.md' + - 'CHANGELOG.md' + - '.gitignore' + - '.gitattributes' + - 'infection.json.dist' + - 'psalm.xml' + + push: + paths-ignore: + - 'docs/**' + - 'README.md' + - 'CHANGELOG.md' + - '.gitignore' + - '.gitattributes' + - 'infection.json.dist' + - 'psalm.xml' + +name: bechmark + +jobs: + phpbench: + uses: yiisoft/actions/.github/workflows/phpbench.yml@master + with: + os: >- + ['ubuntu-latest', 'windows-latest'] + php: >- + ['8.1', '8.2'] + diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 61fb502..33861aa 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -10,7 +10,7 @@ build: environment: php: - version: 8.0.18 + version: 8.1 ini: xdebug.mode: coverage diff --git a/composer.json b/composer.json index 3f3fff5..87f4fec 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "require-dev": { "maglnet/composer-require-checker": "^4.2", "nikic/php-parser": "^4.17", + "phpbench/phpbench": "^1.2", "phpunit/phpunit": "^9.5", "rector/rector": "^0.18.0", "roave/infection-static-analysis-plugin": "^1.16", diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 0000000..5254f28 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,12 @@ +{ + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "tests/Benchmark", + "runner.retry_threshold": 3, + "report.outputs": { + "csv_file": { + "extends": "delimited", + "delimiter": ",", + "file": "benchmarks.csv" + } + } +} diff --git a/tests/Benchmark/ClassifierBench.php b/tests/Benchmark/ClassifierBench.php new file mode 100644 index 0000000..9632903 --- /dev/null +++ b/tests/Benchmark/ClassifierBench.php @@ -0,0 +1,35 @@ + ['classifier' => NativeClassifier::class, 'dirs' => $dirs]; + yield 'Tokenizer' => ['classifier' => TokenizerClassifier::class, 'dirs' => $dirs]; + } + + #[ParamProviders(['provideClassifiers'])] + public function benchClassifier(array $params): void + { + $classifier = $params['classifier']; + $classifierInstance = new $classifier(...$params['dirs']); + $classifierInstance->find(); + } +} From 4b23d762468f87b46afc11915442c80b90a8b804 Mon Sep 17 00:00:00 2001 From: Rustam Date: Mon, 30 Oct 2023 22:58:27 +0500 Subject: [PATCH 23/31] Add filters & tests --- .github/workflows/bechmark.yml | 1 + src/Filter/Condition/FilterAnd.php | 35 ++++++++++++++++++ src/Filter/Condition/FilterOr.php | 35 ++++++++++++++++++ src/NativeClassifier.php | 6 +++- tests/Benchmark/ClassifierBench.php | 1 + tests/Declarations/Car.php | 2 ++ tests/Filter/BaseFilterTest.php | 21 +++++++++++ tests/Filter/Condition/FilterAndTest.php | 45 ++++++++++++++++++++++++ tests/Filter/Condition/FilterOrTest.php | 43 ++++++++++++++++++++++ tests/ReflectionFileTest.php | 3 +- 10 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 src/Filter/Condition/FilterAnd.php create mode 100644 src/Filter/Condition/FilterOr.php create mode 100644 tests/Filter/BaseFilterTest.php create mode 100644 tests/Filter/Condition/FilterAndTest.php create mode 100644 tests/Filter/Condition/FilterOrTest.php diff --git a/.github/workflows/bechmark.yml b/.github/workflows/bechmark.yml index d2d0b5c..8fa0936 100644 --- a/.github/workflows/bechmark.yml +++ b/.github/workflows/bechmark.yml @@ -29,4 +29,5 @@ jobs: ['ubuntu-latest', 'windows-latest'] php: >- ['8.1', '8.2'] + report: aggregate diff --git a/src/Filter/Condition/FilterAnd.php b/src/Filter/Condition/FilterAnd.php new file mode 100644 index 0000000..12e7e6e --- /dev/null +++ b/src/Filter/Condition/FilterAnd.php @@ -0,0 +1,35 @@ +filters = $filters; + } + + /** + * @inheritDoc + */ + public function match(ReflectionClass $reflectionClass): bool + { + foreach ($this->filters as $filter) { + if (!$filter->match($reflectionClass)) { + return false; + } + } + + return true; + } +} diff --git a/src/Filter/Condition/FilterOr.php b/src/Filter/Condition/FilterOr.php new file mode 100644 index 0000000..0b0eb2c --- /dev/null +++ b/src/Filter/Condition/FilterOr.php @@ -0,0 +1,35 @@ +filters = $filters; + } + + /** + * @inheritDoc + */ + public function match(ReflectionClass $reflectionClass): bool + { + foreach ($this->filters as $filter) { + if ($filter->match($reflectionClass)) { + return true; + } + } + + return false; + } +} diff --git a/src/NativeClassifier.php b/src/NativeClassifier.php index d9a6ce0..a81e37e 100644 --- a/src/NativeClassifier.php +++ b/src/NativeClassifier.php @@ -5,7 +5,10 @@ namespace Yiisoft\Classifier; /** - * `NativeClassifier` is a classifier that finds classes using PHP's native function {@see get_declared_classes()}. + * `NativeClassifier` is a classifier that finds classes, interfaces, traits and enums using PHP's native functions: + * - {@see get_declared_classes()} + * - {@see get_declared_interfaces()} + * - {@see get_declared_traits()} */ final class NativeClassifier extends AbstractClassifier { @@ -24,6 +27,7 @@ protected function getAvailableDeclarations(): iterable } } + /** @var string[] $declarations */ $declarations = [...get_declared_classes(), ...get_declared_interfaces(), ...get_declared_traits()]; $directories = $this->directories; diff --git a/tests/Benchmark/ClassifierBench.php b/tests/Benchmark/ClassifierBench.php index 9632903..8a56cab 100644 --- a/tests/Benchmark/ClassifierBench.php +++ b/tests/Benchmark/ClassifierBench.php @@ -19,6 +19,7 @@ public function provideClassifiers(): \Generator $dirs = [ dirname(__DIR__, 2) . '/vendor', dirname(__DIR__) . '/Declarations', + dirname(__DIR__) . '/Support', ]; yield 'Native' => ['classifier' => NativeClassifier::class, 'dirs' => $dirs]; diff --git a/tests/Declarations/Car.php b/tests/Declarations/Car.php index bd40ed0..8bd34e1 100644 --- a/tests/Declarations/Car.php +++ b/tests/Declarations/Car.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Yiisoft\Classifier\Tests\Declarations; + if (!class_exists(class: 'Car', autoload: false)) { class Car { diff --git a/tests/Filter/BaseFilterTest.php b/tests/Filter/BaseFilterTest.php new file mode 100644 index 0000000..13bc3cc --- /dev/null +++ b/tests/Filter/BaseFilterTest.php @@ -0,0 +1,21 @@ +assertSame($expectedResult, $filter->match($reflectionClass)); + } + + abstract public function matchProvider(): iterable; +} diff --git a/tests/Filter/Condition/FilterAndTest.php b/tests/Filter/Condition/FilterAndTest.php new file mode 100644 index 0000000..93ce7f3 --- /dev/null +++ b/tests/Filter/Condition/FilterAndTest.php @@ -0,0 +1,45 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least 2 filters should be provided.'); + + new FilterAnd(new SubclassOf(Car::class)); + } + + public function matchProvider(): iterable + { + yield [ + new FilterAnd(new SubclassOf(User::class), new ClassImplements(UserSubInterface::class)), + new \ReflectionClass(SuperUser::class), + true, + ]; + yield [ + new FilterAnd(new SubclassOf(PostInterface::class), new ClassAttributes(AuthorAttribute::class)), + new \ReflectionClass(Author::class), + false, + ]; + } +} diff --git a/tests/Filter/Condition/FilterOrTest.php b/tests/Filter/Condition/FilterOrTest.php new file mode 100644 index 0000000..c3b1e87 --- /dev/null +++ b/tests/Filter/Condition/FilterOrTest.php @@ -0,0 +1,43 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least 2 filters should be provided.'); + + new FilterOr(new SubclassOf(Car::class)); + } + + public function matchProvider(): iterable + { + yield [ + new FilterOr(new SubclassOf(AuthorPost::class), new TargetAttribute(AuthorAttribute::class)), + new \ReflectionClass(Author::class), + true, + ]; + yield [ + new FilterOr(new SubclassOf(AuthorPost::class), new ClassImplements(PostInterface::class)), + new \ReflectionClass(Author::class), + false, + ]; + } +} diff --git a/tests/ReflectionFileTest.php b/tests/ReflectionFileTest.php index 63f97a2..a65a5b2 100644 --- a/tests/ReflectionFileTest.php +++ b/tests/ReflectionFileTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Classifier\ReflectionFile; +use Yiisoft\Classifier\Tests\Declarations\Car; use Yiisoft\Classifier\Tests\Declarations\StatusEnum; use Yiisoft\Classifier\Tests\Support\User; @@ -48,7 +49,7 @@ public function testContainingClassKeyword(): void $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/Car.php'); $this->assertCount(1, $reflectionFile->getDeclarations()); - $this->assertEquals(\Car::class, $reflectionFile->getDeclarations()[0]); + $this->assertEquals(Car::class, $reflectionFile->getDeclarations()[0]); } public function testBrokenClass(): void From 4f3c784679dfae3d2ef5b812f8651c337a020268 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Mon, 30 Oct 2023 17:58:59 +0000 Subject: [PATCH 24/31] Apply fixes from StyleCI --- tests/Filter/Condition/FilterAndTest.php | 2 -- tests/Filter/Condition/FilterOrTest.php | 2 -- 2 files changed, 4 deletions(-) diff --git a/tests/Filter/Condition/FilterAndTest.php b/tests/Filter/Condition/FilterAndTest.php index 93ce7f3..44cd335 100644 --- a/tests/Filter/Condition/FilterAndTest.php +++ b/tests/Filter/Condition/FilterAndTest.php @@ -8,12 +8,10 @@ use Yiisoft\Classifier\Filter\ClassImplements; use Yiisoft\Classifier\Filter\Condition\FilterAnd; use Yiisoft\Classifier\Filter\SubclassOf; -use Yiisoft\Classifier\Filter\TargetAttribute; use Yiisoft\Classifier\Tests\Declarations\Car; use Yiisoft\Classifier\Tests\Filter\BaseFilterTest; use Yiisoft\Classifier\Tests\Support\Attributes\AuthorAttribute; use Yiisoft\Classifier\Tests\Support\Author; -use Yiisoft\Classifier\Tests\Support\AuthorPost; use Yiisoft\Classifier\Tests\Support\Interfaces\PostInterface; use Yiisoft\Classifier\Tests\Support\SuperUser; use Yiisoft\Classifier\Tests\Support\User; diff --git a/tests/Filter/Condition/FilterOrTest.php b/tests/Filter/Condition/FilterOrTest.php index c3b1e87..741a883 100644 --- a/tests/Filter/Condition/FilterOrTest.php +++ b/tests/Filter/Condition/FilterOrTest.php @@ -4,9 +4,7 @@ namespace Yiisoft\Classifier\Tests\Filter\Condition; -use Yiisoft\Classifier\Filter\ClassAttributes; use Yiisoft\Classifier\Filter\ClassImplements; -use Yiisoft\Classifier\Filter\Condition\FilterAnd; use Yiisoft\Classifier\Filter\Condition\FilterOr; use Yiisoft\Classifier\Filter\SubclassOf; use Yiisoft\Classifier\Filter\TargetAttribute; From c107e22b9382d4e34272d87d3d29de8c2a4e7dd6 Mon Sep 17 00:00:00 2001 From: Rustam Date: Mon, 30 Oct 2023 23:06:05 +0500 Subject: [PATCH 25/31] Fix psalm issues --- src/Filter/Condition/FilterAnd.php | 3 +++ src/Filter/Condition/FilterOr.php | 3 +++ src/NativeClassifier.php | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Filter/Condition/FilterAnd.php b/src/Filter/Condition/FilterAnd.php index 12e7e6e..9f96843 100644 --- a/src/Filter/Condition/FilterAnd.php +++ b/src/Filter/Condition/FilterAnd.php @@ -9,6 +9,9 @@ class FilterAnd implements FilterInterface { + /** + * @var FilterInterface[] + */ private array $filters; public function __construct(FilterInterface ...$filters) diff --git a/src/Filter/Condition/FilterOr.php b/src/Filter/Condition/FilterOr.php index 0b0eb2c..1b02aa8 100644 --- a/src/Filter/Condition/FilterOr.php +++ b/src/Filter/Condition/FilterOr.php @@ -9,6 +9,9 @@ class FilterOr implements FilterInterface { + /** + * @var FilterInterface[] + */ private array $filters; public function __construct(FilterInterface ...$filters) diff --git a/src/NativeClassifier.php b/src/NativeClassifier.php index a81e37e..1936769 100644 --- a/src/NativeClassifier.php +++ b/src/NativeClassifier.php @@ -27,7 +27,7 @@ protected function getAvailableDeclarations(): iterable } } - /** @var string[] $declarations */ + /** @var array $declarations */ $declarations = [...get_declared_classes(), ...get_declared_interfaces(), ...get_declared_traits()]; $directories = $this->directories; From 22e723e7e1e3c9064f028a34650f1dc81edca4e0 Mon Sep 17 00:00:00 2001 From: Rustam Date: Tue, 31 Oct 2023 11:22:19 +0500 Subject: [PATCH 26/31] Add more tests & benchmark --- tests/Benchmark/ClassifierBench.php | 25 +++++++++++++++++++++++++ tests/Declarations/CommonTrait.php | 9 +++++++++ tests/Declarations/Engine.php | 13 +++++++++++++ tests/ReflectionFileTest.php | 18 ++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 tests/Declarations/CommonTrait.php create mode 100644 tests/Declarations/Engine.php diff --git a/tests/Benchmark/ClassifierBench.php b/tests/Benchmark/ClassifierBench.php index 8a56cab..a736d30 100644 --- a/tests/Benchmark/ClassifierBench.php +++ b/tests/Benchmark/ClassifierBench.php @@ -7,7 +7,11 @@ use PhpBench\Attributes\Iterations; use PhpBench\Attributes\ParamProviders; use PhpBench\Attributes\Revs; +use Yiisoft\Classifier\ClassifierInterface; +use Yiisoft\Classifier\Filter\ClassImplements; +use Yiisoft\Classifier\Filter\SubclassOf; use Yiisoft\Classifier\NativeClassifier; +use Yiisoft\Classifier\Tests\Support\Interfaces\PostInterface; use Yiisoft\Classifier\TokenizerClassifier; #[Iterations(5)] @@ -33,4 +37,25 @@ public function benchClassifier(array $params): void $classifierInstance = new $classifier(...$params['dirs']); $classifierInstance->find(); } + + public function provideClassifiersWithFilters(): \Generator + { + $dirs = [ + dirname(__DIR__, 2) . '/vendor', + dirname(__DIR__) . '/Declarations', + dirname(__DIR__) . '/Support', + ]; + $filters = [new ClassImplements(PostInterface::class), new SubclassOf(\Traversable::class)]; + yield 'Native' => ['classifier' => NativeClassifier::class, 'dirs' => $dirs, 'filters' => $filters]; + yield 'Tokenizer' => ['classifier' => TokenizerClassifier::class, 'dirs' => $dirs, 'filters' => $filters]; + } + + #[ParamProviders(['provideClassifiersWithFilters'])] + public function benchClassifierWithFilters(array $params): void + { + /** @var class-string $classifier */ + $classifier = $params['classifier']; + $classifierInstance = new $classifier(...$params['dirs']); + $classifierInstance->withFilter(...$params['filters'])->find(); + } } diff --git a/tests/Declarations/CommonTrait.php b/tests/Declarations/CommonTrait.php new file mode 100644 index 0000000..6cd1a71 --- /dev/null +++ b/tests/Declarations/CommonTrait.php @@ -0,0 +1,9 @@ +assertContains(\Yiisoft\Classifier\Tests\Declarations\Person::class, $reflectionFile->getDeclarations()); } + public function testInterfaceDeclaration(): void + { + $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/Engine.php'); + + $this->assertCount(2, $reflectionFile->getDeclarations()); + $this->assertContains(EngineInterface::class, $reflectionFile->getDeclarations()); + } + + public function testTraitDeclaration(): void + { + $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/CommonTrait.php'); + + $this->assertCount(1, $reflectionFile->getDeclarations()); + $this->assertEquals(CommonTrait::class, $reflectionFile->getDeclarations()[0]); + } + public function testEnumDeclaration(): void { $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/StatusEnum.php'); From d043b6a16b2ddb9d787522bbfb2f7ab8c6197d9f Mon Sep 17 00:00:00 2001 From: Rustam Date: Wed, 1 Nov 2023 22:38:50 +0500 Subject: [PATCH 27/31] Minor fix --- .github/workflows/{bechmark.yml => benchmark.yml} | 0 composer-require-checker.json | 8 +------- composer.json | 4 +--- src/ReflectionFile.php | 6 ++++-- 4 files changed, 6 insertions(+), 12 deletions(-) rename .github/workflows/{bechmark.yml => benchmark.yml} (100%) diff --git a/.github/workflows/bechmark.yml b/.github/workflows/benchmark.yml similarity index 100% rename from .github/workflows/bechmark.yml rename to .github/workflows/benchmark.yml diff --git a/composer-require-checker.json b/composer-require-checker.json index 0809396..62221f2 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,11 +1,5 @@ { "symbol-whitelist": [ - "PhpParser\\Node", - "PhpParser\\NodeTraverser", - "PhpParser\\NodeVisitorAbstract", - "PhpParser\\NodeVisitor\\NameResolver", - "PhpParser\\Node\\Stmt\\Class_", - "PhpParser\\Parser", - "PhpParser\\ParserFactory" + "PhpToken" ] } diff --git a/composer.json b/composer.json index 87f4fec..3a08e6f 100644 --- a/composer.json +++ b/composer.json @@ -29,12 +29,10 @@ "prefer-stable": true, "require": { "php": "^8.1", - "ext-tokenizer": "*", "symfony/finder": "^5.4|^6.0" }, "require-dev": { "maglnet/composer-require-checker": "^4.2", - "nikic/php-parser": "^4.17", "phpbench/phpbench": "^1.2", "phpunit/phpunit": "^9.5", "rector/rector": "^0.18.0", @@ -53,7 +51,7 @@ } }, "suggest": { - "nikic/php-parser": "Need for ParserClassifier implementation" + "ext-tokenizer": "Need for TokenizerClassifier implementation" }, "extra": { "branch-alias": { diff --git a/src/ReflectionFile.php b/src/ReflectionFile.php index e5b1030..0d53577 100644 --- a/src/ReflectionFile.php +++ b/src/ReflectionFile.php @@ -4,6 +4,8 @@ namespace Yiisoft\Classifier; +use PhpToken; + /** * This file was copied from {@link https://github.com/spiral/tokenizer}. * @@ -49,7 +51,7 @@ final class ReflectionFile /** * Parsed tokens array. * - * @var array + * @var array */ private array $tokens; @@ -75,7 +77,7 @@ final class ReflectionFile public function __construct( private string $filename ) { - $this->tokens = \PhpToken::tokenize(file_get_contents($this->filename)); + $this->tokens = PhpToken::tokenize(file_get_contents($this->filename)); $this->countTokens = \count($this->tokens); //Looking for declarations From 776980fbd5c01318f575355d3c1e0dead24c3711 Mon Sep 17 00:00:00 2001 From: Rustam Date: Wed, 1 Nov 2023 22:41:41 +0500 Subject: [PATCH 28/31] Fix composer checker config --- composer-require-checker.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/composer-require-checker.json b/composer-require-checker.json index 62221f2..b369f4c 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,5 +1,13 @@ { "symbol-whitelist": [ - "PhpToken" + "PhpToken", + "T_PAAMAYIM_NEKUDOTAYIM", + "T_NAMESPACE", + "T_STRING", + "T_CLASS", + "T_INTERFACE", + "T_TRAIT", + "T_ENUM", + "T_NS_SEPARATOR" ] } From 5b23a1fd438c037c1cbdf3b0012dc61ad0ded4ee Mon Sep 17 00:00:00 2001 From: Rustam Date: Fri, 3 Nov 2023 18:07:06 +0500 Subject: [PATCH 29/31] Fix composer-checker --- composer-require-checker.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composer-require-checker.json b/composer-require-checker.json index b369f4c..e49b5d8 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -8,6 +8,8 @@ "T_INTERFACE", "T_TRAIT", "T_ENUM", - "T_NS_SEPARATOR" + "T_NS_SEPARATOR", + "T_NEW", + "T_WHITESPACE" ] } From 4054276ef189001380e9e9b9e5660dd724377dd2 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Tue, 16 Jul 2024 19:17:55 +0000 Subject: [PATCH 30/31] Apply fixes from StyleCI --- tests/ReflectionFileTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ReflectionFileTest.php b/tests/ReflectionFileTest.php index 2d04ae8..09685c1 100644 --- a/tests/ReflectionFileTest.php +++ b/tests/ReflectionFileTest.php @@ -27,7 +27,7 @@ public function testNamespaceDeclaration(): void $reflectionFile = new ReflectionFile(__DIR__ . '/Declarations/namespace.php'); $this->assertCount(2, $reflectionFile->getDeclarations()); - $this->assertContains(\Yiisoft\Classifier\Tests\Declarations\Person::class, $reflectionFile->getDeclarations()); + $this->assertContains(Declarations\Person::class, $reflectionFile->getDeclarations()); } public function testInterfaceDeclaration(): void From b357dac0fd347f082a4b7d0fe1522b25463001a9 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Tue, 16 Jul 2024 22:23:37 +0300 Subject: [PATCH 31/31] Remove Classifier.php that was resurrected by merge --- src/Classifier.php | 163 --------------------------------------------- 1 file changed, 163 deletions(-) delete mode 100644 src/Classifier.php 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; - } - } -}