From 7ee9e63f981a519c92e70e94701bb146730a7241 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 31 Aug 2026 13:31:54 +0200 Subject: [PATCH 1/3] Match MRI %c coercion, Hash#default named refs, and hex-float # / 0-fill in sprintf Wire the format binding to the new go-ruby-format host hooks so Kernel#sprintf / Kernel#format / String#% match MRI 4.0.6 for cases the engine could not reach alone: - formatValue.CoerceChar resolves a %c operand with MRI's to_str-then-to_int protocol via the method table (never #respond_to?, so a BasicObject that defines only #to_str is coerced): a String feeds first-character rendering, an Integer a code point, a non-String/#to_int result raises the matching "can't convert X into String/Integer" TypeError, and an object answering neither raises "no implicit conversion of X into Integer". - formatNamedArgs registers a NamedArgs default resolver so a %/%{name} reference to an absent key consults the hash's own #[] (Hash#default / default_proc): a non-nil default is used, a nil one raises MRI's KeyError. A present key (even a nil value) stays in the map and never hits the resolver. The hex-float alternate-form (%#a) and zero-fill (%020a), the "%*1$.*2$3$d" argument-numbered star width/precision, and the width/precision "too big" ArgumentError are fixed in the engine (go-ruby-format/format#7) and land here through the dependency bump. core/string/modulo_spec: 252 -> 263 examples pass. Co-Authored-By: Claude Opus 4.8 --- go.mod | 2 +- go.sum | 2 ++ internal/vm/format_bind.go | 53 +++++++++++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 1f6eb690..ef4625b6 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-20260831112546-5cb7355e4072 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..479733a2 100644 --- a/go.sum +++ b/go.sum @@ -425,6 +425,8 @@ 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-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..9683fc8a 100644 --- a/internal/vm/format_bind.go +++ b/internal/vm/format_bind.go @@ -105,6 +105,43 @@ func (vm *VM) formatDispatchStr(o *RObject, name string) string { return r.ToS() } +// 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 through the method +// table (not #respond_to?), so a BasicObject that defines only #to_str is +// coerced without touching a method 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.respondsTo(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.respondsTo(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 +312,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, From 491dd0385536a790e5cefcb8afa51008ee4aa53b Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 31 Aug 2026 13:36:07 +0200 Subject: [PATCH 2/3] Probe %c/to_ary coercion methods without sending #respond_to? to a BasicObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit String#% unpacked its right-hand side by asking respondsToDynamic whether the object answered #to_ary, whose fallback *sends* #respond_to? — which a BasicObject operand does not have, so `"%c" % basic_object_with_to_str` raised NoMethodError instead of coercing. Add formatRespondsTo, which probes the method table first and only dispatches #respond_to? (and thus #respond_to_missing?) to an object that actually provides it, matching MRI's rb_check_funcall. Use it for the %c to_str/to_int coercion and the to_ary unpacking. Covers CoerceChar and the named-hash default resolver with a new format_c_named_test.go. Co-Authored-By: Claude Opus 4.8 --- internal/vm/format_bind.go | 30 +++++-- internal/vm/format_c_named_test.go | 122 +++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 internal/vm/format_c_named_test.go diff --git a/internal/vm/format_bind.go b/internal/vm/format_bind.go index 9683fc8a..c4027776 100644 --- a/internal/vm/format_bind.go +++ b/internal/vm/format_bind.go @@ -105,6 +105,24 @@ 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 @@ -113,9 +131,9 @@ func (vm *VM) formatDispatchStr(o *RObject, name string) 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 through the method -// table (not #respond_to?), so a BasicObject that defines only #to_str is -// coerced without touching a method it does not have. A non-RObject (String, +// 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) { @@ -124,14 +142,14 @@ func (fv formatValue) CoerceChar() (format.Value, bool) { return nil, false } vm := fv.vm - if vm.respondsTo(o, "to_str") { + 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.respondsTo(o, "to_int") { + 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 @@ -411,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) + } +} From 16ddfb0afa5a9b9f2f5f813b7d9bbd7812f52afc Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 31 Aug 2026 13:58:02 +0200 Subject: [PATCH 3/3] chore(deps): re-pin go-ruby-format/format to merged main Engine PR #7 merged; repoint from the PR-branch pseudoversion to the resulting main pseudoversion so the sprintf/format binding builds against released engine code. Co-Authored-By: Claude Opus 4.8 --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index ef4625b6..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-20260831112546-5cb7355e4072 + 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 479733a2..9cfd9d25 100644 --- a/go.sum +++ b/go.sum @@ -427,6 +427,8 @@ github.com/go-ruby-format/format v0.0.0-20260815195335-4038d17b79a1 h1:TgyhqiG4M 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=