diff --git a/config/services.php b/config/services.php index a0de735ff..65a5b65ae 100644 --- a/config/services.php +++ b/config/services.php @@ -70,6 +70,7 @@ use Qossmic\Deptrac\Core\Layer\Collector\InterfaceCollector; use Qossmic\Deptrac\Core\Layer\Collector\LayerCollector; use Qossmic\Deptrac\Core\Layer\Collector\MethodCollector; +use Qossmic\Deptrac\Core\Layer\Collector\PackageNameCollector; use Qossmic\Deptrac\Core\Layer\Collector\PhpInternalCollector; use Qossmic\Deptrac\Core\Layer\Collector\SuperglobalCollector; use Qossmic\Deptrac\Core\Layer\Collector\TraitCollector; @@ -291,6 +292,9 @@ $services ->set(MethodCollector::class) ->tag('collector', ['type' => CollectorType::TYPE_METHOD->value]); + $services + ->set(PackageNameCollector::class) + ->tag('collector', ['type' => CollectorType::TYPE_PACKAGE_NAME->value]); $services ->set(SuperglobalCollector::class) ->tag('collector', ['type' => CollectorType::TYPE_SUPERGLOBAL->value]); diff --git a/docs/collectors.md b/docs/collectors.md index bdb660c10..511256258 100644 --- a/docs/collectors.md +++ b/docs/collectors.md @@ -267,6 +267,24 @@ deptrac: Every class having a method that matches the regular expression `.*foo`, e.g. `getFoo()` or `setFoo()` becomes a part of the *Foo services* layer. +## `packageName` Collector + +The `packageName` collector allows collecting classes and anything similar to +classes like interfaces, traits or enums, based on a `@package` annotation in +their docblock. Any matching class-like will be added to the assigned layer. + +```yaml +deptrac: + layers: + - name: FooPackages + collectors: + - type: packageName + value: ^Foo$ +``` + +Every class-like with an annotation of `@package Foo` in their docblock will +become part of the *FooPackages* layer. + ## `superglobal` Collector The `superglobal` collector allows collecting superglobal PHP variables matching diff --git a/src/Contract/Config/CollectorType.php b/src/Contract/Config/CollectorType.php index 44d678e3f..94b507564 100644 --- a/src/Contract/Config/CollectorType.php +++ b/src/Contract/Config/CollectorType.php @@ -22,6 +22,7 @@ enum CollectorType: string case TYPE_INTERFACE = 'interface'; case TYPE_LAYER = 'layer'; case TYPE_METHOD = 'method'; + case TYPE_PACKAGE_NAME = 'packageName'; case TYPE_SUPERGLOBAL = 'superglobal'; case TYPE_TRAIT = 'trait'; case TYPE_USES = 'uses'; diff --git a/src/Core/Layer/Collector/PackageNameCollector.php b/src/Core/Layer/Collector/PackageNameCollector.php new file mode 100644 index 000000000..3cbe03ed1 --- /dev/null +++ b/src/Core/Layer/Collector/PackageNameCollector.php @@ -0,0 +1,95 @@ +lexer = new Lexer(); + $this->docParser = new PhpDocParser(new TypeParser(), new ConstExprParser()); + } + + public function satisfy(array $config, TokenReferenceInterface $reference): bool + { + if (!$reference instanceof ClassLikeReference) { + return false; + } + + $regex = $this->getValidatedPattern($config); + + foreach ($this->getPackages($reference) as $package) { + if (1 === preg_match($regex, $package)) { + return true; + } + } + + return false; + } + + protected function getPattern(array $config): string + { + if (!isset($config['value']) || !is_string($config['value'])) { + throw new InvalidCollectorDefinitionException('PackageNameCollector needs the value configuration.'); + } + + return '/'.$config['value'].'/im'; + } + + /** + * @return array + * @throws CouldNotParseFileException + */ + private function getPackages(ClassLikeReference $reference): array + { + $docBlock = $this->getCommentDoc($reference); + + if (!$docBlock) { + return []; + } + + $tokens = new TokenIterator($this->lexer->tokenize($docBlock)); + $docNode = $this->docParser->parse($tokens); + + return array_map( + static fn (PhpDocTagNode $node) => (string) $node->value, + $docNode->getTagsByName('@package') + ); + } + + /** + * @throws CouldNotParseFileException + */ + private function getCommentDoc(ClassLikeReference $reference): string + { + $node = $this->nikicPhpParser->getNodeForClassLikeReference($reference); + + if (null === $node) { + return ''; + } + + $doc = $node->getDocComment(); + + if (null === $doc) { + return ''; + } + + return $doc->getText(); + } +} diff --git a/tests/Core/Layer/Collector/PackageNameCollectorTest.php b/tests/Core/Layer/Collector/PackageNameCollectorTest.php new file mode 100644 index 000000000..1f5b82d62 --- /dev/null +++ b/tests/Core/Layer/Collector/PackageNameCollectorTest.php @@ -0,0 +1,122 @@ +astParser = $this->createMock(NikicPhpParser::class); + + $this->collector = new PackageNameCollector($this->astParser); + } + + public function provideSatisfy(): iterable + { + yield [ + ['value' => 'abc'], + $this->getPackageDocBlock(['abc', 'abcdef', 'xyz']), + true, + ]; + + yield [ + ['value' => 'abc'], + $this->getPackageDocBlock(['abc', 'xyz']), + true, + ]; + + yield [ + ['value' => 'abc'], + $this->getPackageDocBlock(['xyz']), + false, + ]; + } + + /** + * @dataProvider provideSatisfy + */ + public function testSatisfy(array $configuration, Doc $docBlock, bool $expected): void + { + $astClassReference = new ClassLikeReference(ClassLikeToken::fromFQCN('foo')); + + $classLike = $this->createMock(Node\Stmt\ClassLike::class); + $classLike->method('getDocComment')->willReturn($docBlock); + + $this->astParser + ->method('getNodeForClassLikeReference') + ->with($astClassReference) + ->willReturn($classLike); + + $actual = $this->collector->satisfy( + $configuration, + $astClassReference, + ); + + self::assertSame($expected, $actual); + } + + public function testClassLikeAstNotFoundDoesNotSatisfy(): void + { + $astClassReference = new ClassLikeReference(ClassLikeToken::fromFQCN('foo')); + $this->astParser + ->method('getNodeForClassLikeReference') + ->with($astClassReference) + ->willReturn(null); + + $actual = $this->collector->satisfy( + ['value' => 'abc'], + $astClassReference, + ); + + self::assertFalse($actual); + } + + public function testMissingValueThrowsException(): void + { + $astClassReference = new ClassLikeReference(ClassLikeToken::fromFQCN('foo')); + + $this->expectException(InvalidCollectorDefinitionException::class); + $this->expectExceptionMessage('PackageNameCollector needs the value configuration.'); + + $this->collector->satisfy( + [], + $astClassReference, + ); + } + + public function testInvalidRegexParam(): void + { + $astClassReference = new ClassLikeReference(ClassLikeToken::fromFQCN('foo')); + + $this->expectException(InvalidCollectorDefinitionException::class); + + $this->collector->satisfy( + ['value' => '/'], + $astClassReference, + ); + } + + private function getPackageDocBlock(array $packageNames): Doc + { + return new Doc(sprintf( + " /**\n%s */", + implode('', array_map(static fn ($packageName) => ' * @package '.$packageName."\n", $packageNames)) + )); + } +}