Skip to content

Commit c6ffc4b

Browse files
arnaud-lbondrejmirtes
authored andcommitted
Array shapes support
1 parent ab518a5 commit c6ffc4b

File tree

6 files changed

+308
-0
lines changed

6 files changed

+308
-0
lines changed

doc/grammars/type.abnf

+12
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ CallableReturnType
5252
Array
5353
= 1*(TokenSquareBracketOpen TokenSquareBracketClose)
5454

55+
ArrayShape
56+
= TokenCurlyBracketOpen ArrayShapeItem *(TokenComma ArrayShapeItem) TokenCurlyBracketClose
57+
58+
ArrayShapeItem
59+
= (ConstantString / ConstantInt / TokenIdentifier) TokenNullable TokenColon Type
60+
/ Type
5561

5662
; ---------------------------------------------------------------------------- ;
5763
; ConstantExpr ;
@@ -139,6 +145,12 @@ TokenSquareBracketOpen
139145
TokenSquareBracketClose
140146
= "]" *ByteHorizontalWs
141147

148+
TokenCurlyBracketOpen
149+
= "{" *ByteHorizontalWs
150+
151+
TokenCurlyBracketClose
152+
= "}" *ByteHorizontalWs
153+
142154
TokenComma
143155
= "," *ByteHorizontalWs
144156

src/Ast/Type/ArrayShapeItemNode.php

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\PhpDocParser\Ast\Type;
4+
5+
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode;
6+
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode;
7+
8+
class ArrayShapeItemNode implements TypeNode
9+
{
10+
11+
/** @var ConstExprStringNode|ConstExprIntegerNode|IdentifierTypeNode|null */
12+
public $keyName;
13+
14+
/** @var bool */
15+
public $optional;
16+
17+
/** @var TypeNode */
18+
public $valueType;
19+
20+
/**
21+
* @param ConstExprStringNode|ConstExprIntegerNode|IdentifierTypeNode|null $keyName
22+
*/
23+
public function __construct($keyName, bool $optional, TypeNode $valueType)
24+
{
25+
$this->keyName = $keyName;
26+
$this->optional = $optional;
27+
$this->valueType = $valueType;
28+
}
29+
30+
31+
public function __toString(): string
32+
{
33+
if ($this->keyName !== null) {
34+
return sprintf(
35+
'%s%s: %s',
36+
(string) $this->keyName,
37+
$this->optional ? '?' : '',
38+
(string) $this->valueType
39+
);
40+
}
41+
42+
return (string) $this->valueType;
43+
}
44+
45+
}

src/Ast/Type/ArrayShapeNode.php

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\PhpDocParser\Ast\Type;
4+
5+
class ArrayShapeNode implements TypeNode
6+
{
7+
8+
/** @var ArrayShapeItemNode[] */
9+
public $items;
10+
11+
public function __construct(array $items)
12+
{
13+
$this->items = $items;
14+
}
15+
16+
17+
public function __toString(): string
18+
{
19+
return 'array{' . implode(', ', $this->items) . '}';
20+
}
21+
22+
}

src/Lexer/Lexer.php

+6
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ class Lexer
1818
public const TOKEN_CLOSE_ANGLE_BRACKET = 7;
1919
public const TOKEN_OPEN_SQUARE_BRACKET = 8;
2020
public const TOKEN_CLOSE_SQUARE_BRACKET = 9;
21+
public const TOKEN_OPEN_CURLY_BRACKET = 30;
22+
public const TOKEN_CLOSE_CURLY_BRACKET = 31;
2123
public const TOKEN_COMMA = 10;
2224
public const TOKEN_COLON = 29;
2325
public const TOKEN_VARIADIC = 11;
@@ -50,6 +52,8 @@ class Lexer
5052
self::TOKEN_CLOSE_ANGLE_BRACKET => '\'>\'',
5153
self::TOKEN_OPEN_SQUARE_BRACKET => '\'[\'',
5254
self::TOKEN_CLOSE_SQUARE_BRACKET => '\']\'',
55+
self::TOKEN_OPEN_CURLY_BRACKET => '\'{\'',
56+
self::TOKEN_CLOSE_CURLY_BRACKET => '\'}\'',
5357
self::TOKEN_COMMA => '\',\'',
5458
self::TOKEN_COLON => '\':\'',
5559
self::TOKEN_VARIADIC => '\'...\'',
@@ -123,6 +127,8 @@ private function initialize(): void
123127
self::TOKEN_CLOSE_ANGLE_BRACKET => '>',
124128
self::TOKEN_OPEN_SQUARE_BRACKET => '\\[',
125129
self::TOKEN_CLOSE_SQUARE_BRACKET => '\\]',
130+
self::TOKEN_OPEN_CURLY_BRACKET => '\\{',
131+
self::TOKEN_CLOSE_CURLY_BRACKET => '\\}',
126132

