From de396336f9533a9062065f72d753b0da635999ea Mon Sep 17 00:00:00 2001 From: Giandonn Date: Fri, 2 Oct 2026 21:37:58 +0000 Subject: [PATCH] fix(compiler): keep operand evaluation order in echo lists and array keys A call nested in an expression can be materialized into a pre-statement temporary. Two constructs emitted those temporaries before work that PHP performs first, so side effects (output, mutations, exceptions) happened in the wrong order. echo: `echo a, b, c` prints each operand before evaluating the next one. parseEcho() emits one php::echo() per operand, but every operand's hoisted lines were emitted before the first php::echo(): function f(string $s): string { echo "[f:$s]"; return $s; } echo "A", var_export(f("1"), true), "\n"; // PHP: A[f:1]'1' // compiled: [f:1]A'1' echo "E-", var_export(boom(), true); // boom() throws // PHP prints "E-" before the exception; the binary printed nothing. The pre- and post-statement lines produced while parsing an operand are now emitted around that operand's own php::echo(). Array literals: PHP evaluates a key before its value. parseArrayMixed() parsed the value first, so a value that is a call was materialized before an inline key expression: [f("k1") => strtoupper(f("v1")), f("k2") => f("v2")] // PHP: [k1][v1][k2][v2] // compiled: [v1][k1][v2][k2] A side-effecting key is now materialized before its value is parsed. --- src/CompilerBase.php | 14 +++++++++- src/Parser/ArrayExpressionTrait.php | 12 ++++++++- .../array/array-key-evaluation-order.phpt | 19 +++++++++++++ tests/compiler/basic/echo-operand-order.phpt | 27 +++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100755 tests/compiler/array/array-key-evaluation-order.phpt create mode 100755 tests/compiler/basic/echo-operand-order.phpt diff --git a/src/CompilerBase.php b/src/CompilerBase.php index db84d361..78a23a9d 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -2413,9 +2413,21 @@ protected function resolveCalledFunctionDef(NodeAbstract $expr): ?FunctionDef protected function parseEcho(mixed $v): string { + // PHP prints each operand before it evaluates the next one. A nested + // call inside an operand may be materialized into a pre-statement + // temporary; keep those lines next to the operand they belong to, + // otherwise they all run (and may throw) before the first operand + // has been printed. $lines = []; foreach ($v->exprs as $expr) { - $lines[] = 'php::echo(' . $this->parseExprToString($expr) . ');'; + $beforeCount = count($this->context->beforeStmtLines); + $afterCount = count($this->context->afterStmtLines); + $value = $this->parseExprToString($expr); + $before = array_slice($this->context->beforeStmtLines, $beforeCount); + $after = array_slice($this->context->afterStmtLines, $afterCount); + $this->context->beforeStmtLines = array_slice($this->context->beforeStmtLines, 0, $beforeCount); + $this->context->afterStmtLines = array_slice($this->context->afterStmtLines, 0, $afterCount); + array_push($lines, ...$before, ...['php::echo(' . $value . ');'], ...$after); } return implode("\n" . $this->getIndent(), $lines); diff --git a/src/Parser/ArrayExpressionTrait.php b/src/Parser/ArrayExpressionTrait.php index 27a63f29..b45e7016 100644 --- a/src/Parser/ArrayExpressionTrait.php +++ b/src/Parser/ArrayExpressionTrait.php @@ -298,6 +298,16 @@ private function parseArrayMixed(Expr\Array_ $node): string $items = $node->items; foreach ($items as $item) { + // PHP evaluates a key before its value. A side-effecting key must + // be materialized first: the value may hoist a nested call into + // a pre-statement temporary, which would otherwise run before an + // inline key expression. + $orderedKey = null; + if ($item->key && !$item->unpack && $this->shouldMaterializeOrderedOperand($item->key)) { + $this->assertExprCanBeUsedAsValue($item->key, 'array key'); + $this->assertNotNativeObjectArrayKey($item->key); + $orderedKey = $this->parseOrderedOperand($item->key, false, true); + } $this->assertExprCanBeUsedAsValue($item->value, $item->unpack ? 'array unpack value' : 'array value'); if ($item->byRef) { if ($item->unpack) { @@ -314,7 +324,7 @@ private function parseArrayMixed(Expr\Array_ $node): string $this->context->beforeStmtLines[] = $this->getIndent() . $tmpVar . '.merge(' . $value . ');'; } elseif ($item->key) { $this->assertExprCanBeUsedAsValue($item->key, 'array key'); - $key = $this->parseArrayKey($item->key); + $key = $orderedKey ?? $this->parseArrayKey($item->key); $method = $item->byRef ? 'set' : 'setValue'; $this->context->beforeStmtLines[] = $this->getIndent() . $tmpVar . '.' . $method . '(' . $key . ', ' . $value . ');'; } else { diff --git a/tests/compiler/array/array-key-evaluation-order.phpt b/tests/compiler/array/array-key-evaluation-order.phpt new file mode 100755 index 00000000..c057ee30 --- /dev/null +++ b/tests/compiler/array/array-key-evaluation-order.phpt @@ -0,0 +1,19 @@ +--TEST-- +An array literal evaluates a side-effecting key before its value, also when the value nests a call +--FILE-- + strtoupper(f("v1")), f("k2") => f("v2")]; + echo "\n", json_encode($a), "\n"; + $b = [f("x") => str_repeat(f("y"), 2), ...[f("z")]]; + echo "\n", json_encode($b), "\n"; +} +?> +--EXPECT-- +[k1][v1][k2][v2] +{"k1":"V1","k2":"v2"} +[x][y][z] +{"x":"yy","0":"z"} diff --git a/tests/compiler/basic/echo-operand-order.phpt b/tests/compiler/basic/echo-operand-order.phpt new file mode 100755 index 00000000..36486c33 --- /dev/null +++ b/tests/compiler/basic/echo-operand-order.phpt @@ -0,0 +1,27 @@ +--TEST-- +echo prints each operand before evaluating the next, also when an operand nests a call +--FILE-- + f("4"); + echo "D", var_export($g(), true), "\n"; + try { + echo "E-", var_export(boom(), true), "\n"; + } catch (RuntimeException $e) { + echo "| caught ", $e->getMessage(), "\n"; + } +} +?> +--EXPECT-- +A[f:1]'1' +B[f:2]2 +C[f:3]33 +D[f:4]'4' +E-| caught boom