From f0815afed1f803c8b01eef6c573c7c7440b4314a Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 28 Sep 2026 16:42:39 +0200 Subject: [PATCH] vm: render the top-level self as "main", and give remove_method's NameError its receiver Two residuals another wave routed out of its own cluster, both measured against ruby 4.0.5 first. MRI renders an object that carries a singleton class by its identity string, with exactly one exception: the top-level self is "main". name_err_mesg_to_str (error.c ruby_4_0:2679-2689) takes the singleton arm, sets the "an instance of " prefix to empty, and then chooses between rb_any_to_s(obj) and the literal "main" on `obj == rb_vm_top_self()`. rbgo had the arm and not the exception: self.nope_xyz MRI : undefined method 'nope_xyz' for main rbgo : undefined method 'nope_xyz' for # An ordinary object with a singleton was already right, which is what said the arm itself was fine and only its exception was missing. The test also pins what must NOT move, and it is the more interesting half: MRI does NOT dispatch #inspect or #to_s to build that identity. Overriding both on the receiver changes the message on neither engine -- measured, both directions -- so this file's existing choice to construct the string rather than send to the receiver is MRI's choice too, and the test now says so. An error path that runs user code can recurse or raise again. Second: Module#remove_method on a name that is not there raises through rb_name_err_raise (vm_method.c rb_mod_remove_method), whose first argument IS the exception's receiver. rbgo built the same message through the bare raise() form and recorded none, so #receiver answered nil where MRI answers the module. This is one of the 36 bare-raise NameError sites the census in exception.go counts; it is fixed because the oracle was measured for it, not because bare raises are wrong in general -- the global-variable ones legitimately record no receiver. Co-Authored-By: Claude Opus 5 --- internal/vm/exception.go | 10 ++++ internal/vm/modulefunc.go | 13 ++++- internal/vm/wave53_main_receiver_test.go | 63 ++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 internal/vm/wave53_main_receiver_test.go diff --git a/internal/vm/exception.go b/internal/vm/exception.go index d7f33794..8b1993e2 100644 --- a/internal/vm/exception.go +++ b/internal/vm/exception.go @@ -42,6 +42,16 @@ func (vm *VM) undefinedMethodReceiver(self object.Value) string { return kind + " " + vm.classDisplayName(c) } if vm.objSingleton(self) != nil { + // MRI's one exception inside this branch: the top-level self renders as + // "main", not as its identity string. name_err_mesg_to_str (error.c + // ruby_4_0:2679-2689) takes the singleton arm, sets the "an instance of" + // prefix to empty, and then chooses between rb_any_to_s(obj) and the + // literal "main" on `obj == rb_vm_top_self()`. rbgo had the arm and not + // the exception, so `self.nope` at the top level said "for + // #" where MRI says "for main". + if self == vm.main { + return "main" + } return vm.objectIdentityRepr(self) } return "an instance of " + vm.classDisplayName(vm.classOf(self)) diff --git a/internal/vm/modulefunc.go b/internal/vm/modulefunc.go index 8e05515d..83e12ac7 100644 --- a/internal/vm/modulefunc.go +++ b/internal/vm/modulefunc.go @@ -1,6 +1,10 @@ package vm -import "github.com/go-embedded-ruby/ruby/internal/object" +import ( + "fmt" + + "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 @@ -317,7 +321,12 @@ func (vm *VM) registerModuleExtras() { vm.raiseFrozen(mod) } if _, ok := mod.methods[name]; !ok { - raise("NameError", "method '%s' not defined in %s", name, mod.ToS()) + // MRI raises this through rb_name_err_raise (vm_method.c + // rb_mod_remove_method -> remove_method), whose FIRST argument is + // the exception's receiver -- so #receiver answers the module the + // removal was attempted on. rbgo built the same message through + // the bare raise() form and left it unrecorded. + vm.raiseNameError(fmt.Sprintf("method '%s' not defined in %s", name, mod.ToS()), name, mod) } delete(mod.methods, name) bumpMethodSerial() diff --git a/internal/vm/wave53_main_receiver_test.go b/internal/vm/wave53_main_receiver_test.go new file mode 100644 index 00000000..e59bb3d4 --- /dev/null +++ b/internal/vm/wave53_main_receiver_test.go @@ -0,0 +1,63 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import "testing" + +// TestTopLevelSelfRendersAsMain covers MRI's one exception inside the +// singleton-receiver arm of name_err_mesg_to_str (error.c ruby_4_0:2679-2689): +// an object carrying a singleton class renders as its identity string, EXCEPT +// the top-level self, which renders as "main". +// +// Every want below is the byte-for-byte output of the local MRI 4.0.5 on that +// exact source. +func TestTopLevelSelfRendersAsMain(t *testing.T) { + t.Run("top-level self", func(t *testing.T) { + src := "begin; self.nope_xyz; rescue NoMethodError => e; puts e.message; end\n" + const want = "undefined method 'nope_xyz' for main\n" + if got := eval(t, src); got != want { + t.Errorf("got %q want %q", got, want) + } + }) + + // The rest of the arm must not move. An ordinary object that carries a + // singleton still renders as its identity, and — this is the part worth + // pinning — MRI does NOT dispatch #inspect or #to_s to build it. Overriding + // both changes nothing on ruby 4.0.5, which is why this message is + // constructed here rather than by sending to the receiver: an error path + // that runs user code can recurse or raise again. + t.Run("a singleton-carrying object still renders as its identity", func(t *testing.T) { + src := `o = Object.new +def o.inspect; "CUSTOM-INSPECT"; end +def o.to_s; "CUSTOM-TO-S"; end +begin; o.nope; rescue NoMethodError => e; puts e.message.sub(/0x\h+/, "0xX"); end +` + const want = "undefined method 'nope' for #\n" + if got := eval(t, src); got != want { + t.Errorf("got %q want %q — the override must NOT reach the message", got, want) + } + }) + + // And an object with no singleton takes the other arm entirely. + t.Run("no singleton", func(t *testing.T) { + src := "begin; Object.new.nope; rescue NoMethodError => e; puts e.message; end\n" + const want = "undefined method 'nope' for an instance of Object\n" + if got := eval(t, src); got != want { + t.Errorf("got %q want %q", got, want) + } + }) +} + +// TestRemoveMethodNameErrorCarriesItsReceiver: Module#remove_method on a name +// that is not there raises through rb_name_err_raise (vm_method.c +// rb_mod_remove_method), whose first argument IS the exception's receiver. rbgo +// built the same message through the bare raise() form and recorded none. +func TestRemoveMethodNameErrorCarriesItsReceiver(t *testing.T) { + src := "class K; end\nbegin; K.send(:remove_method, :nope); rescue NameError => e; p [e.message, e.name, e.receiver]; end\n" + const want = "[\"method 'nope' not defined in K\", :nope, K]\n" + if got := eval(t, src); got != want { + t.Errorf("got %q want %q", got, want) + } +}