From 214b6eaecb8c6ac02cbb3f15a4b7ab415c10dee1 Mon Sep 17 00:00:00 2001 From: Dmytro Dymarchuk Date: Sun, 19 Jan 2025 21:57:22 +0200 Subject: [PATCH] Narrow variable type in switch cases --- src/Analyser/NodeScopeResolver.php | 31 +++++++++ src/Analyser/TypeSpecifier.php | 65 ++++++++++++++++--- .../Analyser/NodeScopeResolverTest.php | 3 + .../Analyser/data/bug-12432-nullable-enum.php | 35 ++++++++++ .../Analyser/data/bug-12432-nullable-int.php | 26 ++++++++ .../PHPStan/Analyser/nsrt/in_array_loose.php | 2 +- 6 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 tests/PHPStan/Analyser/data/bug-12432-nullable-enum.php create mode 100644 tests/PHPStan/Analyser/data/bug-12432-nullable-int.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e91ca6748e..dfee11bcbc 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -204,6 +204,7 @@ use function array_merge; use function array_pop; use function array_reverse; +use function array_shift; use function array_slice; use function array_values; use function base64_decode; @@ -1513,9 +1514,11 @@ private function processStmtNode( $exitPointsForOuterLoop = []; $throwPoints = $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); + $defaultCondExprs = []; foreach ($stmt->cases as $caseNode) { if ($caseNode->cond !== null) { $condExpr = new BinaryOp\Equal($stmt->cond, $caseNode->cond); + $defaultCondExprs[] = new BinaryOp\NotEqual($stmt->cond, $caseNode->cond); $caseResult = $this->processExprNode($stmt, $caseNode->cond, $scopeForBranches, $nodeCallback, ExpressionContext::createDeep()); $scopeForBranches = $caseResult->getScope(); $hasYield = $hasYield || $caseResult->hasYield(); @@ -1525,6 +1528,11 @@ private function processStmtNode( } else { $hasDefaultCase = true; $branchScope = $scopeForBranches; + $defaultConditions = $this->createBooleanAndFromExpressions($defaultCondExprs); + if ($defaultConditions !== null) { + $branchScope = $this->processExprNode($stmt, $defaultConditions, $scope, static function (): void { + }, ExpressionContext::createDeep())->getTruthyScope()->filterByTruthyValue($defaultConditions); + } } $branchScope = $branchScope->mergeWith($prevScope); @@ -6570,6 +6578,29 @@ private function getPhpDocReturnType(ResolvedPhpDocBlock $resolvedPhpDoc, Type $ return null; } + /** + * @param list $expressions + */ + private function createBooleanAndFromExpressions(array $expressions): ?Expr + { + if (count($expressions) === 0) { + return null; + } + + if (count($expressions) === 1) { + return $expressions[0]; + } + + $left = array_shift($expressions); + $right = $this->createBooleanAndFromExpressions($expressions); + + if ($right === null) { + throw new ShouldNotHappenException(); + } + + return new BooleanAnd($left, $right); + } + /** * @param array $nodes * @return list diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4430e7fe45..682e6085b6 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1572,15 +1572,8 @@ private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\ $leftType = $scope->getType($binaryOperation->left); $rightType = $scope->getType($binaryOperation->right); - $rightExpr = $binaryOperation->right; - if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightExpr = $rightExpr->getExpr(); - } - - $leftExpr = $binaryOperation->left; - if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftExpr = $leftExpr->getExpr(); - } + $rightExpr = $this->extractExpression($binaryOperation->right); + $leftExpr = $this->extractExpression($binaryOperation->left); if ( $leftType instanceof ConstantScalarType @@ -1599,6 +1592,39 @@ private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\ return null; } + /** + * @return array{Expr, Type, Type}|null + */ + private function findEnumTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array + { + $leftType = $scope->getType($binaryOperation->left); + $rightType = $scope->getType($binaryOperation->right); + + $rightExpr = $this->extractExpression($binaryOperation->right); + $leftExpr = $this->extractExpression($binaryOperation->left); + + if ( + $leftType->getEnumCases() === [$leftType] + && !$rightExpr instanceof ConstFetch + && !$rightExpr instanceof ClassConstFetch + ) { + return [$binaryOperation->right, $leftType, $rightType]; + } elseif ( + $rightType->getEnumCases() === [$rightType] + && !$leftExpr instanceof ConstFetch + && !$leftExpr instanceof ClassConstFetch + ) { + return [$binaryOperation->left, $rightType, $leftType]; + } + + return null; + } + + private function extractExpression(Expr $expr): Expr + { + return $expr instanceof AlwaysRememberedExpr ? $expr->getExpr() : $expr; + } + /** @api */ public function create( Expr $expr, @@ -1990,6 +2016,27 @@ public function resolveEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecif ) { return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); } + + if (!$context->null() && TypeCombinator::containsNull($otherType)) { + if ($constantType->toBoolean()->isTrue()->yes()) { + $otherType = TypeCombinator::remove($otherType, new NullType()); + } + + if (!$otherType->isSuperTypeOf($constantType)->no()) { + return $this->create($exprNode, TypeCombinator::intersect($constantType, $otherType), $context, $scope)->setRootExpr($expr); + } + } + } + + $expressions = $this->findEnumTypeExpressionsFromBinaryOperation($scope, $expr); + if ($expressions !== null) { + $exprNode = $expressions[0]; + $enumCaseObjectType = $expressions[1]; + $otherType = $expressions[2]; + + if (!$context->null()) { + return $this->create($exprNode, TypeCombinator::intersect($enumCaseObjectType, $otherType), $context, $scope)->setRootExpr($expr); + } } $leftType = $scope->getType($expr->left); diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index cc9f6112fc..fc76b58e8c 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -101,9 +101,12 @@ private static function findTestFiles(): iterable define('TEST_FALSE_CONSTANT', false); define('TEST_ARRAY_CONSTANT', [true, false, null]); define('TEST_ENUM_CONSTANT', Foo::ONE); + yield __DIR__ . '/data/bug-12432-nullable-enum.php'; yield __DIR__ . '/data/new-in-initializers-runtime.php'; } + yield __DIR__ . '/data/bug-12432-nullable-int.php'; + yield __DIR__ . '/../Rules/Comparison/data/bug-6473.php'; yield __DIR__ . '/../Rules/Methods/data/filter-iterator-child-class.php'; diff --git a/tests/PHPStan/Analyser/data/bug-12432-nullable-enum.php b/tests/PHPStan/Analyser/data/bug-12432-nullable-enum.php new file mode 100644 index 0000000000..ee24d3580a --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-12432-nullable-enum.php @@ -0,0 +1,35 @@ +|int<3, max>', $nullable); + break; + } + + return $nullable; +} diff --git a/tests/PHPStan/Analyser/nsrt/in_array_loose.php b/tests/PHPStan/Analyser/nsrt/in_array_loose.php index 78d2899b8c..8018fd7f65 100644 --- a/tests/PHPStan/Analyser/nsrt/in_array_loose.php +++ b/tests/PHPStan/Analyser/nsrt/in_array_loose.php @@ -42,7 +42,7 @@ public function looseComparison( assertType('int|string', $stringOrInt); // could be '1'|'2'|1|2 } if (in_array($stringOrNull, ['1', 'a'])) { - assertType('string|null', $stringOrNull); // could be '1'|'a' + assertType("'1'|'a'", $stringOrNull); } } }