diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go
index da9c5f7d..4e65f16c 100644
--- a/internal/vm/builtins.go
+++ b/internal/vm/builtins.go
@@ -917,6 +917,15 @@ func (vm *VM) bootstrap() {
vm.consts["NameError"].(*RClass).define("name", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
return getIvar(self, "@name")
})
+ // LoadError#path: the feature name that could not be loaded, stamped into
+ // @path when the LoadError is raised (vm.raiseLoadError in require.go). MRI
+ // declares it as a plain reader — error.c v3_4_0
+ // `rb_attr(rb_eLoadError, path, TRUE, FALSE, FALSE)` — and error.c
+ // raise_loaderror sets the ivar to the path AS PASSED, so a LoadError built by
+ // hand (LoadError.new("x").path) reports nil.
+ vm.consts["LoadError"].(*RClass).define("path", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
+ return getIvar(self, "@path")
+ })
// full_message(highlight:, order:): the MRI-shaped multi-line report. order:
// :top (default) leads with the raise-site frame + detailed message then the
// "\tfrom " tail; order: :bottom prints a "Traceback (most recent call
@@ -1959,6 +1968,29 @@ func (vm *VM) bootstrap() {
vm.cSymbol.define("intern", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
return self // MRI: Symbol#intern is an alias of Symbol#to_sym (returns self)
})
+ // Symbol#encoding: the encoding of the symbol's string (symbol.c v3_4_0
+ // sym_encoding is rb_obj_encoding(rb_sym2str(sym))). rb_str_intern re-tags an
+ // ASCII-only string as US-ASCII before interning it, whatever encoding it
+ // arrived in, and otherwise keeps the string's own encoding -- so :foobar is
+ // US-ASCII whether it came from a UTF-8 or a binary String.
+ //
+ // rbgo's Symbol is an untagged Go string, so the non-ASCII answer has to be
+ // derived from the bytes: valid UTF-8 is UTF-8 (the VM's default source
+ // encoding), and bytes that are not reports ASCII-8BIT. A symbol interned from
+ // a UTF-16LE or from a binary non-ASCII String therefore still reports UTF-8 /
+ // ASCII-8BIT by shape rather than by provenance; carrying the tag would mean
+ // widening object.Symbol itself.
+ vm.cSymbol.define("encoding", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
+ b := []byte(string(self.(object.Symbol)))
+ switch {
+ case asciiOnly(b):
+ return vm.internEncoding("US-ASCII")
+ case utf8.Valid(b):
+ return vm.internEncoding("UTF-8")
+ default:
+ return vm.internEncoding("ASCII-8BIT")
+ }
+ })
symStr := func(self object.Value) string { return string(self.(object.Symbol)) }
vm.cSymbol.define("<=>", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value {
o, ok := args[0].(object.Symbol)
@@ -2229,20 +2261,21 @@ func (vm *VM) bootstrap() {
return strEncOf(self, vm.chompSep(strOf(self), args))
})
vm.cString.define("chop", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
- return strEncOf(self, chopStr(strOf(self)))
+ return strEncOf(self, chopStr(strOf(self), self.(*object.String).Enc))
})
vm.cString.define("chars", func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value {
enc := self.(*object.String).Enc // each character keeps the receiver's encoding
+ pieces := strCharPieces(strOf(self), enc)
if blk != nil {
// The block form yields each character and returns the receiver (MRI).
- for _, r := range strOf(self) {
- vm.callBlock(blk, []object.Value{object.NewStringViewEnc(string(r), enc)})
+ for _, c := range pieces {
+ vm.callBlock(blk, []object.Value{object.NewStringViewEnc(c, enc)})
}
return self
}
- var out []object.Value
- for _, r := range strOf(self) {
- out = append(out, object.NewStringViewEnc(string(r), enc))
+ out := make([]object.Value, 0, len(pieces))
+ for _, c := range pieces {
+ out = append(out, object.NewStringViewEnc(c, enc))
}
return object.NewArrayFromSlice(out)
})
@@ -2302,8 +2335,8 @@ func (vm *VM) bootstrap() {
return enumFor(self, "each_char")
}
enc := self.(*object.String).Enc // each character keeps the receiver's encoding
- for _, r := range strOf(self) {
- vm.callBlock(blk, []object.Value{object.NewStringViewEnc(string(r), enc)})
+ for _, c := range strCharPieces(strOf(self), enc) {
+ vm.callBlock(blk, []object.Value{object.NewStringViewEnc(c, enc)})
}
return self
})
@@ -2379,6 +2412,7 @@ func (vm *VM) bootstrap() {
return object.NewArrayFromSlice(out)
})
vm.cString.define("split", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value {
+ args = vm.splitCheckArgs(self.(*object.String), args)
res := vm.stringSplit(strOf(self), self.(*object.String).Enc, args)
if blk == nil {
return res
@@ -2884,7 +2918,8 @@ func (vm *VM) bootstrap() {
return vm.strBang(self, func(s string) string { return vm.chompSep(s, args) })
})
vm.cString.define("chop!", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
- return vm.strBang(self, chopStr)
+ enc := self.(*object.String).Enc
+ return vm.strBang(self, func(s string) string { return chopStr(s, enc) })
})
vm.cString.define("squeeze!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value {
return vm.strBang(self, func(s string) string { return stringSqueeze(s, args) })
@@ -6673,6 +6708,48 @@ func (vm *VM) coerceStrIndexArgs(args []object.Value) []object.Value {
}
}
+// splitCheckArgs applies the two guards MRI's rb_str_split_m (string.c v3_4_0)
+// runs before it splits anything.
+//
+// mustnot_broken raises ArgumentError "invalid byte sequence in " for a
+// string whose bytes are not valid in its own encoding. MRI calls it on the
+// pattern when the pattern is a String (string.c:9237) and on the receiver in the
+// string and chars paths (9317, 9337); the awk and regexp paths reach the same
+// refusal through rb_enc_check and the regexp engine, so every split form rejects
+// a broken receiver -- witnessed against MRI 4.0.5 for the awk, " ", "", Regexp,
+// String and nil patterns alike. A binary (ASCII-8BIT) string is never broken.
+//
+// The limit is read with NUM2INT, an int (not long) conversion, so a value
+// outside the 32-bit range is a RangeError naming which end it overflowed.
+func (vm *VM) splitCheckArgs(self *object.String, args []object.Value) []object.Value {
+ mustNotBeBroken := func(s *object.String) {
+ if !validInEncoding(s.Bytes(), s.EncName()) {
+ raise("ArgumentError", "invalid byte sequence in %s", s.EncName())
+ }
+ }
+ mustNotBeBroken(self)
+ if len(args) > 0 {
+ if pat, ok := args[0].(*object.String); ok {
+ mustNotBeBroken(pat)
+ }
+ }
+ if len(args) > 1 && !object.IsNil(args[1]) {
+ lim := vm.toIntCoerce(args[1])
+ if lim > math.MaxInt32 {
+ raise("RangeError", "integer %d too big to convert to 'int'", lim)
+ }
+ if lim < math.MinInt32 {
+ raise("RangeError", "integer %d too small to convert to 'int'", lim)
+ }
+ // NUM2INT runs once in MRI, so the coerced value replaces the argument
+ // rather than leaving stringSplit to call #to_int a second time -- the
+ // split specs count the calls with a mock.
+ args = append(append([]object.Value(nil), args...), nil)[:len(args)]
+ args[1] = object.IntValue(lim)
+ }
+ return args
+}
+
// rngKwarg returns the object passed as the random: keyword of a shuffle/sample
// call, or nil when absent (then the VM's default generator is used).
func (vm *VM) rngKwarg(args []object.Value) object.Value {
@@ -7119,16 +7196,57 @@ func chompStr(s string) string {
return s
}
-// chopStr removes the last character (\r\n counts as one), as in Ruby.
-func chopStr(s string) string {
+// strCharPieces splits s into its characters in the encoding named by enc,
+// returning SLICES of s so every byte survives exactly as it was.
+//
+// "What is a character" is the encoding's question -- MRI steps through a string
+// with rb_enc_mbclen (string.c rb_str_each_char / rb_str_chars), which in
+// ASCII-8BIT is one byte per character and in UTF-8 returns 1 for a byte that
+// begins no valid sequence. Ranging over a Go string instead decodes it as UTF-8
+// unconditionally and hands back U+FFFD for each invalid byte, so "\xA4" in a
+// UTF-8 string came back as a three-byte replacement character and a binary
+// string was split on UTF-8 boundaries that mean nothing in it.
+//
+// utf8.DecodeRuneInString reports size 1 for an invalid byte, which is the same
+// fallback rb_enc_mbclen makes, so the loop advances identically.
+func strCharPieces(s, enc string) []string {
+ out := make([]string, 0, len(s))
+ if enc == "ASCII-8BIT" {
+ for i := 0; i < len(s); i++ {
+ out = append(out, s[i:i+1])
+ }
+ return out
+ }
+ for i := 0; i < len(s); {
+ _, size := utf8.DecodeRuneInString(s[i:])
+ out = append(out, s[i:i+size])
+ i += size
+ }
+ return out
+}
+
+// chopStr removes the last character of s in the encoding named by enc (a
+// trailing "\r\n" counting as one), as String#chop and String#chop! do.
+//
+// MRI's chopped_length (string.c v3_4_0) walks back with rb_enc_prev_char, so
+// "the last character" is a question the ENCODING answers: in ASCII-8BIT every
+// byte is a character and chop removes exactly one byte. Decoding as UTF-8
+// regardless -- which is what building a []rune does -- took two bytes off a
+// binary string, and on a UTF-8 string holding invalid bytes it was worse than
+// wrong about the length: []rune replaces each invalid byte with U+FFFD, so
+// re-encoding the prefix rewrote bytes chop never touched.
+func chopStr(s, enc string) string {
if strings.HasSuffix(s, "\r\n") {
return s[:len(s)-2]
}
- r := []rune(s)
- if len(r) == 0 {
+ if s == "" {
return ""
}
- return string(r[:len(r)-1])
+ if enc == "ASCII-8BIT" {
+ return s[:len(s)-1]
+ }
+ _, size := utf8.DecodeLastRuneInString(s)
+ return s[:len(s)-size]
}
// stringToInt mimics String#to_i(base): optional whitespace and sign, an optional
diff --git a/internal/vm/phaseb_test.go b/internal/vm/phaseb_test.go
index 405e7249..3aa0671f 100644
--- a/internal/vm/phaseb_test.go
+++ b/internal/vm/phaseb_test.go
@@ -256,9 +256,25 @@ func TestLoadPathRequire(t *testing.T) {
if cls, _ := evalErr(t, "$LOAD_PATH.unshift "+q+"\nrequire \"nope\""); cls != "LoadError" {
t.Errorf("missing require: got %s, want LoadError", cls)
}
- // A non-string $LOAD_PATH entry is skipped, and a valid one after it works.
- got = eval(t, "$LOAD_PATH.unshift "+q+"\n$LOAD_PATH.unshift 123\nrequire \"greet\"\nputs GREETING\n")
+ // A $LOAD_PATH entry that is not a String and answers neither #to_path nor
+ // #to_str is a TypeError, not an entry to skip: rb_find_file (file.c v3_4_0)
+ // runs rb_get_path over the load path, and rb_get_expanded_load_path coerces
+ // the WHOLE list before the search begins -- so the position of the bad entry
+ // does not matter. Witnessed against MRI 4.0.5 with 123 unshifted before and
+ // pushed after a directory that does hold the feature: both raise
+ // TypeError "no implicit conversion of Integer into String". This test
+ // previously pinned rbgo's older behaviour of silently skipping such an entry.
+ for _, src := range []string{
+ "$LOAD_PATH.unshift " + q + "\n$LOAD_PATH.unshift 123\nrequire \"greet\"\n",
+ "$LOAD_PATH.push 123\n$LOAD_PATH.unshift " + q + "\nrequire \"greet\"\n",
+ } {
+ if cls, msg := evalErr(t, src); cls != "TypeError" || msg != "no implicit conversion of Integer into String" {
+ t.Errorf("non-String $LOAD_PATH entry: got %s: %s, want TypeError", cls, msg)
+ }
+ }
+ // An entry that answers #to_path is a usable directory (rb_get_path).
+ got = eval(t, "o = Object.new\ndef o.to_path; "+q+"; end\n$LOAD_PATH.unshift o\nrequire \"greet\"\nputs GREETING\n")
if !strings.Contains(got, "hi from lib") {
- t.Errorf("require skipping a non-string entry got %q", got)
+ t.Errorf("require via a #to_path $LOAD_PATH entry got %q", got)
}
}
diff --git a/internal/vm/require.go b/internal/vm/require.go
index 5af25aa2..a02375ea 100644
--- a/internal/vm/require.go
+++ b/internal/vm/require.go
@@ -1,6 +1,7 @@
package vm
import (
+ "fmt"
"os"
"path/filepath"
"strings"
@@ -187,16 +188,70 @@ func (vm *VM) doLoad(name string) object.Value {
vm.exec(iseq, vm.main, nil, vm.cObject, "", nil, nil, nil, nil, nil)
return object.Bool(true)
}
- return raise("LoadError", "cannot load such file -- %s", name)
+ return vm.raiseLoadError(name)
}
-// requireName extracts the String feature name, raising TypeError otherwise.
+// requireName coerces the feature argument to its path string exactly as MRI's
+// require/require_relative/load do: load.c v3_4_0 runs every one of them through
+// rb_get_path (file.c), which is rb_get_path_check_to_string followed by
+// rb_get_path_check_convert.
+//
+// rb_get_path_check_to_string takes a String as-is; otherwise it calls #to_path
+// through rb_check_funcall_default(obj, to_path, 0, 0, obj) — the default is the
+// object itself, so an object with no #to_path passes through unchanged — and
+// then runs StringValue over the RESULT. That second step is why a #to_path that
+// yields a non-String has #to_str called on THAT value rather than raising: the
+// conversion chain is #to_path then #to_str, not one or the other.
func requireName(vm *VM, args []object.Value) string {
- s, ok := args[0].(*object.String)
- if !ok {
- raise("TypeError", "no implicit conversion of %s into String", vm.classOf(args[0]).name)
+ s := vm.requirePathStr(args[0])
+ // rb_get_path_check_convert: an ASCII-incompatible encoding is a
+ // CompatibilityError and an embedded NUL an ArgumentError, before the name is
+ // ever looked at as a path.
+ vm.checkPathEncoding(s)
+ name := string(s.Bytes())
+ if strings.IndexByte(name, 0) >= 0 {
+ raise("ArgumentError", "path name contains null byte")
+ }
+ return name
+}
+
+// requirePathStr is MRI's rb_get_path_check_to_string (file.c v3_4_0). It differs
+// from the File-side pathStr in the one case the require specs pin: when #to_path
+// returns a non-String, MRI feeds that result to StringValue (so #to_str runs on
+// it) instead of failing on the spot.
+func (vm *VM) requirePathStr(v object.Value) *object.String {
+ if s, ok := v.(*object.String); ok {
+ return s
+ }
+ if vm.respondsToDynamic(v, "to_path") {
+ v = vm.send(v, "to_path", nil, nil)
+ if s, ok := v.(*object.String); ok {
+ return s
+ }
+ }
+ // StringValue(tmp): #to_str, or TypeError.
+ if vm.respondsToDynamic(v, "to_str") {
+ r := vm.send(v, "to_str", nil, nil)
+ if s, ok := r.(*object.String); ok {
+ return s
+ }
+ raise("TypeError", "can't convert %s to String (%s#to_str gives %s)",
+ vm.builtinClassName(v), vm.builtinClassName(v), vm.builtinClassName(r))
}
- return string(s.Bytes())
+ // rb_builtin_class_name spells nil/true/false as those words: MRI answers
+ // `require nil` with "no implicit conversion of nil into String".
+ raise("TypeError", "no implicit conversion of %s into String", vm.builtinClassName(v))
+ return nil
+}
+
+// raiseLoadError raises the "cannot load such file" LoadError carrying the
+// unresolved name in @path, so LoadError#path reports it. MRI: load.c
+// load_failed -> rb_load_fail -> error.c raise_loaderror, which sets the
+// exception's path ivar to the fname as it was PASSED IN, not as expanded.
+func (vm *VM) raiseLoadError(name string) object.Value {
+ vm.raiseWithIvars("LoadError", "cannot load such file -- "+name,
+ map[string]object.Value{"@path": object.NewString(name)})
+ return object.NilV
}
func (vm *VM) doRequire(name string, relative bool) object.Value {
@@ -225,6 +280,14 @@ func (vm *VM) doRequire(name string, relative bool) object.Value {
if !strings.HasSuffix(file, ".rb") {
file += ".rb"
}
+ // rb_f_require_relative (load.c v3_4_0) expands the name against the requiring
+ // file's directory BEFORE handing it to require_internal, so the failing name
+ // load_failed reports -- and the @path it stamps -- is the expanded, cleaned
+ // absolute path of the name AS PASSED, with no ".rb" appended.
+ errName := name
+ if relative {
+ errName = vm.requireRelativeBase(name)
+ }
// Try each candidate by reading it directly — the read is the existence test.
for _, cand := range vm.requireCandidates(file, relative) {
src, err := os.ReadFile(cand)
@@ -240,6 +303,13 @@ func (vm *VM) doRequire(name string, relative bool) object.Value {
delete(vm.loaded, abs)
}
if vm.loaded[abs] {
+ // A require of a feature whose own load has not finished yet is MRI's
+ // circular require: load_lock (load.c v3_4_0) finds the feature in the
+ // loading table, warns when the caller asked for warnings, then lets the
+ // second require return without running the file again.
+ if vm.loaded[vm.requireLoadingKey(abs)] {
+ vm.warnCircularRequire(abs)
+ }
return object.Bool(false)
}
iseq, cerr := parseCompileFn(string(src))
@@ -252,6 +322,9 @@ func (vm *VM) doRequire(name string, relative bool) object.Value {
// called from.
setISeqFile(iseq, abs)
vm.loaded[abs] = true
+ loadingKey := vm.requireLoadingKey(abs)
+ vm.loaded[loadingKey] = true
+ defer delete(vm.loaded, loadingKey)
vm.noteLoadedFeature(abs)
// A require whose file raises is NOT a completed require: MRI's
// require_internal unregisters the feature when the load unwinds, so the
@@ -270,7 +343,50 @@ func (vm *VM) doRequire(name string, relative bool) object.Value {
ok = true
return object.Bool(true)
}
- return raise("LoadError", "cannot load such file -- %s", name)
+ // Nothing on disk. MRI still returns false when the feature is ALREADY
+ // PROVIDED under the bare name: search_required (load.c v3_4_0) consults
+ // rb_feature_p before rb_find_file_ext, and for a name carrying no extension
+ // an entry in $LOADED_FEATURES equal to that name answers 'u' --
+ //
+ // if (!*(e = f + len)) { if (ext) continue; return 'u'; }
+ //
+ // -- whereupon `case 0: if (ft) goto feature_present;` returns with *path == 0
+ // and require_internal reports false rather than failing to load. Only the
+ // verbatim form is matched here; MRI also accepts an entry spelled
+ // "<$LOAD_PATH entry>/", and its loaded-features index carries a
+ // "distractor" rule for entries with an unusable extension, neither of which
+ // this check reproduces -- it can only turn a LoadError into false, never the
+ // other way round, so it cannot hide a require that would otherwise work.
+ if !relative && !strings.Contains(filepath.Base(name), ".") {
+ if arr, ok := vm.globals["$LOADED_FEATURES"].(*object.Array); ok && featureListed(arr, name) {
+ return object.Bool(false)
+ }
+ }
+ return vm.raiseLoadError(errName)
+}
+
+// requireLoadingKey names the vm.loaded entry marking a feature whose load is
+// still running ON THIS THREAD. MRI keeps that in a loading table (load.c
+// get_loading_table) holding a thread shield per feature, and load_lock warns
+// only when rb_thread_shield_owned says the CURRENT thread already holds it --
+// another thread arriving at a feature mid-load is not a circular require, it is
+// a concurrent one, and it waits instead of warning. Keying the marker by thread
+// reproduces that test; the entry goes in vm.loaded as the "feature:" entries for
+// built-in features already do, so no VM field is needed.
+func (vm *VM) requireLoadingKey(abs string) string {
+ return fmt.Sprintf("loading:%p:%s", vm.currentThread, abs)
+}
+
+// warnCircularRequire emits MRI's circular-require warning. load_lock issues it
+// through rb_warning, not rb_warn, so it appears only in verbose mode ($VERBOSE
+// true) -- `require_internal(ec, fname, 1, RTEST(ruby_verbose))` passes the
+// verbose flag down as load_lock's `warn` argument. It goes to the current
+// $stderr so a reassigned $stderr (mspec's `complain` matcher) captures it.
+func (vm *VM) warnCircularRequire(path string) {
+ if v, ok := vm.globals["$VERBOSE"].(object.Bool); !ok || !bool(v) {
+ return
+ }
+ vm.curStderr().writeStr("warning: loading in progress, circular require considered harmful - " + path + "\n")
}
// setISeqFile stamps path onto iseq and all of its nested children, so a method
@@ -287,6 +403,21 @@ func setISeqFile(iseq *bytecode.ISeq, path string) {
}
}
+// requireRelativeBase expands name the way rb_f_require_relative does --
+// rb_file_absolute_path(fname, dirname(rb_current_realfilepath())) -- leaving an
+// already-absolute name alone and cleaning the result, which is what MRI names in
+// the LoadError of a require_relative that finds nothing.
+func (vm *VM) requireRelativeBase(name string) string {
+ if filepath.IsAbs(name) {
+ return featurePath(name)
+ }
+ dir := vm.currentDir()
+ if f := vm.currentFile(); f != "" {
+ dir = filepath.Dir(f)
+ }
+ return featurePath(filepath.Join(dir, name))
+}
+
// requireCandidates lists the paths to try for file. require_relative resolves
// against the requiring file's directory; a plain require searches that
// directory, the process CWD, then each $LOAD_PATH entry; an absolute path is
@@ -294,15 +425,33 @@ func setISeqFile(iseq *bytecode.ISeq, path string) {
func (vm *VM) requireCandidates(file string, relative bool) []string {
switch {
case relative:
- // require_relative resolves against the directory of the file where the call
- // is written — the executing ISeq's file — so a require_relative inside a
+ // rb_f_require_relative (load.c v3_4_0) is
+ // rb_require_string_internal(rb_file_absolute_path(fname, dirname(base))):
+ // rb_file_absolute_path IGNORES the base when fname is already absolute, so
+ // an absolute require_relative argument is used verbatim and must not be
+ // joined onto the requiring file's directory.
+ if filepath.IsAbs(file) {
+ return []string{file}
+ }
+ // Otherwise resolve against the directory of the file where the call is
+ // written — the executing ISeq's file — so a require_relative inside a
// method works even when that method is called from another file. Fall back
// to the require stack's directory when no file is stamped (e.g. a -e script).
if f := vm.currentFile(); f != "" {
return []string{filepath.Join(filepath.Dir(f), file)}
}
return []string{filepath.Join(vm.currentDir(), file)}
- case filepath.IsAbs(file):
+ case strings.HasPrefix(file, "~"):
+ // rb_find_file (file.c v3_4_0) expands a leading ~ through HOME first and
+ // then takes the expanded name as the ONLY candidate: `if (!rb_file_load_ok(f))
+ // return 0;` returns before the $LOAD_PATH walk, so a ~ path is never
+ // searched on the load path.
+ return []string{expandTildePath(file)}
+ case filepath.IsAbs(file), isExplicitRelative(file):
+ // Same early return in rb_find_file for an absolute path and for an
+ // "explicitly relative" one — is_explicit_relative(f) is true for "./x" and
+ // "../x". Both resolve against the process working directory alone; neither
+ // is ever joined onto a $LOAD_PATH entry.
return []string{file}
default:
cands := []string{filepath.Join(vm.currentDir(), file), file}
@@ -313,9 +462,34 @@ func (vm *VM) requireCandidates(file string, relative bool) []string {
}
}
-// loadPathDirs returns the directory strings currently in $LOAD_PATH. Non-string
-// entries are skipped (MRI coerces them, but the embedded load path holds plain
-// strings).
+// isExplicitRelative reports whether a path is "explicitly relative" in MRI's
+// sense — is_explicit_relative (file.c v3_4_0):
+//
+// if (*path++ != '.') return 0;
+// if (*path == '.') path++;
+// return isdirsep(*path);
+//
+// i.e. exactly a leading "./" or "../". A name that merely begins with a dot
+// ("..foo", ".hidden") is NOT explicitly relative and is still searched on
+// $LOAD_PATH.
+//
+// isdirsep is PLATFORM-DEPENDENT in MRI: on POSIX it is `(x) == '/'`, and only
+// on Windows does it also accept a backslash. os.IsPathSeparator draws exactly
+// that line, so ".\x" is explicitly relative on the windows lane and an ordinary
+// $LOAD_PATH name everywhere else -- as it is under MRI on each.
+func isExplicitRelative(p string) bool {
+ if len(p) == 0 || p[0] != '.' {
+ return false
+ }
+ p = p[1:]
+ if len(p) > 0 && p[0] == '.' {
+ p = p[1:]
+ }
+ return len(p) > 0 && os.IsPathSeparator(p[0])
+}
+
+// loadPathDirs returns the directory strings currently in $LOAD_PATH, coercing
+// each entry through MRI's rb_get_path as rb_find_file does.
func (vm *VM) loadPathDirs() []string {
lp, ok := vm.globals["$LOAD_PATH"].(*object.Array)
if !ok {
@@ -323,9 +497,10 @@ func (vm *VM) loadPathDirs() []string {
}
dirs := make([]string, 0, len(lp.Elems))
for _, e := range lp.Elems {
- if s, ok := e.(*object.String); ok {
- dirs = append(dirs, string(s.Bytes()))
- }
+ // rb_find_file (file.c v3_4_0) runs every $LOAD_PATH entry through
+ // rb_get_path(str) as it walks the list, so an entry that is not a String
+ // but answers #to_path (a Pathname, say) is a usable load-path directory.
+ dirs = append(dirs, string(vm.requirePathStr(e).Bytes()))
}
return dirs
}
diff --git a/internal/vm/string_byteops.go b/internal/vm/string_byteops.go
index c590f4b9..09444efa 100644
--- a/internal/vm/string_byteops.go
+++ b/internal/vm/string_byteops.go
@@ -6,7 +6,6 @@ package vm
import (
"strings"
- "unicode/utf8"
"github.com/go-embedded-ruby/ruby/internal/object"
)
@@ -36,6 +35,47 @@ func stringConv(v object.Value) *object.String {
return s
}
+// builtinClassName names a value the way MRI's error messages do --
+// rb_builtin_class_name (error.c v3_4_0), which spells nil, true and false as
+// those words and otherwise reports the object's class. The package-level
+// classNameOf collapses every object to "Object", which is right for a bare
+// Object but wrong for the true/false literals and for any user class (the
+// byte_index_common specs pin both).
+func (vm *VM) builtinClassName(v object.Value) string {
+ switch t := v.(type) {
+ case object.Nil:
+ return "nil"
+ case object.Bool:
+ if bool(t) {
+ return "true"
+ }
+ return "false"
+ }
+ return vm.classOf(v).name
+}
+
+// strValueArg is MRI's StringValue (rb_str_to_str, string.c v3_4_0): a String
+// passes through, anything else is converted with #to_str, and a value that has
+// no #to_str -- or whose #to_str yields a non-String -- raises TypeError.
+// String#byteindex/#byterindex reach their pattern argument through it
+// (rb_str_byteindex_m / rb_str_byterindex_m call StringValue(sub) once the
+// Regexp branch is ruled out), so an object answering #to_str is accepted.
+func (vm *VM) strValueArg(v object.Value) *object.String {
+ if s, ok := v.(*object.String); ok {
+ return s
+ }
+ if vm.respondsToDynamic(v, "to_str") {
+ r := vm.send(v, "to_str", nil, nil)
+ if s, ok := r.(*object.String); ok {
+ return s
+ }
+ raise("TypeError", "can't convert %s to String (%s#to_str gives %s)",
+ vm.builtinClassName(v), vm.builtinClassName(v), vm.builtinClassName(r))
+ }
+ raise("TypeError", "no implicit conversion of %s into String", vm.builtinClassName(v))
+ return nil
+}
+
// strByteindex implements String#byteindex(pattern, offset=0): the byte index of
// the first match of a String or Regexp at or after offset, or nil.
func (vm *VM) strByteindex(self *object.String, args []object.Value) object.Value {
@@ -43,7 +83,9 @@ func (vm *VM) strByteindex(self *object.String, args []object.Value) object.Valu
n := len(s)
off := 0
if len(args) > 1 {
- off = int(toInt(args[1]))
+ // rb_str_byteindex_m reads the offset with NUM2LONG, which accepts an
+ // Integer or anything answering #to_int.
+ off = int(vm.toIntCoerce(args[1]))
if off < 0 {
off += n
}
@@ -64,7 +106,7 @@ func (vm *VM) strByteindex(self *object.String, args []object.Value) object.Valu
vm.lastMatch = &MatchData{md: md, subject: s, re: needle, byteOff: off}
return object.IntValue(int64(off + md.Begin(0)))
default:
- ns := stringConv(args[0])
+ ns := vm.strValueArg(args[0])
vm.combinedEncName(self, ns) // raises Encoding::CompatibilityError if incompatible
idx := strings.Index(s[off:], ns.Str())
if idx < 0 {
@@ -82,7 +124,8 @@ func (vm *VM) strByterindex(self *object.String, args []object.Value) object.Val
n := len(s)
off := n
if len(args) > 1 {
- off = int(toInt(args[1]))
+ // rb_str_byterindex_m likewise reads the offset with NUM2LONG.
+ off = int(vm.toIntCoerce(args[1]))
if off < 0 {
off += n
}
@@ -100,7 +143,7 @@ func (vm *VM) strByterindex(self *object.String, args []object.Value) object.Val
case *Regexp:
return vm.byterindexRegexp(s, needle, off)
default:
- ns := stringConv(args[0])
+ ns := vm.strValueArg(args[0])
vm.combinedEncName(self, ns) // raises Encoding::CompatibilityError if incompatible
return byterindexString(s, ns.Str(), off, n)
}
@@ -121,40 +164,28 @@ func byterindexString(s, needle string, off, n int) object.Value {
return object.IntValue(int64(idx))
}
-// byterindexRegexp finds the greatest match start at or before off by scanning
-// forward (advancing one character past each match start so overlapping matches
-// are seen) and keeping the last start not exceeding off. It records the winning
-// match as $~.
+// byterindexRegexp returns the greatest byte offset p <= off at which re matches
+// starting exactly at p, recording that match as $~. MRI reaches it through
+// rb_reg_search(sub, str, pos, 1) (string.c rb_str_byterindex_m) -- the trailing
+// 1 is onig_search's reverse flag, so the search runs backwards from pos over the
+// WHOLE subject.
+//
+// Probing anchored matches from off downwards is what keeps the whole subject
+// visible: the previous scan matched re against the tail slice s[p:], which makes
+// every slice start look like the start of the string, so /\A/ reported the last
+// offset instead of 0 and \G could not see the real start offset. The character-
+// indexed String#rindex (strRindexRegexp in regexp.go) already uses this shape;
+// this is its byte-indexed twin.
func (vm *VM) byterindexRegexp(s string, re *Regexp, off int) object.Value {
- best := -1
- for p := 0; p <= len(s); {
- md := re.re.Match(s[p:])
- if md == nil {
- break
+ for p := off; p >= 0; p-- {
+ md := re.matcher().MatchAt(s, p)
+ if md != nil && md.Begin(0) == p {
+ vm.lastMatch = &MatchData{md: md, subject: s, re: re}
+ return object.IntValue(int64(p))
}
- begin := p + md.Begin(0)
- if begin > off {
- break
- }
- best = begin
- vm.lastMatch = &MatchData{md: md, subject: s, re: re, byteOff: p}
- p = begin + runeLenAt(s, begin)
- }
- if best < 0 {
- vm.lastMatch = object.NilV
- return object.NilV
- }
- return object.IntValue(int64(best))
-}
-
-// runeLenAt returns the byte length of the UTF-8 rune beginning at s[i], or 1 at
-// the end of s or on an invalid lead byte, so a scan always advances.
-func runeLenAt(s string, i int) int {
- if i >= len(s) {
- return 1
}
- _, sz := utf8.DecodeRuneInString(s[i:])
- return sz
+ vm.lastMatch = object.NilV
+ return object.NilV
}
// strBytesplice implements String#bytesplice, replacing a byte range of the
diff --git a/internal/vm/wave25_kernstr_test.go b/internal/vm/wave25_kernstr_test.go
new file mode 100644
index 00000000..47d1dc85
--- /dev/null
+++ b/internal/vm/wave25_kernstr_test.go
@@ -0,0 +1,400 @@
+// Copyright (c) the go-embedded-ruby/ruby authors
+//
+// SPDX-License-Identifier: BSD-3-Clause
+
+package vm
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/go-embedded-ruby/ruby/internal/object"
+)
+
+// TestIsExplicitRelative covers MRI's is_explicit_relative (file.c v3_4_0): a
+// path is "explicitly relative" -- and so resolved against the working directory
+// instead of being searched on $LOAD_PATH -- only when it begins with "./" or
+// "../". A name that merely starts with a dot is not.
+func TestIsExplicitRelative(t *testing.T) {
+ cases := []struct {
+ path string
+ want bool
+ }{
+ {"./x", true},
+ {"../x", true},
+ // isdirsep accepts a backslash only on Windows, so MRI's answer for these
+ // two differs by platform and so must rbgo's.
+ {`.\x`, runtime.GOOS == "windows"},
+ {`..\x`, runtime.GOOS == "windows"},
+ {".", false},
+ {"..", false},
+ {"...", false},
+ {"..foo", false},
+ {".hidden", false},
+ {"x", false},
+ {"", false},
+ {"/abs", false},
+ }
+ for _, c := range cases {
+ if got := isExplicitRelative(c.path); got != c.want {
+ t.Errorf("isExplicitRelative(%q) = %v, want %v", c.path, got, c.want)
+ }
+ }
+}
+
+// TestBuiltinClassName covers rb_builtin_class_name (error.c v3_4_0), which
+// names nil, true and false as those words in a TypeError and otherwise reports
+// the object's class.
+func TestBuiltinClassName(t *testing.T) {
+ vm := New(io.Discard)
+ cases := []struct {
+ val object.Value
+ want string
+ }{
+ {object.NilV, "nil"},
+ {object.Bool(true), "true"},
+ {object.Bool(false), "false"},
+ {object.IntValue(1), "Integer"},
+ {object.NewString("s"), "String"},
+ {object.Symbol("s"), "Symbol"},
+ }
+ for _, c := range cases {
+ if got := vm.builtinClassName(c.val); got != c.want {
+ t.Errorf("builtinClassName(%v) = %q, want %q", c.val, got, c.want)
+ }
+ }
+}
+
+// TestChopStrEncoding covers chopStr's encoding-dependent tail: "the last
+// character" is one byte in ASCII-8BIT and one UTF-8 sequence otherwise, a
+// trailing CRLF counts as one either way, and an empty string chops to itself.
+// An invalid byte in a UTF-8 string is one character (utf8.DecodeLastRune gives
+// size 1) and the bytes BEFORE it must come back untouched.
+func TestChopStrEncoding(t *testing.T) {
+ cases := []struct{ s, enc, want string }{
+ {"abc\r\n", "UTF-8", "abc"},
+ {"abc\r\n", "ASCII-8BIT", "abc"},
+ {"", "UTF-8", ""},
+ {"", "ASCII-8BIT", ""},
+ {"hé", "UTF-8", "h"}, // a two-byte rune goes whole
+ {"hé", "ASCII-8BIT", "h\xc3"}, // ... but is two characters in binary
+ {"ab\xdf", "UTF-8", "ab"}, // an invalid lead byte is one character
+ {"\xe3\x81\x82\xa4", "UTF-8", "\xe3\x81\x82"},
+ {"\xa4b", "UTF-8", "\xa4"}, // the invalid PREFIX survives byte for byte
+ }
+ for _, c := range cases {
+ if got := chopStr(c.s, c.enc); got != c.want {
+ t.Errorf("chopStr(%q, %s) = %q, want %q", c.s, c.enc, got, c.want)
+ }
+ }
+}
+
+// TestStrCharPieces covers the encoding-aware character split behind
+// String#chars / #each_char: one byte per character in ASCII-8BIT, one UTF-8
+// sequence otherwise, an invalid byte standing as its own character, and every
+// piece a slice of the original so no byte is rewritten.
+func TestStrCharPieces(t *testing.T) {
+ cases := []struct {
+ s, enc string
+ want []string
+ }{
+ {"", "UTF-8", []string{}},
+ {"", "ASCII-8BIT", []string{}},
+ {"abc", "UTF-8", []string{"a", "b", "c"}},
+ {"hé", "UTF-8", []string{"h", "\xc3\xa9"}},
+ {"hé", "ASCII-8BIT", []string{"h", "\xc3", "\xa9"}},
+ {"\xa4", "UTF-8", []string{"\xa4"}},
+ {"\xe3\x81\x82\xa4x", "UTF-8", []string{"\xe3\x81\x82", "\xa4", "x"}},
+ {"\xf0\xa4\xad\xa2", "ASCII-8BIT", []string{"\xf0", "\xa4", "\xad", "\xa2"}},
+ }
+ for _, c := range cases {
+ got := strCharPieces(c.s, c.enc)
+ if len(got) != len(c.want) {
+ t.Errorf("strCharPieces(%q, %s) = %q, want %q", c.s, c.enc, got, c.want)
+ continue
+ }
+ for i := range got {
+ if got[i] != c.want[i] {
+ t.Errorf("strCharPieces(%q, %s)[%d] = %q, want %q", c.s, c.enc, i, got[i], c.want[i])
+ }
+ }
+ }
+}
+
+// TestRequirePathCoercion covers requireName / requirePathStr, MRI's rb_get_path
+// for a require argument: a String passes through, #to_path is tried first, its
+// non-String result then goes through #to_str, and a value answering neither --
+// or answering with a non-String -- is a TypeError. The NUL-byte and
+// ASCII-incompatible guards of rb_get_path_check_convert fire too.
+func TestRequirePathCoercion(t *testing.T) {
+ cases := []struct{ src, wantClass, wantMsg string }{
+ {`require Object.new`, "TypeError", "no implicit conversion of Object into String"},
+ {`require 42`, "TypeError", "no implicit conversion of Integer into String"},
+ {`require nil`, "TypeError", "no implicit conversion of nil into String"},
+ {`o = Object.new; def o.to_path; 42; end; require o`, "TypeError",
+ "no implicit conversion of Integer into String"},
+ {`o = Object.new; def o.to_str; 42; end; require o`, "TypeError",
+ "can't convert Object to String (Object#to_str gives Integer)"},
+ {"require \"a\\0b\"", "ArgumentError", "path name contains null byte"},
+ // A #to_path answering a String is used as the feature name: it reaches the
+ // LoadError naming exactly that name.
+ {`o = Object.new; def o.to_path; "no_such_feature_xyz"; end; require o`, "LoadError",
+ "cannot load such file -- no_such_feature_xyz"},
+ // A #to_path answering a non-String that answers #to_str: the chain runs to
+ // the end and the STRING is the feature name.
+ {`i = Object.new; def i.to_str; "no_such_feature_pq"; end
+ o = Object.new; def o.to_path; $i; end; $i = i; require o`, "LoadError",
+ "cannot load such file -- no_such_feature_pq"},
+ }
+ for _, c := range cases {
+ cls, msg := evalErr(t, c.src)
+ if cls != c.wantClass || msg != c.wantMsg {
+ t.Errorf("src=%q got %s: %q, want %s: %q", c.src, cls, msg, c.wantClass, c.wantMsg)
+ }
+ }
+}
+
+// TestLoadErrorPath covers LoadError#path, the reader MRI declares with
+// rb_attr(rb_eLoadError, path, TRUE, FALSE, FALSE): it reports the feature name
+// as PASSED for a failed require/require_relative/load, and nil for a LoadError
+// built by hand.
+func TestLoadErrorPath(t *testing.T) {
+ cases := []struct{ src, want string }{
+ {`begin; require "abcd1234"; rescue LoadError => e; p e.path; end`, `"abcd1234"`},
+ {`begin; load "abcd1234"; rescue LoadError => e; p e.path; end`, `"abcd1234"`},
+ {`p LoadError.new("x").path`, `nil`},
+ }
+ for _, c := range cases {
+ if got := eval(t, c.src); got != c.want+"\n" {
+ t.Errorf("src=%q got %q, want %q", c.src, got, c.want+"\n")
+ }
+ }
+
+ // require_relative names the EXPANDED path, because rb_f_require_relative
+ // expands before require_internal sees the name. "Is it expanded?" is asked
+ // with File.expand_path rather than a leading "/", which only holds on POSIX:
+ // featurePath yields "C:/Users/..." on Windows, absolute but not "/"-rooted.
+ got := eval(t, `begin; require_relative "nope_xyz"; rescue LoadError => e; p e.path.end_with?("nope_xyz"), e.path == File.expand_path(e.path); end`)
+ if got != "true\ntrue\n" {
+ t.Errorf("require_relative LoadError#path got %q", got)
+ }
+}
+
+// TestRequireResolutionRules covers requireCandidates against rb_find_file
+// (file.c v3_4_0): an absolute require_relative argument ignores the base, and a
+// "~", absolute or explicitly-relative name is never searched on $LOAD_PATH.
+func TestRequireResolutionRules(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "feat.rb"), []byte("FEAT = 1\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ q := strconv.Quote(filepath.Join(dir, "feat.rb"))
+ // An absolute path given to require_relative is used verbatim.
+ if got := eval(t, "p require_relative("+q+")\np FEAT\n"); got != "true\n1\n" {
+ t.Errorf("absolute require_relative got %q", got)
+ }
+ // "./feat.rb" is NOT found through a $LOAD_PATH entry that holds it.
+ if cls, _ := evalErr(t, "$LOAD_PATH.unshift "+strconv.Quote(dir)+"\nrequire \"./feat.rb\"\n"); cls != "LoadError" {
+ t.Errorf("./ relative resolved against $LOAD_PATH: got %s, want LoadError", cls)
+ }
+ if cls, _ := evalErr(t, "$LOAD_PATH.unshift "+strconv.Quote(dir)+"\nrequire \"../feat.rb\"\n"); cls != "LoadError" {
+ t.Errorf("../ relative resolved against $LOAD_PATH: got %s, want LoadError", cls)
+ }
+ // A "~" name is expanded through HOME and not searched on the load path.
+ if cls, _ := evalErr(t, "$LOAD_PATH.unshift "+strconv.Quote(dir)+"\nrequire \"~/feat.rb\"\n"); cls != "LoadError" {
+ t.Errorf("~ path resolved against $LOAD_PATH: got %s, want LoadError", cls)
+ }
+ // The bare name still resolves through $LOAD_PATH.
+ if got := eval(t, "$LOAD_PATH.unshift "+strconv.Quote(dir)+"\nrequire \"feat\"\np FEAT\n"); !strings.Contains(got, "1") {
+ t.Errorf("bare require via $LOAD_PATH got %q", got)
+ }
+}
+
+// TestRequireAlreadyProvidedBareName covers the last branch of doRequire: a
+// require whose name carries no extension and that finds nothing on disk still
+// returns false when $LOADED_FEATURES already lists that exact name.
+// search_required (load.c v3_4_0) asks rb_feature_p before rb_find_file_ext, and
+// a no-extension entry answers 'u', which reaches `case 0: if (ft) goto
+// feature_present;` -- so require_internal reports false instead of failing.
+// Witnessed against MRI 4.0.5, including the cases that must still raise.
+func TestRequireAlreadyProvidedBareName(t *testing.T) {
+ cases := []struct{ src, want string }{
+ {`$LOADED_FEATURES << "no_such_feature_abc"
+ p require("no_such_feature_abc")`, `false`},
+ {`$LOADED_FEATURES << "dir/other_missing"
+ p require("dir/other_missing")`, `false`},
+ }
+ for _, c := range cases {
+ if got := eval(t, c.src); got != c.want+"\n" {
+ t.Errorf("src=%q got %q, want %q", c.src, got, c.want+"\n")
+ }
+ }
+ // An extensioned name is NOT matched by a bare entry, and a name that is not
+ // listed at all still raises -- both branches of the guard.
+ bad := []string{
+ `$LOADED_FEATURES << "no_such_feature_abc"` + "\n" + `require("no_such_feature_abc.rb")`,
+ `require("totally_absent_zz")`,
+ `$LOADED_FEATURES << "no_such_feature_abc"` + "\n" + `require_relative("no_such_feature_abc")`,
+ }
+ for _, src := range bad {
+ if cls, _ := evalErr(t, src); cls != "LoadError" {
+ t.Errorf("src=%q got %s, want LoadError", src, cls)
+ }
+ }
+}
+
+// TestRequireRelativeBase covers the expansion rb_f_require_relative applies
+// before require_internal runs: an already-absolute name is left alone (only
+// cleaned), a relative one is joined onto the requiring file's directory.
+func TestRequireRelativeBase(t *testing.T) {
+ vm := New(io.Discard)
+ abs := filepath.Join(t.TempDir(), "a", "..", "b.rb")
+ if got := vm.requireRelativeBase(abs); got != featurePath(abs) || strings.Contains(got, "..") {
+ t.Errorf("requireRelativeBase(%q) = %q, want the cleaned absolute path", abs, got)
+ }
+ got := vm.requireRelativeBase("rel.rb")
+ if !filepath.IsAbs(filepath.FromSlash(got)) || !strings.HasSuffix(got, "rel.rb") {
+ t.Errorf("requireRelativeBase(rel.rb) = %q, want an absolute path ending in rel.rb", got)
+ }
+}
+
+// TestCircularRequireWarning covers warnCircularRequire. MRI's load_lock issues
+// the warning through rb_warning, so it appears only when $VERBOSE is true, and
+// only for a re-entrant require ON THE SAME thread (rb_thread_shield_owned).
+func TestCircularRequireWarning(t *testing.T) {
+ dir := t.TempDir()
+ self := filepath.Join(dir, "selfreq.rb")
+ if err := os.WriteFile(self, []byte("require_relative 'selfreq'\nSEEN = 1\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ q := strconv.Quote(self)
+ // $VERBOSE true: the warning is written to $stderr and the file still loads once.
+ src := "$VERBOSE = true\n$stderr = StringIO.new\nrequire " + q + "\np $stderr.string.include?('circular require considered harmful')\n"
+ if got := eval(t, src); !strings.HasSuffix(got, "true\n") {
+ t.Errorf("verbose circular require: got %q, want the warning", got)
+ }
+ // $VERBOSE false: rb_warning is silent.
+ src = "$VERBOSE = false\n$stderr = StringIO.new\nrequire " + q + "\np $stderr.string\n"
+ if got := eval(t, src); !strings.HasSuffix(got, "\"\"\n") {
+ t.Errorf("non-verbose circular require: got %q, want no warning", got)
+ }
+ // $VERBOSE nil: likewise silent (the nil branch of the Bool assertion).
+ src = "$VERBOSE = nil\n$stderr = StringIO.new\nrequire " + q + "\np $stderr.string\n"
+ if got := eval(t, src); !strings.HasSuffix(got, "\"\"\n") {
+ t.Errorf("VERBOSE=nil circular require: got %q, want no warning", got)
+ }
+}
+
+// TestSplitCheckArgs covers the two guards rb_str_split_m runs first:
+// mustnot_broken on the receiver and on a String pattern, and the NUM2INT range
+// on the limit (which is also converted exactly once).
+func TestSplitCheckArgs(t *testing.T) {
+ cases := []struct{ src, wantClass, wantMsg string }{
+ {`"\xDF".split("a")`, "ArgumentError", "invalid byte sequence in UTF-8"},
+ {`"\xDF".split`, "ArgumentError", "invalid byte sequence in UTF-8"},
+ {`"\xDF".split(/a/)`, "ArgumentError", "invalid byte sequence in UTF-8"},
+ {`"a".split("\xDF")`, "ArgumentError", "invalid byte sequence in UTF-8"},
+ {`"a,b".split(",", 2147483649)`, "RangeError", "integer 2147483649 too big to convert to 'int'"},
+ {`"a,b".split(",", -2147483649)`, "RangeError", "integer -2147483649 too small to convert to 'int'"},
+ {`"a".split(",", Object.new)`, "TypeError", "no implicit conversion of Object into Integer"},
+ }
+ for _, c := range cases {
+ cls, msg := evalErr(t, c.src)
+ if cls != c.wantClass || msg != c.wantMsg {
+ t.Errorf("src=%q got %s: %q, want %s: %q", c.src, cls, msg, c.wantClass, c.wantMsg)
+ }
+ }
+ // A nil limit takes the IsNil branch of the range guard and is left to the
+ // splitter, which refuses it as MRI does (MRI: "no implicit conversion from
+ // nil to integer"; rbgo words it "of NilClass into Integer" -- a pre-existing
+ // difference in toIntCoerce, not introduced here, so only the class is
+ // pinned).
+ if cls, _ := evalErr(t, `"a,b".split(",", nil)`); cls != "TypeError" {
+ t.Errorf("nil split limit: got %s, want TypeError", cls)
+ }
+ // A binary receiver is never broken, an in-range limit passes, and #to_int is
+ // called exactly once.
+ ok := []struct{ src, want string }{
+ {`p "\xDF a".b.split("a")`, `["\xDF "]`},
+ {`p "a,b,c".split(",", 2)`, `["a", "b,c"]`},
+ {`p "a,b".split(",", 2147483647)`, `["a", "b"]`},
+ {`p "a,b".split(",", -2147483648)`, `["a", "b"]`},
+ {`o = Object.new; def o.to_int; $n = ($n || 0) + 1; 2; end
+ r = "a,b,c".split(",", o); p r, $n`, "[\"a\", \"b,c\"]\n1"},
+ }
+ for _, c := range ok {
+ if got := eval(t, c.src); got != c.want+"\n" {
+ t.Errorf("src=%q got %q, want %q", c.src, got, c.want+"\n")
+ }
+ }
+}
+
+// TestByteIndexCoercion covers strValueArg and the NUM2LONG offset of
+// String#byteindex / #byterindex: #to_str and #to_int are honoured, and the
+// refusals name the value the way rb_builtin_class_name does.
+func TestByteIndexCoercion(t *testing.T) {
+ ok := []struct{ src, want string }{
+ {`o = Object.new; def o.to_str; "bla"; end; p "blablabla".byteindex(o)`, `0`},
+ {`o = Object.new; def o.to_str; "bla"; end; p "blablabla".byterindex(o)`, `6`},
+ {`o = Object.new; def o.to_int; 3; end; p "blablabla".byteindex("bla", o)`, `3`},
+ {`o = Object.new; def o.to_int; 3; end; p "blablabla".byterindex("bla", o)`, `3`},
+ // The reverse search sees the whole subject, so \A pins to offset 0.
+ {`p "blablabla".byterindex(/\A/)`, `0`},
+ {`p "blablabla".byterindex(/\z/)`, `9`},
+ {`p "blablabla".byterindex(/bla|a/)`, `8`},
+ {`p "blablabla".byterindex(/zzz/)`, `nil`},
+ {`p "blablabla".byterindex(/bla/)`, `6`},
+ }
+ for _, c := range ok {
+ if got := eval(t, c.src); got != c.want+"\n" {
+ t.Errorf("src=%q got %q, want %q", c.src, got, c.want+"\n")
+ }
+ }
+ // A winning reverse match records $~.
+ if got := eval(t, `p "blablabla".byterindex(/b(la)/); p $~[1]`); got != "6\n\"la\"\n" {
+ t.Errorf("byterindex $~ got %q", got)
+ }
+ // A failing one clears it.
+ if got := eval(t, `"blablabla" =~ /bla/; "blablabla".byterindex(/zzz/); p $~`); got != "nil\n" {
+ t.Errorf("byterindex clears $~ got %q", got)
+ }
+ bad := []struct{ src, wantMsg string }{
+ {`"a".byteindex(true)`, "no implicit conversion of true into String"},
+ {`"a".byteindex(false)`, "no implicit conversion of false into String"},
+ {`"a".byterindex(nil)`, "no implicit conversion of nil into String"},
+ {`"a".byteindex(Object.new)`, "no implicit conversion of Object into String"},
+ {`o = Object.new; def o.to_str; 1; end; "a".byteindex(o)`,
+ "can't convert Object to String (Object#to_str gives Integer)"},
+ {`"a".byteindex("a", Object.new)`, "no implicit conversion of Object into Integer"},
+ }
+ for _, c := range bad {
+ cls, msg := evalErr(t, c.src)
+ if cls != "TypeError" || msg != c.wantMsg {
+ t.Errorf("src=%q got %s: %q, want TypeError: %q", c.src, cls, msg, c.wantMsg)
+ }
+ }
+}
+
+// TestSymbolEncoding covers Symbol#encoding: rb_str_intern re-tags an ASCII-only
+// string as US-ASCII whatever it arrived in, and rbgo derives the rest from the
+// bytes (valid UTF-8, else ASCII-8BIT).
+func TestSymbolEncoding(t *testing.T) {
+ cases := []struct{ src, want string }{
+ {`p "foobar".to_sym.encoding`, `#`},
+ {`p "foobar".b.to_sym.encoding`, `#`},
+ {`p :"".encoding`, `#`},
+ {`p "il était une fois".to_sym.encoding`, `#`},
+ {`p "\xDF".b.to_sym.encoding`, `#`},
+ {`p "h\xDFi".b.to_sym.encoding`, `#`},
+ }
+ for _, c := range cases {
+ if got := eval(t, c.src); got != c.want+"\n" {
+ t.Errorf("src=%q got %q, want %q", c.src, got, c.want+"\n")
+ }
+ }
+}