Skip to content

Commit f2cf5ca

Browse files
dmytro-dymarchukondrejmirtes
authored andcommitted
Narrow variable type in switch cases
1 parent 228c589 commit f2cf5ca

File tree

6 files changed

+152
-10
lines changed

6 files changed

+152
-10
lines changed

src/Analyser/NodeScopeResolver.php

+31
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@
206206
use function array_merge;
207207
use function array_pop;
208208
use function array_reverse;
209+
use function array_shift;
209210
use function array_slice;
210211
use function array_values;
211212
use function base64_decode;
@@ -1566,10 +1567,12 @@ private function processStmtNode(
15661567
$throwPoints = $condResult->getThrowPoints();
15671568
$impurePoints = $condResult->getImpurePoints();
15681569
$fullCondExpr = null;
1570+
$defaultCondExprs = [];
15691571
foreach ($stmt->cases as $caseNode) {
15701572
if ($caseNode->cond !== null) {
15711573
$condExpr = new BinaryOp\Equal($stmt->cond, $caseNode->cond);
15721574
$fullCondExpr = $fullCondExpr === null ? $condExpr : new BooleanOr($fullCondExpr, $condExpr);
1575+
$defaultCondExprs[] = new BinaryOp\NotEqual($stmt->cond, $caseNode->cond);
15731576
$caseResult = $this->processExprNode($stmt, $caseNode->cond, $scopeForBranches, $nodeCallback, ExpressionContext::createDeep());
15741577
$scopeForBranches = $caseResult->getScope();
15751578
$hasYield = $hasYield || $caseResult->hasYield();
@@ -1580,6 +1583,11 @@ private function processStmtNode(
15801583
$hasDefaultCase = true;
15811584
$fullCondExpr = null;
15821585
$branchScope = $scopeForBranches;
1586+
$defaultConditions = $this->createBooleanAndFromExpressions($defaultCondExprs);
1587+
if ($defaultConditions !== null) {
1588+
$branchScope = $this->processExprNode($stmt, $defaultConditions, $scope, static function (): void {
1589+
}, ExpressionContext::createDeep())->getTruthyScope()->filterByTruthyValue($defaultConditions);
1590+
}
15831591
}
15841592

15851593
$branchScope = $branchScope->mergeWith($prevScope);
@@ -6701,6 +6709,29 @@ private function getPhpDocReturnType(ResolvedPhpDocBlock $resolvedPhpDoc, Type $
67016709
return null;
67026710
}
67036711

6712+
/**
6713+
* @param list<Expr> $expressions
6714+
*/
6715+
private function createBooleanAndFromExpressions(array $expressions): ?Expr
6716+
{
6717+
if (count($expressions) === 0) {
6718+
return null;
6719+
}
6720+
6721+
if (count($expressions) === 1) {
6722+
return $expressions[0];
6723+
}
6724+
6725+
$left = array_shift($expressions);
6726+
$right = $this->createBooleanAndFromExpressions($expressions);
6727+
6728+
if ($right === null) {
6729+
throw new ShouldNotHappenException();
6730+
}
6731+
6732+
return new BooleanAnd($left, $right);
6733+
}
6734+
67046735
/**
67056736
* @param array<Node> $nodes
67066737
* @return list<Node\Stmt>

src/Analyser/TypeSpecifier.php

+56-9
Original file line numberDiff line numberDiff line change
@@ -1643,15 +1643,8 @@ private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\
16431643
$leftType = $scope->getType($binaryOperation->left);
16441644
$rightType = $scope->getType($binaryOperation->right);
16451645

1646-
$rightExpr = $binaryOperation->right;
1647-
if ($rightExpr instanceof AlwaysRememberedExpr) {
1648-
$rightExpr = $rightExpr->getExpr();
1649-
}
1650-
1651-
$leftExpr = $binaryOperation->left;
1652-
if ($leftExpr instanceof AlwaysRememberedExpr) {
1653-
$leftExpr = $leftExpr->getExpr();
1654-
}
1646+
$rightExpr = $this->extractExpression($binaryOperation->right);
1647+
$leftExpr = $this->extractExpression($binaryOperation->left);
16551648

16561649
if (
16571650
$leftType instanceof ConstantScalarType
@@ -1670,6 +1663,39 @@ private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\
16701663
return null;
16711664
}
16721665

1666+
/**
1667+
* @return array{Expr, Type, Type}|null
1668+
*/
1669+
private function findEnumTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array
1670+
{
1671+
$leftType = $scope->getType($binaryOperation->left);
1672+
$rightType = $scope->getType($binaryOperation->right);
1673+
1674+
$rightExpr = $this->extractExpression($binaryOperation->right);
1675+
$leftExpr = $this->extractExpression($binaryOperation->left);
1676+
1677+
if (
1678+
$leftType->getEnumCases() === [$leftType]
1679+
&& !$rightExpr instanceof ConstFetch
1680+
&& !$rightExpr instanceof ClassConstFetch
1681+
) {
1682+
return [$binaryOperation->right, $leftType, $rightType];
1683+
} elseif (
1684+
$rightType->getEnumCases() === [$rightType]
1685+
&& !$leftExpr instanceof ConstFetch
1686+
&& !$leftExpr instanceof ClassConstFetch
1687+
) {
1688+
return [$binaryOperation->left, $rightType, $leftType];
1689+
}
1690+
1691+
return null;
1692+
}
1693+
1694+
private function extractExpression(Expr $expr): Expr
1695+
{
1696+
return $expr instanceof AlwaysRememberedExpr ? $expr->getExpr() : $expr;
1697+
}
1698+
16731699
/** @api */
16741700
public function create(
16751701
Expr $expr,
@@ -2061,6 +2087,27 @@ public function resolveEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecif
20612087
) {
20622088
return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr);
20632089
}
2090+
2091+
if (!$context->null() && TypeCombinator::containsNull($otherType)) {
2092+
if ($constantType->toBoolean()->isTrue()->yes()) {
2093+
$otherType = TypeCombinator::remove($otherType, new NullType());
2094+
}
2095+
2096+
if (!$otherType->isSuperTypeOf($constantType)->no()) {
2097+
return $this->create($exprNode, TypeCombinator::intersect($constantType, $otherType), $context, $scope)->setRootExpr($expr);
2098+
}
2099+
}
2100+
}
2101+
2102+
$expressions = $this->findEnumTypeExpressionsFromBinaryOperation($scope, $expr);
2103+
if ($expressions !== null) {
2104+
$exprNode = $expressions[0];
2105+
$enumCaseObjectType = $expressions[1];
2106+
$otherType = $expressions[2];
2107+
2108+
if (!$context->null()) {
2109+
return $this->create($exprNode, TypeCombinator::intersect($enumCaseObjectType, $otherType), $context, $scope)->setRootExpr($expr);
2110+
}
20642111
}
20652112

20662113
$leftType = $scope->getType($expr->left);

tests/PHPStan/Analyser/NodeScopeResolverTest.php

+3
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,13 @@ private static function findTestFiles(): iterable
101101
define('TEST_FALSE_CONSTANT', false);
102102
define('TEST_ARRAY_CONSTANT', [true, false, null]);
103103
define('TEST_ENUM_CONSTANT', Foo::ONE);
104+
yield __DIR__ . '/data/bug-12432-nullable-enum.php';
104105
yield __DIR__ . '/data/new-in-initializers-runtime.php';
105106
yield __DIR__ . '/data/scope-in-enum-match-arm-body.php';
106107
}
107108

