Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
73 changes: 71 additions & 2 deletions internal/vm/format_bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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>/%{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,
Expand Down Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions internal/vm/format_c_named_test.go
Original file line number Diff line number Diff line change
@@ -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}/%<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"},
// %<name>s form also consults the default.
{"angle_form", `p("%<foo>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)
}
}