Skip to content

Commit 3ddfb84

Browse files
committed
Merge branch 2.1.x into 2.2.x
2 parents 5a952cc + 73dae21 commit 3ddfb84

5 files changed

Lines changed: 401 additions & 0 deletions

File tree

src/Analyser/MutatingScope.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3354,6 +3354,14 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self
33543354
);
33553355
}
33563356

3357+
/**
3358+
* @return array<string, ConditionalExpressionHolder[]>
3359+
*/
3360+
public function getConditionalExpressions(): array
3361+
{
3362+
return $this->conditionalExpressions;
3363+
}
3364+
33573365
/**
33583366
* @param ConditionalExpressionHolder[] $conditionalExpressionHolders
33593367
*/

src/Analyser/NodeScopeResolver.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@
141141
use PHPStan\TrinaryLogic;
142142
use PHPStan\Type\ArrayType;
143143
use PHPStan\Type\ClosureType;
144+
use PHPStan\Type\Constant\ConstantIntegerType;
145+
use PHPStan\Type\Constant\ConstantStringType;
144146
use PHPStan\Type\FileTypeMapper;
145147
use PHPStan\Type\Generic\TemplateTypeHelper;
146148
use PHPStan\Type\Generic\TemplateTypeMap;
@@ -4062,6 +4064,14 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto
40624064
)->getScope();
40634065
$vars = array_merge($vars, $this->getAssignedVariables($stmt->keyVar));
40644066
}
4067+
4068+
if ($stmt->valueVar instanceof List_) {
4069+
$scope = $this->addDestructureTaggedUnionConditionalHolders(
4070+
$scope,
4071+
$originalScope->getIterableValueType($iterateeType),
4072+
$stmt->valueVar,
4073+
);
4074+
}
40654075
}
40664076

40674077
$constantArrays = $iterateeType->getConstantArrays();
@@ -4120,6 +4130,115 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto
41204130
return $this->processVarAnnotation($scope, $vars, $stmt);
41214131
}
41224132

