Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
use function get_class;
use function implode;
use function in_array;
use function is_bool;
use function is_int;
use function is_string;
use function sprintf;
use function usort;
use const PHP_INT_MAX;
Expand Down Expand Up @@ -1498,6 +1500,77 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged
return array_merge($newArrays, $arraysToProcess);
}

/**
* Fast path for intersect(): the intersection of two unions whose members are all
* disjoint constant scalars is their value-keyed set intersection. Returns null when
* either union has a member that is not safe to compare by value, in which case the
* caller falls back to the general A & (B|C) distribution.
*/
private static function intersectConstScalarUnions(UnionType $a, UnionType $b): ?Type
{
$membersA = self::constScalarUnionMembers($a);
$membersB = self::constScalarUnionMembers($b);
if ($membersA === null || $membersB === null) {
return null;
}

$common = [];
foreach ($membersA as $key => $member) {
if (!array_key_exists($key, $membersB)) {
continue;
}

$common[] = $member;
}

if ($common === []) {
return new NeverType();
}

return self::union(...$common);
}

/**
* Keys a union's members by value for the constant-scalar fast path in intersect().
*
* Returns a value-key => member map, or null if any member is not a constant scalar
* that is safe to compare by value alone. Class-string constant strings are excluded
* (the class-string flag is not captured by the value), and floats are excluded
* (-0.0 / NAN comparison quirks). Two members are interchangeable iff they share a key.
*
* @return array<string, Type>|null
*/
private static function constScalarUnionMembers(UnionType $union): ?array
{
$members = [];
foreach ($union->getTypes() as $member) {
if ($member->isNull()->yes()) {
$members['null'] = $member;
continue;
}

$values = $member->getConstantScalarValues();
if (count($values) !== 1) {
return null;
}

$value = $values[0];
if (is_int($value)) {
$key = 'i:' . $value;
} elseif (is_bool($value)) {
$key = $value ? 'b:1' : 'b:0';
} elseif (is_string($value) && $member->isClassString()->no()) {
$key = 's:' . $value;
} else {
return null;
}

$members[$key] = $member;
}

return $members;
}

public static function intersect(Type ...$types): Type
{
$typesCount = count($types);
Expand All @@ -1516,6 +1589,22 @@ public static function intersect(Type ...$types): Type
}
}

// Fast path: the intersection of two plain unions whose members are all
// disjoint constant scalars is their value-keyed set intersection (O(n)),
// avoiding the O(n*m) `A & (B|C)` distribution + union rebuild below.
// Restricted to the exact UnionType class so BenevolentUnionType and the
// template union types keep their dedicated handling.
if (
$typesCount === 2
&& get_class($types[0]) === UnionType::class
&& get_class($types[1]) === UnionType::class
) {
$constScalarIntersection = self::intersectConstScalarUnions($types[0], $types[1]);
if ($constScalarIntersection !== null) {
return $constScalarIntersection;
}
}

$sortTypes = static function (Type $a, Type $b): int {
if (!$a instanceof UnionType || !$b instanceof UnionType) {
return 0;
Expand Down
56 changes: 56 additions & 0 deletions tests/PHPStan/Type/TypeCombinatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5632,6 +5632,62 @@ public static function dataIntersect(): iterable
ConstantArrayType::class,
'array{a: int}',
];

// intersection of two constant-scalar unions (constant-union fast path)
yield [
[
'0|1|2|3',
'2|3|4|5',
],
UnionType::class,
'2|3',
];

yield [
[
"'a'|'b'|'c'",
"'b'|'c'|'d'",
],
UnionType::class,
"'b'|'c'",
];

yield [
[
'1|2',
'3|4',
],
NeverType::class,
'*NEVER*=implicit',
];

yield [
[
'0|1',
'1|2',
],
ConstantIntegerType::class,
'1',
];

yield [
[
"0|1|'a'|'b'|null",
"1|2|'a'|'c'|null",
],
UnionType::class,
"1|'a'|null",
];

// a non-constant member makes the fast path bail to the normal distribution
yield [
[
'0|1|2|non-empty-string',
'1|2',
],
UnionType::class,
'1|2',
];
}

/**
Expand Down
Loading