Skip to content
1 change: 1 addition & 0 deletions conf/bleedingEdge.neon
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ parameters:
reportMethodPurityOverride: true
checkDynamicConstantNameValues: true
unusedLabel: true
switchConditionAlwaysFalse: true
7 changes: 7 additions & 0 deletions conf/config.level4.neon
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ conditionalTags:
phpstan.rules.rule: %exceptions.check.tooWideThrowType%
PHPStan\Rules\Keywords\UnusedLabelRule:
phpstan.rules.rule: %featureToggles.unusedLabel%
PHPStan\Rules\Comparison\SwitchConditionRule:
phpstan.rules.rule: %featureToggles.switchConditionAlwaysFalse%

parameters:
checkAdvancedIsset: true
Expand All @@ -35,3 +37,8 @@ services:

-
class: PHPStan\Rules\Keywords\UnusedLabelRule

-
class: PHPStan\Rules\Comparison\SwitchConditionRule
arguments:
treatPhpDocTypesAsCertain: %treatPhpDocTypesAsCertain%
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ parameters:
reportMethodPurityOverride: false
checkDynamicConstantNameValues: false
unusedLabel: false
switchConditionAlwaysFalse: false
fileExtensions:
- php
checkAdvancedIsset: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ parametersSchema:
reportMethodPurityOverride: bool()
checkDynamicConstantNameValues: bool()
unusedLabel: bool()
switchConditionAlwaysFalse: bool()
])
fileExtensions: listOf(string())
checkAdvancedIsset: bool()
Expand Down
23 changes: 22 additions & 1 deletion src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@
use PHPStan\Node\PropertyHookStatementNode;
use PHPStan\Node\ReturnStatement;
use PHPStan\Node\StaticMethodCallableNode;
use PHPStan\Node\SwitchConditionArm;
use PHPStan\Node\SwitchConditionNode;
use PHPStan\Node\UnreachableStatementNode;
use PHPStan\Node\VariableAssignNode;
use PHPStan\Node\VarTagChangedExpressionTypeNode;
Expand Down Expand Up @@ -2005,7 +2007,16 @@ public function processStmtNode(
$throwPoints = $condResult->getThrowPoints();
$impurePoints = $condResult->getImpurePoints();
$fullCondExpr = null;
foreach ($stmt->cases as $caseNode) {
$switchConditionArms = [];
$lastNonDefaultCaseKey = null;
foreach ($stmt->cases as $caseKey => $caseNode) {
if ($caseNode->cond === null) {
continue;
}

$lastNonDefaultCaseKey = $caseKey;
}
foreach ($stmt->cases as $caseKey => $caseNode) {
if ($caseNode->cond !== null) {
$condExpr = new BinaryOp\Equal($stmt->cond, $caseNode->cond);
$fullCondExpr = $fullCondExpr === null ? $condExpr : new BooleanOr($fullCondExpr, $condExpr);
Expand All @@ -2014,6 +2025,12 @@ public function processStmtNode(
$hasYield = $hasYield || $caseResult->hasYield();
$throwPoints = array_merge($throwPoints, $caseResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $caseResult->getImpurePoints());
$switchConditionArms[] = new SwitchConditionArm(
$caseNode->cond,
$scopeForBranches,
$caseNode->cond->getStartLine(),
$caseKey === $lastNonDefaultCaseKey,
);
$branchScope = $caseResult->getScope()->filterByTruthyValue($condExpr);
} else {
$hasDefaultCase = true;
Expand Down Expand Up @@ -2051,6 +2068,10 @@ public function processStmtNode(
}
}

if ($switchConditionArms !== []) {
$this->callNodeCallback($nodeCallback, new SwitchConditionNode($stmt->cond, $switchConditionArms, $stmt), $scope, $storage);
}

$exhaustive = $scopeForBranches->getType($stmt->cond) instanceof NeverType;

if (!$hasDefaultCase && !$exhaustive) {
Expand Down
53 changes: 53 additions & 0 deletions src/Node/SwitchConditionArm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php declare(strict_types = 1);

namespace PHPStan\Node;

use PhpParser\Node\Expr;
use PHPStan\Analyser\Scope;

/**
* A single non-default `case` of a `switch`, paired with the scope captured
* right after the case condition was processed (which already excludes the
* values matched by earlier terminating cases).
*
* @api
*/
final class SwitchConditionArm
{

public function __construct(
private Expr $caseCondition,
private Scope $scope,
private int $line,
private bool $isLast,
)
{
}

public function getCaseCondition(): Expr
{
return $this->caseCondition;
}

public function getScope(): Scope
{
return $this->scope;
}

public function getLine(): int
{
return $this->line;
}

/**
* Whether this is the last non-default `case` of the `switch` (only a
* `default` may follow it), in which case an always-true comparison is fine
* because it does not make any subsequent `case` unreachable. A trailing
* `default` is not considered a `case` it would make unreachable.
*/
public function isLast(): bool
{
return $this->isLast;
}

}
61 changes: 61 additions & 0 deletions src/Node/SwitchConditionNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php declare(strict_types = 1);

namespace PHPStan\Node;

use Override;
use PhpParser\Node\Expr;
use PhpParser\Node\Stmt\Switch_;
use PhpParser\NodeAbstract;

/**
* Virtual node emitted once per `switch` statement. It pairs the switch subject
* with each non-default `case` condition so rules can inspect the loose `==`
* comparison the `switch` performs, using the scope captured at each case
* (which already excludes the values matched by earlier cases).
*
* @api
*/
final class SwitchConditionNode extends NodeAbstract implements VirtualNode
{

/**
* @param SwitchConditionArm[] $arms
*/
public function __construct(
private Expr $subject,
private array $arms,
Switch_ $originalNode,
)
{
parent::__construct($originalNode->getAttributes());
}

public function getSubject(): Expr
{
return $this->subject;
}

/**
* @return SwitchConditionArm[]
*/
public function getArms(): array
{
return $this->arms;
}

#[Override]
public function getType(): string
{
return 'PHPStan_Node_SwitchCondition';
}

/**
* @return string[]
*/
#[Override]
public function getSubNodeNames(): array
{
return [];
}

}
4 changes: 4 additions & 0 deletions src/Reflection/InitializerExprTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -1978,6 +1978,10 @@ public function resolveIdenticalType(Type $leftType, Type $rightType): TypeResul
*/
public function resolveEqualType(Type $leftType, Type $rightType): TypeResult
{
if ($leftType instanceof NeverType || $rightType instanceof NeverType) {
return new TypeResult(new ConstantBooleanType(false), []);
}

if (
($leftType->isEnum()->yes() && $rightType->isTrue()->no())
|| ($rightType->isEnum()->yes() && $leftType->isTrue()->no())
Expand Down
129 changes: 129 additions & 0 deletions src/Rules/Comparison/SwitchConditionRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Comparison;

use PhpParser\Node;
use PhpParser\Node\Expr\BinaryOp\Equal;
use PHPStan\Analyser\CollectedDataEmitter;
use PHPStan\Analyser\NodeCallbackInvoker;
use PHPStan\Analyser\Scope;
use PHPStan\Node\SwitchConditionNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Type;
use PHPStan\Type\VerbosityLevel;
use function sprintf;

/**
* @implements Rule<SwitchConditionNode>
*/
final class SwitchConditionRule implements Rule
{

public function __construct(
private ConstantConditionRuleHelper $constantConditionRuleHelper,
private PossiblyImpureTipHelper $possiblyImpureTipHelper,
private ConstantConditionInTraitHelper $constantConditionInTraitHelper,
private bool $treatPhpDocTypesAsCertain,
)
{
}

public function getNodeType(): string
{
return SwitchConditionNode::class;
}

public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array
{
$subject = $node->getSubject();
$errors = [];
$nextCaseIsDeadForType = false;
$nextCaseIsDeadForNativeType = false;

foreach ($node->getArms() as $arm) {
if (
$nextCaseIsDeadForNativeType
|| ($nextCaseIsDeadForType && $this->treatPhpDocTypesAsCertain)
) {
continue;
}

$armScope = $arm->getScope();
$caseCondition = $arm->getCaseCondition();
$conditionExpr = new Equal($subject, $caseCondition);

$conditionType = $armScope->getType($conditionExpr);
if (!$this->isConstantBoolean($conditionType)) {
$this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr);
continue;
}
if ($conditionType->isTrue()->yes()) {

Check warning on line 61 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr); continue; } - if ($conditionType->isTrue()->yes()) { + if (!$conditionType->toBoolean()->isTrue()->no()) { $nextCaseIsDeadForType = true; }

Check warning on line 61 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\LooseBooleanMutator": @@ @@ $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr); continue; } - if ($conditionType->isTrue()->yes()) { + if ($conditionType->toBoolean()->isTrue()->yes()) { $nextCaseIsDeadForType = true; }

Check warning on line 61 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\LooseBooleanMutator": @@ @@ $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr); continue; } - if ($conditionType->isTrue()->yes()) { + if ($conditionType->toBoolean()->isTrue()->yes()) { $nextCaseIsDeadForType = true; }

Check warning on line 61 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr); continue; } - if ($conditionType->isTrue()->yes()) { + if (!$conditionType->toBoolean()->isTrue()->no()) { $nextCaseIsDeadForType = true; }
$nextCaseIsDeadForType = true;
}

if (!$this->treatPhpDocTypesAsCertain) {
$conditionNativeType = $armScope->getNativeType($conditionExpr);
if (!$this->isConstantBoolean($conditionNativeType)) {
$this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr);
continue;
}
if ($conditionNativeType->isTrue()->yes()) {

Check warning on line 71 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr); continue; } - if ($conditionNativeType->isTrue()->yes()) { + if (!$conditionNativeType->toBoolean()->isTrue()->no()) { $nextCaseIsDeadForNativeType = true; } }

Check warning on line 71 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\LooseBooleanMutator": @@ @@ $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr); continue; } - if ($conditionNativeType->isTrue()->yes()) { + if ($conditionNativeType->toBoolean()->isTrue()->yes()) { $nextCaseIsDeadForNativeType = true; } }

Check warning on line 71 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr); continue; } - if ($conditionNativeType->isTrue()->yes()) { + if (!$conditionNativeType->toBoolean()->isTrue()->no()) { $nextCaseIsDeadForNativeType = true; } }
$nextCaseIsDeadForNativeType = true;
}
}

$subjectType = $armScope->getType($subject);
if ($this->isConstantBoolean($subjectType)) {
$caseConditionStandaloneType = $this->constantConditionRuleHelper->getBooleanType($armScope, $caseCondition);
if (!$this->isConstantBoolean($caseConditionStandaloneType)) {
$this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr);
continue;
}
}

if ($conditionType->isFalse()->yes()) {

Check warning on line 85 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } } - if ($conditionType->isFalse()->yes()) { + if (!$conditionType->toBoolean()->isFalse()->no()) { $errorBuilder = RuleErrorBuilder::message(sprintf( 'Switch condition comparison between %s and %s is always false.', $subjectType->describe(VerbosityLevel::value()),

Check warning on line 85 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } } - if ($conditionType->isFalse()->yes()) { + if (!$conditionType->toBoolean()->isFalse()->no()) { $errorBuilder = RuleErrorBuilder::message(sprintf( 'Switch condition comparison between %s and %s is always false.', $subjectType->describe(VerbosityLevel::value()),

Check warning on line 85 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\LooseBooleanMutator": @@ @@ } } - if ($conditionType->isFalse()->yes()) { + if ($conditionType->toBoolean()->isFalse()->yes()) { $errorBuilder = RuleErrorBuilder::message(sprintf( 'Switch condition comparison between %s and %s is always false.', $subjectType->describe(VerbosityLevel::value()),
$errorBuilder = RuleErrorBuilder::message(sprintf(
'Switch condition comparison between %s and %s is always false.',
$subjectType->describe(VerbosityLevel::value()),
$armScope->getType($caseCondition)->describe(VerbosityLevel::value()),
))->line($arm->getLine())->identifier('switch.alwaysFalse');
$this->possiblyImpureTipHelper->addTip($armScope, $conditionExpr, $errorBuilder);
$ruleError = $errorBuilder->build();
if ($scope->isInTrait()) {
$this->constantConditionInTraitHelper->emitError(self::class, $scope, $conditionExpr, false, $ruleError);
} else {
$errors[] = $ruleError;
}
continue;
}

if ($arm->isLast()) {
$this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr);
continue;
}

$errorBuilder = RuleErrorBuilder::message(sprintf(
'Switch condition comparison between %s and %s is always true.',
$subjectType->describe(VerbosityLevel::value()),
$armScope->getType($caseCondition)->describe(VerbosityLevel::value()),
))->line($arm->getLine())->identifier('switch.alwaysTrue')
->tip('Remove remaining cases below this one and this error will disappear too.');
$this->possiblyImpureTipHelper->addTip($armScope, $conditionExpr, $errorBuilder);
$ruleError = $errorBuilder->build();
if ($scope->isInTrait()) {
$this->constantConditionInTraitHelper->emitError(self::class, $scope, $conditionExpr, true, $ruleError);
} else {
$errors[] = $ruleError;
}
}

return $errors;
}

private function isConstantBoolean(Type $type): bool
{
return $type->isTrue()->yes() || $type->isFalse()->yes();

Check warning on line 126 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\LooseBooleanMutator": @@ @@ private function isConstantBoolean(Type $type): bool { - return $type->isTrue()->yes() || $type->isFalse()->yes(); + return $type->isTrue()->yes() || $type->toBoolean()->isFalse()->yes(); } }

Check warning on line 126 in src/Rules/Comparison/SwitchConditionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\LooseBooleanMutator": @@ @@ private function isConstantBoolean(Type $type): bool { - return $type->isTrue()->yes() || $type->isFalse()->yes(); + return $type->isTrue()->yes() || $type->toBoolean()->isFalse()->yes(); } }
}

}
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/nsrt/bcmath-number.php
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,8 @@ public function bcVsNever(Number $a): void
assertType('bool', $a > $b);
assertType('bool', $a >= $b);
assertType('*NEVER*', $a <=> $b);
assertType('bool', $a == $b);
assertType('bool', $a != $b);
assertType('false', $a == $b);
assertType('true', $a != $b);
assertType('*NEVER*', $a & $b);
assertType('*NEVER*', $a ^ $b);
assertType('*NEVER*', $a | $b);
Expand Down
Loading
Loading