Skip to content

Commit c96d5eb

Browse files
phpstan-botondrejmirtesclaude
authored
Interpret array shape keys as @template types when a template of that name is in scope
Co-authored-by: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 47f7b00 commit c96d5eb

15 files changed

Lines changed: 1355 additions & 80 deletions

‎phpstan-baseline.neon‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ parameters:
315315
-
316316
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.'
317317
identifier: phpstanApi.instanceofType
318-
count: 3
318+
count: 1
319319
path: src/PhpDoc/TypeNodeResolver.php
320320

321321
-

‎src/PhpDoc/TypeNodeResolver.php‎

Lines changed: 23 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
use PHPStan\Type\IntersectionType;
9292
use PHPStan\Type\IterableType;
9393
use PHPStan\Type\KeyOfType;
94+
use PHPStan\Type\LateResolvableArrayShapeType;
9495
use PHPStan\Type\MixedType;
9596
use PHPStan\Type\NewObjectType;
9697
use PHPStan\Type\NonAcceptingNeverType;
@@ -1139,94 +1140,43 @@ private function resolveCallableReturnTypeAssertions(CallableTypeNode $typeNode,
11391140

11401141
private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $nameScope): Type
11411142
{
1142-
$builder = ConstantArrayTypeBuilder::createEmpty();
1143-
$builder->disableArrayDegradation();
1144-
1145-
$explicitKeyValues = [];
1143+
$items = [];
11461144
foreach ($typeNode->items as $itemNode) {
1147-
if ($itemNode->valueType instanceof CallableTypeNode) {
1148-
$builder->disableClosureDegradation();
1149-
}
1150-
1151-
$offsetType = $this->resolveArrayShapeOffsetType($itemNode, $nameScope);
1152-
if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) {
1153-
$explicitKeyValues[] = $offsetType->getValue();
1154-
}
1155-
$builder->setOffsetValueType($offsetType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional);
1145+
$items[] = [
1146+
$this->resolveArrayShapeOffsetType($itemNode, $nameScope),
1147+
$this->resolve($itemNode->valueType, $nameScope),
1148+
$itemNode->optional,
1149+
];
11561150
}
11571151

1158-
$isList = in_array($typeNode->kind, [
1159-
ArrayShapeNode::KIND_LIST,
1160-
ArrayShapeNode::KIND_NON_EMPTY_LIST,
1161-
], true);
1162-
1152+
$unsealed = null;
11631153
if (!$typeNode->sealed) {
1164-
if ($typeNode->unsealedType === null) {
1165-
if ($isList) {
1166-
$unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0);
1167-
} else {
1168-
$unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
1169-
}
1170-
$builder->makeUnsealed(
1171-
$unsealedKeyType,
1172-
new MixedType(),
1173-
);
1174-
} else {
1175-
if ($typeNode->unsealedType->keyType === null) {
1176-
if ($isList) {
1177-
$unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0);
1178-
} else {
1179-
$unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
1180-
}
1181-
} else {
1182-
$unsealedKeyType = $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope));
1183-
}
1184-
$unsealedKeyFiniteTypes = $unsealedKeyType->getFiniteTypes();
1185-
$unsealedValueType = $this->resolve($typeNode->unsealedType->valueType, $nameScope);
1186-
if (count($unsealedKeyFiniteTypes) > 0) {
1187-
foreach ($unsealedKeyFiniteTypes as $unsealedKeyFiniteType) {
1188-
// Explicit keys own their slot — the unsealed extras
1189-
// describe entries at keys NOT in the explicit set.
1190-
if (
1191-
($unsealedKeyFiniteType instanceof ConstantIntegerType || $unsealedKeyFiniteType instanceof ConstantStringType)
1192-
&& in_array($unsealedKeyFiniteType->getValue(), $explicitKeyValues, true)
1193-
) {
1194-
continue;
1195-
}
1196-
$builder->setOffsetValueType($unsealedKeyFiniteType, $unsealedValueType, true);
1197-
}
1198-
} else {
1199-
$builder->makeUnsealed($unsealedKeyType, $unsealedValueType);
1200-
}
1201-
}
1202-
}
1154+
// A key type that is not written down is derived from the shape kind
1155+
// when the shape gets built, so that it can be printed back as `...`.
1156+
$unsealedKeyType = $typeNode->unsealedType === null || $typeNode->unsealedType->keyType === null
1157+
? null
1158+
: $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope));
12031159

1204-
$arrayType = $builder->getArray();
1160+
$unsealedValueType = $typeNode->unsealedType === null
1161+
? new MixedType()
1162+
: $this->resolve($typeNode->unsealedType->valueType, $nameScope);
12051163

1206-
$accessories = [];
1207-
if ($isList) {
1208-
$accessories[] = new AccessoryArrayListType();
1164+
$unsealed = [$unsealedKeyType, $unsealedValueType];
12091165
}
12101166

