Skip to content

String/IO encoding conformance: gsub buffer negotiation, paragraph mode, $/, grapheme clusters (+44) - #656

Merged
tannevaled merged 6 commits into
mainfrom
wave30/kern
Sep 25, 2026
Merged

tannevaled merged 6 commits into
mainfrom
wave30/kern

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

What this is

MRI-4.0.x conformance for the special globals, the String encoding paths, the
regexp-backed String methods and IO's paragraph reader. Every change is derived
from the MRI C, cited in the commit that makes it, and checked byte for byte
against ruby 4.0.5 before it was kept.

ruby/spec language + core, measured serially on the same corpus and the
same shim: 22227 → 22271 passing (2206 files).
No file regressed. The floor is
22140.

The measurement this wave used

Running MRI itself under scripts/conformance/rubyspec/spec_helper.rb gives
a per-file ceiling, which separates a real defect from an artefact of the shim.
It changed what was worth doing:

  • language/predefined_spec.rb reads 124 for rbgo and 153 for MRI, not 168 —
    the five $//$-0/$\/$,/$; "warns if assigned non-nil" examples and the
    two $= ones fail for MRI too (rb_warn_deprecated needs
    Warning[:deprecated], which only -w turns on), and so does the
    MSpecScript one. Making them pass would mean diverging from MRI, so they
    were left alone.
  • core/io/write_spec.rb, listed as a target, has no headroom at all: MRI
    under this shim scores it at or below rbgo.
  • The handed-over gsub slice-anchoring lead was already fixed —
    "Text\n".gsub(/^/, " ") is " Text\n" on this branch's parent. The ten
    gsub_spec failures were all encoding, which is what this PR fixes.

What changed

String#sub / #gsub negotiate the result encoding per append

str_gsub (string.c) seeds dest with the receiver's encoding and a 7BIT
coderange, appends the literal runs with rb_enc_str_buf_cat(…, str_enc) and
every replacement with rb_str_buf_append. Both land in
rb_enc_cr_str_buf_cat, the only place the result's encoding is decided. rbgo
used a plain strings.Builder and tagged the result UTF-8 unconditionally, so a
no-match gsub retagged a US-ASCII receiver, a block returning BINARY bytes
never promoted the buffer, and an irreconcilable pair never raised.
stringSub also now runs rb_reg_prepare_enc — but only for a Regexp pattern,
because rb_pat_search sends a String pattern to rb_str_byteindex instead.

IO paragraph mode swallows the trailing newline run

rb_io_getline_1 brackets a paragraph read with two swallow(fptr, '\n')
calls. rbgo had only the leading one, so an ordinary gets after a paragraph
read returned "\n" instead of the next line. The trailing one runs even when a
limit cut the read short, and stringio.c has no equivalent.

$/ is seeded to "\n"

Init_IO sets rb_default_rs = rb_fstring_lit("\n") before hooking $/ and
$-0 up, so an unassigned $/ reads a frozen US-ASCII "\n" and $-0 is the
same object. rbgo read nil through both.

#lines / #each_line resolve the separator the way MRI does

rb_str_enumerate_lines reads $/ when no argument is given — including a
$/ set to nil
, which yields the whole receiver un-chomped — and re-encodes
the default separator into a non-ASCII-compatible receiver's encoding before the
byte search, which is why a dummy UTF-16 string is never split and a dummy UTF-7
one raises.

#chomp and #bytesplice read characters, not bytes

chompped_length's smart_chomp arm removes one newline character and then
a '\r' character, so "abc\r\n".encode("utf-32be").chomp drops eight bytes.
rb_str_bytesplice negotiates with rb_enc_check over the whole of both
strings before the update, so a 7-bit receiver adopts the source's encoding.

#=~, #match, #match?

rb_str_match sends #=~ to any operand that is neither a String nor a Regexp;
rb_str_match_m coerces with get_pat and then dispatches #match on the
pattern, yielding the MatchData to a block; rb_str_match_m_p honours the
position argument. rbgo raised TypeError for a non-Regexp, ignored the block and
dropped match?'s second argument. Regexp#match? now shares the same
regexpMatchP.

The BOM encodings UTF-16 / UTF-32 transcode

String#encode could not reach them at all, so every spec that builds a
dummy-encoded string died before it could measure anything.
transcode.c's converters write a big-endian BOM and read one back, with no
fallback endianness. With the strings constructible, rb_must_asciicompat
became visible: it runs at the head of rb_str_to_dbl, rb_str_to_inum and
rb_str_to_c, before a byte is read and regardless of exception: false.

Grapheme clusters and #chars are questions the encoding answers

rb_str_enumerate_grapheme_clusters falls back to rb_str_enumerate_chars for
a non-Unicode encoding and otherwise clusters in the receiver's own bytes;
WANTARRAY means the block form yields and returns the receiver. Underneath,
strCharPieces was rb_enc_mbclen for ASCII-8BIT and UTF-8 and a UTF-8 guess
for everything else — now it measures each family, including the Shift_JIS and
EUC-JP lead-byte tables.

Per file, before → after (ceiling = MRI under this shim)

file before after ceiling
core/string/gsub_spec.rb 67 76 77
core/string/chomp_spec.rb 61 65 65
core/string/match_spec.rb 19 23 24
core/string/grapheme_clusters_spec.rb 9 13 13
core/string/lines_spec.rb 20 23 23
core/string/each_line_spec.rb 20 23 23
core/string/bytesplice_spec.rb 37 40 40
core/io/gets_spec.rb 39 42 44
core/kernel/Complex_spec.rb 60 62 62
core/string/each_grapheme_cluster_spec.rb 13 15 15
core/string/chars_spec.rb 10 11 11
core/string/each_char_spec.rb 10 11 11
core/symbol/match_spec.rb 7 9 9
core/string/sub_spec.rb 64 65 66
core/string/to_c_spec.rb 37 38 38
core/string/to_f_spec.rb 20 21 21
core/io/readlines_spec.rb 53 54 58

Tree totals: core/string 3566 → 3603, core/io 1354 → 1358, core/kernel 1995 →
1997, language unchanged (everything reachable there lives in files this wave
does not own).

core/random/bytes_spec.rb samples 8 or 9 on both this branch and main over
four runs each — a pre-existing flake, not a regression.

Gates

  • CGO_ENABLED=0 go build ./..., CGO_ENABLED=0 GOWORK=off go test ./... — full
    suite green.
  • Coverage: the below-100 list is identical to main's, function for function
    (99.9% both). Comparing the list rather than the rounded total found two real
    defects — see the commit "Whitebox cover this wave".
  • gofmt -l internal/vm/ empty; GOOS=wasip1 GOARCH=wasm go build ./... green.

Found but not fixed — these live in files this wave does not own

  • internal/vm/prelude.rb — Tempfile#initialize passes mode: straight to
    File.open; MRI's tempfile.rb defaults it to 0 and ORs
    File::RDWR|CREAT|EXCL into it, so Tempfile.new(name, mode: File::RDONLY)
    ENOENTs here. 8 examples in core/io/copy_stream_spec.rb. Note the fix
    needs the default to change from "w+" to 0, which existing String-mode
    callers would feel.
  • Native methods cannot tell a keyword Hash from a positional one. IO.gets,
    #readlines and IO.foreach must raise TypeError for a positional options
    Hash (prepare_getline_args: StringValue(rs) / NUM2LONG(lim)); rbgo strips
    any trailing Hash as options. Needs a flag at the native-call boundary
    (vm.go/proc.go/compiler). 7 examples across readlines/foreach/gets.
  • internal/vm/reflection.go — Method#source_location returns line 1 for
    every method. Kernel#warn(uplevel:) itself is byte-identical to MRI; the four
    core/kernel/warn_spec.rb failures are the fixture computing its expected line
    numbers from source_location. 4 examples, and probably more elsewhere.
  • internal/vm/format_bind.go (and the go-ruby-format engine) — sprintf
    does no encoding negotiation, so String#% cannot preserve or promote the
    format string's encoding. 4–5 examples in core/string/modulo_spec.rb.
  • internal/object/object.go — String#inspect escapes every byte of a
    non-UTF-8 string instead of decoding it, so a UTF-16BE string inspects as
    "\x00a\x00b" rather than "ab". 3 examples in
    core/string/inspect_spec.rb. Symbols also carry no encoding, which is the
    whole of core/string/to_sym_spec.rb's remaining 3.
  • internal/vm/require.go — $LOAD_PATH is empty and has no
    #resolve_feature_path. 4 examples in language/predefined_spec.rb.
  • internal/vm/vm.go / exception.go — $! is not cleared when an
    exception is rescued and not restored to the enclosing value afterwards, and
    $~ is global rather than method-scoped. 11 examples in
    language/predefined_spec.rb, 1 in core/string/gsub_spec.rb.
  • internal/compiler — $& = "" should be a SyntaxError "Can't set variable
    $&" at parse time, and nil = 1 / self = 1 should carry MRI's messages.
    8 examples in language/predefined_spec.rb.
  • internal/vm/object_model.go — alias $new $& copies the value instead of
    sharing the variable, so the alias is not read-only. Part of the 8 above.
  • github.com/go-ruby-regexp/regexp (upstream module) — conditional
    backreferences (?(1)a|b), \k<-1>, \k<01>, forward references, (?~), and
    inline (?imx-imx) are unimplemented. 9 examples in
    language/regexp/back-references_spec.rb, 5 in modifiers_spec.rb.
  • internal/vm/rational.go — String#to_r and Kernel#Rational still parse
    a non-ASCII-compatible String's bytes as digits; the same
    rb_must_asciicompat one-liner this PR adds elsewhere fixes them.
  • internal/vm/io_descriptors.go (syswrite, ungetc) and
    io_class.go (copy_stream) hold the rest of core/io's headroom.

🤖 Generated with Claude Code

tannevaled and others added 6 commits September 25, 2026 10:09
str_gsub (string.c v3_4_0) seeds its dest buffer with the RECEIVER's
encoding and a 7BIT coderange, appends the literal runs between matches
with rb_enc_str_buf_cat(dest, cp, len, str_enc) and every replacement
with rb_str_buf_append(dest, val). Both land in rb_enc_cr_str_buf_cat
(string.c:3521), which is the only place the result's encoding is
decided:

    if (str_encindex != ptr_encindex &&
        str_cr != 7BIT && ptr_cr != 7BIT)   -> incompatible
    if (str_cr == 7BIT)  res = (ptr_cr == 7BIT) ? str_enc : ptr_enc
    if (str_cr == VALID) res = str_enc

rbgo built the result with a plain strings.Builder and tagged it UTF-8
unconditionally, so every one of those outcomes was wrong: a no-match
gsub retagged a US-ASCII receiver as UTF-8, a block returning BINARY
bytes never promoted the buffer, and an irreconcilable pair never
raised.

subEncBuilder (regexp.go) carries the buffer's encoding and coderange
and applies that negotiation on each append. gsub/gsubHash feed it the
literal runs in the receiver's encoding and each replacement in the
encoding rb_obj_as_string gave it; objAsStringVal (builtins.go) is
objAsString keeping the String object so that encoding survives. The
bang arm now also copies dest's encoding onto the receiver, as
str_shared_replace does.

stringSub additionally runs checkSubjectEncoding for a Regexp pattern:
rb_pat_search splits on the pattern's type, and only the Regexp arm
reaches rb_reg_prepare_enc, which raises ArgumentError "invalid byte
sequence in <enc>" on a BROKEN subject. A String pattern goes through
rb_str_byteindex and never sees it.

