Skip to content

Commit 0fbb17c

Browse files
committed
Collect @throws docblock types as dependencies with correct line numbers
- Add `getThrowTagsTypesWithLines()` to Docblock to expose throw tag types along with their docblock-relative line numbers (requires PHPStan's `lines => true` parser config) - Enable `lines => true` in DocblockParserFactory so PHPStan records line info for all parsed tags - Add `THROWS_TYPES_ATTRIBUTE` constant and `resolveThrowsValueType()` to DocblockTypesResolver: resolves each @throws type against the current namespace context and stores them as node attributes, computing the actual file line as: docblock start line + tag's docblock-relative line - 1 - Add `handleThrowsTags()` to FileVisitor to register resolved @throws types as class dependencies - Update DocblockTypesResolverTest to include @throws tags and assert on FQCNs and correct line numbers - Add integration tests in CanParseDocblocksTest for @throws dependency collection with both aliased and fully-qualified exception types https://claude.ai/code/session_01MV9occCkrjhX1JmKUsaYZA
1 parent 319deb4 commit 0fbb17c

File tree

6 files changed

+176
-6
lines changed

6 files changed

+176
-6
lines changed

src/Analyzer/Docblock.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
88
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
9+
use PHPStan\PhpDocParser\Ast\PhpDoc\ThrowsTagValueNode;
910
use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
1011
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
1112
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
@@ -43,6 +44,30 @@ public function getReturnTagTypes(): array
4344
return array_filter($returnTypes);
4445
}
4546

47+
/**
48+
* @return array<array{type: string, line: int}>
49+
*/
50+
public function getThrowTagsTypesWithLines(): array
51+
{
52+
$result = [];
53+
54+
foreach ($this->phpDocNode->getTags() as $tag) {
55+
if (!($tag->value instanceof ThrowsTagValueNode)) {
56+
continue;
57+
}
58+
59+
$type = $this->getType($tag->value->type);
60+
61+
if (null === $type) {
62+
continue;
63+
}
64+
65+
$result[] = ['type' => $type, 'line' => $tag->getAttribute('startLine') ?? 1];
66+
}
67+
68+
return $result;
69+
}
70+
4671
public function getVarTagTypes(): array
4772
{
4873
$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: 38 additions & 3 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;
@@ -143,12 +145,45 @@ private function resolveFunctionTypes(Node $node): void
143145
$type = array_pop($type);
144146

145147
// we ignore any type which is not a class
146-
if (!$this->isTypeClass($type)) {
147-
return;
148+
if ($this->isTypeClass($type)) {
149+
$node->returnType = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL);
150+
}
151+
}
152+
153+
$this->resolveThrowsValueType($node, $docblock);
154+
}
155+
156+
/**
157+
* @param Stmt\ClassMethod|Stmt\Function_|Expr\Closure|Expr\ArrowFunction $node
158+
*/
159+
private function resolveThrowsValueType(Node $node, Docblock $docblock): void
160+
{
161+
// extract throw types from throw tag, with their docblock-relative line numbers
162+
$throwValues = $docblock->getThrowTagsTypesWithLines();
163+
164+
if (empty($throwValues)) {
165+
return;
166+
}
167+
168+
$docComment = $node->getDocComment();
169+
$docblockStartLine = null !== $docComment ? $docComment->getStartLine() : $node->getStartLine();
170+
171+
$throwsTypesResolved = [];
172+
173+
foreach ($throwValues as ['type' => $throwValue, 'line' => $tagDocblockLine]) {
174+
if (str_starts_with($throwValue, '\\')) {
175+
$name = new FullyQualified(substr($throwValue, 1));
176+
} else {
177+
$name = $this->resolveName(new Name($throwValue), Stmt\Use_::TYPE_NORMAL);
148178
}
149179

150-
$node->returnType = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL);
180+
// compute the actual file line: docblock start + tag's line within docblock - 1
181+
$name->setAttribute('startLine', $docblockStartLine + $tagDocblockLine - 1);
182+
183+
$throwsTypesResolved[] = $name;
151184
}
185+
186+
$node->setAttribute(self::THROWS_TYPES_ATTRIBUTE, $throwsTypesResolved);
152187
}
153188

154189
/**

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
@@ -379,4 +382,17 @@ private function handlePropertyHookNode(Node $node): void
379382
$this->addParamDependency($param);
380383
}
381384
}
385+
386+
private function handleThrowsTags(Node $node): void
387+
{
388+
if (!$node->hasAttribute(DocblockTypesResolver::THROWS_TYPES_ATTRIBUTE)) {
389+
return;
390+
}
391+
392+
/** @var Node\Name\FullyQualified $throw */
393+
foreach ($node->getAttribute(DocblockTypesResolver::THROWS_TYPES_ATTRIBUTE) as $throw) {
394+
$this->classDescriptionBuilder
395+
->addDependency(new ClassDependency($throw->toString(), $throw->getLine()));
396+
}
397+
}
382398
}

tests/Unit/Analyzer/DocblockTypesResolverTest.php

Lines changed: 13 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,11 @@ 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());
111+
self::assertEquals(58, $dep[10]->getLine());
101112
}
102113
}

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_1);
529+
$fp->parse($code, 'relativePathName');
530+
531+
$cd = $fp->getClassDescriptions();
532+
533+
self::assertCount(1, $cd);
534+
$dependencies = $cd[0]->getDependencies();
535+
536+
// \Exception is a PHP core class and is filtered out; only FooException and BarException remain
537+
self::assertCount(2, $dependencies);
538+
539+
self::assertEquals('Domain\FooException', $dependencies[0]->getFQCN()->toString());
540+
self::assertEquals(11, $dependencies[0]->getLine());
541+
self::assertEquals('Domain\BarException', $dependencies[1]->getFQCN()->toString());
542+
self::assertEquals(12, $dependencies[1]->getLine());
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+
// \Exception is a PHP core class and is filtered out
574+
self::assertCount(2, $dependencies);
575+
576+
self::assertEquals('Domain\FooException', $dependencies[0]->getFQCN()->toString());
577+
self::assertEquals(9, $dependencies[0]->getLine());
578+
self::assertEquals('App\Services\BarException', $dependencies[1]->getFQCN()->toString());
579+
self::assertEquals(10, $dependencies[1]->getLine());
580+
}
498581
}

0 commit comments

Comments
 (0)