From 9b8451a84246e52d771a4926b4bad7bce13693b8 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 27 Jun 2026 08:31:29 +0200 Subject: [PATCH] vm+compiler: Tier-0 language primitives to unblock real Ruby apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the language-level primitives that blocked real Ruby applications (notably Puppet) from booting on go-embedded-ruby, found by an empirical probe of `require "puppet"`. defined? Compile `defined?` to a non-evaluating tag computation instead of dispatching it as a method. It now returns the correct MRI tag string ("constant"/"method"/"local-variable"/"instance-variable"/"class variable"/"global-variable"/"yield"/"self"/"nil"/"true"/"false"/ "assignment"/"expression") or nil, never raising — so `defined?(Nope)` is nil and `X = 1 unless defined?(X)` works. Method and receiver forms run inside an OpDefinedGuard child ISeq so an undefined sub-expression maps to nil; control-flow signals propagate. Module authoring directives module_function (no-arg mode flag + named form), private/public/ protected, private_class_method/public_class_method, alias_method, and private_constant/public_constant. Visibility is not enforced by this VM (documented), but the directives validate names and return the MRI value. extend / singleton_class on builtin-backed values A VM-side identity table gives Array/String/Hash/… per-object singleton classes, so `$LOAD_PATH.extend(M)`, `"x".extend(M)` and `define_singleton_method` work on any reference value. send/findMethod/ resolveMethod and the inline cache consult it. Adds Object#singleton_class. Reflection UnboundMethod (Module#instance_method, #bind/#bind_call/#name/#owner/ #arity), Method#unbind, Module#method_defined?, and define_method accepting a Method/UnboundMethod body. Constants RUBY_VERSION/ENGINE/ENGINE_VERSION/PATCHLEVEL/PLATFORM/DESCRIPTION/ COPYRIGHT (engine "ruby" so app code takes the MRI path; version 3.4.1) and File::SEPARATOR/ALT_SEPARATOR/PATH_SEPARATOR/NULL. Kernel caller (best-effort frame list via a per-frame name stack), at_exit (LIFO at normal exit, a raising hook is swallowed), __method__. Re-probing `require "puppet"`: the baseline died in the first required file on `private_class_method`; it now clears puppet/version, puppet/concurrent/synchronized (incl. the `$LOAD_PATH.extend` line) and puppet/error, reaching the puppet/util subsystem — where the next blockers are library/stdlib gaps (Gem, the English stdlib), not language primitives. MRI-parity table tests for every form; full suite green; 100% coverage via the CI gate; gofmt + go vet clean; CGO=0; native + js/wasm build. Co-Authored-By: Claude Opus 4.8 --- CONFORMANCE-RAILS-PUPPET.md | 34 +++ internal/bytecode/bytecode.go | 21 ++ internal/compiler/compiler.go | 6 + internal/compiler/defined.go | 153 ++++++++++++++ internal/vm/builtins.go | 27 ++- internal/vm/defined.go | 91 ++++++++ internal/vm/file.go | 7 + internal/vm/inlinecache.go | 6 +- internal/vm/kernel_introspect.go | 123 +++++++++++ internal/vm/method.go | 4 +- internal/vm/modulefunc.go | 119 +++++++++++ internal/vm/object_model.go | 70 ++++++- internal/vm/reflection.go | 94 +++++++++ internal/vm/singleton.go | 34 +-- internal/vm/tier0_internal_test.go | 43 ++++ internal/vm/tier0_test.go | 323 +++++++++++++++++++++++++++++ internal/vm/vm.go | 88 +++++++- 17 files changed, 1212 insertions(+), 31 deletions(-) create mode 100644 internal/compiler/defined.go create mode 100644 internal/vm/defined.go create mode 100644 internal/vm/kernel_introspect.go create mode 100644 internal/vm/modulefunc.go create mode 100644 internal/vm/reflection.go create mode 100644 internal/vm/tier0_internal_test.go create mode 100644 internal/vm/tier0_test.go diff --git a/CONFORMANCE-RAILS-PUPPET.md b/CONFORMANCE-RAILS-PUPPET.md index 09518919..dba8e9f0 100644 --- a/CONFORMANCE-RAILS-PUPPET.md +++ b/CONFORMANCE-RAILS-PUPPET.md @@ -188,3 +188,37 @@ stdlib, C-extension equivalents, and predefined constants. That is ongoing, unproven work — distinct from the parse + compile acceptance reported above, which establishes that *the language / front-end* is essentially complete, not that any given application boots. + +## Tier-0 language primitives: runtime boot progress (2026-06-27) + +Following the front-end work above, a set of **Tier-0 language primitives** that +blocked `require "puppet"` at runtime were implemented (see the +`feat/tier0-language-primitives` PR): correct non-evaluating `defined?`, +`Module#module_function`, `private`/`public`/`protected` and the +`*_class_method` / `*_constant` directives, `Object#extend` on builtin-backed +receivers + `#singleton_class`, the `UnboundMethod` reflection API +(`instance_method`/`bind`/`bind_call`/`unbind`, `define_method` from a Method), +the `RUBY_*` version/platform constants, the `File::SEPARATOR` family, and +`Kernel#caller`/`#at_exit`/`#__method__` plus `Module#alias_method`. + +Re-probing `require "puppet"` under a freshly built `rbgo` +(`$LOAD_PATH.unshift "/lib"; require "puppet"`): + +| | First blocker | +|---|---| +| **Baseline** (pre-Tier-0) | `NoMethodError: undefined method 'private_class_method'` — dies in the **first** required file (`puppet/version` → `puppet/concurrent/synchronized`) | +| **After Tier-0** | `NameError: uninitialized constant Gem` — clears `puppet/version`, `puppet/concurrent/synchronized` (including the `$LOAD_PATH.extend(...)` line), and `puppet/error`, reaching the `puppet/util` subsystem | + +Per-subsystem `require` (baseline → Tier-0): + +| Subsystem | Baseline | Tier-0 | +|-----------|----------|--------| +| `puppet/version` | `private_class_method` missing | **OK** | +| `puppet/concurrent/synchronized`| `RUBY_PLATFORM` missing | **OK** | +| `puppet/error` | OK | **OK** | +| `puppet/util` | `English` stdlib missing | `English` stdlib missing | + +The language-level blockers are cleared: the require graph advances from the +first subsystem to the second-plus, and the remaining first blockers are +**library/stdlib** concerns (`Gem`/RubyGems, the `English` stdlib) rather than +language primitives — exactly the boundary this work targeted. diff --git a/internal/bytecode/bytecode.go b/internal/bytecode/bytecode.go index 95218b10..94c418ae 100644 --- a/internal/bytecode/bytecode.go +++ b/internal/bytecode/bytecode.go @@ -100,6 +100,19 @@ const ( OpOpenSingletonClass // A = Children index; pops a target, runs the child ISeq with the target's singleton (meta) class as the definee (`class << target`) OpAlias // A = Names index (new name), B = Names index (old name); aliases an existing method (or global variable) on the current definee OpUndef // A = Names index; undefines the named method on the current definee + + // defined? support. Each pushes the matching tag String, or nil when the + // operand is not defined, and never raises. The compiler routes each + // `defined?` operand to the right one by its syntactic kind; method/receiver + // cases run under OpDefinedGuard so an undefined sub-expression yields nil. + OpDefinedConst // A = Names index; "constant" if the top-level constant exists, else nil + OpDefinedScopedConst // A = Names index; pops a module/class, "constant" if it has that constant, else nil + OpDefinedIvar // A = Names index; "instance-variable" if self has @name set, else nil + OpDefinedCVar // A = Names index; "class variable" if @@name is visible in the definee chain, else nil + OpDefinedGVar // A = Names index; "global-variable" if $name is set, else nil + OpDefinedMethod // A = Names index; pops a receiver, "method" if it responds to name, else nil + OpDefinedYield // "yield" if a block was passed to the current method, else nil + OpDefinedGuard // A = Children index; runs the child ISeq; any raise inside maps to nil ) var opNames = map[Op]string{ @@ -129,6 +142,14 @@ var opNames = map[Op]string{ OpOpenSingletonClass: "open_singleton_class", OpAlias: "alias", OpUndef: "undef", + OpDefinedConst: "defined_const", + OpDefinedScopedConst: "defined_scoped_const", + OpDefinedIvar: "defined_ivar", + OpDefinedCVar: "defined_cvar", + OpDefinedGVar: "defined_gvar", + OpDefinedMethod: "defined_method", + OpDefinedYield: "defined_yield", + OpDefinedGuard: "defined_guard", } func (o Op) String() string { diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 6065415f..f03c2e4c 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -738,6 +738,12 @@ func isAnonKwSplat(h *ast.HashLit) bool { func (c *Compiler) compileCall(v *ast.Call) { b := c.cur() + // defined? is an operator, not a method: it inspects its operand's syntactic + // kind without evaluating it for value, so it is lowered specially. + if v.Recv == nil && v.Block == nil && v.Name == "defined?" && len(v.Args) == 1 { + c.compileDefined(v.Args[0]) + return + } if fwdAt := forwardIndex(v.Args); fwdAt >= 0 { c.compileForwardCall(v, fwdAt) return diff --git a/internal/compiler/defined.go b/internal/compiler/defined.go new file mode 100644 index 00000000..5e6ca3bc --- /dev/null +++ b/internal/compiler/defined.go @@ -0,0 +1,153 @@ +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" +) + +// compileDefined lowers `defined?(operand)`. Unlike a normal call it does NOT +// evaluate its operand for its value; it inspects the operand's syntactic kind +// and emits code that pushes the matching MRI tag String, or nil, without ever +// raising a NameError. Method/receiver forms (which MRI does evaluate, to test +// the response) run inside an OpDefinedGuard child so an undefined +// sub-expression maps to nil rather than propagating. +func (c *Compiler) compileDefined(operand ast.Node) { + b := c.cur() + switch v := operand.(type) { + case *ast.NilLit: + c.pushDefinedTag("nil") + case *ast.BoolLit: + if v.Value { + c.pushDefinedTag("true") + } else { + c.pushDefinedTag("false") + } + case *ast.SelfLit: + c.pushDefinedTag("self") + case *ast.Yield: + b.emit(bytecode.OpDefinedYield, 0, 0) + case *ast.IvarRef: + b.emit(bytecode.OpDefinedIvar, b.addName(v.Name), 0) + case *ast.CVarRef: + b.emit(bytecode.OpDefinedCVar, b.addName(v.Name), 0) + case *ast.GVarRef: + b.emit(bytecode.OpDefinedGVar, b.addName(v.Name), 0) + case *ast.ConstRef: + 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") + 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") + case *ast.Call: + c.compileDefinedCall(v) + case *ast.BinaryExpr: + c.compileDefinedBinary(v) + case *ast.UnaryExpr: + // `!x` is a method (`!`) on x; `-x`/`~x` dispatch as methods too. MRI tags + // 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") + } +} + +// unaryMethodName maps a unary operator to the method name MRI checks for. Only +// `-` differs (the `-@` unary-minus method); `!` and `~` are their own names. +func unaryMethodName(op string) string { + if op == "-" { + return "-@" + } + return op +} + +// pushDefinedTag pushes a constant tag String. +func (c *Compiler) pushDefinedTag(tag string) { + b := c.cur() + b.emit(bytecode.OpPushConst, b.addConst(object.NewString(tag)), 0) +} + +// compileDefinedScopedConst handles `defined?(A::B)` and `defined?(::B)`. +// A leading `::B` is just a top-level constant. For `A::B`, the base `A` is +// evaluated (under the guard, so an undefined base yields nil) and probed for +// the trailing constant. +func (c *Compiler) compileDefinedScopedConst(v *ast.ScopedConst) { + b := c.cur() + if v.Recv == nil { // leading `::Name` + b.emit(bytecode.OpDefinedConst, b.addName(v.Name), 0) + return + } + c.guarded(func(gb *builder) { + c.compileNode(v.Recv) + gb.emit(bytecode.OpDefinedScopedConst, gb.addName(v.Name), 0) + }) +} + +// compileDefinedCall handles `defined?(call)`. A bare name that resolves to a +// local is "local-variable"; otherwise it is a method check on the (possibly +// implicit) receiver, with the receiver and arguments evaluated. +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") + return + } + } + c.compileDefinedReceiverMethod(v.Recv, v.Name) +} + +// compileDefinedBinary handles `defined?(a OP b)`. `&&`/`||` are "expression"; +// every other binary operator is a method on the left operand. +func (c *Compiler) compileDefinedBinary(v *ast.BinaryExpr) { + if v.Op == "&&" || v.Op == "||" { + c.pushDefinedTag("expression") + return + } + c.compileDefinedReceiverMethod(v.Left, v.Op) +} + +// compileDefinedReceiverMethod emits, under the guard, the response probe for +// `recv.name`. recv == nil means an implicit self receiver. An explicit receiver +// is itself subject to defined? first (MRI: `defined?(@x.m)` is nil when `@x` is +// undefined, even though nil responds to m), so the receiver's own defined-check +// short-circuits to nil before it is evaluated for the response test. +func (c *Compiler) compileDefinedReceiverMethod(recv ast.Node, name string) { + c.guarded(func(gb *builder) { + if recv == nil { + gb.emit(bytecode.OpPushSelf, 0, 0) + gb.emit(bytecode.OpDefinedMethod, gb.addName(name), 0) + return + } + // Inner defined? on the receiver: nil ⇒ whole expression nil. + c.compileDefined(recv) + recvUndef := gb.emit(bytecode.OpBranchNil, 0, 0) + c.compileNode(recv) + gb.emit(bytecode.OpDefinedMethod, gb.addName(name), 0) + done := gb.emit(bytecode.OpJump, 0, 0) + gb.patch(recvUndef, gb.here()) + gb.emit(bytecode.OpPushNil, 0, 0) + gb.patch(done, gb.here()) + }) +} + +// guarded compiles body into a child ISeq run under OpDefinedGuard: it shares +// the current scope (so locals/ivars/self/yield resolve as in the enclosing +// frame) and any raise inside maps to nil. The child leaves exactly one value. +func (c *Compiler) guarded(body func(gb *builder)) { + parent := c.cur() + c.push(newBlockBuilder("", nil, parent)) + gb := c.cur() + body(gb) + gb.emit(bytecode.OpReturn, 0, 0) + child := c.pop().build() + idx := len(parent.children) + parent.children = append(parent.children, child) + parent.emit(bytecode.OpDefinedGuard, idx, 0) +} diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 4e6cbbd3..03881ca1 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -72,6 +72,10 @@ func (vm *VM) bootstrap() { vm.registerRequire() vm.registerSingleton() vm.registerMethod() + vm.registerModuleExtras() + vm.registerReflection() + vm.registerVersionConstants() + vm.registerKernelIntrospection() vm.registerEncoding() vm.registerStringEncoding() vm.registerJSBridge() // browser DOM/Canvas access (wasm only; a no-op natively) @@ -589,6 +593,26 @@ func (vm *VM) bootstrap() { return vm.classEval(self.(*RClass), blk, args) }) vm.cModule.define("define_method", func(_ *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + cls := self.(*RClass) + name := nameArg(args[0]) + // A Method / UnboundMethod second argument transplants that method's body + // under the new name and owner (Ruby allows define_method(:m, other_method)). + if len(args) > 1 { + switch src := args[1].(type) { + case *BoundMethod: + cm := *src.m + cm.name, cm.owner = name, cls + cls.methods[name] = &cm + bumpMethodSerial() + return object.Symbol(name) + case *UnboundMethod: + cm := *src.m + cm.name, cm.owner = name, cls + cls.methods[name] = &cm + bumpMethodSerial() + return object.Symbol(name) + } + } body := blk if body == nil { if len(args) > 1 { @@ -601,8 +625,7 @@ func (vm *VM) bootstrap() { raise("ArgumentError", "tried to create a method without a block") } } - name := args[0].ToS() - self.(*RClass).methods[name] = &Method{name: name, proc: body, owner: self.(*RClass)} + cls.methods[name] = &Method{name: name, proc: body, owner: cls} bumpMethodSerial() return object.Symbol(name) }) diff --git a/internal/vm/defined.go b/internal/vm/defined.go new file mode 100644 index 00000000..f8991e7d --- /dev/null +++ b/internal/vm/defined.go @@ -0,0 +1,91 @@ +package vm + +import ( + "github.com/go-embedded-ruby/ruby/internal/bytecode" + "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) } + +// hasScopedConst reports whether cls (or its superclass chain, or the flat +// top-level table fallback) defines name — the non-raising form of scopedConst. +func (vm *VM) hasScopedConst(cls *RClass, name string) bool { + for c := cls; c != nil; c = c.super { + if _, ok := c.consts[name]; ok { + return true + } + } + _, ok := vm.consts[name] + return ok +} + +// gvarDefined reports whether a global variable is set. User globals live in +// vm.globals; a handful of regexp specials ($~, $1…, $&, …) are always +// considered defined when a last match exists. Anything else is undefined. +func (vm *VM) gvarDefined(name string) bool { + if _, ok := vm.globals[name]; ok { + return true + } + // $~ is always considered defined (the last-match special exists even before + // any match). The other match specials are defined only once a match exists; + // a numbered group ($1…) is defined only if that group participated. + if name == "$~" { + return true + } + md, haveMatch := vm.lastMatch.(*MatchData) + switch name { + case "$&", "$`", "$'", "$+": + return haveMatch + } + if n, isGroup := gvarGroup(name); isGroup { + return haveMatch && n <= md.md.NGroups() && md.md.Begin(n) >= 0 + } + return false +} + +// respondsTo reports whether recv would answer name through real dispatch — a +// resolvable method (singleton, class chain, modules) or one of the +// compiler-fast-path operators. It backs `defined?(recv.m)` / `defined?(a OP b)` +// and never invokes the method. +func (vm *VM) respondsTo(recv object.Value, name string) bool { + if vm.findMethod(recv, name) != nil { + return true + } + if _, ok := operatorOpcode(name); ok { + return true + } + // `!` is a universal BasicObject method (the compiler implements it as a fast + // path rather than a method table entry), so every object responds to it. + if name == "!" { + return true + } + // Unary `-@`/`+@`/`~` are fast-path ops on the numeric tower rather than + // method-table entries; numbers respond to them as in MRI. + switch name { + case "-@", "+@", "~": + switch recv.(type) { + case object.Integer, *object.Bignum, object.Float, *object.Rational, *object.Complex: + return true + } + } + return false +} + +// 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. +func (vm *VM) runDefinedGuard(child *bytecode.ISeq, self object.Value, definee *RClass, parentEnv *Env, block *Proc) (res object.Value) { + defer func() { + if r := recover(); r != nil { + if _, ok := r.(RubyError); ok { + res = object.NilV + return + } + panic(r) // control-flow signals / internal bugs propagate + } + }() + return vm.exec(child, self, nil, definee, "", parentEnv, block, nil) +} diff --git a/internal/vm/file.go b/internal/vm/file.go index 97c97459..2faece30 100644 --- a/internal/vm/file.go +++ b/internal/vm/file.go @@ -29,6 +29,13 @@ func (vm *VM) registerFile() { cFile := newClass("File", vm.cObject) vm.consts["File"] = cFile + // Path constants (POSIX). ALT_SEPARATOR is nil on unix-like platforms; NULL + // is the null device. These let path-handling code branch on File::SEPARATOR + // etc. without a runtime error. + cFile.consts["SEPARATOR"] = object.NewString("/") + cFile.consts["ALT_SEPARATOR"] = object.NilV + cFile.consts["PATH_SEPARATOR"] = object.NewString(":") + cFile.consts["NULL"] = object.NewString("/dev/null") def := func(name string, fn NativeFn) { cFile.smethods[name] = &Method{name: name, owner: cFile, native: fn} } def("basename", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { diff --git a/internal/vm/inlinecache.go b/internal/vm/inlinecache.go index 54ef8308..bbdde033 100644 --- a/internal/vm/inlinecache.go +++ b/internal/vm/inlinecache.go @@ -87,8 +87,10 @@ func (vm *VM) lookupCached(ic *inlineCache, recv object.Value, name string) *Met c := vm.classOf(recv) // An object with a singleton class can carry per-object methods, so its // dispatch must not be cached against the shared class — resolve it directly. - if o, ok := recv.(*RObject); ok && o.singleton != nil { - return undefAsNil(lookupMethod(o.singleton, name)) + // This covers both *RObject (inline singleton) and builtin-backed values that + // were extend-ed / given a singleton method via the side table. + if sc := vm.objSingleton(recv); sc != nil { + return undefAsNil(lookupMethod(sc, name)) } serial := methodSerial() if ic.class == c && ic.serial == serial { diff --git a/internal/vm/kernel_introspect.go b/internal/vm/kernel_introspect.go new file mode 100644 index 00000000..d56f73c7 --- /dev/null +++ b/internal/vm/kernel_introspect.go @@ -0,0 +1,123 @@ +package vm + +import ( + "runtime" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerVersionConstants installs the RUBY_* version/platform constants. The +// engine is reported as "ruby" (MRI) so app code that branches on RUBY_ENGINE — +// Puppet, Rails and most gems guard MRI-only paths with `RUBY_ENGINE == "ruby"` +// — takes the standard CRuby path. The version targets the project's Ruby 3.4 +// compatibility goal, a real version string so guards like +// `if RUBY_VERSION >= "3.0"` pass. +func (vm *VM) registerVersionConstants() { + const version = "3.4.1" + vm.consts["RUBY_VERSION"] = object.NewString(version) + vm.consts["RUBY_ENGINE"] = object.NewString("ruby") + vm.consts["RUBY_ENGINE_VERSION"] = object.NewString(version) + vm.consts["RUBY_PATCHLEVEL"] = object.Integer(0) + vm.consts["RUBY_PLATFORM"] = object.NewString(rubyPlatform()) + vm.consts["RUBY_DESCRIPTION"] = object.NewString("ruby " + version + " [" + rubyPlatform() + "]") + vm.consts["RUBY_COPYRIGHT"] = object.NewString("ruby - Copyright (C) 1993-2025 Yukihiro Matsumoto") +} + +// rubyPlatform renders a Ruby-style platform triple from the Go build target, +// e.g. "arm64-darwin", "x86_64-linux". +func rubyPlatform() string { return rubyPlatformFor(runtime.GOARCH, runtime.GOOS) } + +// rubyPlatformFor maps a (GOARCH, GOOS) pair to a Ruby-style platform triple. It +// is split out from rubyPlatform so the arch/os normalisations are testable on +// any host. +func rubyPlatformFor(arch, os string) string { + switch arch { + case "amd64": + arch = "x86_64" + case "386": + arch = "i686" + } + if os == "wasip1" || os == "js" { + os = "wasm" + } + return arch + "-" + os +} + +// registerKernelIntrospection installs Kernel#caller, #at_exit and #__method__. +func (vm *VM) registerKernelIntrospection() { + // __method__: the name of the method the call sits in, or nil at the top + // level / in a block-only context. __method__ is itself native and pushes no + // frame, so the top of frameNames is its caller's method name. + vm.cObject.define("__method__", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if name := vm.currentMethodName(); name != "" { + return object.Symbol(name) + } + return object.NilV + }) + + // caller: a best-effort backtrace as a String array, outermost-first omitted + // like MRI — it excludes the frame that called caller and lists the rest from + // nearest to the top level. Without source line tracking the line is 0. + vm.cObject.define("caller", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return &object.Array{Elems: vm.callerFrames()} + }) + + // at_exit: register a block to run when the program finishes normally, in + // LIFO order. Returns the block as a Proc, as MRI does. + vm.cObject.define("at_exit", func(vm *VM, _ object.Value, _ []object.Value, blk *Proc) object.Value { + if blk == nil { + raise("LocalJumpError", "no block given") + } + vm.atExit = append(vm.atExit, blk) + return blk + }) +} + +// currentMethodName returns the name of the innermost real (named) method frame, +// or "" when none. Native frames push nothing, so the top entry is the Ruby +// method that called the native introspection method. +func (vm *VM) currentMethodName() string { + // exec always pushes a frame name ("" for top-level/class/block bodies) + // before any native method can run, so the stack is never empty here. + return vm.frameNames[len(vm.frameNames)-1] +} + +// callerFrames builds caller's String array. It drops the topmost frame (the one +// that invoked caller) and walks outward, formatting each as MRI does, with a +// placeholder line number since this VM does not yet track source positions. +func (vm *VM) callerFrames() []object.Value { + if len(vm.frameNames) < 2 { + return nil // only the calling frame (or none): no outer frames to report + } + var out []object.Value + for i := len(vm.frameNames) - 2; i >= 0; i-- { + where := "
" + if name := vm.frameNames[i]; name != "" { + where = name + } + out = append(out, object.NewString("(rbgo):0:in '"+where+"'")) + } + return out +} + +// runAtExit runs the registered at_exit blocks in LIFO order. A raise inside one +// is swallowed so the remaining handlers still run (MRI reports it but keeps +// going); other control-flow signals propagate. +func (vm *VM) runAtExit() { + for i := len(vm.atExit) - 1; i >= 0; i-- { + vm.runAtExitOne(vm.atExit[i]) + } + vm.atExit = nil +} + +func (vm *VM) runAtExitOne(blk *Proc) { + defer func() { + if r := recover(); r != nil { + if _, ok := r.(RubyError); ok { + return // swallow: a failing at_exit hook must not abort the others + } + panic(r) + } + }() + vm.callBlock(blk, nil) +} diff --git a/internal/vm/method.go b/internal/vm/method.go index f9ab9f7b..016a75c0 100644 --- a/internal/vm/method.go +++ b/internal/vm/method.go @@ -73,8 +73,8 @@ func (vm *VM) resolveMethod(recv object.Value, name string) *Method { } } c := vm.classOf(recv) - if o, ok := recv.(*RObject); ok && o.singleton != nil { - c = o.singleton + if sc := vm.objSingleton(recv); sc != nil { + c = sc } return undefAsNil(lookupMethod(c, name)) } diff --git a/internal/vm/modulefunc.go b/internal/vm/modulefunc.go new file mode 100644 index 00000000..db0e9ac3 --- /dev/null +++ b/internal/vm/modulefunc.go @@ -0,0 +1,119 @@ +package vm + +import "github.com/go-embedded-ruby/ruby/internal/object" + +// registerModuleExtras installs the module/class authoring directives that real +// Ruby code (notably Puppet) leans on: module_function, the visibility setters +// (private/public/protected and their _class_method forms), alias_method, and +// the constant-visibility no-ops. Method visibility itself is not enforced by +// this VM, so the visibility setters validate and return the MRI value but do +// not restrict dispatch; module_function and alias_method are fully functional. +func (vm *VM) registerModuleExtras() { + // module_function: with no args, switch the module body into function mode so + // every subsequent `def` is also copied as a module/singleton method. With + // args, convert the named instance methods now. Returns nil (no-arg) or the + // arg list, matching MRI. + vm.cModule.define("module_function", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + mod := self.(*RClass) + if len(args) == 0 { + mod.funcMode = true + return object.NilV + } + for _, a := range args { + name := nameArg(a) + m := lookupMethod(mod, name) + if m == nil || m.undefined { + raise("NameError", "undefined method '%s' for module '%s'", name, mod.name) + } + sm := *m + mod.smethods[name] = &sm + } + bumpMethodSerial() + if len(args) == 1 { + return args[0] + } + return &object.Array{Elems: append([]object.Value(nil), args...)} + }) + + // Visibility setters. With no args they would set the body's default + // visibility (not modelled — every method stays callable); with args they + // return the single name, the arg list, or nil for the no-arg form, as MRI + // does. They accept names/symbols and validate that each method exists. + visibility := func(self object.Value, args []object.Value) object.Value { + mod := self.(*RClass) + for _, a := range args { + name := nameArg(a) + if m := lookupMethod(mod, name); m == nil || m.undefined { + raise("NameError", "undefined method '%s' for class '%s'", name, mod.name) + } + } + switch len(args) { + case 0: + return object.NilV + case 1: + return args[0] + default: + return &object.Array{Elems: append([]object.Value(nil), args...)} + } + } + vm.cModule.define("private", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return visibility(self, args) + }) + vm.cModule.define("public", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return visibility(self, args) + }) + vm.cModule.define("protected", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return visibility(self, args) + }) + + // private_class_method / public_class_method: validate the named class + // methods exist (visibility itself is not enforced). Returns self, as MRI. + classMethodVisibility := func(self object.Value, args []object.Value) object.Value { + mod := self.(*RClass) + for _, a := range args { + name := nameArg(a) + if lookupSMethod(mod, name) == nil { + raise("NameError", "undefined method '%s' for class '%s'", name, mod.name) + } + } + return self + } + vm.cModule.define("private_class_method", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return classMethodVisibility(self, args) + }) + vm.cModule.define("public_class_method", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return classMethodVisibility(self, args) + }) + + // alias_method: the method form of `alias new old`, returning the new name as + // a Symbol (MRI returns a Symbol since 3.0). + vm.cModule.define("alias_method", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + mod := self.(*RClass) + newName, oldName := nameArg(args[0]), nameArg(args[1]) + vm.aliasMethod(mod, newName, oldName) + return object.Symbol(newName) + }) + + // Constant-visibility directives: not enforced (constants are not access- + // controlled here), so accept the names and return self, as MRI does. + constVisibility := func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return self + } + vm.cModule.define("private_constant", constVisibility) + vm.cModule.define("public_constant", constVisibility) +} + +// nameArg coerces a method-name argument (a String or Symbol) to its string, +// raising TypeError for anything else (matching MRI's "not a symbol nor a +// string"). +func nameArg(v object.Value) string { + switch x := v.(type) { + case object.Symbol: + return string(x) + case *object.String: + return x.Str() + default: + raise("TypeError", "%s is not a symbol nor a string", v.Inspect()) + return "" + } +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 046d5f66..6073be64 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -99,6 +99,7 @@ type RClass struct { prepends []*RClass // modules mixed in via prepend (most recent last), ahead of own methods isModule bool meta *RClass // lazily-created metaclass for `class << SomeClass`; its methods map IS this class's smethods + funcMode bool // module_function (no-arg) mode: subsequent instance defs are also copied as module/singleton methods } func newClass(name string, super *RClass) *RClass { @@ -140,6 +141,55 @@ func (vm *VM) singletonClass(o *RObject) *RClass { return o.singleton } +// objSingleton returns the existing per-object singleton class for any value, or +// nil. For *RObject it is the inline field; for other reference values it is the +// side-table entry. Immediate values never have one. +func (vm *VM) objSingleton(v object.Value) *RClass { + if o, ok := v.(*RObject); ok { + return o.singleton + } + if vm.extSingletons == nil { + return nil + } + return vm.extSingletons[v] +} + +// ensureSingleton returns v's singleton class, creating it on first use. It +// handles *RObject (inline field) and other reference values (side table). A +// second bool reports success; immediate values (Integer/Symbol/true/false/nil) +// and classes/modules (which use a metaclass instead) are not eligible here. +func (vm *VM) ensureSingleton(v object.Value) (*RClass, bool) { + switch t := v.(type) { + case *RObject: + return vm.singletonClass(t), true + case *RClass: + return nil, false // classes use metaClass() + } + if !hasIdentitySingleton(v) { + return nil, false + } + if vm.extSingletons == nil { + vm.extSingletons = map[object.Value]*RClass{} + } + if sc := vm.extSingletons[v]; sc != nil { + return sc, true + } + sc := newClass("", vm.classOf(v)) + vm.extSingletons[v] = sc + return sc, true +} + +// hasIdentitySingleton reports whether v is a reference value that can carry a +// side-table singleton class. Immediate / value-semantics types cannot. +func hasIdentitySingleton(v object.Value) bool { + switch v.(type) { + case object.Integer, *object.Bignum, object.Float, object.Symbol, + object.Bool, object.Nil: + return false + } + return true +} + // metaClass returns the metaclass of a class/module — the class whose method // table holds c's class methods (def self.foo). Its methods map aliases c's // smethods map, so methods defined into the metaclass become c's class methods @@ -158,14 +208,10 @@ func (c *RClass) metaClass() *RClass { // singleton (meta) class. A second bool reports success; an immediate value // (Integer, Symbol, true/false/nil, …) has no singleton class in MRI. func (vm *VM) singletonDefinee(target object.Value) (*RClass, bool) { - switch t := target.(type) { - case *RClass: - return t.metaClass(), true - case *RObject: - return vm.singletonClass(t), true - default: - return nil, false + if c, ok := target.(*RClass); ok { + return c.metaClass(), true } + return vm.ensureSingleton(target) } func (o *RObject) ToS() string { @@ -385,6 +431,8 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.cProc case *BoundMethod: return vm.cMethod + case *UnboundMethod: + return vm.consts["UnboundMethod"].(*RClass) case *Enumerator: return vm.cEnumerator case *yielder: @@ -438,8 +486,8 @@ func (vm *VM) findMethod(recv object.Value, name string) *Method { } } c := vm.classOf(recv) - if o, ok := recv.(*RObject); ok && o.singleton != nil { - c = o.singleton + if sc := vm.objSingleton(recv); sc != nil { + c = sc } if m := lookupMethod(c, name); m != nil && !m.undefined { return m @@ -458,8 +506,8 @@ func (vm *VM) send(recv object.Value, name string, args []object.Value, blk *Pro c := vm.classOf(recv) // An object with a singleton class dispatches through it first (per-object // methods + extended modules), then its class chain (the singleton's super). - if o, ok := recv.(*RObject); ok && o.singleton != nil { - c = o.singleton + if sc := vm.objSingleton(recv); sc != nil { + c = sc } if m := lookupMethod(c, name); m != nil && !m.undefined { return vm.invoke(m, recv, args, blk) diff --git a/internal/vm/reflection.go b/internal/vm/reflection.go new file mode 100644 index 00000000..e678ba56 --- /dev/null +++ b/internal/vm/reflection.go @@ -0,0 +1,94 @@ +package vm + +import ( + "fmt" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// UnboundMethod is a method detached from any receiver, produced by +// Module#instance_method or Method#unbind. It is re-bound to a compatible +// receiver with #bind / #bind_call. +type UnboundMethod struct { + name string + owner *RClass // the module/class the method was extracted from + m *Method +} + +func (u *UnboundMethod) ToS() string { + return fmt.Sprintf("#", u.owner.name, u.name) +} +func (u *UnboundMethod) Inspect() string { return u.ToS() } +func (u *UnboundMethod) Truthy() bool { return true } + +// registerReflection installs the reflection API: Module#instance_method, +// Object#method/#singleton_class, the UnboundMethod class, and Method#unbind. It +// also teaches define_method to accept a Method/UnboundMethod body. +func (vm *VM) registerReflection() { + cUnbound := newClass("UnboundMethod", vm.cObject) + vm.consts["UnboundMethod"] = cUnbound + + // Module#instance_method(:m) → UnboundMethod resolved up the ancestor chain. + vm.cModule.define("instance_method", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + mod := self.(*RClass) + name := nameArg(args[0]) + m := lookupMethod(mod, name) + if m == nil || m.undefined { + raise("NameError", "undefined method '%s' for class '%s'", name, mod.name) + } + return &UnboundMethod{name: name, owner: m.owner, m: m} + }) + + // Module#method_defined?(:m): true if m resolves up the ancestor chain. + vm.cModule.define("method_defined?", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + mod := self.(*RClass) + m := lookupMethod(mod, nameArg(args[0])) + return object.Bool(m != nil && !m.undefined) + }) + + cUnbound.define("name", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Symbol(self.(*UnboundMethod).name) + }) + cUnbound.define("owner", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return self.(*UnboundMethod).owner + }) + cUnbound.define("arity", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(methodArity(self.(*UnboundMethod).m)) + }) + // UnboundMethod#bind(obj) → Method; obj must be a kind_of? the owner. + cUnbound.define("bind", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + u := self.(*UnboundMethod) + vm.checkBindable(u, args[0]) + return &BoundMethod{recv: args[0], name: u.name, m: u.m} + }) + // UnboundMethod#bind_call(obj, *args, &blk): bind then invoke in one step. + cUnbound.define("bind_call", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + u := self.(*UnboundMethod) + vm.checkBindable(u, args[0]) + return vm.invoke(u.m, args[0], args[1:], blk) + }) + + // Object#singleton_class: the per-object singleton (meta) class, created on + // demand. Immediate values (Integer/Symbol/true/false/nil) have none in MRI. + vm.cObject.define("singleton_class", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + sc, ok := vm.singletonDefinee(self) + if !ok { + raise("TypeError", "can't define singleton") + } + return sc + }) + + // Method#unbind → UnboundMethod. + vm.cMethod.define("unbind", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + b := self.(*BoundMethod) + return &UnboundMethod{name: b.name, owner: b.m.owner, m: b.m} + }) +} + +// checkBindable raises TypeError unless recv is an instance of the +// UnboundMethod's owner (Ruby's bind compatibility rule). +func (vm *VM) checkBindable(u *UnboundMethod, recv object.Value) { + if !classIsA(vm.classOf(recv), u.owner) { + raise("TypeError", "bind argument must be an instance of %s", u.owner.name) + } +} diff --git a/internal/vm/singleton.go b/internal/vm/singleton.go index d451c21a..fffab7ae 100644 --- a/internal/vm/singleton.go +++ b/internal/vm/singleton.go @@ -21,36 +21,44 @@ func (vm *VM) registerSingleton() { } } name := args[0].ToS() - switch t := self.(type) { - case *RClass: + if t, ok := self.(*RClass); ok { t.smethods[name] = &Method{name: name, proc: body, owner: t} - case *RObject: - sc := vm.singletonClass(t) - sc.methods[name] = &Method{name: name, proc: body, owner: sc} - default: + bumpMethodSerial() + return object.Symbol(name) + } + sc, ok := vm.ensureSingleton(self) + if !ok { raise("TypeError", "can't define singleton method %q for %s", name, vm.classOf(self).name) } + sc.methods[name] = &Method{name: name, proc: body, owner: sc} + bumpMethodSerial() return object.Symbol(name) }) vm.cObject.define("extend", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") + } for _, a := range args { mod, ok := a.(*RClass) if !ok { raise("TypeError", "wrong argument type %s (expected Module)", classNameOf(a)) } - switch t := self.(type) { - case *RObject: - sc := vm.singletonClass(t) - sc.includes = append(sc.includes, mod) - case *RClass: + if t, ok := self.(*RClass); ok { // C.extend(M): M's instance methods become class methods of C. for n, m := range mod.methods { t.smethods[n] = m } - default: - raise("TypeError", "can't extend a %s", vm.classOf(self).name) + } else { + // Any other object (incl. builtin-backed Array/String/… such as + // $LOAD_PATH) mixes the module into its singleton class. + sc, ok := vm.ensureSingleton(self) + if !ok { + raise("TypeError", "can't extend a %s", vm.classOf(self).name) + } + sc.includes = append(sc.includes, mod) } + bumpMethodSerial() // Hook: module.extended(object), if the module defines it. if hook := lookupSMethod(mod, "extended"); hook != nil { vm.invoke(hook, mod, []object.Value{self}, nil) diff --git a/internal/vm/tier0_internal_test.go b/internal/vm/tier0_internal_test.go new file mode 100644 index 00000000..559f1071 --- /dev/null +++ b/internal/vm/tier0_internal_test.go @@ -0,0 +1,43 @@ +package vm + +import ( + "bytes" + "testing" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// TestRubyPlatformFor covers the arch/os normalisations of rubyPlatformFor on +// any host (the live build target only exercises one combination). +func TestRubyPlatformFor(t *testing.T) { + cases := []struct{ arch, os, want string }{ + {"amd64", "linux", "x86_64-linux"}, + {"386", "linux", "i686-linux"}, + {"arm64", "darwin", "arm64-darwin"}, + {"wasm", "js", "wasm-wasm"}, + {"wasm", "wasip1", "wasm-wasm"}, + {"riscv64", "linux", "riscv64-linux"}, + } + for _, c := range cases { + if got := rubyPlatformFor(c.arch, c.os); got != c.want { + t.Errorf("rubyPlatformFor(%q,%q)=%q want %q", c.arch, c.os, got, c.want) + } + } +} + +// TestRunAtExitOneRepanic checks that a non-RubyError control-flow signal raised +// inside an at_exit hook is re-panicked (not swallowed) by runAtExitOne. +func TestRunAtExitOneRepanic(t *testing.T) { + vm := New(&bytes.Buffer{}) + blk := &Proc{native: func(_ *VM, _ []object.Value) object.Value { + panic(throwSignal{tag: object.NilV, value: object.NilV}) + }} + defer func() { + r := recover() + if _, ok := r.(throwSignal); !ok { + t.Fatalf("expected throwSignal to propagate, got %v", r) + } + }() + vm.runAtExitOne(blk) + t.Fatal("runAtExitOne should have re-panicked the throwSignal") +} diff --git a/internal/vm/tier0_test.go b/internal/vm/tier0_test.go new file mode 100644 index 00000000..b96eebd4 --- /dev/null +++ b/internal/vm/tier0_test.go @@ -0,0 +1,323 @@ +package vm_test + +import ( + "strings" + "testing" +) + +// runCase is a (src, want-stdout) pair asserted against MRI Ruby 4.0.5 output. +type runCase struct{ src, want string } + +func checkCases(t *testing.T, cases []runCase) { + t.Helper() + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } +} + +// TestDefinedOperator covers defined? for every form, against MRI semantics: +// it never raises and returns the right tag String or nil. (Oracle: MRI 4.0.5.) +func TestDefinedOperator(t *testing.T) { + checkCases(t, []runCase{ + // The #1 bug: an undefined constant is nil, not a NameError. + {`p defined?(Nope)`, "nil\n"}, + {`X = 1 unless defined?(X); p X`, "1\n"}, + {`X = 1; X = 2 unless defined?(X); p X`, "1\n"}, + // Constants, including scope-resolution paths. + {`A = 1; p defined?(A)`, "\"constant\"\n"}, + {`p defined?(Float::INFINITY)`, "\"constant\"\n"}, + {`p defined?(Float::NOPE)`, "nil\n"}, + {`p defined?(Nope::Bar)`, "nil\n"}, + {`p defined?(::Object)`, "\"constant\"\n"}, + {`p defined?(::Nope)`, "nil\n"}, + // Methods: implicit-self, explicit receiver, with args. + {`p defined?(puts)`, "\"method\"\n"}, + {`p defined?(no_such_method)`, "nil\n"}, + {`def foo(a); end; p defined?(foo(1))`, "\"method\"\n"}, + {`p defined?(undefmeth(1))`, "nil\n"}, + {`p defined?("s".upcase)`, "\"method\"\n"}, + {`p defined?(1.foobar)`, "nil\n"}, + // A defined receiver that is nil still reports method; an undefined + // receiver reports nil (the receiver is itself subject to defined?). + {`@x = nil; p defined?(@x.to_s)`, "\"method\"\n"}, + {`p defined?(@nope.to_s)`, "nil\n"}, + {`p defined?(Nope.foo)`, "nil\n"}, + // Operators are methods. + {`p defined?(1 + 2)`, "\"method\"\n"}, + {`x = 1; p defined?(-x)`, "\"method\"\n"}, + {`p defined?(!true)`, "\"method\"\n"}, + {`p defined?(1 <=> 2)`, "\"method\"\n"}, + // && / || are expressions. + {`p defined?(1 && 2)`, "\"expression\"\n"}, + {`p defined?(nil || 1)`, "\"expression\"\n"}, + // Local variables. + {`y = 5; p defined?(y)`, "\"local-variable\"\n"}, + {`p defined?(undeclared_local)`, "nil\n"}, + // Instance / class / global variables. + {`@iv = 2; p defined?(@iv)`, "\"instance-variable\"\n"}, + {`p defined?(@unset_iv)`, "nil\n"}, + {`$gv = 3; p defined?($gv)`, "\"global-variable\"\n"}, + {`p defined?($unset_gv)`, "nil\n"}, + {`class C; @@v = 1; def t; defined?(@@v); end; def u; defined?(@@nope); end; end; p [C.new.t, C.new.u]`, + "[\"class variable\", nil]\n"}, + // yield. + {`def m; defined?(yield); end; p [m { 1 }, m]`, "[\"yield\", nil]\n"}, + // self / nil / true / false. + {`p defined?(self)`, "\"self\"\n"}, + {`p defined?(nil)`, "\"nil\"\n"}, + {`p defined?(true)`, "\"true\"\n"}, + {`p defined?(false)`, "\"false\"\n"}, + // Assignments. + {`p defined?(x = 1)`, "\"assignment\"\n"}, + {`@z = 0; p defined?(@z += 1)`, "\"assignment\"\n"}, + {`p defined?(A2 = 1)`, "\"assignment\"\n"}, + {`@v = 0; p defined?(@v = 5)`, "\"assignment\"\n"}, + {`$g = 0; p defined?($g = 5)`, "\"assignment\"\n"}, + // Literals and other expressions. + {`p defined?("str")`, "\"expression\"\n"}, + {`p defined?([1, 2])`, "\"expression\"\n"}, + {`p defined?(1)`, "\"expression\"\n"}, + {`p defined?(:sym)`, "\"expression\"\n"}, + {`p defined?({a: 1})`, "\"expression\"\n"}, + {`p defined?(1..3)`, "\"expression\"\n"}, + // Chained receiver where an inner part is undefined. + {`p defined?(@x.foo.bar)`, "nil\n"}, + }) +} + +// TestModuleFunction covers Module#module_function (both forms). +func TestModuleFunction(t *testing.T) { + checkCases(t, []runCase{ + {`module M; def foo; 42; end; module_function :foo; end; p M.foo`, "42\n"}, + {`module M; module_function; def foo; 7; end; def bar; foo * 2; end; end; p [M.foo, M.bar]`, "[7, 14]\n"}, + // The instance method still exists when included. + {`module M; module_function; def foo; 9; end; end; class C; include M; def call; foo; end; end; p C.new.call`, "9\n"}, + {`module M; def a; 1; end; def b; 2; end; p module_function(:a, :b); end`, "[:a, :b]\n"}, + {`module M; def a; 1; end; p module_function :a; end`, ":a\n"}, + }) + if err := runErr(t, `module M; module_function :nope; end`); err == nil || + !strings.Contains(err.Error(), "undefined method 'nope'") { + t.Errorf("module_function on missing method: err=%v", err) + } +} + +// TestVisibilityDirectives covers the visibility setters and the _class_method +// forms. Visibility is not enforced by this VM, but the directives must accept +// names and return the MRI value. +func TestVisibilityDirectives(t *testing.T) { + checkCases(t, []runCase{ + {`class C; def f; 1; end; p private(:f); end`, ":f\n"}, + {`class C; def f; 1; end; def g; 2; end; p public(:f, :g); end`, "[:f, :g]\n"}, + {`class C; p private; end`, "nil\n"}, + {`class C; def f; end; protected(:f); end; p "ok"`, "\"ok\"\n"}, + {`module M; def self.sx; 1; end; p private_class_method(:sx) == M; end`, "true\n"}, + {`module M; def self.sx; 1; end; p public_class_method(:sx) == M; end`, "true\n"}, + // Visibility is not enforced: a "private" method stays callable. + {`class C; def f; 5; end; private :f; end; p C.new.f`, "5\n"}, + // Constant-visibility directives are accepted (no-ops returning nil). + {`class C; X = 1; p private_constant(:X) == C; end`, "true\n"}, + {`class C; X = 1; p public_constant(:X) == C; end`, "true\n"}, + }) + for _, src := range []string{ + `class C; private :nope; end`, + `module M; private_class_method :nope; end`, + } { + if err := runErr(t, src); err == nil || !strings.Contains(err.Error(), "undefined method 'nope'") { + t.Errorf("src=%q expected undefined-method error, got %v", src, err) + } + } +} + +// TestExtendAndSingletonClass covers Object#extend on ordinary and +// builtin-backed receivers, plus Object#singleton_class. +func TestExtendAndSingletonClass(t *testing.T) { + checkCases(t, []runCase{ + // extend on an ordinary object. + {`module M; def a; "A"; end; end; o = Object.new; o.extend(M); p o.a`, "\"A\"\n"}, + // extend on builtin-backed values (the $LOAD_PATH case). + {`module M; def shout; "HEY"; end; end; s = "x"; s.extend(M); p s.shout`, "\"HEY\"\n"}, + {`module M; def tag; :arr; end; end; a = [1]; a.extend(M); p [a.tag, a.length]`, "[:arr, 1]\n"}, + {`module M; def m; 1; end; end; h = {}; h.extend(M); p h.m`, "1\n"}, + // Only the extended instance gains the method. + {`module M; def m; 1; end; end; a = []; a.extend(M); p [].respond_to?(:m)`, "false\n"}, + {`module M; def a; end; end; o = []; o.extend(M); p o.respond_to?(:a)`, "true\n"}, + // extend returns self and chains. + {`module M; def m; 9; end; end; p [].extend(M).m`, "9\n"}, + // singleton_class. + {`p "abc".singleton_class.class`, "Class\n"}, + {`o = Object.new; def o.x; 1; end; p o.singleton_class.instance_methods(false)`, "[:x]\n"}, + // define_singleton_method on a builtin-backed value. + {`a = [1, 2]; a.define_singleton_method(:double) { length * 2 }; p a.double`, "4\n"}, + }) + for _, src := range []string{ + `[].extend(Object.new)`, // not a module + `[].extend`, // no args + `5.singleton_class`, // immediate value + } { + if err := runErr(t, src); err == nil { + t.Errorf("src=%q expected error, got nil", src) + } + } +} + +// TestReflection covers UnboundMethod / Method reflection and define_method +// from a (Unbound)Method. +func TestReflection(t *testing.T) { + checkCases(t, []runCase{ + // instance_method → UnboundMethod, bind, bind_call. + {`class C; def foo; 5; end; end; p C.instance_method(:foo).bind(C.new).call`, "5\n"}, + {`class C; def foo(x); x + 1; end; end; p C.instance_method(:foo).bind_call(C.new, 41)`, "42\n"}, + {`class C; def foo; 3; end; end; um = C.instance_method(:foo); p [um.name, um.owner, um.arity]`, "[:foo, C, 0]\n"}, + {`class C; def foo(a, b); end; end; p C.instance_method(:foo).arity`, "2\n"}, + // Inherited methods resolve. + {`class A; def foo; 1; end; end; class B < A; end; p B.instance_method(:foo).owner`, "A\n"}, + // Method#unbind round-trips. + {`class C; def foo; 11; end; end; m = C.new.method(:foo); p m.unbind.bind(C.new).call`, "11\n"}, + // define_method from an (Unbound)Method. + {`class C; def foo; 3; end; end; class C; define_method(:bar, instance_method(:foo)); end; p C.new.bar`, "3\n"}, + {`class C; def foo; 7; end; end; m = C.new.method(:foo); class D; end; D.define_method(:g, m); p D.new.g`, "7\n"}, + // method_defined?. + {`class C; def foo; end; end; p [C.method_defined?(:foo), C.method_defined?(:nope)]`, "[true, false]\n"}, + }) + for _, src := range []string{ + `class C; def foo; end; end; C.instance_method(:nope)`, // missing method + `class C; def foo; end; end; C.instance_method(:foo).bind(42)`, // incompatible bind + `class C; def foo; end; end; C.instance_method(:foo).bind_call(42)`, + } { + if err := runErr(t, src); err == nil { + t.Errorf("src=%q expected error, got nil", src) + } + } +} + +// TestVersionConstants covers the RUBY_* version/platform constants. +func TestVersionConstants(t *testing.T) { + checkCases(t, []runCase{ + {`p RUBY_VERSION >= "3.0"`, "true\n"}, + {`p RUBY_VERSION.class`, "String\n"}, + {`p RUBY_ENGINE`, "\"ruby\"\n"}, + {`p RUBY_ENGINE_VERSION == RUBY_VERSION`, "true\n"}, + {`p RUBY_PATCHLEVEL`, "0\n"}, + {`p RUBY_PLATFORM.class`, "String\n"}, + {`p RUBY_PLATFORM.include?("-")`, "true\n"}, + {`p RUBY_DESCRIPTION.start_with?("ruby ")`, "true\n"}, + {`p RUBY_COPYRIGHT.include?("Copyright")`, "true\n"}, + }) +} + +// TestFileConstants covers File path constants. +func TestFileConstants(t *testing.T) { + checkCases(t, []runCase{ + {`p File::SEPARATOR`, "\"/\"\n"}, + {`p File::ALT_SEPARATOR`, "nil\n"}, + {`p File::PATH_SEPARATOR`, "\":\"\n"}, + {`p File::NULL`, "\"/dev/null\"\n"}, + }) +} + +// TestKernelIntrospection covers __method__, caller, at_exit, alias_method. +func TestKernelIntrospection(t *testing.T) { + checkCases(t, []runCase{ + {`def m; __method__; end; p m`, ":m\n"}, + {`p __method__`, "nil\n"}, + {`def a; __method__; end; def b; a; end; p b`, ":a\n"}, + // caller is a best-effort String array; from the top level it is empty. + {`p caller`, "[]\n"}, + {`def a; caller; end; def b; a; end; r = b; p [r.class, r.length >= 2]`, "[Array, true]\n"}, + {`def a; caller; end; r = a; p r.length`, "1\n"}, + // alias_method (method form of alias). + {`class C; def foo; 1; end; alias_method :bar, :foo; end; p C.new.bar`, "1\n"}, + {`class C; def foo; 1; end; p alias_method(:bar, :foo); end`, ":bar\n"}, + // at_exit runs in LIFO at normal exit. + {`at_exit { puts "a" }; at_exit { puts "b" }; puts "main"`, "main\nb\na\n"}, + {`x = at_exit { }; p x.class`, "Proc\n"}, + }) + if err := runErr(t, `class C; alias_method :x, :nope; end`); err == nil || + !strings.Contains(err.Error(), "undefined method") { + t.Errorf("alias_method on missing method: err=%v", err) + } + if err := runErr(t, `at_exit`); err == nil { + t.Errorf("at_exit without a block should raise") + } +} + +// TestDefinedGlobalSpecials covers defined? for the regexp special globals and +// numbered match groups, where the result depends on whether a match exists and +// whether the group participated. (Oracle: MRI 4.0.5.) +func TestDefinedGlobalSpecials(t *testing.T) { + checkCases(t, []runCase{ + // $~ is always defined; the others need a match. + {`p defined?($~)`, "\"global-variable\"\n"}, + {`p defined?($&)`, "nil\n"}, + {`p defined?($1)`, "nil\n"}, + // After a match: whole-match specials defined; a participating group + // defined; a non-participating / out-of-range group nil. + {`"ab" =~ /(a)/; p [defined?($~), defined?($&), defined?($1), defined?($2)]`, + "[\"global-variable\", \"global-variable\", \"global-variable\", nil]\n"}, + {"\"abc\" =~ /b/; p [defined?($1), defined?($`)]", "[nil, \"global-variable\"]\n"}, + // A predefined stream global ($stdout) is defined. + {`p defined?($stdout)`, "\"global-variable\"\n"}, + }) +} + +// TestDefinedControlFlow checks that a control-flow signal (throw) raised while +// evaluating a guarded receiver propagates through defined? rather than being +// swallowed, matching MRI. +func TestDefinedControlFlow(t *testing.T) { + checkCases(t, []runCase{ + {`catch(:t) { p defined?(throw(:t).x) }; p "after"`, "\"after\"\n"}, + }) +} + +// TestReflectionInspect covers UnboundMethod's string representation. rbgo uses +// the "#" core form (MRI 4.0.5 also appends the +// parameter signature and source location, which this VM does not track). +func TestReflectionInspect(t *testing.T) { + checkCases(t, []runCase{ + {`class C; def foo; end; end; p C.instance_method(:foo).to_s`, "\"#\"\n"}, + {`class C; def foo; end; end; p C.instance_method(:foo).inspect`, "\"#\"\n"}, + {`class C; def foo; end; end; p(!!C.instance_method(:foo))`, "true\n"}, + }) +} + +// TestStringNameArgs checks the authoring directives accept String (not just +// Symbol) method names, and reject non-name arguments with TypeError. +func TestStringNameArgs(t *testing.T) { + checkCases(t, []runCase{ + {`class C; def f; 1; end; alias_method "g", "f"; end; p C.new.g`, "1\n"}, + {`module M; def f; 3; end; module_function "f"; end; p M.f`, "3\n"}, + {`class C; def f; end; end; p C.instance_method("f").name`, ":f\n"}, + }) + if err := runErr(t, `class C; def f; end; private 1; end`); err == nil || + !strings.Contains(err.Error(), "not a symbol nor a string") { + t.Errorf("private with a non-name arg: err=%v", err) + } +} + +// TestExtendIdempotent covers re-extending / re-defining on the same builtin +// receiver, exercising the existing-singleton branch of the side table. +func TestExtendIdempotent(t *testing.T) { + checkCases(t, []runCase{ + {`module A; def a; 1; end; end; module B; def b; 2; end; end +s = "x"; s.extend(A); s.extend(B); p [s.a, s.b]`, "[1, 2]\n"}, + {`a = [] +a.define_singleton_method(:one) { 1 } +a.define_singleton_method(:two) { 2 } +p [a.one, a.two]`, "[1, 2]\n"}, + }) +} + +// TestAtExitErrorSwallowed checks that a raise inside one at_exit hook does not +// prevent the other hooks (or the program's normal completion) from running. +func TestAtExitErrorSwallowed(t *testing.T) { + got := eval(t, `at_exit { puts "first" } +at_exit { raise "boom" } +at_exit { puts "last" } +puts "main"`) + want := "main\nlast\nfirst\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 71bee13b..c6532cbd 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -147,6 +147,21 @@ type VM struct { // the counter for the next reference id. Lazily initialised; GVL-guarded. objIDs map[object.Value]int64 nextObjID int64 + + // extSingletons holds per-object singleton classes for reference values that + // are not *RObject (Array, String, Hash, Proc, …) — the backing for + // extend/singleton_class/define_singleton_method on builtin-backed objects + // such as $LOAD_PATH. Keyed by object identity; GVL-guarded. + extSingletons map[object.Value]*RClass + + // atExit holds Kernel#at_exit blocks, run in LIFO order when Run completes + // normally. GVL-guarded. + atExit []*Proc + + // frameNames is the running method-name stack (innermost last), maintained by + // exec for Kernel#__method__ and #caller. GVL-guarded (VM code is serialized + // by the GVL). + frameNames []string } // objectID returns the receiver's object_id / __id__. Immediate values get the @@ -282,13 +297,19 @@ func (vm *VM) SetConst(name string, v object.Value) { vm.consts[name] = v } func (vm *VM) Run(iseq *bytecode.ISeq) (result object.Value, err error) { defer func() { if r := recover(); r != nil { + // An exception unwinding past one or more exec frames leaves their + // frameNames entries unpopped; reset the stack so a later top-level + // statement (rescued program, REPL line) starts clean. + vm.frameNames = vm.frameNames[:0] if sig, ok := r.(throwSignal); ok { r = RubyError{Class: "UncaughtThrowError", Message: "uncaught throw " + sig.tag.Inspect()} } result, err = nil, r.(RubyError) } }() - return vm.exec(iseq, vm.main, nil, vm.cObject, "", nil, nil, nil), nil + res := vm.exec(iseq, vm.main, nil, vm.cObject, "", nil, nil, nil) + vm.runAtExit() + return res, nil } // exec runs one ISeq. definee is the class that `def` targets in this frame; @@ -420,6 +441,12 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, } } + // Record this frame's method name for Kernel#caller (best-effort backtrace). + // A real method frame has a non-empty name; top-level/class/block bodies push + // "" so the depth still lines up. The entry is popped on normal completion; + // an exception unwinding past here is reset wholesale at the Run boundary. + vm.frameNames = append(vm.frameNames, methodName) + stack := vm.getStack() push := func(v object.Value) { stack = append(stack, v) } pop := func() object.Value { @@ -671,6 +698,12 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, m.compiled = compiledFor(definee.name, name) } definee.methods[name] = m + // In module_function (no-arg) mode, a copy of the freshly defined + // method also becomes a module/singleton method, so M.name works. + if definee.funcMode { + sm := *m + definee.smethods[name] = &sm + } bumpMethodSerial() // a (re)definition can change what a cached send resolves to // Hook: definee.method_added(:name) for instance-method defs, if // the class/module defines the hook (singleton method). @@ -808,6 +841,58 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, push(object.Bool(match)) case bytecode.OpBlockGiven: push(object.Bool(block != nil)) + case bytecode.OpDefinedConst: + if _, ok := vm.consts[iseq.Names[in.A]]; ok { + push(definedTag("constant")) + } else { + push(object.NilV) + } + case bytecode.OpDefinedScopedConst: + name := iseq.Names[in.A] + recv := pop() + cls, ok := recv.(*RClass) + if ok && vm.hasScopedConst(cls, name) { + push(definedTag("constant")) + } else { + push(object.NilV) + } + case bytecode.OpDefinedIvar: + name := iseq.Names[in.A] + if t := ivarTable(self); t != nil { + if _, ok := t[name]; ok { + push(definedTag("instance-variable")) + break + } + } + push(object.NilV) + case bytecode.OpDefinedCVar: + name := iseq.Names[in.A] + if definee != vm.cObject && cvarOwner(definee, name) != nil { + push(definedTag("class variable")) + } else { + push(object.NilV) + } + case bytecode.OpDefinedGVar: + if vm.gvarDefined(iseq.Names[in.A]) { + push(definedTag("global-variable")) + } else { + push(object.NilV) + } + case bytecode.OpDefinedMethod: + recv := pop() + if vm.respondsTo(recv, iseq.Names[in.A]) { + push(definedTag("method")) + } else { + push(object.NilV) + } + case bytecode.OpDefinedYield: + if block != nil { + push(definedTag("yield")) + } else { + push(object.NilV) + } + case bytecode.OpDefinedGuard: + push(vm.runDefinedGuard(iseq.Children[in.A], self, definee, env, block)) case bytecode.OpBinding: markEnvCaptured(env) push(&Binding{env: env, self: self, definee: definee, names: append([]string(nil), iseq.Locals...)}) @@ -949,6 +1034,7 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // skips recycling and leaves both to the GC — correct, just not pooled. vm.putEnv(env) vm.putStack(stack) + vm.frameNames = vm.frameNames[:len(vm.frameNames)-1] return result }