Skip to content

Commit 4d8bf50

Browse files
github-actions[bot]phpstan-bot
authored andcommitted
Fix variable definedness in always-iterating loops with try/catch
- In while(true), do...while(true), and for(;;) loops, when the only exit path is through break, the scope after the loop should come exclusively from break exit points, not from the unreachable "condition becomes false" path - Previously, the normal-flow scope (e.g. from a catch block where the variable was not assigned) was merged with break exit point scopes, degrading variable certainty from Yes to Maybe - New regression test in tests/PHPStan/Rules/Variables/data/bug-5919.php Closes phpstan/phpstan#5919
1 parent ba4fe14 commit 4d8bf50

File tree

3 files changed

+113
-21
lines changed

3 files changed

+113
-21
lines changed

src/Analyser/NodeScopeResolver.php

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,8 +1520,15 @@ private function processStmtNode(
15201520
}
15211521

15221522
$breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class);
1523-
foreach ($breakExitPoints as $breakExitPoint) {
1524-
$finalScope = $finalScope->mergeWith($breakExitPoint->getScope());
1523+
if ($alwaysIterates && count($breakExitPoints) > 0) {
1524+
$finalScope = $breakExitPoints[0]->getScope();
1525+
for ($i = 1, $breakExitPointsCount = count($breakExitPoints); $i < $breakExitPointsCount; $i++) {
1526+
$finalScope = $finalScope->mergeWith($breakExitPoints[$i]->getScope());
1527+
}
1528+
} else {
1529+
foreach ($breakExitPoints as $breakExitPoint) {
1530+
$finalScope = $finalScope->mergeWith($breakExitPoint->getScope());
1531+
}
15251532
}
15261533

15271534
$isIterableAtLeastOnce = $beforeCondBooleanType->isTrue()->yes();
@@ -1627,8 +1634,16 @@ private function processStmtNode(
16271634
} else {
16281635
$this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep());
16291636
}
1630-
foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) {
1631-
$finalScope = $breakExitPoint->getScope()->mergeWith($finalScope);
1637+
$breakExitPoints = $bodyScopeResult->getExitPointsByType(Break_::class);
1638+
if ($alwaysIterates && count($breakExitPoints) > 0) {
1639+
$finalScope = $breakExitPoints[0]->getScope();
1640+
for ($i = 1, $breakExitPointsCount = count($breakExitPoints); $i < $breakExitPointsCount; $i++) {
1641+
$finalScope = $finalScope->mergeWith($breakExitPoints[$i]->getScope());
1642+
}
1643+
} else {
1644+
foreach ($breakExitPoints as $breakExitPoint) {
1645+
$finalScope = $breakExitPoint->getScope()->mergeWith($finalScope);
1646+
}
16321647
}
16331648

16341649
return new InternalStatementResult(
@@ -1739,26 +1754,34 @@ private function processStmtNode(
17391754
$finalScope = $finalScope->filterByFalseyValue($lastCondExpr);
17401755
}
17411756

1742-
foreach ($finalScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) {
1743-
$finalScope = $breakExitPoint->getScope()->mergeWith($finalScope);
1744-
}
1745-
1746-
if ($isIterableAtLeastOnce->no() || $finalScopeResult->isAlwaysTerminating()) {
1747-
if ($this->polluteScopeWithLoopInitialAssignments) {
1748-
$finalScope = $initScope;
1749-
} else {
1750-
$finalScope = $scope;
1757+
$breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class);
1758+
if ($alwaysIterates->yes() && count($breakExitPoints) > 0) {
1759+
$finalScope = $breakExitPoints[0]->getScope();
1760+
for ($i = 1, $breakExitPointsCount = count($breakExitPoints); $i < $breakExitPointsCount; $i++) {
1761+
$finalScope = $finalScope->mergeWith($breakExitPoints[$i]->getScope());
1762+
}
1763+
} else {
1764+
foreach ($breakExitPoints as $breakExitPoint) {
1765+
$finalScope = $breakExitPoint->getScope()->mergeWith($finalScope);
17511766
}
17521767

1753-
} elseif ($isIterableAtLeastOnce->maybe()) {
1754-
if ($this->polluteScopeWithLoopInitialAssignments) {
1755-
$finalScope = $finalScope->mergeWith($initScope);
1768+
if ($isIterableAtLeastOnce->no() || $finalScopeResult->isAlwaysTerminating()) {
1769+
if ($this->polluteScopeWithLoopInitialAssignments) {
1770+
$finalScope = $initScope;
1771+
} else {
1772+
$finalScope = $scope;
1773+
}
1774+
1775+
} elseif ($isIterableAtLeastOnce->maybe()) {
1776+
if ($this->polluteScopeWithLoopInitialAssignments) {
1777+
$finalScope = $finalScope->mergeWith($initScope);
1778+
} else {
1779+
$finalScope = $finalScope->mergeWith($scope);
1780+
}
17561781
} else {
1757-
$finalScope = $finalScope->mergeWith($scope);
1758-
}
1759-
} else {
1760-
if (!$this->polluteScopeWithLoopInitialAssignments) {
1761-
$finalScope = $finalScope->mergeWith($scope);
1782+
if (!$this->polluteScopeWithLoopInitialAssignments) {
1783+
$finalScope = $finalScope->mergeWith($scope);
1784+
}
17621785
}
17631786
}
17641787

tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,4 +1257,14 @@ public function testBug12944(): void
12571257
$this->analyse([__DIR__ . '/data/bug-12944.php'], []);
12581258
}
12591259

1260+
public function testBug5919(): void
1261+
{
1262+
$this->cliArgumentsVariablesRegistered = true;
1263+
$this->polluteScopeWithLoopInitialAssignments = true;
1264+
$this->checkMaybeUndefinedVariables = true;
1265+
$this->polluteScopeWithAlwaysIterableForeach = true;
1266+
1267+
$this->analyse([__DIR__ . '/data/bug-5919.php'], []);
1268+
}
1269+
12601270
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug5919;
4+
5+
/**
6+
* @return mixed[]
7+
*/
8+
function queryApi(): array
9+
{
10+
return [5];
11+
}
12+
13+
function testWhileTrueWithTryCatch(): void
14+
{
15+
while (true) {
16+
try {
17+
$s = queryApi();
18+
break;
19+
} catch (\Exception $e) {
20+
if (rand(0, 1)) {
21+
throw $e;
22+
}
23+
}
24+
}
25+
26+
var_dump(count($s));
27+
}
28+
29+
function testDoWhileWithTryCatch(): void
30+
{
31+
do {
32+
try {
33+
$s = queryApi();
34+
break;
35+
} catch (\Exception $e) {
36+
if (rand(0, 1)) {
37+
throw $e;
38+
}
39+
}
40+
} while (true);
41+
42+
var_dump(count($s));
43+
}
44+
45+
function testForEverWithTryCatch(): void
46+
{
47+
for (;;) {
48+
try {
49+
$s = queryApi();
50+
break;
51+
} catch (\Exception $e) {
52+
if (rand(0, 1)) {
53+
throw $e;
54+
}
55+
}
56+
}
57+
58+
var_dump(count($s));
59+
}

0 commit comments

Comments
 (0)