From 1da2721aed769c15011a3852fbf29c42fdc971c9 Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Mon, 3 Jun 2019 20:17:12 +0200 Subject: [PATCH] Array shapes support --- doc/grammars/type.abnf | 12 ++ src/Lexer/Lexer.php | 6 + src/Parser/TypeParser.php | 71 +++++++++++ tests/PHPStan/Parser/TypeParserTest.php | 154 ++++++++++++++++++++++++ 4 files changed, 243 insertions(+) diff --git a/doc/grammars/type.abnf b/doc/grammars/type.abnf index 166f819a..7e9da9af 100644 --- a/doc/grammars/type.abnf +++ b/doc/grammars/type.abnf @@ -52,6 +52,12 @@ CallableReturnType Array = 1*(TokenSquareBracketOpen TokenSquareBracketClose) +ArrayShape + = TokenCurlyBracketOpen ArrayItem *(TokenComma ArrayItem) TokenCurlyBracketClose + +ArrayItem + = (ConstantString / ConstantInt / TokenIdentifier) TokenNullable TokenColon Type + / Type ; ---------------------------------------------------------------------------- ; ; ConstantExpr ; @@ -139,6 +145,12 @@ TokenSquareBracketOpen TokenSquareBracketClose = "]" *ByteHorizontalWs +TokenCurlyBracketOpen + = "{" *ByteHorizontalWs + +TokenCurlyBracketClose + = "}" *ByteHorizontalWs + TokenComma = "," *ByteHorizontalWs diff --git a/src/Lexer/Lexer.php b/src/Lexer/Lexer.php index 841b0a11..a8fe3d87 100644 --- a/src/Lexer/Lexer.php +++ b/src/Lexer/Lexer.php @@ -18,6 +18,8 @@ class Lexer public const TOKEN_CLOSE_ANGLE_BRACKET = 7; public const TOKEN_OPEN_SQUARE_BRACKET = 8; public const TOKEN_CLOSE_SQUARE_BRACKET = 9; + public const TOKEN_OPEN_CURLY_BRACKET = 30; + public const TOKEN_CLOSE_CURLY_BRACKET = 31; public const TOKEN_COMMA = 10; public const TOKEN_COLON = 29; public const TOKEN_VARIADIC = 11; @@ -50,6 +52,8 @@ class Lexer self::TOKEN_CLOSE_ANGLE_BRACKET => '\'>\'', self::TOKEN_OPEN_SQUARE_BRACKET => '\'[\'', self::TOKEN_CLOSE_SQUARE_BRACKET => '\']\'', + self::TOKEN_OPEN_CURLY_BRACKET => '\'{\'', + self::TOKEN_CLOSE_CURLY_BRACKET => '\'}\'', self::TOKEN_COMMA => '\',\'', self::TOKEN_COLON => '\':\'', self::TOKEN_VARIADIC => '\'...\'', @@ -123,6 +127,8 @@ private function initialize(): void self::TOKEN_CLOSE_ANGLE_BRACKET => '>', self::TOKEN_OPEN_SQUARE_BRACKET => '\\[', self::TOKEN_CLOSE_SQUARE_BRACKET => '\\]', + self::TOKEN_OPEN_CURLY_BRACKET => '\\{', + self::TOKEN_CLOSE_CURLY_BRACKET => '\\}', self::TOKEN_COMMA => ',', self::TOKEN_VARIADIC => '\\.\\.\\.', diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 596a0d52..07460bb4 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -3,6 +3,8 @@ namespace PHPStan\PhpDocParser\Parser; use PHPStan\PhpDocParser\Ast; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Lexer\Lexer; class TypeParser @@ -53,6 +55,9 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { $type = $this->tryParseArray($tokens, $type); + + } elseif ($type->name === 'array' && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET)) { + $type = $this->parseArrayShape($tokens, $type); } } @@ -93,6 +98,9 @@ private function parseNullable(TokenIterator $tokens): Ast\Type\TypeNode if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { $type = $this->parseGeneric($tokens, $type); + + } elseif ($type->name === 'array' && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET)) { + $type = $this->parseArrayShape($tokens, $type); } return new Ast\Type\NullableTypeNode($type); @@ -167,6 +175,9 @@ private function parseCallableReturnType(TokenIterator $tokens): Ast\Type\TypeNo if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { $type = $this->parseGeneric($tokens, $type); + + } elseif ($type->name === 'array' && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET)) { + $type = $this->parseArrayShape($tokens, $type); } } @@ -208,4 +219,64 @@ private function tryParseArray(TokenIterator $tokens, Ast\Type\TypeNode $type): return $type; } + + private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type): Ast\Type\TypeNode + { + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET); + $items = [$this->parseArrayShapeItem($tokens)]; + + while (!$tokens->tryConsumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET)) { + $tokens->consumeTokenType(Lexer::TOKEN_COMMA); + $items[] = $this->parseArrayShapeItem($tokens); + } + + return new Ast\Type\ArrayShapeNode($items); + } + + + private function parseArrayShapeItem(TokenIterator $tokens): Ast\Type\ArrayItemNode + { + try { + $tokens->pushSavePoint(); + $key = $this->parseArrayShapeKey($tokens); + $optional = $tokens->tryConsumeTokenType(Lexer::TOKEN_NULLABLE); + $tokens->consumeTokenType(Lexer::TOKEN_COLON); + $value = $this->parse($tokens); + $tokens->dropSavePoint(); + + return new Ast\Type\ArrayItemNode($key, $optional, $value); + } catch (\PHPStan\PhpDocParser\Parser\ParserException $e) { + $tokens->rollback(); + $value = $this->parse($tokens); + + return new Ast\Type\ArrayItemNode(null, $optional, $value); + } + } + + /** + * @return ConstExprStringNode|ConstExprIntegerNode|IdentifierTypeNode + */ + private function parseArrayShapeKey(TokenIterator $tokens) + { + if ($tokens->isCurrentTokenType(Lexer::TOKEN_SINGLE_QUOTED_STRING)) { + $key = new ConstExprStringNode($tokens->currentTokenValue()); + $tokens->next(); + + } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_QUOTED_STRING)) { + $key = new ConstExprStringNode($tokens->currentTokenValue()); + $tokens->next(); + + } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_INTEGER)) { + $key = new ConstExprIntegerNode($tokens->currentTokenValue()); + $tokens->next(); + + } else { + $key = new Ast\Type\IdentifierTypeNode($tokens->currentTokenValue()); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + } + + return $key; + } + + } diff --git a/tests/PHPStan/Parser/TypeParserTest.php b/tests/PHPStan/Parser/TypeParserTest.php index da5852eb..0f4fca75 100644 --- a/tests/PHPStan/Parser/TypeParserTest.php +++ b/tests/PHPStan/Parser/TypeParserTest.php @@ -2,6 +2,10 @@ namespace PHPStan\PhpDocParser\Parser; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayItemNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; @@ -264,6 +268,142 @@ public function provideParseData(): array ] ), ], + [ + 'array{\'a\': int}', + new ArrayShapeNode([ + new ArrayItemNode( + new ConstExprStringNode('\'a\''), + false, + new IdentifierTypeNode('int') + ), + ]), + ], + [ + 'array{\'a\': ?int}', + new ArrayShapeNode([ + new ArrayItemNode( + new ConstExprStringNode('\'a\''), + false, + new NullableTypeNode( + new IdentifierTypeNode('int') + ) + ), + ]), + ], + [ + 'array{\'a\'?: ?int}', + new ArrayShapeNode([ + new ArrayItemNode( + new ConstExprStringNode('\'a\''), + true, + new NullableTypeNode( + new IdentifierTypeNode('int') + ) + ), + ]), + ], + [ + 'array{\'a\': int, \'b\': string}', + new ArrayShapeNode([ + new ArrayItemNode( + new ConstExprStringNode('\'a\''), + false, + new IdentifierTypeNode('int') + ), + new ArrayItemNode( + new ConstExprStringNode('\'b\''), + false, + new IdentifierTypeNode('string') + ), + ]), + ], + [ + 'array{int, string, "a": string}', + new ArrayShapeNode([ + new ArrayItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayItemNode( + null, + false, + new IdentifierTypeNode('string') + ), + new ArrayItemNode( + new ConstExprStringNode('"a"'), + false, + new IdentifierTypeNode('string') + ), + ]), + ], + [ + 'array{"a"?: int, \'b\': string, 0: int, 1?: DateTime, hello: string}', + new ArrayShapeNode([ + new ArrayItemNode( + new ConstExprStringNode('"a"'), + true, + new IdentifierTypeNode('int') + ), + new ArrayItemNode( + new ConstExprStringNode('\'b\''), + false, + new IdentifierTypeNode('string') + ), + new ArrayItemNode( + new ConstExprIntegerNode('0'), + false, + new IdentifierTypeNode('int') + ), + new ArrayItemNode( + new ConstExprIntegerNode('1'), + true, + new IdentifierTypeNode('DateTime') + ), + new ArrayItemNode( + new IdentifierTypeNode('hello'), + false, + new IdentifierTypeNode('string') + ), + ]), + ], + [ + 'array{\'a\': int, \'b\': array{\'c\': callable(): int}}', + new ArrayShapeNode([ + new ArrayItemNode( + new ConstExprStringNode('\'a\''), + false, + new IdentifierTypeNode('int') + ), + new ArrayItemNode( + new ConstExprStringNode('\'b\''), + false, + new ArrayShapeNode([ + new ArrayItemNode( + new ConstExprStringNode('\'c\''), + false, + new CallableTypeNode( + new IdentifierTypeNode('callable'), + [], + new IdentifierTypeNode('int') + ) + ), + ]) + ), + ]), + ], + [ + '?array{\'a\': int}', + new NullableTypeNode( + new ArrayShapeNode([ + new ArrayItemNode( + new ConstExprStringNode('\'a\''), + false, + new IdentifierTypeNode('int') + ), + ]) + ), + ], [ 'callable(): Foo', new CallableTypeNode( @@ -339,6 +479,20 @@ public function provideParseData(): array ]) ), ], + [ + 'callable(): array{\'a\': int}', + new CallableTypeNode( + new IdentifierTypeNode('callable'), + [], + new ArrayShapeNode([ + new ArrayItemNode( + new ConstExprStringNode('\'a\''), + false, + new IdentifierTypeNode('int') + ), + ]) + ), + ], [ 'callable(A&...$a=, B&...=, C): Foo', new CallableTypeNode(