Skip to content

Commit d3f1638

Browse files
committed
Harden template-aware array merge inference
Preserve deferred template alternatives across PHPStan traversal while pruning uninhabited array branches for inference. Keep validation conservative and add compatibility-focused regression coverage for nested and specialized templates.
1 parent fe4c6b0 commit d3f1638

8 files changed

Lines changed: 1432 additions & 60 deletions

src/ArrayMergeType.php

Lines changed: 135 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@
4545
use PHPStan\Type\Traits\NonGeneralizableTypeTrait;
4646
use PHPStan\Type\Type;
4747
use PHPStan\Type\TypeCombinator;
48-
use PHPStan\Type\TypeTraverser;
4948
use PHPStan\Type\TypeUtils;
5049
use PHPStan\Type\UnionType;
5150
use PHPStan\Type\VerbosityLevel;
51+
use SplObjectStorage;
5252
use function array_fill_keys;
5353
use function is_callable;
5454
use function sprintf;
@@ -133,13 +133,23 @@ public function describe(VerbosityLevel $level): string
133133

134134
public function isResolvable(): bool
135135
{
136+
$hasTemplateType = false;
137+
136138
foreach ($this->types as $type) {
137-
if (TypeUtils::containsTemplateType($type)) {
138-
return false;
139+
$type = self::removeTopLevelNeverAlternatives($type);
140+
141+
if (!TypeUtils::containsTemplateType($type)) {
142+
continue;
143+
}
144+
145+
if (!$type->isArray()->yes()) {
146+
return true;
139147
}
148+
149+
$hasTemplateType = true;
140150
}
141151

142-
return true;
152+
return !$hasTemplateType;
143153
}
144154

145155
protected function getResult(): Type
@@ -150,7 +160,7 @@ protected function getResult(): Type
150160
$types = [];
151161

152162
foreach ($this->types as $type) {
153-
$type = self::removeUninhabitedConstantArrays($type);
163+
$type = self::normalizeUninhabitedArraysForInference($type);
154164
$type = self::removeTopLevelNeverAlternatives($type);
155165

156166
if ($type instanceof NeverType) {
@@ -366,69 +376,138 @@ private static function normalizeArrayMergeKeyType(Type $keyType): Type
366376
return $keyType instanceof MixedType ? $keyType : $keyType->toArrayKey();
367377
}
368378

369-
private static function removeUninhabitedConstantArrays(Type $type): Type
379+
/** @internal */
380+
public static function normalizeUninhabitedArrays(Type $type): Type
370381
{
371-
return TypeTraverser::map($type, static function (Type $innerType, callable $traverse): Type {
372-
$innerType = $traverse($innerType);
373-
374-
if ($innerType instanceof ConstantArrayType) {
375-
$hasOptionalNever = false;
376-
$hasIntegerKey = false;
377-
$keyTypes = [];
378-
$valueTypes = [];
379-
$optionalKeys = [];
380-
$optionalKeyLookup = array_fill_keys($innerType->getOptionalKeys(), true);
381-
382-
foreach ($innerType->getKeyTypes() as $i => $keyType) {
383-
$valueType = $innerType->getValueTypes()[$i];
384-
$hasIntegerKey = $hasIntegerKey || $keyType instanceof ConstantIntegerType;
385-
$isOptionalKey = isset($optionalKeyLookup[$i]);
386-
387-
if ($valueType instanceof NeverType) {
388-
if (!$isOptionalKey) {
389-
return new NeverType();
390-
}
382+
/** @var SplObjectStorage<Type, Type> $normalizedTypes */
383+
$normalizedTypes = new SplObjectStorage();
391384

392-
$hasOptionalNever = true;
393-
continue;
394-
}
385+
return self::normalizeUninhabitedArraysWithMemo($type, $normalizedTypes, false);
386+
}
395387

396-
if ($isOptionalKey) {
397-
$optionalKeys[] = count($keyTypes);
398-
}
388+
private static function normalizeUninhabitedArraysForInference(Type $type): Type
389+
{
390+
/** @var SplObjectStorage<Type, Type> $normalizedTypes */
391+
$normalizedTypes = new SplObjectStorage();
399392

400-
$keyTypes[] = $keyType;
401-
$valueTypes[] = $valueType;
402-
}
393+
return self::normalizeUninhabitedArraysWithMemo($type, $normalizedTypes, true);
394+
}
403395

404-
if (!$hasOptionalNever) {
405-
return $innerType;
406-
}
396+
/**
397+
* @param SplObjectStorage<Type, Type> $normalizedTypes
398+
*/
399+
private static function normalizeUninhabitedArraysWithMemo(
400+
Type $type,
401+
SplObjectStorage $normalizedTypes,
402+
bool $useTemplateBounds,
403+
): Type {
404+
if (isset($normalizedTypes[$type])) {
405+
return $normalizedTypes[$type];
406+
}
407407

408-
if (self::hasUnknownExtraOffsets($innerType)) {
409-
return $innerType;
410-
}
408+
// Break cycles conservatively while this node is being normalized.
409+
$normalizedTypes[$type] = $type;
411410

412-
if ([0] !== $innerType->getNextAutoIndexes()) {
413-
return $innerType;
414-
}
411+
if ($type instanceof TemplateType) {
412+
$bound = self::normalizeUninhabitedArraysWithMemo(
413+
$type->getBound(),
414+
$normalizedTypes,
415+
$useTemplateBounds,
416+
);
417+
$result = $bound instanceof NeverType || $useTemplateBounds ? $bound : $type;
418+
$normalizedTypes[$type] = $result;
419+
420+
return $result;
421+
}
415422

416-
if ($hasIntegerKey) {
417-
return $innerType;
423+
$traversedType = $type->traverse(
424+
static fn(Type $innerType): Type =>
425+
self::normalizeUninhabitedArraysWithMemo(
426+
$innerType,
427+
$normalizedTypes,
428+
$useTemplateBounds,
429+
),
430+
);
431+
$result = self::normalizeTraversedUninhabitedArray($traversedType);
432+
$normalizedTypes[$type] = $result;
433+
434+
return $result;
435+
}
436+
437+
private static function normalizeTraversedUninhabitedArray(Type $type): Type
438+
{
439+
if (
440+
$type->isArray()->yes()
441+
&& $type->isIterableAtLeastOnce()->yes()
442+
&& (
443+
$type->getIterableKeyType() instanceof NeverType
444+
|| $type->getIterableValueType() instanceof NeverType
445+
)
446+
) {
447+
return new NeverType();
448+
}
449+
450+
$constantArrays = $type->getConstantArrays();
451+
452+
if (count($constantArrays) !== 1 || $type !== $constantArrays[0]) {
453+
return $type;
454+
}
455+
456+
$type = $constantArrays[0];
457+
458+
$hasOptionalNever = false;
459+
$hasIntegerKey = false;
460+
$keyTypes = [];
461+
$valueTypes = [];
462+
$optionalKeys = [];
463+
$optionalKeyLookup = array_fill_keys($type->getOptionalKeys(), true);
464+
465+
foreach ($type->getKeyTypes() as $i => $keyType) {
466+
$valueType = $type->getValueTypes()[$i];
467+
$hasIntegerKey = $hasIntegerKey || $keyType instanceof ConstantIntegerType;
468+
$isOptionalKey = isset($optionalKeyLookup[$i]);
469+
470+
if ($valueType instanceof NeverType) {
471+
if (!$isOptionalKey) {
472+
return new NeverType();
418473
}
419474

420-
return new ConstantArrayType(
421-
$keyTypes,
422-
$valueTypes,
423-
$innerType->getNextAutoIndexes(),
424-
$optionalKeys,
425-
[] === $keyTypes ? TrinaryLogic::createYes() : $innerType->isList(),
426-
self::getUnsealedTypes($innerType),
427-
);
475+
$hasOptionalNever = true;
476+
continue;
477+
}
478+
479+
if ($isOptionalKey) {
480+
$optionalKeys[] = count($keyTypes);
428481
}
429482

430-
return $innerType;
431-
});
483+
$keyTypes[] = $keyType;
484+
$valueTypes[] = $valueType;
485+
}
486+
487+
if (!$hasOptionalNever) {
488+
return $type;
489+
}
490+
491+
if (self::hasUnknownExtraOffsets($type)) {
492+
return $type;
493+
}
494+
495+
if ([0] !== $type->getNextAutoIndexes()) {
496+
return $type;
497+
}
498+
499+
if ($hasIntegerKey) {
500+
return $type;
501+
}
502+
503+
return new ConstantArrayType(
504+
$keyTypes,
505+
$valueTypes,
506+
$type->getNextAutoIndexes(),
507+
$optionalKeys,
508+
[] === $keyTypes ? TrinaryLogic::createYes() : $type->isList(),
509+
self::getUnsealedTypes($type),
510+
);
432511
}
433512

434513
private static function getOptionalMethod(object $object, string $method): ?Closure

src/ArrayMergeTypeNodeResolverExtension.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use PHPStan\Type\BenevolentUnionType;
3434
use PHPStan\Type\ErrorType;
3535
use PHPStan\Type\Generic\TemplateType;
36+
use PHPStan\Type\NeverType;
3637
use PHPStan\Type\Type;
3738
use PHPStan\Type\TypeUtils;
3839
use PHPStan\Type\UnionType;
@@ -147,13 +148,27 @@ private function resolveOperandUnionType(
147148
NameScope $nameScope,
148149
): Type {
149150
$resolvedTypes = [];
151+
$survivingTypeNodes = [];
150152

151153
foreach ($typeNode->types as $innerTypeNode) {
152-
$resolvedTypes[] = $this->typeNodeResolver->resolve($innerTypeNode, $nameScope);
154+
$resolvedType = ArrayMergeType::normalizeUninhabitedArrays(
155+
$this->typeNodeResolver->resolve($innerTypeNode, $nameScope),
156+
);
157+
158+
if ($resolvedType instanceof NeverType) {
159+
continue;
160+
}
161+
162+
$resolvedTypes[] = $resolvedType;
163+
$survivingTypeNodes[] = $innerTypeNode;
153164
}
154165

155166
if ([] === $resolvedTypes) {
156-
return new ErrorType();
167+
return new NeverType();
168+
}
169+
170+
if (count($resolvedTypes) === 1) {
171+
return $resolvedTypes[0];
157172
}
158173

159174
$containsTemplateType = false;
@@ -167,7 +182,7 @@ private function resolveOperandUnionType(
167182

168183
if (!$containsTemplateType) {
169184
return $this->typeNodeResolver->resolve(
170-
new UnionTypeNode($typeNode->types),
185+
new UnionTypeNode($survivingTypeNodes),
171186
$nameScope,
172187
);
173188
}

src/ArrayMergeTypeOperandBenevolentUnionType.php

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use PHPStan\Type\Generic\TemplateType;
2828
use PHPStan\Type\Type;
2929
use PHPStan\Type\TypeCombinator;
30+
use PHPStan\Type\TypeUtils;
3031
use PHPStan\Type\UnionType;
3132

3233
/**
@@ -55,10 +56,64 @@ public function traverse(callable $cb): Type
5556
return $this;
5657
}
5758

59+
return $this->recombineTypes($types);
60+
}
61+
62+
public function traverseSimultaneously(Type $right, callable $cb): Type
63+
{
64+
// PHPStan uses this traversal for too-wide diagnostics. Skipping an unresolved
65+
// right side is conservative; traversing it can discard later specialization.
66+
if (TypeUtils::containsTemplateType($right)) {
67+
return $this;
68+
}
69+
70+
$rightTypes = TypeUtils::flattenTypes($right);
71+
$types = [];
72+
$replace = false;
73+
74+
foreach ($this->getTypes() as $type) {
75+
$candidates = [];
76+
77+
foreach ($rightTypes as $i => $rightType) {
78+
if (!$type->isSuperTypeOf($rightType)->yes()) {
79+
continue;
80+
}
81+
82+
$candidates[] = $rightType;
83+
unset($rightTypes[$i]);
84+
}
85+
86+
if ([] === $candidates) {
87+
$types[] = $type;
88+
continue;
89+
}
90+
91+
$newType = $cb($type, TypeCombinator::union(...$candidates));
92+
$types[] = $newType;
93+
if ($newType !== $type) {
94+
$replace = true;
95+
}
96+
}
97+
98+
return $replace ? $this->recombineTypes($types) : $this;
99+
}
100+
101+
/** @param list<Type> $types */
102+
private function recombineTypes(array $types): Type
103+
{
104+
if ([] === $types) {
105+
return $this;
106+
}
107+
108+
$types = array_map(
109+
static fn(Type $type): Type => ArrayMergeType::normalizeUninhabitedArrays($type),
110+
$types,
111+
);
112+
58113
$flattenedTypes = self::flattenOrdinaryUnions($types);
59114

60115
foreach ($flattenedTypes as $type) {
61-
if ($type instanceof UnionType && $type instanceof TemplateType) {
116+
if (TypeUtils::containsTemplateType($type)) {
62117
return new self($flattenedTypes);
63118
}
64119
}

0 commit comments

Comments
 (0)