From a37047b0f30a3c03c07c38782271f756d6167ae1 Mon Sep 17 00:00:00 2001 From: Michele Orselli Date: Fri, 23 May 2025 00:00:04 +0200 Subject: [PATCH 1/3] wip --- src/Analyzer/Docblock.php | 12 ++++ src/Analyzer/DocblockParserFactory.php | 2 +- src/Analyzer/DocblockTypesResolver.php | 62 ++++++++++++++++--- src/Analyzer/FileVisitor.php | 16 +++++ tests/Unit/Analyzer/DocblockParserTest.php | 21 +++++++ .../Analyzer/DocblockTypesResolverTest.php | 15 ++++- 6 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/Analyzer/Docblock.php b/src/Analyzer/Docblock.php index 63cdd2f1..1c9f63a5 100644 --- a/src/Analyzer/Docblock.php +++ b/src/Analyzer/Docblock.php @@ -6,6 +6,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ThrowsTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; @@ -43,6 +44,17 @@ public function getReturnTagTypes(): array return array_filter($returnTypes); } + public function getThrowTagsTypes(): array + { + $throwTypes = array_map( + fn (ThrowsTagValueNode $throwTag) => $this->getType($throwTag->type), + $this->phpDocNode->getThrowsTagValues() + ); + + // remove null values + return array_filter($throwTypes); + } + public function getVarTagTypes(): array { $varTypes = array_map( diff --git a/src/Analyzer/DocblockParserFactory.php b/src/Analyzer/DocblockParserFactory.php index cfabbc3b..abdf2bd2 100644 --- a/src/Analyzer/DocblockParserFactory.php +++ b/src/Analyzer/DocblockParserFactory.php @@ -22,7 +22,7 @@ public static function create(): DocblockParser // this if is to allow using v 1.2 or v2 if (class_exists(ParserConfig::class)) { - $parserConfig = new ParserConfig([]); + $parserConfig = new ParserConfig(['lines' => true]); $constExprParser = new ConstExprParser($parserConfig); $typeParser = new TypeParser($parserConfig, $constExprParser); $phpDocParser = new PhpDocParser($parserConfig, $typeParser, $constExprParser); diff --git a/src/Analyzer/DocblockTypesResolver.php b/src/Analyzer/DocblockTypesResolver.php index 498ca707..351d58c6 100644 --- a/src/Analyzer/DocblockTypesResolver.php +++ b/src/Analyzer/DocblockTypesResolver.php @@ -25,6 +25,8 @@ */ class DocblockTypesResolver extends NodeVisitorAbstract { + public const THROWS_TYPES_ATTRIBUTE = 'docblock_throws_types'; + private NameContext $nameContext; private bool $parseCustomAnnotations; @@ -137,18 +139,62 @@ private function resolveFunctionTypes(Node $node): void $param->type = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL); } - // extract return type from return tag - if ($this->isTypeArray($node->returnType)) { - $type = $docblock->getReturnTagTypes(); - $type = array_pop($type); + $this->resolveReturnValueType($node, $docblock); - // we ignore any type which is not a class - if (!$this->isTypeClass($type)) { - return; + $this->resolveThrowsValueType($node, $docblock); + } + + /** + * @param Stmt\ClassMethod|Stmt\Function_|Expr\Closure|Expr\ArrowFunction $node + */ + private function resolveReturnValueType(Node $node, Docblock $docblock): void + { + if (null === $node->returnType) { + return; + } + + if (!$this->isTypeArray($node->returnType)) { + return; + } + + $type = $docblock->getReturnTagTypes(); + $type = array_pop($type); + + // we ignore any type which is not a class + if (!$this->isTypeClass($type)) { + return; + } + + $node->returnType = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL); + } + + /** + * @param Stmt\ClassMethod|Stmt\Function_|Expr\Closure|Expr\ArrowFunction $node + */ + private function resolveThrowsValueType(Node $node, Docblock $docblock): void + { + // extract throw types from throw tag + $throwValues = $docblock->getThrowTagsTypes(); + + if (empty($throwValues)) { + return; + } + + $throwsTypesResolved = []; + + foreach ($throwValues as $throwValue) { + if (str_starts_with($throwValue, '\\')) { + $name = new FullyQualified(substr($throwValue, 1)); + } else { + $name = $this->resolveName(new Name($throwValue), Stmt\Use_::TYPE_NORMAL); } - $node->returnType = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL); + $name->setAttribute('startLine', $node->getStartLine()); + + $throwsTypesResolved[] = $name; } + + $node->setAttribute(self::THROWS_TYPES_ATTRIBUTE, $throwsTypesResolved); } /** diff --git a/src/Analyzer/FileVisitor.php b/src/Analyzer/FileVisitor.php index 7ffedda9..72c0e1f9 100644 --- a/src/Analyzer/FileVisitor.php +++ b/src/Analyzer/FileVisitor.php @@ -67,6 +67,9 @@ public function enterNode(Node $node): void // handles attribute definition like #[MyAttribute] $this->handleAttributeNode($node); + + // handles throws types like @throws MyClass + $this->handleThrowsTags($node); } public function getClassDescriptions(): array @@ -334,6 +337,19 @@ private function handleAttributeNode(Node $node): void ->addAttribute($node->name->toString(), $node->getLine()); } + private function handleThrowsTags(Node $node): void + { + if (!$node->hasAttribute(DocblockTypesResolver::THROWS_TYPES_ATTRIBUTE)) { + return; + } + + /** @var Node\Name\FullyQualified $throw */ + foreach ($node->getAttribute(DocblockTypesResolver::THROWS_TYPES_ATTRIBUTE) as $throw) { + $this->classDescriptionBuilder + ->addDependency(new ClassDependency($throw->toString(), $throw->getLine())); + } + } + private function addParamDependency(Node\Param $node): void { if (null === $node->type || $node->type instanceof Node\Identifier) { diff --git a/tests/Unit/Analyzer/DocblockParserTest.php b/tests/Unit/Analyzer/DocblockParserTest.php index 163be91e..c72bd584 100644 --- a/tests/Unit/Analyzer/DocblockParserTest.php +++ b/tests/Unit/Analyzer/DocblockParserTest.php @@ -98,6 +98,27 @@ public function test_it_should_extract_types_from_var_tag(): void self::assertEquals('(int | string)', $varTags[6]); } + public function test_it_should_extract_types_from_throws_tag(): void + { + $parser = DocblockParserFactory::create(); + + $code = <<< 'PHP' + /** + * @throws \Exception + * @throws \Domain\Foo\FooException + * @throws BarException + */ + PHP; + + $db = $parser->parse($code); + + $varTags = $db->getThrowTagsTypes(); + self::assertCount(3, $varTags); + self::assertEquals('\Exception', $varTags[0]); + self::assertEquals('\Domain\Foo\FooException', $varTags[1]); + self::assertEquals('BarException', $varTags[2]); + } + public function test_it_should_extract_doctrine_like_annotations(): void { $parser = DocblockParserFactory::create(); diff --git a/tests/Unit/Analyzer/DocblockTypesResolverTest.php b/tests/Unit/Analyzer/DocblockTypesResolverTest.php index c80067f4..7b7db4d4 100644 --- a/tests/Unit/Analyzer/DocblockTypesResolverTest.php +++ b/tests/Unit/Analyzer/DocblockTypesResolverTest.php @@ -66,8 +66,14 @@ public function myMethod(array $users, array $products, MyOtherClass $other): vo * @param array $users * * @return array + * + * @throws \Exception + * @throws \Domain\Foo\FooException + * @throws BarException */ public function myMethod2(array $aParam, array $users): array + { + } } EOF; @@ -76,13 +82,18 @@ public function myMethod2(array $aParam, array $users): array $cd = $parser->getClassDescriptions()[0]; $dep = $cd->getDependencies(); - self::assertCount(7, $cd->getDependencies()); + self::assertCount(10, $cd->getDependencies()); self::assertEquals('Application\Model\User', $dep[0]->getFQCN()->toString()); self::assertEquals('Application\MyDto', $dep[1]->getFQCN()->toString()); self::assertEquals('Domain\ValueObject', $dep[2]->getFQCN()->toString()); self::assertEquals('Application\Model\User', $dep[3]->getFQCN()->toString()); self::assertEquals('Application\Model\Product', $dep[4]->getFQCN()->toString()); self::assertEquals('Domain\Foo\MyOtherClass', $dep[5]->getFQCN()->toString()); - self::assertEquals('Application\Model\User', $dep[6]->getFQCN()->toString()); + self::assertEquals('Exception', $dep[6]->getFQCN()->toString()); + self::assertEquals('Domain\Foo\FooException', $dep[7]->getFQCN()->toString()); + self::assertEquals('Domain\Foo\BarException', $dep[8]->getFQCN()->toString()); + + self::assertEquals('Application\Model\User', $dep[9]->getFQCN()->toString()); + self::assertEquals(46, $dep[9]->getLine()); } } From 01c1d11074b824284cc4612886c951442e9b5170 Mon Sep 17 00:00:00 2001 From: Michele Orselli Date: Fri, 23 May 2025 00:09:34 +0200 Subject: [PATCH 2/3] wip --- src/Analyzer/DocblockTypesResolver.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Analyzer/DocblockTypesResolver.php b/src/Analyzer/DocblockTypesResolver.php index 351d58c6..f36f1b35 100644 --- a/src/Analyzer/DocblockTypesResolver.php +++ b/src/Analyzer/DocblockTypesResolver.php @@ -119,6 +119,18 @@ private function resolveFunctionTypes(Node $node): void return; } + $this->resolveParamTypes($node, $docblock); + + $this->resolveReturnValueType($node, $docblock); + + $this->resolveThrowsValueType($node, $docblock); + } + + /** + * @param Stmt\ClassMethod|Stmt\Function_|Expr\Closure|Expr\ArrowFunction $node + */ + private function resolveParamTypes(Node $node, Docblock $docblock): void + { // extract param types from param tags foreach ($node->params as $param) { if (!$this->isTypeArray($param->type)) { // not an array, nothing to do @@ -138,10 +150,6 @@ private function resolveFunctionTypes(Node $node): void $param->type = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL); } - - $this->resolveReturnValueType($node, $docblock); - - $this->resolveThrowsValueType($node, $docblock); } /** From fa33a582b20077eda8af57ecb52aee582b101d43 Mon Sep 17 00:00:00 2001 From: Pietro Campagnano Date: Fri, 19 Dec 2025 18:47:30 +0100 Subject: [PATCH 3/3] Add integration tests for @throws docblock dependency collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two integration tests to verify that dependencies defined in @throws docblock tags are properly collected: - test_it_collects_throws_tag_as_dependencies: Tests collection of relative and fully qualified exception names in @throws tags - test_it_collects_throws_tag_with_fully_qualified_names: Tests handling of different naming conventions (backslash-prefixed, relative to namespace) These tests validate that the @throws tag parsing feature works end-to-end and that exceptions are properly resolved to their fully qualified names. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- .../FileParser/CanParseDocblocksTest.php | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/Unit/Analyzer/FileParser/CanParseDocblocksTest.php b/tests/Unit/Analyzer/FileParser/CanParseDocblocksTest.php index d0c7c201..12567338 100644 --- a/tests/Unit/Analyzer/FileParser/CanParseDocblocksTest.php +++ b/tests/Unit/Analyzer/FileParser/CanParseDocblocksTest.php @@ -495,4 +495,87 @@ class ApplicationLevelDto self::assertCount(1, $violations); } + + public function test_it_collects_throws_tag_as_dependencies(): void + { + $code = <<< 'EOF' + parse($code, 'relativePathName'); + + $cd = $fp->getClassDescriptions(); + + self::assertCount(1, $cd); + $dependencies = $cd[0]->getDependencies(); + + // Should have 3 dependencies from @throws: FooException, BarException, Exception + self::assertCount(3, $dependencies); + + $fqcns = array_map(static fn ($dep) => $dep->getFQCN()->toString(), $dependencies); + self::assertContains('Domain\FooException', $fqcns); + self::assertContains('Domain\BarException', $fqcns); + self::assertContains('Exception', $fqcns); + } + + public function test_it_collects_throws_tag_with_fully_qualified_names(): void + { + $code = <<< 'EOF' + parse($code, 'relativePathName'); + + $cd = $fp->getClassDescriptions(); + + self::assertCount(1, $cd); + $dependencies = $cd[0]->getDependencies(); + + // Should have 3 dependencies from @throws + self::assertCount(3, $dependencies); + + $fqcns = array_map(static fn ($dep) => $dep->getFQCN()->toString(), $dependencies); + self::assertContains('Exception', $fqcns); + self::assertContains('Domain\FooException', $fqcns); + self::assertContains('App\Services\BarException', $fqcns); + } }