Skip to content

Commit a8ff510

Browse files
micheleorselliclaudefain182
authored
Collect dependencies defined in @throws docblock tags (#513)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Pietro Campagnano <hello@pietro.camp> Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent fbd6d4d commit a8ff510

File tree

7 files changed

+218
-11
lines changed

7 files changed

+218
-11
lines changed

src/Analyzer/Docblock.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ public function getReturnTagTypes(): array
4343
return array_filter($returnTypes);
4444
}
4545

46+
/**
47+
* @return array<array{type: string, line: int}>
48+
*/
49+
public function getThrowTagsTypes(): array
50+
{
51+
$throwTypes = [];
52+
53+
foreach ($this->phpDocNode->getThrowsTagValues() as $throwTag) {
54+
$type = $this->getType($throwTag->type);
55+
if (null !== $type) {
56+
$throwTypes[] = ['type' => $type, 'line' => $throwTag->getAttribute('startLine') ?? 0];
57+
}
58+
}
59+
60+
return $throwTypes;
61+
}
62+
4663
public function getVarTagTypes(): array
4764
{
4865
$varTypes = array_map(

src/Analyzer/DocblockParserFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public static function create(): DocblockParser
2222

2323
// this if is to allow using v 1.2 or v2
2424
if (class_exists(ParserConfig::class)) {
25-
$parserConfig = new ParserConfig([]);
25+
$parserConfig = new ParserConfig(['lines' => true]);
2626
$constExprParser = new ConstExprParser($parserConfig);
2727
$typeParser = new TypeParser($parserConfig, $constExprParser);
2828
$phpDocParser = new PhpDocParser($parserConfig, $typeParser, $constExprParser);

src/Analyzer/DocblockTypesResolver.php

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
*/
2626
class DocblockTypesResolver extends NodeVisitorAbstract
2727
{
28+
public const THROWS_TYPES_ATTRIBUTE = 'docblock_throws_types';
29+
2830
private NameContext $nameContext;
2931

3032
private bool $parseCustomAnnotations;
@@ -117,6 +119,18 @@ private function resolveFunctionTypes(Node $node): void
117119
return;
118120
}
119121

122+
$this->resolveParamTypes($node, $docblock);
123+
124+
$this->resolveReturnValueType($node, $docblock);
125+
126+
$this->resolveThrowsValueType($node, $docblock);
127+
}
128+
129+
/**
130+
* @param Stmt\ClassMethod|Stmt\Function_|Expr\Closure|Expr\ArrowFunction $node
131+
*/
132+
private function resolveParamTypes(Node $node, Docblock $docblock): void
133+
{
120134
// extract param types from param tags
121135
foreach ($node->params as $param) {
122136
if (!$this->isTypeArray($param->type)) { // not an array, nothing to do
@@ -136,19 +150,62 @@ private function resolveFunctionTypes(Node $node): void
136150

137151
$param->type = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL);
138152
}
153+
}
139154

140-
// extract return type from return tag
141-
if ($this->isTypeArray($node->returnType)) {
142-
$type = $docblock->getReturnTagTypes();
143-
$type = array_pop($type);
155+
/**
156+
* @param Stmt\ClassMethod|Stmt\Function_|Expr\Closure|Expr\ArrowFunction $node
157+
*/
158+
private function resolveReturnValueType(Node $node, Docblock $docblock): void
159+
{
160+
if (null === $node->returnType) {
161+
return;
162+
}
144163

145-
// we ignore any type which is not a class
146-
if (!$this->isTypeClass($type)) {
147-
return;
164+
if (!$this->isTypeArray($node->returnType)) {
165+
return;
166+
}
167+
168+
$type = $docblock->getReturnTagTypes();
169+
$type = array_pop($type);
170+
171+
// we ignore any type which is not a class
172+
if (!$this->isTypeClass($type)) {
173+
return;
174+
}
175+
176+
$node->returnType = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL);
177+
}
178+
179+
/**
180+
* @param Stmt\ClassMethod|Stmt\Function_|Expr\Closure|Expr\ArrowFunction $node
181+
*/
182+
private function resolveThrowsValueType(Node $node, Docblock $docblock): void
183+
{
184+
// extract throw types from throw tag
185+
$throwValues = $docblock->getThrowTagsTypes();
186+
187+
if (empty($throwValues)) {
188+
return;
189+
}
190+
191+
$docComment = $node->getDocComment();
192+
$docblockStartLine = $docComment ? $docComment->getStartLine() : $node->getStartLine();
193+
194+
$throwsTypesResolved = [];
195+
196+
foreach ($throwValues as ['type' => $throwValue, 'line' => $relativeLine]) {
197+
if (str_starts_with($throwValue, '\\')) {
198+
$name = new FullyQualified(substr($throwValue, 1));
199+
} else {
200+
$name = $this->resolveName(new Name($throwValue), Stmt\Use_::TYPE_NORMAL);
148201
}
149202

150-
$node->returnType = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL);
203+
$name->setAttribute('startLine', $docblockStartLine + $relativeLine - 1);
204+
205+
$throwsTypesResolved[] = $name;
151206
}
207+
208+
$node->setAttribute(self::THROWS_TYPES_ATTRIBUTE, $throwsTypesResolved);
152209
}
153210

154211
/**

src/Analyzer/FileVisitor.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ public function enterNode(Node $node): void
7373

7474
// handles property hooks like public string $name { get => ...; set { ... } }
7575
$this->handlePropertyHookNode($node);
76+
77+
// handles throws types like @throws MyClass
78+
$this->handleThrowsTags($node);
7679
}
7780

7881
public function getClassDescriptions(): array
@@ -352,6 +355,19 @@ private function handleAttributeNode(Node $node): void
352355
->addAttribute($node->name->toString(), $node->getLine());
353356
}
354357

358+
private function handleThrowsTags(Node $node): void
359+
{
360+
if (!$node->hasAttribute(DocblockTypesResolver::THROWS_TYPES_ATTRIBUTE)) {
361+
return;
362+
}
363+
364+
/** @var Node\Name\FullyQualified $throw */
365+
foreach ($node->getAttribute(DocblockTypesResolver::THROWS_TYPES_ATTRIBUTE) as $throw) {
366+
$this->classDescriptionBuilder
367+
->addDependency(new ClassDependency($throw->toString(), $throw->getLine()));
368+
}
369+
}
370+
355371
private function addParamDependency(Node\Param $node): void
356372
{
357373
if (null === $node->type || $node->type instanceof Node\Identifier) {

tests/Unit/Analyzer/DocblockParserTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,30 @@ public function test_it_should_extract_types_from_var_tag(): void
9898
self::assertEquals('(int | string)', $varTags[6]);
9999
}
100100

101+
public function test_it_should_extract_types_from_throws_tag(): void
102+
{
103+
$parser = DocblockParserFactory::create();
104+
105+
$code = <<< 'PHP'
106+
/**
107+
* @throws \Exception
108+
* @throws \Domain\Foo\FooException
109+
* @throws BarException
110+
*/
111+
PHP;
112+
113+
$db = $parser->parse($code);
114+
115+
$varTags = $db->getThrowTagsTypes();
116+
self::assertCount(3, $varTags);
117+
self::assertEquals('\Exception', $varTags[0]['type']);
118+
self::assertEquals('\Domain\Foo\FooException', $varTags[1]['type']);
119+
self::assertEquals('BarException', $varTags[2]['type']);
120+
self::assertEquals(2, $varTags[0]['line']);
121+
self::assertEquals(3, $varTags[1]['line']);
122+
self::assertEquals(4, $varTags[2]['line']);
123+
}
124+
101125
public function test_it_should_extract_doctrine_like_annotations(): void
102126
{
103127
$parser = DocblockParserFactory::create();

tests/Unit/Analyzer/DocblockTypesResolverTest.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,14 @@ public function myMethod(array $users, array $products, MyOtherClass $other): vo
7878
* @param array<int, User> $users
7979
*
8080
* @return array<int, int|string>
81+
*
82+
* @throws \Exception
83+
* @throws \Domain\Foo\FooException
84+
* @throws BarException
8185
*/
8286
public function myMethod2(array $aParam, array $users): array
87+
{
88+
}
8389
}
8490
EOF;
8591

@@ -88,7 +94,7 @@ public function myMethod2(array $aParam, array $users): array
8894
$cd = $parser->getClassDescriptions()[0];
8995
$dep = $cd->getDependencies();
9096

91-
self::assertCount(9, $cd->getDependencies());
97+
self::assertCount(11, $cd->getDependencies());
9298
self::assertEquals('Application\Model\User', $dep[0]->getFQCN()->toString());
9399
self::assertEquals('Symfony\Component\Validator\Constraints\NotBlank', $dep[1]->getFQCN()->toString());
94100
self::assertEquals('UuidFactoryInterface', $dep[2]->getFQCN()->toString());
@@ -97,6 +103,10 @@ public function myMethod2(array $aParam, array $users): array
97103
self::assertEquals('Application\Model\User', $dep[5]->getFQCN()->toString());
98104
self::assertEquals('Application\Model\Product', $dep[6]->getFQCN()->toString());
99105
self::assertEquals('Domain\Foo\MyOtherClass', $dep[7]->getFQCN()->toString());
100-
self::assertEquals('Application\Model\User', $dep[8]->getFQCN()->toString());
106+
self::assertEquals('Domain\Foo\FooException', $dep[8]->getFQCN()->toString());
107+
self::assertEquals(55, $dep[8]->getLine());
108+
self::assertEquals('Domain\Foo\BarException', $dep[9]->getFQCN()->toString());
109+
self::assertEquals(56, $dep[9]->getLine());
110+
self::assertEquals('Application\Model\User', $dep[10]->getFQCN()->toString());
101111
}
102112
}

