diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index e7441dc1ed..ea3cea735d 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -28,7 +28,6 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; use function array_filter; use function array_merge; use function array_reverse; @@ -214,36 +213,14 @@ private function specifyTypesForFlattenedBooleanAnd( $arms[] = $current; $arms = array_reverse($arms); - // Truthy: all arms are true → union all SpecifiedTypes. - // Collect per-expression types first, then build unions once - // to avoid O(N²) from incremental growth. - /** @var array}> $sureTypesPerExpr */ - $sureTypesPerExpr = []; - /** @var array}> $sureNotTypesPerExpr */ - $sureNotTypesPerExpr = []; - + // Truthy: all arms are true → the same merge unionWith() does for the + // recursive path, applied to all arms at once + $armTypes = []; foreach ($arms as $arm) { - $armTypes = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - foreach ($armTypes->getSureTypes() as $exprString => [$exprNode, $type]) { - $sureTypesPerExpr[$exprString][0] = $exprNode; - $sureTypesPerExpr[$exprString][1][] = $type; - } - foreach ($armTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { - $sureNotTypesPerExpr[$exprString][0] = $exprNode; - $sureNotTypesPerExpr[$exprString][1][] = $type; - } - } - - $sureTypes = []; - foreach ($sureTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; - } - $sureNotTypes = []; - foreach ($sureNotTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureNotTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; + $armTypes[] = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); } - return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($expr); + return SpecifiedTypes::unionAll($armTypes)->setRootExpr($expr); } /** diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 1ea250d55d..e59067bd29 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -27,7 +27,6 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; use function array_filter; use function array_key_last; use function array_keys; @@ -222,36 +221,14 @@ private function specifyTypesForFlattenedBooleanOr( $arms = array_reverse($arms); if ($context->false() || $context->falsey()) { - // Falsey: all arms are false → union all SpecifiedTypes. - // Collect per-expression types first, then build unions once - // to avoid O(N²) from incremental TypeCombinator::union() growth. - /** @var array}> $sureTypesPerExpr */ - $sureTypesPerExpr = []; - /** @var array}> $sureNotTypesPerExpr */ - $sureNotTypesPerExpr = []; - + // Falsey: all arms are false → the same merge unionWith() does for + // the recursive path, applied to all arms at once + $armTypes = []; foreach ($arms as $arm) { - $armTypes = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - foreach ($armTypes->getSureTypes() as $exprString => [$exprNode, $type]) { - $sureTypesPerExpr[$exprString][0] = $exprNode; - $sureTypesPerExpr[$exprString][1][] = $type; - } - foreach ($armTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { - $sureNotTypesPerExpr[$exprString][0] = $exprNode; - $sureNotTypesPerExpr[$exprString][1][] = $type; - } - } - - $sureTypes = []; - foreach ($sureTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::intersect(...$types)]; - } - $sureNotTypes = []; - foreach ($sureNotTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureNotTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; + $armTypes[] = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); } - return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($expr); + return SpecifiedTypes::unionAll($armTypes)->setRootExpr($expr); } // Truthy: at least one arm is true → intersect all normalized SpecifiedTypes diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index e1cbc91586..b48b56da95 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -8,10 +8,17 @@ use PHPStan\Type\TypeCombinator; use function array_key_exists; use function array_merge; +use function count; final class SpecifiedTypes { + /** + * Cross-producing alternative forms doubles the term count per conjunction; + * past this many terms the entry is widened to a single covering term. + */ + private const ALTERNATIVE_TERMS_LIMIT = 32; + private bool $overwrite = false; /** @var array */ @@ -244,7 +251,7 @@ public function intersectWith(SpecifiedTypes $other): self $sureTypeUnion = []; $sureNotTypeUnion = []; $alternativeUnion = []; - $rootExpr = $this->mergeRootExpr($this->rootExpr, $other->rootExpr); + $rootExpr = self::mergeRootExpr($this->rootExpr, $other->rootExpr); $keys = []; foreach ([$this->sureTypes, $this->sureNotTypes, $this->alternativeTypes, $other->sureTypes, $other->sureNotTypes, $other->alternativeTypes] as $map) { @@ -350,12 +357,138 @@ private function collectTerms(string|int $exprString): ?array return [[$sure, $subtract]]; } + /** + * The both-sides-hold merge of two alternative forms. An entry's value is + * the union of its terms, so conjoining two entries distributes over both + * lists: every pair of terms contributes `(sureA and sureB) minus (subtractA + * or subtractB)`, the same folding collectTerms() does for a sure/sure-not + * pair. Pairs whose sure types cannot hold together drop out. + * + * @param list $terms + * @param list $otherTerms + * @return list + */ + private static function conjoinTerms(array $terms, array $otherTerms): array + { + $conjoined = []; + foreach ($terms as [$sure, $subtract]) { + foreach ($otherTerms as [$otherSure, $otherSubtract]) { + if ($sure === null) { + $mergedSure = $otherSure; + } elseif ($otherSure === null) { + $mergedSure = $sure; + } else { + $mergedSure = TypeCombinator::intersect($sure, $otherSure); + } + + if ($subtract === null) { + $mergedSubtract = $otherSubtract; + } elseif ($otherSubtract === null) { + $mergedSubtract = $subtract; + } else { + $mergedSubtract = TypeCombinator::union($subtract, $otherSubtract); + } + + if ($mergedSure !== null) { + if ($mergedSubtract !== null) { + // a fixed base with a subtraction is just the narrower base - + // folding it keeps the term list free of redundant pairs + $mergedSure = TypeCombinator::remove($mergedSure, $mergedSubtract); + $mergedSubtract = null; + } + if ($mergedSure instanceof NeverType) { + continue; + } + } + + $conjoined[] = [$mergedSure, $mergedSubtract]; + } + } + + if ($conjoined === []) { + // every pair was impossible - so is the conjunction + return [[new NeverType(), null]]; + } + + $conjoined = self::dedupeTerms($conjoined); + if (count($conjoined) > self::ALTERNATIVE_TERMS_LIMIT) { + return [self::widenTerms($conjoined)]; + } + + return $conjoined; + } + + /** + * A single term covering the union of all of them - the safety net that + * stops a chain of conjoined alternative forms from growing its + * cross-product without bound. Widening a narrowing only loses precision. + * + * @param non-empty-list $terms + * @return array{?Type, ?Type} + */ + private static function widenTerms(array $terms): array + { + $sures = []; + $subtracts = []; + foreach ($terms as [$sure, $subtract]) { + if ($sure === null) { + // null reads as the subject's type at the application point, + // which every term is narrowed to anyway + $sures = null; + } elseif ($sures !== null) { + $sures[] = $sure; + } + + if ($subtract === null) { + $subtracts = null; + } elseif ($subtracts !== null) { + $subtracts[] = $subtract; + } + } + + return [ + $sures === null ? null : TypeCombinator::union(...$sures), + $subtracts === null ? null : TypeCombinator::intersect(...$subtracts), + ]; + } + + /** + * @param list $terms + * @return list + */ + private static function dedupeTerms(array $terms): array + { + $deduped = []; + foreach ($terms as [$sure, $subtract]) { + foreach ($deduped as [$seenSure, $seenSubtract]) { + if (($sure === null) !== ($seenSure === null)) { + continue; + } + if (($subtract === null) !== ($seenSubtract === null)) { + continue; + } + if ($sure !== null && $seenSure !== null && !$sure->equals($seenSure)) { + continue; + } + if ($subtract !== null && $seenSubtract !== null && !$subtract->equals($seenSubtract)) { + continue; + } + + continue 2; + } + + $deduped[] = [$sure, $subtract]; + } + + return $deduped; + } + /** @api */ public function unionWith(SpecifiedTypes $other): self { $sureTypeUnion = $this->sureTypes + $other->sureTypes; $sureNotTypeUnion = $this->sureNotTypes + $other->sureNotTypes; - $rootExpr = $this->mergeRootExpr($this->rootExpr, $other->rootExpr); + $rootExpr = self::mergeRootExpr($this->rootExpr, $other->rootExpr); foreach ($this->sureTypes as $exprString => [$exprNode, $type]) { if (!isset($other->sureTypes[$exprString])) { @@ -379,8 +512,21 @@ public function unionWith(SpecifiedTypes $other): self ]; } + $alternativeUnion = $this->alternativeTypes; + foreach ($other->alternativeTypes as $exprString => [$exprNode, $otherTerms]) { + if (!isset($alternativeUnion[$exprString])) { + $alternativeUnion[$exprString] = [$exprNode, $otherTerms]; + continue; + } + + $alternativeUnion[$exprString] = [ + $alternativeUnion[$exprString][0], + self::conjoinTerms($alternativeUnion[$exprString][1], $otherTerms), + ]; + } + $result = new self($sureTypeUnion, $sureNotTypeUnion); - $result->alternativeTypes = $this->alternativeTypes + $other->alternativeTypes; + $result->alternativeTypes = $alternativeUnion; if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } @@ -400,7 +546,83 @@ public function unionWith(SpecifiedTypes $other): self return $result->setRootExpr($rootExpr); } - private function mergeRootExpr(?Expr $rootExprA, ?Expr $rootExprB): ?Expr + /** + * The n-ary both-sides-hold merge - the truthy narrowing of a flattened + * `&&` chain, the falsey narrowing of a flattened `||` chain. Same result + * as folding unionWith() over the list, but each expression's constraints + * are combined in one pass instead of being rebuilt per arm, which is what + * lets the flattened chain paths stay linear in the number of arms. + * + * @param list $typesList + */ + public static function unionAll(array $typesList): self + { + /** @var array}> $surePerExpr */ + $surePerExpr = []; + /** @var array}> $sureNotPerExpr */ + $sureNotPerExpr = []; + /** @var array}> $alternatives */ + $alternatives = []; + $overwrite = false; + $rootExpr = null; + $conditionalExpressionHolders = []; + $recipes = []; + $augments = []; + + foreach ($typesList as $types) { + foreach ($types->sureTypes as $exprString => [$exprNode, $type]) { + $surePerExpr[$exprString][0] = $exprNode; + $surePerExpr[$exprString][1][] = $type; + } + foreach ($types->sureNotTypes as $exprString => [$exprNode, $type]) { + $sureNotPerExpr[$exprString][0] = $exprNode; + $sureNotPerExpr[$exprString][1][] = $type; + } + foreach ($types->alternativeTypes as $exprString => [$exprNode, $terms]) { + if (!isset($alternatives[$exprString])) { + $alternatives[$exprString] = [$exprNode, $terms]; + continue; + } + + $alternatives[$exprString][1] = self::conjoinTerms($alternatives[$exprString][1], $terms); + } + + $overwrite = $overwrite || $types->overwrite; + $rootExpr = self::mergeRootExpr($rootExpr, $types->rootExpr); + + foreach ($types->newConditionalExpressionHolders as $exprString => $holders) { + if (!array_key_exists($exprString, $conditionalExpressionHolders)) { + $conditionalExpressionHolders[$exprString] = $holders; + } else { + $conditionalExpressionHolders[$exprString] = array_merge($conditionalExpressionHolders[$exprString], $holders); + } + } + $recipes = array_merge($recipes, $types->conditionalExpressionHolderRecipes); + $augments = array_merge($augments, $types->deferredAugments); + } + + $sureTypes = []; + foreach ($surePerExpr as $exprString => [$exprNode, $types]) { + $sureTypes[$exprString] = [$exprNode, TypeCombinator::intersect(...$types)]; + } + $sureNotTypes = []; + foreach ($sureNotPerExpr as $exprString => [$exprNode, $types]) { + $sureNotTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; + } + + $result = new self($sureTypes, $sureNotTypes); + $result->alternativeTypes = $alternatives; + if ($overwrite) { + $result = $result->setAlwaysOverwriteTypes(); + } + $result->newConditionalExpressionHolders = $conditionalExpressionHolders; + $result->conditionalExpressionHolderRecipes = $recipes; + $result->deferredAugments = $augments; + + return $result->setRootExpr($rootExpr); + } + + private static function mergeRootExpr(?Expr $rootExprA, ?Expr $rootExprB): ?Expr { if ($rootExprA === $rootExprB) { return $rootExprA; diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index 9b07022d5e..bd91b5286f 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -1208,11 +1208,14 @@ public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType public function tryRemove(Type $typeToRemove): ?Type { - if ($this->isSuperTypeOf($typeToRemove)->yes()) { - return $this->subtract($typeToRemove); + // mixed is the top type, so removal is exactly the subtraction - also + // when an earlier subtraction already lowered isSuperTypeOf() from yes + // to maybe, which used to give up and remove nothing at all + if ($this->isSuperTypeOf($typeToRemove)->no()) { + return null; } - return null; + return $this->subtract($typeToRemove); } public function exponentiate(Type $exponent): Type diff --git a/src/Type/ObjectWithoutClassType.php b/src/Type/ObjectWithoutClassType.php index 5009361b18..bd1d2b8072 100644 --- a/src/Type/ObjectWithoutClassType.php +++ b/src/Type/ObjectWithoutClassType.php @@ -191,11 +191,14 @@ public function traverseSimultaneously(Type $right, callable $cb): Type public function tryRemove(Type $typeToRemove): ?Type { - if ($this->isSuperTypeOf($typeToRemove)->yes()) { - return $this->subtract($typeToRemove); + // object is the top of the object hierarchy, so removal is exactly the + // subtraction - also when an earlier subtraction already lowered + // isSuperTypeOf() from yes to maybe, which used to remove nothing at all + if ($this->isSuperTypeOf($typeToRemove)->no()) { + return null; } - return null; + return $this->subtract($typeToRemove); } public function exponentiate(Type $exponent): Type diff --git a/tests/PHPStan/Analyser/nsrt/bug-1233.php b/tests/PHPStan/Analyser/nsrt/bug-1233.php index 17267e0001..79c85ff812 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-1233.php +++ b/tests/PHPStan/Analyser/nsrt/bug-1233.php @@ -21,7 +21,7 @@ public function toArray($value): array return iterator_to_array($value); } - assertType('mixed~array', $value); + assertType('mixed~iterable', $value); throw new \LogicException(); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15039.php b/tests/PHPStan/Analyser/nsrt/bug-15039.php new file mode 100644 index 0000000000..d830d7129f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15039.php @@ -0,0 +1,107 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15039; + +use function PHPStan\Testing\assertType; + +function repro(int $v): void +{ + assert(!(is_int($v) && $v < 0) && !(is_int($v) && $v >= 1)); + + assertType('0', $v); +} + +function chained(int $v): void +{ + assert(is_int($v) && !(is_int($v) && $v < 0) && !(is_int($v) && $v >= 1)); + + assertType('0', $v); +} + +function doubleNegation(int $v): void +{ + if (!(!(!(is_int($v) && $v < 0) && !(is_int($v) && $v >= 1)))) { + assertType('0', $v); + } +} + +function threeAlternatives(int $v): void +{ + assert(!(is_int($v) && $v < 0) && !(is_int($v) && $v > 5) && !(is_int($v) && $v === 3)); + + assertType('int<0, 2>|int<4, 5>', $v); +} + +// the `&&` chain below is deep enough for BooleanAndHandler to take its +// flattened path, which used to merge the arms without unionWith()'s semantics +function flattenedAnd(int $v, bool $a, bool $b, bool $c, bool $d): void +{ + assert($a && $b && $c && $d && !(is_int($v) && $v < 0) && !(is_int($v) && $v >= 1)); + + assertType('0', $v); +} + +function flattenedLogicalAnd(int $v, bool $a, bool $b, bool $c, bool $d): void +{ + assert($a and $b and $c and $d and !(is_int($v) && $v < 0) and !(is_int($v) && $v >= 1)); + + assertType('0', $v); +} + +function flattenedAndCollidingSureTypes(int|string|float $v, bool $a, bool $b, bool $c, bool $d): void +{ + assert($a && $b && $c && $d && (is_int($v) || is_string($v)) && (is_int($v) || is_float($v))); + + assertType('int', $v); +} + +function shallowAndCollidingSureTypes(int|string|float $v, bool $a): void +{ + assert($a && (is_int($v) || is_string($v)) && (is_int($v) || is_float($v))); + + assertType('int', $v); +} + +// same for the falsey narrowing of a deep `||` chain +function flattenedOr(int $v, bool $a, bool $b, bool $c, bool $d): void +{ + assert(!($a || $b || $c || $d || (is_int($v) && $v < 0) || (is_int($v) && $v >= 1))); + + assertType('0', $v); +} + +function shallowOr(int $v): void +{ + assert(!((is_int($v) && $v < 0) || (is_int($v) && $v >= 1))); + + assertType('0', $v); +} + +// one side's alternative form carries the extra term of an inner `||` +function alternativeWithExtraTerm(int $v): void +{ + assert((!(is_int($v) && $v < 0) || $v === -5) && !(is_int($v) && $v >= 1)); + + assertType('-5|0', $v); +} + +// an alternative form and a plain entry on the same expression still conjoin +function alternativeAndSureType(int $v): void +{ + assert(!(is_int($v) && $v < 0) && $v < 3); + + assertType('int<0, 2>', $v); +} + +function subtractedObject(object $o): void +{ + if (!($o instanceof \ArrayObject)) { + assertType('object~ArrayObject', $o); + + if (!($o instanceof \Traversable)) { + assertType('object~Traversable', $o); + } + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-3991.php b/tests/PHPStan/Analyser/nsrt/bug-3991.php index e42d47fa02..56fe7f5348 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3991.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3991.php @@ -22,7 +22,7 @@ public static function email($config = null) assertType('array{}|null', $config); $config = new \stdClass(); } elseif (! (is_array($config) || $config instanceof \stdClass)) { - assertNativeType('mixed~(0|0.0|\'\'|\'0\'|array{}|stdClass|false|null)', $config); + assertNativeType('mixed~(0|0.0|\'\'|\'0\'|array|stdClass|false|null)', $config); assertType('*NEVER*', $config); }