diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 304ac6c5..dd7731a5 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -1718,6 +1718,19 @@ trait AssignOpTrait if ($this->isVarExpr($expr->expr) and !$this->hasVar($right)) { $this->errorUndefinedVariable($expr->expr); } + + $arrayAccessTarget = $this->resolveCoalesceArrayAccessTarget($expr->var); + if ($arrayAccessTarget !== null) { + return $this->emitCoalesceArrayAccessAssignment( + $arrayAccessTarget, + $isset, + $var, + $right, + $rightBefore, + $rightAfter, + ); + } + $targetClass = $var !== null && $this->isNativeObjectVar($var) ? $this->getNativeObjectVarClass($var) : $this->detectClassOfExpr($expr->var); @@ -1761,6 +1774,82 @@ trait AssignOpTrait return '(' . $isset . '?' . $var . ':(' . $var . ' = ' . $right . '))'; } + /** + * 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 + */ + private function resolveCoalesceArrayAccessTarget(Expr $target): ?array + { + if (!$target instanceof Expr\ArrayDimFetch + || $target->dim === null + || !$this->isVarExpr($target->var) + || $this->isStdContainerExpr($target) + ) { + 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 [ + '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()', + ]; + } + + /** + * @param array{container: string, key: string, objectCondition: string} $target + * @param list $rightBefore + * @param list $rightAfter + */ + private function emitCoalesceArrayAccessAssignment( + array $target, + string $isset, + string $readTarget, + string $right, + array $rightBefore, + array $rightAfter, + ): string { + $current = $this->genTmpVarName(); + $rhs = $this->genTmpVarName(); + $container = $target['container']; + $key = $target['key']; + $isObject = $this->genTmpVarName(); + + $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->formatCapturedStmtLines($rightBefore); + $code .= $this->getIndent() . 'php::Var ' . $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; + } + /** * Rewrite a coalesce-assignment target so that every side-effecting * subexpression is evaluated exactly once, in PHP source order — @@ -1805,6 +1894,21 @@ trait AssignOpTrait writableContainer: true, ); } + // An object container is a handle, so copying it into a temporary + // preserves identity and lets ArrayAccess presence/read/write all + // use the same property or nested-dimension result. Do not do this + // for arrays: their write must continue through the original + // bucket/property lvalue. + if (!$this->isVarExpr($target->var) + && $this->detectTypeOfExpr($target->var) === Type::OBJECT + && !$this->isNativeObjectClass($this->detectClassOfExpr($target->var)) + ) { + $target->var = $this->materializeCoalesceTargetSubexpr( + $target->var, + isReceiver: true, + writableContainer: true, + ); + } if ($target->dim !== null && !$this->isCoalesceTargetTrivialSubexpr($target->dim)) { $target->dim = $this->materializeCoalesceTargetSubexpr($target->dim, false); } diff --git a/tests/compiler/coalesce/array-access-assign.phpt b/tests/compiler/coalesce/array-access-assign.phpt new file mode 100644 index 00000000..1196fb0d --- /dev/null +++ b/tests/compiler/coalesce/array-access-assign.phpt @@ -0,0 +1,281 @@ +--TEST-- +ArrayAccess ??= preserves offsetExists, offsetGet, offsetSet and lazy evaluation semantics +--FILE-- +data); + } + + public function offsetGet(mixed $offset): mixed + { + echo "get:$offset\n"; + return $this->data[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + echo "set:$offset=$value\n"; + $this->data[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->data[$offset]); + } +} + +final class CoalesceHolder +{ + public function __construct(public CoalesceBag $bag) + { + } +} + +function coalesceRhs(string $label, int $value): int +{ + echo "rhs:$label\n"; + return $value; +} + +function throwingCoalesceRhs(): int +{ + echo "rhs:throw\n"; + throw new RuntimeException('failed'); +} + +function coalesceThroughInterface(ArrayAccess $target, string $key, int $value): mixed +{ + return $target[$key] ??= coalesceRhs('interface', $value); +} + +function coalesceThroughMixed(mixed $target, string $key, int $value): array +{ + $result = $target[$key] ??= coalesceRhs('mixed', $value); + return [$result, $target]; +} + +function coalesceThroughNullableObject(?ArrayAccess $target): mixed +{ + return $target['nullable'] ??= coalesceRhs('nullable-object', 34); +} + +function coalesceReceiver(CoalesceBag $target, int &$calls): CoalesceBag +{ + $calls++; + echo "receiver:$calls\n"; + return $target; +} + +function coalesceKey(int &$calls): string +{ + $calls++; + echo "key:$calls\n"; + return 'side'; +} + +function main(): void +{ + echo "-- missing --\n"; + $missing = new CoalesceBag(); + var_dump($missing['service'] ??= coalesceRhs('missing', 42)); + var_dump($missing->data); + + echo "-- present --\n"; + $present = new CoalesceBag(); + $present->data['service'] = 7; + var_dump($present['service'] ??= coalesceRhs('present', 99)); + var_dump($present->data); + + echo "-- null --\n"; + $null = new CoalesceBag(); + $null->data['service'] = null; + var_dump($null['service'] ??= coalesceRhs('null', 21)); + var_dump($null->data); + + echo "-- interface --\n"; + $interface = new CoalesceBag(); + var_dump(coalesceThroughInterface($interface, 'typed', 13)); + var_dump($interface->data); + + echo "-- object property container --\n"; + $holder = new CoalesceHolder(new CoalesceBag()); + var_dump($holder->bag['property'] ??= coalesceRhs('property', 14)); + var_dump($holder->bag->data); + + echo "-- mixed object --\n"; + $mixedObject = new CoalesceBag(); + [$mixedObjectResult] = coalesceThroughMixed($mixedObject, 'dynamic', 31); + var_dump($mixedObjectResult, $mixedObject->data); + + echo "-- mixed object present --\n"; + $mixedPresent = new CoalesceBag(); + $mixedPresent->data['dynamic'] = 8; + [$mixedPresentResult] = coalesceThroughMixed($mixedPresent, 'dynamic', 99); + var_dump($mixedPresentResult, $mixedPresent->data); + + echo "-- mixed object null --\n"; + $mixedNull = new CoalesceBag(); + $mixedNull->data['dynamic'] = null; + [$mixedNullResult] = coalesceThroughMixed($mixedNull, 'dynamic', 33); + var_dump($mixedNullResult, $mixedNull->data); + + echo "-- mixed array --\n"; + [$mixedArrayResult, $mixedArray] = coalesceThroughMixed([], 'dynamic', 32); + var_dump($mixedArrayResult, $mixedArray); + + echo "-- nullable object currently null --\n"; + var_dump(coalesceThroughNullableObject(null)); + + echo "-- receiver and key once --\n"; + $sideEffect = new CoalesceBag(); + $receiverCalls = 0; + $keyCalls = 0; + var_dump(coalesceReceiver($sideEffect, $receiverCalls)[coalesceKey($keyCalls)] + ??= coalesceRhs('side', 55)); + var_dump($receiverCalls, $keyCalls, $sideEffect->data); + + echo "-- unused result --\n"; + $unused = new CoalesceBag(); + $unused['value'] ??= coalesceRhs('unused', 66); + var_dump($unused->data); + + echo "-- throwing rhs --\n"; + $throwing = new CoalesceBag(); + try { + $throwing['value'] ??= throwingCoalesceRhs(); + } catch (RuntimeException $e) { + echo "caught\n"; + } + var_dump($throwing->data); + + echo "-- ArrayObject --\n"; + $arrayObject = new ArrayObject(); + var_dump($arrayObject['value'] ??= coalesceRhs('array-object', 77)); + var_dump($arrayObject->getArrayCopy()); +} +?> +--EXPECT-- +-- missing -- +exists:service +rhs:missing +set:service=42 +int(42) +array(1) { + ["service"]=> + int(42) +} +-- present -- +exists:service +get:service +int(7) +array(1) { + ["service"]=> + int(7) +} +-- null -- +exists:service +get:service +rhs:null +set:service=21 +int(21) +array(1) { + ["service"]=> + int(21) +} +-- interface -- +exists:typed +rhs:interface +set:typed=13 +int(13) +array(1) { + ["typed"]=> + int(13) +} +-- object property container -- +exists:property +rhs:property +set:property=14 +int(14) +array(1) { + ["property"]=> + int(14) +} +-- mixed object -- +exists:dynamic +rhs:mixed +set:dynamic=31 +int(31) +array(1) { + ["dynamic"]=> + int(31) +} +-- mixed object present -- +exists:dynamic +get:dynamic +int(8) +array(1) { + ["dynamic"]=> + int(8) +} +-- mixed object null -- +exists:dynamic +get:dynamic +rhs:mixed +set:dynamic=33 +int(33) +array(1) { + ["dynamic"]=> + int(33) +} +-- mixed array -- +rhs:mixed +int(32) +array(1) { + ["dynamic"]=> + int(32) +} +-- nullable object currently null -- +rhs:nullable-object +int(34) +-- receiver and key once -- +receiver:1 +key:1 +exists:side +rhs:side +set:side=55 +int(55) +int(1) +int(1) +array(1) { + ["side"]=> + int(55) +} +-- unused result -- +exists:value +rhs:unused +set:value=66 +array(1) { + ["value"]=> + int(66) +} +-- throwing rhs -- +exists:value +rhs:throw +caught +array(0) { +} +-- ArrayObject -- +rhs:array-object +int(77) +array(1) { + ["value"]=> + int(77) +}