1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\PhpDocParser\Parser;
4:
5: use PHPStan\PhpDocParser\Ast;
6: use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
7: use PHPStan\PhpDocParser\Lexer\Lexer;
8: use PHPStan\ShouldNotHappenException;
9: use function array_key_exists;
10: use function array_values;
11: use function count;
12: use function trim;
13:
14: class PhpDocParser
15: {
16:
17: private const DISALLOWED_DESCRIPTION_START_TOKENS = [
18: Lexer::TOKEN_UNION,
19: Lexer::TOKEN_INTERSECTION,
20: ];
21:
22: /** @var TypeParser */
23: private $typeParser;
24:
25: /** @var ConstExprParser */
26: private $constantExprParser;
27:
28: /** @var bool */
29: private $requireWhitespaceBeforeDescription;
30:
31: public function __construct(TypeParser $typeParser, ConstExprParser $constantExprParser, bool $requireWhitespaceBeforeDescription = false)
32: {
33: $this->typeParser = $typeParser;
34: $this->constantExprParser = $constantExprParser;
35: $this->requireWhitespaceBeforeDescription = $requireWhitespaceBeforeDescription;
36: }
37:
38:
39: public function parse(TokenIterator $tokens): Ast\PhpDoc\PhpDocNode
40: {
41: $tokens->consumeTokenType(Lexer::TOKEN_OPEN_PHPDOC);
42: $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL);
43:
44: $children = [];
45:
46: if (!$tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_PHPDOC)) {
47: $children[] = $this->parseChild($tokens);
48: while ($tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL) && !$tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_PHPDOC)) {
49: $children[] = $this->parseChild($tokens);
50: }
51: }
52:
53: try {
54: $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_PHPDOC);
55: } catch (ParserException $e) {
56: $name = '';
57: if (count($children) > 0) {
58: $lastChild = $children[count($children) - 1];
59: if ($lastChild instanceof Ast\PhpDoc\PhpDocTagNode) {
60: $name = $lastChild->name;
61: }
62: }
63: $tokens->forwardToTheEnd();
64: return new Ast\PhpDoc\PhpDocNode([
65: new Ast\PhpDoc\PhpDocTagNode($name, new Ast\PhpDoc\InvalidTagValueNode($e->getMessage(), $e)),
66: ]);
67: }
68:
69: return new Ast\PhpDoc\PhpDocNode(array_values($children));
70: }
71:
72:
73: private function parseChild(TokenIterator $tokens): Ast\PhpDoc\PhpDocChildNode
74: {
75: if ($tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_TAG)) {
76: return $this->parseTag($tokens);
77:
78: }
79:
80: return $this->parseText($tokens);
81: }
82:
83:
84: private function parseText(TokenIterator $tokens): Ast\PhpDoc\PhpDocTextNode
85: {
86: $text = '';
87:
88: while (!$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) {
89: $text .= $tokens->getSkippedHorizontalWhiteSpaceIfAny() . $tokens->joinUntil(Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END);
90:
91: if (!$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) {
92: break;
93: }
94:
95: $tokens->pushSavePoint();
96: $tokens->next();
97:
98: if ($tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_TAG, Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END)) {
99: $tokens->rollback();
100: break;
101: }
102:
103: $tokens->dropSavePoint();
104: $text .= "\n";
105: }
106:
107: return new Ast\PhpDoc\PhpDocTextNode(trim($text, " \t"));
108: }
109:
110:
111: public function parseTag(TokenIterator $tokens): Ast\PhpDoc\PhpDocTagNode
112: {
113: $tag = $tokens->currentTokenValue();
114: $tokens->next();
115: $value = $this->parseTagValue($tokens, $tag);
116:
117: return new Ast\PhpDoc\PhpDocTagNode($tag, $value);
118: }
119:
120:
121: public function parseTagValue(TokenIterator $tokens, string $tag): Ast\PhpDoc\PhpDocTagValueNode
122: {
123: try {
124: $tokens->pushSavePoint();
125:
126: switch ($tag) {
127: case '@param':
128: case '@phpstan-param':
129: case '@psalm-param':
130: $tagValue = $this->parseParamTagValue($tokens);
131: break;
132:
133: case '@var':
134: case '@phpstan-var':
135: case '@psalm-var':
136: $tagValue = $this->parseVarTagValue($tokens);
137: break;
138:
139: case '@return':
140: case '@phpstan-return':
141: case '@psalm-return':
142: $tagValue = $this->parseReturnTagValue($tokens);
143: break;
144:
145: case '@throws':
146: case '@phpstan-throws':
147: $tagValue = $this->parseThrowsTagValue($tokens);
148: break;
149:
150: case '@mixin':
151: $tagValue = $this->parseMixinTagValue($tokens);
152: break;
153:
154: case '@deprecated':
155: $tagValue = $this->parseDeprecatedTagValue($tokens);
156: break;
157:
158: case '@property':
159: case '@property-read':
160: case '@property-write':
161: case '@phpstan-property':
162: case '@phpstan-property-read':
163: case '@phpstan-property-write':
164: case '@psalm-property':
165: case '@psalm-property-read':
166: case '@psalm-property-write':
167: $tagValue = $this->parsePropertyTagValue($tokens);
168: break;
169:
170: case '@method':
171: case '@phpstan-method':
172: case '@psalm-method':
173: $tagValue = $this->parseMethodTagValue($tokens);
174: break;
175:
176: case '@template':
177: case '@phpstan-template':
178: case '@psalm-template':
179: case '@template-covariant':
180: case '@phpstan-template-covariant':
181: case '@psalm-template-covariant':
182: case '@template-contravariant':
183: case '@phpstan-template-contravariant':
184: case '@psalm-template-contravariant':
185: $tagValue = $this->parseTemplateTagValue($tokens);
186: break;
187:
188: case '@extends':
189: case '@phpstan-extends':
190: case '@template-extends':
191: $tagValue = $this->parseExtendsTagValue('@extends', $tokens);
192: break;
193:
194: case '@implements':
195: case '@phpstan-implements':
196: case '@template-implements':
197: $tagValue = $this->parseExtendsTagValue('@implements', $tokens);
198: break;
199:
200: case '@use':
201: case '@phpstan-use':
202: case '@template-use':
203: $tagValue = $this->parseExtendsTagValue('@use', $tokens);
204: break;
205:
206: case '@phpstan-type':
207: case '@psalm-type':
208: $tagValue = $this->parseTypeAliasTagValue($tokens);
209: break;
210:
211: case '@phpstan-import-type':
212: case '@psalm-import-type':
213: $tagValue = $this->parseTypeAliasImportTagValue($tokens);
214: break;
215:
216: case '@phpstan-assert':
217: case '@phpstan-assert-if-true':
218: case '@phpstan-assert-if-false':
219: case '@psalm-assert':
220: case '@psalm-assert-if-true':
221: case '@psalm-assert-if-false':
222: $tagValue = $this->parseAssertTagValue($tokens);
223: break;
224:
225: default:
226: $tagValue = new Ast\PhpDoc\GenericTagValueNode($this->parseOptionalDescription($tokens));
227: break;
228: }
229:
230: $tokens->dropSavePoint();
231:
232: } catch (ParserException $e) {
233: $tokens->rollback();
234: $tagValue = new Ast\PhpDoc\InvalidTagValueNode($this->parseOptionalDescription($tokens), $e);
235: }
236:
237: return $tagValue;
238: }
239:
240:
241: /**
242: * @return Ast\PhpDoc\ParamTagValueNode|Ast\PhpDoc\TypelessParamTagValueNode
243: */
244: private function parseParamTagValue(TokenIterator $tokens): Ast\PhpDoc\PhpDocTagValueNode
245: {
246: if (
247: $tokens->isCurrentTokenType(Lexer::TOKEN_REFERENCE)
248: || $tokens->isCurrentTokenType(Lexer::TOKEN_VARIADIC)
249: || $tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)
250: ) {
251: $type = null;
252: } else {
253: $type = $this->typeParser->parse($tokens);
254: }
255:
256: $isReference = $tokens->tryConsumeTokenType(Lexer::TOKEN_REFERENCE);
257: $isVariadic = $tokens->tryConsumeTokenType(Lexer::TOKEN_VARIADIC);
258: $parameterName = $this->parseRequiredVariableName($tokens);
259: $description = $this->parseOptionalDescription($tokens);
260:
261: if ($type !== null) {
262: return new Ast\PhpDoc\ParamTagValueNode($type, $isVariadic, $parameterName, $description, $isReference);
263: }
264:
265: return new Ast\PhpDoc\TypelessParamTagValueNode($isVariadic, $parameterName, $description, $isReference);
266: }
267:
268:
269: private function parseVarTagValue(TokenIterator $tokens): Ast\PhpDoc\VarTagValueNode
270: {
271: $type = $this->typeParser->parse($tokens);
272: $variableName = $this->parseOptionalVariableName($tokens);
273: $description = $this->parseOptionalDescription($tokens, $variableName === '');
274: return new Ast\PhpDoc\VarTagValueNode($type, $variableName, $description);
275: }
276:
277:
278: private function parseReturnTagValue(TokenIterator $tokens): Ast\PhpDoc\ReturnTagValueNode
279: {
280: $type = $this->typeParser->parse($tokens);
281: $description = $this->parseOptionalDescription($tokens, true);
282: return new Ast\PhpDoc\ReturnTagValueNode($type, $description);
283: }
284:
285:
286: private function parseThrowsTagValue(TokenIterator $tokens): Ast\PhpDoc\ThrowsTagValueNode
287: {
288: $type = $this->typeParser->parse($tokens);
289: $description = $this->parseOptionalDescription($tokens, true);
290: return new Ast\PhpDoc\ThrowsTagValueNode($type, $description);
291: }
292:
293: private function parseMixinTagValue(TokenIterator $tokens): Ast\PhpDoc\MixinTagValueNode
294: {
295: $type = $this->typeParser->parse($tokens);
296: $description = $this->parseOptionalDescription($tokens, true);
297: return new Ast\PhpDoc\MixinTagValueNode($type, $description);
298: }
299:
300: private function parseDeprecatedTagValue(TokenIterator $tokens): Ast\PhpDoc\DeprecatedTagValueNode
301: {
302: $description = $this->parseOptionalDescription($tokens);
303: return new Ast\PhpDoc\DeprecatedTagValueNode($description);
304: }
305:
306:
307: private function parsePropertyTagValue(TokenIterator $tokens): Ast\PhpDoc\PropertyTagValueNode
308: {
309: $type = $this->typeParser->parse($tokens);
310: $parameterName = $this->parseRequiredVariableName($tokens);
311: $description = $this->parseOptionalDescription($tokens);
312: return new Ast\PhpDoc\PropertyTagValueNode($type, $parameterName, $description);
313: }
314:
315:
316: private function parseMethodTagValue(TokenIterator $tokens): Ast\PhpDoc\MethodTagValueNode
317: {
318: $isStatic = $tokens->tryConsumeTokenValue('static');
319: $returnTypeOrMethodName = $this->typeParser->parse($tokens);
320:
321: if ($tokens->isCurrentTokenType(Lexer::TOKEN_IDENTIFIER)) {
322: $returnType = $returnTypeOrMethodName;
323: $methodName = $tokens->currentTokenValue();
324: $tokens->next();
325:
326: } elseif ($returnTypeOrMethodName instanceof Ast\Type\IdentifierTypeNode) {
327: $returnType = $isStatic ? new Ast\Type\IdentifierTypeNode('static') : null;
328: $methodName = $returnTypeOrMethodName->name;
329: $isStatic = false;
330:
331: } else {
332: $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); // will throw exception
333: exit;
334: }
335:
336: $parameters = [];
337: $tokens->consumeTokenType(Lexer::TOKEN_OPEN_PARENTHESES);
338: if (!$tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_PARENTHESES)) {
339: $parameters[] = $this->parseMethodTagValueParameter($tokens);
340: while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) {
341: $parameters[] = $this->parseMethodTagValueParameter($tokens);
342: }
343: }
344: $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_PARENTHESES);
345:
346: $description = $this->parseOptionalDescription($tokens);
347: return new Ast\PhpDoc\MethodTagValueNode($isStatic, $returnType, $methodName, $parameters, $description);
348: }
349:
350:
351: private function parseMethodTagValueParameter(TokenIterator $tokens): Ast\PhpDoc\MethodTagValueParameterNode
352: {
353: switch ($tokens->currentTokenType()) {
354: case Lexer::TOKEN_IDENTIFIER:
355: case Lexer::TOKEN_OPEN_PARENTHESES:
356: case Lexer::TOKEN_NULLABLE:
357: $parameterType = $this->typeParser->parse($tokens);
358: break;
359:
360: default:
361: $parameterType = null;
362: }
363:
364: $isReference = $tokens->tryConsumeTokenType(Lexer::TOKEN_REFERENCE);
365: $isVariadic = $tokens->tryConsumeTokenType(Lexer::TOKEN_VARIADIC);
366:
367: $parameterName = $tokens->currentTokenValue();
368: $tokens->consumeTokenType(Lexer::TOKEN_VARIABLE);
369:
370: if ($tokens->tryConsumeTokenType(Lexer::TOKEN_EQUAL)) {
371: $defaultValue = $this->constantExprParser->parse($tokens);
372:
373: } else {
374: $defaultValue = null;
375: }
376:
377: return new Ast\PhpDoc\MethodTagValueParameterNode($parameterType, $isReference, $isVariadic, $parameterName, $defaultValue);
378: }
379:
380: private function parseTemplateTagValue(TokenIterator $tokens): Ast\PhpDoc\TemplateTagValueNode
381: {
382: $name = $tokens->currentTokenValue();
383: $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
384:
385: if ($tokens->tryConsumeTokenValue('of') || $tokens->tryConsumeTokenValue('as')) {
386: $bound = $this->typeParser->parse($tokens);
387:
388: } else {
389: $bound = null;
390: }
391:
392: $description = $this->parseOptionalDescription($tokens);
393:
394: return new Ast\PhpDoc\TemplateTagValueNode($name, $bound, $description);
395: }
396:
397: private function parseExtendsTagValue(string $tagName, TokenIterator $tokens): Ast\PhpDoc\PhpDocTagValueNode
398: {
399: $baseType = new IdentifierTypeNode($tokens->currentTokenValue());
400: $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
401:
402: $type = $this->typeParser->parseGeneric($tokens, $baseType);
403:
404: $description = $this->parseOptionalDescription($tokens);
405:
406: switch ($tagName) {
407: case '@extends':
408: return new Ast\PhpDoc\ExtendsTagValueNode($type, $description);
409: case '@implements':
410: return new Ast\PhpDoc\ImplementsTagValueNode($type, $description);
411: case '@use':
412: return new Ast\PhpDoc\UsesTagValueNode($type, $description);
413: }
414:
415: throw new ShouldNotHappenException();
416: }
417:
418: private function parseTypeAliasTagValue(TokenIterator $tokens): Ast\PhpDoc\TypeAliasTagValueNode
419: {
420: $alias = $tokens->currentTokenValue();
421: $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
422:
423: // support psalm-type syntax
424: $tokens->tryConsumeTokenType(Lexer::TOKEN_EQUAL);
425:
426: $type = $this->typeParser->parse($tokens);
427:
428: return new Ast\PhpDoc\TypeAliasTagValueNode($alias, $type);
429: }
430:
431: private function parseTypeAliasImportTagValue(TokenIterator $tokens): Ast\PhpDoc\TypeAliasImportTagValueNode
432: {
433: $importedAlias = $tokens->currentTokenValue();
434: $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
435:
436: $tokens->consumeTokenValue(Lexer::TOKEN_IDENTIFIER, 'from');
437:
438: $importedFrom = $tokens->currentTokenValue();
439: $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
440:
441: $importedAs = null;
442: if ($tokens->tryConsumeTokenValue('as')) {
443: $importedAs = $tokens->currentTokenValue();
444: $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
445: }
446:
447: return new Ast\PhpDoc\TypeAliasImportTagValueNode($importedAlias, new IdentifierTypeNode($importedFrom), $importedAs);
448: }
449:
450: /**
451: * @return Ast\PhpDoc\AssertTagValueNode|Ast\PhpDoc\AssertTagPropertyValueNode|Ast\PhpDoc\AssertTagMethodValueNode
452: */
453: private function parseAssertTagValue(TokenIterator $tokens): Ast\PhpDoc\PhpDocTagValueNode
454: {
455: $isNegated = $tokens->tryConsumeTokenType(Lexer::TOKEN_NEGATED);
456: $type = $this->typeParser->parse($tokens);
457: $parameter = $this->parseAssertParameter($tokens);
458: $description = $this->parseOptionalDescription($tokens);
459:
460: if (array_key_exists('method', $parameter)) {
461: return new Ast\PhpDoc\AssertTagMethodValueNode($type, $parameter['parameter'], $parameter['method'], $isNegated, $description);
462: } elseif (array_key_exists('property', $parameter)) {
463: return new Ast\PhpDoc\AssertTagPropertyValueNode($type, $parameter['parameter'], $parameter['property'], $isNegated, $description);
464: }
465:
466: return new Ast\PhpDoc\AssertTagValueNode($type, $parameter['parameter'], $isNegated, $description);
467: }
468:
469: /**
470: * @return array{parameter: string}|array{parameter: string, property: string}|array{parameter: string, method: string}
471: */
472: private function parseAssertParameter(TokenIterator $tokens): array
473: {
474: if ($tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) {
475: $parameter = '$this';
476: $requirePropertyOrMethod = true;
477: $tokens->next();
478: } else {
479: $parameter = $tokens->currentTokenValue();
480: $requirePropertyOrMethod = false;
481: $tokens->consumeTokenType(Lexer::TOKEN_VARIABLE);
482: }
483:
484: if ($requirePropertyOrMethod || $tokens->isCurrentTokenType(Lexer::TOKEN_ARROW)) {
485: $tokens->consumeTokenType(Lexer::TOKEN_ARROW);
486:
487: $propertyOrMethod = $tokens->currentTokenValue();
488: $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
489:
490: if ($tokens->tryConsumeTokenType(Lexer::TOKEN_OPEN_PARENTHESES)) {
491: $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_PARENTHESES);
492:
493: return ['parameter' => $parameter, 'method' => $propertyOrMethod];
494: }
495:
496: return ['parameter' => $parameter, 'property' => $propertyOrMethod];
497: }
498:
499: return ['parameter' => $parameter];
500: }
501:
502: private function parseOptionalVariableName(TokenIterator $tokens): string
503: {
504: if ($tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)) {
505: $parameterName = $tokens->currentTokenValue();
506: $tokens->next();
507: } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) {
508: $parameterName = '$this';
509: $tokens->next();
510:
511: } else {
512: $parameterName = '';
513: }
514:
515: return $parameterName;
516: }
517:
518:
519: private function parseRequiredVariableName(TokenIterator $tokens): string
520: {
521: $parameterName = $tokens->currentTokenValue();
522: $tokens->consumeTokenType(Lexer::TOKEN_VARIABLE);
523:
524: return $parameterName;
525: }
526:
527: private function parseOptionalDescription(TokenIterator $tokens, bool $limitStartToken = false): string
528: {
529: if ($limitStartToken) {
530: foreach (self::DISALLOWED_DESCRIPTION_START_TOKENS as $disallowedStartToken) {
531: if (!$tokens->isCurrentTokenType($disallowedStartToken)) {
532: continue;
533: }
534:
535: $tokens->consumeTokenType(Lexer::TOKEN_OTHER); // will throw exception
536: }
537:
538: if (
539: $this->requireWhitespaceBeforeDescription
540: && !$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END)
541: && !$tokens->isPrecededByHorizontalWhitespace()
542: ) {
543: $tokens->consumeTokenType(Lexer::TOKEN_HORIZONTAL_WS); // will throw exception
544: }
545: }
546:
547: return $this->parseText($tokens)->text;
548: }
549:
550: }
551: