diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 3413ec6e8da..81059e849f8 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -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; @@ -1498,6 +1500,98 @@ 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 + * finite, mutually-disjoint values (constant scalars and/or enum cases) is their + * identity-keyed set intersection. Returns null when either union has a member that is + * not such a value, in which case the caller falls back to the general A & (B|C) + * distribution. + */ + private static function intersectFiniteUnions(UnionType $a, UnionType $b): ?Type + { + $membersA = self::finiteUnionMembers($a); + if ($membersA === null) { + return null; + } + + $membersB = self::finiteUnionMembers($b); + if ($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 identity for the finite-union fast path in intersect(). + * + * Handles constant scalars and enum cases: each stands for one concrete value, so two + * members are interchangeable iff they share a key and are otherwise disjoint. Returns + * null if any member is not such a value. 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). Enum cases are keyed by class + case name, the identity + * EnumCaseObjectType::equals() compares. + * + * @return array|null + */ + private static function finiteUnionMembers(UnionType $union): ?array + { + $members = []; + foreach ($union->getTypes() as $member) { + $enumCase = $member->getEnumCaseObject(); + if ($member->isNull()->yes()) { + $key = 'null'; + } elseif ($enumCase !== null) { + // getEnumCaseObject() also returns the case for a refined member - an + // intersection like $this & Enum::C, a whole single-case enum, or an enum + // subtracted to one case - none of which are a bare EnumCaseObjectType. + // Only a bare case is safe to key by class + case name; for the rest, + // EnumCaseObjectType::equals() is false (it requires an EnumCaseObjectType), + // so bail to the slow path rather than collapse the refinement. + if (!$enumCase->equals($member)) { + return null; + } + + // Key by class + case name, the identity EnumCaseObjectType::equals() compares + // (describe() would also fold in a subtracted type, which equals() ignores). + $key = 'enum:' . $enumCase->getClassName() . '::' . $enumCase->getEnumCaseName(); + } else { + $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); @@ -1516,6 +1610,22 @@ public static function intersect(Type ...$types): Type } } + // Fast path: the intersection of two plain unions whose members are all finite, + // mutually-disjoint values (constant scalars and/or enum cases) is their + // identity-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 + ) { + $finiteIntersection = self::intersectFiniteUnions($types[0], $types[1]); + if ($finiteIntersection !== null) { + return $finiteIntersection; + } + } + $sortTypes = static function (Type $a, Type $b): int { if (!$a instanceof UnionType || !$b instanceof UnionType) { return 0; diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index c5499d47447..70c155b1b53 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5632,6 +5632,133 @@ 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', + ]; + + // finite-union fast path: enum-case unions + yield [ + [ + new UnionType([ + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'), + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'), + new EnumCaseObjectType('PHPStan\Fixture\AnotherTestEnum', 'ONE'), + ]), + new UnionType([ + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'), + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'), + new EnumCaseObjectType('PHPStan\Fixture\AnotherTestEnum', 'TWO'), + ]), + ], + UnionType::class, + 'PHPStan\Fixture\TestEnum::ONE|PHPStan\Fixture\TestEnum::TWO', + ]; + + // finite-union fast path: mixed constant scalars + enum cases + yield [ + [ + new UnionType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'), + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'), + ]), + new UnionType([ + new ConstantIntegerType(1), + new ConstantIntegerType(2), + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'), + ]), + ], + UnionType::class, + '1|PHPStan\Fixture\TestEnum::ONE', + ]; + + // a backed enum case (TestEnum::TWO = 2) must not be conflated with the integer 2 + yield [ + [ + new UnionType([ + new ConstantIntegerType(2), + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'), + ]), + new UnionType([ + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'), + new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'), + ]), + ], + EnumCaseObjectType::class, + 'PHPStan\Fixture\TestEnum::TWO', + ]; + + // a constant string '0'/'1' must not be conflated with the integer 0/1 + yield [ + [ + new UnionType([ + new ConstantIntegerType(0), + new ConstantStringType('0'), + new ConstantIntegerType(1), + new ConstantStringType('1'), + ]), + new UnionType([ + new ConstantStringType('0'), + new ConstantIntegerType(1), + ]), + ], + UnionType::class, + "1|'0'", + ]; } /** diff --git a/tests/bench/data/bug-14869-enum.php b/tests/bench/data/bug-14869-enum.php new file mode 100644 index 00000000000..21a7768bdad --- /dev/null +++ b/tests/bench/data/bug-14869-enum.php @@ -0,0 +1,265 @@ +` branches makes the + * conditional-expression machinery repeatedly intersect $v growing + * constant-value union with the narrowed union. Before the fast path this + * was super-linear (O(N^2.3), ~21s at N=400); the identity-keyed set + * intersection turns each intersect O(n). phpstan/phpstan#14869. + */ + +function intChain(int $x): string +{ + $v = 0; + if ($x === 0) { $v = 0; } + if ($x === 1) { $v = 1; } + if ($x === 2) { $v = 2; } + if ($x === 3) { $v = 3; } + if ($x === 4) { $v = 4; } + if ($x === 5) { $v = 5; } + if ($x === 6) { $v = 6; } + if ($x === 7) { $v = 7; } + if ($x === 8) { $v = 8; } + if ($x === 9) { $v = 9; } + if ($x === 10) { $v = 10; } + if ($x === 11) { $v = 11; } + if ($x === 12) { $v = 12; } + if ($x === 13) { $v = 13; } + if ($x === 14) { $v = 14; } + if ($x === 15) { $v = 15; } + if ($x === 16) { $v = 16; } + if ($x === 17) { $v = 17; } + if ($x === 18) { $v = 18; } + if ($x === 19) { $v = 19; } + if ($x === 20) { $v = 20; } + if ($x === 21) { $v = 21; } + if ($x === 22) { $v = 22; } + if ($x === 23) { $v = 23; } + if ($x === 24) { $v = 24; } + if ($x === 25) { $v = 25; } + if ($x === 26) { $v = 26; } + if ($x === 27) { $v = 27; } + if ($x === 28) { $v = 28; } + if ($x === 29) { $v = 29; } + if ($x === 30) { $v = 30; } + if ($x === 31) { $v = 31; } + if ($x === 32) { $v = 32; } + if ($x === 33) { $v = 33; } + if ($x === 34) { $v = 34; } + if ($x === 35) { $v = 35; } + if ($x === 36) { $v = 36; } + if ($x === 37) { $v = 37; } + if ($x === 38) { $v = 38; } + if ($x === 39) { $v = 39; } + if ($x === 40) { $v = 40; } + if ($x === 41) { $v = 41; } + if ($x === 42) { $v = 42; } + if ($x === 43) { $v = 43; } + if ($x === 44) { $v = 44; } + if ($x === 45) { $v = 45; } + if ($x === 46) { $v = 46; } + if ($x === 47) { $v = 47; } + if ($x === 48) { $v = 48; } + if ($x === 49) { $v = 49; } + if ($x === 50) { $v = 50; } + if ($x === 51) { $v = 51; } + if ($x === 52) { $v = 52; } + if ($x === 53) { $v = 53; } + if ($x === 54) { $v = 54; } + if ($x === 55) { $v = 55; } + if ($x === 56) { $v = 56; } + if ($x === 57) { $v = 57; } + if ($x === 58) { $v = 58; } + if ($x === 59) { $v = 59; } + if ($x === 60) { $v = 60; } + if ($x === 61) { $v = 61; } + if ($x === 62) { $v = 62; } + if ($x === 63) { $v = 63; } + if ($x === 64) { $v = 64; } + if ($x === 65) { $v = 65; } + if ($x === 66) { $v = 66; } + if ($x === 67) { $v = 67; } + if ($x === 68) { $v = 68; } + if ($x === 69) { $v = 69; } + if ($x === 70) { $v = 70; } + if ($x === 71) { $v = 71; } + if ($x === 72) { $v = 72; } + if ($x === 73) { $v = 73; } + if ($x === 74) { $v = 74; } + if ($x === 75) { $v = 75; } + if ($x === 76) { $v = 76; } + if ($x === 77) { $v = 77; } + if ($x === 78) { $v = 78; } + if ($x === 79) { $v = 79; } + if ($x === 80) { $v = 80; } + if ($x === 81) { $v = 81; } + if ($x === 82) { $v = 82; } + if ($x === 83) { $v = 83; } + if ($x === 84) { $v = 84; } + if ($x === 85) { $v = 85; } + if ($x === 86) { $v = 86; } + if ($x === 87) { $v = 87; } + if ($x === 88) { $v = 88; } + if ($x === 89) { $v = 89; } + if ($x === 90) { $v = 90; } + if ($x === 91) { $v = 91; } + if ($x === 92) { $v = 92; } + if ($x === 93) { $v = 93; } + if ($x === 94) { $v = 94; } + if ($x === 95) { $v = 95; } + if ($x === 96) { $v = 96; } + if ($x === 97) { $v = 97; } + if ($x === 98) { $v = 98; } + if ($x === 99) { $v = 99; } + if ($x === 100) { $v = 100; } + if ($x === 101) { $v = 101; } + if ($x === 102) { $v = 102; } + if ($x === 103) { $v = 103; } + if ($x === 104) { $v = 104; } + if ($x === 105) { $v = 105; } + if ($x === 106) { $v = 106; } + if ($x === 107) { $v = 107; } + if ($x === 108) { $v = 108; } + if ($x === 109) { $v = 109; } + if ($x === 110) { $v = 110; } + if ($x === 111) { $v = 111; } + if ($x === 112) { $v = 112; } + if ($x === 113) { $v = 113; } + if ($x === 114) { $v = 114; } + if ($x === 115) { $v = 115; } + if ($x === 116) { $v = 116; } + if ($x === 117) { $v = 117; } + if ($x === 118) { $v = 118; } + if ($x === 119) { $v = 119; } + + return (string) $v; +} + +function stringChain(int $x): string +{ + $v = ""; + if ($x === 0) { $v = "v0"; } + if ($x === 1) { $v = "v1"; } + if ($x === 2) { $v = "v2"; } + if ($x === 3) { $v = "v3"; } + if ($x === 4) { $v = "v4"; } + if ($x === 5) { $v = "v5"; } + if ($x === 6) { $v = "v6"; } + if ($x === 7) { $v = "v7"; } + if ($x === 8) { $v = "v8"; } + if ($x === 9) { $v = "v9"; } + if ($x === 10) { $v = "v10"; } + if ($x === 11) { $v = "v11"; } + if ($x === 12) { $v = "v12"; } + if ($x === 13) { $v = "v13"; } + if ($x === 14) { $v = "v14"; } + if ($x === 15) { $v = "v15"; } + if ($x === 16) { $v = "v16"; } + if ($x === 17) { $v = "v17"; } + if ($x === 18) { $v = "v18"; } + if ($x === 19) { $v = "v19"; } + if ($x === 20) { $v = "v20"; } + if ($x === 21) { $v = "v21"; } + if ($x === 22) { $v = "v22"; } + if ($x === 23) { $v = "v23"; } + if ($x === 24) { $v = "v24"; } + if ($x === 25) { $v = "v25"; } + if ($x === 26) { $v = "v26"; } + if ($x === 27) { $v = "v27"; } + if ($x === 28) { $v = "v28"; } + if ($x === 29) { $v = "v29"; } + if ($x === 30) { $v = "v30"; } + if ($x === 31) { $v = "v31"; } + if ($x === 32) { $v = "v32"; } + if ($x === 33) { $v = "v33"; } + if ($x === 34) { $v = "v34"; } + if ($x === 35) { $v = "v35"; } + if ($x === 36) { $v = "v36"; } + if ($x === 37) { $v = "v37"; } + if ($x === 38) { $v = "v38"; } + if ($x === 39) { $v = "v39"; } + if ($x === 40) { $v = "v40"; } + if ($x === 41) { $v = "v41"; } + if ($x === 42) { $v = "v42"; } + if ($x === 43) { $v = "v43"; } + if ($x === 44) { $v = "v44"; } + if ($x === 45) { $v = "v45"; } + if ($x === 46) { $v = "v46"; } + if ($x === 47) { $v = "v47"; } + if ($x === 48) { $v = "v48"; } + if ($x === 49) { $v = "v49"; } + if ($x === 50) { $v = "v50"; } + if ($x === 51) { $v = "v51"; } + if ($x === 52) { $v = "v52"; } + if ($x === 53) { $v = "v53"; } + if ($x === 54) { $v = "v54"; } + if ($x === 55) { $v = "v55"; } + if ($x === 56) { $v = "v56"; } + if ($x === 57) { $v = "v57"; } + if ($x === 58) { $v = "v58"; } + if ($x === 59) { $v = "v59"; } + if ($x === 60) { $v = "v60"; } + if ($x === 61) { $v = "v61"; } + if ($x === 62) { $v = "v62"; } + if ($x === 63) { $v = "v63"; } + if ($x === 64) { $v = "v64"; } + if ($x === 65) { $v = "v65"; } + if ($x === 66) { $v = "v66"; } + if ($x === 67) { $v = "v67"; } + if ($x === 68) { $v = "v68"; } + if ($x === 69) { $v = "v69"; } + if ($x === 70) { $v = "v70"; } + if ($x === 71) { $v = "v71"; } + if ($x === 72) { $v = "v72"; } + if ($x === 73) { $v = "v73"; } + if ($x === 74) { $v = "v74"; } + if ($x === 75) { $v = "v75"; } + if ($x === 76) { $v = "v76"; } + if ($x === 77) { $v = "v77"; } + if ($x === 78) { $v = "v78"; } + if ($x === 79) { $v = "v79"; } + if ($x === 80) { $v = "v80"; } + if ($x === 81) { $v = "v81"; } + if ($x === 82) { $v = "v82"; } + if ($x === 83) { $v = "v83"; } + if ($x === 84) { $v = "v84"; } + if ($x === 85) { $v = "v85"; } + if ($x === 86) { $v = "v86"; } + if ($x === 87) { $v = "v87"; } + if ($x === 88) { $v = "v88"; } + if ($x === 89) { $v = "v89"; } + if ($x === 90) { $v = "v90"; } + if ($x === 91) { $v = "v91"; } + if ($x === 92) { $v = "v92"; } + if ($x === 93) { $v = "v93"; } + if ($x === 94) { $v = "v94"; } + if ($x === 95) { $v = "v95"; } + if ($x === 96) { $v = "v96"; } + if ($x === 97) { $v = "v97"; } + if ($x === 98) { $v = "v98"; } + if ($x === 99) { $v = "v99"; } + if ($x === 100) { $v = "v100"; } + if ($x === 101) { $v = "v101"; } + if ($x === 102) { $v = "v102"; } + if ($x === 103) { $v = "v103"; } + if ($x === 104) { $v = "v104"; } + if ($x === 105) { $v = "v105"; } + if ($x === 106) { $v = "v106"; } + if ($x === 107) { $v = "v107"; } + if ($x === 108) { $v = "v108"; } + if ($x === 109) { $v = "v109"; } + if ($x === 110) { $v = "v110"; } + if ($x === 111) { $v = "v111"; } + if ($x === 112) { $v = "v112"; } + if ($x === 113) { $v = "v113"; } + if ($x === 114) { $v = "v114"; } + if ($x === 115) { $v = "v115"; } + if ($x === 116) { $v = "v116"; } + if ($x === 117) { $v = "v117"; } + if ($x === 118) { $v = "v118"; } + if ($x === 119) { $v = "v119"; } + + return $v; +}