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
78 changes: 77 additions & 1 deletion internal/vm/globals.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,16 @@ func (vm *VM) storeGVar(name string, v object.Value) {
}
vm.lastMatch = object.NilV
return
case "$/", "$-0", `$\`, "$,":
case "$/", "$-0":
// ⛔ $/ and $-0 take a DIFFERENT setter from $\ and $, since 4.0, and
// reading the four as one would carry the new freezing into two
// variables MRI deliberately left alone (io.c v4.0.9 Init_IO):
// rb_define_hooked_variable("$,", …, rb_deprecated_str_setter);
// rb_define_hooked_variable("$/", …, deprecated_rs_setter);
// rb_define_hooked_variable("$-0", …, deprecated_rs_setter);
// rb_define_hooked_variable("$\\", …, rb_deprecated_str_setter);
v = vm.checkRecordSepGvar(name, v)
case `$\`, "$,":
v = vm.checkStrGvar(name, v)
case "$;", "$-F":
v = vm.checkFieldSepGvar(name, v)
Expand Down Expand Up @@ -451,6 +460,73 @@ func (vm *VM) checkStrGvar(name string, v object.Value) object.Value {
return v
}

// checkRecordSepGvar applies io.c v4.0.9 deprecated_rs_setter to $/ and $-0,
// which from Ruby 4.0 no longer store the object they were handed:
//
// 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);
// }
//
// ⛔ THE EQUALITY BRANCH COMES FIRST, and it overrides the "a frozen bare String
// is kept" rule for the one value most likely to be assigned. A frozen, plain
// "\n" satisfies that rule word for word and is still NOT what gets stored:
//
// $/ = "\n".dup.freeze
// $/.equal?(that string) # => false
// $/.equal?($/ as it was) # => true, the canonical separator
//
// Measured on ruby 4.0.7 before this was written. No example in ruby/spec
// reaches it — language/predefined_spec.rb uses "abc" throughout, which can only
// take the second branch — so the suite passes either way and the identity is
// wrong. Proposed upstream as ruby/spec#1410.
func (vm *VM) checkRecordSepGvar(name string, v object.Value) object.Value {
v = vm.checkStrGvar(name, v)
if object.IsNil(v) {
return v
}
s := v.(*object.String)
// rb_str_equal against a one-byte US-ASCII "\n": byte equality decides it.
// Any ASCII-compatible encoding holding that single byte IS == "\n", and an
// ASCII-incompatible one (UTF-16, UTF-32) cannot hold it in one byte at all,
// so there is no case where the encodings would change the answer.
if s.Str() == defaultRecordSeparator.Str() {
return defaultRecordSeparator
}
return frozenBareString(s)
}

// frozenBareString is string.c v4.0.9 rb_str_frozen_bare_string:
//
// if (BARE_STRING_P(orig) && OBJ_FROZEN_RAW(orig)) return orig;
// return str_new_frozen(rb_cString, orig);
//
// where BARE_STRING_P (string.c:372) is `RBASIC_CLASS(str) == rb_cString &&
// !rb_shape_obj_has_ivars(str)`. "Bare" is therefore BOTH halves: the exact
// class, AND no instance variables. A String subclass carrying @ivar fails both
// and a frozen plain String passes both, which is precisely what the three
// examples in language/predefined_spec.rb distinguish.
//
// ⛔ ONLY THE IVAR HALF IS TESTED HERE, and that is not an omission. In rbgo a
// *object.String is class String by construction (classOf maps the Go type
// straight to vm.cString), and an instance of a String SUBCLASS never reaches
// this function at all — checkStrGvar's type assertion rejects it first, with
// "value of $/ must be String". MRI accepts one, because its check is T_STRING
// and a subclass is still T_STRING. That divergence is a defect of its own,
// filed rather than papered over here: it is why
// predefined_spec.rb's "makes a new frozen String from the assigned String"
// still fails, and fixing it belongs with the assertion, not with this rule.
func frozenBareString(s *object.String) *object.String {
if s.Frozen && genericIvarsOf(s, false) == nil {
return s
}
out := object.NewString(s.Str())
out.Enc = s.Enc
out.Frozen = true
return out
}

