Skip to content

Commit 7a01578

Browse files
Narrow variable type in switch cases
1 parent bf923ad commit 7a01578

File tree

6 files changed

+152
-10
lines changed

6 files changed

+152
-10
lines changed

src/Analyser/NodeScopeResolver.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@
204204
use function array_merge;
205205
use function array_pop;
206206
use function array_reverse;
207+
use function array_shift;
207208
use function array_slice;
208209
use function array_values;
209210
use function base64_decode;
@@ -1513,9 +1514,11 @@ private function processStmtNode(
15131514
$exitPointsForOuterLoop = [];
15141515
$throwPoints = $condResult->getThrowPoints();
15151516
$impurePoints = $condResult->getImpurePoints();
1517+
$defaultCondExprs = [];
15161518
foreach ($stmt->cases as $caseNode) {
15171519
if ($caseNode->cond !== null) {
15181520
$condExpr = new BinaryOp\Equal($stmt->cond, $caseNode->cond);
1521+
$defaultCondExprs[] = new BinaryOp\NotEqual($stmt->cond, $caseNode->cond);
15191522
$caseResult = $this->processExprNode($stmt, $caseNode->cond, $scopeForBranches, $nodeCallback, ExpressionContext::createDeep());
15201523
$scopeForBranches = $caseResult->getScope();
15211524
$hasYield = $hasYield || $caseResult->hasYield();
@@ -1525,6 +1528,11 @@ private function processStmtNode(
15251528
} else {
15261529
$hasDefaultCase = true;
15271530
$branchScope = $scopeForBranches;
1531+
$defaultConditions = $this->createBooleanAndFromExpressions($defaultCondExprs);
1532+
if ($defaultConditions !== null) {
1533+
$branchScope = $this->processExprNode($stmt, $defaultConditions, $scope, static function (): void {
1534+
}, ExpressionContext::createDeep())->getTruthyScope()->filterByTruthyValue($defaultConditions);
1535+
}
15281536
}
15291537

15301538
$branchScope = $branchScope->mergeWith($prevScope);
@@ -6548,6 +6556,29 @@ private function getPhpDocReturnType(ResolvedPhpDocBlock $resolvedPhpDoc, Type $
65486556
return null;
65496557
}
65506558

6559+
/**
6560+
* @param list<Expr> $expressions
6561+
*/
6562+
private function createBooleanAndFromExpressions(array $expressions): ?Expr
6563+
{
6564+
if (count($expressions) === 0) {
6565+
return null;
6566+
}
6567+
6568+
if (count($expressions) === 1) {
6569+
return $expressions[0];
6570+
}
6571+
6572+
$left = array_shift($expressions);
6573+
$right = $this->createBooleanAndFromExpressions($expressions);
6574+
6575+
if ($right === null) {
6576+
throw new ShouldNotHappenException();
6577+
}
6578+
6579+
return new BooleanAnd($left, $right);
6580+
}
6581+
65516582
/**
65526583
* @param array<Node> $nodes
65536584
* @return list<Node\Stmt>

src/Analyser/TypeSpecifier.php

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
use PHPStan\Type\Constant\ConstantIntegerType;
4949
use PHPStan\Type\Constant\ConstantStringType;
5050
use PHPStan\Type\ConstantScalarType;
51+
use PHPStan\Type\Enum\EnumCaseObjectType;
5152
use PHPStan\Type\FloatType;
5253
use PHPStan\Type\FunctionTypeSpecifyingExtension;
5354
use PHPStan\Type\Generic\GenericClassStringType;
@@ -1572,15 +1573,8 @@ private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\
15721573
$leftType = $scope->getType($binaryOperation->left);
15731574
$rightType = $scope->getType($binaryOperation->right);
15741575

1575-
$rightExpr = $binaryOperation->right;
1576-
if ($rightExpr instanceof AlwaysRememberedExpr) {
1577-
$rightExpr = $rightExpr->getExpr();
1578-
}
1579-
1580-
$leftExpr = $binaryOperation->left;
1581-
if ($leftExpr instanceof AlwaysRememberedExpr) {
1582-
$leftExpr = $leftExpr->getExpr();
1583-
}
1576+
$rightExpr = $this->extractExpression($binaryOperation->right);
1577+
$leftExpr = $this->extractExpression($binaryOperation->left);
15841578

15851579
if (
15861580
$leftType instanceof ConstantScalarType
@@ -1599,6 +1593,39 @@ private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\
15991593
return null;
16001594
}
16011595

1596+
/**
1597+
* @return array{Expr, EnumCaseObjectType, Type}|null
1598+
*/
1599+
private function findEnumTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array
1600+
{
1601+
$leftType = $scope->getType($binaryOperation->left);
1602+
$rightType = $scope->getType($binaryOperation->right);
1603+
1604+
$rightExpr = $this->extractExpression($binaryOperation->right);
1605+
$leftExpr = $this->extractExpression($binaryOperation->left);
1606+
1607+
if (
1608+
$leftType->getEnumCases() === [$leftType]
1609+
&& !$rightExpr instanceof ConstFetch
1610+
&& !$rightExpr instanceof ClassConstFetch
1611+
) {
1612+
return [$binaryOperation->right, $leftType, $rightType];
1613+
} elseif (
1614+
$rightType->getEnumCases() === [$rightType]
1615+
&& !$leftExpr instanceof ConstFetch
1616+
&& !$leftExpr instanceof ClassConstFetch
1617+
) {
1618+
return [$binaryOperation->left, $rightType, $leftType];
1619+
}
1620+
1621+
return null;
1622+
}
1623+
1624+
private function extractExpression(Expr $expr): Expr
1625+
{
1626+
return $expr instanceof AlwaysRememberedExpr ? $expr->getExpr() : $expr;
1627+
}
1628+
16021629
/** @api */
16031630
public function create(
16041631
Expr $expr,
@@ -1990,6 +2017,27 @@ public function resolveEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecif
19902017
) {
19912018
return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr);
19922019
}
2020+
2021+
if (!$context->null() && TypeCombinator::containsNull($otherType)) {
2022+
if ($constantType->toBoolean()->isTrue()->yes()) {
2023+
$otherType = TypeCombinator::remove($otherType, new NullType());
2024+
}
2025+
2026+
if (!$otherType->isSuperTypeOf($constantType)->no()) {
2027+
return $this->create($exprNode, TypeCombinator::intersect($constantType, $otherType), $context, $scope)->setRootExpr($expr);
2028+
}
2029+
}
2030+
}
2031+
2032+
$expressions = $this->findEnumTypeExpressionsFromBinaryOperation($scope, $expr);
2033+
if ($expressions !== null) {
2034+
$exprNode = $expressions[0];
2035+
$constantType = $expressions[1];
2036+
$otherType = $expressions[2];
2037+
2038+
if (!$context->null()) {
2039+
return $this->create($exprNode, TypeCombinator::intersect($constantType, $otherType), $context, $scope)->setRootExpr($expr);
2040+
}
19932041
}
19942042

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

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ private static function findTestFiles(): iterable
104104
yield __DIR__ . '/data/new-in-initializers-runtime.php';
105105
}
106106

107+
yield __DIR__ . '/data/bug-12432-nullable-int.php';
108+
107109
yield __DIR__ . '/../Rules/Comparison/data/bug-6473.php';
108110

109111
yield __DIR__ . '/../Rules/Methods/data/filter-iterator-child-class.php';
Lines changed: 35 additions & 0 deletions
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+
}
Lines changed: 26 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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)