core/string/gsub_spec.rb 67 -> 76 (MRI under the same shim: 77)
core/string/sub_spec.rb   64 -> 65 (MRI: 66)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two divergences found by running MRI itself under rbgo's own MSpec shim,
which gives a per-file ceiling and so separates real defects from
harness artefacts.

1. io.c v3_4_0 rb_io_getline_1 brackets a paragraph read (an empty
   separator) with TWO swallow(fptr, '\n') calls:

       if (rslen == 0) { rsptr = "\n\n"; rslen = 2; rspara = 1;
                         swallow(fptr, '\n'); ... }
       ...
       if (rspara && c != EOF) swallow(fptr, '\n');

   rbgo had only the leading one. The returned string keeps just the two
   separator newlines, but the STREAM must advance past the whole run, so
   an ordinary gets after a paragraph read returned "\n" instead of the
   next line. The post-read swallow runs even when a limit cut the read
   short, and stringio.c has no equivalent, so it is gated on the stream
   not being a StringIO — which is the same distinction the result-length
   rule above it already draws.

2. io.c Init_IO seeds the input record separator before hooking it up:

       rb_default_rs = rb_fstring_lit("\n");
       rb_rs = rb_default_rs;
       rb_define_hooked_variable("$/",  &rb_rs, 0, deprecated_str_setter);
       rb_define_hooked_variable("$-0", &rb_rs, 0, deprecated_str_setter);

   so an un-assigned $/ reads a frozen US-ASCII "\n", not nil, and $-0 is
   the same object. rbgo read nil through both. gets/chomp already
   defaulted to "\n" internally, so only the observable value changes.

core/io/gets_spec.rb 39 -> 41 (MRI under the same shim: 44)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ch family

Four independent divergences, each measured against MRI running under
rbgo's own MSpec shim so the per-file ceiling is known.

String#lines / #each_line — string.c v3_4_0 rb_str_enumerate_lines
resolves the separator once, before it looks at chomp:

    if (rb_scan_args(argc, argv, "01:", &rs, &opts) == 0) rs = rb_rs;
    ...
    if (NIL_P(rs)) { ENUM_ELEM(ary, str); return …; }

so an absent argument reads $/ INCLUDING a $/ set to nil, and the nil
case yields the whole receiver un-chomped (the early return precedes any
chomping). rbgo only consulted $/ when it held a String, so a nil $/
silently fell back to "\n", and lines(nil, chomp: true) chomped.

String#chomp / #chomp! — chompped_length's smart_chomp arm removes one
trailing newline CHARACTER and then a '\r' character before it, read in
the receiver's encoding:

    if (rb_enc_mbminlen(enc) > 1) {
        pp = rb_enc_left_char_head(p, e-rb_enc_mbminlen(enc), e, enc);
        if (rb_enc_is_newline(pp, e, enc)) e = pp;
        ...
    }

so "abc\r\n".encode("utf-32be").chomp drops eight bytes. rbgo chomped
bytes, which was right for every ASCII-compatible encoding and left half
a character behind in UTF-16/UTF-32.

String#bytesplice — rb_str_bytesplice negotiates between the bounds
checks and the update:

    if (RB_UNLIKELY(ENCODING_GET_INLINED(str) != ENCODING_GET_INLINED(val)))
        rb_enc_associate(str, rb_enc_check(str, val));

reading the WHOLE of both strings, so a 7-bit receiver adopts the
source's encoding. rbgo kept the receiver's tag.

String#=~ / #match / #match? — rb_str_match sends #=~ to ANY operand
that is neither a String nor a Regexp; rb_str_match_m coerces with
get_pat and then DISPATCHES #match on the pattern object, yielding the
MatchData to a block and returning the block's value; rb_str_match_m_p
honours the position argument and calls rb_reg_match_p directly.  rbgo
raised TypeError for a non-Regexp, ignored the block, and dropped
match?'s second argument. Regexp#match? now routes through the same
regexpMatchP so the two spellings cannot drift.

core/string 3566 -> 3589, per file:
  bytesplice 37->40 (ceiling 40)   chomp     61->65 (65)
  each_line  20->21 (23)           gsub      67->76 (77)
  lines      20->21 (23)           match     19->23 (24)
  sub        64->65 (66)
No file regressed; language/ unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… entries

String#encode could not reach Ruby's two BOM-carrying Unicode encodings
at all, so every spec that builds a dummy-encoded string through
"…".encode("UTF-16") died on Encoding::ConverterNotFoundError before it
could measure anything — across core/string, core/io and core/kernel.

transcode.c v3_4_0 defines both directions: to_UTF-16 writes a
big-endian byte-order mark then UTF-16BE, to_UTF-32 writes 00 00 FE FF
then UTF-32BE, and the from_ converters read the mark to choose the
endianness, with no fallback — a string with no mark is an invalid byte
sequence, not a big-endian default. string_encode.go now does exactly
that (splitBOM / bomFor), and stringEncode lets the two names past the
converter check.

With the strings constructible, three more divergences became visible.

rb_str_enumerate_lines re-encodes the separator into the receiver's
encoding before the byte search when the receiver is not
ASCII-compatible and the separator is still the default:

    if ((rs == rb_default_rs) && !rb_enc_asciicompat(enc)) {
        rs = rb_str_new(rsptr, rslen);
        rs = rb_str_encode(rs, rb_enc_from_encoding(enc), 0, Qnil);
    }

which is why a dummy UTF-16 string is never split (the search is for
FE FF 00 0A) and a dummy UTF-7 one raises instead. The rs identity test
is now expressible because $/ has a real rb_default_rs object.

encoding.c rb_must_asciicompat:

    if (!rb_enc_asciicompat(enc))
        rb_raise(rb_eEncCompatError, "ASCII incompatible encoding: %s", …);

runs at the head of rb_str_to_dbl, rb_str_to_inum and rb_str_to_c,
before a single byte is read and regardless of `exception: false`.
String#to_f/#to_i/#to_c and Kernel#Float/#Integer/#Complex now apply it;
String#to_r and Kernel#Rational live in rational.go, which this wave does
not own, and still parse the bytes as digits.

core/string 3589 -> 3595, core/kernel 1995 -> 1996, core/io 1356 -> 1358:
  to_f 20->21 (ceiling 21)   to_c   37->38 (38)   to_i 57 (57, already)
  lines 21->23 (23)          each_line 21->23 (23)
  Complex 60->62 (62)        readlines 53->54 (58)  gets 41->42 (44)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wave30_kern_internal_test.go drives every branch this wave added, and
comparing the below-100 LIST against main's (not the rounded total)
turned up two of them that no test could reach because the code was
wrong.

io.go defaultGetsSep fell back to "\n" when $/ did not hold a String.
$/ is a String or nil and nothing else — deprecated_str_setter refuses
the rest — so that fallback WAS the nil case, and io.c v3_4_0
rb_io_getline_1 takes `rs = rb_rs` with no separator, where a NIL rs
leaves rsptr/rslen at zero and appendline never stops. So `$/ = nil;
io.gets` must return the whole remainder; rbgo returned one line, and
readlines/each_line split the same way. The line was only reachable at
all once $/ stopped reading nil by default, which is why it had been
100%-covered before and dropped to 66.7% here.

regexp.go getPat short-circuited a Regexp operand before calling
strMatchRegexp, orphaning that function's T_REGEXP arm (100% -> 87.5%
in a file the change otherwise only added to). strMatchRegexp already
returns a Regexp as the SAME object, which is what get_pat needs for
String#match's dispatch, so the short-circuit was redundant; getPat now
only interposes the #to_str coercion and lets all three arms through.

The below-100 list is now identical to main's, function for function.

