Skip to content

Commit 6eb7e4a

Browse files
tannevaledclaude
andauthored
$/ and $-0 stop storing the object they were handed (#886)
Ruby 4.0's deprecated_rs_setter (io.c v4.0.9:8734) replaces the assigned String unless it is already a frozen, bare one: rb_deprecated_str_setter(val, id, &val); // TypeError, and the warning if (!NIL_P(val)) { if (rb_str_equal(val, rb_default_rs)) val = rb_default_rs; else val = rb_str_frozen_bare_string(val); } ## Three things reading the source prevented 1. ⛔ $\ AND $, DO NOT TAKE THIS SETTER. Init_IO hooks them to rb_deprecated_str_setter and only $/ and $-0 to deprecated_rs_setter. rbgo handled all four through one checkStrGvar, so the obvious edit -- the one that does not read Init_IO -- would have frozen four variables where MRI freezes two. Both are now controls, and both were checked against a real MRI 4.0.7 rather than against my reading. 2. ⛔ THE EQUALITY BRANCH RUNS FIRST, so a frozen plain "\n" -- which satisfies "frozen and has no instance variables" word for word -- is still replaced, by the canonical separator object. No example in ruby/spec reaches it: language/predefined_spec.rb uses "abc" throughout, which can only take the other branch. Proposed upstream as ruby/spec#1410. 3. BARE_STRING_P (string.c:372) is BOTH halves -- exact class String AND no instance variables -- which is what makes the three existing examples distinguish each other. ## Measured against the oracle rbgo and ruby 4.0.7 now agree byte for byte on every case, including the two controls that assert $\ and $, were left alone: ors equal?=true frozen?=false <- $\, unchanged ofs equal?=true frozen?=false <- $,, unchanged ret true <- assignment returns the value GIVEN dash0 true <- $-0 and $/ stay one object ## A limit this does not fix, named rather than papered over predefined_spec.rb's "makes a new frozen String from the assigned String" assigns a String SUBCLASS, and rbgo refuses that one step earlier -- checkStrGvar decides with a Go type assertion where MRI tests T_STRING. Filed as #884. Widening the assertion touches every "$x must be String" global, so it is a separate change and not a rider on this one. Refs #799 Refs #884 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 20839f4 commit 6eb7e4a

2 files changed

Lines changed: 163 additions & 1 deletion

File tree

‎internal/vm/globals.go‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,16 @@ func (vm *VM) storeGVar(name string, v object.Value) {
412412
}
413413
vm.lastMatch = object.NilV
414414
return
415-
case "$/", "$-0", `$\`, "$,":
415+
case "$/", "$-0":
416+
// ⛔ $/ and $-0 take a DIFFERENT setter from $\ and $, since 4.0, and
417+
// reading the four as one would carry the new freezing into two
418+
// variables MRI deliberately left alone (io.c v4.0.9 Init_IO):
419+
// rb_define_hooked_variable("$,", …, rb_deprecated_str_setter);
420+
// rb_define_hooked_variable("$/", …, deprecated_rs_setter);
421+
// rb_define_hooked_variable("$-0", …, deprecated_rs_setter);
422+
// rb_define_hooked_variable("$\\", …, rb_deprecated_str_setter);
423+
v = vm.checkRecordSepGvar(name, v)
424+
case `$\`, "$,":
416425
v = vm.checkStrGvar(name, v)
417426
case "$;", "$-F":
418427
v = vm.checkFieldSepGvar(name, v)
@@ -451,6 +460,73 @@ func (vm *VM) checkStrGvar(name string, v object.Value) object.Value {
451460
return v
452461
}
453462

463+
// checkRecordSepGvar applies io.c v4.0.9 deprecated_rs_setter to $/ and $-0,
464+
// which from Ruby 4.0 no longer store the object they were handed:
465+
//
466+
// rb_deprecated_str_setter(val, id, &val); // TypeError, and the warning
467+
// if (!NIL_P(val)) {
468+
// if (rb_str_equal(val, rb_default_rs)) val = rb_default_rs;
469+
// else val = rb_str_frozen_bare_string(val);
470+
// }
471+
//
472+
// ⛔ THE EQUALITY BRANCH COMES FIRST, and it overrides the "a frozen bare String
473+
// is kept" rule for the one value most likely to be assigned. A frozen, plain
474+
// "\n" satisfies that rule word for word and is still NOT what gets stored:
475+
//
476+
// $/ = "\n".dup.freeze
477+
// $/.equal?(that string) # => false
478+
// $/.equal?($/ as it was) # => true, the canonical separator
479+
//
480+
// Measured on ruby 4.0.7 before this was written. No example in ruby/spec
481+
// reaches it — language/predefined_spec.rb uses "abc" throughout, which can only
482+
// take the second branch — so the suite passes either way and the identity is
483+
// wrong. Proposed upstream as ruby/spec#1410.
484+
func (vm *VM) checkRecordSepGvar(name string, v object.Value) object.Value {
485+
v = vm.checkStrGvar(name, v)
486+
if object.IsNil(v) {
487+
return v
488+
}
489+
s := v.(*object.String)
490+
// rb_str_equal against a one-byte US-ASCII "\n": byte equality decides it.
491+
// Any ASCII-compatible encoding holding that single byte IS == "\n", and an
492+
// ASCII-incompatible one (UTF-16, UTF-32) cannot hold it in one byte at all,
493+
// so there is no case where the encodings would change the answer.
494+
if s.Str() == defaultRecordSeparator.Str() {
495+
return defaultRecordSeparator
496+
}
497+
return frozenBareString(s)
498+
}
499+
500+
// frozenBareString is string.c v4.0.9 rb_str_frozen_bare_string:
501+
//
502+
// if (BARE_STRING_P(orig) && OBJ_FROZEN_RAW(orig)) return orig;
503+
// return str_new_frozen(rb_cString, orig);
504+
//
505+
// where BARE_STRING_P (string.c:372) is `RBASIC_CLASS(str) == rb_cString &&
506+
// !rb_shape_obj_has_ivars(str)`. "Bare" is therefore BOTH halves: the exact
507+
// class, AND no instance variables. A String subclass carrying @ivar fails both
508+
// and a frozen plain String passes both, which is precisely what the three
509+
// examples in language/predefined_spec.rb distinguish.
510+
//
511+
// ⛔ ONLY THE IVAR HALF IS TESTED HERE, and that is not an omission. In rbgo a
512+
// *object.String is class String by construction (classOf maps the Go type
513+
// straight to vm.cString), and an instance of a String SUBCLASS never reaches
514+
// this function at all — checkStrGvar's type assertion rejects it first, with
515+
// "value of $/ must be String". MRI accepts one, because its check is T_STRING
516+
// and a subclass is still T_STRING. That divergence is a defect of its own,
517+
// filed rather than papered over here: it is why
518+
// predefined_spec.rb's "makes a new frozen String from the assigned String"
519+
// still fails, and fixing it belongs with the assertion, not with this rule.
520+
func frozenBareString(s *object.String) *object.String {
521+
if s.Frozen && genericIvarsOf(s, false) == nil {
522+
return s
523+
}
524+
out := object.NewString(s.Str())
525+
out.Enc = s.Enc
526+
out.Frozen = true
527+
return out
528+
}
529+
454530
// checkFieldSepGvar applies string.c v3_4_0 rb_fs_setter to $; / $-F: nil, a
455531
// String or a Regexp is taken as is, anything else is offered #to_str
456532
// (rb_fs_check's rb_check_string_type) before the TypeError, and a non-nil
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package vm
2+
3+
import "testing"
4+
5+
// Ruby 4.0 stops storing the object assigned to $/ and $-0
6+
// (io.c v4.0.9 deprecated_rs_setter, language/predefined_spec.rb:698-722).
7+
//
8+
// Every expectation below was measured on a real
9+
// `ruby 4.0.7 (2026-09-15 revision 229531a6cf) [arm64-darwin25]` before it was
10+
// written here, because two of them are not in ruby/spec at all.
11+
func TestRecordSeparatorAssignment(t *testing.T) {
12+
for _, tc := range []struct{ name, src, want string }{
13+
{
14+
"an unfrozen String is replaced by a frozen copy",
15+
`$VERBOSE = nil; s = +"abc"; $/ = s; p [$/.equal?(s), $/.frozen?, $/ == s]`,
16+
"[false, true, true]",
17+
},
18+
{
19+
"a frozen String with no instance variables is kept",
20+
`$VERBOSE = nil; s = "abc".freeze; $/ = s; p $/.equal?(s)`,
21+
"true",
22+
},
23+
24+
// ⛔ NOT IN ruby/spec. The equality branch runs BEFORE the "frozen bare
25+
// string is kept" rule, so a frozen plain "\n" — which satisfies that
26+
// rule word for word — is still replaced, by the canonical separator.
27+
// Every example in predefined_spec.rb uses "abc" and so can only reach
28+
// the other branch; proposed upstream as ruby/spec#1410.
29+
{
30+
"a String equal to the default becomes the default object",
31+
`$VERBOSE = nil; d = $/; s = "\n".dup.freeze; $/ = s; p [$/.equal?(s), $/.equal?(d)]`,
32+
"[false, true]",
33+
},
34+
{
35+
"and so does an unfrozen one",
36+
`$VERBOSE = nil; d = $/; $/ = +"\n"; p $/.equal?(d)`,
37+
"true",
38+
},
39+
40+
// $-0 shares the slot, so it takes the same setter.
41+
{
42+
"$-0 is replaced the same way",
43+
`$VERBOSE = nil; s = +"abc"; $-0 = s; p [$-0.equal?(s), $-0.frozen?]`,
44+
"[false, true]",
45+
},
46+
{
47+
"and still reads as the same object as $/",
48+
`$VERBOSE = nil; $/ = +"xyz"; p $-0.equal?($/)`,
49+
"true",
50+
},
51+
52+
// ⛔ CONTROLS: $\ and $, take rb_deprecated_str_setter, NOT this one
53+
// (io.c v4.0.9 Init_IO). Carrying the freezing into them would be a
54+
// silent divergence in the opposite direction, and nothing else in the
55+
// suite asserts it.
56+
{
57+
`$\ keeps the object it is given`,
58+
`$VERBOSE = nil; s = +"abc"; $\ = s; p [$\.equal?(s), $\.frozen?]`,
59+
"[true, false]",
60+
},
61+
{
62+
`$, keeps the object it is given`,
63+
`$VERBOSE = nil; s = +"abc"; $, = s; p [$,.equal?(s), $,.frozen?]`,
64+
"[true, false]",
65+
},
66+
67+
// Unchanged by this: nil, the TypeError, and the returned value.
68+
{"nil is accepted", `$/ = nil; p $/`, "nil"},
69+
{
70+
"a non-String is still refused without #to_str",
71+
`begin; $/ = 1; rescue TypeError => e; p e.message; end`,
72+
`"value of $/ must be String"`,
73+
},
74+
{
75+
"assignment returns the value given, not the value stored",
76+
`$VERBOSE = nil; s = +"xyz"; p((($/ = s)).equal?(s))`,
77+
"true",
78+
},
79+
} {
80+
t.Run(tc.name, func(t *testing.T) {
81+
if got := runSrc(t, tc.src); got != tc.want {
82+
t.Errorf("%s\n got %q\nwant %q", tc.src, got, tc.want)
83+
}
84+
})
85+
}
86+
}

0 commit comments

Comments
 (0)