Skip to content
4 changes: 1 addition & 3 deletions build/ignore-by-php-version.neon.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@
$includes[] = __DIR__ . '/more-enum-adapter-errors.neon';
}

if (PHP_VERSION_ID < 80000) {
$includes[] = __DIR__ . '/spl-autoload-functions-pre-php-7.neon';
} else {
if (PHP_VERSION_ID >= 80000) {
$includes[] = __DIR__ . '/spl-autoload-functions-php-8.neon';
}

Expand Down
5 changes: 0 additions & 5 deletions build/spl-autoload-functions-pre-php-7.neon

This file was deleted.

29 changes: 27 additions & 2 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -5006,6 +5006,31 @@ private function generalizeVariableTypeHolders(
return $newVariableTypeHolders;
}

/**
* TypeUtils::flattenTypes() expands a shape with optional keys into every
* concrete variant (2^N of them, four lossy representatives above ten),
* only for the union below to merge them back into the very same shape -
* quadratic in the number of variants. The per-key widening reads the
* shape's keys, values and optionality directly, so only unions are split.
*
* @return list<Type>
*/
private function flattenUnionForGeneralization(Type $type): array
{
if (!$type instanceof UnionType) {
return [$type];
}

$types = [];
foreach ($type->getTypes() as $innerType) {
foreach ($this->flattenUnionForGeneralization($innerType) as $flattenedType) {
$types[] = $flattenedType;
}
}

return $types;
}

private function generalizeType(Type $a, Type $b, int $depth): Type
{
if ($a->equals($b)) {
Expand Down Expand Up @@ -5033,8 +5058,8 @@ private function generalizeType(Type $a, Type $b, int $depth): Type
$otherTypes = [];

foreach ([
'a' => TypeUtils::flattenTypes($a),
'b' => TypeUtils::flattenTypes($b),
'a' => $this->flattenUnionForGeneralization($a),
'b' => $this->flattenUnionForGeneralization($b),
] as $key => $types) {
foreach ($types as $type) {
if ($type instanceof ConstantIntegerType) {
Expand Down
12 changes: 11 additions & 1 deletion src/Reflection/ClassReflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ final class ClassReflection

private ?bool $isDeprecated = null;

/** @var array<Type>|null */
private ?array $allowedSubTypes = null;

private bool $allowedSubTypesResolved = false;

private ?bool $isGeneric = null;

private ?bool $isInternal = null;
Expand Down Expand Up @@ -2330,9 +2335,14 @@ public function getResolvedMixinTypes(): array
*/
public function getAllowedSubTypes(): ?array
{
if ($this->allowedSubTypesResolved) {
return $this->allowedSubTypes;
}

$this->allowedSubTypesResolved = true;
foreach ($this->classReflectionExtensionRegistryProvider->getRegistry()->getAllowedSubTypesClassReflectionExtensions() as $allowedSubTypesClassReflectionExtension) {
if ($allowedSubTypesClassReflectionExtension->supports($this)) {
return $allowedSubTypesClassReflectionExtension->getAllowedSubTypes($this);
return $this->allowedSubTypes = $allowedSubTypesClassReflectionExtension->getAllowedSubTypes($this);
}
}

Expand Down
42 changes: 41 additions & 1 deletion src/Type/ArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Generic\TemplateMixedType;
use PHPStan\Type\Generic\TemplateStrictMixedType;
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\Generic\TemplateTypeMap;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\Traits\ArrayTypeTrait;
Expand Down Expand Up @@ -406,7 +407,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni

return new IntersectionType([
$this->withTypes(
TypeCombinator::union($this->keyType, $offsetType),
$this->unionKeyTypeWithConstantOffset($offsetType),
TypeCombinator::union($this->itemType, $valueType),
),
new HasOffsetValueType($offsetType, $valueType),
Expand All @@ -423,12 +424,51 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni
]);
}

/**
* The key type with a constant offset written into the array.
*
* A key union that already holds the offset is handed back as it is. Unioning would
* rebuild the same members, and an array keyed by a union of a few hundred literals
* pays that rebuild for every key written. The array type - and its key union - lives
* on across the writes, so the membership check pays for the union's finite value
* index once; asking it of every union TypeCombinator sees would build that index for
* short-lived unions that are never asked again.
*/
private function unionKeyTypeWithConstantOffset(Type $offsetType): Type
{
if (
$this->keyType instanceof UnionType
&& !$this->keyType instanceof BenevolentUnionType
&& !$this->keyType instanceof TemplateType
&& $this->keyType->isSuperTypeOf($offsetType)->yes()
) {
return $this->keyType;
}

return TypeCombinator::union($this->keyType, $offsetType);
}

public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type
{
if ($this->itemType->isConstantArray()->yes() && $valueType->isConstantArray()->yes()) {
$newItemTypes = [];

$itemConstantArrays = $this->itemType->getConstantArrays();
foreach ($valueType->getConstantArrays() as $constArray) {
if ($constArray->getOptionalKeys() !== [] && count($itemConstantArrays) === 1) {
// A written shape with optional keys is not all-or-nothing: each
// optional key may or may not be present on its own, so it is
// written as optional (present keys keep their certainty, the
// value unions with what the key held) instead of once with every
// key required and once with every optional key unset.
$builder = ConstantArrayTypeBuilder::createFromConstantArray($itemConstantArrays[0]);
foreach ($constArray->getKeyTypes() as $i => $keyType) {
$builder->setOffsetValueType($keyType, $constArray->getOffsetValueType($keyType), $constArray->isOptionalKey($i));
}
$newItemTypes[] = TypeCombinator::intersect($builder->getArray(), ...TypeUtils::getAccessoryTypes($this->itemType));
continue;
}

$newItemType = $this->itemType;
$optionalKeyTypes = [];
foreach ($constArray->getKeyTypes() as $i => $keyType) {
Expand Down
27 changes: 19 additions & 8 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -3606,15 +3606,29 @@ public function getFiniteTypes(): array
// Build finite array types incrementally, processing one key at a time.
// For optional keys, fork each partial result into with/without variants.
// This avoids generating 2^N ConstantArrayType objects via getAllArrays().
/** @var list<ConstantArrayTypeBuilder> $partials */
$partials = [ConstantArrayTypeBuilder::createEmpty()];

foreach ($this->keyTypes as $i => $keyType) {
$finiteValueTypes = $this->valueTypes[$i]->getFiniteTypes();
// Count first: a shape with many optional keys overflows the limit after a
// handful of keys, and building the partial arrays up to that point costs
// hundreds of builder clones per call for a result that is thrown away.
$finiteValueTypesPerKey = [];
$count = 1;
foreach ($this->valueTypes as $i => $valueType) {
$finiteValueTypes = $valueType->getFiniteTypes();
if ($finiteValueTypes === []) {
return [];
}

$finiteValueTypesPerKey[$i] = $finiteValueTypes;
$count *= count($finiteValueTypes) + ($this->isOptionalKey($i) ? 1 : 0);
if ($count > $limit) {
return [];
}
}

/** @var list<ConstantArrayTypeBuilder> $partials */
$partials = [ConstantArrayTypeBuilder::createEmpty()];

foreach ($this->keyTypes as $i => $keyType) {
$finiteValueTypes = $finiteValueTypesPerKey[$i];
$isOptional = $this->isOptionalKey($i);
$newPartials = [];

Expand All @@ -3630,9 +3644,6 @@ public function getFiniteTypes(): array
}

$partials = $newPartials;
if (count($partials) > $limit) {
return [];
}
}

$finiteTypes = [];
Expand Down
53 changes: 49 additions & 4 deletions src/Type/ObjectType.php
Original file line number Diff line number Diff line change
Expand Up @@ -1850,22 +1850,42 @@ private function matchAllowedSubTypes(array $subtractedTypes): ?Type
$allowedSubTypesCount = count($allowedSubTypes);
$subtractedSubTypes = [];

// Enum cases are finite values: FiniteTypeSet keys them by value identity, so
// a subtracted case is a lookup instead of an equals() sweep over every
// allowed case - quadratic in the enum size. Allowed subtypes it cannot key
// (a sealed class hierarchy) still take the sweep.
$allowedSet = FiniteTypeSet::create(array_values($allowedSubTypes));
$keyedAllowedSubTypes = $allowedSet !== null ? $allowedSet->getMembers() : [];
$otherAllowedSubTypes = $allowedSet !== null ? $allowedSet->getOthers() : array_values($allowedSubTypes);

foreach ($subtractedTypes as $subType) {
foreach ($allowedSubTypes as $key => $allowedSubType) {
$key = FiniteTypeSet::key($subType);
if ($key !== null) {
if (!array_key_exists($key, $keyedAllowedSubTypes) || !$subType->equals($keyedAllowedSubTypes[$key])) {
return null;
}

$subtractedSubTypes[] = $subType;
unset($keyedAllowedSubTypes[$key]);
continue;
}

foreach ($otherAllowedSubTypes as $i => $allowedSubType) {
if ($subType->equals($allowedSubType)) {
// An allowed subtype is dropped as it matches, so no two matches
// can be the same one and the matches need no keying.
$subtractedSubTypes[] = $subType;
unset($allowedSubTypes[$key]);
unset($otherAllowedSubTypes[$i]);
continue 2;
}
}

return null;
}

if (count($allowedSubTypes) === 1) {
return array_values($allowedSubTypes)[0];
$remainingAllowedSubTypes = array_merge(array_values($keyedAllowedSubTypes), array_values($otherAllowedSubTypes));
if (count($remainingAllowedSubTypes) === 1) {
return $remainingAllowedSubTypes[0];
}

$subtractedSubTypesCount = count($subtractedSubTypes);
Expand Down Expand Up @@ -2064,6 +2084,31 @@ public function tryRemove(Type $typeToRemove): ?Type
return $this->subtract($typeToRemove);
}

$classReflection = $this->getClassReflection();
if ($typeToRemove instanceof UnionType && $classReflection !== null && $classReflection->getAllowedSubTypes() !== null) {
// A sealed hierarchy subtracts by set difference, so the members this
// type no longer holds (already subtracted) are no-ops and the rest come
// off in one subtraction - the same result as removing them one at a
// time, without rebuilding the subtracted union once per member.
$membersToRemove = [];
foreach ($typeToRemove->getTypes() as $member) {
$isSuperTypeOfMember = $this->isSuperTypeOf($member);
if ($isSuperTypeOfMember->yes()) {
$membersToRemove[] = $member;
continue;
}
if ($isSuperTypeOfMember->maybe()) {
return null;
}
}

if ($membersToRemove === []) {
return $this;
}

return $this->subtract(count($membersToRemove) === 1 ? $membersToRemove[0] : new UnionType($membersToRemove));
}

return null;
}

Expand Down
Loading
Loading