Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 26 additions & 24 deletions src/CompilerBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,8 @@ protected function parseStmts(array $stmts): string
$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();
Expand Down Expand Up @@ -1715,37 +1717,37 @@ protected function parseStmts(array $stmts): string
$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);
Expand Down
4 changes: 4 additions & 0 deletions src/Context/FunctionContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
2 changes: 1 addition & 1 deletion src/Parser/ForeachTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ protected function parseForeachItemAsList(string $listTmpVar, array $listItems):

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
Expand Down
39 changes: 31 additions & 8 deletions src/Parser/LoopControlTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ protected function parseFor(Node\Stmt\For_ $v): string
$code .= ') {' . PHP_EOL;

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

return $code;
Expand Down Expand Up @@ -138,7 +137,6 @@ protected function parseWhile(Node\Stmt\While_ $v): string
$code .= 'while (' . $cond . ') {' . PHP_EOL;
}
$code .= $this->parseBlockStmts($stmts);
$code .= $this->genLoopEndFlagCheck();
$code .= $this->getIndent() . '}' . PHP_EOL;

return $code;
Expand Down Expand Up @@ -172,7 +170,6 @@ protected function parseDo(Node\Stmt\Do_ $v): string
$code = $this->parseBeforeStmtLines() . PHP_EOL;
$code .= 'do {' . PHP_EOL;
$code .= $bodyCode;
$code .= $this->genLoopEndFlagCheck();
$code .= $this->getIndent() . '} while (' . $cond . ');' . PHP_EOL;

return $code;
Expand All @@ -189,6 +186,7 @@ protected function parseBreak(Node\Stmt\Break_ $v): string
}
$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;';
Expand All @@ -205,6 +203,7 @@ protected function parseContinue(Node\Stmt\Continue_ $v): string
}
$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;';
Expand All @@ -214,20 +213,44 @@ protected function parseContinue(Node\Stmt\Continue_ $v): string
}

/**
* 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();
if ($this->context->hasMultiLevelBreak) {
$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;
}
Expand Down
4 changes: 1 addition & 3 deletions src/Parser/SwitchTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,7 +64,6 @@ protected function parseSwitch(Node\Stmt\Switch_ $v): string
}
$this->indentLevel--;
$code .= $this->getIndent() . '}' . PHP_EOL;
$code .= $this->genLoopEndFlagCheck();
$this->indentLevel--;
$code .= $this->getIndent() . '} while(0);' . PHP_EOL;

Expand Down Expand Up @@ -166,7 +165,6 @@ protected function parseSwitch(Node\Stmt\Switch_ $v): string
$code .= $this->getIndent() . '}' . PHP_EOL;
}
}
$code .= $this->genLoopEndFlagCheck();
$this->indentLevel--;
$code .= $this->getIndent() . '} while (0);';

Expand Down
155 changes: 155 additions & 0 deletions tests/compiler/control_flow/break-continue-level-placement.phpt
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions tests/compiler/control_flow/break-continue-level.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,20 @@ 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++;
if ($i == 1 && $j == 2) {
continue 2;
}
}
$i++;
}
echo "continue-2-while: done\n";

Expand Down