1211-
if (in_array($typeNode->kind, [
1212-
ArrayShapeNode::KIND_NON_EMPTY_ARRAY,
1213-
ArrayShapeNode::KIND_NON_EMPTY_LIST,
1214-
], true)) {
1215-
$accessories[] = new NonEmptyArrayType();
1216-
}
1217-
1218-
if (count($accessories) > 0) {
1219-
return TypeCombinator::intersect($arrayType, ...$accessories);
1220-
}
1221-
1222-
return $arrayType;
1167+
return LateResolvableArrayShapeType::create($items, $unsealed, $typeNode->kind);
12231168
}
12241169

12251170
private function resolveArrayShapeOffsetType(ArrayShapeItemNode $itemNode, NameScope $nameScope): ?Type
12261171
{
12271172
if ($itemNode->keyName instanceof ConstExprIntegerNode) {
12281173
return new ConstantIntegerType((int) $itemNode->keyName->value);
12291174
} elseif ($itemNode->keyName instanceof IdentifierTypeNode) {
1175+
$templateType = $nameScope->resolveTemplateTypeName($itemNode->keyName->name);
1176+
if ($templateType !== null) {
1177+
return $templateType;
1178+
}
1179+
12301180
return new ConstantStringType($itemNode->keyName->name);
12311181
} elseif ($itemNode->keyName instanceof ConstExprStringNode) {
12321182
return new ConstantStringType($itemNode->keyName->value);

‎src/Type/Constant/ConstantArrayType.php‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2802,10 +2802,11 @@ public function traverse(callable $cb): Type
28022802
$unsealed = $this->unsealed;
28032803
if ($unsealed !== null) {
28042804
[$unsealedKeyType, $unsealedValueType] = $unsealed;
2805+
$transformedUnsealedKeyType = $cb($unsealedKeyType);
28052806
$transformedUnsealedValueType = $cb($unsealedValueType);
2806-
if ($transformedUnsealedValueType !== $unsealedValueType) {
2807+
if ($transformedUnsealedKeyType !== $unsealedKeyType || $transformedUnsealedValueType !== $unsealedValueType) {
28072808
$stillOriginal = false;
2808-
$unsealed = [$unsealedKeyType, $transformedUnsealedValueType];
2809+
$unsealed = [$transformedUnsealedKeyType, $transformedUnsealedValueType];
28092810
}
28102811
}
28112812

@@ -2838,10 +2839,11 @@ public function traverseSimultaneously(Type $right, callable $cb): Type
28382839
$unsealed = $this->unsealed;
28392840
if ($unsealed !== null) {
28402841
[$unsealedKeyType, $unsealedValueType] = $unsealed;
2842+
$transformedUnsealedKeyType = $cb($unsealedKeyType, $right->getIterableKeyType());
28412843
$transformedUnsealedValueType = $cb($unsealedValueType, $right->getIterableValueType());
2842-
if ($transformedUnsealedValueType !== $unsealedValueType) {
2844+
if ($transformedUnsealedKeyType !== $unsealedKeyType || $transformedUnsealedValueType !== $unsealedValueType) {
28432845
$stillOriginal = false;
2844-
$unsealed = [$unsealedKeyType, $transformedUnsealedValueType];
2846+
$unsealed = [$transformedUnsealedKeyType, $transformedUnsealedValueType];
28452847
}
28462848
}
28472849

‎src/Type/Constant/ConstantArrayTypeBuilder.php‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,33 @@ public function makeUnsealed(Type $keyType, Type $valueType): void
9898
$this->unsealed = [$keyType, $valueType];
9999
}
100100

101+
/**
102+
* Adds unsealed extras on top of the ones the builder already collected
103+
* instead of replacing them.
104+
*
105+
* Offsets the builder could not give a slot of their own (a non-constant
106+
* key like `string`) end up in the unsealed extras, so overwriting them
107+
* would drop their value type.
108+
*/
109+
public function mergeUnsealed(Type $keyType, Type $valueType): void
110+
{
111+
if ($this->unsealed === null) {
112+
$this->unsealed = [$keyType, $valueType];
113+
return;
114+
}
115+
116+
[$existingKey, $existingValue] = $this->unsealed;
117+
if ($existingKey instanceof NeverType && $existingKey->isExplicit()) {
118+
$this->unsealed = [$keyType, $valueType];
119+
return;
120+
}
121+
122+
$this->unsealed = [
123+
TypeCombinator::union($existingKey, $keyType),
124+
TypeCombinator::union($existingValue, $valueType),
125+
];
126+
}
127+
101128
public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $optional = false): void
102129
{
103130
if ($offsetType !== null) {

0 commit comments

Comments
 (0)