Skip to content

Commit c31a558

Browse files
MagicalTuxclaude
andcommitted
vm: lower $obj->$x = v / $obj->{$x} = v to OP_OBJECT_DYN_SET
Adds OP_OBJECT_DYN_SET handling the dynamic-name property write shape that was previously generic-AST-delegated via OpClassConst. Stack: [receiver, name, value] → optionally push value back. A bit 0 = keep-value-on-stack (expr context). Handler validates the receiver is a ZObjectAccess (throwing "Attempt to assign property \"NAME\" on TYPE" otherwise) and dispatches through objI.ObjectSet — which already covers typed properties, hooks, asymmetric visibility, etc. emitObjectVarAssign routes runObjectVar with $-prefixed name (\$obj->\$x = v) through the new opcode; a new emitObjectDynVarAssign handles runObjectDynVar (\$obj->{\$x} = v). Receiver-warn semantics match the AST runners — runObjectVar suppresses the undefined-variable warning via OpLoadLocal, runObjectDynVar does not (emitExpr → normal warn). Also brings the dyn-form error message in line with real PHP: "Attempt to assign property \"foo\" on int" (the AST runner was omitting the property name). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 49ceec7 commit c31a558

4 files changed

Lines changed: 112 additions & 10 deletions

File tree

core/vm/opcode.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,16 @@ const (
523523
OpIssetStaticProp
524524
OpEmptyStaticProp
525525

526+
// OP_OBJECT_DYN_SET handles both `$obj->$x = v` (runObjectVar with
527+
// $-prefixed name) and `$obj->{$x} = v` (runObjectDynVar). Stack
528+
// pops value, name, receiver (in that order). Encoding:
529+
// A bit 0 = keep-value-on-stack (expr context)
530+
// Handler checks receiver is a ZObjectAccess (throws
531+
// "Attempt to assign property \"NAME\" on TYPE" otherwise) and
532+
// dispatches to objI.ObjectSet which handles typed properties,
533+
// hooks, asymmetric visibility, etc.
534+
OpObjectDynSet
535+
526536
// Sentinel — keep last.
527537
opLast
528538
)
@@ -666,4 +676,5 @@ var opNames = [...]string{
666676
OpIncDecStaticProp: "INC_DEC_STATIC_PROP",
667677
OpIssetStaticProp: "ISSET_STATIC_PROP",
668678
OpEmptyStaticProp: "EMPTY_STATIC_PROP",
679+
OpObjectDynSet: "OBJECT_DYN_SET",
669680
}

core/vm/vm.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1377,6 +1377,37 @@ func (f *Frame) runUntilError(ctx phpv.Context) (retVal *phpv.ZVal, finished boo
13771377
f.push(res)
13781378
}
13791379

1380+
// --- $obj->$x = v / $obj->{$x} = v dyn-name write ------------
1381+
case OpObjectDynSet:
1382+
val := f.pop()
1383+
nameV := f.pop()
1384+
recv := f.pop()
1385+
if recv == nil || recv.Value() == nil {
1386+
propName := ""
1387+
if nameV != nil {
1388+
propName = string(nameV.AsString(ctx))
1389+
}
1390+
return nil, false, phpobj.ThrowError(ctx, phpobj.Error,
1391+
fmt.Sprintf("Attempt to assign property \"%s\" on null", propName))
1392+
}
1393+
objI, ok := recv.Value().(phpv.ZObjectAccess)
1394+
if !ok {
1395+
typeName := compiler.PhpValueTypeName(recv)
1396+
propName := ""
1397+
if nameV != nil {
1398+
propName = string(nameV.AsString(ctx))
1399+
}
1400+
return nil, false, phpobj.ThrowError(ctx, phpobj.Error,
1401+
fmt.Sprintf("Attempt to assign property \"%s\" on %s", propName, typeName))
1402+
}
1403+
if err := objI.ObjectSet(ctx, nameV, val); err != nil {
1404+
return nil, false, err
1405+
}
1406+
// A bit 0: keep value on stack (expr context).
1407+
if ins.A()&1 != 0 {
1408+
f.push(val)
1409+
}
1410+
13801411
// --- global $x ---------------------------------------------
13811412
case OpGlobalBind:
13821413
nameV := f.pop()

