diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..8fa0936 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,33 @@ +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'] + report: aggregate + 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/.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/.styleci.yml b/.styleci.yml index 1ab379b..f83bea2 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -7,6 +7,9 @@ finder: exclude: - docs - vendor + not-name: + - wrong_file.php + - namespace.php enabled: - alpha_ordered_traits diff --git a/README.md b/README.md index 2cd7a45..f366b58 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ [![Latest Stable Version](https://poser.pugx.org/yiisoft/classifier/v/stable.png)](https://packagist.org/packages/yiisoft/classifier) [![Total Downloads](https://poser.pugx.org/yiisoft/classifier/downloads.png)](https://packagist.org/packages/yiisoft/classifier) [![Build status](https://github.com/yiisoft/classifier/workflows/build/badge.svg)](https://github.com/yiisoft/classifier/actions?query=workflow%3Abuild) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/yiisoft/classifier/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/classifier/?branch=master) -[![Code Coverage](https://scrutinizer-ci.com/g/yiisoft/classifier/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/classifier/?branch=master) +[![Code Coverage](https://codecov.io/gh/yiisoft/classifier/branch/master/graph/badge.svg)](https://codecov.io/gh/yiisoft/classifier) [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fclassifier%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/classifier/master) [![static analysis](https://github.com/yiisoft/classifier/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/classifier/actions?query=workflow%3A%22static+analysis%22) [![type-coverage](https://shepherd.dev/github/yiisoft/classifier/coverage.svg)](https://shepherd.dev/github/yiisoft/classifier) +[![psalm-level](https://shepherd.dev/github/yiisoft/classifier/level.svg)](https://shepherd.dev/github/yiisoft/classifier) Classifier traverses file system to find classes by a certain criteria. diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..e49b5d8 --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,15 @@ +{ + "symbol-whitelist": [ + "PhpToken", + "T_PAAMAYIM_NEKUDOTAYIM", + "T_NAMESPACE", + "T_STRING", + "T_CLASS", + "T_INTERFACE", + "T_TRAIT", + "T_ENUM", + "T_NS_SEPARATOR", + "T_NEW", + "T_WHITESPACE" + ] +} diff --git a/composer.json b/composer.json index 1dafe1e..af6b052 100644 --- a/composer.json +++ b/composer.json @@ -30,11 +30,12 @@ } ], "require": { - "php": "^8.0", + "php": "^8.1", "symfony/finder": "^5.4|^6.0|^7.1" }, "require-dev": { "maglnet/composer-require-checker": "^4.2", + "phpbench/phpbench": "^1.2", "phpunit/phpunit": "^9.5", "rector/rector": "^1.0.0", "roave/infection-static-analysis-plugin": "^1.16", @@ -51,6 +52,9 @@ "Yiisoft\\Classifier\\Tests\\": "tests" } }, + "suggest": { + "ext-tokenizer": "Need for TokenizerClassifier implementation" + }, "config": { "sort-packages": true, "allow-plugins": { 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/rector.php b/rector.php index 63713ce..531f028 100644 --- a/rector.php +++ b/rector.php @@ -12,6 +12,10 @@ __DIR__ . '/tests', ]); + $rectorConfig->skip([ + __DIR__ . '/tests/Support/wrong_file.php', + ]); + // register a single rule $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); diff --git a/src/AbstractClassifier.php b/src/AbstractClassifier.php new file mode 100644 index 0000000..ce96d1a --- /dev/null +++ b/src/AbstractClassifier.php @@ -0,0 +1,93 @@ + + */ + protected static array $reflectionsCache = []; + + /** + * @var FilterInterface[] + */ + private array $filters = []; + /** + * @var string[] + */ + protected array $directories; + + public function __construct(string $directory, string ...$directories) + { + $this->directories = [$directory, ...array_values($directories)]; + } + + public function withFilter(FilterInterface ...$filter): static + { + $new = clone $this; + array_push($new->filters, ...array_values($filter)); + + return $new; + } + + /** + * @return iterable + */ + public function find(): iterable + { + foreach ($this->getAvailableDeclarations() as $declaration) { + if ($this->skipDeclaration($declaration)) { + continue; + } + yield $declaration; + } + } + + protected function getFiles(): Finder + { + return (new Finder()) + ->in($this->directories) + ->name('*.php') + ->sortByName() + ->files(); + } + + /** + * @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 getAvailableDeclarations(): iterable; +} diff --git a/src/Classifier.php b/src/Classifier.php deleted file mode 100644 index 65f3f07..0000000 --- a/src/Classifier.php +++ /dev/null @@ -1,163 +0,0 @@ -directories = [$directory, ...array_values($directories)]; - } - - /** - * @param string ...$interfaces Interfaces to search for. - * @psalm-param class-string ...$interfaces - */ - public function withInterface(string ...$interfaces): self - { - $new = clone $this; - array_push($new->interfaces, ...array_values($interfaces)); - - return $new; - } - - /** - * @param string $parentClass Parent class to search for. - * @psalm-param class-string $parentClass - */ - public function withParentClass(string $parentClass): self - { - $new = clone $this; - $new->parentClass = $parentClass; - return $new; - } - - /** - * @para string ...$attributes Attributes to search for. - * @psalm-param class-string ...$attributes - */ - public function withAttribute(string ...$attributes): self - { - $new = clone $this; - array_push($new->attributes, ...array_values($attributes)); - - return $new; - } - - /** - * @return string[] Classes found. - * @psalm-return iterable - */ - public function find(): iterable - { - $countInterfaces = count($this->interfaces); - $countAttributes = count($this->attributes); - - if ($countInterfaces === 0 && $countAttributes === 0 && $this->parentClass === null) { - return []; - } - - $this->scanFiles(); - - $classesToFind = get_declared_classes(); - $isWindows = DIRECTORY_SEPARATOR === '\\'; - $directories = $this->directories; - - if ($isWindows) { - /** @var string[] $directories */ - $directories = str_replace('/', '\\', $directories); - } - - foreach ($classesToFind as $className) { - $reflection = new ReflectionClass($className); - - if (!$reflection->isUserDefined()) { - continue; - } - - $matchedDirs = array_filter( - $directories, - static fn($directory) => str_starts_with($reflection->getFileName(), $directory) - ); - - if (count($matchedDirs) === 0) { - continue; - } - - if ($countInterfaces > 0) { - $interfaces = $reflection->getInterfaces(); - $interfaces = array_map(static fn(ReflectionClass $class) => $class->getName(), $interfaces); - - if (count(array_intersect($this->interfaces, $interfaces)) !== $countInterfaces) { - continue; - } - } - - if ($countAttributes > 0) { - $attributes = $reflection->getAttributes(); - $attributes = array_map( - static fn(ReflectionAttribute $attribute) => $attribute->getName(), - $attributes - ); - - if (count(array_intersect($this->attributes, $attributes)) !== $countAttributes) { - continue; - } - } - - if (($this->parentClass !== null) && !is_subclass_of($className, $this->parentClass)) { - continue; - } - - yield $className; - } - } - - /** - * Find all PHP files and require each one so these could be further analyzed via reflection. - * @psalm-suppress UnresolvableInclude - */ - private function scanFiles(): void - { - $files = (new Finder()) - ->in($this->directories) - ->name('*.php') - ->sortByName() - ->files(); - - foreach ($files as $file) { - require_once $file; - } - } -} diff --git a/src/ClassifierInterface.php b/src/ClassifierInterface.php new file mode 100644 index 0000000..e4c487e --- /dev/null +++ b/src/ClassifierInterface.php @@ -0,0 +1,22 @@ + 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/Condition/FilterAnd.php b/src/Filter/Condition/FilterAnd.php new file mode 100644 index 0000000..9f96843 --- /dev/null +++ b/src/Filter/Condition/FilterAnd.php @@ -0,0 +1,38 @@ +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..1b02aa8 --- /dev/null +++ b/src/Filter/Condition/FilterOr.php @@ -0,0 +1,38 @@ +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/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 new file mode 100644 index 0000000..1936769 --- /dev/null +++ b/src/NativeClassifier.php @@ -0,0 +1,59 @@ +getFiles(); + + foreach ($files as $file) { + try { + require_once $file; + } catch (\Throwable) { + // Ignore syntax errors + } + } + + /** @var array $declarations */ + $declarations = [...get_declared_classes(), ...get_declared_interfaces(), ...get_declared_traits()]; + + $directories = $this->directories; + $isWindows = DIRECTORY_SEPARATOR === '\\'; + + if ($isWindows) { + /** + * @psalm-var string[] $directories + */ + // @codeCoverageIgnoreStart + $directories = str_replace('/', '\\', $directories); + // @codeCoverageIgnoreEnd + } + + foreach ($declarations as $declaration) { + $reflectionClass = self::$reflectionsCache[$declaration] ??= new \ReflectionClass($declaration); + + $matchedDirs = array_filter( + $directories, + static fn($directory) => $reflectionClass->getFileName() && str_starts_with($reflectionClass->getFileName(), $directory) + ); + + if (count($matchedDirs) === 0) { + continue; + } + yield $reflectionClass->getName(); + } + } +} diff --git a/src/ReflectionFile.php b/src/ReflectionFile.php new file mode 100644 index 0000000..0d53577 --- /dev/null +++ b/src/ReflectionFile.php @@ -0,0 +1,277 @@ + + */ + 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/ClassifierTest.php b/tests/BaseClassifierTest.php similarity index 56% rename from tests/ClassifierTest.php rename to tests/BaseClassifierTest.php index 3db3823..95d3d57 100644 --- a/tests/ClassifierTest.php +++ b/tests/BaseClassifierTest.php @@ -5,8 +5,13 @@ namespace Yiisoft\Classifier\Tests; use PHPUnit\Framework\TestCase; -use Yiisoft\Classifier\Classifier; +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; @@ -20,13 +25,24 @@ use Yiisoft\Classifier\Tests\Support\User; use Yiisoft\Classifier\Tests\Support\UserSubclass; -final class ClassifierTest extends TestCase +abstract class BaseClassifierTest extends TestCase { - public function testMultipleDirectories() + public function testMultipleUse(): void { $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; - $finder = new Classifier(...$dirs); - $finder = $finder->withInterface(UserInterface::class); + $finder = $this->createClassifier(...$dirs); + $finder = $finder->withFilter(new ClassImplements(UserInterface::class)); + + $result = $finder->find(); + + $this->assertEqualsCanonicalizing(iterator_to_array($finder->find()), iterator_to_array($result)); + } + + public function testMultipleDirectories(): void + { + $dirs = [__DIR__ . '/Support/Dir1', __DIR__ . '/Support/Dir2']; + $finder = $this->createClassifier(...$dirs); + $finder = $finder->withFilter(new ClassImplements(UserInterface::class)); $result = $finder->find(); @@ -38,15 +54,15 @@ public function testMultipleDirectories() */ public function testInterfaces(string $directory, array $interfaces, array $expectedClasses): void { - $finder = new Classifier($directory); - $finder = $finder->withInterface(...$interfaces); + $finder = $this->createClassifier($directory); + $finder = $finder->withFilter(new ClassImplements(...$interfaces)); $result = $finder->find(); $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); } - public function interfacesDataProvider(): array + public static function interfacesDataProvider(): array { return [ [ @@ -67,7 +83,15 @@ public 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__, @@ -92,8 +116,21 @@ public function interfacesDataProvider(): array */ public function testAttributes(array $attributes, array $expectedClasses): void { - $finder = new Classifier(__DIR__); - $finder = $finder->withAttribute(...$attributes); + $finder = $this->createClassifier(__DIR__); + $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(); @@ -105,15 +142,15 @@ public function testAttributes(array $attributes, array $expectedClasses): void */ public function testParentClass(string $parent, array $expectedClasses): void { - $finder = new Classifier(__DIR__); - $finder = $finder->withParentClass($parent); + $finder = $this->createClassifier(__DIR__); + $finder = $finder->withFilter(new SubclassOf($parent)); $result = $finder->find(); $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); } - public function attributesDataProvider(): array + public static function attributesDataProvider(): array { return [ [ @@ -121,7 +158,25 @@ public 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], ], ]; @@ -132,17 +187,16 @@ 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); + ->withFilter(new ClassAttributes(...$attributes), new ClassImplements(...$interfaces)); $result = $finder->find(); $this->assertEqualsCanonicalizing($expectedClasses, iterator_to_array($result)); } - public function mixedDataProvider(): array + public static function mixedDataProvider(): array { return [ [ @@ -158,7 +212,7 @@ public function mixedDataProvider(): array ]; } - public function parentClassDataProvider(): array + public static function parentClassDataProvider(): array { return [ [ @@ -167,4 +221,6 @@ public function parentClassDataProvider(): array ], ]; } + + abstract protected function createClassifier(string ...$dirs): ClassifierInterface; } diff --git a/tests/Benchmark/ClassifierBench.php b/tests/Benchmark/ClassifierBench.php new file mode 100644 index 0000000..a736d30 --- /dev/null +++ b/tests/Benchmark/ClassifierBench.php @@ -0,0 +1,61 @@ + ['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(); + } + + 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/Car.php b/tests/Declarations/Car.php new file mode 100644 index 0000000..8bd34e1 --- /dev/null +++ b/tests/Declarations/Car.php @@ -0,0 +1,11 @@ +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..44cd335 --- /dev/null +++ b/tests/Filter/Condition/FilterAndTest.php @@ -0,0 +1,43 @@ +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..741a883 --- /dev/null +++ b/tests/Filter/Condition/FilterOrTest.php @@ -0,0 +1,41 @@ +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/NativeClassifierTest.php b/tests/NativeClassifierTest.php new file mode 100644 index 0000000..372ffdb --- /dev/null +++ b/tests/NativeClassifierTest.php @@ -0,0 +1,16 @@ +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(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'); + + $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 @@ +