Repository navigation
String/IO encoding conformance: gsub buffer negotiation, paragraph mode, $/, grapheme clusters (+44) - #656
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.5before it was kept.ruby/spec
language+core, measured serially on the same corpus and thesame 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.rbgivesa per-file ceiling, which separates a real defect from an artefact of the shim.
It changed what was worth doing:
language/predefined_spec.rbreads 124 for rbgo and 153 for MRI, not 168 —the five
$//$-0/$\/$,/$;"warns if assigned non-nil" examples and thetwo
$=ones fail for MRI too (rb_warn_deprecatedneedsWarning[:deprecated], which only-wturns on), and so does theMSpecScriptone. Making them pass would mean diverging from MRI, so theywere left alone.
core/io/write_spec.rb, listed as a target, has no headroom at all: MRIunder this shim scores it at or below rbgo.
gsubslice-anchoring lead was already fixed —"Text\n".gsub(/^/, " ")is" Text\n"on this branch's parent. The tengsub_specfailures were all encoding, which is what this PR fixes.What changed
String#sub/#gsubnegotiate the result encoding per appendstr_gsub(string.c) seedsdestwith the receiver's encoding and a 7BITcoderange, appends the literal runs with
rb_enc_str_buf_cat(…, str_enc)andevery replacement with
rb_str_buf_append. Both land inrb_enc_cr_str_buf_cat, the only place the result's encoding is decided. rbgoused a plain
strings.Builderand tagged the result UTF-8 unconditionally, so ano-match
gsubretagged a US-ASCII receiver, a block returning BINARY bytesnever promoted the buffer, and an irreconcilable pair never raised.
stringSubalso now runsrb_reg_prepare_enc— but only for a Regexp pattern,because
rb_pat_searchsends a String pattern torb_str_byteindexinstead.IO paragraph mode swallows the trailing newline run
rb_io_getline_1brackets a paragraph read with twoswallow(fptr, '\n')calls. rbgo had only the leading one, so an ordinary
getsafter a paragraphread returned
"\n"instead of the next line. The trailing one runs even when alimit cut the read short, and
stringio.chas no equivalent.$/is seeded to"\n"Init_IOsetsrb_default_rs = rb_fstring_lit("\n")before hooking$/and$-0up, so an unassigned$/reads a frozen US-ASCII"\n"and$-0is thesame object. rbgo read
nilthrough both.#lines/#each_lineresolve the separator the way MRI doesrb_str_enumerate_linesreads$/when no argument is given — including a$/set tonil, which yields the whole receiver un-chomped — and re-encodesthe 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.
#chompand#bytespliceread characters, not byteschompped_length'ssmart_chomparm removes one newline character and thena
'\r'character, so"abc\r\n".encode("utf-32be").chompdrops eight bytes.rb_str_bytesplicenegotiates withrb_enc_checkover the whole of bothstrings before the update, so a 7-bit receiver adopts the source's encoding.
#=~,#match,#match?rb_str_matchsends#=~to any operand that is neither a String nor a Regexp;rb_str_match_mcoerces withget_patand then dispatches#matchon thepattern, yielding the MatchData to a block;
rb_str_match_m_phonours theposition argument. rbgo raised TypeError for a non-Regexp, ignored the block and
dropped
match?'s second argument.Regexp#match?now shares the sameregexpMatchP.The BOM encodings UTF-16 / UTF-32 transcode
String#encodecould not reach them at all, so every spec that builds adummy-encoded string died before it could measure anything.
transcode.c's converters write a big-endian BOM and read one back, with nofallback endianness. With the strings constructible,
rb_must_asciicompatbecame visible: it runs at the head of
rb_str_to_dbl,rb_str_to_inumandrb_str_to_c, before a byte is read and regardless ofexception: false.Grapheme clusters and
#charsare questions the encoding answersrb_str_enumerate_grapheme_clustersfalls back torb_str_enumerate_charsfora non-Unicode encoding and otherwise clusters in the receiver's own bytes;
WANTARRAYmeans the block form yields and returns the receiver. Underneath,strCharPieceswasrb_enc_mbclenfor ASCII-8BIT and UTF-8 and a UTF-8 guessfor 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)
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.rbsamples 8 or 9 on both this branch and main overfour runs each — a pre-existing flake, not a regression.
Gates
CGO_ENABLED=0 go build ./...,CGO_ENABLED=0 GOWORK=off go test ./...— fullsuite green.
(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#initializepassesmode:straight toFile.open; MRI's tempfile.rb defaults it to0and ORsFile::RDWR|CREAT|EXCLinto it, soTempfile.new(name, mode: File::RDONLY)ENOENTs here. 8 examples in
core/io/copy_stream_spec.rb. Note the fixneeds the default to change from
"w+"to0, which existing String-modecallers would feel.
IO.gets,#readlinesandIO.foreachmust raise TypeError for a positional optionsHash (
prepare_getline_args:StringValue(rs)/NUM2LONG(lim)); rbgo stripsany 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_locationreturns line 1 forevery method.
Kernel#warn(uplevel:)itself is byte-identical to MRI; the fourcore/kernel/warn_spec.rbfailures are the fixture computing its expected linenumbers from
source_location. 4 examples, and probably more elsewhere.internal/vm/format_bind.go(and thego-ruby-formatengine) —sprintfdoes no encoding negotiation, so
String#%cannot preserve or promote theformat string's encoding. 4–5 examples in
core/string/modulo_spec.rb.internal/object/object.go—String#inspectescapes every byte of anon-UTF-8 string instead of decoding it, so a UTF-16BE string inspects as
"\x00a\x00b"rather than"ab". 3 examples incore/string/inspect_spec.rb. Symbols also carry no encoding, which is thewhole of
core/string/to_sym_spec.rb's remaining 3.internal/vm/require.go—$LOAD_PATHis empty and has no#resolve_feature_path. 4 examples inlanguage/predefined_spec.rb.internal/vm/vm.go/exception.go—$!is not cleared when anexception is rescued and not restored to the enclosing value afterwards, and
$~is global rather than method-scoped. 11 examples inlanguage/predefined_spec.rb, 1 incore/string/gsub_spec.rb.internal/compiler—$& = ""should be a SyntaxError "Can't set variable$&" at parse time, and
nil = 1/self = 1should carry MRI's messages.8 examples in
language/predefined_spec.rb.internal/vm/object_model.go—alias $new $&copies the value instead ofsharing the variable, so the alias is not read-only. Part of the 8 above.
github.com/go-ruby-regexp/regexp(upstream module) — conditionalbackreferences
(?(1)a|b),\k<-1>,\k<01>, forward references,(?~), andinline
(?imx-imx)are unimplemented. 9 examples inlanguage/regexp/back-references_spec.rb, 5 inmodifiers_spec.rb.internal/vm/rational.go—String#to_randKernel#Rationalstill parsea non-ASCII-compatible String's bytes as digits; the same
rb_must_asciicompatone-liner this PR adds elsewhere fixes them.internal/vm/io_descriptors.go(syswrite,ungetc) andio_class.go(copy_stream) hold the rest of core/io's headroom.🤖 Generated with Claude Code