tests/Unit/Analyzer/FileParser/CanParseDocblocksTest.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,4 +495,87 @@ class ApplicationLevelDto
495495

496496
self::assertCount(1, $violations);
497497
}
498+
499+
public function test_it_collects_throws_tag_as_dependencies(): void
500+
{
501+
$code = <<< 'EOF'
502+
<?php
503+
504+
namespace Domain\Foo;
505+
506+
use Domain\FooException;
507+
use Domain\BarException;
508+
509+
class MyClass
510+
{
511+
/**
512+
* @throws FooException
513+
* @throws BarException
514+
*/
515+
public function method1()
516+
{
517+
}
518+
519+
/**
520+
* @throws \Exception
521+
*/
522+
public function method2()
523+
{
524+
}
525+
}
526+
EOF;
527+
528+
$fp = FileParserFactory::forPhpVersion(TargetPhpVersion::PHP_8_0);
529+
$fp->parse($code, 'relativePathName');
530+
531+
$cd = $fp->getClassDescriptions();
532+
533+
self::assertCount(1, $cd);
534+
$dependencies = $cd[0]->getDependencies();
535+
536+
// Should have 2 dependencies from @throws: FooException, BarException
537+
// \Exception is a PHP core class and is filtered out
538+
self::assertCount(2, $dependencies);
539+
540+
$fqcns = array_map(static fn ($dep) => $dep->getFQCN()->toString(), $dependencies);
541+
self::assertContains('Domain\FooException', $fqcns);
542+
self::assertContains('Domain\BarException', $fqcns);
543+
}
544+
545+
public function test_it_collects_throws_tag_with_fully_qualified_names(): void
546+
{
547+
$code = <<< 'EOF'
548+
<?php
549+
550+
namespace App\Services;
551+
552+
class MyService
553+
{
554+
/**
555+
* @throws \Exception
556+
* @throws \Domain\FooException
557+
* @throws BarException
558+
*/
559+
public function doSomething()
560+
{
561+
}
562+
}
563+
EOF;
564+
565+
$fp = FileParserFactory::forPhpVersion(TargetPhpVersion::PHP_8_1);
566+
$fp->parse($code, 'relativePathName');
567+
568+
$cd = $fp->getClassDescriptions();
569+
570+
self::assertCount(1, $cd);
571+
$dependencies = $cd[0]->getDependencies();
572+
573+
// Should have 2 dependencies from @throws
574+
// \Exception is a PHP core class and is filtered out
575+
self::assertCount(2, $dependencies);
576+
577+
$fqcns = array_map(static fn ($dep) => $dep->getFQCN()->toString(), $dependencies);
578+
self::assertContains('Domain\FooException', $fqcns);
579+
self::assertContains('App\Services\BarException', $fqcns);
580+
}
498581
}

0 commit comments

Comments
 (0)