diff --git a/src/Ast/PhpDoc/TypeAliasTagValueNode.php b/src/Ast/PhpDoc/TypeAliasTagValueNode.php index e80037dd..b4e11ed5 100644 --- a/src/Ast/PhpDoc/TypeAliasTagValueNode.php +++ b/src/Ast/PhpDoc/TypeAliasTagValueNode.php @@ -4,6 +4,7 @@ use PHPStan\PhpDocParser\Ast\NodeAttributes; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use function implode; use function trim; class TypeAliasTagValueNode implements PhpDocTagValueNode @@ -15,15 +16,25 @@ class TypeAliasTagValueNode implements PhpDocTagValueNode public TypeNode $type; - public function __construct(string $alias, TypeNode $type) + /** @var TemplateTagValueNode[] */ + public array $templateTypes; + + /** + * @param TemplateTagValueNode[] $templateTypes + */ + public function __construct(string $alias, TypeNode $type, array $templateTypes = []) { $this->alias = $alias; $this->type = $type; + $this->templateTypes = $templateTypes; } public function __toString(): string { - return trim("{$this->alias} {$this->type}"); + $templateTypes = $this->templateTypes !== [] + ? '<' . implode(', ', $this->templateTypes) . '>' + : ''; + return trim("{$this->alias}{$templateTypes} {$this->type}"); } /** @@ -31,7 +42,7 @@ public function __toString(): string */ public static function __set_state(array $properties): self { - $instance = new self($properties['alias'], $properties['type']); + $instance = new self($properties['alias'], $properties['type'], $properties['templateTypes'] ?? []); if (isset($properties['attributes'])) { foreach ($properties['attributes'] as $key => $value) { $instance->setAttribute($key, $value); diff --git a/src/Parser/PhpDocParser.php b/src/Parser/PhpDocParser.php index cbb0e1a3..10901c3c 100644 --- a/src/Parser/PhpDocParser.php +++ b/src/Parser/PhpDocParser.php @@ -1067,6 +1067,40 @@ private function parseTypeAliasTagValue(TokenIterator $tokens): Ast\PhpDoc\TypeA $alias = $tokens->currentTokenValue(); $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + $templateTypes = []; + if ($tokens->tryConsumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { + // Skip whitespace and newlines after opening bracket + while ($tokens->isCurrentTokenType(Lexer::TOKEN_HORIZONTAL_WS, Lexer::TOKEN_PHPDOC_EOL)) { + $tokens->next(); + } + + do { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + $templateTypes[] = $this->enrichWithAttributes( + $tokens, + $this->typeParser->parseTemplateTagValue($tokens), + $startLine, + $startIndex, + ); + + // Skip whitespace and newlines after template type + while ($tokens->isCurrentTokenType(Lexer::TOKEN_HORIZONTAL_WS, Lexer::TOKEN_PHPDOC_EOL)) { + $tokens->next(); + } + + if (!$tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { + break; + } + + // Skip whitespace and newlines after comma + while ($tokens->isCurrentTokenType(Lexer::TOKEN_HORIZONTAL_WS, Lexer::TOKEN_PHPDOC_EOL)) { + $tokens->next(); + } + } while (true); + $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); + } + // support phan-type/psalm-type syntax $tokens->tryConsumeTokenType(Lexer::TOKEN_EQUAL); @@ -1087,12 +1121,13 @@ private function parseTypeAliasTagValue(TokenIterator $tokens): Ast\PhpDoc\TypeA } } - return new Ast\PhpDoc\TypeAliasTagValueNode($alias, $type); + return new Ast\PhpDoc\TypeAliasTagValueNode($alias, $type, $templateTypes); } catch (ParserException $e) { $this->parseOptionalDescription($tokens, false); return new Ast\PhpDoc\TypeAliasTagValueNode( $alias, $this->enrichWithAttributes($tokens, new Ast\Type\InvalidTypeNode($e), $startLine, $startIndex), + $templateTypes, ); } } diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php index 36f6ebe1..71ba1757 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -383,8 +383,11 @@ private function printTagValue(PhpDocTagValueNode $node): string ); } if ($node instanceof TypeAliasTagValueNode) { + $templateTypes = $node->templateTypes !== [] + ? '<' . implode(', ', array_map(fn (TemplateTagValueNode $templateNode): string => $this->print($templateNode), $node->templateTypes)) . '>' + : ''; $type = $this->printType($node->type); - return trim("{$node->alias} {$type}"); + return trim("{$node->alias}{$templateTypes} {$type}"); } if ($node instanceof UsesTagValueNode) { $type = $this->printType($node->type); diff --git a/tests/PHPStan/Ast/ToString/PhpDocToStringTest.php b/tests/PHPStan/Ast/ToString/PhpDocToStringTest.php index 47a25db3..dbbc9d5a 100644 --- a/tests/PHPStan/Ast/ToString/PhpDocToStringTest.php +++ b/tests/PHPStan/Ast/ToString/PhpDocToStringTest.php @@ -229,6 +229,10 @@ public static function provideClassCases(): Generator yield from [ ['Foo array', new TypeAliasTagValueNode('Foo', $arrayOfStrings)], + ['Wrapper T', new TypeAliasTagValueNode('Wrapper', new IdentifierTypeNode('T'), [new TemplateTagValueNode('T', null, '')])], + ['Pair TFirst', new TypeAliasTagValueNode('Pair', new IdentifierTypeNode('TFirst'), [new TemplateTagValueNode('TFirst', null, ''), new TemplateTagValueNode('TSecond', null, '')])], + ['Collection array', new TypeAliasTagValueNode('Collection', $arrayOfStrings, [new TemplateTagValueNode('T', new IdentifierTypeNode('object'), '')])], + ['WithDefault T', new TypeAliasTagValueNode('WithDefault', new IdentifierTypeNode('T'), [new TemplateTagValueNode('T', null, '', new IdentifierTypeNode('string'))])], ['Test from Foo\Bar', new TypeAliasImportTagValueNode('Test', $bar, null)], ['Test from Foo\Bar as Foo', new TypeAliasImportTagValueNode('Test', $bar, 'Foo')], ]; diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index 12a2c40f..abe65b57 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -5152,6 +5152,130 @@ public function provideTypeAliasTagsData(): Iterator ), ]), ]; + + yield [ + 'OK with one template type', + '/** @phpstan-type Wrapper T */', + new PhpDocNode([ + new PhpDocTagNode( + '@phpstan-type', + new TypeAliasTagValueNode( + 'Wrapper', + new IdentifierTypeNode('T'), + [ + new TemplateTagValueNode('T', null, ''), + ], + ), + ), + ]), + ]; + + yield [ + 'OK with two template types', + '/** @phpstan-type Pair TFirst */', + new PhpDocNode([ + new PhpDocTagNode( + '@phpstan-type', + new TypeAliasTagValueNode( + 'Pair', + new IdentifierTypeNode('TFirst'), + [ + new TemplateTagValueNode('TFirst', null, ''), + new TemplateTagValueNode('TSecond', null, ''), + ], + ), + ), + ]), + ]; + + yield [ + 'OK with bounded template type', + '/** @phpstan-type Collection list */', + new PhpDocNode([ + new PhpDocTagNode( + '@phpstan-type', + new TypeAliasTagValueNode( + 'Collection', + new GenericTypeNode( + new IdentifierTypeNode('list'), + [new IdentifierTypeNode('T')], + [GenericTypeNode::VARIANCE_INVARIANT], + ), + [ + new TemplateTagValueNode('T', new IdentifierTypeNode('object'), ''), + ], + ), + ), + ]), + ]; + + yield [ + 'OK with default template type', + '/** @phpstan-type WithDefault T */', + new PhpDocNode([ + new PhpDocTagNode( + '@phpstan-type', + new TypeAliasTagValueNode( + 'WithDefault', + new IdentifierTypeNode('T'), + [ + new TemplateTagValueNode('T', null, '', new IdentifierTypeNode('string')), + ], + ), + ), + ]), + ]; + + yield [ + 'OK multiline with two template types', + '/** + * @phpstan-type Widget< + * TFoo, + * TBar + * > array{foo: TFoo, bar: TBar} + */', + new PhpDocNode([ + new PhpDocTagNode( + '@phpstan-type', + new TypeAliasTagValueNode( + 'Widget', + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('foo'), false, new IdentifierTypeNode('TFoo')), + new ArrayShapeItemNode(new IdentifierTypeNode('bar'), false, new IdentifierTypeNode('TBar')), + ]), + [ + new TemplateTagValueNode('TFoo', null, ''), + new TemplateTagValueNode('TBar', null, ''), + ], + ), + ), + ]), + ]; + + yield [ + 'OK multiline with bounded template', + '/** + * @phpstan-type Collection< + * T of object + * > list + */', + new PhpDocNode([ + new PhpDocTagNode( + '@phpstan-type', + new TypeAliasTagValueNode( + 'Collection', + new GenericTypeNode( + new IdentifierTypeNode('list'), + [new IdentifierTypeNode('T')], + [GenericTypeNode::VARIANCE_INVARIANT], + ), + [ + new TemplateTagValueNode('T', new IdentifierTypeNode('object'), ''), + ], + ), + ), + ]), + ]; } public function provideTypeAliasImportTagsData(): Iterator