// checkFieldSepGvar applies string.c v3_4_0 rb_fs_setter to $; / $-F: nil, a
// String or a Regexp is taken as is, anything else is offered #to_str
// (rb_fs_check's rb_check_string_type) before the TypeError, and a non-nil
Expand Down
86 changes: 86 additions & 0 deletions internal/vm/record_separator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package vm

import "testing"

// Ruby 4.0 stops storing the object assigned to $/ and $-0
// (io.c v4.0.9 deprecated_rs_setter, language/predefined_spec.rb:698-722).
//
// Every expectation below was measured on a real
// `ruby 4.0.7 (2026-09-15 revision 229531a6cf) [arm64-darwin25]` before it was
// written here, because two of them are not in ruby/spec at all.
func TestRecordSeparatorAssignment(t *testing.T) {
for _, tc := range []struct{ name, src, want string }{
{
"an unfrozen String is replaced by a frozen copy",
`$VERBOSE = nil; s = +"abc"; $/ = s; p [$/.equal?(s), $/.frozen?, $/ == s]`,
"[false, true, true]",
},
{
"a frozen String with no instance variables is kept",
`$VERBOSE = nil; s = "abc".freeze; $/ = s; p $/.equal?(s)`,
"true",
},

// ⛔ NOT IN ruby/spec. The equality branch runs BEFORE the "frozen bare
// string is kept" rule, so a frozen plain "\n" — which satisfies that
// rule word for word — is still replaced, by the canonical separator.
// Every example in predefined_spec.rb uses "abc" and so can only reach
// the other branch; proposed upstream as ruby/spec#1410.
{
"a String equal to the default becomes the default object",
`$VERBOSE = nil; d = $/; s = "\n".dup.freeze; $/ = s; p [$/.equal?(s), $/.equal?(d)]`,
"[false, true]",
},
{
"and so does an unfrozen one",
`$VERBOSE = nil; d = $/; $/ = +"\n"; p $/.equal?(d)`,
"true",
},

// $-0 shares the slot, so it takes the same setter.
{
"$-0 is replaced the same way",
`$VERBOSE = nil; s = +"abc"; $-0 = s; p [$-0.equal?(s), $-0.frozen?]`,
"[false, true]",
},
{
"and still reads as the same object as $/",
`$VERBOSE = nil; $/ = +"xyz"; p $-0.equal?($/)`,
"true",
},

// ⛔ CONTROLS: $\ and $, take rb_deprecated_str_setter, NOT this one
// (io.c v4.0.9 Init_IO). Carrying the freezing into them would be a
// silent divergence in the opposite direction, and nothing else in the
// suite asserts it.
{
`$\ keeps the object it is given`,
`$VERBOSE = nil; s = +"abc"; $\ = s; p [$\.equal?(s), $\.frozen?]`,
"[true, false]",
},
{
`$, keeps the object it is given`,
`$VERBOSE = nil; s = +"abc"; $, = s; p [$,.equal?(s), $,.frozen?]`,
"[true, false]",
},

// Unchanged by this: nil, the TypeError, and the returned value.
{"nil is accepted", `$/ = nil; p $/`, "nil"},
{
"a non-String is still refused without #to_str",
`begin; $/ = 1; rescue TypeError => e; p e.message; end`,
`"value of $/ must be String"`,
},
{
"assignment returns the value given, not the value stored",
`$VERBOSE = nil; s = +"xyz"; p((($/ = s)).equal?(s))`,
"true",
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := runSrc(t, tc.src); got != tc.want {
t.Errorf("%s\n got %q\nwant %q", tc.src, got, tc.want)
}
})
}
}
Loading