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
master
韩天峰 14 hours ago
parent e4d8cbb798
commit 58efbbc13a
  1. 2
      composer.json
  2. 165
      src/Parser/AssignOpTrait.php
  3. 101
      tests/compiler/coalesce/array-access-indirect.phpt

@ -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": "*"
},

@ -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<string> $rightBefore
* @param list<string> $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() . '}()';
}
/**

@ -0,0 +1,101 @@
--TEST--
ArrayAccess ??= writes common nested and magic-property targets
--FILE--
<?php
final class IndirectBag implements ArrayAccess
{
public array $calls = [];
public function __construct(public array $data = [])
{
}
public function offsetExists(mixed $offset): bool
{
$this->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"]]
Loading…
Cancel
Save