127133
self::TOKEN_COMMA => ',',
128134
self::TOKEN_VARIADIC => '\\.\\.\\.',

src/Parser/TypeParser.php

+69
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode
5353

5454
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) {
5555
$type = $this->tryParseArray($tokens, $type);
56+
57+
} elseif ($type->name === 'array' && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET)) {
58+
$type = $this->parseArrayShape($tokens, $type);
5659
}
5760
}
5861

@@ -93,6 +96,9 @@ private function parseNullable(TokenIterator $tokens): Ast\Type\TypeNode
9396

9497
if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) {
9598
$type = $this->parseGeneric($tokens, $type);
99+
100+
} elseif ($type->name === 'array' && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET)) {
101+
$type = $this->parseArrayShape($tokens, $type);
96102
}
97103

98104
return new Ast\Type\NullableTypeNode($type);
@@ -167,6 +173,9 @@ private function parseCallableReturnType(TokenIterator $tokens): Ast\Type\TypeNo
167173

168174
if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) {
169175
$type = $this->parseGeneric($tokens, $type);
176+
177+
} elseif ($type->name === 'array' && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET)) {
178+
$type = $this->parseArrayShape($tokens, $type);
170179
}
171180
}
172181

@@ -208,4 +217,64 @@ private function tryParseArray(TokenIterator $tokens, Ast\Type\TypeNode $type):
208217
return $type;
209218
}
210219

220+
221+
private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type): Ast\Type\TypeNode
222+
{
223+
$tokens->consumeTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET);
224+
$items = [$this->parseArrayShapeItem($tokens)];
225+
226+
while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) {
227+
$items[] = $this->parseArrayShapeItem($tokens);
228+
}
229+
230+
$tokens->consumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET);
231+
232+
return new Ast\Type\ArrayShapeNode($items);
233+
}
234+
235+
236+
private function parseArrayShapeItem(TokenIterator $tokens): Ast\Type\ArrayShapeItemNode
237+
{
238+
try {
239+
$tokens->pushSavePoint();
240+
$key = $this->parseArrayShapeKey($tokens);
241+
$optional = $tokens->tryConsumeTokenType(Lexer::TOKEN_NULLABLE);
242+
$tokens->consumeTokenType(Lexer::TOKEN_COLON);
243+
$value = $this->parse($tokens);
244+
$tokens->dropSavePoint();
245+
246+
return new Ast\Type\ArrayShapeItemNode($key, $optional, $value);
247+
} catch (\PHPStan\PhpDocParser\Parser\ParserException $e) {
248+
$tokens->rollback();
249+
$value = $this->parse($tokens);
250+
251+
return new Ast\Type\ArrayShapeItemNode(null, false, $value);
252+
}
253+
}
254+
255+
/**
256+
* @return Ast\ConstExpr\ConstExprStringNode|Ast\ConstExpr\ConstExprIntegerNode|Ast\Type\IdentifierTypeNode
257+
*/
258+
private function parseArrayShapeKey(TokenIterator $tokens)
259+
{
260+
if ($tokens->isCurrentTokenType(Lexer::TOKEN_SINGLE_QUOTED_STRING)) {
261+
$key = new Ast\ConstExpr\ConstExprStringNode($tokens->currentTokenValue());
262+
$tokens->next();
263+
264+
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_QUOTED_STRING)) {
265+
$key = new Ast\ConstExpr\ConstExprStringNode($tokens->currentTokenValue());
266+
$tokens->next();
267+
268+
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_INTEGER)) {
269+
$key = new Ast\ConstExpr\ConstExprIntegerNode($tokens->currentTokenValue());
270+
$tokens->next();
271+
272+
} else {
273+
$key = new Ast\Type\IdentifierTypeNode($tokens->currentTokenValue());
274+
$tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
275+
}
276+
277+
return $key;
278+
}
279+
211280
}

tests/PHPStan/Parser/TypeParserTest.php

+154
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
namespace PHPStan\PhpDocParser\Parser;
44

