Skip to content

Match MRI %c coercion, Hash#default named refs, and hex-float #/0-fill in sprintf - #522

Merged
tannevaled merged 3 commits into
mainfrom
feat/agent-sprintf2
Aug 31, 2026
Merged

tannevaled merged 3 commits into
mainfrom
feat/agent-sprintf2

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Summary

Advances MRI 4.0.6 conformance of Kernel#sprintf / Kernel#format / String#%. core/string/modulo_spec rises 252 → 263 examples passing (fail 11 → 6, error 8 → 2); the remaining modulo failures are all encoding-subsystem (Encoding::CompatibilityError, US-ASCII/BINARY tracking) or the $DEBUG IOStub harness, outside this cluster.

Binding changes (internal/vm/format_bind.go)

  • %c operand coercion — formatValue.CoerceChar resolves a user object with MRI's to_str-then-to_int protocol: 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.
  • %<name> / %{name} Hash#default — formatNamedArgs registers a NamedArgs default resolver, so a 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 (with #key/#receiver set).
  • formatRespondsTo — probes a coercion method via the method table and only dispatches #respond_to? (hence #respond_to_missing?) to an object that actually provides it, matching MRI's rb_check_funcall. Fixes "%c" % basic_object_with_to_str, which previously raised NoMethodError: undefined method 'respond_to?' for BasicObject while unpacking the % right-hand side.

The hex-float alternate-form (%#a → 0x1.p+4) and zero-fill (%020a → 0x000…1.9p+3), the argument-numbered star width/precision (%*1$.*2$3$d), and the width/precision too big ArgumentError are fixed in the format engine (go-ruby-format/format#7) and land here through the dependency bump.

Dependency

Important

go.mod bumps github.com/go-ruby-format/format to a commit of the branch behind go-ruby-format/format#7, which is not yet merged. Merge that engine PR first, then re-pin this module to the resulting main pseudoversion before merging here.

Testing

  • GOWORK=off CGO_ENABLED=0 go test ./... — green.
  • GOOS=js GOARCH=wasm go build ./... — green.
  • gofmt -l on changed files — empty.
  • Merged coverage (-coverpkg over internal/...) — 100.0%; the new CoerceChar, formatRespondsTo, and formatNamedArgs are each 100%.
  • New internal/vm/format_c_named_test.go covers the coercion, default-resolver, and KeyError branches; every case verified byte-for-byte against ruby 4.0.

🤖 Generated with Claude Code

tannevaled and others added 3 commits August 31, 2026 13:31
…ill 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>/%{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 <noreply@anthropic.com>
…sicObject

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 4b49d9d into main Aug 31, 2026
7 checks passed
@tannevaled
tannevaled deleted the feat/agent-sprintf2 branch August 31, 2026 13:19
tannevaled added a commit that referenced this pull request Aug 31, 2026
* Raise ruby/spec floor to 17470 after wave-3 conformance work

Locks in the gains from #521 (regexp \xHH), #523 (String#encode
fallback/xml/IBM437/default_internal), #524 (Marshal Data + Regexp
encoding), #525 (Module#define_method arity/visibility/hooks) and #522
(sprintf %c/named-default/hex-float). Measured 17470 passing on the
pinned corpus, up from 17310.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: re-trigger after transient GOPROXY stream errors

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant