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
韩天峰 19 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", "marcj/topsort": "^2.0",
"symfony/var-dumper": "^8.0", "symfony/var-dumper": "^8.0",
"symfony/yaml": "^8.0", "symfony/yaml": "^8.0",
"swoole/phpx": "~2.6.11", "swoole/phpx": "~2.6.12",
"ajaxray/ansikit": "^0.3", "ajaxray/ansikit": "^0.3",
"ext-dom": "*" "ext-dom": "*"
}, },

@ -1711,9 +1711,24 @@ trait AssignOpTrait
} }
} }
$isset = $var !== null && $this->isNativeObjectVar($var) $arrayAccessTarget = $this->resolveCoalesceArrayAccessTarget($expr->var);
? $var . ' != nullptr' $arrayAccessSelectedValue = null;
: $this->parseChainedExpr($expr->var, self::OP_ISSET); $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); $var ??= $this->parseWritableIdentifier($expr->var);
$propertyWriteTarget = $this->preparePropertyWriteTarget($expr->var); $propertyWriteTarget = $this->preparePropertyWriteTarget($expr->var);
@ -1744,12 +1759,13 @@ trait AssignOpTrait
$this->errorUndefinedVariable($expr->expr); $this->errorUndefinedVariable($expr->expr);
} }
$arrayAccessTarget = $this->resolveCoalesceArrayAccessTarget($expr->var);
if ($arrayAccessTarget !== null) { if ($arrayAccessTarget !== null) {
return $this->emitCoalesceArrayAccessAssignment( return $this->emitCoalesceArrayAccessAssignment(
$arrayAccessTarget, $arrayAccessTarget,
$isset, $isset,
$var, $arrayAccessSelectedValue,
$arrayAccessContainer,
$arrayAccessKey,
$right, $right,
$rightBefore, $rightBefore,
$rightAfter, $rightAfter,
@ -1800,79 +1816,128 @@ trait AssignOpTrait
} }
/** /**
* ArrayAccess dimensions do not expose writable buckets: offsetGet() * Use the separated ArrayAccess read/write path only when the dimension
* returns a value, while a write must dispatch through offsetSet(). Keep * container may be an object at runtime. Fixed arrays keep their existing
* ordinary arrays on the existing lvalue path so assignments into array * bucket-lvalue path.
* references continue to update the referenced bucket in place.
*
* @return array{container: string, key: string, objectCondition: string}|null
*/ */
private function resolveCoalesceArrayAccessTarget(Expr $target): ?array private function resolveCoalesceArrayAccessTarget(Expr $target): ?Expr\ArrayDimFetch
{ {
if (!$target instanceof Expr\ArrayDimFetch if (!$target instanceof Expr\ArrayDimFetch
|| $target->dim === null || $target->dim === null
|| !$this->isVarExpr($target->var)
|| $this->isStdContainerExpr($target) || $this->isStdContainerExpr($target)
|| $this->isNativeObjectClass($this->detectClassOfExpr($target->var))
) { ) {
return null; return null;
} }
$container = $this->parseIdentifier($target->var); return in_array(
$containerType = $this->getVarType($container); $this->detectTypeOfExpr($target->var),
if (!in_array($containerType, [Type::OBJECT, Type::VAR, Type::REF], true)) { [Type::OBJECT, Type::VAR, Type::REF],
return null; 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 [ return [
'container' => $container, 'condition' => $containerPresence === null
'key' => $this->parseIdentifier($target->dim), ? $probe
// Even a statically object-typed PHP variable may currently hold : '(' . $containerPresence . ' && ' . $probe . ')',
// null. PHP converts that null to an array on dimension write, so 'container' => $stableContainer,
// only the runtime object case may dispatch through offsetSet(). 'key' => $stableKey,
'objectCondition' => $container . '.isObject()',
]; ];
} }
/** /**
* @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> $rightBefore
* @param list<string> $rightAfter * @param list<string> $rightAfter
*/ */
private function emitCoalesceArrayAccessAssignment( private function emitCoalesceArrayAccessAssignment(
array $target, Expr\ArrayDimFetch $target,
string $isset, string $isset,
string $readTarget, string $selectedValue,
string $stableContainer,
string $stableKey,
string $right, string $right,
array $rightBefore, array $rightBefore,
array $rightAfter, array $rightAfter,
): string { ): string {
$current = $this->genTmpVarName(); $rhs = $this->addTmpVar(Type::VAR);
$rhs = $this->genTmpVarName(); $store = $this->parseArrayAccessCoalesceStore(
$container = $target['container']; $target,
$key = $target['key']; $stableContainer,
$isObject = $this->genTmpVarName(); $stableKey,
$rhs,
);
if ($rightBefore === [] && $rightAfter === []) {
return '(' . $isset . ' ? ' . $selectedValue
. ' : ((' . $rhs . ' = ' . $right . '), ' . $store . '))';
}
$code = '[&]() -> php::Var {' . PHP_EOL; $code = '[&]() -> php::Var {' . PHP_EOL;
$code .= $this->getIndent() . 'const bool ' . $isObject . ' = ' $code .= $this->getIndent() . 'if (' . $isset . ') { return ' . $selectedValue . '; }' . PHP_EOL;
. $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->formatCapturedStmtLines($rightBefore); $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->formatCapturedStmtLines($rightAfter);
$code .= $this->getIndent() . 'if (' . $isObject . ') {' . PHP_EOL; $code .= $this->getIndent() . 'return ' . $store . ';' . PHP_EOL;
$code .= $this->getIndent(2) . $container . '.offsetSet(' . $key . ', ' . $rhs . ');' . PHP_EOL; return $code . $this->getIndent() . '}()';
$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;
} }
/** /**

@ -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