Skip to content

Commit 1105b2d

Browse files
committed
Keep the terminated iterations' scope after an unrolled constant-array foreach when no iteration leaves the loop
- ForeachHandler::tryProcessUnrolledConstantArrayForeach() collects the end scope of every non-optional iteration whose body never reaches the next iteration. When no chain scope and no break scope remain (the code after the loop is unreachable), these scopes become the end scope. Previously the end scope stayed null, and the caller fell back to the generic loop pass. That pass sees the loop variable as the union of all values, so the subject lost its narrowing to `never`. This was a regression from 003d601. - Ported the same change to the turbo-ext mirror (ForeachHandler.cpp). - Analogous constructs: while/for/do-while loops are not unrolled, and the generic foreach path already falls back to the body result scope (`$finalScope ??= $finalScopeResult->getScope()`), so only the unrolled path was affected.
1 parent acd0511 commit 1105b2d

3 files changed

Lines changed: 86 additions & 0 deletions

File tree

‎src/Analyser/StmtHandler/ForeachHandler.php‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,7 @@ private function tryProcessUnrolledConstantArrayForeach(
724724
$allBodyScopes = [];
725725
$allChainScopes = [];
726726
$allBreakScopes = [];
727+
$allTerminatedScopes = [];
727728

728729
$bodyContext = $context->enterUnrolledForeach($totalKeys);
729730

@@ -807,6 +808,7 @@ private function tryProcessUnrolledConstantArrayForeach(
807808
}
808809

809810
// no later iteration runs, the loop is left only through its break statements
811+
$allTerminatedScopes[] = $bodyResult->getScope();
810812
$chainScope = null;
811813
break;
812814
}
@@ -853,6 +855,14 @@ private function tryProcessUnrolledConstantArrayForeach(
853855
$endScope = $endScope === null ? $breakScope : $endScope->mergeWith($breakScope);
854856
}
855857

858+
// the code after the loop is unreachable - keep what the terminated
859+
// iterations narrowed instead of falling back to the generic loop pass
860+
if ($endScope === null) {
861+
foreach ($allTerminatedScopes as $terminatedScope) {
862+
$endScope = $endScope === null ? $terminatedScope : $endScope->mergeWith($terminatedScope);
863+
}
864+
}
865+
856866
// Unsealed shapes describe zero-or-more additional entries beyond the
857867
// explicit keys. Run the scope-generalizing loop on top of the
858868
// unrolled explicit iterations so body-scope variables (e.g. counters)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug15390;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
enum Status
10+
{
11+
case A;
12+
case B;
13+
case C;
14+
}
15+
16+
function f(Status $status): void
17+
{
18+
foreach ([Status::A, Status::B, Status::C] as $s) {
19+
if ($status === $s) {
20+
return;
21+
}
22+
}
23+
24+
assertType('*NEVER*', $status);
25+
}
26+
27+
function g(Status $status): int
28+
{
29+
foreach ([1 => [Status::A, Status::B], 2 => [Status::C]] as $value => $statuses) {
30+
if (in_array($status, $statuses, true)) {
31+
return $value;
32+
}
33+
}
34+
35+
assertType('*NEVER*', $status);
36+
37+
return 0;
38+
}
39+
40+
function h(Status $status): void
41+
{
42+
foreach ([Status::A, Status::B] as $s) {
43+
if ($status === $s) {
44+
return;
45+
}
46+
}
47+
48+
assertType('Bug15390\Status::C', $status);
49+
}
50+
51+
/** @param 1|2|3 $i */
52+
function i(int $i): void
53+
{
54+
foreach ([1, 2, 3] as $v) {
55+
if ($i === $v) {
56+
throw new \Exception();
57+
}
58+
}
59+
60+
assertType('*NEVER*', $i);
61+
}

‎turbo-ext/src/ForeachHandler.cpp‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,6 +1503,7 @@ class ForeachHandler
15031503
zv::Arr allBodyScopes = zv::Arr::empty();
15041504
zv::Arr allChainScopes = zv::Arr::empty();
15051505
zv::Arr allBreakScopes = zv::Arr::empty();
1506+
zv::Arr allTerminatedScopes = zv::Arr::empty();
15061507

15071508
zv::Val bodyContext = pt_statement_context_enter_unrolled_foreach(context, totalKeys);
15081509
if (UNEXPECTED(bodyContext.isUndef())) return false;
@@ -1635,6 +1636,12 @@ class ForeachHandler
16351636
}
16361637

16371638
// no later iteration runs, the loop is left only through its break statements
1639+
{
1640+
zv::Val hold;
1641+
zval *terminatedScope = pt_internal_statement_result_scope(bodyResult.raw(), hold);
1642+
if (UNEXPECTED(terminatedScope == NULL)) return false;
1643+
allTerminatedScopes.push(zv::Ref(terminatedScope));
1644+
}
16381645
chainScope = zv::Val::null();
16391646
break;
16401647
}
@@ -1692,6 +1699,14 @@ class ForeachHandler
16921699
if (UNEXPECTED(!ptlh::mergeOrTake(endScope, entry.value().deref().raw()))) return false;
16931700
}
16941701

1702+
// the code after the loop is unreachable - keep what the terminated
1703+
// iterations narrowed instead of falling back to the generic loop pass
1704+
if (endScope.isNull()) {
1705+
for (auto entry : zv::ArrRef(allTerminatedScopes.raw())) {
1706+
if (UNEXPECTED(!ptlh::mergeOrTake(endScope, entry.value().deref().raw()))) return false;
1707+
}
1708+
}
1709+
16951710
// Unsealed shapes describe zero-or-more additional entries beyond the
16961711
// explicit keys. Run the scope-generalizing loop on top of the
16971712
// unrolled explicit iterations so body-scope variables (e.g. counters)

0 commit comments

Comments
 (0)