1: <?php declare(strict_types = 1);
2:
3: namespace ApiGen\Analyzer\NodeVisitors;
4:
5: use ApiGen\Analyzer\IdentifierKind;
6: use ApiGen\Analyzer\NameContextFrame;
7: use ApiGen\Info\AliasReferenceInfo;
8: use ApiGen\Info\ClassLikeReferenceInfo;
9: use ApiGen\Info\ConstantReferenceInfo;
10: use ApiGen\Info\Expr\ArrayExprInfo;
11: use ApiGen\Info\Expr\ArrayItemExprInfo;
12: use ApiGen\Info\Expr\BooleanExprInfo;
13: use ApiGen\Info\Expr\ClassConstantFetchExprInfo;
14: use ApiGen\Info\Expr\ConstantFetchExprInfo;
15: use ApiGen\Info\Expr\FloatExprInfo;
16: use ApiGen\Info\Expr\IntegerExprInfo;
17: use ApiGen\Info\Expr\NullExprInfo;
18: use ApiGen\Info\Expr\StringExprInfo;
19: use ApiGen\Info\ExprInfo;
20: use ApiGen\Info\FunctionReferenceInfo;
21: use ApiGen\Info\MemberReferenceInfo;
22: use ApiGen\Info\MethodReferenceInfo;
23: use ApiGen\Info\PropertyReferenceInfo;
24: use LogicException;
25: use Nette\Utils\Strings;
26: use Nette\Utils\Validators;
27: use PhpParser\NameContext;
28: use PhpParser\Node;
29: use PhpParser\Node\Stmt\Use_;
30: use PhpParser\NodeVisitorAbstract;
31: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode;
32: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFalseNode;
33: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFloatNode;
34: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode;
35: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNode;
36: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNullNode;
37: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode;
38: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprTrueNode;
39: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode;
40: use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
41: use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
42: use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode;
43: use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
44: use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasImportTagValueNode;
45: use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasTagValueNode;
46: use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode;
47: use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
48: use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
49: use PHPStan\PhpDocParser\Lexer\Lexer;
50: use PHPStan\PhpDocParser\Parser\PhpDocParser;
51: use PHPStan\PhpDocParser\Parser\TokenIterator;
52:
53: use function array_filter;
54: use function assert;
55: use function count;
56: use function explode;
57: use function get_debug_type;
58: use function is_array;
59: use function is_object;
60: use function property_exists;
61: use function sprintf;
62: use function str_contains;
63: use function strlen;
64: use function strtolower;
65: use function substr;
66:
67:
68: class PhpDocResolver extends NodeVisitorAbstract
69: {
70: protected const NATIVE_KEYWORDS = [
71: 'array' => true,
72: 'bool' => true,
73: 'callable' => true,
74: 'false' => true,
75: 'float' => true,
76: 'int' => true,
77: 'iterable' => true,
78: 'mixed' => true,
79: 'never' => true,
80: 'null' => true,
81: 'object' => true,
82: 'parent' => true,
83: 'self' => true,
84: 'static' => true,
85: 'string' => true,
86: 'true' => true,
87: 'void' => true,
88: ];
89:
90: protected const KEYWORDS = self::NATIVE_KEYWORDS + [
91: 'array-key' => true,
92: 'associative-array' => true,
93: 'boolean' => true,
94: 'callable-object' => true,
95: 'callable-string' => true,
96: 'class-string' => true,
97: 'double' => true,
98: 'empty' => true,
99: 'integer' => true,
100: 'interface-string' => true,
101: 'key-of' => true,
102: 'list' => true,
103: 'literal-string' => true,
104: 'lowercase-string' => true,
105: 'max' => true,
106: 'min' => true,
107: 'negative-int' => true,
108: 'never-return' => true,
109: 'never-returns' => true,
110: 'no-return' => true,
111: 'non-empty-array' => true,
112: 'non-empty-list' => true,
113: 'non-empty-lowercase-string' => true,
114: 'non-empty-string' => true,
115: 'non-falsy-string' => true,
116: 'non-negative-int' => true,
117: 'non-positive-int' => true,
118: 'noreturn' => true,
119: 'number' => true,
120: 'numeric' => true,
121: 'numeric-string' => true,
122: 'positive-int' => true,
123: 'resource' => true,
124: 'scalar' => true,
125: 'trait-string' => true,
126: 'truthy-string' => true,
127: 'value-of' => true,
128: ];
129:
130: protected NameContextFrame $nameContextFrame;
131:
132:
133: public function __construct(
134: protected Lexer $lexer,
135: protected PhpDocParser $parser,
136: protected NameContext $nameContext,
137: ) {
138: $this->nameContextFrame = new NameContextFrame(parent: null);
139: }
140:
141:
142: public function enterNode(Node $node): null|int|Node
143: {
144: $doc = $node->getDocComment();
145:
146: if ($doc !== null) {
147: $tokens = $this->lexer->tokenize($doc->getText());
148: $phpDoc = $this->parser->parse(new TokenIterator($tokens));
149:
150: if ($node instanceof Node\Stmt\ClassLike) {
151: assert($node->namespacedName !== null);
152: $scope = new ClassLikeReferenceInfo($node->namespacedName->toString());
153: $this->nameContextFrame = $this->resolveNameContext($phpDoc, $this->nameContextFrame, $scope);
154:
155: } elseif ($node instanceof Node\FunctionLike) {
156: $scope = $this->nameContextFrame->scope;
157: $this->nameContextFrame = $this->resolveNameContext($phpDoc, $this->nameContextFrame, $scope);
158: }
159:
160: $node->setAttribute('phpDoc', $this->resolvePhpDoc($phpDoc));
161:
162: } elseif ($node instanceof Node\Stmt\ClassLike || $node instanceof Node\FunctionLike) {
163: $this->nameContextFrame = new NameContextFrame($this->nameContextFrame);
164: }
165:
166: return null;
167: }
168:
169:
170: public function leaveNode(Node $node): null|int|Node|array
171: {
172: if ($node instanceof Node\Stmt\ClassLike || $node instanceof Node\FunctionLike) {
173: if ($this->nameContextFrame->parent === null) {
174: throw new \LogicException('Name context stack is empty.');
175:
176: } else {
177: $this->nameContextFrame = $this->nameContextFrame->parent;
178: }
179: }
180:
181: return null;
182: }
183:
184:
185: protected function resolveNameContext(PhpDocNode $doc, NameContextFrame $parent, ?ClassLikeReferenceInfo $scope): NameContextFrame
186: {
187: $frame = new NameContextFrame($parent);
188:
189: foreach ($doc->children as $child) {
190: if ($child instanceof PhpDocTagNode) {
191: if ($child->value instanceof TypeAliasTagValueNode) {
192: assert($scope !== null);
193: $lower = strtolower($child->value->alias);
194: $frame->aliases[$lower] = new AliasReferenceInfo($scope, $child->value->alias);
195:
196: } elseif ($child->value instanceof TypeAliasImportTagValueNode) {
197: $classLike = new ClassLikeReferenceInfo($this->resolveClassLikeIdentifier($child->value->importedFrom->name));
198: $lower = strtolower($child->value->importedAs ?? $child->value->importedAlias);
199: $frame->aliases[$lower] = new AliasReferenceInfo($classLike, $child->value->importedAlias);
200:
201: } elseif ($child->value instanceof TemplateTagValueNode) {
202: $lower = strtolower($child->value->name);
203: $frame->genericParameters[$lower] = true;
204: }
205: }
206: }
207:
208: return $frame;
209: }
210:
211:
212: protected function resolvePhpDoc(PhpDocNode $phpDoc): PhpDocNode
213: {
214: $newChildren = [];
215:
216: foreach ($phpDoc->children as $child) {
217: if ($child instanceof PhpDocTagNode) {
218: $this->resolvePhpDocTag($child);
219: $newChildren[] = $child;
220:
221: } elseif ($child instanceof PhpDocTextNode) {
222: foreach ($this->resolvePhpDocTextNode($child->text) as $newChild) {
223: $newChildren[] = $newChild;
224: }
225: }
226: }
227:
228: return new PhpDocNode($newChildren);
229: }
230:
231:
232: protected function resolvePhpDocTag(PhpDocTagNode $tag): void
233: {
234: $stack = [$tag];
235: $index = 1;
236:
237: while ($index > 0) {
238: $value = $stack[--$index];
239:
240: if ($value instanceof IdentifierTypeNode) {
241: $this->resolveIdentifier($value);
242:
243: } elseif ($value instanceof ConstExprNode) {
244: $value->setAttribute('info', $this->resolveConstExpr($value));
245:
246: } elseif ($value instanceof ArrayShapeItemNode || $value instanceof ObjectShapeItemNode) {
247: $stack[$index++] = $value->valueType; // intentionally not pushing $value->keyName
248:
249: } else {
250: foreach ((array) $value as $item) {
251: if (is_array($item) || is_object($item)) {
252: $stack[$index++] = $item;
253: }
254: }
255: }
256: }
257:
258: if (property_exists($tag->value, 'description')) {
259: $tag->value->setAttribute('description', $this->resolvePhpDocTextNode($tag->value->description));
260: }
261: }
262:
263:
264: /**
265: * @return PhpDocTextNode[] indexed by []
266: */
267: public function resolvePhpDocTextNode(string $text): array
268: {
269: $matches = Strings::matchAll($text, '#\{(@(?:[a-z][a-z0-9-\\\\]+:)?[a-z][a-z0-9-\\\\]*+)(?:[ \t]++([^}]++))?\}#', captureOffset: true);
270:
271: $nodes = [];
272: $offset = 0;
273:
274: foreach ($matches as $match) {
275: $matchText = $match[0][0];
276: $matchOffset = $match[0][1];
277: $tagName = $match[1][0];
278: $tagValue = $match[2][0] ?? '';
279:
280: $nodes[] = new PhpDocTextNode(substr($text, $offset, $matchOffset - $offset));
281: $nodes[] = $this->resolveInlineTag($tagName, $tagValue) ?? new PhpDocTextNode($matchText);
282: $offset = $matchOffset + strlen($matchText);
283: }
284:
285: $nodes[] = new PhpDocTextNode(substr($text, $offset));
286: return array_filter($nodes, static fn(PhpDocTextNode $node): bool => $node->text !== '');
287: }
288:
289:
290: protected function resolveInlineTag(string $tagName, string $tagValue): ?PhpDocTextNode
291: {
292: if ($tagName === '@link' || $tagName === '@see') {
293: $parts = explode(' ', $tagValue, 2);
294: $node = new PhpDocTextNode($parts[1] ?? $parts[0]);
295: $references = $this->resolveLinkTarget($parts[0]);
296:
297: if (count($references) > 0) {
298: $node->setAttribute('targets', $references);
299: }
300:
301: return $node;
302: }
303:
304: return null;
305: }
306:
307:
308: /**
309: * @return list<ClassLikeReferenceInfo|MemberReferenceInfo|FunctionReferenceInfo|string>
310: */
311: protected function resolveLinkTarget(string $target): array
312: {
313: $identifier = '[a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*+';
314: $qualifiedIdentifier = "\\\\?+{$identifier}(?:\\\\{$identifier})*+";
315: $references = [];
316:
317: if (($match = Strings::match($target, "#^{$qualifiedIdentifier}#")) !== null) {
318: $classLike = new ClassLikeReferenceInfo($this->resolveClassLikeIdentifier($match[0]));
319: $offset = strlen($match[0]);
320:
321: if ($offset === strlen($target)) {
322: $references[] = $classLike;
323:
324: foreach ($this->resolveFunctionLinkTarget($match[0]) as $functionReference) {
325: $references[] = $functionReference;
326: }
327:
328: if (!str_contains($target, '\\')) {
329: $classLike = new ClassLikeReferenceInfo('self');
330: $references[] = new ConstantReferenceInfo($classLike, $target);
331: $references[] = new MethodReferenceInfo($classLike, $target);
332: }
333:
334: } elseif (($match = Strings::match($target, "#::($identifier)\\(\\)$#A", offset: $offset)) !== null) {
335: $references[] = new MethodReferenceInfo($classLike, $match[1]);
336:
337: } elseif (($match = Strings::match($target, "#::($identifier)$#A", offset: $offset)) !== null) {
338: $references[] = new ConstantReferenceInfo($classLike, $match[1]);
339: $references[] = new MethodReferenceInfo($classLike, $match[1]);
340:
341: } elseif (($match = Strings::match($target, "#::\\\$($identifier)$#A", offset: $offset)) !== null) {
342: $references[] = new PropertyReferenceInfo($classLike, $match[1]);
343:
344: } elseif (Strings::match($target, "#\\(\\)$#A", offset: $offset) !== null) {
345: $functionName = substr($target, 0, -2);
346: foreach ($this->resolveFunctionLinkTarget($functionName) as $functionReference) {
347: $references[] = $functionReference;
348: }
349:
350: if (!str_contains($functionName, '\\')) {
351: $classLike = new ClassLikeReferenceInfo('self');
352: $references[] = new MethodReferenceInfo($classLike, $functionName);
353: }
354:
355: } elseif (Validators::isUrl($target)) {
356: $references[] = $target;
357: }
358:
359: } elseif (($match = Strings::match($target, "#^\\\$($identifier)$#")) !== null) {
360: $classLike = new ClassLikeReferenceInfo('self');
361: $references[] = new PropertyReferenceInfo($classLike, $match[1]);
362: }
363:
364: return $references;
365: }
366:
367:
368: /**
369: * @return FunctionReferenceInfo[] indexed by []
370: */
371: protected function resolveFunctionLinkTarget(string $target): array
372: {
373: $resolvedFunctionIdentifier = $this->resolveFunctionIdentifier($target);
374:
375: if ($resolvedFunctionIdentifier !== null) {
376: return [
377: new FunctionReferenceInfo($resolvedFunctionIdentifier),
378: ];
379:
380: } elseif (($namespace = $this->nameContext->getNamespace()?->toString()) !== null) {
381: return [
382: new FunctionReferenceInfo("{$namespace}\\{$target}"),
383: new FunctionReferenceInfo($target),
384: ];
385:
386: } else {
387: throw new LogicException("Unable to resolve function {$target}");
388: }
389: }
390:
391:
392: protected function resolveIdentifier(IdentifierTypeNode $identifier): void
393: {
394: $lower = strtolower($identifier->name);
395:
396: if (isset(self::KEYWORDS[$identifier->name]) || isset(self::NATIVE_KEYWORDS[$lower]) || str_contains($lower, '-')) {
397: $identifier->setAttribute('kind', IdentifierKind::Keyword);
398:
399: } elseif (isset($this->nameContextFrame->genericParameters[$lower])) {
400: $identifier->setAttribute('kind', IdentifierKind::Generic);
401:
402: } elseif (isset($this->nameContextFrame->aliases[$lower])) {
403: $identifier->setAttribute('kind', IdentifierKind::Alias);
404: $identifier->setAttribute('aliasReference', $this->nameContextFrame->aliases[$lower]);
405:
406: } else {
407: $classLikeReference = new ClassLikeReferenceInfo($this->resolveClassLikeIdentifier($identifier->name));
408: $identifier->setAttribute('kind', IdentifierKind::ClassLike);
409: $identifier->setAttribute('classLikeReference', $classLikeReference);
410: }
411: }
412:
413:
414: protected function resolveClassLikeIdentifier(string $identifier): string
415: {
416: if ($identifier[0] === '\\') {
417: return substr($identifier, 1);
418:
419: } else {
420: return $this->nameContext->getResolvedClassName(new Node\Name($identifier))->toString();
421: }
422: }
423:
424:
425: protected function resolveFunctionIdentifier(string $identifier): ?string
426: {
427: if ($identifier[0] === '\\') {
428: return substr($identifier, 1);
429:
430: } else {
431: return $this->nameContext->getResolvedName(new Node\Name($identifier), Use_::TYPE_FUNCTION)?->toString();
432: }
433: }
434:
435:
436: protected function resolveConstExpr(ConstExprNode $expr): ExprInfo
437: {
438: if ($expr instanceof ConstExprTrueNode) {
439: return new BooleanExprInfo(true);
440:
441: } elseif ($expr instanceof ConstExprFalseNode) {
442: return new BooleanExprInfo(false);
443:
444: } elseif ($expr instanceof ConstExprNullNode) {
445: return new NullExprInfo();
446:
447: } elseif ($expr instanceof ConstExprIntegerNode) {
448: $node = Node\Scalar\LNumber::fromString($expr->value);
449: return new IntegerExprInfo($node->value, $node->getAttribute('kind'), $expr->value);
450:
451: } elseif ($expr instanceof ConstExprFloatNode) {
452: return new FloatExprInfo(Node\Scalar\DNumber::parse($expr->value), $expr->value);
453:
454: } elseif ($expr instanceof ConstExprStringNode) {
455: return new StringExprInfo($expr->value, raw: null);
456:
457: } elseif ($expr instanceof ConstExprArrayNode) {
458: $items = [];
459:
460: foreach ($expr->items as $item) {
461: $items[] = new ArrayItemExprInfo(
462: $item->key ? $this->resolveConstExpr($item->key) : null,
463: $this->resolveConstExpr($item->value),
464: );
465: }
466:
467: return new ArrayExprInfo($items);
468:
469: } elseif ($expr instanceof ConstFetchNode) {
470: if ($expr->className === '') {
471: return new ConstantFetchExprInfo($expr->name);
472:
473: } else {
474: return new ClassConstantFetchExprInfo(new ClassLikeReferenceInfo($this->resolveClassLikeIdentifier($expr->className)), $expr->name);
475: }
476:
477: } else {
478: throw new \LogicException(sprintf('Unsupported const expr node %s used in PHPDoc', get_debug_type($expr)));
479: }
480: }
481: }
482: