diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3f63434d03f..dd9e0e72812 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1726,6 +1726,18 @@ public function processStmtNode( $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScopeMaybeRan->getType($stmt->cond) : $bodyScopeMaybeRan->getNativeType($stmt->cond))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); + + // When every non-terminating path through the body keeps the loop condition + // satisfied (e.g. the boundary iteration always returns or throws), the loop can + // never exit normally, so the code after it is unreachable. + if ( + !$alwaysIterates + && $beforeCondBooleanType->isTrue()->yes() + && !$finalScopeResult->isAlwaysTerminating() + && $finalScopeResult->getScope()->getType($stmt->cond)->toBoolean()->isTrue()->yes() + ) { + $alwaysIterates = true; + } } if (!$alwaysIterates) { foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -1957,6 +1969,20 @@ public function processStmtNode( foreach ($stmt->loop as $loopExpr) { $loopScope = $this->processExprNode($stmt, $loopExpr, $loopScope, $storage, $nodeCallback, ExpressionContext::createTopLevel())->getScope(); } + + // When every non-terminating path through the body keeps the loop condition + // satisfied (e.g. the boundary iteration always returns or throws), the loop can + // never exit normally, so the code after it is unreachable. + if ( + $context->isTopLevel() + && $lastCondExpr !== null + && $isIterableAtLeastOnce->yes() + && !$finalScopeResult->isAlwaysTerminating() + && $loopScope->getType($lastCondExpr)->toBoolean()->isTrue()->yes() + ) { + $alwaysIterates = TrinaryLogic::createYes(); + } + $finalScope = $finalScope->generalizeWith($loopScope); if ($lastCondExpr !== null) { diff --git a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php index ee565a39fa6..ee7b5b0bc34 100644 --- a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php +++ b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php @@ -380,4 +380,11 @@ public function testBug14638(): void $this->analyse([__DIR__ . '/data/bug-14638.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug9444(): void + { + $this->checkExplicitMixedMissingReturn = true; + $this->analyse([__DIR__ . '/data/bug-9444.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Missing/data/bug-9444.php b/tests/PHPStan/Rules/Missing/data/bug-9444.php new file mode 100644 index 00000000000..dd8f34e1e57 --- /dev/null +++ b/tests/PHPStan/Rules/Missing/data/bug-9444.php @@ -0,0 +1,72 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug9444; + +class One +{ + private static int $i = 0; + + public static function run(): string + { + self::$i++; + if (self::$i <= 3) { + throw new \Exception('One:run'); + } + + return 'Ok'; + } +} + +class Main +{ + public function process(): string + { + for ($i = 0; $i <= 5; $i++) { + try { + return One::run(); + } catch (\Throwable $e) { + $sleep = match ($i) { + 0 => 0.5, + 1 => 1, + 2 => 3, + 3 => 6, + 4 => 9, + default => throw $e, + }; + + echo $sleep . PHP_EOL; + } + } + } + + public function processWithIf(): string + { + for ($i = 0; $i <= 5; $i++) { + try { + return One::run(); + } catch (\Throwable $e) { + if ($i >= 5) { + throw $e; + } + echo $i . PHP_EOL; + } + } + } + + public function processWhile(): string + { + $i = 0; + while ($i <= 5) { + try { + return One::run(); + } catch (\Throwable $e) { + if ($i >= 5) { + throw $e; + } + $i++; + } + } + } +}