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
15 changes: 15 additions & 0 deletions src/Type/ArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,22 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T
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
96 changes: 71 additions & 25 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
use function get_class;
use function implode;
use function in_array;
use function spl_object_id;
use function sprintf;
use function usort;
use const PHP_INT_MAX;
Expand Down Expand Up @@ -236,6 +237,15 @@ public static function union(Type ...$types): Type
return self::doUnion(...$types);
}

private static function isUnionHoldingConstantScalar(Type $union, Type $scalar): bool
{
return $union instanceof UnionType
&& !$union instanceof BenevolentUnionType
&& !$union instanceof TemplateType
&& $scalar->isConstantScalarValue()->yes()
&& $union->isSuperTypeOf($scalar)->yes();
}

/** @internal Delegated to from TypeCombinatorCache, which the native extension shadows to memoize it. */
public static function doUnion(Type ...$types): Type
{
Expand Down Expand Up @@ -291,54 +301,89 @@ public static function doUnion(Type ...$types): Type
if ($a === $b || ($a->equals($b) && $a->isArray()->yes())) {
return $a;
}

// union(U, x) = U when x is a constant scalar U already holds: the
// general path would rebuild the same members. Adding a known key to
// a large array key union (a few hundred locale strings) hits this
// once per key.
if (self::isUnionHoldingConstantScalar($a, $b)) {
return $a;
}
if (self::isUnionHoldingConstantScalar($b, $a)) {
return $b;
}
}

// A member passed more than once contributes nothing; dropping the
// repeats up front keeps the pairwise comparison below from paying for
// them (array_values() of a shape unions the same value type per slot).
if ($typesCount > 2) {
$seenTypes = [];
$uniqueTypes = [];
foreach ($types as $type) {
$typeId = spl_object_id($type);
if (isset($seenTypes[$typeId])) {
continue;
}
$seenTypes[$typeId] = true;
$uniqueTypes[] = $type;
}
if (count($uniqueTypes) === 1) {
return $uniqueTypes[0];
}
if (count($uniqueTypes) === 2) {
return self::union($uniqueTypes[0], $uniqueTypes[1]);
}
$types = $uniqueTypes;
}

$alreadyNormalized = [];
$alreadyNormalizedCounter = 0;

$benevolentTypes = [];
$neverCount = 0;
// transform A | (B | C) to A | B | C
for ($i = 0; $i < $typesCount; $i++) {
// transform A | (B | C) to A | B | C - in one pass, a union's members are
// never unions, implicit never or implicit mixed themselves
$flattenedTypes = [];
foreach ($types as $type) {
if (
$types[$i] instanceof MixedType
&& !$types[$i]->isExplicitMixed()
&& !$types[$i] instanceof TemplateMixedType
&& $types[$i]->getSubtractedType() === null
$type instanceof MixedType
&& !$type->isExplicitMixed()
&& !$type instanceof TemplateMixedType
&& $type->getSubtractedType() === null
) {
return $types[$i];
return $type;
}
if ($types[$i] instanceof NeverType && !$types[$i]->isExplicit()) {
if ($type instanceof NeverType && !$type->isExplicit()) {
$neverCount++;
$flattenedTypes[] = $type;
continue;
}
if ($types[$i] instanceof BenevolentUnionType) {
if ($types[$i] instanceof TemplateType) {
if ($type instanceof BenevolentUnionType) {
if ($type instanceof TemplateType) {
$flattenedTypes[] = $type;
continue;
}
$benevolentTypesCount = 0;
$typesInner = $types[$i]->getTypes();
foreach ($typesInner as $benevolentInnerType) {
$benevolentTypesCount++;
foreach ($type->getTypes() as $benevolentInnerType) {
$benevolentTypes[$benevolentInnerType->describe(VerbosityLevel::value())] = $benevolentInnerType;
$flattenedTypes[] = $benevolentInnerType;
}
array_splice($types, $i, 1, $typesInner);
$typesCount += $benevolentTypesCount - 1;
continue;
}
if (!($types[$i] instanceof UnionType)) {
continue;
}
if ($types[$i] instanceof TemplateType) {
if (!($type instanceof UnionType) || $type instanceof TemplateType) {
$flattenedTypes[] = $type;
continue;
}

$typesInner = $types[$i]->getTypes();
$typesInner = $type->getTypes();
$alreadyNormalized[$alreadyNormalizedCounter] = $typesInner;
$alreadyNormalizedCounter++;
array_splice($types, $i, 1, $typesInner);
$typesCount += count($typesInner) - 1;
foreach ($typesInner as $innerType) {
$flattenedTypes[] = $innerType;
}
}
$types = $flattenedTypes;
$typesCount = count($types);

// Bulk-remove implicit NeverTypes (skipped during the loop above)
if ($neverCount > 0) {
Expand Down Expand Up @@ -427,8 +472,9 @@ public static function doUnion(Type ...$types): Type
static fn (IntegerRangeType $a, IntegerRangeType $b): int => ($a->getMin() ?? PHP_INT_MIN) <=> ($b->getMin() ?? PHP_INT_MIN)
?: ($a->getMax() ?? PHP_INT_MAX) <=> ($b->getMax() ?? PHP_INT_MAX),
);
$types = array_merge($types, $integerRangeTypes);
$types = array_values($types);
// array_merge() hands the first array back as it is when the second one
// is empty, so the keys the bucketing above unset must be renumbered here
$types = array_merge(array_values($types), $integerRangeTypes);
$typesCount = count($types);

foreach ($scalarTypes as $classType => $scalarTypeItems) {
Expand Down
Loading
Loading