diff --git a/src/Ast/Type/ArrayShapeNode.php b/src/Ast/Type/ArrayShapeNode.php index 806783f9..1f4ed4a9 100644 --- a/src/Ast/Type/ArrayShapeNode.php +++ b/src/Ast/Type/ArrayShapeNode.php @@ -22,15 +22,24 @@ class ArrayShapeNode implements TypeNode /** @var self::KIND_* */ public $kind; + /** @var ArrayShapeUnsealedTypeNode|null */ + public $unsealedType; + /** * @param ArrayShapeItemNode[] $items * @param self::KIND_* $kind */ - public function __construct(array $items, bool $sealed = true, string $kind = self::KIND_ARRAY) + public function __construct( + array $items, + bool $sealed = true, + string $kind = self::KIND_ARRAY, + ?ArrayShapeUnsealedTypeNode $unsealedType = null + ) { $this->items = $items; $this->sealed = $sealed; $this->kind = $kind; + $this->unsealedType = $unsealedType; } @@ -39,7 +48,7 @@ public function __toString(): string $items = $this->items; if (! $this->sealed) { - $items[] = '...'; + $items[] = '...' . $this->unsealedType; } return $this->kind . '{' . implode(', ', $items) . '}'; diff --git a/src/Ast/Type/ArrayShapeUnsealedTypeNode.php b/src/Ast/Type/ArrayShapeUnsealedTypeNode.php new file mode 100644 index 00000000..7ffdf1d2 --- /dev/null +++ b/src/Ast/Type/ArrayShapeUnsealedTypeNode.php @@ -0,0 +1,34 @@ +valueType = $valueType; + $this->keyType = $keyType; + } + + public function __toString(): string + { + if ($this->keyType !== null) { + return sprintf('<%s, %s>', $this->keyType, $this->valueType); + } + return sprintf('<%s>', $this->valueType); + } + +} diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 2e404655..c47ba10f 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -848,6 +848,7 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, $items = []; $sealed = true; + $unsealedType = null; do { $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); @@ -858,6 +859,17 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, if ($tokens->tryConsumeTokenType(Lexer::TOKEN_VARIADIC)) { $sealed = false; + + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + 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->tryConsumeTokenType(Lexer::TOKEN_COMMA); break; } @@ -870,7 +882,7 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET); - return new Ast\Type\ArrayShapeNode($items, $sealed, $kind); + return new Ast\Type\ArrayShapeNode($items, $sealed, $kind, $unsealedType); } @@ -949,6 +961,63 @@ private function parseArrayShapeKey(TokenIterator $tokens) ); } + /** + * @phpstan-impure + */ + private function parseArrayShapeUnsealedType(TokenIterator $tokens): Ast\Type\ArrayShapeUnsealedTypeNode + { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $valueType = $this->parse($tokens); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $keyType = null; + if ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $keyType = $valueType; + $valueType = $this->parse($tokens); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + } + + $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); + + return $this->enrichWithAttributes( + $tokens, + new Ast\Type\ArrayShapeUnsealedTypeNode($valueType, $keyType), + $startLine, + $startIndex + ); + } + + /** + * @phpstan-impure + */ + private function parseListShapeUnsealedType(TokenIterator $tokens): Ast\Type\ArrayShapeUnsealedTypeNode + { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $valueType = $this->parse($tokens); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); + + return $this->enrichWithAttributes( + $tokens, + new Ast\Type\ArrayShapeUnsealedTypeNode($valueType, null), + $startLine, + $startIndex + ); + } + /** * @phpstan-impure */ diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php index 044d07f8..c4b9c356 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -43,6 +43,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; @@ -229,6 +230,12 @@ function (PhpDocChildNode $child): string { $isOptional = $node->isOptional ? '=' : ''; return trim("{$type}{$isReference}{$isVariadic}{$node->parameterName}") . $isOptional; } + if ($node instanceof ArrayShapeUnsealedTypeNode) { + if ($node->keyType !== null) { + return sprintf('<%s, %s>', $this->printType($node->keyType), $this->printType($node->valueType)); + } + return sprintf('<%s>', $this->printType($node->valueType)); + } if ($node instanceof DoctrineAnnotation) { return (string) $node; } @@ -366,7 +373,7 @@ private function printType(TypeNode $node): string }, $node->items); if (! $node->sealed) { - $items[] = '...'; + $items[] = '...' . ($node->unsealedType === null ? '' : $this->print($node->unsealedType)); } return $node->kind . '{' . implode(', ', $items) . '}'; diff --git a/tests/PHPStan/Parser/TypeParserTest.php b/tests/PHPStan/Parser/TypeParserTest.php index d6c66bb8..3610fa52 100644 --- a/tests/PHPStan/Parser/TypeParserTest.php +++ b/tests/PHPStan/Parser/TypeParserTest.php @@ -13,6 +13,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; @@ -760,6 +761,403 @@ public function provideParseData(): array ArrayShapeNode::KIND_LIST ), ], + [ + 'array{...}', + new ArrayShapeNode( + [], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{a: int, b?: int, ...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{a:int,b?:int,...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{a: int, b?: int, ... ' . PHP_EOL + . ' < ' . PHP_EOL + . ' string ' . PHP_EOL + . ' > ' . PHP_EOL + . ' , ' . PHP_EOL + . ' }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{...}', + new ArrayShapeNode( + [], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int') + ) + ), + ], + [ + 'array{a: int, b?: int, ...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int') + ) + ), + ], + [ + 'array{a:int,b?:int,...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int') + ) + ), + ], + [ + 'array{a: int, b?: int, ... ' . PHP_EOL + . ' < ' . PHP_EOL + . ' int ' . PHP_EOL + . ' , ' . PHP_EOL + . ' string ' . PHP_EOL + . ' > ' . PHP_EOL + . ' , ' . PHP_EOL + . ' }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int') + ) + ), + ], + [ + 'list{...}', + new ArrayShapeNode( + [], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{int, int, ...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{int,int,...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{int, int, ... ' . PHP_EOL + . ' < ' . PHP_EOL + . ' string ' . PHP_EOL + . ' > ' . PHP_EOL + . ' , ' . PHP_EOL + . ' }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{0: int, 1?: int, ...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new ConstExprIntegerNode('0'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new ConstExprIntegerNode('1'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{0:int,1?:int,...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new ConstExprIntegerNode('0'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new ConstExprIntegerNode('1'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{0: int, 1?: int, ... ' . PHP_EOL + . ' < ' . PHP_EOL + . ' string ' . PHP_EOL + . ' > ' . PHP_EOL + . ' , ' . PHP_EOL + . ' }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new ConstExprIntegerNode('0'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new ConstExprIntegerNode('1'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{...<>}', + new ParserException( + '>', + Lexer::TOKEN_CLOSE_ANGLE_BRACKET, + 10, + Lexer::TOKEN_IDENTIFIER + ), + ], + [ + 'array{...}', + new ParserException( + '>', + Lexer::TOKEN_CLOSE_ANGLE_BRACKET, + 14, + Lexer::TOKEN_IDENTIFIER + ), + ], + [ + 'array{...}', + new ParserException( + ',', + Lexer::TOKEN_COMMA, + 21, + Lexer::TOKEN_CLOSE_ANGLE_BRACKET + ), + ], + [ + 'array{...}', + new ParserException( + ',', + Lexer::TOKEN_COMMA, + 21, + Lexer::TOKEN_CLOSE_ANGLE_BRACKET + ), + ], + [ + 'list{...<>}', + new ParserException( + '>', + Lexer::TOKEN_CLOSE_ANGLE_BRACKET, + 9, + Lexer::TOKEN_IDENTIFIER + ), + ], + [ + 'list{...}', + new ParserException( + ',', + Lexer::TOKEN_COMMA, + 12, + Lexer::TOKEN_CLOSE_ANGLE_BRACKET + ), + ], + [ + 'list{...}', + new ParserException( + ',', + Lexer::TOKEN_COMMA, + 12, + Lexer::TOKEN_CLOSE_ANGLE_BRACKET + ), + ], [ 'callable(): Foo', new CallableTypeNode( diff --git a/tests/PHPStan/Printer/PrinterTest.php b/tests/PHPStan/Printer/PrinterTest.php index 07d68af0..24a28236 100644 --- a/tests/PHPStan/Printer/PrinterTest.php +++ b/tests/PHPStan/Printer/PrinterTest.php @@ -28,6 +28,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasImportTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; @@ -51,6 +52,7 @@ use function array_splice; use function array_unshift; use function array_values; +use function assert; use function count; use const PHP_EOL; @@ -1740,6 +1742,128 @@ public function enterNode(Node $node) }, ]; + + yield [ + '/** @return array{foo: int, ...} */', + '/** @return array{foo: int} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->sealed = true; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return array{foo: int, ...} */', + '/** @return array{foo: int, ...} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->unsealedType = new ArrayShapeUnsealedTypeNode(new IdentifierTypeNode('string'), null); + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return array{foo: int, ...} */', + '/** @return array{foo: int, ...} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + assert($node->unsealedType !== null); + $node->unsealedType->keyType = new IdentifierTypeNode('int'); + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return array{foo: int, ...} */', + '/** @return array{foo: int} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->sealed = true; + $node->unsealedType = null; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return list{int, ...} */', + '/** @return list{int} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->sealed = true; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return list{int, ...} */', + '/** @return list{int, ...} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->unsealedType = new ArrayShapeUnsealedTypeNode(new IdentifierTypeNode('string'), null); + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return list{int, ...} */', + '/** @return list{int} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->sealed = true; + $node->unsealedType = null; + } + + return $node; + } + + }, + ]; } /**