Skip to content

Commit fea85e5

Browse files
fix(parser): propagate multi-level break/continue before trailing statements
The flag checks that translate `break N` / `continue N` were emitted only at the end of each enclosing loop body. After the inner construct exited with the countdown flag set, every trailing statement of the enclosing body still executed before the check ran: foreach ([1] as $x) { foreach ([1] as $y) { break 2; } echo "leaked"; // ran in compiled output, not in PHP } The native (int-typed) switch path was worse: its check sat inside the do-while(0) wrapper, decrementing the flag a second time for the switch level the C++ `break` had already exited. A `break 2` from a native switch inside a loop therefore never exited the loop at all. Emit the propagation check immediately after every nested loop / switch statement instead, from the statement dispatcher, and drop the dead end-of-body emissions. The check now also distinguishes the enclosing construct: when it sits inside a switch, a continue that lands on the switch level lowers to `break`, matching PHP's continue-targets-switch semantics. parseBreak/parseContinue now reject levels exceeding the number of enclosing breakable constructs - the same compile-time validation PHP performs (`Cannot 'break' 2 levels`) - which the countdown scheme relies on to terminate at an enclosing construct. The continue-2-while scenario in break-continue-level.phpt encoded the old leaked behavior: its `$i++` after the inner loop only ran because of the misplaced check; standard PHP loops forever on it. The counter now advances before the inner loop.
1 parent a70a0ad commit fea85e5

7 files changed

Lines changed: 223 additions & 38 deletions

File tree

src/CompilerBase.php

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1684,6 +1684,8 @@ protected function parseStmts(array $stmts): string
16841684
$lines = [];
16851685
$inLoopTop = $this->context->inLoop;
16861686
$inContinuableLoopTop = $this->context->inContinuableLoop;
1687+
$breakableIsSwitchTop = $this->context->breakableIsSwitch;
1688+
$breakableDepthTop = $this->context->breakableDepth;
16871689
$last = array_key_last($stmts);
16881690
foreach ($stmts as $i => $v) {
16891691
$class = $v->getType();
@@ -1715,37 +1717,37 @@ protected function parseStmts(array $stmts): string
17151717
$result = $this->parseReturn($v);
17161718
break;
17171719
case 'Stmt_For':
1718-
$this->context->inLoop = true;
1719-
$this->context->inContinuableLoop = true;
1720-
$result = $this->parseFor($v);
1721-
$this->context->inLoop = $inLoopTop;
1722-
$this->context->inContinuableLoop = $inContinuableLoopTop;
1723-
break;
17241720
case 'Stmt_Foreach':
1725-
$this->context->inLoop = true;
1726-
$this->context->inContinuableLoop = true;
1727-
$result = $this->parseForeach($v);
1728-
$this->context->inLoop = $inLoopTop;
1729-
$this->context->inContinuableLoop = $inContinuableLoopTop;
1730-
break;
17311721
case 'Stmt_Switch':
1732-
$this->context->inLoop = true;
1733-
$result = $this->parseSwitch($v);
1734-
$this->context->inLoop = $inLoopTop;
1735-
break;
17361722
case 'Stmt_While':
1737-
$this->context->inLoop = true;
1738-
$this->context->inContinuableLoop = true;
1739-
$result = $this->parseWhile($v);
1740-
$this->context->inLoop = $inLoopTop;
1741-
$this->context->inContinuableLoop = $inContinuableLoopTop;
1742-
break;
17431723
case 'Stmt_Do':
1724+
$isSwitch = $class === 'Stmt_Switch';
17441725
$this->context->inLoop = true;
1745-
$this->context->inContinuableLoop = true;
1746-
$result = $this->parseDo($v);
1726+
if (!$isSwitch) {
1727+
$this->context->inContinuableLoop = true;
1728+
}
1729+
$this->context->breakableIsSwitch = $isSwitch;
1730+
$this->context->breakableDepth = $breakableDepthTop + 1;
1731+
$result = match ($class) {
1732+
'Stmt_For' => $this->parseFor($v),
1733+
'Stmt_Foreach' => $this->parseForeach($v),
1734+
'Stmt_Switch' => $this->parseSwitch($v),
1735+
'Stmt_While' => $this->parseWhile($v),
1736+
default => $this->parseDo($v),
1737+
};
17471738
$this->context->inLoop = $inLoopTop;
17481739
$this->context->inContinuableLoop = $inContinuableLoopTop;
1740+
$this->context->breakableIsSwitch = $breakableIsSwitchTop;
1741+
$this->context->breakableDepth = $breakableDepthTop;
1742+
// A multi-level break/continue exits the nested construct
1743+
// with its countdown flag still set. The propagation check
1744+
// must run before any trailing statement of this body.
1745+
if ($inLoopTop) {
1746+
$flagCheck = $this->genMultiLevelJumpCheck($breakableIsSwitchTop);
1747+
if ($flagCheck !== '') {
1748+
$result = rtrim($result, "\r\n") . PHP_EOL . $flagCheck;
1749+
}
1750+
}
17491751
break;
17501752
case 'Stmt_If':
17511753
$result = $this->parseIf($v);

src/Context/FunctionContext.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ class FunctionContext
8484
public bool $inLoop = false;
8585
/** True while parsing a for/foreach/while/do-while body. */
8686
public bool $inContinuableLoop = false;
87+
/** Number of breakable constructs (loops and switches) enclosing the statement being parsed. */
88+
public int $breakableDepth = 0;
89+
/** True when the innermost enclosing breakable construct is a switch, not a loop. */
90+
public bool $breakableIsSwitch = false;
8791
public bool $inClosure = false;
8892
public ?array $closureReturnTypeCheck = null;
8993
public string $closureReturnTypeStr = '';

src/Parser/ForeachTrait.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ protected function parseForeachItemAsList(string $listTmpVar, array $listItems):
4848

4949
protected function parseForeachBody(Foreach_ $node): string
5050
{
51-
return $this->parseStmts($node->stmts) . $this->genLoopEndFlagCheck();
51+
return $this->parseStmts($node->stmts);
5252
}
5353

5454
protected function parseForeachKeyAssignment(Foreach_ $node, string $keyExpr, string $defaultType = Type::VAR): string

src/Parser/LoopControlTrait.php

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ protected function parseFor(Node\Stmt\For_ $v): string
102102
$code .= ') {' . PHP_EOL;
103103

104104
$code .= $this->parseBlockStmts($stmts);
105-
$code .= $this->genLoopEndFlagCheck();
106105
$code .= $this->getIndent() . '}' . PHP_EOL;
107106

108107
return $code;
@@ -138,7 +137,6 @@ protected function parseWhile(Node\Stmt\While_ $v): string
138137
$code .= 'while (' . $cond . ') {' . PHP_EOL;
139138
}
140139
$code .= $this->parseBlockStmts($stmts);
141-
$code .= $this->genLoopEndFlagCheck();
142140
$code .= $this->getIndent() . '}' . PHP_EOL;
143141

144142
return $code;
@@ -172,7 +170,6 @@ protected function parseDo(Node\Stmt\Do_ $v): string
172170
$code = $this->parseBeforeStmtLines() . PHP_EOL;
173171
$code .= 'do {' . PHP_EOL;
174172
$code .= $bodyCode;
175-
$code .= $this->genLoopEndFlagCheck();
176173
$code .= $this->getIndent() . '} while (' . $cond . ');' . PHP_EOL;
177174

178175
return $code;
@@ -189,6 +186,7 @@ protected function parseBreak(Node\Stmt\Break_ $v): string
189186
}
190187
$num = $v->num;
191188
if ($num) {
189+
$this->checkLoopJumpLevel($v, $num, 'break');
192190
if ($num->value > 1) {
193191
$this->context->hasMultiLevelBreak = true;
194192
return '_brk_flag = ' . ($num->value - 1) . '; break;';
@@ -205,6 +203,7 @@ protected function parseContinue(Node\Stmt\Continue_ $v): string
205203
}
206204
$num = $v->num;
207205
if ($num) {
206+
$this->checkLoopJumpLevel($v, $num, 'continue');
208207
if ($num->value > 1) {
209208
$this->context->hasMultiLevelContinue = true;
210209
return '_cnt_flag = ' . ($num->value - 1) . '; break;';
@@ -214,20 +213,44 @@ protected function parseContinue(Node\Stmt\Continue_ $v): string
214213
}
215214

216215
/**
217-
* Emit flag-propagation checks at the end of a loop body.
216+
* PHP only accepts a positive integer literal that does not exceed the
217+
* number of enclosing loops/switches. The flag lowering relies on this:
218+
* it guarantees the countdown reaches zero at an enclosing construct.
219+
*/
220+
protected function checkLoopJumpLevel(Node\Stmt $v, Node\Expr $num, string $operator): void
221+
{
222+
if (!$num instanceof Node\Scalar\Int_ || $num->value < 1) {
223+
$this->fatalError($v, "'{$operator}' operator accepts only positive integer literals");
224+
}
225+
if ($num->value > $this->context->breakableDepth) {
226+
$this->fatalError($v, "Cannot '{$operator}' {$num->value} levels");
227+
}
228+
}
229+
230+
/**
231+
* Emit flag-propagation checks right after a nested breakable construct.
218232
*
219-
* Translates multi-level break / continue into plain break / continue
220-
* by decrementing a counter at each loop boundary until it reaches zero.
233+
* A multi-level break / continue is lowered to a flag assignment plus a
234+
* plain break out of the innermost construct. Each enclosing loop or
235+
* switch places this check immediately after every nested loop / switch
236+
* statement, so the flag keeps breaking outward — before any trailing
237+
* statements of the enclosing body can run — until it reaches zero at
238+
* the targeted level. When the check sits inside a switch, a continue
239+
* that lands on the switch level behaves like break, matching PHP.
221240
*/
222-
protected function genLoopEndFlagCheck(): string
241+
protected function genMultiLevelJumpCheck(bool $enclosingIsSwitch): string
223242
{
224243
$code = '';
225244
$indent = $this->getIndent();
226245
if ($this->context->hasMultiLevelBreak) {
227246
$code .= "{$indent}if (_brk_flag > 0) { _brk_flag--; break; }" . PHP_EOL;
228247
}
229248
if ($this->context->hasMultiLevelContinue) {
230-
$code .= "{$indent}if (_cnt_flag > 0) { _cnt_flag--; if (_cnt_flag == 0) continue; else break; }" . PHP_EOL;
249+
if ($enclosingIsSwitch) {
250+
$code .= "{$indent}if (_cnt_flag > 0) { _cnt_flag--; break; }" . PHP_EOL;
251+
} else {
252+
$code .= "{$indent}if (_cnt_flag > 0) { _cnt_flag--; if (_cnt_flag == 0) continue; else break; }" . PHP_EOL;
253+
}
231254
}
232255
return $code;
233256
}

src/Parser/SwitchTrait.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
/**
33
* This file is part of TypePHP.
44
*
5-
* Lowers switch cases, fallthrough, defaults, and loop-exit flags.
5+
* Lowers switch cases, fallthrough, and defaults.
66
*/
77

88
namespace TypePhp\Parser;
@@ -64,7 +64,6 @@ protected function parseSwitch(Node\Stmt\Switch_ $v): string
6464
}
6565
$this->indentLevel--;
6666
$code .= $this->getIndent() . '}' . PHP_EOL;
67-
$code .= $this->genLoopEndFlagCheck();
6867
$this->indentLevel--;
6968
$code .= $this->getIndent() . '} while(0);' . PHP_EOL;
7069

@@ -166,7 +165,6 @@ protected function parseSwitch(Node\Stmt\Switch_ $v): string
166165
$code .= $this->getIndent() . '}' . PHP_EOL;
167166
}
168167
}
169-
$code .= $this->genLoopEndFlagCheck();
170168
$this->indentLevel--;
171169
$code .= $this->getIndent() . '} while (0);';
172170

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
--TEST--
2+
Multi-level break/continue must skip trailing statements of enclosing bodies
3+
--FILE--
4+
<?php
5+
6+
function nativeSwitchBreak(int $n): void
7+
{
8+
// break 2 from a native (int-typed) switch inside a loop must exit the loop
9+
for ($i = 0; $i < 3; $i++) {
10+
echo "n-iter $i\n";
11+
switch ($n) {
12+
case 1:
13+
echo "n-case\n";
14+
break 2;
15+
default:
16+
break;
17+
}
18+
echo "n-after $i\n";
19+
}
20+
echo "native-switch-break-2: done\n";
21+
}
22+
23+
function main(): void
24+
{
25+
// break 2: statements after the inner loop must not run
26+
foreach ([1, 2, 3] as $x) {
27+
foreach ([1, 2, 3] as $y) {
28+
echo "b2 inner $x.$y\n";
29+
break 2;
30+
}
31+
echo "b2 leaked $x\n";
32+
}
33+
echo "break-2: done\n";
34+
35+
// continue 2: statements after the inner loop must not run
36+
foreach ([1, 2] as $x) {
37+
foreach ([1, 2] as $y) {
38+
echo "c2 inner $x.$y\n";
39+
continue 2;
40+
}
41+
echo "c2 leaked $x\n";
42+
}
43+
echo "continue-2: done\n";
44+
45+
// break 2 from a switch inside a loop: statements after the switch
46+
// must not run and the loop must exit
47+
for ($i = 0; $i < 3; $i++) {
48+
echo "sw iter $i\n";
49+
switch ($i) {
50+
case 1:
51+
echo "sw case $i\n";
52+
break 2;
53+
default:
54+
break;
55+
}
56+
echo "sw after $i\n";
57+
}
58+
echo "switch-break-2: done\n";
59+
60+
// continue 2 from a switch inside a loop targets the loop
61+
for ($i = 0; $i < 3; $i++) {
62+
switch ($i) {
63+
case 1:
64+
echo "swc case $i\n";
65+
continue 2;
66+
default:
67+
break;
68+
}
69+
echo "swc after $i\n";
70+
}
71+
echo "switch-continue-2: done\n";
72+
73+
// break 3 from a loop inside a switch inside a loop
74+
for ($i = 0; $i < 3; $i++) {
75+
switch ($i) {
76+
case 0:
77+
foreach ([1, 2] as $y) {
78+
echo "b3 deep $i.$y\n";
79+
break 3;
80+
}
81+
echo "b3 leaked after deep loop\n";
82+
break;
83+
default:
84+
echo "b3 leaked default\n";
85+
break;
86+
}
87+
echo "b3 leaked after switch $i\n";
88+
}
89+
echo "break-3-through-switch: done\n";
90+
91+
// continue 2 from a loop inside a switch acts as break on the switch
92+
// level: statements after the switch must still run
93+
for ($i = 0; $i < 2; $i++) {
94+
switch ($i) {
95+
case 0:
96+
foreach ([1, 2] as $y) {
97+
echo "c2s deep $i.$y\n";
98+
continue 2;
99+
}
100+
echo "c2s leaked after deep loop\n";
101+
break;
102+
default:
103+
break;
104+
}
105+
echo "c2s after switch $i\n";
106+
}
107+
echo "continue-2-targets-switch: done\n";
108+
109+
// continue 3 propagates through a switch up to the outer loop
110+
for ($i = 0; $i < 2; $i++) {
111+
switch ($i) {
112+
case 0:
113+
foreach ([1, 2] as $y) {
114+
echo "c3 deep $i.$y\n";
115+
continue 3;
116+
}
117+
echo "c3 leaked after deep loop\n";
118+
break;
119+
default:
120+
break;
121+
}
122+
echo "c3 after switch $i\n";
123+
}
124+
echo "continue-3-through-switch: done\n";
125+
126+
nativeSwitchBreak(1);
127+
}
128+
?>
129+
--EXPECT--
130+
b2 inner 1.1
131+
break-2: done
132+
c2 inner 1.1
133+
c2 inner 2.1
134+
continue-2: done
135+
sw iter 0
136+
sw after 0
137+
sw iter 1
138+
sw case 1
139+
switch-break-2: done
140+
swc after 0
141+
swc case 1
142+
swc after 2
143+
switch-continue-2: done
144+
b3 deep 0.1
145+
break-3-through-switch: done
146+
c2s deep 0.1
147+
c2s after switch 0
148+
c2s after switch 1
149+
continue-2-targets-switch: done
150+
c3 deep 0.1
151+
c3 after switch 1
152+
continue-3-through-switch: done
153+
n-iter 0
154+
n-case
155+
native-switch-break-2: done

tests/compiler/control_flow/break-continue-level.phpt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,20 @@ while ($i < 3) {
3737
}
3838
echo "break-2-while: done\n";
3939

40-
// continue 2 from nested while
40+
// continue 2 from nested while. The counter must advance before the
41+
// inner loop: continue 2 jumps straight to the outer condition, so a
42+
// trailing $i++ would never run and the loop would never terminate
43+
// (PHP itself loops forever on that variant).
4144
$i = 0;
4245
while ($i < 3) {
46+
$i++;
4347
$j = 0;
4448
while ($j < 3) {
4549
$j++;
4650
if ($i == 1 && $j == 2) {
4751
continue 2;
4852
}
4953
}
50-
$i++;
5154
}
5255
echo "continue-2-while: done\n";
5356

0 commit comments

Comments
 (0)