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) + } +}