Full ruby/spec language+core, serial 2-way: 22227 -> 22264 passing,
2206 files, no file regressed. core/symbol/match_spec.rb 7 -> 9 comes
along for free, Symbol#match sharing String's get_pat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
string.c v3_4_0 rb_str_enumerate_grapheme_clusters splits on the
encoding before it looks at a byte:

    rb_encoding *enc = get_encoding(str);
    if (!rb_enc_unicode_p(enc)) return rb_str_enumerate_chars(str, ary);
    ...
    while (ptr < end) {
        OnigPosition len = onig_match(reg_grapheme_cluster, …);
        if (len <= 0) break;
        ENUM_ELEM(ary, rb_str_subseq(str, ptr-ptr0, len));
        ptr += len;
    }

rbgo clustered every receiver's bytes as UTF-8, so a Latin-1 "é" came
back as one two-byte cluster and a UTF-16 string as a pile of invalid
bytes. Now a non-Unicode encoding — every single-byte set, ASCII-8BIT
and the DUMMY UTF-16/UTF-32, which carry no Oniguruma flags — is
enumerated by character, and a real UTF-16/UTF-32 string is clustered in
ITS bytes: the boundaries are found on the decoded text and mapped back
through the per-character widths, and the walk stops at the first byte
run that is not a character, exactly where onig_match returns <= 0.

rb_str_grapheme_clusters passes WANTARRAY(…), which is 0 when a block
is given, so #grapheme_clusters yields and returns the receiver like
#each_grapheme_cluster. rbgo declared no block parameter at all and
silently returned the Array.

Underneath, strCharPieces was rb_enc_mbclen for ASCII-8BIT and UTF-8 and
a UTF-8 guess for everything else. encCharLen now measures each family:
one byte for US-ASCII, ISO-8859-1 and every set whose x/text codec is a
charmap.Charmap; two or four for UTF-16 (surrogate pairs) and four for
UTF-32; and the Shift_JIS and EUC-JP lead-byte tables from Oniguruma's
enc/shift_jis.c and enc/euc_jp.c. The stateful sets (ISO-2022-JP) keep
the UTF-8 walk, since they cannot be measured one character at a time.

Per file, all four now at the MRI-under-this-shim ceiling:
  grapheme_clusters      9 -> 13   each_grapheme_cluster 13 -> 15
  chars                 10 -> 11   each_char             10 -> 11

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit fb83d59 into main Sep 25, 2026
7 checks passed
@tannevaled
tannevaled deleted the wave30/kern branch September 25, 2026 14:27
tannevaled added a commit that referenced this pull request Sep 25, 2026
Three units, and one of them closes the last wholly-absent subsystem.

  #654  TracePoint, 0 -> 69 of 75. core/tracepoint had been pass=0 across six
        censuses, untouched because it is hooks in the execution core rather
        than an API to complete — and because a tracepoint must report a line,
        which the VM could not do until #645. Events line/class/end/call/
        return/b_call/b_return/rescue, every accessor, enable with target:/
        target_line:/target_thread:, allow_reentry. Costs ~1-2% on every
        program; that trade is recorded and justified in issue #658, together
        with the zero-cost alternative (MRI rewrites instruction encodings
        rather than testing per instruction).
  #656  String/IO encoding conformance: gsub's result-encoding negotiation,
        IO paragraph mode's second swallow, $/ seeded to "\n", grapheme
        clusters, chomp character-wise, UTF-16/32 transcoding      (+44)
  #653  core/module: Module#dup RETURNED THE RECEIVER, Module#ruby2_keywords
        did not exist at all, visibility directives, const_source_location's
        search                                                     (+19)

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus at
87b1631, 16-way, TIMEOUT=25, three consecutive runs: 22359, 22359, 22358. The
floor is set thirteen below the lowest, the margin rule the last five bumps
used.

A caveat that belongs on this number rather than a later one: issue #655
records that the shim's `should.equal?` asserts NOTHING — it never reaches the
matcher — and it is used 765 times across 328 corpus files. An unknown share of
the total above is therefore vacuous. Fixing it will move the floor in an
unpredictable direction and the result must be re-measured, not assumed. The
number is the best we can currently measure, not a claim that every example
beneath it tests something.

Co-authored-by: Claude Opus 5 <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