From 965ab413eea615a6eaf8af83addf4eb452a50d69 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 23 Sep 2026 14:10:07 +0200 Subject: [PATCH 1/6] lang: defined?(super), frozen defined? tags, container operands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `defined?` diverged from MRI in four ways that language/defined_spec.rb pins. Read for this change: compile.c v3_4_0 `defined_expr0` (5952-6172) and `compile_defined_expr` (6213), iseq.h's `enum defined_type` (309-326), `rb_iseq_defined_string` (iseq.c 3692) and `vm_defined` (vm_insnhelper.c 5447-5527). defined?(super) answered "expression". NODE_SUPER and NODE_ZSUPER are both DEFINED_ZSUPER (compile.c 6139-6143), lowered as `putnil` + `defined ZSUPER` — the super ARGUMENTS are never evaluated, only the existence of the method super would reach is tested. Adds OpDefinedSuper and answers it from the same resolution the super CALL uses: invokeSuper is split so `superMethod` locates the target without invoking it, which is what vm_defined's DEFINED_ZSUPER does (rb_method_boundp over vm_search_normal_superclass of the frame's defined_class, vm_insnhelper.c 5505-5518). A block and a define_method body answer for their home method, because they already carry its name and definee. The tags were not frozen. MRI builds every answer with rb_iseq_defined_string, i.e. rb_fstring_cstr — a frozen, deduplicated String — and the spec asserts `.frozen?` on each tag it names. Both halves now go through one constructor, bytecode.DefinedTag: the compiler for the syntactically-decided tags, the VM for the ones only it can decide. Doing one half alone would have left defined? half-frozen. OpPushConst had to stop cloning a frozen String constant: String.Dup returns an UNFROZEN copy, so the freeze never survived the push. Sharing is what MRI does — `putobject` over the shared fstring for a frozen literal, `putstring` (which duplicates) only for a mutable one — and a frozen String cannot be told apart by sharing. No other constant is frozen today, so nothing else changes shape. An array or hash literal was flatly "expression". MRI probes every element with defined_expr0 and branches to the whole expression's nil label on the first undefined one (compile.c 5975-5993 for NODE_HASH / NODE_LIST, 6011-6019 for NODE_SPLAT), so `defined?([NonExistentConstant, Array])` is nil. An empty container (NODE_ZLIST) has nothing to probe and stays "expression". __FILE__, __LINE__ and __ENCODING__ answered "method". They are keywords in MRI's grammar, parsed to NODE_FILE / NODE_LINE / NODE_ENCODING, which sit with the literals falling through to DEFINED_EXPR (compile.c 5996-6008); go-ruby-parser hands them over as bare calls, so the compiler has to recognise them. language/defined_spec.rb 213 -> 250 passing; no other file in language/ moves (2158 -> 2195 over the tree). Co-Authored-By: Claude Opus 5 --- internal/bytecode/bytecode.go | 37 +++++++++++ internal/compiler/defined.go | 121 +++++++++++++++++++++++++++++----- internal/vm/defined.go | 10 +-- internal/vm/vm.go | 89 ++++++++++++++++++------- 4 files changed, 214 insertions(+), 43 deletions(-) diff --git a/internal/bytecode/bytecode.go b/internal/bytecode/bytecode.go index ecaee3bc..4639ae31 100644 --- a/internal/bytecode/bytecode.go +++ b/internal/bytecode/bytecode.go @@ -120,8 +120,44 @@ const ( OpGetConstTop // A = Names index; leading `::Name` — top-level (Object) constant only, ignoring lexical nesting OpDefinedConstTop // A = Names index; "constant" if the top-level `::Name` exists, else nil OpRegexpOnce // A = target pc past the interpolation build; guards a /o literal: if the occurrence's Regexp is already memoised, push it and jump to A, else fall through to (re)build once + OpDefinedSuper // "super" if the method `super` would reach from the current frame exists, else nil; never evaluates the super arguments ) +// Defined tag Strings. +// +// MRI builds every `defined?` answer with rb_iseq_defined_string (iseq.c +// v3_4_0:3692), which returns rb_fstring_cstr(...) — a FROZEN, deduplicated +// String. ruby/spec pins that: language/defined_spec.rb asserts +// `defined?(nil).frozen?.should == true` for every tag it names. +// +// The tags live here, in the package both halves of the implementation import, +// because both halves must agree: the compiler bakes a tag into the literal +// pool for the syntactically-decided cases (nil/self/expression/…), while the +// VM builds one at run time for the cases only it can decide +// (constant/method/yield/…). One table, one constructor, so the two cannot +// drift apart and leave defined? half-frozen. +const ( + DefinedNil = "nil" + DefinedIvar = "instance-variable" + DefinedLvar = "local-variable" + DefinedGvar = "global-variable" + DefinedCvar = "class variable" + DefinedConst = "constant" + DefinedMethod = "method" + DefinedYield = "yield" + DefinedZSuper = "super" + DefinedSelf = "self" + DefinedTrue = "true" + DefinedFalse = "false" + DefinedAsgn = "assignment" + DefinedExprTag = "expression" +) + +// DefinedTag returns the frozen String a `defined?` answer is made of. It +// mirrors rb_iseq_defined_string: the result is frozen, so a caller may share +// it freely, and `defined?(nil).frozen?` is true as in MRI. +func DefinedTag(tag string) *object.String { return object.NewFrozenStringView(tag) } + var opNames = map[Op]string{ OpNop: "nop", OpPushConst: "push_const", OpPushNil: "push_nil", OpPushTrue: "push_true", OpPushFalse: "push_false", OpPushSelf: "push_self", @@ -159,6 +195,7 @@ var opNames = map[Op]string{ OpDefinedGuard: "defined_guard", OpGetConstTop: "get_const_top", OpDefinedConstTop: "defined_const_top", + OpDefinedSuper: "defined_super", } func (o Op) String() string { diff --git a/internal/compiler/defined.go b/internal/compiler/defined.go index c230b5bd..39e534ca 100644 --- a/internal/compiler/defined.go +++ b/internal/compiler/defined.go @@ -2,7 +2,6 @@ package compiler import ( "github.com/go-embedded-ruby/ruby/internal/bytecode" - "github.com/go-embedded-ruby/ruby/internal/object" "github.com/go-ruby-parser/parser/ast" ) @@ -17,15 +16,36 @@ func (c *Compiler) compileDefined(operand ast.Node) { c.declareDefinedLocals(operand) switch v := operand.(type) { case *ast.NilLit: - c.pushDefinedTag("nil") + c.pushDefinedTag(bytecode.DefinedNil) case *ast.BoolLit: if v.Value { - c.pushDefinedTag("true") + c.pushDefinedTag(bytecode.DefinedTrue) } else { - c.pushDefinedTag("false") + c.pushDefinedTag(bytecode.DefinedFalse) } case *ast.SelfLit: - c.pushDefinedTag("self") + c.pushDefinedTag(bytecode.DefinedSelf) + case *ast.Super: + // NODE_SUPER and NODE_ZSUPER are both DEFINED_ZSUPER (compile.c + // v3_4_0:6139-6143): a `putnil` then `defined ZSUPER`, which means the + // super ARGUMENTS are never evaluated — only the existence of the method + // `super` would reach is tested. The VM half answers it (vm_defined's + // DEFINED_ZSUPER, vm_insnhelper.c v3_4_0:5505-5518). + b.emit(bytecode.OpDefinedSuper, 0, 0) + case *ast.ArrayLit: + c.compileDefinedElements(v.Elems) + case *ast.HashLit: + // NODE_HASH walks its nd_head, a flat LIST of key, value, key, value… + // (compile.c v3_4_0:5975-5993), so keys are probed like values. + pairs := make([]ast.Node, 0, len(v.Keys)+len(v.Values)) + for i, k := range v.Keys { + pairs = append(pairs, k, v.Values[i]) + } + c.compileDefinedElements(pairs) + case *ast.SplatArg: + // NODE_SPLAT probes the splatted expression, then reports "expression" + // (compile.c v3_4_0:6011-6019). + c.compileDefinedElements([]ast.Node{v.Value}) case *ast.Yield: b.emit(bytecode.OpDefinedYield, 0, 0) case *ast.IvarRef: @@ -38,12 +58,12 @@ func (c *Compiler) compileDefined(operand ast.Node) { b.emit(bytecode.OpDefinedConst, b.addName(v.Name), 0) case *ast.VarRef: // The parser already classified this as a known local read. - c.pushDefinedTag("local-variable") + c.pushDefinedTag(bytecode.DefinedLvar) case *ast.ScopedConst: c.compileDefinedScopedConst(v) case *ast.Assign, *ast.OpAssign, *ast.MultiAssign, *ast.ConstAssign, *ast.ScopedConstAssign, *ast.IvarAssign, *ast.CVarAssign, *ast.GVarAssign: - c.pushDefinedTag("assignment") + c.pushDefinedTag(bytecode.DefinedAsgn) case *ast.Call: // A compound assignment to an attribute or an index (`a.b += 1`, // `a[:b] ||= 1`) reaches the compiler as the parser's textual desugaring @@ -57,11 +77,11 @@ func (c *Compiler) compileDefined(operand ast.Node) { // the two apart; see opassign.go for why identity is the test. if isSetterCall(v) { if _, _, ok := indexOpAssign(v); ok { - c.pushDefinedTag("assignment") + c.pushDefinedTag(bytecode.DefinedAsgn) return } if _, ok := attrOpAssign(v); ok { - c.pushDefinedTag("assignment") + c.pushDefinedTag(bytecode.DefinedAsgn) return } } @@ -73,10 +93,54 @@ func (c *Compiler) compileDefined(operand ast.Node) { // all of them "method" once the operand is defined. c.compileDefinedReceiverMethod(v.Operand, unaryMethodName(v.Op)) default: - // Literals (numbers, strings, arrays, ranges, regexps, hashes, …) and any - // other expression are "expression". - c.pushDefinedTag("expression") + // Literals (numbers, strings, ranges, regexps, …) and any other expression + // are "expression" — compile.c's `default:` arm (v3_4_0:6003-6008). + c.pushDefinedTag(bytecode.DefinedExprTag) + } +} + +// isSourceKeyword reports whether name is one of the three source-position +// pseudo-variables the parser hands over as a bare call. They are keywords in +// MRI's grammar (parse.y v3_4_0: `keyword__FILE__`, `keyword__LINE__`, +// `keyword__ENCODING__` in `var_ref`), never method calls, so defined? must not +// probe self for a method of that name. +func isSourceKeyword(name string) bool { + switch name { + case "__FILE__", "__LINE__", "__ENCODING__": + return true + } + return false +} + +// compileDefinedElements lowers the container forms — an array literal, a hash +// literal, a splat — whose answer is "expression" but only once EVERY part is +// itself defined. MRI probes each element with defined_expr0 and branches to the +// whole expression's nil label on the first undefined one (compile.c +// v3_4_0:5975-6019); `defined?([NonExistentConstant, Array])` is nil, not +// "expression". An empty container has nothing to probe and is plain +// "expression" (NODE_ZLIST). +// +// Each probe is a full defined? of the element, so nothing is evaluated for its +// value and a raising sub-expression is already guarded by the element's own +// lowering. +func (c *Compiler) compileDefinedElements(elems []ast.Node) { + if len(elems) == 0 { + c.pushDefinedTag(bytecode.DefinedExprTag) + return + } + b := c.cur() + undef := make([]int, 0, len(elems)) + for _, e := range elems { + c.compileDefined(e) + undef = append(undef, b.emit(bytecode.OpBranchNil, 0, 0)) } + c.pushDefinedTag(bytecode.DefinedExprTag) + done := b.emit(bytecode.OpJump, 0, 0) + for _, at := range undef { + b.patch(at, b.here()) + } + b.emit(bytecode.OpPushNil, 0, 0) + b.patch(done, b.here()) } // unaryMethodName maps a unary operator to the method name MRI checks for. Only @@ -88,10 +152,14 @@ func unaryMethodName(op string) string { return op } -// pushDefinedTag pushes a constant tag String. +// pushDefinedTag pushes a constant tag String. The tag comes from +// bytecode.DefinedTag, the one constructor the VM half uses too, so it is +// FROZEN: MRI answers defined? with rb_iseq_defined_string, an fstring +// (iseq.c v3_4_0:3692), and language/defined_spec.rb asserts `.frozen?` on +// every tag it names. func (c *Compiler) pushDefinedTag(tag string) { b := c.cur() - b.emit(bytecode.OpPushConst, b.addConst(object.NewString(tag)), 0) + b.emit(bytecode.OpPushConst, b.addConst(bytecode.DefinedTag(tag)), 0) } // compileDefinedScopedConst handles `defined?(A::B)` and `defined?(::B)`. @@ -117,7 +185,15 @@ func (c *Compiler) compileDefinedCall(v *ast.Call) { b := c.cur() if v.Recv == nil && v.Block == nil && len(v.Args) == 0 { if _, _, ok := b.resolve(v.Name); ok { - c.pushDefinedTag("local-variable") + c.pushDefinedTag(bytecode.DefinedLvar) + return + } + // __FILE__, __LINE__ and __ENCODING__ are keywords, not methods: the + // parser hands them over as bare calls, but MRI parses them to + // NODE_FILE / NODE_LINE / NODE_ENCODING, which are listed with the + // literals that fall through to DEFINED_EXPR (compile.c v3_4_0:5996-6008). + if isSourceKeyword(v.Name) { + c.pushDefinedTag(bytecode.DefinedExprTag) return } } @@ -135,10 +211,10 @@ func (c *Compiler) compileDefinedBinary(v *ast.BinaryExpr) { // (prism_compile.c v3_4_0:4059 and 4063; compile.c's NODE_OP_CDECL at // v3_4_0:6162 says the same) — and ruby/spec pins it. if constOrAssign(v) { - c.pushDefinedTag("assignment") + c.pushDefinedTag(bytecode.DefinedAsgn) return } - c.pushDefinedTag("expression") + c.pushDefinedTag(bytecode.DefinedExprTag) return } c.compileDefinedReceiverMethod(v.Left, v.Op) @@ -269,6 +345,17 @@ func (c *Compiler) declareDefinedLocals(n ast.Node) { c.declareDefinedLocals(v.Right) case *ast.UnaryExpr: c.declareDefinedLocals(v.Operand) + case *ast.ArrayLit: + for _, e := range v.Elems { + c.declareDefinedLocals(e) + } + case *ast.HashLit: + for i, k := range v.Keys { + c.declareDefinedLocals(k) + c.declareDefinedLocals(v.Values[i]) + } + case *ast.SplatArg: + c.declareDefinedLocals(v.Value) } } diff --git a/internal/vm/defined.go b/internal/vm/defined.go index a9b154a2..5e790af0 100644 --- a/internal/vm/defined.go +++ b/internal/vm/defined.go @@ -5,10 +5,12 @@ import ( "github.com/go-embedded-ruby/ruby/internal/object" ) -// definedTag returns the canonical (frozen) String for a `defined?` tag. The -// String is freshly built each call to match MRI, which returns a new String -// object from defined?; callers never mutate it. -func definedTag(tag string) object.Value { return object.NewString(tag) } +// definedTag returns the canonical String for a `defined?` tag, through the one +// constructor the compiler half uses too (bytecode.DefinedTag). It is FROZEN: +// MRI builds every defined? answer with rb_iseq_defined_string, which returns +// rb_fstring_cstr(...) (iseq.c v3_4_0:3692), and language/defined_spec.rb +// asserts `.frozen?` on the tags it names. +func definedTag(tag string) object.Value { return bytecode.DefinedTag(tag) } // hasScopedConst reports whether cls or its ancestors define name — the // non-raising form of scopedConst (used by defined?(A::B)). A pending autoload on diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 9a1b8343..cd4b9e9c 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -1448,7 +1448,16 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // A string literal evaluates to a fresh mutable object each time // (Ruby semantics), so clone string constants on push; every other // constant is immutable and can be shared. - if s, ok := iseq.Consts[in.A].(*object.String); ok { + // + // A FROZEN String constant is shared, not cloned. It cannot be + // mutated, so nothing can tell the sharing apart — and cloning would + // hand back an unfrozen copy (String.Dup drops Frozen), which is + // exactly what MRI does not do: it pushes a frozen literal with + // `putobject` over the shared fstring, and only a mutable one with + // `putstring`, which duplicates. `defined?` tags are built that way + // (rb_iseq_defined_string, iseq.c v3_4_0:3692), so a clone here made + // `defined?(nil).frozen?` false. + if s, ok := iseq.Consts[in.A].(*object.String); ok && !s.Frozen { push(s.Dup()) } else { push(iseq.Consts[in.A]) @@ -1971,13 +1980,13 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // reports "constant" without requiring the file. name := iseq.Names[in.A] if _, ok := vm.resolveConstNoAutoload(lexCref, name); ok || vm.autoloadPending(lexCref, name) { - push(definedTag("constant")) + push(definedTag(bytecode.DefinedConst)) } else { push(object.NilV) } case bytecode.OpDefinedConstTop: if _, ok := vm.cObject.consts[iseq.Names[in.A]]; ok { - push(definedTag("constant")) + push(definedTag(bytecode.DefinedConst)) } else { push(object.NilV) } @@ -1990,7 +1999,7 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // (vm_insnhelper.c, ruby/ruby v3_4_0:1139-1141), so a // private_constant is not `defined?` by its qualified path. if ok && vm.hasScopedConst(cls, name) && !vm.scopedConstIsPrivate(cls, name) { - push(definedTag("constant")) + push(definedTag(bytecode.DefinedConst)) } else { push(object.NilV) } @@ -1998,7 +2007,7 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, name := iseq.Names[in.A] if t := ivarTable(self); t != nil { if _, ok := t[name]; ok { - push(definedTag("instance-variable")) + push(definedTag(bytecode.DefinedIvar)) break } } @@ -2006,26 +2015,38 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, case bytecode.OpDefinedCVar: name := iseq.Names[in.A] if definee != vm.cObject && cvarOwner(definee, name) != nil { - push(definedTag("class variable")) + push(definedTag(bytecode.DefinedCvar)) } else { push(object.NilV) } case bytecode.OpDefinedGVar: if vm.gvarDefined(iseq.Names[in.A]) { - push(definedTag("global-variable")) + push(definedTag(bytecode.DefinedGvar)) } else { push(object.NilV) } case bytecode.OpDefinedMethod: recv := pop() if vm.respondsTo(recv, iseq.Names[in.A]) { - push(definedTag("method")) + push(definedTag(bytecode.DefinedMethod)) } else { push(object.NilV) } case bytecode.OpDefinedYield: if block != nil { - push(definedTag("yield")) + push(definedTag(bytecode.DefinedYield)) + } else { + push(object.NilV) + } + case bytecode.OpDefinedSuper: + // DEFINED_ZSUPER: MRI takes the frame's method entry, finds the normal + // superclass of its defined_class and asks rb_method_boundp for the + // method's ORIGINAL id (vm_insnhelper.c v3_4_0:5505-5518). A frame with + // no method entry — the top level, a class body — answers nil. + // homeSuperName/homeSuperDefinee are the same pair the super CALL uses, + // so a block and a define_method body answer for their home method. + if homeSuperName != "" && vm.superMethod(self, homeSuperDefinee, homeSuperName) != nil { + push(definedTag(bytecode.DefinedZSuper)) } else { push(object.NilV) } @@ -2575,14 +2596,47 @@ func (vm *VM) invokeSuper(self object.Value, definee *RClass, methodName string, if methodName == "" { raise("RuntimeError", "super called outside of method") } + if found := vm.superMethod(self, definee, methodName); found != nil { + return vm.invoke(found, self, args, blk) + } + // No callable superclass method. MRI routes a failed super through + // method_missing: a user-defined one handles it, while the default (on + // BasicObject) reports the super-specific message below. Dispatch only a + // genuine override so the default path keeps that exact message. + // + // A refinement method's super has no method_missing fallback in MRI — it is + // resolved in the refined class alone — so it reports straight away. + if !definee.isRefinement { + if mm := lookupMethod(vm.classOf(self), "method_missing"); mm != nil && !mm.undefined && mm.owner != vm.cBasicObject { + mmArgs := append([]object.Value{object.SymVal(methodName)}, args...) + return vm.invoke(mm, self, mmArgs, blk) + } + } + raise("NoMethodError", "super: no superclass method '%s'", methodName) + return object.NilV +} + +// superMethod resolves the method a `super` written in methodName (defined in +// definee, running on receiver self) would call, WITHOUT calling it. It returns +// nil when no callable definition exists above definee. +// +// It is the single resolution shared by the super CALL (OpInvokeSuper) and by +// `defined?(super)` (OpDefinedSuper): MRI answers DEFINED_ZSUPER with +// rb_method_boundp over vm_search_normal_superclass of the frame's +// defined_class (vm_insnhelper.c v3_4_0:5505-5518), i.e. the same search the +// call performs, so the two must not drift apart. +func (vm *VM) superMethod(self object.Value, definee *RClass, methodName string) *Method { + if methodName == "" || definee == nil { + return nil + } // super inside a refinement method resolves in the refined class only — its // own method and normal ancestor chain — skipping every other active // refinement (MRI: "looks only in the refined class"). if definee.isRefinement { if m := lookupMethod(definee.refinedClass, methodName); m != nil && !m.undefined { - return vm.invoke(m, self, args, blk) + return m } - raise("NoMethodError", "super: no superclass method '%s'", methodName) + return nil } // super resolves to the next definition of methodName after the current // method's owner (definee) in the receiver's ancestor chain — so it walks @@ -2634,16 +2688,7 @@ func (vm *VM) invokeSuper(self object.Value, definee *RClass, methodName string, found = lookupSMethod(definee.super, methodName) } if found != nil && !found.undefined { - return vm.invoke(found, self, args, blk) + return found } - // No callable superclass method. MRI routes a failed super through - // method_missing: a user-defined one handles it, while the default (on - // BasicObject) reports the super-specific message below. Dispatch only a - // genuine override so the default path keeps that exact message. - if mm := lookupMethod(vm.classOf(self), "method_missing"); mm != nil && !mm.undefined && mm.owner != vm.cBasicObject { - mmArgs := append([]object.Value{object.SymVal(methodName)}, args...) - return vm.invoke(mm, self, mmArgs, blk) - } - raise("NoMethodError", "super: no superclass method '%s'", methodName) - return object.NilV + return nil } From 85b90fb783da78ba9b8eb715b98cd33ef45b2123 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 23 Sep 2026 14:24:38 +0200 Subject: [PATCH 2/6] lang: defined? method visibility, respond_to_missing?, void context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read for this change: vm_defined's DEFINED_FUNC / DEFINED_METHOD arms (vm_insnhelper.c v3_4_0:5472-5498), defined_expr0's method-dispatch case (compile.c 6072-6119) and NODE_DEFINED in iseq_compile_each0 (compile.c 11332-11336). MRI keeps two method probes apart and rbgo had one. DEFINED_FUNC — no receiver written — is rb_ec_obj_respond_to with the include-private flag, so a private method called bare is "method". DEFINED_METHOD — a receiver written, `self.` included — screens the method entry's visibility: private is not defined?, protected only when the calling self is a kind of the method's owner. OpDefinedMethod's B operand now carries which one, and the explicit form asks visBlockedKind, the same helper an explicit-receiver send uses, rather than restating the rule. `defined?(Object.print)` and `defined?(obj.protected_method)` were "method" and are now nil. Neither probe consulted respond_to_missing?, so a proxy serving a method through method_missing read as undefined; MRI reaches it through check_respond_to_missing (5496) and rb_obj_respond_to's own fallback. The operator fast path over-claimed. Arithmetic operators have no method-table entry in rbgo, so respondsTo answers for them directly — but it answered for EVERY receiver, and `defined?(nil / 2)` was "method" while `nil / 2` raises NoMethodError here exactly as in MRI. The fallback is now taken only for a receiver the fast path computes on; nil, true/false, a Symbol and a user object with no built-in backing go to the respond_to_missing? path, which is where MRI sends them. TestDefinedOperatorMethod asserted the over-claim for a plain object; MRI 4.0.5 prints nil for `defined?(Plain.new + 1)`, so the expectation is corrected to that, with the witness recorded. A `defined?` whose value is discarded compiled and ran its receiver. MRI compiles NODE_DEFINED to nothing when popped, and ruby/spec pins it: a bare `defined?(DefinedSpecs.side_effects / 2)` statement must not run side_effects. The operand's locals are still declared, because MRI declares them in the parser whatever the context does with the answer. A nested `defined?(defined?(x))` probed self for a method named "defined?" and answered nil; NODE_DEFINED is not one of defined_expr0's cases, so it falls through to "expression". language/defined_spec.rb 250 -> 255 passing. Measured over language/ AND core/ (2206 files): 21788 -> 21793, no file loses an example. Co-Authored-By: Claude Opus 5 --- internal/compiler/compiler.go | 22 +++++++++++ internal/compiler/defined.go | 19 ++++++++- internal/vm/defined.go | 64 +++++++++++++++++++++++++++++++ internal/vm/puppet_batch2_test.go | 16 ++++++-- internal/vm/vm.go | 5 ++- 5 files changed, 120 insertions(+), 6 deletions(-) diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 0ab0e600..8b473837 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -380,6 +380,9 @@ func (c *Compiler) compileBody(body []ast.Node) { return } for i, n := range body { + if i < len(body)-1 && c.compileDiscarded(n) { + continue + } c.compileNode(n) if i < len(body)-1 { c.cur().emit(bytecode.OpPop, 0, 0) @@ -387,6 +390,25 @@ func (c *Compiler) compileBody(body []ast.Node) { } } +// compileDiscarded emits n in a position where its value is thrown away, and +// reports whether it handled n (the caller emits it normally otherwise). +// +// Only `defined?` is special here. MRI compiles NODE_DEFINED to NOTHING when +// its result is popped — `case NODE_DEFINED: if (!popped) …` (compile.c +// v3_4_0:11332-11336) — and ruby/spec pins the consequence: a bare +// `defined?(DefinedSpecs.side_effects / 2)` statement must not run the +// receiver, even though the value-returning form does. The operand's LOCALS are +// still declared, because MRI declares them in the parser, which walks the +// operand whatever the context does with the answer. +func (c *Compiler) compileDiscarded(n ast.Node) bool { + v, ok := n.(*ast.Call) + if !ok || v.Recv != nil || v.Block != nil || v.Name != "defined?" || len(v.Args) != 1 { + return false + } + c.declareDefinedLocals(v.Args[0]) + return true +} + func (c *Compiler) compileNode(n ast.Node) { b := c.cur() switch v := n.(type) { diff --git a/internal/compiler/defined.go b/internal/compiler/defined.go index 39e534ca..5b7a9d02 100644 --- a/internal/compiler/defined.go +++ b/internal/compiler/defined.go @@ -183,6 +183,16 @@ func (c *Compiler) compileDefinedScopedConst(v *ast.ScopedConst) { // implicit) receiver, with the receiver and arguments evaluated. func (c *Compiler) compileDefinedCall(v *ast.Call) { b := c.cur() + // A nested `defined?` is not a method call on self: MRI parses it to + // NODE_DEFINED, which is not among the cases defined_expr0 names and so + // falls through to its `default:` arm — "expression" (compile.c + // v3_4_0:6003-6008). The operand's locals are still declared, as compiling it + // normally would do. + if v.Recv == nil && v.Block == nil && v.Name == "defined?" && len(v.Args) == 1 { + c.declareDefinedLocals(v.Args[0]) + c.pushDefinedTag(bytecode.DefinedExprTag) + return + } if v.Recv == nil && v.Block == nil && len(v.Args) == 0 { if _, _, ok := b.resolve(v.Name); ok { c.pushDefinedTag(bytecode.DefinedLvar) @@ -260,6 +270,9 @@ func constOrAssign(v *ast.BinaryExpr) bool { func (c *Compiler) compileDefinedReceiverMethod(recv ast.Node, name string) { c.guarded(func(gb *builder) { if recv == nil { + // No receiver written: MRI's DEFINED_FUNC, which tests the response at + // ANY visibility (rb_ec_obj_respond_to with the include-private flag), + // so a private method called bare is still "method". B = 0 says so. gb.emit(bytecode.OpPushSelf, 0, 0) gb.emit(bytecode.OpDefinedMethod, gb.addName(name), 0) return @@ -268,7 +281,11 @@ func (c *Compiler) compileDefinedReceiverMethod(recv ast.Node, name string) { c.compileDefined(recv) recvUndef := gb.emit(bytecode.OpBranchNil, 0, 0) c.compileNode(recv) - gb.emit(bytecode.OpDefinedMethod, gb.addName(name), 0) + // A receiver was written: MRI's DEFINED_METHOD, which screens the method + // entry's visibility — private is not defined?, protected only for a kin + // self (compile.c v3_4_0:6099-6111 picks the tag; vm_insnhelper.c + // v3_4_0:5476-5498 applies the rule). B = 1 says so. + gb.emit(bytecode.OpDefinedMethod, gb.addName(name), 1) done := gb.emit(bytecode.OpJump, 0, 0) gb.patch(recvUndef, gb.here()) gb.emit(bytecode.OpPushNil, 0, 0) diff --git a/internal/vm/defined.go b/internal/vm/defined.go index 5e790af0..1fa6a765 100644 --- a/internal/vm/defined.go +++ b/internal/vm/defined.go @@ -112,6 +112,70 @@ func (vm *VM) respondsTo(recv object.Value, name string) bool { return false } +// definedResponds answers the two `defined?` method probes, which MRI keeps +// apart (vm_insnhelper.c v3_4_0:5472-5498): +// +// - DEFINED_FUNC, an implicit receiver (`defined?(foo)`), is +// rb_ec_obj_respond_to(..., TRUE): ANY visibility counts, so a private +// method called without a receiver is "method". +// - DEFINED_METHOD, an explicit receiver (`defined?(obj.foo)`), inspects the +// method entry's visibility: private is NOT defined?, and protected only +// when the calling self is a kind of the method's owner. That is the same +// rule an explicit-receiver SEND enforces, so it is asked of the same +// helper (visBlockedKind) rather than restated here. +// +// Both fall back to respond_to_missing? when there is no method entry at all +// (check_respond_to_missing in the DEFINED_METHOD arm, and rb_obj_respond_to's +// own fallback in the DEFINED_FUNC one), which is how a proxy that answers +// through method_missing reports as defined. +// +// caller is the self of the frame the defined? was written in. +func (vm *VM) definedResponds(caller, recv object.Value, name string, explicit bool) bool { + if m := vm.findMethod(recv, name); m != nil { + if !explicit { + return true + } + return vm.visBlockedKind(recv, name, m, caller) == visPublic + } + // The arithmetic operators are a compiler fast path in rbgo, not method-table + // entries, so a receiver that HAS them answers nothing above. They are still + // methods in MRI, hence the fallback — but only for a receiver the fast path + // actually computes on: `nil / 2` raises NoMethodError here exactly as in + // MRI, so `defined?(nil / 2)` must be nil rather than "method". + if _, ok := operatorOpcode(name); ok && hasInlineArith(recv) { + return true + } + return vm.respondToMissing(recv, name) +} + +// hasInlineArith reports whether the arithmetic fast path would compute for +// recv rather than raise NoMethodError. It names the receivers it EXCLUDES, +// because the fast path's own fallback (arith.go's binary) covers the built-in +// value types generically: nil, true/false and a Symbol have no arithmetic in +// any Ruby, and a user object with no built-in backing that reached here has no +// operator method either, so it belongs to the respond_to_missing? path — which +// is where MRI sends it too. +func hasInlineArith(recv object.Value) bool { + switch o := recv.(type) { + case object.Nil, object.Bool, object.Symbol: + return false + case *RObject: + return !object.IsNil(o.builtin) + } + return true +} + +// respondToMissing runs MRI's check_respond_to_missing: it asks recv's +// respond_to_missing?(name, true), which is how an object that serves a method +// through method_missing reports it as defined. A receiver with no override +// answers false through Object#respond_to_missing?. +func (vm *VM) respondToMissing(recv object.Value, name string) bool { + if vm.findMethod(recv, "respond_to_missing?") == nil { + return false + } + return vm.send(recv, "respond_to_missing?", []object.Value{object.Symbol(name), object.True}, nil).Truthy() +} + // runDefinedGuard executes a `defined?` guard child ISeq sharing the enclosing // frame's scope (parentEnv), self, definee and block, mapping any raise inside // to nil. The child always leaves exactly one value via OpReturn. diff --git a/internal/vm/puppet_batch2_test.go b/internal/vm/puppet_batch2_test.go index a25c0f38..08289565 100644 --- a/internal/vm/puppet_batch2_test.go +++ b/internal/vm/puppet_batch2_test.go @@ -116,12 +116,20 @@ func TestEmptyKwSplatDrop(t *testing.T) { } } -// TestDefinedOperatorMethod covers defined?(recv op arg) routing an operator name -// with no method-table entry (a plain object) through operatorOpcode, including %. +// TestDefinedOperatorMethod covers defined?(recv op arg) for an operator name +// with no method-table entry, including %. +// +// A PLAIN object answers nil, not "method". The two cases below asserted +// "method" while defined? took rbgo's operator fast path for every receiver; +// MRI is the witness that this was wrong — `class Plain; end; +// p defined?(Plain.new + 1)` prints nil under ruby 4.0.5, because +// DEFINED_METHOD finds no method entry and Object#respond_to_missing? answers +// false (vm_insnhelper.c v3_4_0:5476-5498). `!=` differs: it is a real +// BasicObject method, so it is found and reported. func TestDefinedOperatorMethod(t *testing.T) { cases := []struct{ src, want string }{ - {`class Plain; end; p defined?(Plain.new % 1)`, "\"method\"\n"}, - {`class Plain; end; p defined?(Plain.new + 1)`, "\"method\"\n"}, + {`class Plain; end; p defined?(Plain.new % 1)`, "nil\n"}, + {`class Plain; end; p defined?(Plain.new + 1)`, "nil\n"}, {`class Plain; end; p defined?(Plain.new != 1)`, "\"method\"\n"}, // Unary ~/+@/-@ resolve as real numeric methods, so defined? reports them. {`p defined?(5.~)`, "\"method\"\n"}, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index cd4b9e9c..a629b182 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -2026,8 +2026,11 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, push(object.NilV) } case bytecode.OpDefinedMethod: + // B == 1 is MRI's DEFINED_METHOD (an explicit receiver, so visibility + // applies); B == 0 is DEFINED_FUNC (an implicit self receiver, where + // every visibility counts). See definedResponds. recv := pop() - if vm.respondsTo(recv, iseq.Names[in.A]) { + if vm.definedResponds(self, recv, iseq.Names[in.A], in.B == 1) { push(definedTag(bytecode.DefinedMethod)) } else { push(object.NilV) From 757eab8c2156ca79ab4b1411af80d5666b5e62cd Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 23 Sep 2026 14:33:55 +0200 Subject: [PATCH 3/6] lang: a block's auto-splat converts with #to_ary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A block taking several parameters and handed ONE argument splats it. rbgo tested the argument with a type assertion, so only a real Array was splatted. MRI runs it through rb_check_array_type — args_check_block_arg0 (vm_args.c v3_4_0:97-119), reached from setup_parameters_complex's arg_setup_block arm (838-852) and from vm_callee_setup_block_arg_arg0_check for a simple block (vm_insnhelper.c v3_4_0:5183-5193). That conversion: - consults an OVERRIDDEN #respond_to? before calling #to_ary, - raises TypeError when #to_ary answers with a non-Array, - lets an exception from #to_ary out untouched, - and DECLINES — leaving the object whole — when there is no #to_ary or it returns nil. masgnExpandOperand is already that rb_check_array_type, written for expandarray, down to the respond_to? rule; its one-element [v] result on a decline is exactly "do not splat", so the auto-splat now asks it rather than restating the conversion. language/block_spec.rb 139 -> 152 passing. Measured over language/ AND core/ (2206 files): 21793 -> 21806, no file loses an example. Noted while measuring, NOT caused by this change and not in this lane: core/module/autoload_spec.rb aborts in about 1 run in 10, on this commit and on its parent alike (12 runs each), with `panic: runtime error: slice bounds out of range [:-1]` repanicking as `interface conversion: runtime.boundsError, not vm.RubyError`. It comes from the concurrent autoload examples; an aborted run contributes 0 passing examples, so it can look like a 57-example regression in a single measurement. Co-Authored-By: Claude Opus 5 --- internal/vm/object_model.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index a82aadeb..7025c0d2 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -2171,9 +2171,16 @@ func (vm *VM) bindBlockArgs(p *Proc, args []object.Value) []object.Value { func (vm *VM) bindBlockPositionals(p *Proc, args []object.Value) []object.Value { np := len(p.iseq.Params) if np > 1 && len(args) == 1 { - if arr, ok := args[0].(*object.Array); ok { - args = arr.Elems - } + // The auto-splat is not a type test on the lone argument: MRI runs it + // through rb_check_array_type (args_check_block_arg0, vm_args.c + // v3_4_0:97-119), which converts a non-Array with #to_ary, consulting an + // OVERRIDDEN #respond_to? first, raising TypeError when #to_ary answers + // with a non-Array, and declining — leaving the object whole — when there + // is no #to_ary or it returns nil. masgnExpandOperand is that same + // rb_check_array_type, already written for expandarray: it yields the + // elements on a successful conversion and the one-element list [v] on a + // decline, which is exactly "do not splat". + args = vm.masgnExpandOperand(args[0]) } if p.iseq.SplatIndex >= 0 { // A *rest block param has variable arity: pad up to the required From f365610f1ca561062e5c9e3bceb5564ff2ad5976 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 23 Sep 2026 14:43:46 +0200 Subject: [PATCH 4/6] =?UTF-8?q?lang:=20pattern=20matching=20=E2=80=94=20co?= =?UTF-8?q?nstant=20=3D=3D=3D,=20deconstruct=20contract,=20one=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read for this change: iseq_compile_pattern_each's NODE_ARYPTN, NODE_FNDPTN and NODE_HSHPTN arms (compile.c v3_4_0:7067-7600), with iseq_compile_pattern_constant (7054+) and iseq_compile_array_deconstruct (7055+). A container pattern's constant was tested with `subject.is_a?(Const)`. MRI compiles it to `checkmatch VM_CHECKMATCH_TYPE_CASE` over the constant — Module#=== — so a redefined (or refined) #=== decides the match; a bare constant pattern is the same. Both now use `===`. A hash pattern IGNORED its constant entirely: `in Point(x:, y:)` matched any object answering #deconstruct_keys. The guard is now emitted, and FIRST, before the protocol call, which is what ruby/spec's "checks Constant === object before calling #deconstruct_keys" pins. #deconstruct_keys was always passed nil. The argument names the keys the pattern will ask for, so the subject may build only those. Witnessed against ruby 4.0.5 for all four shapes and matching the three "passes keys …" examples: the key list for a pattern with no rest and for an anonymous `**`, nil for a named `**rest` and for `**nil`. A #deconstruct returning a non-Array (or #deconstruct_keys a non-Hash) was treated as a failed match, so the next `in` clause ran. MRI raises TypeError from its type_error arm (compile.c 7224, 7396, 7587). rbgo has no raw type-check opcode, so the test is `is_a?` where MRI uses `checktype`; the two differ only for an object redefining #is_a?. #deconstruct was called once per clause. MRI memoises it for the whole `case` — the clauses share a subject — in a slot with three states: nil not tried, false does not answer #deconstruct, else the Array (iseq_compile_array_deconstruct's use_deconstructed_cache). The cache reaches top-level array and find patterns and their alternatives only; a nested pattern matches an ELEMENT, not the subject. It is reset at the head of the statement, because a `case` in a loop runs again in the same frame with a different subject. language/pattern_matching_spec.rb 95 -> 102 passing. Measured over language/ AND core/: 21806 -> 21812, no file loses an example (core/random/bytes_spec.rb varies by one between runs on this commit and its parent alike). Co-Authored-By: Claude Opus 5 --- internal/compiler/compiler.go | 206 ++++++++++++++++++++++++++-------- 1 file changed, 162 insertions(+), 44 deletions(-) diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 8b473837..506fad9f 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -199,6 +199,10 @@ type Compiler struct { // (and index arguments) were evaluated into before the right-hand side ran. // See preEvalMasgnTarget. masgnPre map[ast.Node]*masgnPreEval + // patCache is the local slot holding the current `case/in` statement's + // deconstructed-subject cache, or -1 outside one (the zero value is fixed up + // in compilePattern, which never sees slot 0 as a cache). See compileCaseIn. + patCache int } // masgnPreEval holds the temporaries into which one multiple-assignment target's @@ -232,7 +236,7 @@ func CompileWithEncoding(prog *ast.Program, srcEnc string) (iseq *bytecode.ISeq, iseq, err = nil, r.(compileError) } }() - c := &Compiler{srcEnc: srcEnc} + c := &Compiler{srcEnc: srcEnc, patCache: -1} c.push(newBuilder("
", nil)) c.compileBody(prog.Body) c.cur().emit(bytecode.OpReturn, 0, 0) @@ -344,7 +348,7 @@ func CompileWithLocals(prog *ast.Program, localNames []string) (iseq *bytecode.I iseq, err = nil, r.(compileError) } }() - c := &Compiler{} + c := &Compiler{patCache: -1} parent := newBuilder("", nil) parent.locals = append([]string(nil), localNames...) parent.borrowed = true @@ -1945,9 +1949,26 @@ func (c *Compiler) compileCaseIn(v *ast.CaseIn) { b.emit(bytecode.OpSetLocal, subj, 0) b.emit(bytecode.OpPop, 0, 0) + // One deconstructed-subject cache for the whole statement. MRI keeps it on + // the stack beside the subject and threads `use_deconstructed_cache` into the + // TOP-LEVEL patterns only (iseq_compile_array_deconstruct, compile.c + // v3_4_0:7055+); the clauses of one `case` share a subject, so the first + // #deconstruct answers for all of them, and ruby/spec pins the single call. + // + // Three states, as in MRI: nil = not tried, false = the subject does not + // answer #deconstruct, an Array = the deconstruction. It is reset here rather + // than relying on frame-entry zeroing, because a `case` inside a loop runs + // again in the same frame with a different subject. + cache := b.localSlot("") + b.emit(bytecode.OpPushNil, 0, 0) + b.emit(bytecode.OpSetLocal, cache, 0) + b.emit(bytecode.OpPop, 0, 0) + var endJumps []int for _, clause := range v.Clauses { + c.patCache = cache c.compilePattern(clause.Pattern, subj) + c.patCache = -1 if clause.Guard != nil { // Pattern match AND guard: only evaluate the guard when the pattern // matched (the pattern's bindings are visible to the guard). @@ -1991,6 +2012,15 @@ func (c *Compiler) compileCaseIn(v *ast.CaseIn) { // bindings as a side effect. func (c *Compiler) compilePattern(pat ast.Pattern, subj int) { b := c.cur() + // The deconstructed-subject cache reaches a TOP-LEVEL array/find pattern and + // its alternatives, and nothing nested inside one: a nested pattern is + // matched against an ELEMENT, not the subject. MRI expresses the same by + // passing use_deconstructed_cache = false down every recursive call but the + // alternative arms'. + cache := c.patCache + if _, isAlt := pat.(*ast.AltPattern); !isAlt { + c.patCache = -1 + } switch p := pat.(type) { case *ast.BindPattern: // Bind the whole subject; always matches. @@ -2005,10 +2035,13 @@ func (c *Compiler) compilePattern(pat ast.Pattern, subj int) { b.emit(bytecode.OpSend, b.addName("==="), 1) b.emit(bytecode.OpTruthy, 0, 0) case *ast.ConstPattern: - // subject.is_a?(Const) - b.emit(bytecode.OpGetLocal, subj, 0) + // Const === subject, not subject.is_a?(Const): a bare constant pattern is + // compiled like a value pattern, `checkmatch VM_CHECKMATCH_TYPE_CASE` + // (compile.c v3_4_0, iseq_compile_pattern_each's default arm), so a + // redefined — or refined — #=== decides the match. c.compileNode(p.Const) - b.emit(bytecode.OpSend, b.addName("is_a?"), 1) + b.emit(bytecode.OpGetLocal, subj, 0) + b.emit(bytecode.OpSend, b.addName("==="), 1) b.emit(bytecode.OpTruthy, 0, 0) case *ast.BindingPattern: // Match the sub-pattern, then bind the subject to name on success. @@ -2023,11 +2056,11 @@ func (c *Compiler) compilePattern(pat ast.Pattern, subj int) { b.emit(bytecode.OpPushFalse, 0, 0) b.patch(end, b.here()) case *ast.ArrayPattern: - c.compileArrayPattern(p, subj) + c.compileArrayPattern(p, subj, cache) case *ast.HashPattern: c.compileHashPattern(p, subj) case *ast.FindPattern: - c.compileFindPattern(p, subj) + c.compileFindPattern(p, subj, cache) case *ast.AltPattern: // Alternative: true if any branch matches (short-circuit on the first). var hits []int @@ -2050,29 +2083,15 @@ func (c *Compiler) compilePattern(pat ast.Pattern, subj int) { // compileArrayPattern emits the deconstruct-protocol match for an array pattern. // It checks the optional constant, that the subject responds to :deconstruct, // the resulting Array's length, then each element against its sub-pattern. -func (c *Compiler) compileArrayPattern(p *ast.ArrayPattern, subj int) { +func (c *Compiler) compileArrayPattern(p *ast.ArrayPattern, subj, cache int) { b := c.cur() arr := b.localSlot("") // the deconstructed Array // Result accumulator: start true, AND each test, short-circuiting via jumps // to a shared failure label. var fails []int - // Optional constant guard: subject.is_a?(Const). - if p.Const != nil { - b.emit(bytecode.OpGetLocal, subj, 0) - c.compileNode(p.Const) - b.emit(bytecode.OpSend, b.addName("is_a?"), 1) - fails = append(fails, b.emit(bytecode.OpBranchUnless, 0, 0)) - } - // respond_to?(:deconstruct) - b.emit(bytecode.OpGetLocal, subj, 0) - b.emit(bytecode.OpPushConst, b.addConst(object.Symbol("deconstruct")), 0) - b.emit(bytecode.OpSend, b.addName("respond_to?"), 1) - fails = append(fails, b.emit(bytecode.OpBranchUnless, 0, 0)) - // arr = subject.deconstruct - b.emit(bytecode.OpGetLocal, subj, 0) - b.emit(bytecode.OpSend, b.addName("deconstruct"), 0) - b.emit(bytecode.OpSetLocal, arr, 0) - b.emit(bytecode.OpPop, 0, 0) + // Optional constant guard, FIRST and before any protocol call. + fails = append(fails, c.patternConstantGuard(p.Const, subj)...) + fails = append(fails, c.deconstructInto(arr, subj, cache)...) // Length check: == (pre+post) without splat, >= (pre+post) with. b.emit(bytecode.OpGetLocal, arr, 0) b.emit(bytecode.OpSend, b.addName("length"), 0) @@ -2141,25 +2160,12 @@ func (c *Compiler) compileArrayPattern(p *ast.ArrayPattern, subj int) { // compileFindPattern emits the array find pattern `[*pre, mid…, *post]`: it // deconstructs the subject, then scans for the first window where every Mid // matches, binding pre/post. It leaves a boolean (matched) on the stack. -func (c *Compiler) compileFindPattern(p *ast.FindPattern, subj int) { +func (c *Compiler) compileFindPattern(p *ast.FindPattern, subj, cache int) { b := c.cur() arr := b.localSlot("") var fails []int - if p.Const != nil { - b.emit(bytecode.OpGetLocal, subj, 0) - c.compileNode(p.Const) - b.emit(bytecode.OpSend, b.addName("is_a?"), 1) - fails = append(fails, b.emit(bytecode.OpBranchUnless, 0, 0)) - } - // respond_to?(:deconstruct), then arr = subject.deconstruct. - b.emit(bytecode.OpGetLocal, subj, 0) - b.emit(bytecode.OpPushConst, b.addConst(object.Symbol("deconstruct")), 0) - b.emit(bytecode.OpSend, b.addName("respond_to?"), 1) - fails = append(fails, b.emit(bytecode.OpBranchUnless, 0, 0)) - b.emit(bytecode.OpGetLocal, subj, 0) - b.emit(bytecode.OpSend, b.addName("deconstruct"), 0) - b.emit(bytecode.OpSetLocal, arr, 0) - b.emit(bytecode.OpPop, 0, 0) + fails = append(fails, c.patternConstantGuard(p.Const, subj)...) + fails = append(fails, c.deconstructInto(arr, subj, cache)...) // n = arr.length n := b.localSlot("") b.emit(bytecode.OpGetLocal, arr, 0) @@ -2251,22 +2257,134 @@ func (c *Compiler) setLocalInt(slot int, v int64) { b.emit(bytecode.OpPop, 0, 0) } +// deconstructInto emits the #deconstruct protocol for an array-shaped pattern, +// leaving the deconstructed Array in slot arr. It returns the branch sites to +// patch to the pattern's failure label. +// +// It mirrors iseq_compile_array_deconstruct (compile.c v3_4_0:7055+): +// respond_to?(:deconstruct), the call, and `unless Array === d` -> TypeError, +// because a #deconstruct answering with something else is an error, not a +// failed match. When cache >= 0 the result is memoised in that slot for the +// rest of the `case`, with MRI's three states — nil not tried, false does not +// answer #deconstruct, else the Array. +func (c *Compiler) deconstructInto(arr, subj, cache int) []int { + b := c.cur() + var fails []int + reuse := -1 + if cache >= 0 { + b.emit(bytecode.OpGetLocal, cache, 0) + fresh := b.emit(bytecode.OpBranchNil, 0, 0) // nil: not tried yet + b.emit(bytecode.OpGetLocal, cache, 0) + fails = append(fails, b.emit(bytecode.OpBranchUnless, 0, 0)) // false: no #deconstruct + b.emit(bytecode.OpGetLocal, cache, 0) + b.emit(bytecode.OpSetLocal, arr, 0) + b.emit(bytecode.OpPop, 0, 0) + reuse = b.emit(bytecode.OpJump, 0, 0) + b.patch(fresh, b.here()) + } + // respond_to?(:deconstruct) + b.emit(bytecode.OpGetLocal, subj, 0) + b.emit(bytecode.OpPushConst, b.addConst(object.Symbol("deconstruct")), 0) + b.emit(bytecode.OpSend, b.addName("respond_to?"), 1) + if cache >= 0 { + // Remember the answer. A false stays; a true is overwritten by the Array + // below. OpSetLocal leaves the value, which the branch then consumes. + b.emit(bytecode.OpSetLocal, cache, 0) + } + fails = append(fails, b.emit(bytecode.OpBranchUnless, 0, 0)) + // arr = subject.deconstruct + b.emit(bytecode.OpGetLocal, subj, 0) + b.emit(bytecode.OpSend, b.addName("deconstruct"), 0) + b.emit(bytecode.OpSetLocal, arr, 0) + b.emit(bytecode.OpPop, 0, 0) + c.patternTypeCheck(arr, "Array", "deconstruct must return Array") + if cache >= 0 { + b.emit(bytecode.OpGetLocal, arr, 0) + b.emit(bytecode.OpSetLocal, cache, 0) + b.emit(bytecode.OpPop, 0, 0) + b.patch(reuse, b.here()) // the cached path rejoins past the type check + } + return fails +} + +// patternConstantGuard emits the optional constant test a container pattern +// carries (`in Point[x, y]`, `in Point(x:, y:)`). It returns the branch sites to +// patch to the pattern's failure label. +// +// The test is `Const === subject`, not `subject.is_a?(Const)`: MRI compiles it +// to `checkmatch VM_CHECKMATCH_TYPE_CASE` over the constant +// (iseq_compile_pattern_constant, compile.c v3_4_0:7054+), which is Module#=== +// — so a constant whose #=== is redefined decides the match, and ruby/spec pins +// that ("does not match object if Constant === object returns false"). +// +// It is emitted BEFORE the deconstruct protocol, which is what +// "checks Constant === object before calling #deconstruct_keys" pins. +func (c *Compiler) patternConstantGuard(konst ast.Node, subj int) []int { + if konst == nil { + return nil + } + b := c.cur() + c.compileNode(konst) + b.emit(bytecode.OpGetLocal, subj, 0) + b.emit(bytecode.OpSend, b.addName("==="), 1) + return []int{b.emit(bytecode.OpBranchUnless, 0, 0)} +} + +// patternTypeCheck emits MRI's type_error arm: the value a deconstruct protocol +// method returned must be of the expected class, and a wrong one is a TypeError +// (compile.c v3_4_0:7224 and 7587), not a failed match that would let the next +// `in` clause run. rbgo has no raw type-check opcode, so the test is +// `value.is_a?(Class)` where MRI uses `checktype`; the two differ only for an +// object that redefines #is_a?. +func (c *Compiler) patternTypeCheck(slot int, className, msg string) { + b := c.cur() + b.emit(bytecode.OpGetLocal, slot, 0) + b.emit(bytecode.OpGetConst, b.addName(className), 0) + b.emit(bytecode.OpSend, b.addName("is_a?"), 1) + ok := b.emit(bytecode.OpBranchIf, 0, 0) + b.emit(bytecode.OpPushSelf, 0, 0) + b.emit(bytecode.OpGetConst, b.addName("TypeError"), 0) + b.emit(bytecode.OpPushConst, b.addConst(object.NewString(msg)), 0) + b.emit(bytecode.OpSend, b.addName("raise"), 2) + b.emit(bytecode.OpPop, 0, 0) + b.patch(ok, b.here()) +} + func (c *Compiler) compileHashPattern(p *ast.HashPattern, subj int) { b := c.cur() h := b.localSlot("") // the deconstructed Hash var fails []int + // Optional constant guard (`in Point(x:, y:)`), FIRST: MRI screens the + // constant before it asks anything of the subject, so a subject the constant + // rejects is never sent #deconstruct_keys at all. + fails = append(fails, c.patternConstantGuard(p.Const, subj)...) // respond_to?(:deconstruct_keys) b.emit(bytecode.OpGetLocal, subj, 0) b.emit(bytecode.OpPushConst, b.addConst(object.Symbol("deconstruct_keys")), 0) b.emit(bytecode.OpSend, b.addName("respond_to?"), 1) fails = append(fails, b.emit(bytecode.OpBranchUnless, 0, 0)) - // h = subject.deconstruct_keys(nil) — we pass nil (full hash) for simplicity, - // which is always a valid request under the protocol. + // h = subject.deconstruct_keys(keys) + // + // The argument tells the subject which keys the pattern will ask for, so it + // may build only those. It is the pattern's key list, EXCEPT when the pattern + // has a named `**rest` or a `**nil` — then the whole hash is wanted and MRI + // passes nil. An anonymous `**` still gets the key list. Witnessed against + // ruby 4.0.5 for all four shapes, and pinned by ruby/spec's three + // "passes keys …" examples. b.emit(bytecode.OpGetLocal, subj, 0) - b.emit(bytecode.OpPushNil, 0, 0) + if p.RestNil || p.RestName != "" { + b.emit(bytecode.OpPushNil, 0, 0) + } else { + for _, key := range p.Keys { + b.emit(bytecode.OpPushConst, b.addConst(object.Symbol(key)), 0) + } + b.emit(bytecode.OpNewArray, len(p.Keys), 0) + } b.emit(bytecode.OpSend, b.addName("deconstruct_keys"), 1) b.emit(bytecode.OpSetLocal, h, 0) b.emit(bytecode.OpPop, 0, 0) + // `unless Hash === d: goto type_error`. + c.patternTypeCheck(h, "Hash", "deconstruct_keys must return Hash") // `**nil`, or the empty pattern `{}`: the hash must have exactly the named // keys and no extras (an empty `{}` thus matches only an empty hash). if p.RestNil || (len(p.Keys) == 0 && !p.HasRest) { From ca5fca886805a97a9bbaaf60e57d5fc6135273dd Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 23 Sep 2026 14:50:33 +0200 Subject: [PATCH 5/6] lang: a backtick literal calls Kernel#` with a frozen String MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cmd` and %x{cmd} ran the shell directly from the opcode, so a redefined `` ` `` never saw them. MRI compiles NODE_XSTR to a CALL — putself, putobject of the command fstring, `send :\`` — which is why language/execution_spec.rb can define a singleton `` ` `` and assert both that it is reached and that its argument is frozen. OpXStr now dispatches that send instead of running the command itself; Kernel#` (kernel_introspect.go) still does the work, so the two spellings cannot drift apart. The String is frozen, as an fstring is — and, now that OpPushConst shares a frozen constant, `.frozen?` reports it. language/execution_spec.rb 0 -> 2 passing. The four that remain need interpolation inside a backtick literal, which go-ruby-parser does not parse: ast.XStr carries a plain Command string, so `echo #{x}` reaches the shell verbatim and `#` starts a comment there. Reported, not forced. Measured over language/ AND core/: 21812 -> 21814, no file loses an example. Co-Authored-By: Claude Opus 5 --- internal/vm/vm.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/vm/vm.go b/internal/vm/vm.go index a629b182..32b166c5 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -2167,7 +2167,13 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, continue } case bytecode.OpXStr: - push(object.NewString(vm.runShellCommand(iseq.Names[in.A]))) + // A backtick / %x{} literal is not a builtin operation: MRI compiles + // it to a CALL of the method `` ` `` on self with the command String + // (NODE_XSTR -> putself, putobject, send :`), so a redefined + // Kernel#` — or a singleton `` ` `` on the object the literal is + // evaluated against — receives it. The String is an fstring, hence + // frozen; language/execution_spec.rb asserts both. + push(vm.send(self, "`", []object.Value{object.NewFrozenStringView(iseq.Names[in.A])}, nil)) case bytecode.OpSplatToArray: push(vm.splatToArray(pop())) case bytecode.OpExpandArray: From 89f8dd8829b91c38e4cc9e4741a5cc01ce661612 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 23 Sep 2026 15:10:46 +0200 Subject: [PATCH 6/6] lang: whitebox tests for the wave-27 compiler and VM changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One test per new branch, so the 100% gate is met by tests rather than by another package's incidental coverage: internal/compiler/wave27_defined_test.go — the tags baked into the literal pool are frozen; defined?(super) is its own opcode and emits no send for the super arguments; a container probes each element while an empty one does not; the three source keywords are not method probes while a near-miss name is; OpDefinedMethod's B operand records whether a receiver was written; a discarded defined? emits nothing but still declares its operand's locals; the container arms of declareDefinedLocals. internal/vm/wave27_lang_test.go — defined?(super) across the shapes (block, define_method, module, undef blocker, class method, refinement, top level, unevaluated arguments); frozen tags for every runtime-decided kind; the visibility rule both ways; the respond_to_missing? fallback; which receivers the operator fast path answers for; the void-context elision; the #to_ary auto-splat and its TypeError and decline paths; the pattern-matching contract (constant ===, the deconstruct_keys key list, both TypeErrors, one #deconstruct per case including the remembered ABSENCE, the nested and looped cases); and the backtick literal reaching a redefined Kernel#`. superMethod's "no method entry" guard is now the only one: OpDefinedSuper called it behind a second, identical check, which made the guard itself unreachable. Asking it once makes a top-level defined?(super) exercise it, and matches MRI, where DEFINED_ZSUPER puts the same question to rb_vm_frame_method_entry before searching. Coverage, measured as CI does (-coverpkg over ./internal/..., whole suite): the functions below 100% are EXACTLY the sixteen that origin/main has on this machine — object.repr, arith.binary/rubyEql/ valueEqlRec, the two bind Inspects, bootstrap, cloneSingleton, registerNumericGeneric, the two converter_codec helpers, io.inspectStr, registerNetHTTP, objSingleton, classOf, ivarTable. Nothing this branch adds or touches is below 100%, and no function dropped. (The darwin total reads 99.9% on main too; the gate is enforced on the POSIX CI lanes.) Co-Authored-By: Claude Opus 5 --- internal/compiler/wave27_defined_test.go | 212 ++++++++++++++++ internal/vm/vm.go | 7 +- internal/vm/wave27_lang_test.go | 305 +++++++++++++++++++++++ 3 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 internal/compiler/wave27_defined_test.go create mode 100644 internal/vm/wave27_lang_test.go diff --git a/internal/compiler/wave27_defined_test.go b/internal/compiler/wave27_defined_test.go new file mode 100644 index 00000000..6295f7d4 --- /dev/null +++ b/internal/compiler/wave27_defined_test.go @@ -0,0 +1,212 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package compiler + +import ( + "testing" + + "github.com/go-embedded-ruby/ruby/internal/bytecode" + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// countOp reports how many times op appears in iseq's instructions. +func countOp(iseq *bytecode.ISeq, op bytecode.Op) int { + n := 0 + for _, in := range iseq.Insns { + if in.Op == op { + n++ + } + } + return n +} + +// definedTagConsts collects the String constants of an ISeq's pool, which for a +// `defined?` lowering are exactly the tags the compiler decided statically. +func definedTagConsts(iseq *bytecode.ISeq) []*object.String { + var out []*object.String + for _, c := range iseq.Consts { + if s, ok := c.(*object.String); ok { + out = append(out, s) + } + } + return out +} + +// TestDefinedTagsAreFrozenConstants covers pushDefinedTag: every tag the +// compiler bakes into the literal pool is built by bytecode.DefinedTag and is +// therefore FROZEN, as MRI's rb_iseq_defined_string (an fstring) is. +func TestDefinedTagsAreFrozenConstants(t *testing.T) { + t.Parallel() + for _, src := range []string{ + `defined?(nil)`, `defined?(true)`, `defined?(false)`, `defined?(self)`, + `defined?(1)`, `defined?(x = 1)`, `x = 1; defined?(x)`, + `defined?(__FILE__)`, `defined?(__LINE__)`, `defined?(__ENCODING__)`, + `defined?(defined?(y))`, `defined?([])`, `defined?([1, 2])`, + `defined?({1 => 2})`, + } { + iseq := compileSrc(t, src) + tags := definedTagConsts(iseq) + if len(tags) == 0 { + t.Fatalf("%s: no String constant in the pool, expected a defined? tag", src) + } + for _, s := range tags { + if !s.Frozen { + t.Errorf("%s: tag %q is not frozen", src, s.Str()) + } + } + } +} + +// TestDefinedSuperEmitsItsOwnOpcode covers the *ast.Super arm of compileDefined: +// `defined?(super)` is DEFINED_ZSUPER, an opcode of its own, and it must NOT +// evaluate the super arguments — so no send for them is emitted either. +func TestDefinedSuperEmitsItsOwnOpcode(t *testing.T) { + t.Parallel() + for _, src := range []string{ + `def m; defined?(super); end`, + `def m(a); defined?(super(a)); end`, + `def m(a); defined?(super(raise("never"))); end`, + } { + iseq := compileSrc(t, src) + if len(iseq.Children) != 1 { + t.Fatalf("%s: want one child ISeq, got %d", src, len(iseq.Children)) + } + body := iseq.Children[0] + if got := countOp(body, bytecode.OpDefinedSuper); got != 1 { + t.Errorf("%s: OpDefinedSuper count = %d, want 1", src, got) + } + if got := countOp(body, bytecode.OpSend); got != 0 { + t.Errorf("%s: emitted %d sends, want 0 (the super arguments are never evaluated)", src, got) + } + } +} + +// TestDefinedContainerProbesEachElement covers compileDefinedElements: a +// non-empty container probes every element and branches away on the first +// undefined one, while an EMPTY one has nothing to probe and is a bare tag push. +func TestDefinedContainerProbesEachElement(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + src string + wantBranch int + }{ + {`defined?([])`, 0}, + {`defined?({})`, 0}, + {`defined?([A])`, 1}, + {`defined?([A, B, C])`, 3}, + {`defined?({A => B})`, 2}, // a hash probes keys AND values + // The array probes its one element, and that element — a splat — probes + // the splatted expression in turn: two nested probes. + {`defined?([*A])`, 2}, + } { + iseq := compileSrc(t, tc.src) + if got := countOp(iseq, bytecode.OpBranchNil); got != tc.wantBranch { + t.Errorf("%s: OpBranchNil count = %d, want %d", tc.src, got, tc.wantBranch) + } + } +} + +// TestDefinedSourceKeywordsAreNotMethodProbes covers isSourceKeyword and its +// call site: the three source pseudo-variables answer "expression" with a plain +// constant push, while a bare name that is neither a local nor one of them is a +// method probe (a guard child ISeq). +func TestDefinedSourceKeywordsAreNotMethodProbes(t *testing.T) { + t.Parallel() + for _, name := range []string{"__FILE__", "__LINE__", "__ENCODING__"} { + iseq := compileSrc(t, `defined?(`+name+`)`) + if got := countOp(iseq, bytecode.OpDefinedGuard); got != 0 { + t.Errorf("defined?(%s): emitted a method-probe guard", name) + } + } + // A near-miss name (not one of the three) still probes for a method. + iseq := compileSrc(t, `defined?(__method__)`) + if got := countOp(iseq, bytecode.OpDefinedGuard); got != 1 { + t.Errorf("defined?(__method__): OpDefinedGuard count = %d, want 1", got) + } + if isSourceKeyword("__method__") { + t.Error(`isSourceKeyword("__method__") = true, want false`) + } +} + +// TestDefinedMethodProbeRecordsTheReceiverKind covers the B operand of +// OpDefinedMethod: 0 for an implicit receiver (MRI's DEFINED_FUNC, any +// visibility) and 1 for a written one (DEFINED_METHOD, visibility screened). +func TestDefinedMethodProbeRecordsTheReceiverKind(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + src string + want int + }{ + {`defined?(puts)`, 0}, + {`defined?(nil.foo)`, 1}, + {`defined?(self.foo)`, 1}, + } { + iseq := compileSrc(t, tc.src) + if len(iseq.Children) != 1 { + t.Fatalf("%s: want one guard child, got %d", tc.src, len(iseq.Children)) + } + found := false + for _, in := range iseq.Children[0].Insns { + if in.Op != bytecode.OpDefinedMethod { + continue + } + found = true + if in.B != tc.want { + t.Errorf("%s: OpDefinedMethod B = %d, want %d", tc.src, in.B, tc.want) + } + } + if !found { + t.Errorf("%s: no OpDefinedMethod emitted", tc.src) + } + } +} + +// TestDefinedInVoidContextEmitsNothing covers compileDiscarded: MRI compiles +// NODE_DEFINED to nothing when its value is popped, so the operand's receiver is +// never run — but the locals it assigns are still declared. +func TestDefinedInVoidContextEmitsNothing(t *testing.T) { + t.Parallel() + kept := compileSrc(t, `defined?(foo.bar); 1`) + if got := countOp(kept, bytecode.OpDefinedGuard); got != 0 { + t.Errorf("a discarded defined? emitted %d guards, want 0", got) + } + if len(kept.Children) != 0 { + t.Errorf("a discarded defined? built %d child ISeqs, want 0", len(kept.Children)) + } + // The value-returning form in the same position still compiles. + used := compileSrc(t, `x = defined?(foo.bar); 1`) + if got := countOp(used, bytecode.OpDefinedGuard); got != 1 { + t.Errorf("a used defined? emitted %d guards, want 1", got) + } + // A discarded defined? still declares the operand's locals. + decl := compileSrc(t, "defined?(zz = 1)\np 2") + if !hasLocal(decl, "zz") { + t.Error("a discarded defined?(zz = 1) did not declare zz") + } + // A non-defined? statement in a discarded position is untouched. + other := compileSrc(t, `foo; 1`) + if countOp(other, bytecode.OpSend) == 0 { + t.Error("a discarded ordinary call was elided") + } +} + +// TestDefinedDeclaresLocalsInsideContainers covers the container arms of +// declareDefinedLocals: `defined?` never evaluates its operand, but the +// assignments inside one still take a local slot, as they do in MRI's parser. +func TestDefinedDeclaresLocalsInsideContainers(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ src, name string }{ + {"defined?([a = 1])\np 0", "a"}, + {"defined?({(b = 1) => 2})\np 0", "b"}, + {"defined?({1 => (c = 2)})\np 0", "c"}, + {"defined?([*(d = [1])])\np 0", "d"}, + {"defined?(defined?(e = 1))\np 0", "e"}, + } { + iseq := compileSrc(t, tc.src) + if !hasLocal(iseq, tc.name) { + t.Errorf("%s: local %q not declared (locals %v)", tc.src, tc.name, iseq.Locals) + } + } +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 32b166c5..b0a787e3 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -2048,7 +2048,7 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // no method entry — the top level, a class body — answers nil. // homeSuperName/homeSuperDefinee are the same pair the super CALL uses, // so a block and a define_method body answer for their home method. - if homeSuperName != "" && vm.superMethod(self, homeSuperDefinee, homeSuperName) != nil { + if vm.superMethod(self, homeSuperDefinee, homeSuperName) != nil { push(definedTag(bytecode.DefinedZSuper)) } else { push(object.NilV) @@ -2635,7 +2635,10 @@ func (vm *VM) invokeSuper(self object.Value, definee *RClass, methodName string, // defined_class (vm_insnhelper.c v3_4_0:5505-5518), i.e. the same search the // call performs, so the two must not drift apart. func (vm *VM) superMethod(self object.Value, definee *RClass, methodName string) *Method { - if methodName == "" || definee == nil { + // A frame with no method entry — the top level, a class body — has no super + // to find. MRI's DEFINED_ZSUPER asks the same question of + // rb_vm_frame_method_entry before searching (vm_insnhelper.c v3_4_0:5507). + if methodName == "" { return nil } // super inside a refinement method resolves in the refined class only — its diff --git a/internal/vm/wave27_lang_test.go b/internal/vm/wave27_lang_test.go new file mode 100644 index 00000000..945c8f89 --- /dev/null +++ b/internal/vm/wave27_lang_test.go @@ -0,0 +1,305 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import "testing" + +// TestDefinedSuper covers OpDefinedSuper and superMethod: DEFINED_ZSUPER asks +// whether the method `super` would reach exists, without calling it, and answers +// nil when there is none — or when the frame has no method at all. +func TestDefinedSuper(t *testing.T) { + for _, tc := range []struct{ src, want string }{ + // A superclass method exists. + {`class A; def m; end; end + class B < A; def m; p defined?(super); end; end + B.new.m`, "\"super\"\n"}, + // None does. + {`class C; def m; p defined?(super); end; end + C.new.m`, "nil\n"}, + // From a block inside the method: a block is transparent to super. + {`class A2; def m; end; end + class B2 < A2; def m; [1].each { p defined?(super) }; end; end + B2.new.m`, "\"super\"\n"}, + // From a define_method body. + {`class A3; def m; end; end + class B3 < A3; define_method(:m) { p defined?(super) }; end + B3.new.m`, "\"super\"\n"}, + // Through an included module's method, into the including hierarchy. + {`class D; def m; end; end + module M; def m; p defined?(super); end; end + class E < D; include M; end + E.new.m`, "\"super\"\n"}, + // An undef'ed superclass method is a blocker, not a definition. + {`class F; def m; end; end + class G < F; undef_method :m; end + class H < G; def m; p defined?(super); end; end + H.new.m`, "nil\n"}, + // Outside any method there is no method entry to ask about. + {`p defined?(super)`, "nil\n"}, + // The super ARGUMENTS are not evaluated. + {`class A4; def m(x); end; end + class B4 < A4; def m(x); p defined?(super(raise("never"))); end; end + B4.new.m(1)`, "\"super\"\n"}, + // A class-method super. + {`class A5; def self.m; end; end + class B5 < A5; def self.m; p defined?(super); end; end + B5.m`, "\"super\"\n"}, + } { + if got := eval(t, tc.src); got != tc.want { + t.Errorf("src=%q got=%q want=%q", tc.src, got, tc.want) + } + } +} + +// TestDefinedSuperInRefinement covers superMethod's refinement arm: a `super` +// written in a refinement method resolves in the REFINED class alone. +func TestDefinedSuperInRefinement(t *testing.T) { + src := `class RBase; def m; "base"; end; end + module RMod + refine RBase do + def m; p defined?(super); super; end + end + end + using RMod + p RBase.new.m` + if got, want := eval(t, src), "\"super\"\n\"base\"\n"; got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestDefinedTagsAreFrozen covers the runtime half of the tag change: the tags +// the VM decides (constant/method/yield/…) are frozen too, as MRI's +// rb_iseq_defined_string fstrings are, and a frozen String CONSTANT is shared by +// OpPushConst rather than duplicated. +func TestDefinedTagsAreFrozen(t *testing.T) { + src := `p defined?(nil).frozen? + p defined?(self).frozen? + p defined?(String).frozen? + p defined?(puts).frozen? + p defined?(@x = 1).frozen? + $g = 1; p defined?($g).frozen? + @iv = 1; p defined?(@iv).frozen? + class FT; @@cv = 1; def m; defined?(@@cv); end; end + p FT.new.m.frozen? + def fy; defined?(yield); end + p fy { }.frozen? + p defined?(::String).frozen? + p defined?(Kernel::Integer).frozen? + p "plain".frozen?` + want := "true\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\nfalse\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestDefinedMethodVisibility covers definedResponds: DEFINED_FUNC (no receiver +// written) counts any visibility, DEFINED_METHOD (a receiver written) screens +// it, and a protected method answers only for a kin self. +func TestDefinedMethodVisibility(t *testing.T) { + src := `class V + def pub; end + private; def priv; end + protected; def prot; end + public + def probe; [defined?(priv), defined?(self.priv), defined?(prot), defined?(self.prot), defined?(self.pub)]; end + end + p V.new.probe + p defined?(V.new.priv) + p defined?(V.new.prot) + class W < V; def probe2; defined?(V.new.prot); end; end + p W.new.probe2` + want := "[\"method\", nil, \"method\", \"method\", \"method\"]\nnil\nnil\n\"method\"\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestDefinedRespondToMissing covers the respond_to_missing? fallback of both +// probes, and the branch where the receiver has no such hook to ask. +func TestDefinedRespondToMissing(t *testing.T) { + src := `o = Object.new + def o.respond_to_missing?(n, p) = n == :yes + p defined?(o.yes) + p defined?(o.no) + p defined?(Object.new.whatever) + b = BasicObject.new + p defined?(b.whatever)` + want := "\"method\"\nnil\nnil\nnil\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestDefinedOperatorReceiverKind covers hasInlineArith: the arithmetic +// operators have no method-table entry in rbgo, so defined? falls back to the +// fast path — but only for a receiver that path computes on. nil, true/false and +// a Symbol have no arithmetic at all, and a user object with no built-in backing +// belongs to the respond_to_missing? path. +func TestDefinedOperatorReceiverKind(t *testing.T) { + src := `p defined?(1 / 2) + p defined?("a" % 1) + p defined?([1] + [2]) + p defined?(nil / 2) + p defined?(true + 1) + p defined?(:a + 1) + p defined?(Object.new + 1) + class SubStr < String; end + p defined?(SubStr.new("a") + "b")` + want := "\"method\"\n\"method\"\n\"method\"\nnil\nnil\nnil\nnil\n\"method\"\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestRespondsToOperatorFastPath covers the operator arm of respondsTo, the +// VM-internal predicate (distinct from defined?'s) that Object#try and the +// library bindings use to ask whether a receiver answers a name. +func TestRespondsToOperatorFastPath(t *testing.T) { + if got, want := eval(t, `p 1.try(:+, 2)`), "3\n"; got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestDefinedInVoidContextDoesNotRunTheReceiver covers the runtime consequence +// of compileDiscarded. +func TestDefinedInVoidContextDoesNotRunTheReceiver(t *testing.T) { + src := `$ran = false + def se; $ran = true; 1; end + defined?(se / 2) + p $ran + p defined?(se / 2) + p $ran` + want := "false\n\"method\"\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestBlockAutoSplatUsesToAry covers the auto-splat conversion: a lone argument +// handed to a multi-parameter block goes through rb_check_array_type, so #to_ary +// converts, a non-Array answer is a TypeError, and an object that declines stays +// whole. +func TestBlockAutoSplatUsesToAry(t *testing.T) { + for _, tc := range []struct{ src, want string }{ + {`o = Object.new; def o.to_ary = [1, 2] + [o].each { |a, b| p [a, b] }`, "[1, 2]\n"}, + {`o = Object.new + [o].each { |a, b| p [a.class, b] }`, "[Object, nil]\n"}, + {`o = Object.new; def o.to_ary = nil + [o].each { |a, b| p [a.class, b] }`, "[Object, nil]\n"}, + {`o = Object.new; def o.to_ary = 1 + begin; [o].each { |a, b| }; rescue TypeError => e; p e.class; end`, "TypeError\n"}, + {`o = Object.new; def o.to_ary = raise("boom") + begin; [o].each { |a, b| }; rescue RuntimeError => e; p e.message; end`, "\"boom\"\n"}, + {`[[1, 2]].each { |a, b| p [a, b] }`, "[1, 2]\n"}, + {`[[1, 2]].each { |a| p a }`, "[1, 2]\n"}, + } { + if got := eval(t, tc.src); got != tc.want { + t.Errorf("src=%q got=%q want=%q", tc.src, got, tc.want) + } + } +} + +// TestPatternMatchingDeconstructContract covers the pattern-matching changes: +// the constant is tested with ===, before the protocol call; #deconstruct_keys +// is told which keys the pattern wants; a wrong return type is a TypeError; and +// #deconstruct is called once per `case`. +func TestPatternMatchingDeconstructContract(t *testing.T) { + for _, tc := range []struct{ src, want string }{ + // The constant decides through ===, and is asked FIRST. + {`class NoK; def self.===(o) = false; end + o = Object.new; def o.deconstruct_keys(k) = (p :asked; {a: 1}) + case o + in NoK(a: 1) then p :matched + else p :else + end`, ":else\n"}, + {`class YesK; def self.===(o) = true; end + o = Object.new; def o.deconstruct_keys(k) = {a: 1} + case o + in YesK(a: 1) then p :matched + end`, ":matched\n"}, + // The key list reaches #deconstruct_keys. + {`o = Object.new; def o.deconstruct_keys(k) = (p k; {a: 1, b: 2}) + case o + in {a:, b:} then p :ok + end`, "[:a, :b]\n:ok\n"}, + {`o = Object.new; def o.deconstruct_keys(k) = (p k; {a: 1, b: 2}) + case o + in {a:, **rest} then p :ok + end`, "nil\n:ok\n"}, + {`o = Object.new; def o.deconstruct_keys(k) = (p k; {a: 1}) + case o + in {a:, **nil} then p :ok + end`, "nil\n:ok\n"}, + // A wrong return type is a TypeError, not a failed match. + {`class BadD; def deconstruct = 1; end + begin; case BadD.new; in [1]; end; rescue TypeError => e; p e.message; end`, + "\"deconstruct must return Array\"\n"}, + {`class BadK2; def deconstruct_keys(k) = 1; end + begin; case BadK2.new; in {a: 1}; end; rescue TypeError => e; p e.message; end`, + "\"deconstruct_keys must return Hash\"\n"}, + // #deconstruct is asked once for the whole case, across clauses… + {`$n = 0 + o = Object.new; def o.deconstruct = ($n += 1; [0, 1]) + case o + in [1, 2] then p false + in [0, 1] then p true + end + p $n`, "true\n1\n"}, + // …and its ABSENCE is remembered too, so respond_to? is asked once. + {`$n = 0 + o = Object.new + def o.respond_to?(m, p = false) = ($n += 1; false) + case o + in [1] then p :a + in [2] then p :b + else p :else + end + p $n`, ":else\n1\n"}, + // A nested pattern matches an ELEMENT, so it must not read the cache. + {`case [[1, 2], [3, 4]] + in [[1, 2], [3, 4]] then p :nested + end`, ":nested\n"}, + // A case in a loop re-deconstructs each new subject. + {`2.times do |i| + o = Object.new + o.define_singleton_method(:deconstruct) { [i] } + case o + in [0] then p :zero + in [1] then p :one + end + end`, ":zero\n:one\n"}, + // A find pattern takes the same protocol. + {`o = Object.new; def o.deconstruct = [1, 2, 3] + case o + in [*, 2, *] then p :found + end`, ":found\n"}, + // A bare constant pattern goes through === as well. + {`class OnlyEq; def self.===(o) = o == 7; end + case 7 + in OnlyEq then p :seven + end`, ":seven\n"}, + } { + if got := eval(t, tc.src); got != tc.want { + t.Errorf("src=%q got=%q want=%q", tc.src, got, tc.want) + } + } +} + +// TestBacktickLiteralCallsTheMethod covers OpXStr: the literal is a CALL of +// “ ` “ on self, with a frozen String, so a redefinition receives it. +func TestBacktickLiteralCallsTheMethod(t *testing.T) { + src := `runner = Object.new + runner.singleton_class.define_method(:` + "`" + `) do |str| + p [str, str.frozen?] + :from_backtick + end + runner.instance_exec { p ` + "`test command`" + ` } + runner.instance_exec { p %x(other command) }` + want := "[\"test command\", true]\n:from_backtick\n[\"other command\", true]\n:from_backtick\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +}