Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
33 changes: 5 additions & 28 deletions src/Analyser/ExprHandler/BooleanAndHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{Expr, list<Type>}> $sureTypesPerExpr */
$sureTypesPerExpr = [];
/** @var array<string, array{Expr, list<Type>}> $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);
}

/**
Expand Down
33 changes: 5 additions & 28 deletions src/Analyser/ExprHandler/BooleanOrHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{Expr, list<Type>}> $sureTypesPerExpr */
$sureTypesPerExpr = [];
/** @var array<string, array{Expr, list<Type>}> $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
Expand Down
230 changes: 226 additions & 4 deletions src/Analyser/SpecifiedTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ConditionalExpressionHolder[]> */
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<array{?Type, ?Type}> $terms
* @param list<array{?Type, ?Type}> $otherTerms
* @return list<array{?Type, ?Type}>
*/
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<array{?Type, ?Type}> $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<array{?Type, ?Type}> $terms
* @return list<array{?Type, ?Type}>
*/
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])) {
Expand All @@ -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();
}
Expand All @@ -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<self> $typesList
*/
public static function unionAll(array $typesList): self
{
/** @var array<string, array{Expr, list<Type>}> $surePerExpr */
$surePerExpr = [];
/** @var array<string, array{Expr, list<Type>}> $sureNotPerExpr */
$sureNotPerExpr = [];
/** @var array<string, array{Expr, list<array{?Type, ?Type}>}> $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;
Expand Down
9 changes: 6 additions & 3 deletions src/Type/MixedType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading