From 58efbbc13a4d220577fbd8a360471a7f511b4d7e Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Thu, 3 Sep 2026 12:16:11 +0800 Subject: [PATCH] feat(parser): implement ArrayAccess ??= operator support for nested targets - Add support for coalescing assignment to ArrayAccess objects with nested dimensions - Implement proper handling of magic property access in coalescing operations - Create new test cases covering array access, ArrayAccess implementation and magic property scenarios - Refactor coalesce array access target resolution logic for better performance - Add proper null checking and object detection for ArrayAccess operations - Update phpx dependency from ~2.6.11 to ~2.6.12 for compatibility fixes --- composer.json | 2 +- src/Parser/AssignOpTrait.php | 165 ++++++++++++------ .../coalesce/array-access-indirect.phpt | 101 +++++++++++ 3 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 tests/compiler/coalesce/array-access-indirect.phpt diff --git a/composer.json b/composer.json index 88e6b0fd..232fa779 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "marcj/topsort": "^2.0", "symfony/var-dumper": "^8.0", "symfony/yaml": "^8.0", - "swoole/phpx": "~2.6.11", + "swoole/phpx": "~2.6.12", "ajaxray/ansikit": "^0.3", "ext-dom": "*" }, diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 258a948f..c43d03f8 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -1711,9 +1711,24 @@ trait AssignOpTrait } } - $isset = $var !== null && $this->isNativeObjectVar($var) - ? $var . ' != nullptr' - : $this->parseChainedExpr($expr->var, self::OP_ISSET); + $arrayAccessTarget = $this->resolveCoalesceArrayAccessTarget($expr->var); + $arrayAccessSelectedValue = null; + $arrayAccessContainer = null; + $arrayAccessKey = null; + if ($arrayAccessTarget !== null) { + $arrayAccessSelectedValue = $this->addTmpVar(Type::VAR); + $arrayAccessPresence = $this->parseArrayAccessCoalescePresence( + $arrayAccessTarget, + $arrayAccessSelectedValue, + ); + $isset = $arrayAccessPresence['condition']; + $arrayAccessContainer = $arrayAccessPresence['container']; + $arrayAccessKey = $arrayAccessPresence['key']; + } else { + $isset = $var !== null && $this->isNativeObjectVar($var) + ? $var . ' != nullptr' + : $this->parseChainedExpr($expr->var, self::OP_ISSET); + } $var ??= $this->parseWritableIdentifier($expr->var); $propertyWriteTarget = $this->preparePropertyWriteTarget($expr->var); @@ -1744,12 +1759,13 @@ trait AssignOpTrait $this->errorUndefinedVariable($expr->expr); } - $arrayAccessTarget = $this->resolveCoalesceArrayAccessTarget($expr->var); if ($arrayAccessTarget !== null) { return $this->emitCoalesceArrayAccessAssignment( $arrayAccessTarget, $isset, - $var, + $arrayAccessSelectedValue, + $arrayAccessContainer, + $arrayAccessKey, $right, $rightBefore, $rightAfter, @@ -1800,79 +1816,128 @@ trait AssignOpTrait } /** - * ArrayAccess dimensions do not expose writable buckets: offsetGet() - * returns a value, while a write must dispatch through offsetSet(). Keep - * ordinary arrays on the existing lvalue path so assignments into array - * references continue to update the referenced bucket in place. - * - * @return array{container: string, key: string, objectCondition: string}|null + * Use the separated ArrayAccess read/write path only when the dimension + * container may be an object at runtime. Fixed arrays keep their existing + * bucket-lvalue path. */ - private function resolveCoalesceArrayAccessTarget(Expr $target): ?array + private function resolveCoalesceArrayAccessTarget(Expr $target): ?Expr\ArrayDimFetch { if (!$target instanceof Expr\ArrayDimFetch || $target->dim === null - || !$this->isVarExpr($target->var) || $this->isStdContainerExpr($target) + || $this->isNativeObjectClass($this->detectClassOfExpr($target->var)) ) { return null; } - $container = $this->parseIdentifier($target->var); - $containerType = $this->getVarType($container); - if (!in_array($containerType, [Type::OBJECT, Type::VAR, Type::REF], true)) { - return null; - } + return in_array( + $this->detectTypeOfExpr($target->var), + [Type::OBJECT, Type::VAR, Type::REF], + true, + ) ? $target : null; + } + + /** + * Resolve nested ArrayAccess presence from outermost to innermost. This + * avoids assigning to the value returned by offsetGet() and avoids the + * generic exists() chain invoking offsetExists() more than once. + * + * @return array{condition: string, container: string, key: string} + */ + private function parseArrayAccessCoalescePresence( + Expr\ArrayDimFetch $target, + string $selectedValue, + ): array { + if ($this->isVarExpr($target->var)) { + $container = $this->parseIdentifier($target->var); + $this->checkVarMustExist($target->var, $container); + $containerPresence = null; + } elseif ($target->var instanceof Expr\ArrayDimFetch && $target->var->dim !== null) { + $container = $this->addTmpVar(Type::VAR); + $outerPresence = $this->parseArrayAccessCoalescePresence( + $target->var, + $container, + ); + $containerPresence = $outerPresence['condition']; + } else { + $container = $this->parseIdentifier($target->var); + $containerPresence = null; + } + + $key = $this->parseIdentifier($target->dim); + $stableContainer = $this->addTmpVar(Type::VAR); + $stableKey = $this->addTmpVar(Type::VAR); + $objectPresence = '(' . $stableContainer . '.offsetExists(' . $stableKey . ')' + . ' && ((' . $selectedValue . ' = ' . $stableContainer . '.offsetGet(' . $stableKey . ')),' + . ' !' . $selectedValue . '.isNull()))'; + $otherPresence = 'php::exists(' . $stableContainer . ', ' + . '{{php::ArrayDimFetch, ' . Type::VAR . '(' . $stableKey . ')}}, ' + . $selectedValue . ')'; + $probe = '((' . $stableContainer . ' = ' . $container . '), (' + . $stableKey . ' = ' . $key . '), (' . $stableContainer . '.isObject() ? ' + . $objectPresence . ' : ' . $otherPresence . '))'; return [ - 'container' => $container, - 'key' => $this->parseIdentifier($target->dim), - // Even a statically object-typed PHP variable may currently hold - // null. PHP converts that null to an array on dimension write, so - // only the runtime object case may dispatch through offsetSet(). - 'objectCondition' => $container . '.isObject()', + 'condition' => $containerPresence === null + ? $probe + : '(' . $containerPresence . ' && ' . $probe . ')', + 'container' => $stableContainer, + 'key' => $stableKey, ]; } /** - * @param array{container: string, key: string, objectCondition: string} $target + * Arrays expose a writable bucket. Every other supported dynamic receiver + * goes through PHPX offsetSet(); unsupported scalars fail there with one + * stable PHPX error rather than silently discarding the write. + */ + private function parseArrayAccessCoalesceStore( + Expr\ArrayDimFetch $target, + string $stableContainer, + string $stableKey, + string $value, + ): string { + $array = $this->parseWritableIdentifier($target->var); + + return '(' . $stableContainer . '.isObject()' + . ' ? (' . $stableContainer . '.offsetSet(' . $stableKey . ', ' . $value . '), ' . $value . ')' + . ' : (' . $array . '.item(' . $stableKey . ', true) = ' . $value . '))'; + } + + /** * @param list $rightBefore * @param list $rightAfter */ private function emitCoalesceArrayAccessAssignment( - array $target, + Expr\ArrayDimFetch $target, string $isset, - string $readTarget, + string $selectedValue, + string $stableContainer, + string $stableKey, string $right, array $rightBefore, array $rightAfter, ): string { - $current = $this->genTmpVarName(); - $rhs = $this->genTmpVarName(); - $container = $target['container']; - $key = $target['key']; - $isObject = $this->genTmpVarName(); + $rhs = $this->addTmpVar(Type::VAR); + $store = $this->parseArrayAccessCoalesceStore( + $target, + $stableContainer, + $stableKey, + $rhs, + ); + + if ($rightBefore === [] && $rightAfter === []) { + return '(' . $isset . ' ? ' . $selectedValue + . ' : ((' . $rhs . ' = ' . $right . '), ' . $store . '))'; + } $code = '[&]() -> php::Var {' . PHP_EOL; - $code .= $this->getIndent() . 'const bool ' . $isObject . ' = ' - . $target['objectCondition'] . ';' . PHP_EOL; - $code .= $this->getIndent() . 'if (' . $isset . ') {' . PHP_EOL; - $code .= $this->getIndent(2) . 'php::Var ' . $current . ' = ' . $isObject - . ' ? ' . $container . '.offsetGet(' . $key . ') : ' . $readTarget . ';' . PHP_EOL; - $code .= $this->getIndent(2) . 'if (!' . $current . '.isNull()) {' . PHP_EOL; - $code .= $this->getIndent(3) . 'return ' . $current . ';' . PHP_EOL; - $code .= $this->getIndent(2) . '}' . PHP_EOL; - $code .= $this->getIndent() . '}' . PHP_EOL; + $code .= $this->getIndent() . 'if (' . $isset . ') { return ' . $selectedValue . '; }' . PHP_EOL; $code .= $this->formatCapturedStmtLines($rightBefore); - $code .= $this->getIndent() . 'php::Var ' . $rhs . ' = ' . $right . ';' . PHP_EOL; + $code .= $this->getIndent() . $rhs . ' = ' . $right . ';' . PHP_EOL; $code .= $this->formatCapturedStmtLines($rightAfter); - $code .= $this->getIndent() . 'if (' . $isObject . ') {' . PHP_EOL; - $code .= $this->getIndent(2) . $container . '.offsetSet(' . $key . ', ' . $rhs . ');' . PHP_EOL; - $code .= $this->getIndent() . '} else {' . PHP_EOL; - $code .= $this->getIndent(2) . $readTarget . ' = ' . $rhs . ';' . PHP_EOL; - $code .= $this->getIndent() . '}' . PHP_EOL; - $code .= $this->getIndent() . 'return ' . $rhs . ';' . PHP_EOL; - $code .= $this->getIndent() . '}()'; - return $code; + $code .= $this->getIndent() . 'return ' . $store . ';' . PHP_EOL; + return $code . $this->getIndent() . '}()'; } /** diff --git a/tests/compiler/coalesce/array-access-indirect.phpt b/tests/compiler/coalesce/array-access-indirect.phpt new file mode 100644 index 00000000..8c871691 --- /dev/null +++ b/tests/compiler/coalesce/array-access-indirect.phpt @@ -0,0 +1,101 @@ +--TEST-- +ArrayAccess ??= writes common nested and magic-property targets +--FILE-- +calls[] = "exists:$offset"; + return array_key_exists($offset, $this->data); + } + + public function offsetGet(mixed $offset): mixed + { + $this->calls[] = "get:$offset"; + return $this->data[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->calls[] = "set:$offset"; + $this->data[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->data[$offset]); + } +} + +final class IndirectOuterBag implements ArrayAccess +{ + public function __construct(private IndirectBag $bag) + { + } + + public function offsetExists(mixed $offset): bool + { + return $offset === 'bag'; + } + + public function offsetGet(mixed $offset): mixed + { + return $this->bag; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + } + + public function offsetUnset(mixed $offset): void + { + } +} + +final class IndirectMagicHolder +{ + public function __construct(private IndirectBag $bag) + { + } + + public function __get(string $name): mixed + { + return $this->bag; + } +} + +function showIndirectResult(string $label, mixed $result, IndirectBag $bag): void +{ + echo $label, ':', json_encode([$result, $bag->data, $bag->calls]), "\n"; +} + +function main(): void +{ + $nestedBag = new IndirectBag(); + $nested = ['bag' => $nestedBag]; + $result = ($nested['bag']['key'] ??= 51); + showIndirectResult('array', $result, $nestedBag); + + $innerBag = new IndirectBag(); + $outerBag = new IndirectOuterBag($innerBag); + $result = ($outerBag['bag']['key'] ??= 52); + showIndirectResult('array-access', $result, $innerBag); + + $magicBag = new IndirectBag(); + $magic = new IndirectMagicHolder($magicBag); + $result = ($magic->virtual['key'] ??= 53); + showIndirectResult('magic-property', $result, $magicBag); +} +?> +--EXPECT-- +array:[51,{"key":51},["exists:key","set:key"]] +array-access:[52,{"key":52},["exists:key","set:key"]] +magic-property:[53,{"key":53},["exists:key","set:key"]]