├── docs ├── .nojekyll ├── _sidebar.md ├── CONTRIBUTING.md ├── iterators.md ├── index.html ├── index.md └── finder.md ├── .github ├── FUNDING.yml └── workflows │ ├── cscheck.yml │ ├── phpstan.yml │ └── test.yml ├── .gitignore ├── data ├── Composer │ ├── Psr4 │ │ ├── test_file.php │ │ ├── FooTrait.php │ │ ├── FooInterface.php │ │ ├── BarBar.php │ │ ├── Foobar.php │ │ ├── Foobarbar.php │ │ ├── SubNs │ │ │ └── FooBaz.php │ │ ├── NotAClass.php │ │ └── AbstractClass.php │ ├── Psr0Fallback │ │ ├── LoggerInterface.php │ │ ├── Logger.php │ │ └── FallbackNamespace0 │ │ │ └── MyClass.php │ ├── Psr4Fallback │ │ ├── Logger4.php │ │ └── FallbackNamespace │ │ │ └── MyClass.php │ └── Psr0 │ │ └── Kcs │ │ └── ClassFinder │ │ └── Fixtures │ │ └── Psr0 │ │ ├── Foobar.php │ │ ├── BarBar.php │ │ └── SubNs │ │ └── FooBaz.php ├── Recursive │ ├── Bar.php │ ├── Foo.php │ ├── class-foo-bar.php │ └── bootstrap.php └── Psr4WithClassMap │ ├── BarBar.php │ ├── Map │ └── ErrorStub.php │ └── NonExistentImplementation.php ├── tests ├── functional │ ├── nested-composer-projects │ │ ├── otherProject │ │ │ └── composer.json │ │ ├── composer.json │ │ └── test.phpt │ ├── fallback-dirs │ │ ├── src │ │ │ └── Foobar.php │ │ ├── composer.json │ │ ├── fall │ │ │ └── Symfony │ │ │ │ └── Component │ │ │ │ └── String │ │ │ │ └── LazyString.php │ │ └── test.phpt │ ├── invalid-class │ │ ├── src │ │ │ ├── Foobar.php │ │ │ └── InvalidClass.php │ │ ├── composer.json │ │ └── test.phpt │ ├── lists-all-classes-in-project.phpt │ └── error-handler │ │ ├── error-handler-user-notice.phpt │ │ ├── error-handler-user-warning.phpt │ │ ├── error-handler-user-error.phpt │ │ └── error-handler-void-previous-handler.phpt ├── issue-13 │ ├── composer.json │ ├── test.phpt │ ├── test-bogon-files-filter.phpt │ └── test-path-callback.phpt ├── unit │ ├── Reflection │ │ └── BetterReflectionReflectorFactoryTest.php │ ├── FileFinder │ │ ├── CachedFileFinderTest.php │ │ └── DefaultFileFinderTest.php │ ├── Iterator │ │ ├── RecursiveIteratorTest.php │ │ ├── Psr0IteratorTest.php │ │ ├── Psr4IteratorTest.php │ │ ├── ClassMapIteratorTest.php │ │ ├── FilteredComposerIteratorTest.php │ │ └── PhpDocumentorIteratorTest.php │ ├── Util │ │ ├── ErrorHandlerTest.php │ │ └── ClassMapTest.php │ └── Finder │ │ ├── Psr0FinderTest.php │ │ ├── Psr4FinderTest.php │ │ └── RecursiveFinderTest.php └── install-deps.php ├── lib ├── Util │ ├── Error.php │ ├── Offline │ │ └── Metadata.php │ ├── PhpDocumentor │ │ └── MetadataRegistry.php │ ├── BogonFilesFilter.php │ ├── ErrorHandler.php │ ├── ClassMap.php │ └── PhpParser │ │ └── AnnotationParser.php ├── Reflection │ ├── NativeReflectorFactory.php │ ├── BetterReflectionReflectorFactory.php │ └── ReflectorFactoryInterface.php ├── Finder │ ├── RecursiveFinderTrait.php │ ├── RecursiveFinder.php │ ├── ClassMapFinder.php │ ├── PhpDocumentorFilterTrait.php │ ├── PhpDocumentorFinder.php │ ├── ReflectionFilterTrait.php │ ├── Psr0Finder.php │ ├── Psr4Finder.php │ ├── PhpParserFinder.php │ ├── FinderInterface.php │ ├── FinderTrait.php │ └── ComposerFinder.php ├── FileFinder │ ├── FileFinderInterface.php │ ├── CachedFileFinder.php │ └── DefaultFileFinder.php ├── Iterator │ ├── RecursiveIteratorTrait.php │ ├── PsrIteratorTrait.php │ ├── ClassMapIterator.php │ ├── RecursiveIterator.php │ ├── OfflineIteratorTrait.php │ ├── Psr0Iterator.php │ ├── ComposerIterator.php │ ├── Psr4Iterator.php │ ├── ClassIterator.php │ └── PhpParserIterator.php ├── FilterIterator │ ├── Reflection │ │ ├── SuperClassFilterIterator.php │ │ ├── AttributeFilterIterator.php │ │ ├── AnnotationFilterIterator.php │ │ ├── NamespaceFilterIterator.php │ │ ├── DirectoryFilterIterator.php │ │ ├── InterfaceImplementationFilterIterator.php │ │ └── PathFilterIterator.php │ ├── PhpParser │ │ ├── AttributeFilterIterator.php │ │ ├── AnnotationFilterIterator.php │ │ ├── NamespaceFilterIterator.php │ │ ├── NotNamespaceFilterIterator.php │ │ ├── SuperClassFilterIterator.php │ │ └── InterfaceImplementationFilterIterator.php │ ├── PhpDocumentor │ │ ├── NamespaceFilterIterator.php │ │ ├── NotNamespaceFilterIterator.php │ │ ├── AttributeFilterIterator.php │ │ ├── AnnotationFilterIterator.php │ │ ├── SuperClassFilterIterator.php │ │ └── InterfaceImplementationFilterIterator.php │ └── MultiplePcreFilterIterator.php └── PathNormalizer.php ├── phpdoc-compat-metadata.php ├── phpbench.json ├── bench ├── ComposerFinderBench.php └── ClassMapFinderBench.php ├── LICENSE ├── phpunit.xml.dist ├── README.md └── composer.json /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [alekitto] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .phpunit.result.cache 3 | .php_cs.cache 4 | composer.lock 5 | -------------------------------------------------------------------------------- /data/Composer/Psr4/test_file.php: -------------------------------------------------------------------------------- 1 | useAutoloading(false); 8 | iterator_to_array($finder); 9 | 10 | echo "OK"; 11 | ?> 12 | --EXPECT-- 13 | OK 14 | -------------------------------------------------------------------------------- /tests/functional/fallback-dirs/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "repositories": [ 3 | { 4 | "type": "vcs", 5 | "url": "../../.." 6 | } 7 | ], 8 | "autoload": { 9 | "Kcs\\ClassFinder\\FunctionalTests\\": "src/" 10 | }, 11 | "require": { 12 | "kcs/class-finder": "dev-master", 13 | "symfony/string": "*" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/functional/error-handler/error-handler-user-notice.phpt: -------------------------------------------------------------------------------- 1 | --TEST-- 2 | ErrorHandler - Test User Notice does not throw Error 3 | --FILE-- 4 | 14 | --EXPECTF-- 15 | Notice: This is a notice in %a 16 | -------------------------------------------------------------------------------- /tests/functional/error-handler/error-handler-user-warning.phpt: -------------------------------------------------------------------------------- 1 | --TEST-- 2 | ErrorHandler - Test User Warning does not throw Error 3 | --FILE-- 4 | 14 | --EXPECTF-- 15 | Warning: This is a warning in %a 16 | -------------------------------------------------------------------------------- /tests/functional/fallback-dirs/fall/Symfony/Component/String/LazyString.php: -------------------------------------------------------------------------------- 1 | skipBogonFiles(); 10 | iterator_to_array($finder); 11 | 12 | echo "OK"; 13 | ?> 14 | --EXPECT-- 15 | OK 16 | -------------------------------------------------------------------------------- /tests/issue-13/test.phpt: -------------------------------------------------------------------------------- 1 | --TEST-- 2 | ComposerFinder - compatibility with symfony/cache: exclude namespace (#13) 3 | --FILE-- 4 | notInNamespace('Symfony\Component\Cache\Traits'); 9 | 10 | $count = 0; 11 | foreach ($finder as $className => $reflector) { 12 | ++$count; 13 | } 14 | 15 | echo "OK" 16 | ?> 17 | --EXPECT-- 18 | OK 19 | -------------------------------------------------------------------------------- /lib/Finder/RecursiveFinderTrait.php: -------------------------------------------------------------------------------- 1 | fileFinder = $fileFinder; 16 | 17 | return $this; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/issue-13/test-bogon-files-filter.phpt: -------------------------------------------------------------------------------- 1 | --TEST-- 2 | ComposerFinder - compatibility with symfony/cache: exclude path (#13) 3 | --FILE-- 4 | skipBogonFiles(); 9 | 10 | $count = 0; 11 | foreach ($finder as $className => $reflector) { 12 | ++$count; 13 | } 14 | 15 | if ($count > 0) { 16 | echo "OK"; 17 | } 18 | ?> 19 | --EXPECT-- 20 | OK 21 | -------------------------------------------------------------------------------- /lib/Reflection/BetterReflectionReflectorFactory.php: -------------------------------------------------------------------------------- 1 | reflector() 15 | ->reflectClass($className); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/functional/error-handler/error-handler-user-error.phpt: -------------------------------------------------------------------------------- 1 | --TEST-- 2 | ErrorHandler - Test User Error does throw Error 3 | --INI-- 4 | error_reporting=22527 5 | --FILE-- 6 | 16 | --EXPECTF-- 17 | Fatal error: Uncaught Kcs\ClassFinder\Util\Error: This is an error in %a 18 | -------------------------------------------------------------------------------- /tests/functional/error-handler/error-handler-void-previous-handler.phpt: -------------------------------------------------------------------------------- 1 | --TEST-- 2 | ErrorHandler - Test User Notice does not panic if previous handler returns void 3 | --FILE-- 4 | 16 | --EXPECTF-- 17 | Notice: This is a notice in %a 18 | -------------------------------------------------------------------------------- /tests/issue-13/test-path-callback.phpt: -------------------------------------------------------------------------------- 1 | --TEST-- 2 | ComposerFinder - compatibility with symfony/cache: exclude path (#13) 3 | --FILE-- 4 | pathFilter(static fn (string $path): bool => !preg_match('#symfony/cache/Traits/Redis(?:Cluster)?\dProxy\.php$#', $path)); 9 | 10 | $count = 0; 11 | foreach ($finder as $className => $reflector) { 12 | ++$count; 13 | } 14 | 15 | echo "OK" 16 | ?> 17 | --EXPECT-- 18 | OK 19 | -------------------------------------------------------------------------------- /lib/FileFinder/FileFinderInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function search(string $pattern): iterable; 20 | } 21 | -------------------------------------------------------------------------------- /lib/Reflection/ReflectorFactoryInterface.php: -------------------------------------------------------------------------------- 1 | skipBogonFiles(); 10 | $arr = iterator_to_array($finder); 11 | 12 | /** @var ReflectionClass $class */ 13 | $class = $arr[\Symfony\Component\String\LazyString::class]; 14 | echo str_replace(DIRECTORY_SEPARATOR, '/', $class->getFileName()); 15 | ?> 16 | --EXPECTF-- 17 | %s/tests/functional/fallback-dirs/vendor/symfony/string/LazyString.php 18 | -------------------------------------------------------------------------------- /lib/Util/Offline/Metadata.php: -------------------------------------------------------------------------------- 1 | fileFinder = $fileFinder; 18 | } 19 | 20 | /** @inheritDoc */ 21 | private function search(): iterable 22 | { 23 | yield from ($this->fileFinder ?? new DefaultFileFinder()) 24 | ->search($this->path . '/*'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/unit/Reflection/BetterReflectionReflectorFactoryTest.php: -------------------------------------------------------------------------------- 1 | reflect(self::class)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /bench/ComposerFinderBench.php: -------------------------------------------------------------------------------- 1 | skipBogonFiles(); 19 | iterator_to_array($finder); 20 | } 21 | 22 | #[Bench\Iterations(10)] 23 | #[Bench\Revs(10)] 24 | public function benchIterateNamespaceFilter(): void 25 | { 26 | $finder = (new ComposerFinder()) 27 | ->inNamespace('Symfony') 28 | ->skipBogonFiles(); 29 | iterator_to_array($finder); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/functional/invalid-class/test.phpt: -------------------------------------------------------------------------------- 1 | --TEST-- 2 | Offline finders - skip invalid files/classes 3 | --INI-- 4 | error_reporting=E_ALL & ~E_NOTICE & ~E_DEPRECATED 5 | --FILE-- 6 | inNamespace('Kcs\ClassFinder\FunctionalTests'); 11 | var_dump(array_keys(iterator_to_array($finder))); 12 | 13 | $finder = (new Kcs\ClassFinder\Finder\PhpParserFinder(__DIR__)) 14 | ->inNamespace('Kcs\ClassFinder\FunctionalTests'); 15 | var_dump(array_keys(iterator_to_array($finder))); 16 | 17 | echo "OK"; 18 | ?> 19 | --EXPECT-- 20 | array(1) { 21 | [0]=> 22 | string(38) "Kcs\ClassFinder\FunctionalTests\Foobar" 23 | } 24 | array(1) { 25 | [0]=> 26 | string(38) "Kcs\ClassFinder\FunctionalTests\Foobar" 27 | } 28 | OK 29 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Every contribution is welcome. Feel free to open PRs or issues on the relevant repository. 4 | Before you open an issue, please check the existing issues to avoid duplication. 5 | 6 | ### You have an idea for a new feature? 7 | 8 | That's great! 9 | Please open an issue to discuss the idea with other community members before make any change. 10 | 11 | ### Ready to make a change? 12 | 13 | Fork the repositories and send us a PR. Please link any relevant issue. 14 | Be sure to write tests for your feature or bugfix and respect code-styling rules. 15 | 16 | ### Issue type 17 | 18 | When opening an issue, write in the title if it is a question (a support request), a bug report, 19 | a discussion (or a request for comment for a new idea) or other. 20 | 21 | ### Code of conduct 22 | 23 | Be respectful and polite with other members of the community. Respect the [code of conduct](./CODE_OF_CONDUCT.md) 24 | -------------------------------------------------------------------------------- /lib/FilterIterator/Reflection/SuperClassFilterIterator.php: -------------------------------------------------------------------------------- 1 | 14 | * @template-extends FilterIterator 15 | */ 16 | final class SuperClassFilterIterator extends FilterIterator 17 | { 18 | /** 19 | * @param T $iterator 20 | * @phpstan-param class-string $superClass 21 | */ 22 | public function __construct(Iterator $iterator, private string $superClass) 23 | { 24 | parent::__construct($iterator); 25 | } 26 | 27 | public function accept(): bool 28 | { 29 | return $this 30 | ->getInnerIterator() 31 | ->current() 32 | ->isSubclassOf($this->superClass); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/cscheck.yml: -------------------------------------------------------------------------------- 1 | name: Check CS 2 | 3 | on: 4 | push: 5 | pull_request_target: 6 | 7 | jobs: 8 | build: 9 | env: 10 | COMPOSER_ROOT_VERSION: dev-master 11 | 12 | runs-on: ubuntu-latest 13 | name: Check code style 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 10 18 | 19 | - name: Install PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | coverage: none 23 | php-version: "8.1" 24 | tools: cs2pr 25 | 26 | - name: Install Composer dependencies 27 | uses: ramsey/composer-install@v2 28 | with: 29 | # Bust the cache at least once a month - output format: YYYY-MM. 30 | custom-cache-suffix: $(date -u "+%Y-%m") 31 | 32 | - name: Run git-phpcs 33 | run: composer cscheck -- --report=checkstyle | cs2pr 34 | -------------------------------------------------------------------------------- /lib/Iterator/PsrIteratorTrait.php: -------------------------------------------------------------------------------- 1 | namespace)) { 27 | return false; 28 | } 29 | 30 | if (! parent::validNamespace($class)) { 31 | return false; 32 | } 33 | 34 | return (bool) preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+$/', $class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/FilterIterator/Reflection/AttributeFilterIterator.php: -------------------------------------------------------------------------------- 1 | 16 | * @template-extends FilterIterator 17 | */ 18 | final class AttributeFilterIterator extends FilterIterator 19 | { 20 | /** 21 | * @param T $iterator 22 | * @phpstan-param class-string $attribute 23 | */ 24 | public function __construct(Iterator $iterator, private string $attribute) 25 | { 26 | parent::__construct($iterator); 27 | } 28 | 29 | public function accept(): bool 30 | { 31 | $reflectionClass = $this->getInnerIterator()->current(); 32 | if ($reflectionClass->isInternal()) { 33 | return false; 34 | } 35 | 36 | return count($reflectionClass->getAttributes($this->attribute)) > 0; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/FilterIterator/Reflection/AnnotationFilterIterator.php: -------------------------------------------------------------------------------- 1 | 15 | * @template-extends FilterIterator 16 | */ 17 | final class AnnotationFilterIterator extends FilterIterator 18 | { 19 | private AnnotationReader $reader; 20 | 21 | /** 22 | * @param T $iterator 23 | * @phpstan-param class-string $annotation 24 | */ 25 | public function __construct(Iterator $iterator, private readonly string $annotation) 26 | { 27 | parent::__construct($iterator); 28 | 29 | $this->reader = new AnnotationReader(); 30 | } 31 | 32 | public function accept(): bool 33 | { 34 | return $this->reader->getClassAnnotation($this->getInnerIterator()->current(), $this->annotation) !== null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/phpstan.yml: -------------------------------------------------------------------------------- 1 | name: PHPStan 2 | 3 | on: 4 | push: 5 | pull_request_target: 6 | 7 | jobs: 8 | build: 9 | env: 10 | COMPOSER_ROOT_VERSION: dev-master 11 | 12 | runs-on: ubuntu-latest 13 | name: Static analysis 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Install PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | coverage: none 21 | php-version: "8.3" 22 | tools: cs2pr 23 | 24 | - name: Install Composer dependencies 25 | uses: ramsey/composer-install@v2 26 | with: 27 | # Bust the cache at least once a month - output format: YYYY-MM. 28 | custom-cache-suffix: $(date -u "+%Y-%m") 29 | 30 | - name: Run a static analysis with phpstan/phpstan 31 | run: php vendor/bin/phpstan analyse lib/ -c vendor/solido/php-coding-standards/phpstan.neon --level=8 --no-progress -vvv --memory-limit=2048M --error-format=checkstyle | cs2pr 32 | -------------------------------------------------------------------------------- /lib/Util/PhpDocumentor/MetadataRegistry.php: -------------------------------------------------------------------------------- 1 | > */ 13 | private WeakMap $map; 14 | 15 | public function __construct() 16 | { 17 | $this->map = new WeakMap(); 18 | } 19 | 20 | public function addMetadata(object $target, Metadata $metadata): void 21 | { 22 | $storage = $this->map[$target] ?? []; 23 | $storage[$metadata->key()] = $metadata; 24 | 25 | $this->map[$target] = $storage; 26 | } 27 | 28 | /** @return array */ 29 | public function getMetadata(object $target): array 30 | { 31 | return $this->map[$target] ?? []; 32 | } 33 | 34 | public static function getInstance(): self 35 | { 36 | static $instance; 37 | if ($instance === null) { 38 | $instance = new self(); 39 | } 40 | 41 | return $instance; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2021 Alessandro Chitolina 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | tests/unit/ 10 | 11 | 12 | tests/functional/error-handler/ 13 | tests/functional/fallback-dirs/test.phpt 14 | tests/functional/invalid-class/test.phpt 15 | tests/functional/nested-composer-projects/test.phpt 16 | tests/issue-13/test.phpt 17 | tests/issue-13/test-bogon-files-filter.phpt 18 | tests/issue-13/test-path-callback.phpt 19 | 20 | 21 | 22 | 23 | lib/ 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /bench/ClassMapFinderBench.php: -------------------------------------------------------------------------------- 1 | skipBogonFiles(); 21 | $this->finder = ClassMap::fromFinder($finder)->createFinder(); 22 | } 23 | 24 | #[Bench\BeforeMethods('setUp')] 25 | #[Bench\Iterations(10)] 26 | #[Bench\Revs(10)] 27 | public function benchIterate(): void 28 | { 29 | $this->finder->skipBogonFiles(); 30 | iterator_to_array($this->finder); 31 | } 32 | 33 | #[Bench\BeforeMethods('setUp')] 34 | #[Bench\Iterations(10)] 35 | #[Bench\Revs(10)] 36 | public function benchIterateNamespaceFilter(): void 37 | { 38 | $this->finder 39 | ->inNamespace('Symfony') 40 | ->skipBogonFiles(); 41 | iterator_to_array($this->finder); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/FilterIterator/Reflection/NamespaceFilterIterator.php: -------------------------------------------------------------------------------- 1 | 16 | * @template-extends FilterIterator 17 | */ 18 | final class NamespaceFilterIterator extends FilterIterator 19 | { 20 | /** 21 | * @param T $iterator 22 | * @param string[] $namespaces 23 | */ 24 | public function __construct(Iterator $iterator, private readonly array $namespaces) 25 | { 26 | parent::__construct($iterator); 27 | } 28 | 29 | public function accept(): bool 30 | { 31 | $reflectionClass = $this->getInnerIterator()->current(); 32 | 33 | foreach ($this->namespaces as $namespace) { 34 | if ($namespace === $reflectionClass->getNamespaceName() || str_starts_with($reflectionClass->getNamespaceName(), $namespace . '\\')) { 35 | return true; 36 | } 37 | } 38 | 39 | return false; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/FileFinder/CachedFileFinder.php: -------------------------------------------------------------------------------- 1 | cacheItemPool->getItem($cacheKey); 25 | if ($item->isHit()) { 26 | $files = $item->get(); 27 | } else { 28 | $files = []; 29 | foreach ($this->innerFinder->search($pattern) as $path => $_) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.NotCamelCaps 30 | $files[] = $path; 31 | } 32 | 33 | $item->set($files); 34 | $this->cacheItemPool->save($item); 35 | } 36 | 37 | foreach ($files as $file) { 38 | yield $file => new SplFileInfo($file); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpParser/AttributeFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 21 | * @phpstan-param class-string $attribute 22 | */ 23 | public function __construct(Iterator $iterator, private readonly string $attribute) 24 | { 25 | parent::__construct($iterator); 26 | } 27 | 28 | public function accept(): bool 29 | { 30 | $reflector = $this->getInnerIterator()->current(); 31 | assert($reflector instanceof Stmt\ClassLike); 32 | 33 | $attrs = []; 34 | foreach ($reflector->attrGroups as $attrGroup) { 35 | array_push($attrs, ...$attrGroup->attrs); 36 | } 37 | 38 | return count(array_filter( 39 | $attrs, 40 | fn (Node\Attribute $attr): bool => $attr->name->toString() === $this->attribute, 41 | )) > 0; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/unit/FileFinder/CachedFileFinderTest.php: -------------------------------------------------------------------------------- 1 | createMock(FileFinderInterface::class); 21 | $inner->expects(self::once()) 22 | ->method('search') 23 | ->willReturn(['/test_file.php' => new SplFileInfo(__FILE__)]); 24 | 25 | $finder = new CachedFileFinder( 26 | $inner, 27 | $cache = new ArrayAdapter(), 28 | ); 29 | 30 | $itr = iterator_to_array($finder->search('/*.php')); 31 | iterator_to_array($finder->search('/*.php')); 32 | iterator_to_array($finder->search('/*.php')); 33 | 34 | $values = $cache->getValues(); 35 | self::assertCount(1, $values); 36 | self::assertEquals(['/test_file.php'], array_keys($itr)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpParser/AnnotationFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 19 | * @phpstan-param class-string $annotation 20 | */ 21 | public function __construct(Iterator $iterator, private readonly string $annotation) 22 | { 23 | parent::__construct($iterator); 24 | } 25 | 26 | public function accept(): bool 27 | { 28 | $reflector = $this->getInnerIterator()->current(); 29 | assert($reflector instanceof Stmt\ClassLike); 30 | 31 | /** @var Node\Name[] $annotations */ 32 | $annotations = $reflector->getAttribute('annotations') ?? []; 33 | foreach ($annotations as $annotation) { 34 | if ( 35 | $annotation->toString() === $this->annotation 36 | || is_subclass_of($annotation->toString(), $this->annotation) 37 | ) { 38 | return true; 39 | } 40 | } 41 | 42 | return false; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/unit/FileFinder/DefaultFileFinderTest.php: -------------------------------------------------------------------------------- 1 | search(__DIR__ . '/../../../data/Recursive/*.php'); 21 | 22 | $files = array_keys(iterator_to_array($itr)); 23 | sort($files); 24 | 25 | self::assertEquals([ 26 | realpath(__DIR__ . '/../../../data/Recursive/Bar.php'), 27 | realpath(__DIR__ . '/../../../data/Recursive/Foo.php'), 28 | realpath(__DIR__ . '/../../../data/Recursive/bootstrap.php'), 29 | realpath(__DIR__ . '/../../../data/Recursive/class-foo-bar.php'), 30 | ], $files); 31 | } 32 | 33 | public function testSearchReturnsEmpty(): void 34 | { 35 | $finder = new DefaultFileFinder(); 36 | $itr = $finder->search(__DIR__ . '/../../../data/Recursive/*.empty'); 37 | 38 | self::assertEquals([], iterator_to_array($itr)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpDocumentor/NamespaceFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 21 | * @param string[] $namespaces 22 | */ 23 | public function __construct(Iterator $iterator, private readonly array $namespaces) 24 | { 25 | parent::__construct($iterator); 26 | } 27 | 28 | public function accept(): bool 29 | { 30 | $reflector = $this->getInnerIterator()->current(); 31 | assert($reflector instanceof Element); 32 | 33 | $fqen = (string) $reflector->getFqsen(); 34 | $index = strrpos($fqen, '\\'); 35 | $classNamespace = ltrim($index !== false ? substr($fqen, 0, $index) : $fqen, '\\'); 36 | 37 | foreach ($this->namespaces as $namespace) { 38 | if ($classNamespace === $namespace || str_starts_with($classNamespace, $namespace . '\\')) { 39 | return true; 40 | } 41 | } 42 | 43 | return false; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/Finder/RecursiveFinder.php: -------------------------------------------------------------------------------- 1 | path = $path; 24 | } 25 | 26 | /** @return Traversable */ 27 | public function getIterator(): Traversable 28 | { 29 | $pathFilterCallback = $this->pathFilterCallback !== null ? ($this->pathFilterCallback)(...) : null; 30 | if ($this->skipBogonClasses) { 31 | $pathFilterCallback = BogonFilesFilter::getFileFilterFn($pathFilterCallback); 32 | } 33 | 34 | $iterator = new RecursiveIterator( 35 | $this->path, 36 | pathCallback: $pathFilterCallback, 37 | ); 38 | 39 | if (isset($this->fileFinder)) { 40 | $iterator->setFileFinder($this->fileFinder); 41 | } 42 | 43 | return $this->applyFilters($iterator); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpDocumentor/NotNamespaceFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 21 | * @param string[] $namespaces 22 | */ 23 | public function __construct(Iterator $iterator, private readonly array $namespaces) 24 | { 25 | parent::__construct($iterator); 26 | } 27 | 28 | public function accept(): bool 29 | { 30 | $reflector = $this->getInnerIterator()->current(); 31 | assert($reflector instanceof Element); 32 | 33 | $fqen = (string) $reflector->getFqsen(); 34 | $index = strrpos($fqen, '\\'); 35 | $classNamespace = ltrim($index !== false ? substr($fqen, 0, $index) : $fqen, '\\'); 36 | 37 | foreach ($this->namespaces as $namespace) { 38 | if ($classNamespace === $namespace || str_starts_with($classNamespace, $namespace . '\\')) { 39 | return false; 40 | } 41 | } 42 | 43 | return true; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpParser/NamespaceFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 21 | * @param string[] $namespaces 22 | */ 23 | public function __construct(Iterator $iterator, private readonly array $namespaces) 24 | { 25 | parent::__construct($iterator); 26 | } 27 | 28 | public function accept(): bool 29 | { 30 | $reflector = $this->getInnerIterator()->current(); 31 | assert($reflector instanceof Stmt\ClassLike); 32 | 33 | $fqen = ltrim((string) $reflector->namespacedName, '\\'); 34 | $index = strrpos($fqen, '\\'); 35 | $classNamespace = ltrim($index !== false ? substr($fqen, 0, $index) : $fqen, '\\'); 36 | 37 | foreach ($this->namespaces as $namespace) { 38 | if ($classNamespace === $namespace || str_starts_with($classNamespace, $namespace . '\\')) { 39 | return true; 40 | } 41 | } 42 | 43 | return false; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpParser/NotNamespaceFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 21 | * @param string[] $namespaces 22 | */ 23 | public function __construct(Iterator $iterator, private readonly array $namespaces) 24 | { 25 | parent::__construct($iterator); 26 | } 27 | 28 | public function accept(): bool 29 | { 30 | $reflector = $this->getInnerIterator()->current(); 31 | assert($reflector instanceof Stmt\ClassLike); 32 | 33 | $fqen = ltrim((string) $reflector->namespacedName, '\\'); 34 | $index = strrpos($fqen, '\\'); 35 | $classNamespace = ltrim($index !== false ? substr($fqen, 0, $index) : $fqen, '\\'); 36 | 37 | foreach ($this->namespaces as $namespace) { 38 | if ($classNamespace === $namespace || str_starts_with($classNamespace, $namespace . '\\')) { 39 | return false; 40 | } 41 | } 42 | 43 | return true; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /docs/iterators.md: -------------------------------------------------------------------------------- 1 | # Iterators 2 | 3 | The finders listed in [finder section](./finder.md?id=finder) use underlying iterator classes to find PHP files and do some *quick and dirty* filtering. 4 | 5 | While iterators should not be used directly (finders should be used instead), they can be used to create new (possibly more specific) finders. 6 | 7 | ## Available iterators 8 | 9 | - `ClassIterator` - the base iterator class for all the iterators; implements all the basic operations. 10 | - `ComposerIterator` - this iterator uses the composer loader and yields *all* the classes in the classmap and in the psr-0/psr-4 prefixes. If composer created an authoritative classmap, the psr prefixes will be not iterated. 11 | - `FilteredComposerIterator` - the same of `ComposerIterator`, but does some *quick* filtering on namespaces and directories. These filters are not precise enough and its results need to be re-processed. 12 | - `Psr0Iterator`/`Psr4Iterator` - explore a psr prefix searching for classes. 13 | - `RecursiveIterator` - uses a `RecursiveIteratorIterator` to search for PHP files 14 | - `PhpDocumentorIterator` - analyzes a directory with php documentor and collects the found classes. 15 | - `PhpParserIterator` - recursively scan a directory files with php-parser and iterates on the found symbols. 16 | - `ClassMapIterator` - accepts a class-map array, including the listed files and collecting the declared classes. 17 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kcs Class Finder - Documentation 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /lib/PathNormalizer.php: -------------------------------------------------------------------------------- 1 | 0) { 45 | array_pop($newPath); 46 | } else { 47 | $newPath[] = $pathPart; 48 | } 49 | } 50 | 51 | return implode(DIRECTORY_SEPARATOR, $newPath); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpParser/SuperClassFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 19 | * @phpstan-param class-string $superClass 20 | */ 21 | public function __construct(Iterator $iterator, private readonly string $superClass) 22 | { 23 | parent::__construct($iterator); 24 | } 25 | 26 | public function accept(): bool 27 | { 28 | $reflector = $this->getInnerIterator()->current(); 29 | if ($reflector instanceof Stmt\Class_ || $reflector instanceof Stmt\Interface_) { 30 | $metadata = $reflector->getAttribute(Metadata::METADATA_KEY); 31 | assert($metadata instanceof Metadata); 32 | 33 | foreach ($metadata->superclasses as $superClass) { 34 | assert($superClass instanceof Stmt\Class_ || $superClass instanceof Stmt\Interface_); 35 | $name = ltrim((string) $superClass->namespacedName, '\\'); 36 | if ($name === $this->superClass) { 37 | return true; 38 | } 39 | } 40 | } 41 | 42 | return false; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/install-deps.php: -------------------------------------------------------------------------------- 1 | $file->getBasename()[0] !== '.', 14 | ), 15 | RecursiveIteratorIterator::LEAVES_ONLY | RecursiveIteratorIterator::CHILD_FIRST, 16 | ); 17 | 18 | foreach ($files as $filepath => $info) { 19 | if (! $info->isFile()) { 20 | continue; 21 | } 22 | 23 | if ($info->getFilename() !== 'composer.json') { 24 | continue; 25 | } 26 | 27 | if (str_contains(dirname(str_replace(DIRECTORY_SEPARATOR, '/', $info->getPath())), '/vendor/')) { 28 | continue; 29 | } 30 | 31 | echo 'Processing ' . $info->getPath() . "...\n"; 32 | 33 | $descriptorspec = [STDIN, STDOUT, STDERR]; 34 | $proc = proc_open(trim('composer install ' . getenv('COMPOSER_FLAGS') ?: ''), $descriptorspec, $pipes, $info->getPath()); 35 | for ($running = true; $running;) { 36 | $status = proc_get_status($proc); 37 | $running = $status['running']; 38 | $exitcode = $status['exitcode']; 39 | } 40 | 41 | if ($exitcode !== 0) { 42 | return; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/FileFinder/DefaultFileFinder.php: -------------------------------------------------------------------------------- 1 | $file->getBasename()[0] !== '.', 29 | ), 30 | RecursiveIteratorIterator::LEAVES_ONLY, 31 | ); 32 | 33 | foreach ($files as $filepath => $info) { 34 | if (! $info->isFile()) { 35 | continue; 36 | } 37 | 38 | yield PathNormalizer::resolvePath($filepath) => $info; 39 | } 40 | } elseif (is_file($path)) { 41 | yield PathNormalizer::resolvePath($path) => new SplFileInfo($path); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/unit/Iterator/RecursiveIteratorTest.php: -------------------------------------------------------------------------------- 1 | new ReflectionClass(Bar::class), 26 | FooBar::class => new ReflectionClass(FooBar::class), 27 | Foo::class => new ReflectionClass(Foo::class), 28 | ], iterator_to_array($iterator)); 29 | } 30 | 31 | public function testRecursiveIteratorShouldSkipFilesThatDoNotMatchFilter(): void 32 | { 33 | $iterator = new RecursiveIterator( 34 | __DIR__ . '/../../../data/Recursive', 35 | 0, 36 | null, 37 | static function (string $path): bool { 38 | return str_starts_with(basename($path), 'class-'); 39 | }, 40 | ); 41 | 42 | self::assertEquals([ 43 | FooBar::class => new ReflectionClass(FooBar::class), 44 | ], iterator_to_array($iterator)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpDocumentor/AttributeFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 26 | * @phpstan-param class-string $attribute 27 | */ 28 | public function __construct(Iterator $iterator, private readonly string $attribute) 29 | { 30 | parent::__construct($iterator); 31 | } 32 | 33 | public function accept(): bool 34 | { 35 | $reflector = $this->getInnerIterator()->current(); 36 | assert($reflector instanceof Class_ || $reflector instanceof Interface_ || $reflector instanceof Trait_); 37 | 38 | if (! method_exists($reflector, 'getAttributes')) { 39 | throw new RuntimeException('Attributes support is not implemented in phpdocumentor'); 40 | } 41 | 42 | return count(array_filter( 43 | $reflector->getAttributes(), 44 | fn (Attribute $attr): bool => ltrim((string) $attr->getFqsen(), '\\') === $this->attribute, 45 | )) > 0; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/FilterIterator/Reflection/DirectoryFilterIterator.php: -------------------------------------------------------------------------------- 1 | 19 | * @template-extends FilterIterator 20 | */ 21 | final class DirectoryFilterIterator extends FilterIterator 22 | { 23 | /** @var string[] */ 24 | private array $dirs; 25 | 26 | /** 27 | * @param T $iterator 28 | * @param string[] $dirs 29 | */ 30 | public function __construct(Iterator $iterator, array $dirs) 31 | { 32 | parent::__construct($iterator); 33 | 34 | $this->dirs = (static function (string ...$dirs) { 35 | return array_map(PathNormalizer::class . '::resolvePath', $dirs); 36 | })(...$dirs); 37 | } 38 | 39 | public function accept(): bool 40 | { 41 | $reflectionClass = $this->getInnerIterator()->current(); 42 | if ($reflectionClass->isInternal()) { 43 | return false; 44 | } 45 | 46 | foreach ($this->dirs as $dir) { 47 | $filename = $reflectionClass->getFileName(); 48 | if (is_string($filename) && str_starts_with($filename, $dir)) { 49 | return true; 50 | } 51 | } 52 | 53 | return false; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpDocumentor/AnnotationFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 20 | * @phpstan-param class-string $annotation 21 | */ 22 | public function __construct(Iterator $iterator, private readonly string $annotation) 23 | { 24 | parent::__construct($iterator); 25 | } 26 | 27 | public function accept(): bool 28 | { 29 | $reflector = $this->getInnerIterator()->current(); 30 | assert($reflector instanceof Class_ || $reflector instanceof Interface_ || $reflector instanceof Trait_); 31 | 32 | $docblock = $reflector->getDocBlock(); 33 | 34 | if ($docblock !== null) { 35 | if ($docblock->hasTag($this->annotation)) { 36 | return true; 37 | } 38 | 39 | $context = $docblock->getContext(); 40 | if ($context === null) { 41 | return true; 42 | } 43 | 44 | foreach ($context->getNamespaceAliases() as $alias => $name) { 45 | if ($name === $this->annotation && $docblock->hasTag($alias)) { 46 | return true; 47 | } 48 | } 49 | } 50 | 51 | return true; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/Util/BogonFilesFilter.php: -------------------------------------------------------------------------------- 1 | true; 36 | 37 | return static function (string $path) use ($filter): bool { 38 | if (preg_match(self::BOGON_FILES_REGEX, $path) === 1) { 39 | return false; 40 | } 41 | 42 | return $filter($path); 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/FilterIterator/Reflection/InterfaceImplementationFilterIterator.php: -------------------------------------------------------------------------------- 1 | 20 | * @template-extends FilterIterator 21 | */ 22 | final class InterfaceImplementationFilterIterator extends FilterIterator 23 | { 24 | /** 25 | * @param T $iterator 26 | * @param string[] $interfaces 27 | * @phpstan-param class-string[] $interfaces 28 | */ 29 | public function __construct(Iterator $iterator, private readonly array $interfaces) 30 | { 31 | parent::__construct($iterator); 32 | } 33 | 34 | public function accept(): bool 35 | { 36 | $reflectionClass = $this->getInnerIterator()->current(); 37 | assert($reflectionClass instanceof ReflectionClass); 38 | if ( 39 | $reflectionClass->isInterface() || 40 | $reflectionClass->isTrait() || 41 | (method_exists($reflectionClass, 'isEnum') && $reflectionClass->isEnum()) 42 | ) { 43 | return false; 44 | } 45 | 46 | return count( 47 | array_filter( 48 | array_map( 49 | static fn (string $interface) => $reflectionClass->implementsInterface($interface), 50 | $this->interfaces, 51 | ), 52 | static fn (bool $r) => $r === false, 53 | ), 54 | ) === 0; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/Finder/ClassMapFinder.php: -------------------------------------------------------------------------------- 1 | $classMap */ 24 | public function __construct(private readonly array $classMap) 25 | { 26 | } 27 | 28 | public function setReflectorFactory(ReflectorFactoryInterface|null $reflectorFactory): self 29 | { 30 | $this->reflectorFactory = $reflectorFactory; 31 | 32 | return $this; 33 | } 34 | 35 | /** @return Iterator */ 36 | public function getIterator(): Iterator 37 | { 38 | $flags = 0; 39 | if ($this->skipNonInstantiable) { 40 | $flags |= ClassIterator::SKIP_NON_INSTANTIABLE; 41 | } 42 | 43 | $pathFilterCallback = $this->pathFilterCallback !== null ? ($this->pathFilterCallback)(...) : null; 44 | if ($this->skipBogonClasses) { 45 | $pathFilterCallback = BogonFilesFilter::getFileFilterFn($pathFilterCallback); 46 | } 47 | 48 | $iterator = new ClassMapIterator( 49 | $this->classMap, 50 | $this->reflectorFactory, 51 | $flags, 52 | $this->notNamespaces, 53 | pathCallback: $pathFilterCallback, 54 | ); 55 | 56 | return $this->applyFilters($iterator); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpParser/InterfaceImplementationFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 23 | * @param string[] $interfaces 24 | * @phpstan-param class-string[] $interfaces 25 | */ 26 | public function __construct(Iterator $iterator, private readonly array $interfaces) 27 | { 28 | parent::__construct($iterator); 29 | } 30 | 31 | public function accept(): bool 32 | { 33 | $reflector = $this->getInnerIterator()->current(); 34 | if (! $reflector instanceof Stmt\Class_) { 35 | return false; 36 | } 37 | 38 | $metadata = $reflector->getAttribute(Metadata::METADATA_KEY); 39 | assert($metadata instanceof Metadata); 40 | 41 | $implementations = array_map( 42 | static fn (Stmt\Interface_ $i) => ltrim((string) $i->namespacedName, '\\'), 43 | array_filter($metadata->superclasses, static fn (object $o) => $o instanceof Stmt\Interface_), 44 | ); 45 | 46 | return count( 47 | array_filter( 48 | array_map( 49 | static fn (string $interface) => in_array($interface, $implementations, true), 50 | $this->interfaces, 51 | ), 52 | static fn (bool $r) => $r === false, 53 | ), 54 | ) === 0; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/unit/Iterator/Psr0IteratorTest.php: -------------------------------------------------------------------------------- 1 | new ReflectionClass(Psr0\BarBar::class), 29 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 30 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 31 | ], iterator_to_array($iterator)); 32 | } 33 | 34 | public function testIteratorShouldCallPathCallback(): void 35 | { 36 | $iterator = new Psr0Iterator( 37 | 'Kcs\\ClassFinder\\Fixtures\\Psr0\\', 38 | realpath(__DIR__ . '/../../../data/Composer/Psr0'), 39 | new NativeReflectorFactory(), 40 | pathCallback: static function (string $path): bool { 41 | return ! str_ends_with($path, 'BarBar.php'); 42 | }, 43 | ); 44 | 45 | self::assertEquals([ 46 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 47 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 48 | ], iterator_to_array($iterator)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/Finder/PhpDocumentorFilterTrait.php: -------------------------------------------------------------------------------- 1 | $iterator 20 | * 21 | * @return Iterator 22 | */ 23 | private function applyFilters(Iterator $iterator): Iterator 24 | { 25 | if ($this->namespaces) { 26 | $iterator = new Filters\NamespaceFilterIterator($iterator, $this->namespaces); 27 | } 28 | 29 | if ($this->notNamespaces) { 30 | $iterator = new Filters\NotNamespaceFilterIterator($iterator, $this->notNamespaces); 31 | } 32 | 33 | if ($this->implements) { 34 | $iterator = new Filters\InterfaceImplementationFilterIterator($iterator, $this->implements); 35 | } 36 | 37 | if ($this->extends) { 38 | $iterator = new Filters\SuperClassFilterIterator($iterator, $this->extends); 39 | } 40 | 41 | if ($this->annotation) { 42 | $iterator = new Filters\AnnotationFilterIterator($iterator, $this->annotation); 43 | } 44 | 45 | if ($this->attribute) { 46 | $iterator = new Filters\AttributeFilterIterator($iterator, $this->attribute); 47 | } 48 | 49 | if ($this->filterCallback !== null) { 50 | $iterator = new CallbackFilterIterator($iterator, function ($current, $key) { 51 | assert($this->filterCallback !== null); 52 | 53 | return (bool) ($this->filterCallback)($current, $key); 54 | }); 55 | } 56 | 57 | return $iterator; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Class Finder 2 | __Discover classes in your PHP project__ 3 | 4 | ## Introduction 5 | 6 | Class Finder provides helpers and utilities to find, filter and enumerate classes, interfaces and traits 7 | in your PHP projects analyzing files statically or at runtime. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | $ composer require kcs/class-finder 13 | ``` 14 | 15 | ## Usage 16 | 17 | A `Finder` is an object implementing `FinderInterface` which is iterable and exposes some convenient methods 18 | to filter, exclude or restrict the search of the classes. 19 | 20 | If iterated, the finder will yield a key/value tuple where the key is the fully-qualified class name 21 | as string, and the value is a reflector object (could be a runtime `Reflector` or any other type of reflector object). 22 | 23 | #### Examples 24 | 25 | ##### Finds all the classes into "src" folder 26 | 27 | ```php 28 | use Kcs\ClassFinder\Finder\ComposerFinder; 29 | 30 | $finder = new ComposerFinder(); 31 | $finder->path(__DIR__ . '/src'); 32 | 33 | foreach ($finder as $className => $reflector) { 34 | // Do magic things. 35 | } 36 | ``` 37 | 38 | ##### Finds all the classes implementing HttpClientInterface 39 | 40 | ```php 41 | use Kcs\ClassFinder\Finder\ComposerFinder; 42 | use Psr\Http\Client\HttpClientInterface; 43 | 44 | $finder = new ComposerFinder(); 45 | $finder->implementationOf(HttpClientInterface::class); 46 | 47 | foreach ($finder as $className => $reflector) { 48 | // All the yielded reflectors are referred to classes implementing of http client. 49 | } 50 | ``` 51 | 52 | See [finder section](./finder.md) for more information 53 | 54 | ## License 55 | 56 | The library is released under the business-friendly MIT license. 57 | This documentation is released under CC0 license. 58 | 59 | ## Contributing 60 | 61 | Contributions are always welcome. 62 | Feel free to open a PR or file an issue. 63 | -------------------------------------------------------------------------------- /lib/Finder/PhpDocumentorFinder.php: -------------------------------------------------------------------------------- 1 | */ 32 | public function getIterator(): Iterator 33 | { 34 | $flags = 0; 35 | if ($this->skipNonInstantiable) { 36 | $flags |= ClassIterator::SKIP_NON_INSTANTIABLE; 37 | } 38 | 39 | $pathCallback = $this->pathFilterCallback !== null ? ($this->pathFilterCallback)(...) : null; 40 | $iterator = new PhpDocumentorIterator($this->dir, $flags, $this->notNamespaces, $pathCallback); 41 | if (isset($this->fileFinder)) { 42 | $iterator->setFileFinder($this->fileFinder); 43 | } 44 | 45 | if ($this->dirs !== null) { 46 | $iterator->in($this->dirs); 47 | } 48 | 49 | if ($this->paths !== null) { 50 | $iterator->path($this->paths); 51 | } 52 | 53 | if ($this->notPaths !== null) { 54 | $iterator->notPath($this->notPaths); 55 | } 56 | 57 | return $this->applyFilters($iterator); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/FilterIterator/Reflection/PathFilterIterator.php: -------------------------------------------------------------------------------- 1 | 19 | * @template-extends MultiplePcreFilterIterator 20 | */ 21 | final class PathFilterIterator extends MultiplePcreFilterIterator 22 | { 23 | /** 24 | * Filters the iterator values. 25 | * 26 | * @return bool true if the value should be kept, false otherwise 27 | */ 28 | public function accept(): bool 29 | { 30 | $reflector = $this->getInnerIterator()->current(); 31 | if ($reflector->isInternal()) { 32 | return false; 33 | } 34 | 35 | $filename = $reflector->getFileName(); 36 | if ($filename === false) { 37 | return false; 38 | } 39 | 40 | if (DIRECTORY_SEPARATOR === '\\') { 41 | $filename = str_replace('\\', '/', $filename); 42 | } 43 | 44 | return $this->isAccepted($filename); 45 | } 46 | 47 | /** 48 | * Converts strings to regexp. 49 | * 50 | * PCRE patterns are left unchanged. 51 | * 52 | * Default conversion: 53 | * 'lorem/ipsum/dolor' ==> 'lorem\/ipsum\/dolor/' 54 | * 55 | * Use only / as directory separator (on Windows also). 56 | * 57 | * @param string $str Pattern: regexp or dirname 58 | * 59 | * @return string regexp corresponding to a given string or regexp 60 | */ 61 | public static function toRegex(string $str): string 62 | { 63 | return self::isRegex($str) ? $str : '/' . preg_quote($str, '/') . '/'; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/Finder/ReflectionFilterTrait.php: -------------------------------------------------------------------------------- 1 | 22 | * 23 | * @template T of Iterator 24 | */ 25 | private function applyFilters(Iterator $iterator): Iterator 26 | { 27 | if ($this->namespaces) { 28 | $iterator = new Filters\NamespaceFilterIterator($iterator, $this->namespaces); 29 | } 30 | 31 | if ($this->dirs) { 32 | $iterator = new Filters\DirectoryFilterIterator($iterator, $this->dirs); 33 | } 34 | 35 | if ($this->implements) { 36 | $iterator = new Filters\InterfaceImplementationFilterIterator($iterator, $this->implements); 37 | } 38 | 39 | if ($this->extends) { 40 | $iterator = new Filters\SuperClassFilterIterator($iterator, $this->extends); 41 | } 42 | 43 | if ($this->annotation) { 44 | $iterator = new Filters\AnnotationFilterIterator($iterator, $this->annotation); 45 | } 46 | 47 | if ($this->attribute) { 48 | $iterator = new Filters\AttributeFilterIterator($iterator, $this->attribute); 49 | } 50 | 51 | if ($this->filterCallback !== null) { 52 | $iterator = new CallbackFilterIterator($iterator, function ($current, $key) { 53 | assert($this->filterCallback !== null); 54 | 55 | return (bool) ($this->filterCallback)($current, $key); 56 | }); 57 | } 58 | 59 | if ($this->paths || $this->notPaths) { 60 | $iterator = new Filters\PathFilterIterator($iterator, $this->paths ?? [], $this->notPaths ?? []); 61 | } 62 | 63 | return $iterator; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpDocumentor/SuperClassFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 28 | * @phpstan-param class-string $superClass 29 | */ 30 | public function __construct(Iterator $iterator, private readonly string $superClass) 31 | { 32 | parent::__construct($iterator); 33 | 34 | $this->getMetadata = method_exists(Class_::class, 'getMetadata') ? 35 | static fn (object $reflector) => $reflector->getMetadata() : /** @phpstan-ignore-line */ 36 | static fn (object $reflector) => MetadataRegistry::getInstance()->getMetadata($reflector); 37 | } 38 | 39 | public function accept(): bool 40 | { 41 | $reflector = $this->getInnerIterator()->current(); 42 | if ($reflector instanceof Class_ || $reflector instanceof Interface_) { 43 | $metadataSet = array_filter(($this->getMetadata)($reflector), static fn (object $o) => $o instanceof Metadata); 44 | $metadata = reset($metadataSet); 45 | assert($metadata instanceof Metadata); 46 | 47 | foreach ($metadata->superclasses as $superClass) { 48 | assert($superClass instanceof Class_ || $superClass instanceof Interface_); 49 | $name = ltrim((string) $superClass->getFqsen(), '\\'); 50 | if ($name === $this->superClass) { 51 | return true; 52 | } 53 | } 54 | } 55 | 56 | return false; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ClassFinder 2 | 3 | Utility classes to help discover other classes/namespaces 4 | 5 | ![Tests](https://github.com/alekitto/class-finder/workflows/Tests/badge.svg) 6 | [![codecov](https://codecov.io/gh/alekitto/class-finder/branch/master/graph/badge.svg)](https://codecov.io/gh/alekitto/class-finder) 7 | 8 | --- 9 | 10 | ## Installation 11 | 12 | ```bash 13 | $ composer require kcs/class-finder 14 | ``` 15 | 16 | ## Usage 17 | 18 | Finder helps you to discover classes into your project. 19 | 20 | The most common way to discover is to use the provided `ComposerFinder`. 21 | This will search for classes using the auto-generated class loader 22 | from composer and resolving PSR-* namespaces accordingly. 23 | 24 | Read more in the docs at [https://alekitto.github.io/class-finder/](https://alekitto.github.io/class-finder/) 25 | 26 | ### Basic usage 27 | 28 | ```php 29 | use Kcs\ClassFinder\Finder\ComposerFinder; 30 | 31 | $finder = new ComposerFinder(); 32 | foreach ($finder as $className => $reflector) { 33 | // Do magic things... 34 | } 35 | ``` 36 | 37 | ### Filtering 38 | 39 | You can filter classes using the methods exposed by `FinderInterface`: 40 | 41 | - `implementationOf(array $interfaces)`: Finds the classes that implements 42 | all the given interfaces. You can pass a single interface as string. 43 | - `subclassOf(string $superClass)`: Finds all the classes that are subclasses 44 | of the given class. 45 | - `annontatedBy(string $annotationClass)`: Finds all the classes that have 46 | the given annotation in the class docblock. 47 | - `withAttribute(string $attributeClass)`: Finds all the classes that have 48 | the given attribute applied on the class (PHP >= 8.0) only. 49 | - `in(array $dirs)`: Searches only in given directories. 50 | - `inNamespace(array $namespaces)`: Searches only in given namespaces. 51 | - `filter(callable $callback)`: Custom filtering callback. 52 | - `pathFilter(callable $callback)`: Custom filtering callback for loading files. 53 | 54 | 55 | ## License 56 | 57 | This library is released under the MIT license. 58 | 59 | ## Contributions 60 | 61 | Contributions are always welcome. 62 | Please feel free to open a PR or file an issue. 63 | 64 | --- 65 | 66 | Thank you for reading 67 | A. 68 | -------------------------------------------------------------------------------- /lib/Iterator/ClassMapIterator.php: -------------------------------------------------------------------------------- 1 | $classMap */ 23 | public function __construct( 24 | private readonly array $classMap, 25 | ReflectorFactoryInterface|null $reflectorFactory, 26 | int $flags = 0, 27 | array|null $excludeNamespaces = null, 28 | Closure|null $pathCallback = null, 29 | ) { 30 | parent::__construct($flags, $excludeNamespaces, $pathCallback); 31 | 32 | $this->reflectorFactory = $reflectorFactory ?? new NativeReflectorFactory(); 33 | } 34 | 35 | /** @return Generator */ 36 | protected function getGenerator(): Generator 37 | { 38 | $include = Closure::bind( 39 | static function (string $path): void { 40 | include_once $path; 41 | }, 42 | null, 43 | null, 44 | ); 45 | 46 | foreach ($this->classMap as $className => $path) { 47 | $path = PathNormalizer::resolvePath($path); 48 | if ($this->pathCallback && ! ($this->pathCallback)(PathNormalizer::normalize($path))) { 49 | continue; 50 | } 51 | 52 | ErrorHandler::register(); 53 | try { 54 | @$include($path); 55 | $reflectionClass = $this->reflectorFactory->reflect($className); 56 | } catch (Throwable) { /** @phpstan-ignore-line */ 57 | continue; 58 | } finally { 59 | ErrorHandler::unregister(); 60 | } 61 | 62 | assert($reflectionClass instanceof ReflectionClass); 63 | 64 | yield $className => $reflectionClass; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/Iterator/RecursiveIterator.php: -------------------------------------------------------------------------------- 1 | path = PathNormalizer::resolvePath($path); 27 | 28 | parent::__construct($flags, $excludeNamespaces, $pathCallback); 29 | } 30 | 31 | /** @return Generator */ 32 | protected function getGenerator(): Generator 33 | { 34 | $includedFiles = []; 35 | $pattern = defined('HHVM_VERSION') ? '/\\.(php|hh)$/i' : '/\\.php$/i'; 36 | 37 | foreach ($this->search() as $path => $info) { 38 | $path = PathNormalizer::resolvePath($path); 39 | if (! preg_match($pattern, $path, $m) || ! $info->isReadable()) { 40 | continue; 41 | } 42 | 43 | if ($this->pathCallback && ! ($this->pathCallback)(PathNormalizer::normalize($path))) { 44 | continue; 45 | } 46 | 47 | require_once $path; 48 | $includedFiles[] = $path; 49 | } 50 | 51 | foreach ($this->getDeclaredClasses() as $className) { 52 | if (! $this->validNamespace($className)) { 53 | continue; 54 | } 55 | 56 | $reflClass = new ReflectionClass($className); 57 | if (! in_array($reflClass->getFileName(), $includedFiles, true)) { 58 | continue; 59 | } 60 | 61 | yield $className => $reflClass; 62 | } 63 | } 64 | 65 | private function getDeclaredClasses(): Generator 66 | { 67 | yield from get_declared_classes(); 68 | yield from get_declared_interfaces(); 69 | yield from get_declared_traits(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/Finder/Psr0Finder.php: -------------------------------------------------------------------------------- 1 | namespace = $namespace; 43 | $this->path = PathNormalizer::resolvePath($path); 44 | } 45 | 46 | public function setReflectorFactory(ReflectorFactoryInterface|null $reflectorFactory): self 47 | { 48 | $this->reflectorFactory = $reflectorFactory; 49 | 50 | return $this; 51 | } 52 | 53 | /** @return Iterator */ 54 | public function getIterator(): Iterator 55 | { 56 | $flags = 0; 57 | if ($this->skipNonInstantiable) { 58 | $flags |= ClassIterator::SKIP_NON_INSTANTIABLE; 59 | } 60 | 61 | $pathFilterCallback = $this->pathFilterCallback !== null ? ($this->pathFilterCallback)(...) : null; 62 | if ($this->skipBogonClasses) { 63 | $pathFilterCallback = BogonFilesFilter::getFileFilterFn($pathFilterCallback); 64 | } 65 | 66 | $iterator = new Psr0Iterator( 67 | $this->namespace, 68 | $this->path, 69 | $this->reflectorFactory, 70 | $flags, 71 | pathCallback: $pathFilterCallback, 72 | ); 73 | 74 | if (isset($this->fileFinder)) { 75 | $iterator->setFileFinder($this->fileFinder); 76 | } 77 | 78 | return $this->applyFilters($iterator); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/Finder/Psr4Finder.php: -------------------------------------------------------------------------------- 1 | namespace = $namespace; 44 | $this->path = $path; 45 | } 46 | 47 | public function setReflectorFactory(ReflectorFactoryInterface|null $reflectorFactory): self 48 | { 49 | $this->reflectorFactory = $reflectorFactory; 50 | 51 | return $this; 52 | } 53 | 54 | /** @return Iterator */ 55 | public function getIterator(): Iterator 56 | { 57 | $flags = 0; 58 | if ($this->skipNonInstantiable) { 59 | $flags |= ClassIterator::SKIP_NON_INSTANTIABLE; 60 | } 61 | 62 | $pathFilterCallback = $this->pathFilterCallback !== null ? ($this->pathFilterCallback)(...) : null; 63 | if ($this->skipBogonClasses) { 64 | $pathFilterCallback = BogonFilesFilter::getFileFilterFn($pathFilterCallback); 65 | } 66 | 67 | $iterator = new Psr4Iterator( 68 | $this->namespace, 69 | $this->path, 70 | $this->reflectorFactory, 71 | $flags, 72 | pathCallback: $pathFilterCallback, 73 | ); 74 | 75 | if (isset($this->fileFinder)) { 76 | $iterator->setFileFinder($this->fileFinder); 77 | } 78 | 79 | return $this->applyFilters($iterator); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/FilterIterator/PhpDocumentor/InterfaceImplementationFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 31 | * @param string[] $interfaces 32 | * @phpstan-param class-string[] $interfaces 33 | */ 34 | public function __construct(Iterator $iterator, private readonly array $interfaces) 35 | { 36 | parent::__construct($iterator); 37 | 38 | $this->getMetadata = method_exists(Class_::class, 'getMetadata') ? 39 | static fn (object $reflector) => $reflector->getMetadata() : /** @phpstan-ignore-line */ 40 | static fn (object $reflector) => MetadataRegistry::getInstance()->getMetadata($reflector); 41 | } 42 | 43 | public function accept(): bool 44 | { 45 | $reflector = $this->getInnerIterator()->current(); 46 | if (! $reflector instanceof Class_) { 47 | return false; 48 | } 49 | 50 | $metadataSet = array_filter(($this->getMetadata)($reflector), static fn (object $o) => $o instanceof Metadata); 51 | $metadata = reset($metadataSet); 52 | assert($metadata instanceof Metadata); 53 | 54 | $implementations = array_map( 55 | static fn (Interface_ $i) => ltrim((string) $i->getFqsen(), '\\'), 56 | array_filter($metadata->superclasses, static fn (object $o) => $o instanceof Interface_), 57 | ); 58 | 59 | return count( 60 | array_filter( 61 | array_map( 62 | static fn (string $interface) => in_array($interface, $implementations, true), 63 | $this->interfaces, 64 | ), 65 | static fn (bool $r) => $r === false, 66 | ), 67 | ) === 0; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/unit/Iterator/Psr4IteratorTest.php: -------------------------------------------------------------------------------- 1 | new ReflectionClass(Psr4\BarBar::class), 29 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 30 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 31 | Psr4\FooInterface::class => new ReflectionClass(Psr4\FooInterface::class), 32 | Psr4\FooTrait::class => new ReflectionClass(Psr4\FooTrait::class), 33 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 34 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 35 | ], iterator_to_array($iterator)); 36 | } 37 | 38 | public function testIteratorShouldCallPathCallback(): void 39 | { 40 | $iterator = new Psr4Iterator( 41 | 'Kcs\\ClassFinder\\Fixtures\\Psr4\\', 42 | realpath(__DIR__ . '/../../../data/Composer/Psr4'), 43 | new NativeReflectorFactory(), 44 | pathCallback: static function (string $path): bool { 45 | return ! str_ends_with($path, 'BarBar.php'); 46 | }, 47 | ); 48 | 49 | self::assertEquals([ 50 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 51 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 52 | Psr4\FooInterface::class => new ReflectionClass(Psr4\FooInterface::class), 53 | Psr4\FooTrait::class => new ReflectionClass(Psr4\FooTrait::class), 54 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 55 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 56 | ], iterator_to_array($iterator)); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/unit/Iterator/ClassMapIteratorTest.php: -------------------------------------------------------------------------------- 1 | realpath(__DIR__ . '/../../../data/Composer/Psr0') . '/Kcs/ClassFinder/Fixtures/Psr0/BarBar.php', 25 | 'Kcs\ClassFinder\Fixtures\Psr0\Foobar' => realpath(__DIR__ . '/../../../data/Composer/Psr0') . '/Kcs/ClassFinder/Fixtures/Psr0/Foobar.php', 26 | 'Kcs\ClassFinder\Fixtures\Psr4\Foobar' => realpath(__DIR__ . '/../../../data/Composer/Psr4') . '/Foobar.php', 27 | ], 28 | new NativeReflectorFactory(), 29 | ); 30 | 31 | self::assertEquals([ 32 | Psr0\BarBar::class => new ReflectionClass(Psr0\BarBar::class), 33 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 34 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 35 | ], iterator_to_array($iterator)); 36 | } 37 | 38 | public function testIteratorShouldCallPathCallback(): void 39 | { 40 | $iterator = new ClassMapIterator( 41 | [ 42 | 'Kcs\ClassFinder\Fixtures\Psr0\BarBar' => realpath(__DIR__ . '/../../../data/Composer/Psr0') . '/Kcs/ClassFinder/Fixtures/Psr0/BarBar.php', 43 | 'Kcs\ClassFinder\Fixtures\Psr0\Foobar' => realpath(__DIR__ . '/../../../data/Composer/Psr0') . '/Kcs/ClassFinder/Fixtures/Psr0/Foobar.php', 44 | 'Kcs\ClassFinder\Fixtures\Psr4\Foobar' => realpath(__DIR__ . '/../../../data/Composer/Psr4') . '/Foobar.php', 45 | ], 46 | new NativeReflectorFactory(), 47 | pathCallback: static function (string $path): bool { 48 | return ! str_ends_with($path, 'BarBar.php'); 49 | }, 50 | ); 51 | 52 | self::assertEquals([ 53 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 54 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 55 | ], iterator_to_array($iterator)); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kcs/class-finder", 3 | "description": "Utility classes to help discover other classes/namespaces", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Alessandro Chitolina", 9 | "email": "alekitto@gmail.com" 10 | } 11 | ], 12 | "scripts": { 13 | "phpstan": "phpstan analyse lib/ -c vendor/solido/php-coding-standards/phpstan.neon --level=8 --no-progress -vvv --memory-limit=2048M", 14 | "cscheck": "vendor/bin/phpcs --standard=Solido lib/", 15 | "csfix": "vendor/bin/phpcbf --standard=Solido lib/" 16 | }, 17 | "minimum-stability": "stable", 18 | "require": { 19 | "php": "^8.1", 20 | "thecodingmachine/safe": "^1.0 || ^2.0 || ^3.0" 21 | }, 22 | "require-dev": { 23 | "doctrine/annotations": "^1.0", 24 | "nikic/php-parser": "^4.0 || ^5.0", 25 | "phpbench/phpbench": "^1.2", 26 | "phpdocumentor/reflection": "^4.0 || ^5.0 || ^6.0", 27 | "phpunit/phpunit": "^10.5", 28 | "roave/better-reflection": "^6.0", 29 | "roave/security-advisories": "dev-master", 30 | "solido/php-coding-standards": "dev-master", 31 | "symfony/cache": "^5.0 || ^6.0 || ^7.0", 32 | "symfony/error-handler": "^5.0 || ^6.0 || ^7.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Kcs\\ClassFinder\\": "lib/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "files": [ 41 | "data/Composer/Psr4/test_file.php" 42 | ], 43 | "psr-4": { 44 | "Kcs\\ClassFinder\\Benchmark\\": "bench/", 45 | "Kcs\\ClassFinder\\Tests\\": "tests/", 46 | "Kcs\\ClassFinder\\Fixtures\\Psr4\\": "data/Composer/Psr4/", 47 | "Kcs\\ClassFinder\\Fixtures\\Psr4WithClassMap\\": "data/Psr4WithClassMap/", 48 | "": "data/Composer/Psr4Fallback" 49 | }, 50 | "psr-0": { 51 | "Kcs\\ClassFinder\\Fixtures\\Psr0\\": "data/Composer/Psr0/", 52 | "": "data/Composer/Psr0Fallback" 53 | }, 54 | "classmap": [ 55 | "data/Psr4WithClassMap/Map" 56 | ] 57 | }, 58 | "config": { 59 | "sort-packages": true, 60 | "allow-plugins": { 61 | "dealerdirect/phpcodesniffer-composer-installer": true, 62 | "phpstan/extension-installer": true 63 | } 64 | }, 65 | "extra": { 66 | "branch-alias": { 67 | "dev-master": "0.6.x-dev" 68 | } 69 | }, 70 | "archive": { 71 | "exclude": [ "tests", "data", "docs" ] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/Util/ErrorHandler.php: -------------------------------------------------------------------------------- 1 | false; 62 | self::$registered = true; 63 | } 64 | 65 | public static function unregister(): void 66 | { 67 | if (! self::$registered) { 68 | return; 69 | } 70 | 71 | $stack = []; 72 | while (true) { 73 | $previous = set_error_handler(static fn () => false); 74 | restore_error_handler(); 75 | if ($previous === null) { 76 | throw new Error('Error handler has changed, cannot unregister the handler'); // @phpstan-ignore-line 77 | } 78 | 79 | restore_error_handler(); 80 | if ($previous === [self::class, 'handleError']) { 81 | array_map('set_error_handler', array_reverse($stack)); 82 | break; 83 | } 84 | 85 | $stack[] = $previous; 86 | } 87 | 88 | self::$registered = false; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/unit/Util/ErrorHandlerTest.php: -------------------------------------------------------------------------------- 1 | getMessage()); 45 | self::assertEquals(E_USER_WARNING, $e->getCode()); 46 | } finally { 47 | restore_error_handler(); 48 | } 49 | } 50 | 51 | public function testShouldThrowErrorOnErrorOrUserError(): void 52 | { 53 | $prev = error_reporting(E_ALL & ~E_DEPRECATED); 54 | try { 55 | trigger_error('This is an error', E_USER_ERROR); 56 | } catch (Error $e) { 57 | self::assertNotNull($e); 58 | 59 | return; 60 | } finally { 61 | error_reporting($prev); 62 | } 63 | 64 | self::fail('Expected error'); 65 | } 66 | 67 | public function testShouldPassErrorsToPreviousErrorHandlerIfSilenced(): void 68 | { 69 | $error = null; 70 | 71 | ErrorHandler::unregister(); 72 | $previous = set_error_handler(static function () use (&$previous, &$error) { 73 | $error = func_get_args(); 74 | 75 | return call_user_func_array($previous, $error); 76 | }); 77 | 78 | ErrorHandler::register(); 79 | @unlink('this_file_does_not_exist.bad_idea'); 80 | 81 | self::assertNotNull($error); 82 | self::assertEquals('unlink(this_file_does_not_exist.bad_idea): No such file or directory', $error[1]); 83 | } 84 | 85 | public function testShouldNotCrashIfPreviousErrorHandlerReturnsNullOrVoid(): void 86 | { 87 | $this->expectNotToPerformAssertions(); 88 | 89 | ErrorHandler::unregister(); 90 | $previous = set_error_handler(static function () use (&$previous): void { 91 | call_user_func_array($previous, func_get_args()); 92 | }); 93 | 94 | ErrorHandler::register(); 95 | @unlink('this_file_does_not_exist.bad_idea'); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /lib/Finder/PhpParserFinder.php: -------------------------------------------------------------------------------- 1 | */ 35 | public function getIterator(): Iterator 36 | { 37 | $flags = 0; 38 | if ($this->skipNonInstantiable) { 39 | $flags |= ClassIterator::SKIP_NON_INSTANTIABLE; 40 | } 41 | 42 | $pathCallback = $this->pathFilterCallback !== null ? ($this->pathFilterCallback)(...) : null; 43 | $iterator = new PhpParserIterator($this->dir, $flags, $this->notNamespaces, $pathCallback); 44 | if (isset($this->fileFinder)) { 45 | $iterator->setFileFinder($this->fileFinder); 46 | } 47 | 48 | if ($this->dirs !== null) { 49 | $iterator->in($this->dirs); 50 | } 51 | 52 | if ($this->paths !== null) { 53 | $iterator->path($this->paths); 54 | } 55 | 56 | if ($this->notPaths !== null) { 57 | $iterator->notPath($this->notPaths); 58 | } 59 | 60 | return $this->applyFilters($iterator); 61 | } 62 | 63 | /** 64 | * @param Iterator $iterator 65 | * 66 | * @return Iterator 67 | */ 68 | private function applyFilters(Iterator $iterator): Iterator 69 | { 70 | if ($this->namespaces) { 71 | $iterator = new Filters\NamespaceFilterIterator($iterator, $this->namespaces); 72 | } 73 | 74 | if ($this->notNamespaces) { 75 | $iterator = new Filters\NotNamespaceFilterIterator($iterator, $this->notNamespaces); 76 | } 77 | 78 | if ($this->implements) { 79 | $iterator = new Filters\InterfaceImplementationFilterIterator($iterator, $this->implements); 80 | } 81 | 82 | if ($this->extends) { 83 | $iterator = new Filters\SuperClassFilterIterator($iterator, $this->extends); 84 | } 85 | 86 | if ($this->annotation) { 87 | $iterator = new Filters\AnnotationFilterIterator($iterator, $this->annotation); 88 | } 89 | 90 | if ($this->attribute) { 91 | $iterator = new Filters\AttributeFilterIterator($iterator, $this->attribute); 92 | } 93 | 94 | if ($this->filterCallback !== null) { 95 | $iterator = new CallbackFilterIterator($iterator, function ($current, $key) { 96 | assert($this->filterCallback !== null); 97 | 98 | return (bool) ($this->filterCallback)($current, $key); 99 | }); 100 | } 101 | 102 | return $iterator; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/FilterIterator/MultiplePcreFilterIterator.php: -------------------------------------------------------------------------------- 1 | 17 | * @template-extends FilterIterator 18 | */ 19 | abstract class MultiplePcreFilterIterator extends FilterIterator 20 | { 21 | /** @var string[] */ 22 | protected array $matchRegexps = []; 23 | 24 | /** @var string[] */ 25 | protected array $noMatchRegexps = []; 26 | 27 | /** 28 | * @param T $iterator The Iterator to filter 29 | * @param string[] $matchPatterns An array of patterns that need to match 30 | * @param string[] $noMatchPatterns An array of patterns that need to not match 31 | */ 32 | public function __construct(Iterator $iterator, array $matchPatterns, array $noMatchPatterns) 33 | { 34 | foreach ($matchPatterns as $pattern) { 35 | $this->matchRegexps[] = static::toRegex($pattern); 36 | } 37 | 38 | foreach ($noMatchPatterns as $pattern) { 39 | $this->noMatchRegexps[] = static::toRegex($pattern); 40 | } 41 | 42 | parent::__construct($iterator); 43 | } 44 | 45 | /** 46 | * Checks whether the string is a regex. 47 | * 48 | * @return bool Whether the given string is a regex 49 | */ 50 | public static function isRegex(string $str): bool 51 | { 52 | if (preg_match('/^(.{3,}?)[imsxuADU]*$/', $str, $m)) { 53 | $start = substr($m[1], 0, 1); 54 | $end = substr($m[1], -1); 55 | 56 | if ($start === $end) { 57 | return ! preg_match('/[*?[:alnum:] \\\\]/', $start); 58 | } 59 | 60 | foreach ([['{', '}'], ['(', ')'], ['[', ']'], ['<', '>']] as $delimiters) { 61 | if ($start === $delimiters[0] && $end === $delimiters[1]) { 62 | return true; 63 | } 64 | } 65 | } 66 | 67 | return false; 68 | } 69 | 70 | /** 71 | * Checks whether the string is accepted by the regex filters. 72 | * 73 | * If there is no regex defined in the class, this method will accept the string. 74 | * Such case can be handled by child classes before calling the method if they want to 75 | * apply a different behavior. 76 | * 77 | * @param string $string The string to be matched against filters 78 | */ 79 | protected function isAccepted(string $string): bool 80 | { 81 | // should at least not match one rule to exclude 82 | foreach ($this->noMatchRegexps as $regex) { 83 | if (preg_match($regex, $string)) { 84 | return false; 85 | } 86 | } 87 | 88 | // should at least match one rule 89 | if ($this->matchRegexps) { 90 | foreach ($this->matchRegexps as $regex) { 91 | if (preg_match($regex, $string)) { 92 | return true; 93 | } 94 | } 95 | 96 | return false; 97 | } 98 | 99 | // If there is no match rules, the file is accepted 100 | return true; 101 | } 102 | 103 | /** 104 | * Converts string into regexp. 105 | * 106 | * @param string $str Pattern 107 | * 108 | * @return string regexp corresponding to a given string 109 | */ 110 | abstract public static function toRegex(string $str): string; 111 | } 112 | -------------------------------------------------------------------------------- /lib/Iterator/OfflineIteratorTrait.php: -------------------------------------------------------------------------------- 1 | dirs = array_unique(array_merge($this->dirs ?? [], $resolvedDirs)); 58 | 59 | return $this; 60 | } 61 | 62 | /** @param string[] $patterns */ 63 | public function path(array $patterns): self 64 | { 65 | $this->paths = array_map(PathFilterIterator::class . '::toRegex', $patterns); 66 | 67 | return $this; 68 | } 69 | 70 | /** @param string[] $patterns */ 71 | public function notPath(array $patterns): self 72 | { 73 | $this->notPaths = array_map(PathFilterIterator::class . '::toRegex', $patterns); 74 | 75 | return $this; 76 | } 77 | 78 | private function accept(string $path): bool 79 | { 80 | if ($this->pathCallback && ! ($this->pathCallback)(PathNormalizer::normalize($path))) { 81 | return false; 82 | } 83 | 84 | return $this->acceptDirs($path) && 85 | $this->acceptPaths($path); 86 | } 87 | 88 | private function acceptPaths(string $path): bool 89 | { 90 | // should at least not match one rule to exclude 91 | if ($this->notPaths !== null) { 92 | foreach ($this->notPaths as $regex) { 93 | if (preg_match($regex, $path)) { 94 | return false; 95 | } 96 | } 97 | } 98 | 99 | // should at least match one rule 100 | if ($this->paths !== null) { 101 | foreach ($this->paths as $regex) { 102 | if (preg_match($regex, $path)) { 103 | return true; 104 | } 105 | } 106 | 107 | return false; 108 | } 109 | 110 | // If there is no match rules, the file is accepted 111 | return true; 112 | } 113 | 114 | private function acceptDirs(string $path): bool 115 | { 116 | if ($this->dirs === null) { 117 | return true; 118 | } 119 | 120 | foreach ($this->dirs as $dir) { 121 | if (str_starts_with($path, $dir)) { 122 | return true; 123 | } 124 | } 125 | 126 | return false; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/unit/Finder/Psr0FinderTest.php: -------------------------------------------------------------------------------- 1 | new ReflectionClass(Psr0\BarBar::class), 31 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 32 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 33 | ], iterator_to_array($finder)); 34 | } 35 | 36 | public function testFinderShouldFilterByDirectory(): void 37 | { 38 | $finder = new Psr0Finder('Kcs\ClassFinder\Fixtures\Psr0', __DIR__ . '/../../../data/Composer/Psr0'); 39 | $finder->in([__DIR__ . '/../../../data/Composer/Psr0/Kcs/ClassFinder/Fixtures/Psr0/SubNs']); 40 | 41 | self::assertEquals([ 42 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 43 | ], iterator_to_array($finder)); 44 | } 45 | 46 | public function testFinderShouldFilterByInterfaceImplementation(): void 47 | { 48 | $finder = new Psr0Finder('Kcs\ClassFinder\Fixtures\Psr0', __DIR__ . '/../../../data/Composer/Psr0'); 49 | $finder->implementationOf(Psr4\FooInterface::class); 50 | 51 | self::assertEquals([ 52 | Psr0\BarBar::class => new ReflectionClass(Psr0\BarBar::class), 53 | ], iterator_to_array($finder)); 54 | } 55 | 56 | public function testFinderShouldFilterBySuperClass(): void 57 | { 58 | $finder = new Psr0Finder('Kcs\ClassFinder\Fixtures\Psr0', __DIR__ . '/../../../data/Composer/Psr0'); 59 | $finder->subclassOf(Psr4\AbstractClass::class); 60 | 61 | self::assertEquals([ 62 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 63 | ], iterator_to_array($finder)); 64 | } 65 | 66 | public function testFinderShouldFilterByAnnotation(): void 67 | { 68 | $finder = new Psr0Finder('Kcs\ClassFinder\Fixtures\Psr0', __DIR__ . '/../../../data/Composer/Psr0'); 69 | $finder->annotatedBy(Psr4\SubNs\FooBaz::class); 70 | 71 | self::assertEquals([ 72 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 73 | ], iterator_to_array($finder)); 74 | } 75 | 76 | public function testFinderShouldFilterByAttribute(): void 77 | { 78 | $finder = new Psr0Finder('Kcs\ClassFinder\Fixtures\Psr0', __DIR__ . '/../../../data/Composer/Psr0'); 79 | $finder->withAttribute(Psr4\SubNs\FooBaz::class); 80 | 81 | self::assertEquals([ 82 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 83 | ], iterator_to_array($finder)); 84 | } 85 | 86 | public function testFinderShouldFilterByPathCallback(): void 87 | { 88 | $finder = new Psr0Finder('Kcs\ClassFinder\Fixtures\Psr0', __DIR__ . '/../../../data/Composer/Psr0'); 89 | $finder->pathFilter(static fn (string $path): bool => ! str_ends_with($path, 'BarBar.php')); 90 | 91 | self::assertEquals([ 92 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 93 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 94 | ], iterator_to_array($finder)); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/Iterator/Psr0Iterator.php: -------------------------------------------------------------------------------- 1 | $classMap 38 | * @param string[] $excludeNamespaces 39 | */ 40 | public function __construct( 41 | private readonly string $namespace, 42 | string $path, 43 | ReflectorFactoryInterface|null $reflectorFactory = null, 44 | int $flags = 0, 45 | array $classMap = [], 46 | array|null $excludeNamespaces = null, 47 | Closure|null $pathCallback = null, 48 | ) { 49 | $this->path = PathNormalizer::resolvePath($path); 50 | $this->reflectorFactory = $reflectorFactory ?? new NativeReflectorFactory(); 51 | $this->pathLen = strlen($this->path); 52 | $this->classMap = array_map(PathNormalizer::class . '::resolvePath', $classMap); 53 | 54 | parent::__construct($flags, $excludeNamespaces, $pathCallback); 55 | } 56 | 57 | protected function getGenerator(): Generator 58 | { 59 | $pattern = defined('HHVM_VERSION') ? '/\\.(php|hh)$/i' : '/\\.php$/i'; 60 | $include = Closure::bind( 61 | $this->flags & self::USE_AUTOLOADING ? 62 | static function (string $path, string $class): void { 63 | class_exists($class, true); 64 | } : static function (string $path): void { 65 | include_once $path; 66 | }, 67 | null, 68 | null, 69 | ); 70 | 71 | assert($include instanceof Closure); 72 | 73 | foreach ($this->search() as $path => $info) { 74 | $path = PathNormalizer::resolvePath($path); 75 | if (! preg_match($pattern, $path, $m) || ! $info->isReadable()) { 76 | continue; 77 | } 78 | 79 | if (in_array($path, $this->classMap, true)) { 80 | continue; 81 | } 82 | 83 | if ($this->pathCallback && ! ($this->pathCallback)(PathNormalizer::normalize($path))) { 84 | continue; 85 | } 86 | 87 | /** @phpstan-var class-string $class */ 88 | $class = ltrim(str_replace('/', '\\', substr($path, $this->pathLen, -strlen($m[0]))), '\\'); 89 | if (! $this->validNamespace($class)) { 90 | continue; 91 | } 92 | 93 | // Due to composer bug #6987 and the refuse of think about a proper 94 | // solution, we are forced to include the file here and check if class 95 | // exists with autoload flag disabled (see method exists). 96 | 97 | ErrorHandler::register(); 98 | try { 99 | $include($path, $class); 100 | } catch (Throwable) { /** @phpstan-ignore-line */ 101 | continue; 102 | } finally { 103 | ErrorHandler::unregister(); 104 | } 105 | 106 | if (! $this->exists($class)) { 107 | continue; 108 | } 109 | 110 | yield $class => $this->reflectorFactory->reflect($class); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/Iterator/ComposerIterator.php: -------------------------------------------------------------------------------- 1 | reflectorFactory = $reflectorFactory ?? new NativeReflectorFactory(); 30 | 31 | parent::__construct($flags, $excludeNamespaces, $pathCallback); 32 | } 33 | 34 | protected function getGenerator(): Generator 35 | { 36 | yield from $this->searchInClassMap(); 37 | yield from $this->searchInPsrMap(); 38 | } 39 | 40 | /** 41 | * Searches for class definitions in class map. 42 | */ 43 | private function searchInClassMap(): Generator 44 | { 45 | /** @phpstan-ignore-next-line */ 46 | yield from new ClassMapIterator($this->classLoader->getClassMap(), $this->reflectorFactory, $this->flags, $this->excludeNamespaces, $this->pathCallback); 47 | } 48 | 49 | /** 50 | * Iterates over psr-* maps and yield found classes. 51 | * 52 | * NOTE: If the class loader has been generated with ClassMapAuthoritative flag, 53 | * this method will not yield any element. 54 | */ 55 | private function searchInPsrMap(): Generator 56 | { 57 | if ($this->classLoader->isClassMapAuthoritative()) { 58 | // In this case, no psr-* map will be checked when autoloading classes. 59 | return; 60 | } 61 | 62 | foreach ($this->classLoader->getPrefixesPsr4() as $ns => $dirs) { 63 | foreach ($dirs as $dir) { 64 | $itr = new Psr4Iterator($ns, $dir, $this->reflectorFactory, $this->flags, $this->classLoader->getClassMap(), $this->excludeNamespaces, $this->pathCallback); 65 | if (isset($this->fileFinder)) { 66 | $itr->setFileFinder($this->fileFinder); 67 | } 68 | 69 | yield from $itr; 70 | } 71 | } 72 | 73 | foreach ($this->classLoader->getPrefixes() as $ns => $dirs) { 74 | foreach ($dirs as $dir) { 75 | $itr = new Psr0Iterator($ns, $dir, $this->reflectorFactory, $this->flags, $this->classLoader->getClassMap(), $this->excludeNamespaces, $this->pathCallback); 76 | if (isset($this->fileFinder)) { 77 | $itr->setFileFinder($this->fileFinder); 78 | } 79 | 80 | yield from $itr; 81 | } 82 | } 83 | 84 | foreach ($this->classLoader->getFallbackDirsPsr4() as $dir) { 85 | $itr = new Psr4Iterator('', $dir, $this->reflectorFactory, $this->flags, $this->classLoader->getClassMap(), $this->excludeNamespaces, $this->pathCallback); 86 | if (isset($this->fileFinder)) { 87 | $itr->setFileFinder($this->fileFinder); 88 | } 89 | 90 | yield from $itr; 91 | } 92 | 93 | foreach ($this->classLoader->getFallbackDirs() as $dir) { 94 | $itr = new Psr0Iterator('', $dir, $this->reflectorFactory, $this->flags, $this->classLoader->getClassMap(), $this->excludeNamespaces, $this->pathCallback); 95 | if (isset($this->fileFinder)) { 96 | $itr->setFileFinder($this->fileFinder); 97 | } 98 | 99 | yield from $itr; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /lib/Iterator/Psr4Iterator.php: -------------------------------------------------------------------------------- 1 | */ 34 | private array $classMap; 35 | 36 | /** 37 | * @param array $classMap 38 | * @param string[] $excludeNamespaces 39 | */ 40 | public function __construct( 41 | private readonly string $namespace, 42 | string $path, 43 | ReflectorFactoryInterface|null $reflectorFactory = null, 44 | int $flags = 0, 45 | array $classMap = [], 46 | array|null $excludeNamespaces = null, 47 | Closure|null $pathCallback = null, 48 | ) { 49 | $this->path = PathNormalizer::resolvePath($path); 50 | $this->reflectorFactory = $reflectorFactory ?? new NativeReflectorFactory(); 51 | $this->prefixLen = strlen($this->path); 52 | $this->classMap = array_map(PathNormalizer::class . '::resolvePath', $classMap); 53 | 54 | parent::__construct($flags, $excludeNamespaces, $pathCallback); 55 | } 56 | 57 | protected function getGenerator(): Generator 58 | { 59 | $pattern = defined('HHVM_VERSION') ? '/\\.(php|hh)$/i' : '/\\.php$/i'; 60 | $include = Closure::bind( 61 | $this->flags & self::USE_AUTOLOADING ? 62 | static function (string $path, string $class): void { 63 | class_exists($class, true); 64 | } : static function (string $path): void { 65 | include_once $path; 66 | }, 67 | null, 68 | null, 69 | ); 70 | 71 | assert($include instanceof Closure); 72 | 73 | foreach ($this->search() as $path => $info) { 74 | $path = PathNormalizer::resolvePath($path); 75 | if (! preg_match($pattern, $path, $m) || ! $info->isReadable()) { 76 | continue; 77 | } 78 | 79 | if (in_array($path, $this->classMap, true)) { 80 | continue; 81 | } 82 | 83 | if ($this->pathCallback && ! ($this->pathCallback)(PathNormalizer::normalize($path))) { 84 | continue; 85 | } 86 | 87 | /** @phpstan-var class-string $class */ 88 | $class = $this->namespace . ltrim(str_replace('/', '\\', substr($path, $this->prefixLen, -strlen($m[0]))), '\\'); 89 | if (! $this->validNamespace($class)) { 90 | continue; 91 | } 92 | 93 | // Due to composer bug #6987 and the refuse of think about a proper 94 | // solution, we are forced to include the file here and check if class 95 | // exists with autoload flag disabled (see method exists). 96 | 97 | ErrorHandler::register(); 98 | try { 99 | $include($path, $class); 100 | } catch (Throwable) { /** @phpstan-ignore-line */ 101 | continue; 102 | } finally { 103 | ErrorHandler::unregister(); 104 | } 105 | 106 | if (! $this->exists($class)) { 107 | continue; 108 | } 109 | 110 | yield $class => $this->reflectorFactory->reflect($class); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/Util/ClassMap.php: -------------------------------------------------------------------------------- 1 | */ 39 | private array $map = []; 40 | 41 | private function __construct() 42 | { 43 | } 44 | 45 | public static function fromFinder(FinderInterface $finder): self 46 | { 47 | $classMap = new self(); 48 | 49 | /** @var class-string $className */ 50 | foreach ($finder as $className => $reflector) { 51 | if ($reflector instanceof ReflectionClass || $reflector instanceof BetterReflection\Reflection\ReflectionClass) { 52 | $filename = $reflector->getFileName(); 53 | } elseif ($reflector instanceof ClassLike) { 54 | $metadata = $reflector->getAttribute(Metadata::METADATA_KEY); 55 | assert($metadata instanceof Metadata || $metadata === null); 56 | $filename = $metadata?->filePath; 57 | } elseif ($reflector instanceof Class_ || $reflector instanceof Interface_ || $reflector instanceof Trait_ || $reflector instanceof Enum_) { 58 | $metadata = method_exists($reflector, 'getMetadata') ? $reflector->getMetadata() : MetadataRegistry::getInstance()->getMetadata($reflector); 59 | $metadata = $metadata[Metadata::METADATA_KEY] ?? null; 60 | assert($metadata instanceof Metadata || $metadata === null); 61 | $filename = $metadata?->filePath; 62 | } else { 63 | throw new RuntimeException('Cannot create classmap from reflector class ' . $reflector::class); 64 | } 65 | 66 | if ($filename === null || $filename === false) { 67 | continue; 68 | } 69 | 70 | $classMap->map[$className] = PathNormalizer::resolvePath($filename); 71 | } 72 | 73 | return $classMap; 74 | } 75 | 76 | public function createFinder(): ClassMapFinder 77 | { 78 | return new ClassMapFinder($this->map); 79 | } 80 | 81 | /** @return array */ 82 | public function getMap(string|null $relativeTo = null): array 83 | { 84 | if ($relativeTo === null) { 85 | return $this->map; 86 | } 87 | 88 | return array_map(static fn (string $fn) => self::relativePath($relativeTo, $fn), $this->map); 89 | } 90 | 91 | /** @return Traversable */ 92 | public function getIterator(): Traversable 93 | { 94 | return new ClassMapIterator($this->map, null); 95 | } 96 | 97 | private static function relativePath(string $from, string $to): string 98 | { 99 | $from = PathNormalizer::resolvePath($from); 100 | 101 | $from = explode(DIRECTORY_SEPARATOR, rtrim($from, DIRECTORY_SEPARATOR)); 102 | $to = explode(DIRECTORY_SEPARATOR, rtrim($to, DIRECTORY_SEPARATOR)); 103 | 104 | while (count($from) && count($to) && $from[0] === $to[0]) { 105 | array_shift($from); 106 | array_shift($to); 107 | } 108 | 109 | return str_pad('', count($from) * 3, '..' . DIRECTORY_SEPARATOR) . implode(DIRECTORY_SEPARATOR, $to); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/unit/Finder/Psr4FinderTest.php: -------------------------------------------------------------------------------- 1 | new ReflectionClass(Psr4\BarBar::class), 30 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 31 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 32 | Psr4\FooInterface::class => new ReflectionClass(Psr4\FooInterface::class), 33 | Psr4\FooTrait::class => new ReflectionClass(Psr4\FooTrait::class), 34 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 35 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 36 | ], iterator_to_array($finder)); 37 | } 38 | 39 | public function testFinderShouldFilterByDirectory(): void 40 | { 41 | $finder = new Psr4Finder('Kcs\ClassFinder\Fixtures\Psr4', __DIR__ . '/../../../data/Composer/Psr4'); 42 | $finder->in([__DIR__ . '/../../../data/Composer/Psr4/SubNs']); 43 | 44 | self::assertEquals([ 45 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 46 | ], iterator_to_array($finder)); 47 | } 48 | 49 | public function testFinderShouldFilterByInterfaceImplementation(): void 50 | { 51 | $finder = new Psr4Finder('Kcs\ClassFinder\Fixtures\Psr4', __DIR__ . '/../../../data/Composer/Psr4'); 52 | $finder->implementationOf(Psr4\FooInterface::class); 53 | 54 | self::assertEquals([ 55 | Psr4\BarBar::class => new ReflectionClass(Psr4\BarBar::class), 56 | ], iterator_to_array($finder)); 57 | } 58 | 59 | public function testFinderShouldFilterBySuperClass(): void 60 | { 61 | $finder = new Psr4Finder('Kcs\ClassFinder\Fixtures\Psr4', __DIR__ . '/../../../data/Composer/Psr4'); 62 | $finder->subclassOf(Psr4\AbstractClass::class); 63 | 64 | self::assertEquals([ 65 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 66 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 67 | ], iterator_to_array($finder)); 68 | } 69 | 70 | public function testFinderShouldFilterByAnnotation(): void 71 | { 72 | $finder = new Psr4Finder('Kcs\ClassFinder\Fixtures\Psr4', __DIR__ . '/../../../data/Composer/Psr4'); 73 | $finder->annotatedBy(Psr4\SubNs\FooBaz::class); 74 | 75 | self::assertEquals([ 76 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 77 | ], iterator_to_array($finder)); 78 | } 79 | 80 | public function testFinderShouldFilterByAttribute(): void 81 | { 82 | $finder = new Psr4Finder('Kcs\ClassFinder\Fixtures\Psr4', __DIR__ . '/../../../data/Composer/Psr4'); 83 | $finder->withAttribute(Psr4\SubNs\FooBaz::class); 84 | 85 | self::assertEquals([ 86 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 87 | ], iterator_to_array($finder)); 88 | } 89 | 90 | public function testFinderShouldFilterByPathCallback(): void 91 | { 92 | $finder = new Psr4Finder('Kcs\ClassFinder\Fixtures\Psr4', __DIR__ . '/../../../data/Composer/Psr4'); 93 | $finder->pathFilter(static fn (string $path): bool => ! str_ends_with($path, 'BarBar.php')); 94 | 95 | self::assertEquals([ 96 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 97 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 98 | Psr4\FooInterface::class => new ReflectionClass(Psr4\FooInterface::class), 99 | Psr4\FooTrait::class => new ReflectionClass(Psr4\FooTrait::class), 100 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 101 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 102 | ], iterator_to_array($finder)); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /docs/finder.md: -------------------------------------------------------------------------------- 1 | # Finder 2 | 3 | A finder finds classes, interfaces and traits based on different criteria (namespace, location, attributes, annotations, etc.) via an intuitive fluent interface. 4 | 5 | ?> `Finder` interface is inspired by Symfony finder component. 6 | 7 | ## Available finders 8 | 9 | The following finders are available in this library: 10 | 11 | - `ComposerFinder` - detects a composer `ClassLoader` and uses its class-map and psr-0/4 autoloader rules to filter and find all the installed classes. 12 | - `Psr0Finder` - finds classes into a psr-0 folder. 13 | - `Psr4Finder` - finds classes into a psr-4 folder. 14 | - `RecursiveFinder` - recursive search PHP files into a folder. Note: this finder includes the files with `require_once` and uses `get_declared_classes/interfaces/traits` to enumerate the symbols. 15 | - `PhpDocumentorFinder` - analyzes a directory with `phpdocumentor/reflection` package (version 4, 5 or 6) to extract the symbols declared in the PHP files. 16 | - `PhpParserFinder` - analyzes a directory with `nikic/php-parser` package (version 4) to parse PHP files and find the declared symbols. 17 | - `ClassMapFinder` - uses a classmap array and finds classes contained into the map. 18 | 19 | All the finders implement the `Kcs\ClassFinder\Finder\FinderInterface` interface. 20 | 21 | ### Finders are iterable 22 | 23 | All the finders are iterable which yields key/value tuple: 24 | 25 | - The key *always* contains the fully-qualified class name as string 26 | - The value is a reflector object. This can vary upon the used finder. In particular: 27 | - `ComposerFinder`, `Psr0Finder`, `Psr4Finder` and `RecursiveFinder` yield PHP's `Reflector` objects (`ReflectionClass`) 28 | - `PhpDocumentorFinder` yield instances of `phpDocumentor\Reflection\Php\Class_`, `phpDocumentor\Reflection\Php\Interface_`, `phpDocumentor\Reflection\Php\Trait_` and `phpDocumentor\Reflection\Php\Enum_` 29 | - `PhpParserFinder` yield instances of `PhpParser\Node\Stmt\ClassLike` 30 | 31 | Additionally `ComposerFinder` and `Psr*Finder` expose a `setReflectorFactory` method which can be used to customize the reflector object creation. 32 | The only available implementation of the reflector factory (`NativeReflectorFactory`) *always* return a `ReflectionClass` object. 33 | 34 | ## Finder criteria 35 | 36 | Criteria can be added to the finder using the following methods: 37 | 38 | - `implementationOf(array|string $interfaces)` - Only the classes that implements all the given interfaces will be yielded. You can pass a single interface as string. 39 | - `subclassOf(string $superClass)` - Only the classes that are subclasses of the given class will be yielded. 40 | - `annontatedBy(string $annotationClass)` - Finds all the classes that have the given annotation in the class docblock. 41 | - `withAttribute(string $attributeClass)` - Finds all the classes that have the given attribute applied on the class (PHP >= 8.0) only. 42 | - `in(array $dirs)` - Searches only in given directories. 43 | - `inNamespace(array $namespaces)` - Searches only in given namespaces. 44 | - `notInNamespace(array $namespaces)` - Searches only *outside* the given namespaces. 45 | - `path(string $pattern)` - Adds a filter based on file pathname. If starts with '/' will be interpreted as a regex. 46 | - `notPath(string $pattern)` - Adds a negative filter based on file pathname. If starts with '/' will be interpreted as a regex. 47 | - `filter(callable $callback)` - Adds a custom filter callback. 48 | - `pathFilter(callable $callback)` - Adds a custom file pathname filter callback. 49 | - `skipNonInstantiable(bool $skip = true)` - Whether to skip or not abstract classes, traits and interfaces. 50 | - `skipBogonFiles(bool $skip = true)` - Prevents the inclusion of files known to cause bugs and possible fatal errors. 51 | 52 | ## Offline finders 53 | 54 | There are two "offline" finders: `PhpDocumentorFinder` and `PhpParserFinder` which analyse the php files searching for classes/interfaces/traits/enums without including them. 55 | These finders are slower can be useful in case you don't want to execute PHP files (untrusted sources, possibly invalid files, etc.) 56 | 57 | Some limitations apply: 58 | 59 | - `PhpParserFinder` uses a custom annotation reader, not fully tested, filtering by annotations could be buggy 60 | - `PhpDocumentorFinder` will skip classes with invalid phpdoc tags 61 | - offline finders will skip invalid files/classes if there's a syntax error 62 | - superclass and interface implementation filtering could be incomplete: all the symbols must be known to build a full class chain. 63 | If a class in the chain is unknown, the finders cannot calculate interface implementations and class chain correctly. 64 | To correctly calculate the class chain for extensions/core subclasses, you need to install stubs file (ex: using `jetbrains/phpstorm-stubs` package). 65 | -------------------------------------------------------------------------------- /lib/Finder/FinderInterface.php: -------------------------------------------------------------------------------- 1 | path('some/special/dir') 100 | * $finder->path('/some\/special\/dir/') // same as above 101 | * 102 | * Use only / as dirname separator. 103 | * 104 | * @param string $pattern A pattern (a regexp or a string) 105 | * 106 | * @return $this 107 | */ 108 | public function path(string $pattern): static; 109 | 110 | /** 111 | * Adds rules that filenames must not match. 112 | * 113 | * You can use patterns (delimited with / sign) or simple strings. 114 | * 115 | * $finder->notPath('some/special/dir') 116 | * $finder->notPath('/some\/special\/dir/') // same as above 117 | * 118 | * Use only / as dirname separator. 119 | * 120 | * @param string $pattern A pattern (a regexp or a string) 121 | * 122 | * @return $this 123 | */ 124 | public function notPath(string $pattern): static; 125 | 126 | /** 127 | * Sets a custom callback for file filtering. 128 | * The callback will receive the full filepath as the only argument. 129 | * 130 | * @param callable(string):bool|null $callback 131 | * 132 | * @return $this 133 | */ 134 | public function pathFilter(callable|null $callback): static; 135 | 136 | /** 137 | * Skips non-instantiable (abstract) classes, as well as interfaces and traits. 138 | * 139 | * @return $this 140 | */ 141 | public function skipNonInstantiable(bool $skip = true): static; 142 | 143 | /** 144 | * Prevents the inclusion of files known to cause bugs and possible fatal errors. 145 | * 146 | * @return $this 147 | */ 148 | public function skipBogonFiles(bool $skip = true): static; 149 | } 150 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | pull_request_target: 7 | 8 | jobs: 9 | tests: 10 | permissions: 11 | id-token: write 12 | contents: read 13 | 14 | env: 15 | COMPOSER_ROOT_VERSION: dev-master 16 | 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | os: 21 | - ubuntu-latest 22 | - windows-latest 23 | 24 | php_version: 25 | - '8.1' 26 | - '8.2' 27 | - '8.3' 28 | - '8.4' 29 | 30 | composer_flags: 31 | - '' 32 | - '-o' 33 | 34 | name: 'PHP ${{ matrix.php_version }} (os: ${{ matrix.os }} composer_flags: "${{ matrix.composer_flags }}")' 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Setup PHP with pecl extension 38 | uses: shivammathur/setup-php@v2 39 | with: 40 | php-version: ${{ matrix.php_version }} 41 | extensions: :opcache, pcov 42 | 43 | - run: composer remove --dev --no-update roave/better-reflection 44 | if: ${{ matrix.php_version == '8.4' }} # Temporary: better reflection does not support 8.4 45 | 46 | - name: Install Composer dependencies 47 | uses: ramsey/composer-install@v3 48 | with: 49 | composer-options: ${{ matrix.composer_flags }} 50 | 51 | - name: Install Composer dependencies (tests) 52 | run: php tests/install-deps.php 53 | env: 54 | COMPOSER_FLAGS: ${{ matrix.composer_flags }} 55 | 56 | - run: vendor/bin/phpunit 57 | if: ${{ matrix.php_version != '8.3' }} 58 | - run: vendor/bin/phpunit --coverage-clover coverage.xml --log-junit junit.xml 59 | if: ${{ matrix.php_version == '8.3' }} 60 | env: 61 | XDEBUG_MODE: coverage 62 | 63 | - name: Upload test results to Codecov 64 | if: ${{ !cancelled() && matrix.php_version == '8.3' }} 65 | uses: codecov/test-results-action@v1 66 | with: 67 | token: ${{ secrets.CODECOV_TOKEN }} 68 | 69 | - name: Upload coverage to Codecov 70 | if: ${{ !cancelled() && matrix.php_version == '8.3' }} 71 | uses: codecov/codecov-action@v4 72 | with: 73 | use_oidc: true 74 | file: ./coverage.xml 75 | 76 | phpdocumentor_tests: 77 | permissions: 78 | id-token: write 79 | contents: read 80 | 81 | env: 82 | COMPOSER_ROOT_VERSION: dev-master 83 | 84 | runs-on: ubuntu-latest 85 | strategy: 86 | matrix: 87 | phpdocumentor_version: 88 | - '4.0' 89 | - '5.0' 90 | 91 | composer_flags: 92 | - '' 93 | - '-o' 94 | 95 | name: 'phpDocumentor ${{ matrix.phpdocumentor_version }} (on PHP 8.3 composer_flags: "${{ matrix.composer_flags }}")' 96 | steps: 97 | - uses: actions/checkout@v4 98 | - name: Setup PHP with pecl extension 99 | uses: shivammathur/setup-php@v2 100 | with: 101 | php-version: '8.3' 102 | extensions: :opcache, pcov 103 | 104 | - name: Require phpDocumentor ${{ matrix.phpdocumentor_version }} 105 | run: composer require --dev --no-update -n "phpdocumentor/reflection:^${{ matrix.phpdocumentor_version }}" 106 | 107 | - name: Install Composer dependencies 108 | uses: ramsey/composer-install@v3 109 | with: 110 | composer-options: ${{ matrix.composer_flags }} 111 | dependency-versions: highest 112 | 113 | - name: Install Composer dependencies (tests) 114 | run: php tests/install-deps.php 115 | env: 116 | COMPOSER_FLAGS: ${{ matrix.composer_flags }} 117 | 118 | - run: vendor/bin/phpunit --coverage-clover coverage.xml --log-junit junit.xml 119 | env: 120 | XDEBUG_MODE: coverage 121 | 122 | - name: Upload test results to Codecov 123 | if: ${{ !cancelled() }} 124 | uses: codecov/test-results-action@v1 125 | with: 126 | token: ${{ secrets.CODECOV_TOKEN }} 127 | 128 | - name: Upload coverage to Codecov 129 | if: ${{ !cancelled() }} 130 | uses: codecov/codecov-action@v4 131 | with: 132 | use_oidc: true 133 | file: ./coverage.xml 134 | -------------------------------------------------------------------------------- /lib/Finder/FinderTrait.php: -------------------------------------------------------------------------------- 1 | implements = (array) $interface; 68 | 69 | return $this; 70 | } 71 | 72 | public function subclassOf(string|null $superClass): static 73 | { 74 | $this->extends = $superClass; 75 | 76 | return $this; 77 | } 78 | 79 | public function annotatedBy(string|null $annotationClass): static 80 | { 81 | $this->annotation = $annotationClass; 82 | 83 | return $this; 84 | } 85 | 86 | public function withAttribute(string|null $attributeClass): static 87 | { 88 | $this->attribute = $attributeClass; 89 | 90 | return $this; 91 | } 92 | 93 | /** 94 | * {@inheritDoc} 95 | */ 96 | public function in($dirs): static 97 | { 98 | $resolvedDirs = []; 99 | 100 | foreach ((array) $dirs as $dir) { 101 | if (is_dir($dir)) { 102 | $resolvedDirs[] = $dir; 103 | } else { 104 | $glob = glob($dir, (defined('GLOB_BRACE') ? GLOB_BRACE : 0) | GLOB_ONLYDIR); 105 | if (empty($glob)) { 106 | throw new InvalidArgumentException('The "' . $dir . '" directory does not exist.'); 107 | } 108 | 109 | array_push($resolvedDirs, ...$glob); 110 | } 111 | } 112 | 113 | $resolvedDirs = array_map(PathNormalizer::class . '::resolvePath', $resolvedDirs); 114 | $this->dirs = array_unique(array_merge($this->dirs ?? [], $resolvedDirs)); 115 | 116 | return $this; 117 | } 118 | 119 | /** 120 | * {@inheritDoc} 121 | */ 122 | public function inNamespace($namespaces): static 123 | { 124 | $this->namespaces = array_unique(array_merge($this->namespaces ?? [], (array) $namespaces)); 125 | 126 | return $this; 127 | } 128 | 129 | /** 130 | * {@inheritDoc} 131 | */ 132 | public function notInNamespace($namespaces): static 133 | { 134 | $this->notNamespaces = array_unique(array_merge($this->notNamespaces ?? [], (array) $namespaces)); 135 | 136 | return $this; 137 | } 138 | 139 | public function filter(callable|null $callback): static 140 | { 141 | $this->filterCallback = $callback; 142 | 143 | return $this; 144 | } 145 | 146 | /** 147 | * {@inheritDoc} 148 | */ 149 | public function path($pattern): static 150 | { 151 | $this->paths[] = $pattern; 152 | 153 | return $this; 154 | } 155 | 156 | /** 157 | * {@inheritDoc} 158 | */ 159 | public function notPath($pattern): static 160 | { 161 | $this->notPaths[] = $pattern; 162 | 163 | return $this; 164 | } 165 | 166 | public function pathFilter(callable|null $callback): static 167 | { 168 | $this->pathFilterCallback = $callback; 169 | 170 | return $this; 171 | } 172 | 173 | public function skipNonInstantiable(bool $skip = true): static 174 | { 175 | $this->skipNonInstantiable = $skip; 176 | 177 | return $this; 178 | } 179 | 180 | public function skipBogonFiles(bool $skip = true): static 181 | { 182 | $this->skipBogonClasses = $skip; 183 | 184 | return $this; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /lib/Finder/ComposerFinder.php: -------------------------------------------------------------------------------- 1 | */ 37 | private array $files; 38 | private bool $useAutoloading = true; 39 | 40 | public function __construct(ClassLoader|null $loader = null) 41 | { 42 | $this->loader = $loader ?? self::getValidLoader(); 43 | $vendorDir = array_search($this->loader, ClassLoader::getRegisteredLoaders()); 44 | 45 | if ($vendorDir === false) { 46 | return; 47 | } 48 | 49 | $autoloadFilesFn = $vendorDir . '/composer/autoload_files.php'; 50 | if (! file_exists($autoloadFilesFn)) { 51 | return; 52 | } 53 | 54 | $files = include $autoloadFilesFn; 55 | if (! is_array($files)) { 56 | return; 57 | } 58 | 59 | $this->files = array_combine($files, $files); 60 | } 61 | 62 | public function setReflectorFactory(ReflectorFactoryInterface|null $reflectorFactory): self 63 | { 64 | $this->reflectorFactory = $reflectorFactory; 65 | 66 | return $this; 67 | } 68 | 69 | public function useAutoloading(bool $use = true): self 70 | { 71 | $this->useAutoloading = $use; 72 | 73 | return $this; 74 | } 75 | 76 | /** @return Iterator */ 77 | public function getIterator(): Iterator 78 | { 79 | $flags = 0; 80 | if ($this->skipNonInstantiable) { 81 | $flags |= ClassIterator::SKIP_NON_INSTANTIABLE; 82 | } 83 | 84 | $pathFilterCallback = $this->pathFilterCallback ? ($this->pathFilterCallback)(...) : null; 85 | if ($this->useAutoloading) { 86 | $flags |= ClassIterator::USE_AUTOLOADING; 87 | 88 | $pathFilterCallback ??= static fn () => true; 89 | $pathFilterCallback = function (string $path) use ($pathFilterCallback): bool { 90 | if (isset($this->files[$path])) { 91 | return false; 92 | } 93 | 94 | return $pathFilterCallback($path); 95 | }; 96 | } 97 | 98 | if ($this->skipBogonClasses) { 99 | $pathFilterCallback = BogonFilesFilter::getFileFilterFn($pathFilterCallback); 100 | } 101 | 102 | if ($this->namespaces || $this->dirs || $this->notNamespaces) { 103 | $iterator = new FilteredComposerIterator( 104 | $this->loader, 105 | $this->reflectorFactory, 106 | $this->namespaces, 107 | $this->notNamespaces, 108 | $this->dirs, 109 | $flags, 110 | $pathFilterCallback, 111 | ); 112 | } else { 113 | $iterator = new ComposerIterator( 114 | $this->loader, 115 | $this->reflectorFactory, 116 | $flags, 117 | $this->notNamespaces, 118 | $pathFilterCallback, 119 | ); 120 | } 121 | 122 | if (isset($this->fileFinder)) { 123 | $iterator->setFileFinder($this->fileFinder); 124 | } 125 | 126 | return $this->applyFilters($iterator); 127 | } 128 | 129 | /** 130 | * Try to get a registered instance of composer ClassLoader. 131 | * 132 | * @throws RuntimeException if composer CLassLoader cannot be found. 133 | */ 134 | private static function getValidLoader(): ClassLoader 135 | { 136 | foreach (spl_autoload_functions() as $autoloadFn) { 137 | if (is_array($autoloadFn) && class_exists(DebugClassLoader::class) && $autoloadFn[0] instanceof DebugClassLoader) { 138 | $autoloadFn = $autoloadFn[0]->getClassLoader(); 139 | } 140 | 141 | if (is_array($autoloadFn) && $autoloadFn[0] instanceof ClassLoader) { 142 | return $autoloadFn[0]; 143 | } 144 | } 145 | 146 | throw new RuntimeException('Cannot find a valid composer class loader in registered autoloader functions. Cannot continue.'); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/unit/Iterator/FilteredComposerIteratorTest.php: -------------------------------------------------------------------------------- 1 | classMap = [__CLASS__ => __FILE__]; // phpcs:ignore 27 | 28 | $loader->prefixDirsPsr4 = [ 29 | 'Kcs\\ClassFinder\\Fixtures\\Psr4\\' => [ 30 | __DIR__ . '/../../..' . '/data/Composer/Psr4', 31 | ], 32 | ]; 33 | 34 | $loader->prefixesPsr0 = [ 35 | 'K' => [ 36 | 'Kcs\\ClassFinder\\Fixtures\\Psr0\\' => [ 37 | __DIR__ . '/../../..' . '/data/Composer/Psr0', 38 | ], 39 | ], 40 | ]; 41 | }, null, ClassLoader::class))(); 42 | 43 | $this->loader = $loader; 44 | } 45 | 46 | public function testComposerIteratorShouldWork(): void 47 | { 48 | $iterator = new FilteredComposerIterator($this->loader, null, null, null, null); 49 | 50 | self::assertEquals([ 51 | self::class => new ReflectionClass(self::class), 52 | Psr4\BarBar::class => new ReflectionClass(Psr4\BarBar::class), 53 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 54 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 55 | Psr4\FooInterface::class => new ReflectionClass(Psr4\FooInterface::class), 56 | Psr4\FooTrait::class => new ReflectionClass(Psr4\FooTrait::class), 57 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 58 | Psr0\BarBar::class => new ReflectionClass(Psr0\BarBar::class), 59 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 60 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 61 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 62 | ], iterator_to_array($iterator)); 63 | } 64 | 65 | public function testComposerIteratorShouldCallPathCallback(): void 66 | { 67 | $iterator = new FilteredComposerIterator($this->loader, null, null, null, null, 0, static function (string $path): bool { 68 | return ! str_ends_with($path, 'BarBar.php'); 69 | }); 70 | 71 | self::assertEquals([ 72 | self::class => new ReflectionClass(self::class), 73 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 74 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 75 | Psr4\FooInterface::class => new ReflectionClass(Psr4\FooInterface::class), 76 | Psr4\FooTrait::class => new ReflectionClass(Psr4\FooTrait::class), 77 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 78 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 79 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 80 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 81 | ], iterator_to_array($iterator)); 82 | } 83 | 84 | public function testComposerIteratorShouldFilterNotIntersectingPath(): void 85 | { 86 | // NOTE: This test could be interpreted as wrong, but is not: 87 | // the purpose of the FilteredComposerIterator class is to do some *quick and dirty* filtering 88 | // not to be precise enough to be used directly. In this case the Psr4/ direct children 89 | // intersects perfectly with the requested dirs. The upper finder should filter out the 90 | // non-matching results. 91 | 92 | $iterator = new FilteredComposerIterator($this->loader, null, null, null, [__DIR__ . '/../../..' . '/data/Composer/Psr4/SubNs']); 93 | 94 | self::assertEquals([ 95 | Psr4\BarBar::class => new ReflectionClass(Psr4\BarBar::class), 96 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 97 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 98 | Psr4\FooInterface::class => new ReflectionClass(Psr4\FooInterface::class), 99 | Psr4\FooTrait::class => new ReflectionClass(Psr4\FooTrait::class), 100 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 101 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 102 | ], iterator_to_array($iterator)); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/Iterator/ClassIterator.php: -------------------------------------------------------------------------------- 1 | */ 31 | private array $foundClasses = []; 32 | 33 | private Closure $_apply; 34 | private mixed $_currentElement; 35 | private mixed $_current = null; 36 | 37 | public function __construct( 38 | protected readonly int $flags = 0, 39 | protected readonly array|null $excludeNamespaces = null, 40 | protected Closure|null $pathCallback = null, 41 | ) { 42 | $this->apply(null); 43 | } 44 | 45 | public function current(): mixed 46 | { 47 | if (! $this->valid()) { 48 | return null; 49 | } 50 | 51 | if ($this->_current === null) { 52 | $this->_current = ($this->_apply)($this->_currentElement); 53 | } 54 | 55 | return $this->_current; 56 | } 57 | 58 | public function next(): void 59 | { 60 | $generator = $this->generator(); 61 | $valid = false; 62 | 63 | while (! $valid && $generator->valid()) { 64 | $generator->next(); 65 | 66 | $this->_current = null; 67 | $this->_currentElement = $generator->current(); 68 | $valid = $this->filter(); 69 | } 70 | } 71 | 72 | public function key(): int|string 73 | { 74 | return $this->generator()->key(); 75 | } 76 | 77 | public function valid(): bool 78 | { 79 | return $this->generator()->valid(); 80 | } 81 | 82 | public function rewind(): void 83 | { 84 | $this->foundClasses = []; 85 | $this->generator = null; 86 | 87 | $this->_currentElement = $this->generator()->current(); 88 | } 89 | 90 | /** 91 | * Registers a callable to apply to each element of the iterator. 92 | * 93 | * @return $this 94 | */ 95 | public function apply(callable|null $func = null): self 96 | { 97 | if ($func === null) { 98 | $func = static function ($val) { 99 | return $val; 100 | }; 101 | } 102 | 103 | $this->_current = null; 104 | $this->_apply = $func(...); 105 | 106 | return $this; 107 | } 108 | 109 | /** 110 | * Returns a generator to iterate classes over. 111 | * Yielded elements must have the class name as key 112 | * and the reflector as its value. 113 | */ 114 | abstract protected function getGenerator(): Generator; 115 | 116 | /** 117 | * Checks whether the given class is instantiable. 118 | */ 119 | protected function isInstantiable(mixed $reflector): bool 120 | { 121 | return $reflector instanceof ReflectionClass && $reflector->isInstantiable(); 122 | } 123 | 124 | protected function validNamespace(string $class): bool 125 | { 126 | if ($this->excludeNamespaces !== null) { 127 | foreach ($this->excludeNamespaces as $namespace) { 128 | if (str_starts_with($class, $namespace)) { 129 | return false; 130 | } 131 | } 132 | } 133 | 134 | return true; 135 | } 136 | 137 | /** 138 | * Do some basic validity checks on the given class. 139 | * Returns FALSE if the class is not valid or already 140 | * returned by this iterator, TRUE otherwise. 141 | */ 142 | private function filter(): bool 143 | { 144 | if ($this->_currentElement === null) { 145 | // End of the generator. 146 | return false; 147 | } 148 | 149 | $className = $this->generator()->key(); 150 | assert(is_string($className)); 151 | if (isset($this->foundClasses[$className])) { 152 | return false; 153 | } 154 | 155 | $this->foundClasses[$className] = true; 156 | if (! $this->validNamespace($className)) { 157 | return false; 158 | } 159 | 160 | return ! ($this->flags & self::SKIP_NON_INSTANTIABLE) || $this->isInstantiable($this->_currentElement); 161 | } 162 | 163 | private function generator(): Generator 164 | { 165 | if ($this->generator === null) { 166 | $this->generator = $this->getGenerator(); 167 | $this->_currentElement = $this->generator->current(); 168 | if (! $this->filter()) { 169 | $this->next(); 170 | } 171 | } 172 | 173 | assert($this->generator !== null); 174 | 175 | return $this->generator; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /tests/unit/Iterator/PhpDocumentorIteratorTest.php: -------------------------------------------------------------------------------- 1 | in([__DIR__ . '/../../..' . '/data/Composer/Psr?']); 51 | $classes = iterator_to_array($iterator); 52 | 53 | self::assertArrayHasKey(Psr4\BarBar::class, $classes); 54 | self::assertInstanceOf(Class_::class, $classes[Psr4\BarBar::class]); 55 | self::assertArrayHasKey(Psr4\Foobar::class, $classes); 56 | self::assertInstanceOf(Class_::class, $classes[Psr4\Foobar::class]); 57 | self::assertArrayHasKey(Psr4\AbstractClass::class, $classes); 58 | self::assertInstanceOf(Class_::class, $classes[Psr4\AbstractClass::class]); 59 | self::assertArrayHasKey(Psr4\SubNs\FooBaz::class, $classes); 60 | self::assertInstanceOf(Class_::class, $classes[Psr4\SubNs\FooBaz::class]); 61 | self::assertArrayHasKey(Psr4\FooInterface::class, $classes); 62 | self::assertInstanceOf(Interface_::class, $classes[Psr4\FooInterface::class]); 63 | self::assertArrayHasKey(Psr4\FooTrait::class, $classes); 64 | self::assertInstanceOf(Trait_::class, $classes[Psr4\FooTrait::class]); 65 | 66 | self::assertArrayHasKey(Psr0\BarBar::class, $classes); 67 | self::assertInstanceOf(Class_::class, $classes[Psr0\BarBar::class]); 68 | self::assertArrayHasKey(Psr0\Foobar::class, $classes); 69 | self::assertInstanceOf(Class_::class, $classes[Psr0\Foobar::class]); 70 | self::assertArrayHasKey(Psr0\SubNs\FooBaz::class, $classes); 71 | self::assertInstanceOf(Class_::class, $classes[Psr0\SubNs\FooBaz::class]); 72 | 73 | $iterator->path(['/Sub.*/']); 74 | $classes = iterator_to_array($iterator); 75 | 76 | self::assertEquals([ 77 | Psr0\SubNs\FooBaz::class, 78 | Psr4\SubNs\FooBaz::class, 79 | ], array_keys($classes)); 80 | 81 | $iterator->path(['/Sub.*/'])->notPath(['/sr4/']); 82 | $classes = iterator_to_array($iterator); 83 | 84 | self::assertEquals([ 85 | Psr0\SubNs\FooBaz::class, 86 | ], array_keys($classes)); 87 | } 88 | 89 | public function testIteratorShouldCallPathCallback(): void 90 | { 91 | $iterator = new PhpDocumentorIterator( 92 | realpath(__DIR__ . '/../../../data/Composer/Psr4'), 93 | pathCallback: static function (string $path): bool { 94 | return ! str_ends_with($path, 'BarBar.php'); 95 | }, 96 | ); 97 | 98 | $classes = iterator_to_array($iterator); 99 | 100 | self::assertArrayHasKey(Psr4\Foobar::class, $classes); 101 | self::assertInstanceOf(Class_::class, $classes[Psr4\Foobar::class]); 102 | self::assertArrayHasKey(Psr4\AbstractClass::class, $classes); 103 | self::assertInstanceOf(Class_::class, $classes[Psr4\AbstractClass::class]); 104 | self::assertArrayHasKey(Psr4\SubNs\FooBaz::class, $classes); 105 | self::assertInstanceOf(Class_::class, $classes[Psr4\SubNs\FooBaz::class]); 106 | self::assertArrayHasKey(Psr4\FooInterface::class, $classes); 107 | self::assertInstanceOf(Interface_::class, $classes[Psr4\FooInterface::class]); 108 | self::assertArrayHasKey(Psr4\FooTrait::class, $classes); 109 | self::assertInstanceOf(Trait_::class, $classes[Psr4\FooTrait::class]); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /lib/Util/PhpParser/AnnotationParser.php: -------------------------------------------------------------------------------- 1 | lexer = new DocLexer(); 40 | $this->nameResolver = (fn (Node\Name $name): Node\Name => $this->resolveClassName($name)) /** @phpstan-ignore-line */ 41 | ->bindTo($nameResolver, NameResolver::class); 42 | } 43 | 44 | public function leaveNode(Node $node): Node|null 45 | { 46 | if (! $node instanceof Node\Stmt\ClassLike) { 47 | return null; 48 | } 49 | 50 | $docblock = $node->getDocComment(); 51 | if ($docblock === null) { 52 | return null; 53 | } 54 | 55 | $input = $docblock->getReformattedText(); 56 | $this->lexer->setInput(trim(substr($input, $this->findInitialTokenPosition($input) ?? 0), '* /')); 57 | $this->lexer->moveNext(); 58 | 59 | $annotations = []; 60 | while ($this->lexer->lookahead !== null) { 61 | if ($this->lexer->lookahead['type'] !== DocLexer::T_AT) { 62 | $this->lexer->moveNext(); 63 | continue; 64 | } 65 | 66 | // make sure the @ is preceded by non-catchable pattern 67 | if ( 68 | $this->lexer->token !== null && 69 | $this->lexer->lookahead['position'] === $this->lexer->token['position'] + strlen( 70 | $this->lexer->token['value'], 71 | ) 72 | ) { 73 | $this->lexer->moveNext(); 74 | continue; 75 | } 76 | 77 | // make sure the @ is followed by either a namespace separator, or 78 | // an identifier token 79 | $peek = $this->lexer->glimpse(); 80 | if ( 81 | ($peek === null) 82 | || $peek['position'] !== $this->lexer->lookahead['position'] + 1 83 | || ($peek['type'] !== DocLexer::T_NAMESPACE_SEPARATOR && ! in_array( 84 | $peek['type'], 85 | self::CLASS_IDENTIFIERS, 86 | true, 87 | )) 88 | ) { 89 | $this->lexer->moveNext(); 90 | continue; 91 | } 92 | 93 | $annot = $this->annotation(); 94 | if ($annot === false) { 95 | continue; 96 | } 97 | 98 | $annotations[] = $annot; 99 | } 100 | 101 | if (! $annotations) { 102 | return null; 103 | } 104 | 105 | $node->setAttribute('annotations', $annotations); 106 | 107 | return null; 108 | } 109 | 110 | private function findInitialTokenPosition(string $input): int|null 111 | { 112 | $pos = 0; 113 | 114 | // search for first valid annotation 115 | while (($pos = strpos($input, '@', $pos)) !== false) { 116 | $preceding = $input[$pos - 1]; 117 | 118 | // if the @ is preceded by a space, a tab or * it is valid 119 | if ($pos === 0 || $preceding === ' ' || $preceding === '*' || $preceding === "\t") { 120 | return $pos; 121 | } 122 | 123 | $pos++; 124 | } 125 | 126 | return null; 127 | } 128 | 129 | private function annotation(): false|Node\Name 130 | { 131 | assert($this->lexer->isNextToken(DocLexer::T_AT)); 132 | $this->lexer->moveNext(); 133 | 134 | // check if we have an annotation 135 | $name = $this->identifier(); 136 | if ($name === null) { 137 | return false; 138 | } 139 | 140 | if ( 141 | $this->lexer->isNextToken(DocLexer::T_MINUS) 142 | && $this->lexer->nextTokenIsAdjacent() 143 | ) { 144 | // Annotations with dashes, such as "@foo-" or "@foo-bar", are to be discarded 145 | return false; 146 | } 147 | 148 | $name = ($this->nameResolver)($name); 149 | if ($this->lexer->isNextToken(DocLexer::T_OPEN_PARENTHESIS)) { 150 | $level = 1; 151 | while ($level) { 152 | $this->lexer->moveNext(); 153 | if ($this->lexer->isNextToken(DocLexer::T_OPEN_PARENTHESIS)) { 154 | ++$level; 155 | } elseif ($this->lexer->isNextToken(DocLexer::T_CLOSE_PARENTHESIS)) { 156 | --$level; 157 | } 158 | } 159 | } 160 | 161 | return $name; 162 | } 163 | 164 | private function identifier(): Node\Name|null 165 | { 166 | // check if we have an annotation 167 | if (! $this->lexer->isNextTokenAny(self::CLASS_IDENTIFIERS)) { 168 | return null; 169 | } 170 | 171 | $this->lexer->moveNext(); 172 | $className = $this->lexer->token['value']; /** @phpstan-ignore-line */ 173 | 174 | while ( 175 | $this->lexer->lookahead !== null && 176 | $this->lexer->lookahead['position'] === ($this->lexer->token['position'] + /** @phpstan-ignore-line */ 177 | strlen($this->lexer->token['value'])) && /** @phpstan-ignore-line */ 178 | $this->lexer->isNextToken(DocLexer::T_NAMESPACE_SEPARATOR) 179 | ) { 180 | if (! $this->lexer->isNextToken(DocLexer::T_NAMESPACE_SEPARATOR)) { 181 | return null; 182 | } 183 | 184 | $this->lexer->moveNext(); 185 | 186 | if (! $this->lexer->isNextTokenAny(self::CLASS_IDENTIFIERS)) { 187 | return null; 188 | } 189 | 190 | $this->lexer->moveNext(); 191 | $className .= '\\' . $this->lexer->token['value']; /** @phpstan-ignore-line */ 192 | } 193 | 194 | return new Node\Name($className); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/unit/Util/ClassMapTest.php: -------------------------------------------------------------------------------- 1 | new ReflectionClass(Psr4\BarBar::class), 28 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 29 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 30 | Psr4\FooInterface::class => new ReflectionClass(Psr4\FooInterface::class), 31 | Psr4\FooTrait::class => new ReflectionClass(Psr4\FooTrait::class), 32 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 33 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 34 | ], iterator_to_array($classMap)); 35 | } 36 | 37 | public function testCreateFinder(): void 38 | { 39 | $finder = new Psr4Finder('Kcs\ClassFinder\Fixtures\Psr4', __DIR__ . '/../../../data/Composer/Psr4'); 40 | $classMap = ClassMap::fromFinder($finder); 41 | 42 | $finder = $classMap->createFinder()->skipNonInstantiable(); 43 | 44 | self::assertEquals([ 45 | Psr4\BarBar::class => new ReflectionClass(Psr4\BarBar::class), 46 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 47 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 48 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 49 | ], iterator_to_array($finder)); 50 | } 51 | 52 | public function testGetMap(): void 53 | { 54 | $finder = new Psr4Finder('Kcs\ClassFinder\Fixtures\Psr4', __DIR__ . '/../../../data/Composer/Psr4'); 55 | $classMap = ClassMap::fromFinder($finder); 56 | 57 | self::assertEquals([ 58 | Psr4\BarBar::class => realpath(__DIR__ . '/../../../data/Composer/Psr4/BarBar.php'), 59 | Psr4\Foobar::class => realpath(__DIR__ . '/../../../data/Composer/Psr4/Foobar.php'), 60 | Psr4\AbstractClass::class => realpath(__DIR__ . '/../../../data/Composer/Psr4/AbstractClass.php'), 61 | Psr4\FooInterface::class => realpath(__DIR__ . '/../../../data/Composer/Psr4/FooInterface.php'), 62 | Psr4\FooTrait::class => realpath(__DIR__ . '/../../../data/Composer/Psr4/FooTrait.php'), 63 | Psr4\SubNs\FooBaz::class => realpath(__DIR__ . '/../../../data/Composer/Psr4/SubNs/FooBaz.php'), 64 | Psr4\Foobarbar::class => realpath(__DIR__ . '/../../../data/Composer/Psr4/Foobarbar.php'), 65 | ], $classMap->getMap()); 66 | } 67 | 68 | public function testGetMapRelative(): void 69 | { 70 | $finder = new Psr4Finder('Kcs\ClassFinder\Fixtures\Psr4', __DIR__ . '/../../../data/Composer/Psr4'); 71 | $classMap = ClassMap::fromFinder($finder); 72 | 73 | self::assertEquals([ 74 | Psr4\BarBar::class => PathNormalizer::resolvePath('data/Composer/Psr4/BarBar.php'), 75 | Psr4\Foobar::class => PathNormalizer::resolvePath('data/Composer/Psr4/Foobar.php'), 76 | Psr4\AbstractClass::class => PathNormalizer::resolvePath('data/Composer/Psr4/AbstractClass.php'), 77 | Psr4\FooInterface::class => PathNormalizer::resolvePath('data/Composer/Psr4/FooInterface.php'), 78 | Psr4\FooTrait::class => PathNormalizer::resolvePath('data/Composer/Psr4/FooTrait.php'), 79 | Psr4\SubNs\FooBaz::class => PathNormalizer::resolvePath('data/Composer/Psr4/SubNs/FooBaz.php'), 80 | Psr4\Foobarbar::class => PathNormalizer::resolvePath('data/Composer/Psr4/Foobarbar.php'), 81 | ], $classMap->getMap(__DIR__ . '/../../../')); 82 | } 83 | 84 | public function testClassmapFromPhpDocumentorFinder(): void 85 | { 86 | $finder = new PhpDocumentorFinder(__DIR__ . '/../../../data/Composer/Psr4'); 87 | $classMap = ClassMap::fromFinder($finder); 88 | 89 | self::assertEquals([ 90 | Psr4\BarBar::class => PathNormalizer::resolvePath('data/Composer/Psr4/BarBar.php'), 91 | Psr4\Foobar::class => PathNormalizer::resolvePath('data/Composer/Psr4/Foobar.php'), 92 | Psr4\AbstractClass::class => PathNormalizer::resolvePath('data/Composer/Psr4/AbstractClass.php'), 93 | Psr4\FooInterface::class => PathNormalizer::resolvePath('data/Composer/Psr4/FooInterface.php'), 94 | Psr4\FooTrait::class => PathNormalizer::resolvePath('data/Composer/Psr4/FooTrait.php'), 95 | Psr4\SubNs\FooBaz::class => PathNormalizer::resolvePath('data/Composer/Psr4/SubNs/FooBaz.php'), 96 | Psr4\Foobarbar::class => PathNormalizer::resolvePath('data/Composer/Psr4/Foobarbar.php'), 97 | Psr4\HiddenClass::class => PathNormalizer::resolvePath('data/Composer/Psr4/NotAClass.php'), 98 | ], $classMap->getMap(__DIR__ . '/../../../')); 99 | } 100 | 101 | public function testClassmapFromPhpParserFinder(): void 102 | { 103 | $finder = new PhpParserFinder(__DIR__ . '/../../../data/Composer/Psr4'); 104 | $classMap = ClassMap::fromFinder($finder); 105 | 106 | self::assertEquals([ 107 | Psr4\BarBar::class => PathNormalizer::resolvePath('data/Composer/Psr4/BarBar.php'), 108 | Psr4\Foobar::class => PathNormalizer::resolvePath('data/Composer/Psr4/Foobar.php'), 109 | Psr4\AbstractClass::class => PathNormalizer::resolvePath('data/Composer/Psr4/AbstractClass.php'), 110 | Psr4\FooInterface::class => PathNormalizer::resolvePath('data/Composer/Psr4/FooInterface.php'), 111 | Psr4\FooTrait::class => PathNormalizer::resolvePath('data/Composer/Psr4/FooTrait.php'), 112 | Psr4\SubNs\FooBaz::class => PathNormalizer::resolvePath('data/Composer/Psr4/SubNs/FooBaz.php'), 113 | Psr4\Foobarbar::class => PathNormalizer::resolvePath('data/Composer/Psr4/Foobarbar.php'), 114 | Psr4\HiddenClass::class => PathNormalizer::resolvePath('data/Composer/Psr4/NotAClass.php'), 115 | ], $classMap->getMap(__DIR__ . '/../../../')); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/unit/Finder/RecursiveFinderTest.php: -------------------------------------------------------------------------------- 1 | inNamespace(['Kcs\ClassFinder\Fixtures\Psr4']); 37 | 38 | $classes = iterator_to_array($finder); 39 | 40 | self::assertEquals([ 41 | Psr4\BarBar::class => new ReflectionClass(Psr4\BarBar::class), 42 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 43 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 44 | Psr4\FooInterface::class => new ReflectionClass(Psr4\FooInterface::class), 45 | Psr4\FooTrait::class => new ReflectionClass(Psr4\FooTrait::class), 46 | Psr4\HiddenClass::class => new ReflectionClass(Psr4\HiddenClass::class), 47 | Psr4\SubNs\FooBaz::class => new ReflectionClass(Psr4\SubNs\FooBaz::class), 48 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 49 | ], $classes); 50 | } 51 | 52 | public function testFinderShouldFilterByDirectory(): void 53 | { 54 | $finder = new RecursiveFinder(__DIR__ . '/../../../data/Composer'); 55 | $finder->in([__DIR__ . '/../../../data/Composer/Psr0']); 56 | 57 | self::assertEquals([ 58 | Psr0\BarBar::class => new ReflectionClass(Psr0\BarBar::class), 59 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 60 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 61 | Logger::class => new ReflectionClass(Logger::class), 62 | LoggerInterface::class => new ReflectionClass(LoggerInterface::class), 63 | FallbackNamespace0\MyClass::class => new ReflectionClass(FallbackNamespace0\MyClass::class), 64 | ], iterator_to_array($finder)); 65 | } 66 | 67 | public function testFinderShouldFilterByInterfaceImplementation(): void 68 | { 69 | $finder = new RecursiveFinder(__DIR__ . '/../../../data/Composer'); 70 | $finder->implementationOf(Psr4\FooInterface::class); 71 | 72 | self::assertEquals([ 73 | Psr4\BarBar::class => new ReflectionClass(Psr4\BarBar::class), 74 | Psr0\BarBar::class => new ReflectionClass(Psr0\BarBar::class), 75 | ], iterator_to_array($finder)); 76 | } 77 | 78 | public function testFinderShouldFilterBySuperClass(): void 79 | { 80 | $finder = new RecursiveFinder(__DIR__ . '/../../../data/Composer'); 81 | $finder->subclassOf(Psr4\AbstractClass::class); 82 | 83 | self::assertEquals([ 84 | Psr4\Foobar::class => new ReflectionClass(Psr4\Foobar::class), 85 | Psr0\Foobar::class => new ReflectionClass(Psr0\Foobar::class), 86 | Psr4\Foobarbar::class => new ReflectionClass(Psr4\Foobarbar::class), 87 | ], iterator_to_array($finder)); 88 | } 89 | 90 | public function testFinderShouldFilterByAnnotation(): void 91 | { 92 | $finder = new RecursiveFinder(__DIR__ . '/../../../data/Composer'); 93 | $finder->annotatedBy(Psr4\SubNs\FooBaz::class); 94 | 95 | self::assertEquals([ 96 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 97 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 98 | ], iterator_to_array($finder)); 99 | } 100 | 101 | public function testFinderShouldFilterByAttribute(): void 102 | { 103 | $finder = new RecursiveFinder(__DIR__ . '/../../../data/Composer'); 104 | $finder->withAttribute(Psr4\SubNs\FooBaz::class); 105 | 106 | self::assertEquals([ 107 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 108 | Psr0\SubNs\FooBaz::class => new ReflectionClass(Psr0\SubNs\FooBaz::class), 109 | ], iterator_to_array($finder)); 110 | } 111 | 112 | public function testFinderShouldFilterByCallback(): void 113 | { 114 | $finder = new RecursiveFinder(__DIR__ . '/../../../data/Composer'); 115 | $finder->filter(static function (ReflectionClass $class) { 116 | return $class->getName() === Psr4\AbstractClass::class; 117 | }); 118 | 119 | self::assertEquals([ 120 | Psr4\AbstractClass::class => new ReflectionClass(Psr4\AbstractClass::class), 121 | ], iterator_to_array($finder)); 122 | } 123 | 124 | public function testFinderShouldFilterByIteratorCallback(): void 125 | { 126 | $finder = new RecursiveFinder(__DIR__ . '/../../../data/Recursive'); 127 | $finder->pathFilter(static function (string $path): bool { 128 | return ! str_starts_with(basename($path), 'class-'); 129 | }); 130 | 131 | $classes = iterator_to_array($finder); 132 | 133 | self::assertEquals([ 134 | Recursive\Bar::class => new ReflectionClass(Recursive\Bar::class), 135 | Recursive\Foo::class => new ReflectionClass(Recursive\Foo::class), 136 | ], $classes); 137 | } 138 | 139 | public function testFinderShouldUseGivenFileFinder(): void 140 | { 141 | $finder = new RecursiveFinder(__DIR__ . '/../../../data/Recursive'); 142 | $fileFinder = $this->createMock(FileFinderInterface::class); 143 | $finder->withFileFinder($fileFinder); 144 | 145 | $fileFinder->expects(self::once()) 146 | ->method('search') 147 | ->willReturn([ 148 | realpath(__DIR__ . '/../../../data/Recursive/Foo.php') => new SplFileInfo(realpath(__DIR__ . '/../../../data/Recursive/Foo.php')), 149 | realpath(__DIR__ . '/../../../data/Recursive/class-foo-bar.php') => new SplFileInfo(realpath(__DIR__ . '/../../../data/Recursive/class-foo-bar.php')), 150 | ]); 151 | 152 | $classes = iterator_to_array($finder); 153 | 154 | self::assertEquals([ 155 | Recursive\Foo::class => new ReflectionClass(Recursive\Foo::class), 156 | Recursive\FooBar::class => new ReflectionClass(Recursive\FooBar::class), 157 | ], $classes); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /lib/Iterator/PhpParserIterator.php: -------------------------------------------------------------------------------- 1 | path = PathNormalizer::resolvePath($path); 45 | $this->parser = (new ParserFactory())->createForHostVersion(); 46 | $this->traverser = new NodeTraverser(); 47 | $this->traverser->addVisitor($nameResolver = new NameResolver()); 48 | if (class_exists(AnnotationParser::class)) { 49 | $this->traverser->addVisitor(new AnnotationParser($nameResolver)); 50 | } 51 | 52 | parent::__construct($flags, $excludeNamespaces, $pathCallback); 53 | } 54 | 55 | protected function isInstantiable(mixed $reflector): bool 56 | { 57 | return $reflector instanceof Stmt\Class_ && ! $reflector->isAbstract(); 58 | } 59 | 60 | protected function getGenerator(): Generator 61 | { 62 | $symbols = $files = []; 63 | 64 | foreach ($this->search() as $path => $info) { 65 | if (! preg_match(self::EXTENSION_PATTERN, $path, $m) || ! $info->isReadable()) { 66 | continue; 67 | } 68 | 69 | $code = file_get_contents($path); 70 | if ($code === false) { 71 | continue; 72 | } 73 | 74 | try { 75 | $stmts = $this->parser->parse($code); 76 | } catch (Error) { 77 | continue; 78 | } 79 | 80 | $nodeVisitor = new class extends NodeVisitorAbstract { 81 | /** @var Stmt\ClassLike[] */ 82 | public array $reflections = []; 83 | 84 | public function leaveNode(Node $node): Node|null 85 | { 86 | if (! ($node instanceof Stmt\ClassLike)) { 87 | return null; 88 | } 89 | 90 | $this->reflections[] = $node; 91 | 92 | return null; 93 | } 94 | }; 95 | 96 | $this->traverser->addVisitor($nodeVisitor); 97 | $this->traverser->traverse($stmts ?? []); 98 | $this->traverser->removeVisitor($nodeVisitor); 99 | 100 | $fileSymbols = array_map( 101 | static function (Stmt\ClassLike $class) use (&$symbols): object { 102 | return $symbols[(string) $class->namespacedName] = $class; 103 | }, 104 | $nodeVisitor->reflections, 105 | ); 106 | 107 | if (! $this->accept(PathNormalizer::resolvePath($path))) { 108 | continue; 109 | } 110 | 111 | $files[$path] = $fileSymbols; 112 | } 113 | 114 | foreach ($files as $path => $fileSymbols) { 115 | assert(is_string($path)); 116 | 117 | foreach ($fileSymbols as $fileSymbol) { 118 | if ($fileSymbol instanceof Stmt\Class_) { 119 | $parents = []; 120 | $interfaces = []; 121 | $this->processInterfaces($interfaces, $fileSymbol->implements, $symbols); 122 | 123 | $parent = $fileSymbol; 124 | while (($parentName = $parent->extends)) { 125 | $parent = $symbols[(string) $parentName] ?? null; 126 | if ($parent === null) { 127 | break; // We don't have information on the parent class. 128 | } 129 | 130 | assert($parent instanceof Stmt\Class_); 131 | $parents[] = $parent; 132 | $this->processInterfaces($interfaces, $parent->implements, $symbols); 133 | } 134 | 135 | $fileSymbol->setAttribute(Metadata::METADATA_KEY, new Metadata($path, [...$parents, ...array_values($interfaces)])); 136 | } elseif ($fileSymbol instanceof Stmt\Interface_) { 137 | $interfaces = []; 138 | $this->processInterfaces($interfaces, $fileSymbol->extends, $symbols); 139 | $fileSymbol->setAttribute(Metadata::METADATA_KEY, new Metadata($path, array_values($interfaces))); 140 | } elseif ($fileSymbol instanceof Stmt\Trait_ || $fileSymbol instanceof Stmt\Enum_) { 141 | $fileSymbol->setAttribute(Metadata::METADATA_KEY, new Metadata($path, [])); 142 | } 143 | } 144 | 145 | yield from $this->processNodes($fileSymbols); 146 | } 147 | } 148 | 149 | /** 150 | * Processes classes array. 151 | * 152 | * @param Stmt\ClassLike[] $stmts 153 | * 154 | * @return Generator 155 | */ 156 | private function processNodes(array $stmts): Generator 157 | { 158 | foreach ($stmts as $stmt) { 159 | $className = (string) ($stmt->namespacedName ?? $stmt->name); 160 | if (! $this->validNamespace($className)) { 161 | continue; 162 | } 163 | 164 | yield ltrim($className, '\\') => $stmt; 165 | } 166 | } 167 | 168 | /** 169 | * @param Stmt\Interface_[] $interfaces 170 | * @param Node\Name[] $parents 171 | * @param array $symbols 172 | */ 173 | private function processInterfaces(array &$interfaces, array $parents, array &$symbols): void 174 | { 175 | while ($parents) { 176 | $currentLevel = []; 177 | foreach ($parents as $parentName) { 178 | $p = $symbols[$parentName->toString()] ?? null; 179 | if (! $p instanceof Stmt\Interface_) { 180 | continue; 181 | } 182 | 183 | $interfaces[$parentName->toString()] = $p; 184 | array_push($currentLevel, ...$p->extends); 185 | } 186 | 187 | $parents = $currentLevel; 188 | } 189 | } 190 | } 191 | --------------------------------------------------------------------------------