From 4e54c0025207aa4e2f5ca20c7b67aa6b3e05b98b Mon Sep 17 00:00:00 2001 From: PurHur Date: Wed, 3 Jun 2026 04:23:32 +0000 Subject: [PATCH] Language: list destructuring with spread on VM (#4835) Teach php-cfg parseListAssignment to honor unpack arms, lower TYPE_LIST_SPREAD_ASSIGN for the tail, and slice the list RHS in the VM. Co-authored-by: Cursor --- lib/Compiler.php | 74 ++++++++++++++++++- lib/OpCode.php | 2 + lib/OpCodeNames.php | 2 + lib/VM.php | 29 ++++++++ patches/php-cfg-list-spread.patch | 51 +++++++++++++ script/apply-patches.sh | 5 ++ test/compliance/JITTest.php | 4 + .../language/list_destructuring_spread.phpt | 9 +++ test/repro/list_destructuring_spread_4835.php | 4 + 9 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 patches/php-cfg-list-spread.patch create mode 100644 test/compliance/cases/language/list_destructuring_spread.phpt create mode 100644 test/repro/list_destructuring_spread_4835.php diff --git a/lib/Compiler.php b/lib/Compiler.php index c1288992c1a..6aceee62121 100755 --- a/lib/Compiler.php +++ b/lib/Compiler.php @@ -1066,7 +1066,10 @@ protected function compileOps(array $ops, Block $block): void { // Lowered by compileExpr Empty_ via TYPE_ISSET + TYPE_BOOLEAN_NOT (#3298, #4586). break; } elseif ( - $child instanceof Op\Expr\ArrayDimFetch + ( + $child instanceof Op\Expr\ArrayDimFetch + || $this->isListSpreadAssignOp($child) + ) && $this->isListDestructGroupStart($ops, $i) ) { [$block, $i] = $this->compileListDestructGroup($ops, $i, $block); @@ -1138,6 +1141,13 @@ private function isKeyedListDestructDimFetch(array $ops, int $index): bool return $assign->expr === $fetch->result; } + private function isListSpreadAssignOp(Op $op): bool + { + return $op instanceof Op\Expr\Assign + && null !== $op->listSpreadRhs + && null !== $op->listSpreadFromIndex; + } + /** * php-cfg lowers `list($a, …) = $rhs` to integer-key dim fetches (#4298). * @@ -1145,6 +1155,9 @@ private function isKeyedListDestructDimFetch(array $ops, int $index): bool */ private function isListDestructGroupStart(array $ops, int $index): bool { + if ($this->isListSpreadAssignOp($ops[$index])) { + return !$this->isListDestructSpreadTail($ops, $index); + } if (!$this->isPlainListDestructDimFetch($ops, $index)) { return false; } @@ -1171,6 +1184,27 @@ private function isListDestructGroupStart(array $ops, int $index): bool return true; } + /** + * Spread arm at the end of `[$a, ...$rest] = $rhs` — not a separate group start (#4835). + * + * @param Op[] $ops + */ + private function isListDestructSpreadTail(array $ops, int $index): bool + { + if (!$this->isListSpreadAssignOp($ops[$index])) { + return false; + } + if ($index < 1) { + return false; + } + $p = $index - 1; + if ($ops[$p] instanceof Op\Expr\Assign || $ops[$p] instanceof Op\Expr\AssignRef) { + --$p; + } + + return $p >= 0 && $this->isPlainListDestructDimFetch($ops, $p); + } + /** * @param Op[] $ops */ @@ -1221,13 +1255,36 @@ private function isListDestructDimFetchConsumer(array $ops, int $index): bool private function listDestructGroupEndIndex(array $ops, int $start): int { $i = $start; + if ($this->isListSpreadAssignOp($ops[$i])) { + return $i; + } while ($i < count($ops) && $this->isPlainListDestructDimFetch($ops, $i)) { $i = $this->listDestructOpEndIndex($ops, $i); } + if ($i < count($ops) && $this->isListSpreadAssignOp($ops[$i])) { + return $i; + } return $i - 1; } + /** + * @param Op[] $ops + */ + private function listDestructRhsOperand(array $ops, int $start): Operand + { + if ($this->isListSpreadAssignOp($ops[$start])) { + /** @var Op\Expr\Assign $spread */ + $spread = $ops[$start]; + + return $spread->listSpreadRhs; + } + /** @var Op\Expr\ArrayDimFetch $firstFetch */ + $firstFetch = $ops[$start]; + + return $firstFetch->var; + } + /** * @param Op[] $ops */ @@ -1262,13 +1319,12 @@ private function listDestructOpEndIndex(array $ops, int $index): int private function compileListDestructGroup(array $ops, int $start, Block $block): array { $end = $this->listDestructGroupEndIndex($ops, $start); - /** @var Op\Expr\ArrayDimFetch $firstFetch */ - $firstFetch = $ops[$start]; + $rhs = $this->listDestructRhsOperand($ops, $start); $checkOp = new OpCode( OpCode::TYPE_LIST_UNPACK_CHECK, null, - $this->compileOperand($firstFetch->var, $block, true), + $this->compileOperand($rhs, $block, true), ); $block->addOpCode($checkOp); @@ -3612,6 +3668,16 @@ protected function compileExpr(Op\Expr $expr, Block $block): array { $this->compileOperand($expr->expr, $block, true) )]; case Op\Expr\Assign::class: + if (null !== $expr->listSpreadRhs && null !== $expr->listSpreadFromIndex) { + $fromIndex = new Operand\Literal($expr->listSpreadFromIndex); + + return [new OpCode( + OpCode::TYPE_LIST_SPREAD_ASSIGN, + $this->compileOperand($expr->var, $block, false), + $this->compileOperand($expr->listSpreadRhs, $block, true), + $this->compileOperand($fromIndex, $block, true), + )]; + } $staticPropertyFetch = $this->unwrapStaticPropertyFetch($expr->var); if (null !== $staticPropertyFetch) { $fetchSlot = $this->compileOperand($staticPropertyFetch->result, $block, false); diff --git a/lib/OpCode.php b/lib/OpCode.php index 113e6cbebe1..ca74bda95e3 100755 --- a/lib/OpCode.php +++ b/lib/OpCode.php @@ -186,6 +186,8 @@ class OpCode { const TYPE_FUNCTION_STATIC_INIT_STORE = 122; /** PHP 8.3+ `$needle in $haystack` strict contains (#4682). arg2=needle, arg3=haystack. */ const TYPE_IN = 123; + /** `[$a, ...$rest] = $list` tail: arg1=dest, arg2=source array, arg3=from-index constant slot (#4835). */ + const TYPE_LIST_SPREAD_ASSIGN = 124; public int $type; public ?int $arg1; diff --git a/lib/OpCodeNames.php b/lib/OpCodeNames.php index bef33c19d80..ca653da1271 100644 --- a/lib/OpCodeNames.php +++ b/lib/OpCodeNames.php @@ -236,6 +236,8 @@ function opcode_type_name(int $type): string return 'TYPE_FUNCTION_STATIC_INIT_STORE'; case 123: return 'TYPE_IN'; + case 124: + return 'TYPE_LIST_SPREAD_ASSIGN'; default: return 'unknown opcode'; } diff --git a/lib/VM.php b/lib/VM.php index eb23ff3ccb1..95bf0dd12bc 100755 --- a/lib/VM.php +++ b/lib/VM.php @@ -1267,6 +1267,35 @@ private function runFramesInner(): int } } break; + case OpCode::TYPE_LIST_SPREAD_ASSIGN: + $dest = $frame->scope[$op->arg1]; + $src = $frame->scope[$op->arg2]->resolveIndirect(); + if (Variable::TYPE_ARRAY !== $src->type) { + if (null !== $op->block1) { + $frame = $this->frameForBranch($frame, $op->block1); + goto restart; + } + break; + } + if (!isset($frame->block->constants[$op->arg3])) { + throw new \LogicException('list spread assign requires compile-time offset'); + } + $offset = $frame->block->constants[$op->arg3]->toInt(); + $ht = $src->toArray(); + if (!\PHPCompiler\ext\standard\VmArray::isList($ht)) { + $catchFrame = $this->dispatchVmTypeError( + new \TypeError('Cannot unpack array with string keys'), + $frame + ); + if (null !== $catchFrame) { + $frame = $catchFrame; + goto restart; + } + break; + } + $tail = $ht->sliceCopy($offset, null); + $dest->array($tail); + break; case OpCode::TYPE_ARRAY_DIM_FETCH: case OpCode::TYPE_ARRAY_DIM_FETCH_WRITE: $arg1 = $frame->scope[$op->arg1]; diff --git a/patches/php-cfg-list-spread.patch b/patches/php-cfg-list-spread.patch new file mode 100644 index 00000000000..1ce9556c06d --- /dev/null +++ b/patches/php-cfg-list-spread.patch @@ -0,0 +1,51 @@ +--- vendor/ircmaxell/php-cfg/lib/PHPCfg/Op/Expr/Assign.php ++++ vendor/ircmaxell/php-cfg/lib/PHPCfg/Op/Expr/Assign.php +@@ -20,6 +20,12 @@ class Assign extends Expr + public $expr; + ++ /** `[$a, ...$rest] = $rhs` tail: full list RHS (#4835). */ ++ public $listSpreadRhs = null; ++ ++ /** Zero-based index of first element merged into the spread target (#4835). */ ++ public $listSpreadFromIndex = null; ++ + protected $writeVariables = ['var', 'result']; + + public function __construct(Operand $var, Operand $expr, array $attributes = []) +--- vendor/ircmaxell/php-cfg/lib/PHPCfg/Parser.php ++++ vendor/ircmaxell/php-cfg/lib/PHPCfg/Parser.php +@@ -1508,11 +1508,27 @@ + } + + $attributes = $this->mapAttributes($expr); ++ $logicalIndex = 0; + foreach ($expr->items as $i => $item) { + if (null === $item) { + continue; + } + + if ($item->key === null) { +- $key = new Operand\Literal($i); ++ $key = new Operand\Literal($logicalIndex); + } else { + $key = $this->readVariable($this->parseExprNode($item->key)); + } + + $var = $item->value; ++ if ($item->unpack) { ++ $target = $this->writeVariable($this->parseExprNode($var)); ++ $assign = new Op\Expr\Assign($target, $rhs, $attributes); ++ $assign->listSpreadRhs = $rhs; ++ $assign->listSpreadFromIndex = $logicalIndex; ++ $this->block->children[] = $assign; ++ ++ continue; ++ } ++ ++ if ($item->key === null) { ++ ++$logicalIndex; ++ } ++ + $fetch = new Op\Expr\ArrayDimFetch($rhs, $key, $attributes); + $this->block->children[] = $fetch; + if ($var instanceof Expr\List_ || $var instanceof Expr\Array_) { diff --git a/script/apply-patches.sh b/script/apply-patches.sh index dfce9b17614..3656d35b3da 100755 --- a/script/apply-patches.sh +++ b/script/apply-patches.sh @@ -267,6 +267,10 @@ patch_already_applied() { grep -q 'isEmptyListExpr' "$ROOT/vendor/ircmaxell/php-cfg/lib/PHPCfg/Parser.php" 2>/dev/null \ && grep -q "Cannot use empty list" "$ROOT/vendor/ircmaxell/php-cfg/lib/PHPCfg/Parser.php" 2>/dev/null ;; + php-cfg-list-spread.patch) + grep -q 'listSpreadRhs' "$ROOT/vendor/ircmaxell/php-cfg/lib/PHPCfg/Op/Expr/Assign.php" 2>/dev/null \ + && grep -q '\$item->unpack' "$ROOT/vendor/ircmaxell/php-cfg/lib/PHPCfg/Parser.php" 2>/dev/null + ;; php-cfg-first-class-callable.patch) grep -q 'isFirstClassCallable' "$ROOT/vendor/ircmaxell/php-cfg/lib/PHPCfg/Parser.php" 2>/dev/null ;; @@ -2576,6 +2580,7 @@ if [[ -d "$ROOT/vendor/ircmaxell/php-cfg" ]]; then apply_patch "$PATCH_DIR/php-cfg-assignop-coalesce.patch" apply_patch "$PATCH_DIR/php-cfg-list-destruct-byref.patch" apply_patch "$PATCH_DIR/php-cfg-empty-list-assignment.patch" + apply_patch "$PATCH_DIR/php-cfg-list-spread.patch" apply_patch "$PATCH_DIR/php-cfg-first-class-callable.patch" apply_patch "$PATCH_DIR/php-cfg-arrow-function.patch" apply_patch "$PATCH_DIR/php-cfg-anonymous-class.patch" diff --git a/test/compliance/JITTest.php b/test/compliance/JITTest.php index b0e17e0fb25..a5a8689ddf1 100755 --- a/test/compliance/JITTest.php +++ b/test/compliance/JITTest.php @@ -513,6 +513,10 @@ public static function providePHPTests(): \Generator if (str_contains($name, 'list_destructure_string')) { continue; } + // list spread `[$a, ...$rest] = $arr`: VM (#4835); MCJIT execute pending. + if (str_contains($name, 'list_destructuring_spread')) { + continue; + } // PHP 8.3 typed class constants: VM + AOT; MCJIT execute unstable (#4511, #3592). if (str_contains($name, 'typed_class_const')) { continue; diff --git a/test/compliance/cases/language/list_destructuring_spread.phpt b/test/compliance/cases/language/list_destructuring_spread.phpt new file mode 100644 index 00000000000..27fbd31c033 --- /dev/null +++ b/test/compliance/cases/language/list_destructuring_spread.phpt @@ -0,0 +1,9 @@ +--TEST-- +list destructuring with spread — [$a, ...$rest] = $arr +--FILE-- +