diff --git a/src/Parser/TokenIterator.php b/src/Parser/TokenIterator.php index 87ded21a..a9738d62 100644 --- a/src/Parser/TokenIterator.php +++ b/src/Parser/TokenIterator.php @@ -205,6 +205,19 @@ public function tryConsumeTokenType(int $tokenType): bool } + /** @phpstan-impure */ + public function skipNewLineTokens(): void + { + if (!$this->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { + return; + } + + do { + $foundNewLine = $this->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + } while ($foundNewLine === true); + } + + private function detectNewline(): void { $value = $this->currentTokenValue(); diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 84a3880d..d5f9217c 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -40,17 +40,44 @@ public function parse(TokenIterator $tokens): Ast\Type\TypeNode } else { $type = $this->parseAtomic($tokens); - if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) { - $type = $this->parseUnion($tokens, $type); + $tokens->pushSavePoint(); + $tokens->skipNewLineTokens(); + + try { + $enrichedType = $this->enrichTypeOnUnionOrIntersection($tokens, $type); + + } catch (ParserException $parserException) { + $enrichedType = null; + } - } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) { - $type = $this->parseIntersection($tokens, $type); + if ($enrichedType !== null) { + $type = $enrichedType; + $tokens->dropSavePoint(); + + } else { + $tokens->rollback(); + $type = $this->enrichTypeOnUnionOrIntersection($tokens, $type) ?? $type; } } return $this->enrichWithAttributes($tokens, $type, $startLine, $startIndex); } + /** @phpstan-impure */ + private function enrichTypeOnUnionOrIntersection(TokenIterator $tokens, Ast\Type\TypeNode $type): ?Ast\Type\TypeNode + { + if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) { + return $this->parseUnion($tokens, $type); + + } + + if ($tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) { + return $this->parseIntersection($tokens, $type); + } + + return null; + } + /** * @internal * @template T of Ast\Node @@ -90,7 +117,7 @@ private function subParse(TokenIterator $tokens): Ast\Type\TypeNode if ($tokens->isCurrentTokenValue('is')) { $type = $this->parseConditional($tokens, $type); } else { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) { $type = $this->subParseUnion($tokens, $type); @@ -112,9 +139,9 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode $startIndex = $tokens->currentTokenIndex(); if ($tokens->tryConsumeTokenType(Lexer::TOKEN_OPEN_PARENTHESES)) { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $type = $this->subParse($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_PARENTHESES); @@ -256,9 +283,9 @@ private function subParseUnion(TokenIterator $tokens, Ast\Type\TypeNode $type): $types = [$type]; while ($tokens->tryConsumeTokenType(Lexer::TOKEN_UNION)) { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $types[] = $this->parseAtomic($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); } return new Ast\Type\UnionTypeNode($types); @@ -284,9 +311,9 @@ private function subParseIntersection(TokenIterator $tokens, Ast\Type\TypeNode $ $types = [$type]; while ($tokens->tryConsumeTokenType(Lexer::TOKEN_INTERSECTION)) { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $types[] = $this->parseAtomic($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); } return new Ast\Type\IntersectionTypeNode($types); @@ -306,15 +333,15 @@ private function parseConditional(TokenIterator $tokens, Ast\Type\TypeNode $subj $targetType = $this->parse($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $tokens->consumeTokenType(Lexer::TOKEN_NULLABLE); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $ifType = $this->parse($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $tokens->consumeTokenType(Lexer::TOKEN_COLON); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $elseType = $this->subParse($tokens); @@ -335,15 +362,15 @@ private function parseConditionalForParameter(TokenIterator $tokens, string $par $targetType = $this->parse($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $tokens->consumeTokenType(Lexer::TOKEN_NULLABLE); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $ifType = $this->parse($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $tokens->consumeTokenType(Lexer::TOKEN_COLON); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $elseType = $this->subParse($tokens); @@ -409,8 +436,11 @@ public function parseGeneric(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $variances = []; $isFirst = true; - while ($isFirst || $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + while ( + $isFirst + || $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA) + ) { + $tokens->skipNewLineTokens(); // trailing comma case if (!$isFirst && $tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET)) { @@ -419,7 +449,7 @@ public function parseGeneric(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $isFirst = false; [$genericTypes[], $variances[]] = $this->parseGenericTypeArgument($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); } $type = new Ast\Type\GenericTypeNode($baseType, $genericTypes, $variances); @@ -510,19 +540,19 @@ private function parseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNod : []; $tokens->consumeTokenType(Lexer::TOKEN_OPEN_PARENTHESES); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $parameters = []; if (!$tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_PARENTHESES)) { $parameters[] = $this->parseCallableParameter($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); if ($tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_PARENTHESES)) { break; } $parameters[] = $this->parseCallableParameter($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); } } @@ -550,7 +580,7 @@ private function parseCallableTemplates(TokenIterator $tokens): array $isFirst = true; while ($isFirst || $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); // trailing comma case if (!$isFirst && $tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET)) { @@ -559,7 +589,7 @@ private function parseCallableTemplates(TokenIterator $tokens): array $isFirst = false; $templates[] = $this->parseCallableTemplateArgument($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); } $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); @@ -830,7 +860,7 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, $unsealedType = null; do { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); if ($tokens->tryConsumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET)) { return Ast\Type\ArrayShapeNode::createSealed($items, $kind); @@ -839,14 +869,14 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, if ($tokens->tryConsumeTokenType(Lexer::TOKEN_VARIADIC)) { $sealed = false; - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { if ($kind === Ast\Type\ArrayShapeNode::KIND_ARRAY) { $unsealedType = $this->parseArrayShapeUnsealedType($tokens); } else { $unsealedType = $this->parseListShapeUnsealedType($tokens); } - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); } $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA); @@ -855,10 +885,10 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, $items[] = $this->parseArrayShapeItem($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET); if ($sealed) { @@ -945,18 +975,18 @@ private function parseArrayShapeUnsealedType(TokenIterator $tokens): Ast\Type\Ar $startIndex = $tokens->currentTokenIndex(); $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $valueType = $this->parse($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $keyType = null; if ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $keyType = $valueType; $valueType = $this->parse($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); } $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); @@ -978,10 +1008,10 @@ private function parseListShapeUnsealedType(TokenIterator $tokens): Ast\Type\Arr $startIndex = $tokens->currentTokenIndex(); $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $valueType = $this->parse($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); @@ -1003,7 +1033,7 @@ private function parseObjectShape(TokenIterator $tokens): Ast\Type\ObjectShapeNo $items = []; do { - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); if ($tokens->tryConsumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET)) { return new Ast\Type\ObjectShapeNode($items); @@ -1011,10 +1041,10 @@ private function parseObjectShape(TokenIterator $tokens): Ast\Type\ObjectShapeNo $items[] = $this->parseObjectShapeItem($tokens); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); - $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + $tokens->skipNewLineTokens(); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET); return new Ast\Type\ObjectShapeNode($items); diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index 8e98d639..2cbde3c6 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -62,6 +62,8 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\InvalidTypeNode; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use PHPStan\PhpDocParser\Lexer\Lexer; @@ -4027,6 +4029,228 @@ public function provideMultiLinePhpDocData(): iterable new PhpDocTextNode(''), ]), ]; + + yield [ + 'Multiline PHPDoc with new line across generic type declaration', + '/**' . PHP_EOL . + ' * @param array,' . PHP_EOL . + ' * }> $a' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTagNode('@param', new ParamTagValueNode( + new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + new IdentifierTypeNode('string'), + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('foo'), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(new IdentifierTypeNode('bar'), true, new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + new IdentifierTypeNode('string'), + new UnionTypeNode([ + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('foo1'), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(new IdentifierTypeNode('bar1'), true, new IdentifierTypeNode('true')), + ]), + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('foo1'), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(new IdentifierTypeNode('foo2'), false, new IdentifierTypeNode('true')), + new ArrayShapeItemNode(new IdentifierTypeNode('bar1'), true, new IdentifierTypeNode('true')), + ]), + ]), + ], + [ + GenericTypeNode::VARIANCE_INVARIANT, + GenericTypeNode::VARIANCE_INVARIANT, + ], + )), + ]), + ], + [ + GenericTypeNode::VARIANCE_INVARIANT, + GenericTypeNode::VARIANCE_INVARIANT, + ], + ), + false, + '$a', + '', + false, + )), + ]), + ]; + + yield [ + 'Multiline PHPDoc with new line within type declaration', + '/**' . PHP_EOL . + ' * @param array,' . PHP_EOL . + ' * }> $a' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTagNode('@param', new ParamTagValueNode( + new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + new IdentifierTypeNode('string'), + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('foo'), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(new IdentifierTypeNode('bar'), true, new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + new IdentifierTypeNode('string'), + new UnionTypeNode([ + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('foo1'), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(new IdentifierTypeNode('bar1'), true, new IdentifierTypeNode('true')), + ]), + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('foo1'), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(new IdentifierTypeNode('foo2'), false, new IdentifierTypeNode('true')), + new ArrayShapeItemNode(new IdentifierTypeNode('bar1'), true, new IdentifierTypeNode('true')), + ]), + ]), + ], + [ + GenericTypeNode::VARIANCE_INVARIANT, + GenericTypeNode::VARIANCE_INVARIANT, + ], + )), + ]), + ], + [ + GenericTypeNode::VARIANCE_INVARIANT, + GenericTypeNode::VARIANCE_INVARIANT, + ], + ), + false, + '$a', + '', + false, + )), + ]), + ]; + + yield [ + 'Multiline PHPDoc with new line within type declaration including usage of braces', + '/**' . PHP_EOL . + ' * @phpstan-type FactoriesConfigurationType = array<' . PHP_EOL . + ' * string,' . PHP_EOL . + ' * (class-string|Factory\FactoryInterface)' . PHP_EOL . + ' * |callable(ContainerInterface,?string,array|null):object' . PHP_EOL . + ' * >' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTagNode('@phpstan-type', new TypeAliasTagValueNode( + 'FactoriesConfigurationType', + new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + new IdentifierTypeNode('string'), + new UnionTypeNode([ + new UnionTypeNode([ + new GenericTypeNode( + new IdentifierTypeNode('class-string'), + [new IdentifierTypeNode('Factory\\FactoryInterface')], + [GenericTypeNode::VARIANCE_INVARIANT], + ), + new IdentifierTypeNode('Factory\\FactoryInterface'), + ]), + new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode(new IdentifierTypeNode('ContainerInterface'), false, false, '', false), + new CallableTypeParameterNode( + new NullableTypeNode( + new IdentifierTypeNode('string'), + ), + false, + false, + '', + false, + ), + new CallableTypeParameterNode( + new UnionTypeNode([ + new GenericTypeNode( + new IdentifierTypeNode('array'), + [new IdentifierTypeNode('mixed')], + [GenericTypeNode::VARIANCE_INVARIANT], + ), + new IdentifierTypeNode('null'), + ]), + false, + false, + '', + false, + ), + ], + new IdentifierTypeNode('object'), + [], + ), + ]), + ], + [ + GenericTypeNode::VARIANCE_INVARIANT, + GenericTypeNode::VARIANCE_INVARIANT, + ], + ), + )), + ]), + ]; + + /** + * @return object{ + * a: int, + * + * b: int, + * } + */ + + yield [ + 'Multiline PHPDoc with new line within object type declaration', + '/**' . PHP_EOL . + ' * @return object{' . PHP_EOL . + ' * a: int,' . PHP_EOL . + ' *' . PHP_EOL . + ' * b: int,' . PHP_EOL . + ' * }' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTagNode( + '@return', + new ReturnTagValueNode( + new ObjectShapeNode( + [ + new ObjectShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int'), + ), + new ObjectShapeItemNode( + new IdentifierTypeNode('b'), + false, + new IdentifierTypeNode('int'), + ), + ], + ), + '', + ), + ), + ]), + ]; } public function provideTemplateTagsData(): Iterator