109+
yield __DIR__ . '/data/bug-12432-nullable-int.php';
110+
108111
yield __DIR__ . '/../Rules/Comparison/data/bug-6473.php';
109112

110113
yield __DIR__ . '/../Rules/Methods/data/filter-iterator-child-class.php';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace Bug12432;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
enum Foo: int
8+
{
9+
case BAR = 1;
10+
case BAZ = 2;
11+
case QUX = 3;
12+
}
13+
14+
function requireNullableEnum(?Foo $nullable): ?Foo
15+
{
16+
switch ($nullable) {
17+
case Foo::BAR:
18+
assertType('Bug12432\Foo::BAR', $nullable);
19+
case Foo::BAZ:
20+
assertType('Bug12432\Foo::BAR|Bug12432\Foo::BAZ', $nullable);
21+
break;
22+
case '':
23+
assertType('null', $nullable);
24+
case null:
25+
assertType('null', $nullable);
26+
break;
27+
case 0:
28+
assertType('*NEVER*', $nullable);
29+
default:
30+
assertType('Bug12432\Foo::QUX', $nullable);
31+
break;
32+
}
33+
34+
return $nullable;
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Bug12432;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function requireNullableInt(?int $nullable): ?int
8+
{
9+
switch ($nullable) {
10+
case 1:
11+
assertType('1', $nullable);
12+
case 2:
13+
assertType('1|2', $nullable);
14+
break;
15+
case '':
16+
assertType('0|null', $nullable);
17+
case 0:
18+
assertType('0|null', $nullable);
19+
break;
20+
default:
21+
assertType('int<min, -1>|int<3, max>', $nullable);
22+
break;
23+
}
24+
25+
return $nullable;
26+
}

tests/PHPStan/Analyser/nsrt/in_array_loose.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public function looseComparison(
4242
assertType('int|string', $stringOrInt); // could be '1'|'2'|1|2
4343
}
4444
if (in_array($stringOrNull, ['1', 'a'])) {
45-
assertType('string|null', $stringOrNull); // could be '1'|'a'
45+
assertType("'1'|'a'", $stringOrNull);
4646
}
4747
}
4848
}

0 commit comments

Comments
 (0)