diff --git a/go.mod b/go.mod index 1f6eb690..8d76d576 100644 --- a/go.mod +++ b/go.mod @@ -74,7 +74,7 @@ require ( github.com/go-ruby-faraday/faraday v0.0.0-20260704105000-d9589491af46 github.com/go-ruby-fast-gettext/fast-gettext v0.0.0-20260709172829-8beb2fbc96a8 github.com/go-ruby-find/find v0.0.0-20260630081030-35072d185272 - github.com/go-ruby-format/format v0.0.0-20260815195335-4038d17b79a1 + github.com/go-ruby-format/format v0.0.0-20260831115501-f58c7d12507c github.com/go-ruby-friendly-id/friendly-id v0.0.0-20260707195609-ff85fef61060 github.com/go-ruby-getoptlong/getoptlong v0.0.0-20260629150025-1a1bfd19bc49 github.com/go-ruby-grape/grape v0.0.0-20260702151528-455377c8c7c3 diff --git a/go.sum b/go.sum index a2f98774..9cfd9d25 100644 --- a/go.sum +++ b/go.sum @@ -425,6 +425,10 @@ github.com/go-ruby-find/find v0.0.0-20260630081030-35072d185272 h1:tHmlER04rO8Lx github.com/go-ruby-find/find v0.0.0-20260630081030-35072d185272/go.mod h1:wpq0ciEFn8zUc1vfuHRjF2U8mH0krhJCRHHr+JOGaEY= github.com/go-ruby-format/format v0.0.0-20260815195335-4038d17b79a1 h1:TgyhqiG4MVsDnL9X1SEhfEEfbdqWooPJFdbJJkyCpK8= github.com/go-ruby-format/format v0.0.0-20260815195335-4038d17b79a1/go.mod h1:1tuz12SHPOcnFSfdPYRFwVvRhMT4Xn4M4jfPFgM7pLA= +github.com/go-ruby-format/format v0.0.0-20260831112546-5cb7355e4072 h1:SVRU3Y3fX8aX9pvwaRJSCBUQOe+xGh3LurJCQabeeZc= +github.com/go-ruby-format/format v0.0.0-20260831112546-5cb7355e4072/go.mod h1:1tuz12SHPOcnFSfdPYRFwVvRhMT4Xn4M4jfPFgM7pLA= +github.com/go-ruby-format/format v0.0.0-20260831115501-f58c7d12507c h1:ffhla/GiFfRo9sfePiyW9kvXOx/Y0GYhIk+vinmBgTs= +github.com/go-ruby-format/format v0.0.0-20260831115501-f58c7d12507c/go.mod h1:1tuz12SHPOcnFSfdPYRFwVvRhMT4Xn4M4jfPFgM7pLA= github.com/go-ruby-friendly-id/friendly-id v0.0.0-20260707195609-ff85fef61060 h1:2DOgIkLoef9c3ZEn6qNoWU4Fq/YUIY5bX/iJ4AjE+PQ= github.com/go-ruby-friendly-id/friendly-id v0.0.0-20260707195609-ff85fef61060/go.mod h1:DfleWbrCkxABZECUbJBXgF+tBZaX/9UbjH8CimwfrCQ= github.com/go-ruby-getoptlong/getoptlong v0.0.0-20260629150025-1a1bfd19bc49 h1:v2Cy3bZTTi+u9XKBRP8FmYHwsWwZ+t8hlh3s4i1gGfQ= diff --git a/internal/vm/format_bind.go b/internal/vm/format_bind.go index 3aa667d1..c4027776 100644 --- a/internal/vm/format_bind.go +++ b/internal/vm/format_bind.go @@ -105,6 +105,61 @@ func (vm *VM) formatDispatchStr(o *RObject, name string) string { return r.ToS() } +// formatRespondsTo reports whether recv answers name for a sprintf coercion, +// the way MRI's rb_check_funcall probes it: a real method (including one on a +// singleton) answers directly, and an object that provides #respond_to? (so +// also #respond_to_missing? dispatch) is consulted dynamically. Unlike +// respondsToDynamic it never *sends* #respond_to? to an object that does not +// have it, so a BasicObject operand (which lacks #respond_to?) is probed +// without raising NoMethodError — matching MRI, which answers this at the C +// level for every object. +func (vm *VM) formatRespondsTo(recv object.Value, name string) bool { + if vm.respondsTo(recv, name) { + return true + } + if vm.respondsTo(recv, "respond_to?") { + return vm.send(recv, "respond_to?", []object.Value{object.Symbol(name)}, nil).Truthy() + } + return false +} + +// CoerceChar resolves a user object's %c operand with MRI's to_str-then-to_int +// protocol (sprintf.c's 'c' case): a String operand is used natively by the +// engine, so this hook only fires for a non-String, non-Integer value. It +// dispatches #to_str first — a String result feeds the engine's first-character +// rendering, a non-String result raises TypeError "can't convert X into String"; +// otherwise #to_int — an Integer result feeds the code-point rendering, a +// non-Integer result raises TypeError "can't convert X into Integer". An object +// answering neither raises TypeError "no implicit conversion of X into Integer", +// exactly as MRI's NUM2INT does. Method presence is probed with +// formatRespondsTo, so a BasicObject that defines only #to_str is coerced +// without ever being sent a #respond_to? it does not have. A non-RObject (String, +// Integer, nil, Array) returns ok=false so the engine keeps its Kind-based path +// and its own "no implicit conversion" / "invalid character" errors. +func (fv formatValue) CoerceChar() (format.Value, bool) { + o, ok := fv.v.(*RObject) + if !ok || fv.vm == nil { + return nil, false + } + vm := fv.vm + if vm.formatRespondsTo(o, "to_str") { + r := vm.send(o, "to_str", nil, nil) + if s, isStr := r.(*object.String); isStr { + return formatValue{v: s, vm: vm}, true + } + raise("TypeError", "can't convert %s into String", vm.classOf(o).name) + } + if vm.formatRespondsTo(o, "to_int") { + r := vm.send(o, "to_int", nil, nil) + if _, isInt := object.BigOf(r); isInt { + return formatValue{v: r, vm: vm}, true + } + raise("TypeError", "can't convert %s into Integer", vm.classOf(o).name) + } + raise("TypeError", "no implicit conversion of %s into Integer", vm.classOf(o).name) + return nil, false +} + // ClassName names the value's Ruby class for TypeError messages, mirroring // classNameOf so the library's messages are byte-identical to the former // formatter's. @@ -275,7 +330,21 @@ func (vm *VM) formatNamedArgs(args []object.Value) *format.NamedArgs { v, _ := h.Get(k) m[string(sym)] = formatValue{v: v, vm: vm} } - return format.NewNamedArgs(m) + na := format.NewNamedArgs(m) + // A %/%{name} reference to a key that is not a present symbol entry of + // the hash resolves through the hash's own #[] (Hash#default / default_proc), + // exactly as MRI does: a non-nil default is used, a nil one leaves the key + // unresolved so the engine raises the MRI KeyError. A present key (even one + // whose value is nil) is already in m above and never reaches this resolver, + // so `"%{foo}" % {foo: nil}` still renders "" rather than consulting a default. + na.SetDefault(func(name string) (format.Value, bool) { + r := vm.send(h, "[]", []object.Value{object.Symbol(name)}, nil) + if object.IsNil(r) { + return nil, false + } + return formatValue{v: r, vm: vm}, true + }) + return na } // formatString renders a Ruby format string with the given positional operands, @@ -360,7 +429,7 @@ func (vm *VM) vmFormatArgs(b object.Value) []object.Value { case *object.Hash: return []object.Value{b} } - if vm.respondsToDynamic(b, "to_ary") { + if vm.formatRespondsTo(b, "to_ary") { if r := vm.send(b, "to_ary", nil, nil); !object.IsNil(r) { if arr, ok := r.(*object.Array); ok { return arr.Elems diff --git a/internal/vm/format_c_named_test.go b/internal/vm/format_c_named_test.go new file mode 100644 index 00000000..6a66e693 --- /dev/null +++ b/internal/vm/format_c_named_test.go @@ -0,0 +1,122 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm_test + +import ( + "strings" + "testing" +) + +// TestSprintfCharCoercion covers formatValue.CoerceChar: MRI's %c operand +// coercion for a user object, dispatching #to_str before #to_int through the +// method table (never #respond_to?, so a BasicObject that defines only one of +// them is coerced without raising NoMethodError). Every expectation is +// byte-for-byte against MRI 4.0. +func TestSprintfCharCoercion(t *testing.T) { + tests := []struct{ name, src, want string }{ + // #to_str returning a String uses its first character. + {"to_str_object", `o=Object.new; def o.to_str; "abc"; end; p("%c" % o)`, "\"a\"\n"}, + // #to_str on a BasicObject (no #respond_to?) is still found and used. + {"to_str_basicobject", + `o=BasicObject.new; def o.to_str; "Q"; end; ::Kernel.p("%c" % o)`, "\"Q\"\n"}, + // With no #to_str, #to_int supplies a code point. + {"to_int_object", `o=Object.new; def o.to_int; 90; end; p("%c" % o)`, "\"Z\"\n"}, + // #to_str is preferred over #to_int when both are present. + {"to_str_over_to_int", + `o=Object.new; def o.to_str; "x"; end; def o.to_int; 90; end; p("%c" % o)`, "\"x\"\n"}, + // A plain String / Integer argument bypasses the hook (regression guard). + {"string_native", `p("%c" % "hi")`, "\"h\"\n"}, + {"integer_native", `p("%c" % 97)`, "\"a\"\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := eval(t, tc.src); got != tc.want { + t.Errorf("src=%q\n got=%q\nwant=%q", tc.src, got, tc.want) + } + }) + } +} + +// TestSprintfCharCoercionErrors covers CoerceChar's raising branches: a #to_str +// or #to_int result of the wrong type, and an object answering neither, each +// raising the MRI TypeError message verbatim. +func TestSprintfCharCoercionErrors(t *testing.T) { + tests := []struct{ name, src, wantClass, wantMsg string }{ + {"to_str_non_string", + `o=Object.new; def o.to_str; :foo; end; "%c" % o`, + "TypeError", "can't convert Object into String"}, + {"to_int_non_integer", + `o=Object.new; def o.to_int; :foo; end; "%c" % o`, + "TypeError", "can't convert Object into Integer"}, + {"neither", + `o=Object.new; "%c" % o`, + "TypeError", "no implicit conversion of Object into Integer"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := runErr(t, tc.src) + if err == nil { + t.Fatalf("src=%q: expected %s, got nil", tc.src, tc.wantClass) + } + if !strings.Contains(err.Error(), tc.wantClass) || !strings.Contains(err.Error(), tc.wantMsg) { + t.Errorf("src=%q: want %s %q, got %v", tc.src, tc.wantClass, tc.wantMsg, err) + } + }) + } +} + +// TestSprintfNamedHashDefault covers formatNamedArgs' NamedArgs default +// resolver: a %{name}/%s reference to a key absent from the hash consults +// the hash's own #[] (Hash#default / default_proc). A non-nil default is used; a +// nil one raises MRI's "key{NAME} not found" KeyError; a present key (even a nil +// value) never reaches the resolver. +func TestSprintfNamedHashDefault(t *testing.T) { + tests := []struct{ name, src, want string }{ + // Static default value. + {"default_value", `p("%{foo}" % Hash.new(123))`, "\"123\"\n"}, + // default_proc. + {"default_proc", `p("%{foo}" % Hash.new { 123 })`, "\"123\"\n"}, + // %s form also consults the default. + {"angle_form", `p("%s" % Hash.new(123))`, "\"123\"\n"}, + // A present key with a nil value renders "" (never hits the default). + {"present_nil", `p("%{foo}" % {foo: nil})`, "\"\"\n"}, + // A present key with a value wins over any default. + {"present_value", `p("%{foo}" % Hash.new(9).merge(foo: 1))`, "\"1\"\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := eval(t, tc.src); got != tc.want { + t.Errorf("src=%q\n got=%q\nwant=%q", tc.src, got, tc.want) + } + }) + } +} + +// TestSprintfNamedHashDefaultKeyError covers the resolver's nil-default branch: +// an absent key whose default is nil (a bare hash, Hash.new(nil), or a proc +// returning nil) raises MRI's KeyError with #key and #receiver set. +func TestSprintfNamedHashDefaultKeyError(t *testing.T) { + tests := []struct{ name, src string }{ + {"empty_hash", `"%{foo}" % {}`}, + {"default_nil", `"%{foo}" % Hash.new(nil)`}, + {"proc_nil", `"%{foo}" % Hash.new { nil }`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := runErr(t, tc.src) + if err == nil { + t.Fatalf("src=%q: expected KeyError, got nil", tc.src) + } + if !strings.Contains(err.Error(), "KeyError") || !strings.Contains(err.Error(), "key{foo} not found") { + t.Errorf("src=%q: want KeyError key{foo} not found, got %v", tc.src, err) + } + }) + } + // #key and #receiver are set on the raised KeyError. + got := eval(t, `h={}; begin; "%{foo}" % h; rescue KeyError => e; p [e.key, e.receiver.equal?(h)]; end`) + if want := "[:foo, true]\n"; got != want { + t.Errorf("key/receiver: got=%q want=%q", got, want) + } +}