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.
master
Alessio Giacobbe 5 hours ago
parent a70a0ad078
commit fea85e5fcb
No known key found for this signature in database
  1. 50
      src/CompilerBase.php
  2. 4
      src/Context/FunctionContext.php
  3. 2
      src/Parser/ForeachTrait.php
  4. 39
      src/Parser/LoopControlTrait.php
  5. 4
      src/Parser/SwitchTrait.php
  6. 155
      tests/compiler/control_flow/break-continue-level-placement.phpt
  7. 7
      tests/compiler/control_flow/break-continue-level.phpt

@ -1684,6 +1684,8 @@ class CompilerBase implements PropertyAccessContext
$lines = [];
$inLoopTop = $this->context->inLoop;
$inContinuableLoopTop = $this->context->inContinuableLoop;
$breakableIsSwitchTop = $this->context->breakableIsSwitch;
$breakableDepthTop = $this->context->breakableDepth;
$last = array_key_last($stmts);
foreach ($stmts as $i => $v) {
$class = $v->getType();
@ -1715,37 +1717,37 @@ class CompilerBase implements PropertyAccessContext
$result = $this->parseReturn($v);
break;
case 'Stmt_For':
$this->context->inLoop = true;
$this->context->inContinuableLoop = true;
$result = $this->parseFor($v);
$this->context->inLoop = $inLoopTop;
$this->context->inContinuableLoop = $inContinuableLoopTop;
break;
case 'Stmt_Foreach':
$this->context->inLoop = true;
$this->context->inContinuableLoop = true;
$result = $this->parseForeach($v);
$this->context->inLoop = $inLoopTop;
$this->context->inContinuableLoop = $inContinuableLoopTop;
break;
case 'Stmt_Switch':
$this->context->inLoop = true;
$result = $this->parseSwitch($v);
$this->context->inLoop = $inLoopTop;
break;
case 'Stmt_While':
$this->context->inLoop = true;
$this->context->inContinuableLoop = true;
$result = $this->parseWhile($v);
$this->context->inLoop = $inLoopTop;
$this->context->inContinuableLoop = $inContinuableLoopTop;
break;
case 'Stmt_Do':
$isSwitch = $class === 'Stmt_Switch';
$this->context->inLoop = true;
$this->context->inContinuableLoop = true;
$result = $this->parseDo($v);
if (!$isSwitch) {
$this->context->inContinuableLoop = true;
}
$this->context->breakableIsSwitch = $isSwitch;
$this->context->breakableDepth = $breakableDepthTop + 1;
$result = match ($class) {
'Stmt_For' => $this->parseFor($v),
'Stmt_Foreach' => $this->parseForeach($v),
'Stmt_Switch' => $this->parseSwitch($v),
'Stmt_While' => $this->parseWhile($v),
default => $this->parseDo($v),
};
$this->context->inLoop = $inLoopTop;
$this->context->inContinuableLoop = $inContinuableLoopTop;
$this->context->breakableIsSwitch = $breakableIsSwitchTop;
$this->context->breakableDepth = $breakableDepthTop;
// A multi-level break/continue exits the nested construct
// with its countdown flag still set. The propagation check
// must run before any trailing statement of this body.
if ($inLoopTop) {
$flagCheck = $this->genMultiLevelJumpCheck($breakableIsSwitchTop);
if ($flagCheck !== '') {
$result = rtrim($result, "\r\n") . PHP_EOL . $flagCheck;
}
}
break;
case 'Stmt_If':
$result = $this->parseIf($v);

@ -84,6 +84,10 @@ class FunctionContext
public bool $inLoop = false;
/** True while parsing a for/foreach/while/do-while body. */
public bool $inContinuableLoop = false;
/** Number of breakable constructs (loops and switches) enclosing the statement being parsed. */
public int $breakableDepth = 0;
/** True when the innermost enclosing breakable construct is a switch, not a loop. */
public bool $breakableIsSwitch = false;
public bool $inClosure = false;
public ?array $closureReturnTypeCheck = null;
public string $closureReturnTypeStr = '';

@ -48,7 +48,7 @@ trait ForeachTrait
protected function parseForeachBody(Foreach_ $node): string
{
return $this->parseStmts($node->stmts) . $this->genLoopEndFlagCheck();
return $this->parseStmts($node->stmts);
}
protected function parseForeachKeyAssignment(Foreach_ $node, string $keyExpr, string $defaultType = Type::VAR): string

@ -102,7 +102,6 @@ trait LoopControlTrait
$code .= ') {' . PHP_EOL;
$code .= $this->parseBlockStmts($stmts);
$code .= $this->genLoopEndFlagCheck();
$code .= $this->getIndent() . '}' . PHP_EOL;
return $code;
@ -138,7 +137,6 @@ trait LoopControlTrait
$code .= 'while (' . $cond . ') {' . PHP_EOL;
}
$code .= $this->parseBlockStmts($stmts);
$code .= $this->genLoopEndFlagCheck();
$code .= $this->getIndent() . '}' . PHP_EOL;
return $code;
@ -172,7 +170,6 @@ trait LoopControlTrait
$code = $this->parseBeforeStmtLines() . PHP_EOL;
$code .= 'do {' . PHP_EOL;
$code .= $bodyCode;
$code .= $this->genLoopEndFlagCheck();
$code .= $this->getIndent() . '} while (' . $cond . ');' . PHP_EOL;
return $code;
@ -189,6 +186,7 @@ trait LoopControlTrait
}
$num = $v->num;
if ($num) {
$this->checkLoopJumpLevel($v, $num, 'break');
if ($num->value > 1) {
$this->context->hasMultiLevelBreak = true;
return '_brk_flag = ' . ($num->value - 1) . '; break;';
@ -205,6 +203,7 @@ trait LoopControlTrait
}
$num = $v->num;
if ($num) {
$this->checkLoopJumpLevel($v, $num, 'continue');
if ($num->value > 1) {
$this->context->hasMultiLevelContinue = true;
return '_cnt_flag = ' . ($num->value - 1) . '; break;';
@ -214,12 +213,32 @@ trait LoopControlTrait
}
/**
* Emit flag-propagation checks at the end of a loop body.
* PHP only accepts a positive integer literal that does not exceed the
* number of enclosing loops/switches. The flag lowering relies on this:
* it guarantees the countdown reaches zero at an enclosing construct.
*/
protected function checkLoopJumpLevel(Node\Stmt $v, Node\Expr $num, string $operator): void
{
if (!$num instanceof Node\Scalar\Int_ || $num->value < 1) {
$this->fatalError($v, "'{$operator}' operator accepts only positive integer literals");
}
if ($num->value > $this->context->breakableDepth) {
$this->fatalError($v, "Cannot '{$operator}' {$num->value} levels");
}
}
/**
* Emit flag-propagation checks right after a nested breakable construct.
*
* Translates multi-level break / continue into plain break / continue
* by decrementing a counter at each loop boundary until it reaches zero.
* A multi-level break / continue is lowered to a flag assignment plus a
* plain break out of the innermost construct. Each enclosing loop or
* switch places this check immediately after every nested loop / switch
* statement, so the flag keeps breaking outward — before any trailing
* statements of the enclosing body can run — until it reaches zero at
* the targeted level. When the check sits inside a switch, a continue
* that lands on the switch level behaves like break, matching PHP.
*/
protected function genLoopEndFlagCheck(): string
protected function genMultiLevelJumpCheck(bool $enclosingIsSwitch): string
{
$code = '';
$indent = $this->getIndent();
@ -227,7 +246,11 @@ trait LoopControlTrait
$code .= "{$indent}if (_brk_flag > 0) { _brk_flag--; break; }" . PHP_EOL;
}
if ($this->context->hasMultiLevelContinue) {
$code .= "{$indent}if (_cnt_flag > 0) { _cnt_flag--; if (_cnt_flag == 0) continue; else break; }" . PHP_EOL;
if ($enclosingIsSwitch) {
$code .= "{$indent}if (_cnt_flag > 0) { _cnt_flag--; break; }" . PHP_EOL;
} else {
$code .= "{$indent}if (_cnt_flag > 0) { _cnt_flag--; if (_cnt_flag == 0) continue; else break; }" . PHP_EOL;
}
}
return $code;
}

@ -2,7 +2,7 @@
/**
* This file is part of TypePHP.
*
* Lowers switch cases, fallthrough, defaults, and loop-exit flags.
* Lowers switch cases, fallthrough, and defaults.
*/
namespace TypePhp\Parser;
@ -64,7 +64,6 @@ trait SwitchTrait
}
$this->indentLevel--;
$code .= $this->getIndent() . '}' . PHP_EOL;
$code .= $this->genLoopEndFlagCheck();
$this->indentLevel--;
$code .= $this->getIndent() . '} while(0);' . PHP_EOL;
@ -166,7 +165,6 @@ trait SwitchTrait
$code .= $this->getIndent() . '}' . PHP_EOL;
}
}
$code .= $this->genLoopEndFlagCheck();
$this->indentLevel--;
$code .= $this->getIndent() . '} while (0);';

