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 @@ +