Skip to content

Commit 8558f0e

Browse files
MagicalTuxclaude
andcommitted
vm: lower isset/empty($obj->prop) natively via OP_ISSET_OBJ_PROP / OP_EMPTY_OBJ_PROP
Currently isset($obj->prop) / empty($obj->prop) fall through to OpClassConst (generic AST.Run delegation). Add native opcodes that pop the receiver and read the prop name from the const pool, then dispatch to EvalIssetObjProp / EvalEmptyObjProp. EvalIssetObjProp / EvalEmptyObjProp mirror checkExistence / checkEmpty's runObjectVar branches — non-object receiver returns false/true (no warn), otherwise HasProp + (for empty) ObjectGet + IsValueEmpty. IsIssetSupportedArg extended to accept objectVarNode shapes with static names (non-dollar-prefix, non-nullsafe) and a recursively supported receiver. Nested forms ($outer->inner->prop) work via emitIssetContainerRead which now recurses via OP_OBJECT_GET_SAFE for intermediate object reads. Bodies that use only the natively-supported isset/empty shapes (simple-local, array-access on simple container, object prop on supported receiver) stay slot-safe. Dollar-prefix dyn-name and nullsafe forms still AST-delegate via OpClassConst. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1be0792 commit 8558f0e

5 files changed

Lines changed: 130 additions & 0 deletions

File tree

core/compiler/compile-isset.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,45 @@ func EvalEmptyDim(ctx phpv.Context, value, key *phpv.ZVal) (bool, error) {
388388
return IsValueEmpty(ctx, val), nil
389389
}
390390

391+
// EvalIssetObjProp performs the existence-and-not-null check on
392+
// `$obj->propName`. Mirrors checkExistence's runObjectVar branch for
393+
// the post-receiver-evaluation portion: non-object receivers return
394+
// false (no warn), object receivers dispatch to HasProp (which itself
395+
// handles __isset and visibility).
396+
func EvalIssetObjProp(ctx phpv.Context, receiver *phpv.ZVal, propName phpv.ZString) (bool, error) {
397+
if receiver == nil || receiver.GetType() != phpv.ZtObject {
398+
return false, nil
399+
}
400+
obj, ok := receiver.Value().(*phpobj.ZObject)
401+
if !ok {
402+
return false, nil
403+
}
404+
return obj.HasProp(ctx, propName)
405+
}
406+
407+
// EvalEmptyObjProp performs the "empty" check on `$obj->propName`.
408+
// Mirrors checkEmpty's runObjectVar branch: non-object receivers and
409+
// missing properties return true (empty); otherwise reads and applies
410+
// IsValueEmpty.
411+
func EvalEmptyObjProp(ctx phpv.Context, receiver *phpv.ZVal, propName phpv.ZString) (bool, error) {
412+
if receiver == nil || receiver.GetType() != phpv.ZtObject {
413+
return true, nil
414+
}
415+
obj, ok := receiver.Value().(*phpobj.ZObject)
416+
if !ok {
417+
return true, nil
418+
}
419+
exists, err := obj.HasProp(ctx, propName)
420+
if err != nil || !exists {
421+
return true, nil
422+
}
423+
val, err := obj.ObjectGet(ctx, propName)
424+
if err != nil {
425+
return true, nil
426+
}
427+
return IsValueEmpty(ctx, val), nil
428+
}
429+
391430
func IsValueEmpty(ctx phpv.Context, v *phpv.ZVal) bool {
392431
if v == nil {
393432
return true

core/compiler/vmaccess.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,10 +916,26 @@ func IssetEmptyAllSimple(r phpv.Runnable) bool {
916916
// - simple variable ($x)
917917
// - array-access with a non-nullsafe simple-shape container
918918
// ($x[$k], $x[k], also nested $x[$k1][$k2])
919+
// - object property with a static (non-dollar-prefix) name and no
920+
// nullsafe marker ($obj->prop). Receiver itself can be any
921+
// emittable expression.
919922
func IsIssetSupportedArg(r phpv.Runnable) bool {
920923
if IsSimpleVariable(r) {
921924
return true
922925
}
926+
if o, ok := r.(*runObjectVar); ok {
927+
// Reject dollar-prefix dyn-name and nullsafe forms — those
928+
// need separate opcodes or extra short-circuit logic.
929+
if o.nullsafe || o.nullChain {
930+
return false
931+
}
932+
if len(o.varName) > 0 && o.varName[0] == '$' {
933+
return false
934+
}
935+
// Receiver chain must also be a supported isset shape so
936+
// emitIssetContainerRead can recurse without falling back.
937+
return IsIssetSupportedArg(o.ref)
938+
}
923939
a, ok := r.(*runArrayAccess)
924940
if !ok || a.nullChain {
925941
return false

core/vm/opcode.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,14 @@ const (
456456
// PHP's `??` only silences property-existence, not receiver-type.
457457
OpObjectGetSafe
458458

459+
// OP_ISSET_OBJ_PROP / OP_EMPTY_OBJ_PROP — native `isset($obj->prop)` /
460+
// `empty($obj->prop)` for static-name, non-nullsafe property access.
461+
// Encoding: A = const-pool name index. Pops receiver, pushes bool.
462+
// Dispatches to compiler.EvalIssetObjProp / EvalEmptyObjProp which
463+
// mirror checkExistence / checkEmpty's runObjectVar branches.
464+
OpIssetObjProp
465+
OpEmptyObjProp
466+
459467
// Sentinel — keep last.
460468
opLast
461469
)
@@ -590,4 +598,6 @@ var opNames = [...]string{
590598
OpObjectCallByExprs: "OBJECT_CALL_BY_EXPRS",
591599
OpObjectDynCall: "OBJECT_DYN_CALL",
592600
OpObjectGetSafe: "OBJECT_GET_SAFE",
601+
OpIssetObjProp: "ISSET_OBJ_PROP",
602+
OpEmptyObjProp: "EMPTY_OBJ_PROP",
593603
}

core/vm/vm.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,30 @@ func (f *Frame) runUntilError(ctx phpv.Context) (retVal *phpv.ZVal, finished boo
10351035
}
10361036
f.push(res)
10371037

1038+
case OpIssetObjProp:
1039+
receiver := f.pop()
1040+
name, ok := f.fn.Consts[ins.A()].(phpv.ZString)
1041+
if !ok {
1042+
return nil, false, fmt.Errorf("vm: OP_ISSET_OBJ_PROP name const is %T not ZString", f.fn.Consts[ins.A()])
1043+
}
1044+
exists, err := compiler.EvalIssetObjProp(ctx, receiver, name)
1045+
if err != nil {
1046+
return nil, false, err
1047+
}
1048+
f.push(phpv.ZBool(exists).ZVal())
1049+
1050+
case OpEmptyObjProp:
1051+
receiver := f.pop()
1052+
name, ok := f.fn.Consts[ins.A()].(phpv.ZString)
1053+
if !ok {
1054+
return nil, false, fmt.Errorf("vm: OP_EMPTY_OBJ_PROP name const is %T not ZString", f.fn.Consts[ins.A()])
1055+
}
1056+
isEmpty, err := compiler.EvalEmptyObjProp(ctx, receiver, name)
1057+
if err != nil {
1058+
return nil, false, err
1059+
}
1060+
f.push(phpv.ZBool(isEmpty).ZVal())
1061+
10381062
case OpObjectCall:
10391063
argc := int(ins.B())
10401064
args := make([]*phpv.ZVal, argc)

core/vm/vmcompiler/emit_expr.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,19 @@ func (e *emitter) emitIssetArg(a phpv.Runnable) error {
11411141
e.popStack(1) // 2 in, 1 out
11421142
return nil
11431143
}
1144+
if ov, ok := a.(objectVarNode); ok {
1145+
// `isset($obj->prop)` — emit receiver as a permissive read so
1146+
// undefined-variable warnings on the receiver are suppressed,
1147+
// then OP_ISSET_OBJ_PROP pops receiver + reads name from the
1148+
// const pool to dispatch to EvalIssetObjProp.
1149+
if err := e.withSubexpr(func() error { return e.emitIssetContainerRead(ov.ObjectVarReceiver()) }); err != nil {
1150+
return err
1151+
}
1152+
idx := e.constIndex(ov.ObjectVarName())
1153+
e.emit(vm.OpIssetObjProp, idx, 0, 0)
1154+
// pop receiver, push bool → net stack delta zero.
1155+
return nil
1156+
}
11441157
return unsupportedf("emitIssetArg: unsupported shape %T", a)
11451158
}
11461159

@@ -1164,6 +1177,18 @@ func (e *emitter) emitEmptyArg(a phpv.Runnable) error {
11641177
e.popStack(1)
11651178
return nil
11661179
}
1180+
if ov, ok := a.(objectVarNode); ok {
1181+
// `empty($obj->prop)` — emit receiver permissively then
1182+
// OP_EMPTY_OBJ_PROP. The dispatch returns true for non-object
1183+
// receivers and missing properties (matches PHP semantics).
1184+
if err := e.withSubexpr(func() error { return e.emitIssetContainerRead(ov.ObjectVarReceiver()) }); err != nil {
1185+
return err
1186+
}
1187+
idx := e.constIndex(ov.ObjectVarName())
1188+
e.emit(vm.OpEmptyObjProp, idx, 0, 0)
1189+
// pop receiver, push bool → net stack delta zero.
1190+
return nil
1191+
}
11671192
return unsupportedf("emitEmptyArg: unsupported shape %T", a)
11681193
}
11691194

@@ -1194,6 +1219,22 @@ func (e *emitter) emitIssetContainerRead(c phpv.Runnable) error {
11941219
e.popStack(1)
11951220
return nil
11961221
}
1222+
if ov, ok := c.(objectVarNode); ok {
1223+
// Nested `$outer->inner->...` — recurse for the receiver, then
1224+
// permissive object read so a missing intermediate property
1225+
// just produces null without warning.
1226+
name := ov.ObjectVarName()
1227+
if !ov.ObjectVarIsNullSafe() && len(name) > 0 && name[0] != '$' {
1228+
if err := e.emitIssetContainerRead(ov.ObjectVarReceiver()); err != nil {
1229+
return err
1230+
}
1231+
idx := e.constIndex(name)
1232+
e.emit(vm.OpObjectGetSafe, idx, 0, 0)
1233+
return nil
1234+
}
1235+
// Fall through to unsupported for nullsafe/dyn-name nested
1236+
// containers — those keep AST delegation.
1237+
}
11971238
return unsupportedf("emitIssetContainerRead: unsupported shape %T", c)
11981239
}
11991240

0 commit comments

Comments
 (0)