Skip to content

Commit 26417fe

Browse files
Use custom resolver for extracting type information from docblocks (#504)
1 parent 96cd45c commit 26417fe

File tree

8 files changed

+421
-472
lines changed

8 files changed

+421
-472
lines changed

src/Analyzer/DocblockParser.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Arkitect\Analyzer;
5+
6+
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
7+
use PHPStan\PhpDocParser\Lexer\Lexer;
8+
use PHPStan\PhpDocParser\Parser\PhpDocParser;
9+
use PHPStan\PhpDocParser\Parser\TokenIterator;
10+
11+
class DocblockParser
12+
{
13+
private PhpDocParser $innerParser;
14+
15+
private Lexer $innerLexer;
16+
17+
public function __construct(PhpDocParser $innerParser, Lexer $innerLexer)
18+
{
19+
$this->innerParser = $innerParser;
20+
$this->innerLexer = $innerLexer;
21+
}
22+
23+
public function parse(string $docblock): PhpDocNode
24+
{
25+
$tokens = $this->innerLexer->tokenize($docblock);
26+
$tokenIterator = new TokenIterator($tokens);
27+
28+
return $this->innerParser->parse($tokenIterator);
29+
}
30+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Arkitect\Analyzer;
5+
6+
use PHPStan\PhpDocParser\Lexer\Lexer;
7+
use PHPStan\PhpDocParser\Parser\ConstExprParser;
8+
use PHPStan\PhpDocParser\Parser\PhpDocParser;
9+
use PHPStan\PhpDocParser\Parser\TypeParser;
10+
use PHPStan\PhpDocParser\ParserConfig;
11+
12+
class DocblockParserFactory
13+
{
14+
/**
15+
* @psalm-suppress TooFewArguments
16+
* @psalm-suppress InvalidArgument
17+
*/
18+
public static function create(): DocblockParser
19+
{
20+
$phpDocParser = null;
21+
$phpDocLexer = null;
22+
23+
// this if is to allow using v 1.2 or v2
24+
if (class_exists(ParserConfig::class)) {
25+
$parserConfig = new ParserConfig([]);
26+
$constExprParser = new ConstExprParser($parserConfig);
27+
$typeParser = new TypeParser($parserConfig, $constExprParser);
28+
$phpDocParser = new PhpDocParser($parserConfig, $typeParser, $constExprParser);
29+
$phpDocLexer = new Lexer($parserConfig);
30+
} else {
31+
$typeParser = new TypeParser();
32+
$constExprParser = new ConstExprParser();
33+
$phpDocParser = new PhpDocParser($typeParser, $constExprParser);
34+
$phpDocLexer = new Lexer();
35+
}
36+
37+
return new DocblockParser($phpDocParser, $phpDocLexer);
38+
}
39+
}
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arkitect\Analyzer;
6+
7+
use PhpParser\Comment\Doc;
8+
use PhpParser\ErrorHandler;
9+
use PhpParser\NameContext;
10+
use PhpParser\Node;
11+
use PhpParser\Node\Expr;
12+
use PhpParser\Node\Name;
13+
use PhpParser\Node\Name\FullyQualified;
14+
use PhpParser\Node\Stmt;
15+
use PhpParser\NodeAbstract;
16+
use PhpParser\NodeVisitorAbstract;
17+
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
18+
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
19+
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
20+
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
21+
22+
/**
23+
* This class is used to collect type information from dockblocks, in particular
24+
* - regular dockblock tags: @param, @var, @return
25+
* - old style annotations like @Assert\Blank
26+
* and assign them to the piece of code the docblock is attached to.
27+
*
28+
* This allows to detect dependencies declared only in dockblocks
29+
*/
30+
class DocblockTypesResolver extends NodeVisitorAbstract
31+
{
32+
private NameContext $nameContext;
33+
34+
private bool $parseCustomAnnotations;
35+
36+
private DocblockParser $docblockParser;
37+
38+
public function __construct(bool $parseCustomAnnotations = true)
39+
{
40+
$this->nameContext = new NameContext(new ErrorHandler\Throwing());
41+
42+
$this->parseCustomAnnotations = $parseCustomAnnotations;
43+
44+
$this->docblockParser = DocblockParserFactory::create();
45+
}
46+
47+
public function beforeTraverse(array $nodes): ?array
48+
{
49+
// this also clears the name context so there is not need to reinstantiate it
50+
$this->nameContext->startNamespace();
51+
52+
return null;
53+
}
54+
55+
public function enterNode(Node $node): void
56+
{
57+
if ($node instanceof Stmt\Namespace_) {
58+
$this->nameContext->startNamespace($node->name);
59+
}
60+
61+
if ($node instanceof Stmt\Use_) {
62+
$this->addAliases($node->uses, $node->type, null);
63+
}
64+
65+
if ($node instanceof Stmt\GroupUse) {
66+
$this->addAliases($node->uses, $node->type, $node->prefix);
67+
}
68+
69+
$this->resolveFunctionTypes($node);
70+
71+
$this->resolveParamTypes($node);
72+
}
73+
74+
private function resolveParamTypes(Node $node): void
75+
{
76+
if (!($node instanceof Stmt\Property)) {
77+
return;
78+
}
79+
80+
$phpDocNode = $this->parseDocblock($node);
81+
82+
if (null === $phpDocNode) {
83+
return;
84+
}
85+
86+
if ($this->isNodeOfTypeArray($node)) {
87+
$arrayItemType = null;
88+
89+
foreach ($phpDocNode->getVarTagValues() as $tagValue) {
90+
$arrayItemType = $this->getArrayItemType($tagValue->type);
91+
}
92+
93+
if (null !== $arrayItemType) {
94+
$node->type = $this->resolveName(new Name($arrayItemType), Stmt\Use_::TYPE_NORMAL);
95+
96+
return;
97+
}
98+
}
99+
100+
foreach ($phpDocNode->getVarTagValues() as $tagValue) {
101+
$type = $this->resolveName(new Name((string) $tagValue->type), Stmt\Use_::TYPE_NORMAL);
102+
$node->type = $type;
103+
break;
104+
}
105+
106+
if ($this->parseCustomAnnotations && !($node->type instanceof FullyQualified)) {
107+
foreach ($phpDocNode->getTags() as $tagValue) {
108+
if ('@' === $tagValue->name[0] && !str_contains($tagValue->name, '@var')) {
109+
$customTag = str_replace('@', '', $tagValue->name);
110+
$type = $this->resolveName(new Name($customTag), Stmt\Use_::TYPE_NORMAL);
111+
$node->type = $type;
112+
113+
break;
114+
}
115+
}
116+
}
117+
}
118+
119+
private function resolveFunctionTypes(Node $node): void
120+
{
121+
if (
122+
!($node instanceof Stmt\ClassMethod
123+
|| $node instanceof Stmt\Function_
124+
|| $node instanceof Expr\Closure
125+
|| $node instanceof Expr\ArrowFunction)
126+
) {
127+
return;
128+
}
129+
130+
$phpDocNode = $this->parseDocblock($node);
131+
132+
if (null === $phpDocNode) { // no docblock, nothing to do
133+
return;
134+
}
135+
136+
foreach ($node->params as $param) {
137+
if (!$this->isNodeOfTypeArray($param)) { // not an array, nothing to do
138+
continue;
139+
}
140+
141+
foreach ($phpDocNode->getParamTagValues() as $phpDocParam) {
142+
if ($param->var instanceof Expr\Variable && \is_string($param->var->name) && $phpDocParam->parameterName === ('$'.$param->var->name)) {
143+
$arrayItemType = $this->getArrayItemType($phpDocParam->type);
144+
145+
if (null !== $arrayItemType) {
146+
$param->type = $this->resolveName(new Name($arrayItemType), Stmt\Use_::TYPE_NORMAL);
147+
}
148+
}
149+
}
150+
}
151+
152+
if ($node->returnType instanceof Node\Identifier && 'array' === $node->returnType->name) {
153+
$arrayItemType = null;
154+
155+
foreach ($phpDocNode->getReturnTagValues() as $tagValue) {
156+
$arrayItemType = $this->getArrayItemType($tagValue->type);
157+
}
158+
159+
if (null !== $arrayItemType) {
160+
$node->returnType = $this->resolveName(new Name($arrayItemType), Stmt\Use_::TYPE_NORMAL);
161+
}
162+
}
163+
}
164+
165+
/**
166+
* Resolve name, according to name resolver options.
167+
*
168+
* @param Name $name Function or constant name to resolve
169+
* @param Stmt\Use_::TYPE_* $type One of Stmt\Use_::TYPE_*
170+
*
171+
* @return Name Resolved name, or original name with attribute
172+
*/
173+
private function resolveName(Name $name, int $type): Name
174+
{
175+
$resolvedName = $this->nameContext->getResolvedName($name, $type);
176+
177+
if (null !== $resolvedName) {
178+
return $resolvedName;
179+
}
180+
181+
// unqualified names inside a namespace cannot be resolved at compile-time
182+
// add the namespaced version of the name as an attribute
183+
$name->setAttribute('namespacedName', FullyQualified::concat(
184+
$this->nameContext->getNamespace(),
185+
$name,
186+
$name->getAttributes()
187+
));
188+
189+
return $name;
190+
}
191+
192+
/**
193+
* @param array<Node\UseItem> $uses
194+
*/
195+
private function addAliases(array $uses, int $type, ?Name $prefix = null): void
196+
{
197+
foreach ($uses as $useItem) {
198+
$this->addAlias($useItem, $type, $prefix);
199+
}
200+
}
201+
202+
/**
203+
* @psalm-suppress PossiblyNullArgument
204+
* @psalm-suppress ArgumentTypeCoercion
205+
*/
206+
private function addAlias(Node\UseItem $use, int $type, ?Name $prefix = null): void
207+
{
208+
// Add prefix for group uses
209+
$name = $prefix ? Name::concat($prefix, $use->name) : $use->name;
210+
// Type is determined either by individual element or whole use declaration
211+
$type |= $use->type;
212+
213+
$this->nameContext->addAlias(
214+
$name,
215+
(string) $use->getAlias(),
216+
$type,
217+
$use->getAttributes()
218+
);
219+
}
220+
221+
private function parseDocblock(NodeAbstract $node): ?PhpDocNode
222+
{
223+
if (null === $node->getDocComment()) {
224+
return null;
225+
}
226+
227+
/** @var Doc $docComment */
228+
$docComment = $node->getDocComment();
229+
230+
return $this->docblockParser->parse($docComment->getText());
231+
}
232+
233+
/**
234+
* @param Node\Param|Stmt\Property $node
235+
*/
236+
private function isNodeOfTypeArray($node): bool
237+
{
238+
return null !== $node->type && isset($node->type->name) && 'array' === $node->type->name;
239+
}
240+
241+
private function getArrayItemType(TypeNode $typeNode): ?string
242+
{
243+
$arrayItemType = null;
244+
245+
if ($typeNode instanceof GenericTypeNode) {
246+
if (1 === \count($typeNode->genericTypes)) {
247+
// this handles list<ClassName>
248+
$arrayItemType = (string) $typeNode->genericTypes[0];
249+
} elseif (2 === \count($typeNode->genericTypes)) {
250+
// this handles array<int, ClassName>
251+
$arrayItemType = (string) $typeNode->genericTypes[1];
252+
}
253+
}
254+
255+
if ($typeNode instanceof ArrayTypeNode) {
256+
// this handles ClassName[]
257+
$arrayItemType = (string) $typeNode->type;
258+
}
259+
260+
$validFqcn = '/^[a-zA-Z_\x7f-\xff\\\\][a-zA-Z0-9_\x7f-\xff\\\\]*[a-zA-Z0-9_\x7f-\xff]$/';
261+
262+
if (null !== $arrayItemType && !(bool) preg_match($validFqcn, $arrayItemType)) {
263+
return null;
264+
}
265+
266+
return $arrayItemType;
267+
}
268+
}

src/Analyzer/FileParser.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Arkitect\Rules\ParsingError;
99
use PhpParser\ErrorHandler\Collecting;
1010
use PhpParser\NodeTraverser;
11+
use PhpParser\NodeVisitor\NameResolver;
1112
use PhpParser\Parser as PhpParser;
1213
use PhpParser\ParserFactory;
1314
use PhpParser\PhpVersion;
@@ -27,6 +28,7 @@ public function __construct(
2728
NodeTraverser $traverser,
2829
FileVisitor $fileVisitor,
2930
NameResolver $nameResolver,
31+
DocblockTypesResolver $docblockTypesResolver,
3032
TargetPhpVersion $targetPhpVersion
3133
) {
3234
$this->fileVisitor = $fileVisitor;
@@ -35,6 +37,7 @@ public function __construct(
3537
$this->parser = (new ParserFactory())->createForVersion(PhpVersion::fromString($targetPhpVersion->get()));
3638
$this->traverser = $traverser;
3739
$this->traverser->addVisitor($nameResolver);
40+
$this->traverser->addVisitor($docblockTypesResolver);
3841
$this->traverser->addVisitor($this->fileVisitor);
3942
}
4043

src/Analyzer/FileParserFactory.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Arkitect\CLI\TargetPhpVersion;
88
use PhpParser\NodeTraverser;
9+
use PhpParser\NodeVisitor\NameResolver;
910

1011
class FileParserFactory
1112
{
@@ -14,7 +15,8 @@ public static function createFileParser(TargetPhpVersion $targetPhpVersion, bool
1415
return new FileParser(
1516
new NodeTraverser(),
1617
new FileVisitor(new ClassDescriptionBuilder()),
17-
new NameResolver(null, ['parseCustomAnnotations' => $parseCustomAnnotations]),
18+
new NameResolver(),
19+
new DocblockTypesResolver($parseCustomAnnotations),
1820
$targetPhpVersion
1921
);
2022
}

0 commit comments

Comments
 (0)