@ -0,0 +1,155 @@
--TEST--
Multi-level break/continue must skip trailing statements of enclosing bodies
--FILE--
<?php
function nativeSwitchBreak(int $n): void
{
// break 2 from a native (int-typed) switch inside a loop must exit the loop
for ($i = 0; $i < 3; $i++) {
echo "n-iter $i\n";
switch ($n) {
case 1:
echo "n-case\n";
break 2;
default:
break;
}
echo "n-after $i\n";
}
echo "native-switch-break-2: done\n";
}
function main(): void
{
// break 2: statements after the inner loop must not run
foreach ([1, 2, 3] as $x) {
foreach ([1, 2, 3] as $y) {
echo "b2 inner $x.$y\n";
break 2;
}
echo "b2 leaked $x\n";
}
echo "break-2: done\n";
// continue 2: statements after the inner loop must not run
foreach ([1, 2] as $x) {
foreach ([1, 2] as $y) {
echo "c2 inner $x.$y\n";
continue 2;
}
echo "c2 leaked $x\n";
}
echo "continue-2: done\n";
// break 2 from a switch inside a loop: statements after the switch
// must not run and the loop must exit
for ($i = 0; $i < 3; $i++) {
echo "sw iter $i\n";
switch ($i) {
case 1:
echo "sw case $i\n";
break 2;
default:
break;
}
echo "sw after $i\n";
}
echo "switch-break-2: done\n";
// continue 2 from a switch inside a loop targets the loop
for ($i = 0; $i < 3; $i++) {
switch ($i) {
case 1:
echo "swc case $i\n";
continue 2;
default:
break;
}
echo "swc after $i\n";
}
echo "switch-continue-2: done\n";
// break 3 from a loop inside a switch inside a loop
for ($i = 0; $i < 3; $i++) {
switch ($i) {
case 0:
foreach ([1, 2] as $y) {
echo "b3 deep $i.$y\n";
break 3;
}
echo "b3 leaked after deep loop\n";
break;
default:
echo "b3 leaked default\n";
break;
}
echo "b3 leaked after switch $i\n";
}
echo "break-3-through-switch: done\n";
// continue 2 from a loop inside a switch acts as break on the switch
// level: statements after the switch must still run
for ($i = 0; $i < 2; $i++) {
switch ($i) {
case 0:
foreach ([1, 2] as $y) {
echo "c2s deep $i.$y\n";
continue 2;
}
echo "c2s leaked after deep loop\n";
break;
default:
break;
}
echo "c2s after switch $i\n";
}
echo "continue-2-targets-switch: done\n";
// continue 3 propagates through a switch up to the outer loop
for ($i = 0; $i < 2; $i++) {
switch ($i) {
case 0:
foreach ([1, 2] as $y) {
echo "c3 deep $i.$y\n";
continue 3;
}
echo "c3 leaked after deep loop\n";
break;
default:
break;
}
echo "c3 after switch $i\n";
}
echo "continue-3-through-switch: done\n";
nativeSwitchBreak(1);
}
?>
--EXPECT--
b2 inner 1.1
break-2: done
c2 inner 1.1
c2 inner 2.1
continue-2: done
sw iter 0
sw after 0
sw iter 1
sw case 1
switch-break-2: done
swc after 0
swc case 1
swc after 2
switch-continue-2: done
b3 deep 0.1
break-3-through-switch: done
c2s deep 0.1
c2s after switch 0
c2s after switch 1
continue-2-targets-switch: done
c3 deep 0.1
c3 after switch 1
continue-3-through-switch: done
n-iter 0
n-case
native-switch-break-2: done

@ -37,9 +37,13 @@ while ($i < 3) {
}
echo "break-2-while: done\n";
// continue 2 from nested while
// continue 2 from nested while. The counter must advance before the
// inner loop: continue 2 jumps straight to the outer condition, so a
// trailing $i++ would never run and the loop would never terminate
// (PHP itself loops forever on that variant).
$i = 0;
while ($i < 3) {
$i++;
$j = 0;
while ($j < 3) {
$j++;
@ -47,7 +51,6 @@ while ($i < 3) {
continue 2;
}
}
$i++;
}
echo "continue-2-while: done\n";

Loading…
Cancel
Save