From 6c43ad28fe165b28ca6e2585aed5c333327e26ff Mon Sep 17 00:00:00 2001 From: shmax Date: Thu, 2 Apr 2026 08:26:19 -0700 Subject: [PATCH 1/3] support for generic params on alias types --- src/Ast/PhpDoc/TypeAliasTagValueNode.php | 17 +++- src/Parser/PhpDocParser.php | 18 +++- src/Printer/Printer.php | 5 +- .../Ast/ToString/PhpDocToStringTest.php | 4 + tests/PHPStan/Parser/PhpDocParserTest.php | 87 +++++++++++++++++-- 5 files changed, 119 insertions(+), 12 deletions(-) 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..3d727a61 100644 --- a/src/Parser/PhpDocParser.php +++ b/src/Parser/PhpDocParser.php @@ -1067,6 +1067,21 @@ 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)) { + do { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + $templateTypes[] = $this->enrichWithAttributes( + $tokens, + $this->typeParser->parseTemplateTagValue($tokens), + $startLine, + $startIndex, + ); + } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); + $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); + } + // support phan-type/psalm-type syntax $tokens->tryConsumeTokenType(Lexer::TOKEN_EQUAL); @@ -1087,12 +1102,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..5a41978d 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -5145,16 +5145,89 @@ public function provideTypeAliasTagsData(): Iterator Lexer::TOKEN_CLOSE_PHPDOC, 18, Lexer::TOKEN_IDENTIFIER, - null, - 1, - ), + null, + 1, ), ), - ]), - ]; - } + ), + ]), + ]; + + 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')), + ], + ), + ), + ]), + ]; +} - public function provideTypeAliasImportTagsData(): Iterator +public function provideTypeAliasImportTagsData(): Iterator { yield [ 'OK', From cccccc1e77506120f33ee28ade2458569a8bbb40 Mon Sep 17 00:00:00 2001 From: shmax Date: Thu, 2 Apr 2026 08:47:52 -0700 Subject: [PATCH 2/3] linting --- tests/PHPStan/Parser/PhpDocParserTest.php | 160 +++++++++++----------- 1 file changed, 80 insertions(+), 80 deletions(-) diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index 5a41978d..60fd42ed 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -5145,89 +5145,89 @@ public function provideTypeAliasTagsData(): Iterator Lexer::TOKEN_CLOSE_PHPDOC, 18, Lexer::TOKEN_IDENTIFIER, - null, - 1, + null, + 1, + ), ), ), - ), - ]), - ]; - - 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 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')), + ], + ), + ), + ]), + ]; + } -public function provideTypeAliasImportTagsData(): Iterator + public function provideTypeAliasImportTagsData(): Iterator { yield [ 'OK', From 0183817a82ee55f732eff9a5f68b0c6ffaac506f Mon Sep 17 00:00:00 2001 From: shmax Date: Thu, 2 Apr 2026 08:56:15 -0700 Subject: [PATCH 3/3] allow multiline --- src/Parser/PhpDocParser.php | 21 +++++++++- tests/PHPStan/Parser/PhpDocParserTest.php | 51 +++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/Parser/PhpDocParser.php b/src/Parser/PhpDocParser.php index 3d727a61..10901c3c 100644 --- a/src/Parser/PhpDocParser.php +++ b/src/Parser/PhpDocParser.php @@ -1069,6 +1069,11 @@ private function parseTypeAliasTagValue(TokenIterator $tokens): Ast\PhpDoc\TypeA $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(); @@ -1078,7 +1083,21 @@ private function parseTypeAliasTagValue(TokenIterator $tokens): Ast\PhpDoc\TypeA $startLine, $startIndex, ); - } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); + + // 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); } diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index 60fd42ed..abe65b57 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -5225,6 +5225,57 @@ public function provideTypeAliasTagsData(): Iterator ), ]), ]; + + 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