Emit SwitchConditionNode and report always-false switch case comparisons#5854
Emit SwitchConditionNode and report always-false switch case comparisons#5854phpstan-bot wants to merge 11 commits into
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
To be consistent with MatchExpressionRule I think the SwitchConditionRule should also report always-true
|
Done. Here's a summary of how I addressed VincentLanglet's review ("To be consistent with MatchExpressionRule I think the SwitchConditionRule should also report always-true"). What changed
To get true consistency I had to address a structural difference. Reporting always-true correctly requires cross-case state (when a case is always-true, the cases below it become dead and must be suppressed rather than reported as always-false — exactly what
A welcome side effect: reading each case's scope at the settled point (like match does) produces more precise subject types in messages (e.g. Behavior, verified against matchFor the idiomatic enum- Tests
|
|
We need to think about what to do about those "always true" last cases (or last before default). Match expression is supposed to be exhaustive. We're steering people away from having Switch statements do not have to be exhaustive. We should not steer people away from having Should the new rule for switch statement follow https://phpstan.org/config-reference#reportalwaystrueinlastcondition? And consider even the last non-default case before |
I'm fine with this strategy ; we could still change later. |
VincentLanglet
left a comment
There was a problem hiding this comment.
Do not report always-true error if the only remaining case is the default one.
(Basically a case which is only followed by default could be considered as last too)
|
I've addressed VincentLanglet's review. What changedThe review asked: "Do not report always-true error if the only remaining case is the This matches the strategy ondrejmirtes and VincentLanglet agreed on in the discussion — follow
Behavior impactThis directly resolves the concern ondrejmirtes raised about the idiomatic enum-switch + Tests
|
VincentLanglet
left a comment
There was a problem hiding this comment.
Fix
FILE: src/Analyser/NodeScopeResolver.php
--------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------------
2013 | ERROR | [x] Use early exit to reduce code nesting.
| | (SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed)
--------------------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
--------------------------------------------------------------------------------
|
Done. I addressed the coding-standard finding ( foreach ($stmt->cases as $caseKey => $caseNode) {
if ($caseNode->cond === null) {
continue;
}
$lastNonDefaultCaseKey = $caseKey;
}Verification:
Committed and pushed to the PR branch. |
|
This should be ready @ondrejmirtes @staabm |
36c25f8 to
d262b05
Compare
|
reported the larastan errors upstream in larastan/larastan#2504 |
staabm
left a comment
There was a problem hiding this comment.
this PR does not add new errors for phpstan/phpstan#712 which we expect from it
| if ($leftType instanceof NeverType || $rightType instanceof NeverType) { | ||
| return new TypeResult(new ConstantBooleanType(false), []); | ||
| } |
There was a problem hiding this comment.
not sure about this change, because in #5906 we seem to change to an undecided boolean instead (for ===)
There was a problem hiding this comment.
The #712 example now correctly reports the 2×catchall and 2×unknown duplicates. Both review concerns are addressed.
Summary
I addressed staabm's two review points on PR #5854:
1. "This PR does not add new errors for #712 which we expect from it"
The scope-based always-false detection cannot flag a duplicate case when the switch subject is a general, non-narrowable type like string — subtracting a single literal from string isn't representable, so switch ($string) { case 'catchall'; ... case 'catchall'; } went unreported (I confirmed match has the same limitation, so this is inherent to the narrowing approach).
Fix (commit "Report duplicate switch cases"): SwitchConditionRule now tracks the constant scalar / enum-case value of each case condition (reusing the proven logic from the original #5849 rule) and reports a later case carrying an already-seen value as switch.duplicateCase, pointing at the original case line. This takes precedence over always-false/always-true for that arm, so every exact-duplicate case — string, int, enum, bool, null — is now reported consistently, regardless of whether the subject narrows. The #712 example now emits the expected 2×catchall / 2×unknown errors.
2. resolveEqualType never change conflicts with #5906's direction
#5906 changes strict comparison (===) with a never operand to an undecided boolean rather than constant false, on the basis that never denotes unreachable code carrying no comparable value.
Fix (commit "Keep loose comparison with a never operand undecided"): Reverted the resolveEqualType() short-circuit so never == X / never != X stay an undecided bool (via NeverType::looseCompare()), consistent with #5906. Reverted the bcmath-number.php assertions back to bool, and dropped the now-obsolete *NEVER*-subject expectation from the rule test (that case is no longer flagged — consistent with how match behaves under #5906).
Verification
- Full suite: 17498 tests pass (
make tests) make phpstanclean,make csclean- Both changes committed separately and pushed to the PR branch.
One design note worth flagging for the reviewers: I folded duplicate detection into SwitchConditionRule rather than restoring #5849's separate rule, to avoid double-reporting (int/enum duplicates would otherwise be flagged as both switch.duplicateCase and switch.alwaysFalse). A side effect is that existing int/enum/bool/null duplicate cases now report as switch.duplicateCase instead of switch.alwaysFalse — more informative since the message points to the original case line, and coherent across all subject types.
| $caseKey = $this->getCaseKey($armScope->getType($caseCondition)); | ||
| if ($caseKey !== null) { | ||
| $firstSeen = null; | ||
| foreach ($seenCases as $seenCase) { | ||
| if ($seenCase['key'] === $caseKey) { | ||
| $firstSeen = $seenCase; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if ($firstSeen !== null) { | ||
| $errors[] = RuleErrorBuilder::message(sprintf( | ||
| 'Case %s in switch is a duplicate of case %s on line %d.', | ||
| $this->exprPrinter->printExpr($caseCondition), | ||
| $firstSeen['printed'], | ||
| $firstSeen['line'], | ||
| ))->line($arm->getLine())->identifier('switch.duplicateCase')->build(); | ||
| continue; | ||
| } | ||
|
|
||
| $seenCases[] = [ | ||
| 'key' => $caseKey, | ||
| 'printed' => $this->exprPrinter->printExpr($caseCondition), | ||
| 'line' => $arm->getLine(), | ||
| ]; | ||
| } |
There was a problem hiding this comment.
why do we need case-key matching? we should be able to identify already subtracted values based on type
There was a problem hiding this comment.
Done. I addressed staabm's review.
What changed
staabm asked: "why do we need case-key matching? we should be able to identify already subtracted values based on type"
The duplicate detection in SwitchConditionRule was building a hand-rolled identity key for each case value — ['scalar', $value] for scalars and ['enum', $class, $caseName] for enums — then comparing those arrays with ===. That reimplemented type identity by hand, which the codebase convention explicitly steers away from (compare types via equals(), don't reconstruct identity).
src/Rules/Comparison/SwitchConditionRule.php:
- A case carries a single definite value exactly when
getFiniteTypes()returns one element — this unifies scalars, enum cases,boolandnullinto a single type-based check, replacing both branches of the oldgetCaseKey()helper. - Two such values are duplicates when their
Typesequals()each other, soseenCasesnow stores theTyperather than a synthetic key. - Removed the now-dead
getCaseKey()method, and reused the already-computed$caseConditionTypein the always-falsedescribe()call instead of re-fetching it.
This is a behavior-preserving refactor: loose-but-distinct literals (1 vs '1', 0 vs false) still aren't duplicates because equals() is strict, and a backed enum case isn't a duplicate of its backing string — exactly what the existing tests assert.
Verification
SwitchConditionRuleTest— 6/6 passmake tests— 17498 passmake phpstan— cleanmake cs— clean
Committed as "Identify duplicate switch cases by type instead of a hand-rolled key" and pushed to the PR branch.
@staabm I don't think a special handling should be done here, since it's not handled in Maybe it's better to just let 712 open for another PR which has to work for both match and switch, like
|
- Add `SwitchConditionNode` virtual node, emitted by `NodeScopeResolver` for every non-default `case`, pairing the switch subject with the case condition. - Add `SwitchConditionRule` (level 4, gated behind the bleeding-edge `switchConditionAlwaysFalse` feature toggle) which inspects the loose `==` comparison the `switch` performs in the scope captured at the case condition. That scope already excludes the values matched by earlier (terminating) cases, so the rule reports type-incompatible cases (phpstan/phpstan#712), exhausted unions/enums, integer-range/literal cases and duplicate int/enum/bool cases as `switch.alwaysFalse`. This mirrors how `MatchExpressionRule` reports `match.alwaysFalse`. - Fix `InitializerExprTypeResolver::resolveEqualType()` to return `false` when either operand is `NeverType`, mirroring the existing handling in `resolveIdenticalType()`. Without this, `never == X` returned `bool`, so exhausted switch subjects (narrowed to `never`) were not detected. - Update `nsrt/bcmath-number.php` expectations: `never == X` / `never != X` are now `false` / `true` (previously the inconsistent `bool`), matching `===`/`!==`.
Mirror MatchExpressionRule's trait handling test: combine the rule with ConstantConditionInTraitRule via CompositeRule and assert that an always-false switch case inside a trait is reported once when constant in every using class, while a case that is constant in only some contexts is not reported. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror MatchExpressionRule by also reporting switch cases whose loose `==` comparison against the subject can never be false. A case is flagged `switch.alwaysTrue` when the subject is narrowed (by earlier terminating cases) to exactly match it, unless it is the last case of the switch — in which case the always-true comparison makes nothing unreachable. As with match, once a case is always-true the subsequent (now dead) cases are suppressed instead of being reported as always-false. To track this across cases, SwitchConditionNode is now emitted once per switch carrying every non-default case together with the scope captured at that case (like MatchExpressionNode), and SwitchConditionRule iterates them with cross-case dead-case tracking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A `case` followed only by `default` does not make any subsequent `case` unreachable, so an always-true comparison there is fine — just like the genuinely last `case`. This mirrors reportAlwaysTrueInLastCondition and keeps the idiomatic enum-switch + `default: throw` pattern from being flagged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The switch-condition-always-false.php test data file used the native `mixed` type hint, which is only available on PHP 8.0+. Use a `@param mixed` PHPDoc instead so the file lints on PHP 7.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enum-based tests (testRuleEnum, testAlwaysTrue, testImpossibleCases) require PHP 8.1, so guard them with #[RequiresPhp]. The int == 'foo' loose comparison is only always-false since PHP 8.0 (before that a non-numeric string loosely equals 0), so testDoNotTreatPhpDocTypesAsCertain expects no error on PHP < 8.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts the resolveEqualType() short-circuit that made `never == X` / `never != X` evaluate to a constant `false` / `true`. A `never` operand denotes unreachable code that carries no comparable value, so the result should stay an undecided `bool` (via NeverType::looseCompare()), matching the direction taken for strict comparison in phpstan-src#5906. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scope-based always-false detection cannot flag a duplicate `case` when the switch subject is a general, non-narrowable type such as `string` (subtracting a single literal from `string` is not representable), so duplicates like the ones in phpstan/phpstan#712 went unreported. Track the constant scalar / enum-case value of each `case` condition and report a later `case` carrying a value already seen earlier as a `switch.duplicateCase` error pointing at the original case. This takes precedence over the always-false/always-true reporting for that arm, so every exact-duplicate case is reported the same way regardless of whether the subject type happens to narrow. Closes phpstan/phpstan#712 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the bespoke scalar/enum case-key construction with the Type API: a case carries a single definite value when getFiniteTypes() returns exactly one type, and two such values are duplicates when they equals() each other. This works uniformly for scalars, enum cases, bool and null without reconstructing identity by hand, and follows the codebase convention of comparing types via equals() rather than ad-hoc keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
de29e7c to
a77edf9
Compare
Summary
PHPStan already reports always-false
matcharm comparisons (match.alwaysFalse) by tracking types inScope, but had no equivalent forswitch. This adds that diagnostic forswitchstatements: acasewhose loose==comparison against the switch subject can never be true is now reported asswitch.alwaysFalse.This catches type-incompatible cases (e.g.
switch ($int) { case 'foo': }— phpstan/phpstan#712), cases on already-exhausted subjects (unions/enums narrowed away by earlier cases), and duplicateint/enum/boolcases. As withmatch, generalstringsubjects that PHPStan cannot narrow are intentionally not flagged.Changes
src/Node/SwitchConditionNode.php(new): virtual node pairing the switch subject with a case condition.src/Analyser/NodeScopeResolver.php: emit aSwitchConditionNodefor every non-defaultcase, using the scope captured right after the case condition is processed (which already excludes values matched by earlier terminating cases).src/Rules/Comparison/SwitchConditionRule.php(new): builds the looseEqual(subject, caseCondition)comparison and reportsswitch.alwaysFalsewhen it is constantlyfalse. MirrorsMatchExpressionRule's native-type /treatPhpDocTypesAsCertain, boolean-subject and in-trait handling.conf/config.neon,conf/parametersSchema.neon,conf/bleedingEdge.neon,conf/config.level4.neon: newswitchConditionAlwaysFalsefeature toggle (off by default, on under bleeding edge), registering the rule at level 4.src/Reflection/InitializerExprTypeResolver.php:resolveEqualType()now returnsfalsewhen either operand isNeverType, mirroringresolveIdenticalType().Root cause
switchhad no type-based always-false detection at all — only the never-merged syntacticDuplicateCasesInSwitchRule(#5849) approached the problem, and only for syntactic duplicates. The scope-tracking approach reuses the narrowing PHPStan already performs between cases.The accompanying type-system bug is an instance of "the same bug in adjacent code":
resolveIdenticalType()short-circuitedNeverTypeoperands tofalse, but its siblingresolveEqualType()did not and fell through tolooseCompare(), yieldingbool. As a result a switch subject narrowed tonever(all cases exhausted) producednever == X === bool, hiding the always-false case. FixingresolveEqualType()makes loose and strict comparison consistent fornever.Test
tests/PHPStan/Rules/Comparison/SwitchConditionRuleTest.phpwith data files:switch-condition-always-false.php— the samples from phpstan-src#5849, asserting the different errors the scope-based approach produces (duplicateintcase, duplicatetrue/nullonmixed).switch-condition-always-false-enum.php— duplicate enum case.switch-condition-always-false-impossible.php— type mismatch (More precise dirname signature #712), exhausted literal-int / string unions, integer ranges, and a non-constant case on an exhausted (never) subject that specifically exercises theresolveEqualTypefix.switch-condition-always-false-native.php—treatPhpDocTypesAsCertain = falsemode.tests/PHPStan/Analyser/nsrt/bcmath-number.phpupdated:never == X/never != Xnow inferfalse/true, locking in theresolveEqualTypefix.Fixes phpstan/phpstan#14815
Closes phpstan/phpstan#712