4133+
/**
4134+
* When destructuring an iterable whose value type is a tagged union of
4135+
* constant arrays — e.g. `array<array{null, int}|array{int, null}>` — the
4136+
* variants describe a relationship between the destructured variables that
4137+
* a per-variable narrowing would normally lose: knowing `$x === null` should
4138+
* imply `$y === int`, but `foreach ($a as [$x, $y])` assigns `$x` and `$y`
4139+
* independently, so each ends up as the union (`int|null`) and the link is
4140+
* dropped.
4141+
*
4142+
* Recover the link by storing conditional-expression holders on each
4143+
* destructured variable: for every variant, "when this variable matches the
4144+
* variant's value at its position, the other variables match the variant's
4145+
* values at their positions". A later `if ($x === null)` then fires the
4146+
* matching holder and narrows `$y` accordingly.
4147+
*
4148+
* Only handles flat positional / keyed destructure patterns (List_) where
4149+
* each item's target is a plain Variable; nested destructure is left for
4150+
* the regular per-variable type tracking.
4151+
*/
4152+
private function addDestructureTaggedUnionConditionalHolders(
4153+
MutatingScope $scope,
4154+
Type $iterableValueType,
4155+
List_ $list,
4156+
): MutatingScope
4157+
{
4158+
$constantArrays = $iterableValueType->getConstantArrays();
4159+
if (count($constantArrays) < 2) {
4160+
return $scope;
4161+
}
4162+
4163+
// Collect each list item's array-key value and target variable.
4164+
$items = [];
4165+
foreach ($list->items as $position => $item) {
4166+
if ($item === null) {
4167+
continue;
4168+
}
4169+
if (!$item->value instanceof Variable || !is_string($item->value->name)) {
4170+
return $scope;
4171+
}
4172+
if ($item->key === null) {
4173+
$keyValue = $position;
4174+
} elseif ($item->key instanceof Node\Scalar\String_) {
4175+
$keyValue = $item->key->value;
4176+
} elseif ($item->key instanceof Node\Scalar\Int_) {
4177+
$keyValue = $item->key->value;
4178+
} else {
4179+
return $scope;
4180+
}
4181+
$items[] = ['key' => $keyValue, 'name' => $item->value->name];
4182+
}
4183+
4184+
if (count($items) < 2) {
4185+
return $scope;
4186+
}
4187+
4188+
// For every variant, every item must have a matching key with a single
4189+
// value type at it; otherwise the variants don't all describe the same
4190+
// destructure shape and we can't form a sound holder set.
4191+
$variantValuesByItem = [];
4192+
foreach ($items as $itemIdx => $itemInfo) {
4193+
$variantValuesByItem[$itemIdx] = [];
4194+
foreach ($constantArrays as $variantIdx => $variant) {
4195+
$keyType = is_int($itemInfo['key']) ? new ConstantIntegerType($itemInfo['key']) : new ConstantStringType($itemInfo['key']);
4196+
if (!$variant->hasOffsetValueType($keyType)->yes()) {
4197+
return $scope;
4198+
}
4199+
$variantValuesByItem[$itemIdx][$variantIdx] = $variant->getOffsetValueType($keyType);
4200+
}
4201+
}
4202+
4203+
// For each item × variant, build a holder: "when item is variant's value
4204+
// at this position, the *other* items are the variant's values at their
4205+
// positions". Skip the variant if the condition value is too wide to be
4206+
// a useful discriminator (i.e. equal to the union of all the variant
4207+
// values at this position — narrowing it back wouldn't pick a variant).
4208+
foreach ($items as $itemIdx => $itemInfo) {
4209+
$exprString = '$' . $itemInfo['name'];
4210+
$variantConditionTypes = $variantValuesByItem[$itemIdx];
4211+
$itemUnionType = TypeCombinator::union(...array_values($variantConditionTypes));
4212+
$holders = [];
4213+
foreach (array_keys($constantArrays) as $variantIdx) {
4214+
$conditionType = $variantConditionTypes[$variantIdx];
4215+
if ($conditionType->equals($itemUnionType)) {
4216+
continue;
4217+
}
4218+
$conditions = [
4219+
$exprString => ExpressionTypeHolder::createYes(new Variable($itemInfo['name']), $conditionType),
4220+
];
4221+
foreach ($items as $otherIdx => $otherInfo) {
4222+
if ($otherIdx === $itemIdx) {
4223+
continue;
4224+
}
4225+
$otherType = $variantValuesByItem[$otherIdx][$variantIdx];
4226+
$holder = new ConditionalExpressionHolder(
4227+
$conditions,
4228+
ExpressionTypeHolder::createYes(new Variable($otherInfo['name']), $otherType),
4229+
);
4230+
$holders['$' . $otherInfo['name']][$holder->getKey()] = $holder;
4231+
}
4232+
}
4233+
4234+
foreach ($holders as $targetExprString => $targetHolders) {
4235+
$scope = $scope->addConditionalExpressions($targetExprString, $targetHolders);
4236+
}
4237+
}
4238+
4239+
return $scope;
4240+
}
4241+
41234242
/**
41244243
* @param callable(Node $node, Scope $scope): void $nodeCallback
41254244
*/

src/Analyser/TypeSpecifier.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
use PHPStan\Type\TypeTraverser;
8383
use PHPStan\Type\UnionType;
8484
use function array_key_exists;
85+
use function array_key_first;
8586
use function array_last;
8687
use function array_map;
8788
use function array_merge;
@@ -783,6 +784,7 @@ public function specifyTypesInCondition(
783784
$types = $leftTypes->normalize($scope);
784785
} else {
785786
$types = $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope));
787+
$types = $this->augmentBooleanOrTruthyWithConditionalHolders($scope, $rightScope, $expr, $types);
786788
}
787789
} else {
788790
$types = $leftTypes->unionWith($rightTypes);
@@ -1941,6 +1943,81 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun
19411943
return $this->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope);
19421944
}
19431945

