Skip to content

Commit e87cb6b

Browse files
committed
Support list/array shapes
1 parent 15db8d8 commit e87cb6b

File tree

4 files changed

+153
-1
lines changed

4 files changed

+153
-1
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"require": {
1717
"php": "^8.2",
1818
"phpstan/phpdoc-parser": "^2.1",
19+
"symfony/polyfill-php84": "^1.33",
1920
"typhoon/type": "^0.6@dev"
2021
},
2122
"require-dev": {

composer.lock

Lines changed: 81 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Internal/ContextualParser.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprTrueNode;
1414
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode;
1515
use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
16+
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
1617
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
1718
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
1819
use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode;
@@ -107,6 +108,7 @@ public function parse(TypeNode $node): Type
107108
$node instanceof UnionTypeNode => orT(...array_map($this->parse(...), $node->types)),
108109
$node instanceof IntersectionTypeNode => andT(...array_map($this->parse(...), $node->types)),
109110
$node instanceof ArrayTypeNode => arrayT(value: $this->parse($node->type)),
111+
$node instanceof ArrayShapeNode => $this->arrayShape($node),
110112
$node instanceof OffsetAccessTypeNode => offsetT($this->parse($node->type), $this->parse($node->offset)),
111113
$node instanceof CallableTypeNode => $this->callable($node),
112114
default => throw new \LogicException(\sprintf('`%s` is not supported', $node::class)),
@@ -303,6 +305,56 @@ private function list(array $templateArguments, bool $isNonEmpty = false): ListT
303305
};
304306
}
305307

308+
private function arrayShape(ArrayShapeNode $node): Type
309+
{
310+
$elements = [];
311+
312+
foreach ($node->items as $item) {
313+
$elements[] = new Type\ArrayElement(
314+
key: match (true) {
315+
$item->keyName === null => \count($elements),
316+
$item->keyName instanceof ConstExprIntegerNode => (int) $item->keyName->value,
317+
$item->keyName instanceof ConstExprStringNode => $item->keyName->value,
318+
$item->keyName instanceof IdentifierTypeNode => $item->keyName->name,
319+
default => throw new \LogicException(),
320+
},
321+
type: $this->parse($item->valueType),
322+
isOptional: $item->optional,
323+
);
324+
}
325+
326+
$valueType = match (true) {
327+
$node->sealed => neverT,
328+
$node->unsealedType === null => mixedT,
329+
default => $this->parse($node->unsealedType->valueType),
330+
};
331+
332+
if (($node->kind === ArrayShapeNode::KIND_NON_EMPTY_LIST || $node->kind === ArrayShapeNode::KIND_LIST)
333+
&& $node->unsealedType?->keyType === null
334+
&& array_column($elements, 'key') === array_keys($elements)
335+
&& array_all($elements, static fn(Type\ArrayElement $e): bool => !$e->isOptional)
336+
) {
337+
return new ListT(
338+
valueType: $valueType,
339+
elementTypes: array_column($elements, 'type'),
340+
isNonEmpty: $node->kind === ArrayShapeNode::KIND_NON_EMPTY_LIST,
341+
);
342+
}
343+
344+
$keyType = match (true) {
345+
$node->sealed => neverT,
346+
$node->unsealedType?->keyType === null => arrayKeyT,
347+
default => $this->parse($node->unsealedType->keyType),
348+
};
349+
350+
return new ArrayT(
351+
keyType: $keyType,
352+
valueType: $valueType,
353+
elements: $elements,
354+
isNonEmpty: $node->kind === ArrayShapeNode::KIND_NON_EMPTY_LIST,
355+
);
356+
}
357+
306358
/**
307359
* @param list<Type> $templateArguments
308360
*/

tests/ParserTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Typhoon\PHPStanTypeParser\Internal\ContextualParser;
1111
use Typhoon\Type;
1212
use function Typhoon\Type\andT;
13+
use function Typhoon\Type\arrayShapeT;
1314
use function Typhoon\Type\arrayT;
1415
use function Typhoon\Type\callableT;
1516
use function Typhoon\Type\classConstantMaskT;
@@ -20,15 +21,19 @@
2021
use function Typhoon\Type\intRangeT;
2122
use function Typhoon\Type\intT;
2223
use function Typhoon\Type\iterableT;
24+
use function Typhoon\Type\listShapeT;
2325
use function Typhoon\Type\listT;
2426
use function Typhoon\Type\namedObjectT;
2527
use function Typhoon\Type\nonEmptyArrayT;
2628
use function Typhoon\Type\nonEmptyListT;
2729
use function Typhoon\Type\nullOrT;
2830
use function Typhoon\Type\offsetT;
31+
use function Typhoon\Type\optional;
2932
use function Typhoon\Type\orT;
3033
use function Typhoon\Type\stringT;
3134
use function Typhoon\Type\template;
35+
use function Typhoon\Type\unsealedArrayShapeT;
36+
use function Typhoon\Type\unsealedListShapeT;
3237
use const Typhoon\Type\arrayKeyT;
3338
use const Typhoon\Type\arrayT;
3439
use const Typhoon\Type\boolT;
@@ -122,10 +127,24 @@ private static function cases(): \Generator
122127
yield 'int&string' => andT(intT, stringT);
123128
yield '(int&string)&float' => andT(andT(intT, stringT), floatT);
124129
yield 'list' => listT();
130+
yield 'list{}' => listShapeT();
131+
yield 'list{int}' => listShapeT([intT]);
132+
yield 'list{a: int}' => arrayShapeT(['a' => intT]);
133+
yield 'list{int, ...}' => unsealedListShapeT([intT]);
134+
yield 'list{int, ...<string>}' => unsealedListShapeT([intT], stringT);
135+
yield 'list{int, 1?: float, ...<string>}' => unsealedArrayShapeT([intT, optional(floatT)], value: stringT);
125136
yield 'non-empty-list' => nonEmptyListT();
126137
yield 'list<string>' => listT(stringT);
127138
yield 'non-empty-list<string>' => nonEmptyListT(stringT);
128139
yield 'array' => arrayT;
140+
yield 'array{}' => arrayShapeT();
141+
yield 'array{a: int}' => arrayShapeT(['a' => intT]);
142+
yield 'array{"a": int}' => arrayShapeT(['a' => intT]);
143+
yield "array{'a': int}" => arrayShapeT(['a' => intT]);
144+
yield 'array{int}' => arrayShapeT([intT]);
145+
yield 'array{int, ...}' => unsealedArrayShapeT([intT]);
146+
yield 'array{int, ...<string>}' => unsealedArrayShapeT([intT], value: stringT);
147+
yield 'array{float, ...<int, string>}' => unsealedArrayShapeT([floatT], intT, stringT);
129148
yield 'string[]' => arrayT(value: stringT);
130149
yield 'array<string>' => arrayT(value: stringT);
131150
yield 'array<int, string>' => arrayT(intT, stringT);

0 commit comments

Comments
 (0)