core/vm/vmcompiler/emit_expr.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -884,17 +884,25 @@ func (e *emitter) emitAssign(n operatorNode) error {
884884
// the AST runner. IsSlotSafe rejects bodies with these writes,
885885
// so the hashtable is in sync when the AST reads locals.
886886
if ov, ok := n.OperatorA().(objectVarNode); ok {
887-
// `$obj->prop = v`: emit natively for static-name, non-nullsafe.
888-
// OP_OBJECT_SET dispatches via ZObject's ObjectSet which already
889-
// handles typed properties, hooks, and asymmetric visibility.
890-
// Expr-context uses the B=1 keep-value flag so the assignment
891-
// result lands on the stack.
892-
name := ov.ObjectVarName()
893-
if !ov.ObjectVarIsNullSafe() && !(len(name) > 0 && name[0] == '$') {
887+
// `$obj->prop = v` / `$obj->$x = v`: emit natively for
888+
// non-nullsafe access. emitObjectVarAssign routes the static-name
889+
// form through OP_OBJECT_SET and the dyn-name ($-prefixed) form
890+
// through OP_OBJECT_DYN_SET — both dispatch via ZObject.ObjectSet
891+
// (typed properties, hooks, asymmetric visibility). Expr-context
892+
// uses the keep-value flag so the assignment result lands on the
893+
// stack.
894+
if !ov.ObjectVarIsNullSafe() {
894895
return e.emitObjectVarAssign(ov, n.OperatorB(), e.stmtCtx)
895896
}
896897
return e.emitAssignViaAST(n)
897898
}
899+
if compiler.IsObjectDynVarReadNode(n.OperatorA()) && !compiler.ObjectDynVarIsNullSafe(n.OperatorA()) {
900+
// `$obj->{$x} = v`: dyn-name property write via OP_OBJECT_DYN_SET.
901+
if dv, ok := n.OperatorA().(objectDynVarReadNode); ok {
902+
return e.emitObjectDynVarAssign(dv, n.OperatorB(), e.stmtCtx)
903+
}
904+
return e.emitAssignViaAST(n)
905+
}
898906
if compiler.IsStaticPropertyTarget(n.OperatorA()) {
899907
// `Foo::$bar = v`: emit natively when the LHS is the static-name
900908
// form (`*runClassStaticVarRef`). AssignClassStaticProp handles

core/vm/vmcompiler/emit_object.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,6 @@ func (e *emitter) emitObjectVarAssign(lhs objectVarNode, rhs phpv.Runnable, stmt
7373
return unsupportedf("nullsafe property assign")
7474
}
7575
name := lhs.ObjectVarName()
76-
if len(name) > 0 && name[0] == '$' {
77-
return unsupportedf("dynamic property name in assign")
78-
}
7976
// PHP's write-context for `$x->prop = v`: an undefined `$x` does
8077
// NOT emit "Undefined variable" — the AST's runVariable.Run sees
8178
// Parent=runOperator(write) and short-circuits the warning. For
@@ -91,6 +88,27 @@ func (e *emitter) emitObjectVarAssign(lhs objectVarNode, rhs phpv.Runnable, stmt
9188
return err
9289
}
9390
}
91+
// `$obj->$x = v` has varName prefixed with `$`. Push the local
92+
// holding the dyn name on the stack and use OP_OBJECT_DYN_SET.
93+
if len(name) > 0 && name[0] == '$' {
94+
localIdx := e.localIndex(name[1:])
95+
e.emit(vm.OpLoadLocal, localIdx, 0, 0)
96+
e.pushStack(1)
97+
if err := e.withSubexpr(func() error { return e.emitExpr(rhs) }); err != nil {
98+
return err
99+
}
100+
var a uint16
101+
if !stmtCtx {
102+
a |= 1 // keep value on stack
103+
}
104+
e.emit(vm.OpObjectDynSet, a, 0, 0)
105+
if stmtCtx {
106+
e.popStack(3) // pop receiver+name+value, push nothing
107+
} else {
108+
e.popStack(2) // pop receiver+name+value, push value back → net -2
109+
}
110+
return nil
111+
}
94112
if err := e.withSubexpr(func() error { return e.emitExpr(rhs) }); err != nil {
95113
return err
96114
}
@@ -107,6 +125,40 @@ func (e *emitter) emitObjectVarAssign(lhs objectVarNode, rhs phpv.Runnable, stmt
107125
return nil
108126
}
109127

128+
// emitObjectDynVarAssign emits `$obj->{$x} = v` natively. Receiver and
129+
// name are evaluated and pushed; OP_OBJECT_DYN_SET dispatches to
130+
// ZObject.ObjectSet (typed props, hooks, asymmetric visibility).
131+
//
132+
// Note: unlike runObjectVar.WriteValue, the runObjectDynVar AST runner
133+
// does NOT call SetWriteContext on its receiver. An undefined simple-
134+
// variable receiver still emits the "Undefined variable" warning. Use
135+
// OP_LOAD_LOCAL_OR_WARN here to match AST semantics — the dyn form
136+
// behaves like a value read on the receiver, then a write on the
137+
// resolved property.
138+
func (e *emitter) emitObjectDynVarAssign(lhs objectDynVarReadNode, rhs phpv.Runnable, stmtCtx bool) error {
139+
recv := lhs.ObjectDynVarReceiver()
140+
if err := e.withSubexpr(func() error { return e.emitExpr(recv) }); err != nil {
141+
return err
142+
}
143+
if err := e.withSubexpr(func() error { return e.emitExpr(lhs.ObjectDynVarNameExpr()) }); err != nil {
144+
return err
145+
}
146+
if err := e.withSubexpr(func() error { return e.emitExpr(rhs) }); err != nil {
147+
return err
148+
}
149+
var a uint16
150+
if !stmtCtx {
151+
a |= 1
152+
}
153+
e.emit(vm.OpObjectDynSet, a, 0, 0)
154+
if stmtCtx {
155+
e.popStack(3)
156+
} else {
157+
e.popStack(2)
158+
}
159+
return nil
160+
}
161+
110162
// emitObjectVarCompoundAssign emits `$obj->prop OP= rhs` for static-name,
111163
// non-nullsafe property access. The receiver is evaluated once; the
112164
// resulting value handle is reused for both objectGet (read current) and

0 commit comments

Comments
 (0)