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") + } + } +}