5+
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode;
6+
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode;
7+
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode;
8+
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
59
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
610
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
711
use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode;
@@ -264,6 +268,142 @@ public function provideParseData(): array
264268
]
265269
),
266270
],
271+
[
272+
'array{\'a\': int}',
273+
new ArrayShapeNode([
274+
new ArrayShapeItemNode(
275+
new ConstExprStringNode('\'a\''),
276+
false,
277+
new IdentifierTypeNode('int')
278+
),
279+
]),
280+
],
281+
[
282+
'array{\'a\': ?int}',
283+
new ArrayShapeNode([
284+
new ArrayShapeItemNode(
285+
new ConstExprStringNode('\'a\''),
286+
false,
287+
new NullableTypeNode(
288+
new IdentifierTypeNode('int')
289+
)
290+
),
291+
]),
292+
],
293+
[
294+
'array{\'a\'?: ?int}',
295+
new ArrayShapeNode([
296+
new ArrayShapeItemNode(
297+
new ConstExprStringNode('\'a\''),
298+
true,
299+
new NullableTypeNode(
300+
new IdentifierTypeNode('int')
301+
)
302+
),
303+
]),
304+
],
305+
[
306+
'array{\'a\': int, \'b\': string}',
307+
new ArrayShapeNode([
308+
new ArrayShapeItemNode(
309+
new ConstExprStringNode('\'a\''),
310+
false,
311+
new IdentifierTypeNode('int')
312+
),
313+
new ArrayShapeItemNode(
314+
new ConstExprStringNode('\'b\''),
315+
false,
316+
new IdentifierTypeNode('string')
317+
),
318+
]),
319+
],
320+
[
321+
'array{int, string, "a": string}',
322+
new ArrayShapeNode([
323+
new ArrayShapeItemNode(
324+
null,
325+
false,
326+
new IdentifierTypeNode('int')
327+
),
328+
new ArrayShapeItemNode(
329+
null,
330+
false,
331+
new IdentifierTypeNode('string')
332+
),
333+
new ArrayShapeItemNode(
334+
new ConstExprStringNode('"a"'),
335+
false,
336+
new IdentifierTypeNode('string')
337+
),
338+
]),
339+
],
340+
[
341+
'array{"a"?: int, \'b\': string, 0: int, 1?: DateTime, hello: string}',
342+
new ArrayShapeNode([
343+
new ArrayShapeItemNode(
344+
new ConstExprStringNode('"a"'),
345+
true,
346+
new IdentifierTypeNode('int')
347+
),
348+
new ArrayShapeItemNode(
349+
new ConstExprStringNode('\'b\''),
350+
false,
351+
new IdentifierTypeNode('string')
352+
),
353+
new ArrayShapeItemNode(
354+
new ConstExprIntegerNode('0'),
355+
false,
356+
new IdentifierTypeNode('int')
357+
),
358+
new ArrayShapeItemNode(
359+
new ConstExprIntegerNode('1'),
360+
true,
361+
new IdentifierTypeNode('DateTime')
362+
),
363+
new ArrayShapeItemNode(
364+
new IdentifierTypeNode('hello'),
365+
false,
366+
new IdentifierTypeNode('string')
367+
),
368+
]),
369+
],
370+
[
371+
'array{\'a\': int, \'b\': array{\'c\': callable(): int}}',
372+
new ArrayShapeNode([
373+
new ArrayShapeItemNode(
374+
new ConstExprStringNode('\'a\''),
375+
false,
376+
new IdentifierTypeNode('int')
377+
),
378+
new ArrayShapeItemNode(
379+
new ConstExprStringNode('\'b\''),
380+
false,
381+
new ArrayShapeNode([
382+
new ArrayShapeItemNode(
383+
new ConstExprStringNode('\'c\''),
384+
false,
385+
new CallableTypeNode(
386+
new IdentifierTypeNode('callable'),
387+
[],
388+
new IdentifierTypeNode('int')
389+
)
390+
),
391+
])
392+
),
393+
]),
394+
],
395+
[
396+
'?array{\'a\': int}',
397+
new NullableTypeNode(
398+
new ArrayShapeNode([
399+
new ArrayShapeItemNode(
400+
new ConstExprStringNode('\'a\''),
401+
false,
402+
new IdentifierTypeNode('int')
403+
),
404+
])
405+
),
406+
],
267407
[
268408
'callable(): Foo',
269409
new CallableTypeNode(
@@ -339,6 +479,20 @@ public function provideParseData(): array
339479
])
340480
),
341481
],
482+
[
483+
'callable(): array{\'a\': int}',
484+
new CallableTypeNode(
485+
new IdentifierTypeNode('callable'),
486+
[],
487+
new ArrayShapeNode([
488+
new ArrayShapeItemNode(
489+
new ConstExprStringNode('\'a\''),
490+
false,
491+
new IdentifierTypeNode('int')
492+
),
493+
])
494+
),
495+
],
342496
[
343497
'callable(A&...$a=, B&...=, C): Foo',
344498
new CallableTypeNode(

0 commit comments

Comments
 (0)