Skip to content

Commit 87004e7

Browse files
committed
Fix match regression
1 parent e50b57c commit 87004e7

File tree

4 files changed

+103
-5
lines changed

4 files changed

+103
-5
lines changed

src/Analyser/NodeScopeResolver.php

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4215,10 +4215,10 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
42154215
continue;
42164216
}
42174217

4218-
$condNodes = [];
4219-
$conditionCases = [];
4220-
$conditionExprs = [];
4221-
foreach ($arm->conds as $j => $cond) {
4218+
// Pre-validate all conditions before processing to avoid
4219+
// partial consumption of enum cases when a later condition
4220+
// causes the arm to be skipped
4221+
foreach ($arm->conds as $cond) {
42224222
if (!$cond instanceof Expr\ClassConstFetch) {
42234223
continue 2;
42244224
}
@@ -4233,14 +4233,38 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
42334233
if (!array_key_exists($loweredFetchedClassName, $indexedEnumCases)) {
42344234
continue 2;
42354235
}
4236+
$caseName = $cond->name->toString();
4237+
if (!array_key_exists($caseName, $indexedEnumCases[$loweredFetchedClassName])) {
4238+
continue 2;
4239+
}
4240+
}
4241+
4242+
$condNodes = [];
4243+
$conditionCases = [];
4244+
$conditionExprs = [];
4245+
foreach ($arm->conds as $j => $cond) {
4246+
if (!$cond instanceof Expr\ClassConstFetch) {
4247+
throw new ShouldNotHappenException();
4248+
}
4249+
if (!$cond->class instanceof Name) {
4250+
throw new ShouldNotHappenException();
4251+
}
4252+
if (!$cond->name instanceof Node\Identifier) {
4253+
throw new ShouldNotHappenException();
4254+
}
4255+
$fetchedClassName = $scope->resolveName($cond->class);
4256+
$loweredFetchedClassName = strtolower($fetchedClassName);
4257+
if (!array_key_exists($loweredFetchedClassName, $indexedEnumCases)) {
4258+
throw new ShouldNotHappenException();
4259+
}
42364260

42374261
if (!array_key_exists($loweredFetchedClassName, $unusedIndexedEnumCases)) {
42384262
throw new ShouldNotHappenException();
42394263
}
42404264

42414265
$caseName = $cond->name->toString();
42424266
if (!array_key_exists($caseName, $indexedEnumCases[$loweredFetchedClassName])) {
4243-
continue 2;
4267+
throw new ShouldNotHappenException();
42444268
}
42454269

42464270
$enumCase = $indexedEnumCases[$loweredFetchedClassName][$caseName];
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Comparison;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
use PHPUnit\Framework\Attributes\RequiresPhp;
8+
9+
/**
10+
* Regression test: when a match arm has multiple enum case conditions and the
11+
* enum fast-path analysis cannot handle all of them (e.g. because the condition
12+
* type is narrowed to a single case), the analysis must not partially consume
13+
* enum cases from the unused pool. Partial consumption caused the remaining
14+
* type to become NeverType, corrupting the scope for subsequent match expressions.
15+
*
16+
* @extends RuleTestCase<MatchCallbackScopeRegressionRule>
17+
*/
18+
class MatchEnumPartialArmRegressionTest extends RuleTestCase
19+
{
20+
21+
protected function getRule(): Rule
22+
{
23+
return new MatchCallbackScopeRegressionRule();
24+
}
25+
26+
#[RequiresPhp('>= 8.1')]
27+
public function testEnumPartialArmConsumption(): void
28+
{
29+
$this->analyse([__DIR__ . '/data/match-enum-partial-arm-regression.php'], [
30+
[
31+
'MatchEnumPartialArmRegression\MyEnum::A',
32+
19,
33+
],
34+
[
35+
'MatchEnumPartialArmRegression\MyEnum::A',
36+
25,
37+
],
38+
]);
39+
}
40+
41+
}

tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ public function testEnums(): void
152152
'Match arm comparison between *NEVER* and MatchEnums\DifferentEnum::ONE is always false.',
153153
113,
154154
],
155+
[
156+
'Match arm comparison between MatchEnums\Foo::ONE and MatchEnums\Foo::ONE is always true.',
157+
113,
158+
'Remove remaining cases below this one and this error will disappear too.',
159+
],
155160
]);
156161
}
157162

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php // lint >= 8.1
2+
3+
namespace MatchEnumPartialArmRegression;
4+
5+
enum MyEnum
6+
{
7+
case A;
8+
case B;
9+
}
10+
11+
function test(): void
12+
{
13+
$enum = MyEnum::A;
14+
15+
// This match has both cases in a single arm, but $enum is known to be
16+
// MyEnum::A only. The enum fast-path analysis should not partially consume
17+
// case A from unused cases and then bail out on case B, as that would
18+
// incorrectly narrow the remaining type to never.
19+
match ($enum) {
20+
MyEnum::A, MyEnum::B => null,
21+
};
22+
23+
// Without the fix, this second match would see $enum as *NEVER* because
24+
// the first match corrupted the scope via partial enum case consumption.
25+
match ($enum) {
26+
default => null,
27+
};
28+
}

0 commit comments

Comments
 (0)