1946+
/**
1947+
* For `if ($a || $b)` truthy, expressions narrowed by stored conditional
1948+
* holders (e.g. `$a = $obj instanceof ClassA;` records "when `$a` is
1949+
* truthy, `$obj` is `ClassA`") need to be projected into the OR-truthy
1950+
* scope as the union of the per-arm narrowings. specifyTypesInCondition
1951+
* for each arm only looks at the boolean variable itself, so the held
1952+
* narrowing of `$obj` would otherwise be invisible until a later check
1953+
* pins one of the booleans down.
1954+
*
1955+
* For each conditional-holder target $T:
1956+
* - resolve $T's type in the left-truthy and right-truthy filtered scopes
1957+
* - if both narrow $T strictly below the original, add `$T : leftT|rightT`
1958+
* as a sure type to the OR-truthy result
1959+
*
1960+
* The asymmetric case (one arm narrows, the other doesn't) is intentionally
1961+
* skipped: in the OR-truthy scope the arm that didn't narrow could still be
1962+
* the truthy one, so the sound result is the original (unnarrowed) type.
1963+
*/
1964+
private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $scope, MutatingScope $rightScope, BooleanOr|LogicalOr $expr, SpecifiedTypes $types): SpecifiedTypes
1965+
{
1966+
$leftTruthyScope = $scope->filterByTruthyValue($expr->left);
1967+
$rightTruthyScope = $rightScope->filterByTruthyValue($expr->right);
1968+
1969+
$seen = [];
1970+
foreach ([$scope, $rightScope] as $sourceScope) {
1971+
foreach ($sourceScope->getConditionalExpressions() as $exprString => $holders) {
1972+
if (isset($seen[$exprString])) {
1973+
continue;
1974+
}
1975+
if ($holders === []) {
1976+
continue;
1977+
}
1978+
$seen[$exprString] = true;
1979+
$targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr();
1980+
1981+
// Only project when the target stays Yes-defined in the original
1982+
// scope and in both filtered branches. A sure type implicitly
1983+
// raises certainty to Yes, which would wrongly upgrade Maybe-defined
1984+
// variables — `if (empty($a['bar']))` for instance leaves `$a`
1985+
// Maybe-defined because `empty()` tolerates undefined offsets.
1986+
if (!$scope->hasExpressionType($targetExpr)->yes()) {
1987+
continue;
1988+
}
1989+
if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) {
1990+
continue;
1991+
}
1992+
if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) {
1993+
continue;
1994+
}
1995+
1996+
$origType = $scope->getType($targetExpr);
1997+
$leftType = $leftTruthyScope->getType($targetExpr);
1998+
$rightType = $rightTruthyScope->getType($targetExpr);
1999+
2000+
$leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes();
2001+
$rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes();
2002+
2003+
if (!$leftNarrowed || !$rightNarrowed) {
2004+
continue;
2005+
}
2006+
2007+
$unionType = TypeCombinator::union($leftType, $rightType);
2008+
if ($unionType->equals($origType)) {
2009+
continue;
2010+
}
2011+
2012+
$types = $types->unionWith(
2013+
$this->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope),
2014+
);
2015+
}
2016+
}
2017+
2018+
return $types;
2019+
}
2020+
19442021
/**
19452022
* @return array<string, ConditionalExpressionHolder[]>
19462023
*/
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace Bug9519;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class ClassA {}
8+
class ClassB {}
9+
10+
function instanceofVariants(mixed $obj): void
11+
{
12+
$isA = $obj instanceof ClassA;
13+
$isB = $obj instanceof ClassB;
14+
15+
if ($isA || $isB) {
16+
assertType('Bug9519\\ClassA|Bug9519\\ClassB', $obj);
17+
}
18+
19+
// Sanity check: the equivalent inline form has always worked, so the
20+
// stored-boolean form should produce the same narrowing.
21+
if (($obj instanceof ClassA) || ($obj instanceof ClassB)) {
22+
assertType('Bug9519\\ClassA|Bug9519\\ClassB', $obj);
23+
}
24+
}
25+
26+
/**
27+
* Three-way OR over stored booleans — every arm narrows the same target.
28+
*/
29+
class ClassC {}
30+
31+
function threeWayInstanceof(mixed $obj): void
32+
{
33+
$isA = $obj instanceof ClassA;
34+
$isB = $obj instanceof ClassB;
35+
$isC = $obj instanceof ClassC;
36+
37+
if ($isA || $isB || $isC) {
38+
assertType('Bug9519\\ClassA|Bug9519\\ClassB|Bug9519\\ClassC', $obj);
39+
}
40+
}
41+
42+
/**
43+
* Different narrowing kinds across the OR's arms — `null !==` on the left,
44+
* `instanceof` on the right.
45+
*/
46+
function mixedNarrowingKinds(?ClassA $a, mixed $b): void
47+
{
48+
$aNotNull = $a !== null;
49+
$bIsB = $b instanceof ClassB;
50+
51+
if ($aNotNull || $bIsB) {
52+
// Inside the truthy branch we don't know which arm fired, so each
53+
// target keeps the union of (narrowed-when-its-arm-fired)
54+
// and (original-when-the-other-arm-fired).
55+
assertType(
56+
'Bug9519\\ClassA|null',
57+
$a,
58+
);
59+
assertType(
60+
'mixed',
61+
$b,
62+
);
63+
}
64+
}

0 commit comments

Comments
 (0)