diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 1d42441a..5704fdd0 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -294,6 +294,43 @@ func (vm *VM) bootstrap() { exc("ScriptError", "Exception") exc("SyntaxError", "ScriptError") exc("LoadError", "ScriptError") + // Exceptions that sit directly under Exception (NOT StandardError), so a bare + // `rescue` does not catch them — matching MRI. SystemExit additionally carries + // an exit status (defined below). + exc("NoMemoryError", "Exception") + exc("SecurityError", "Exception") + exc("SignalException", "Exception") + exc("Interrupt", "SignalException") + systemExit := exc("SystemExit", "Exception") + // SystemExit#initialize accepts (status=0, message=nil) or (message); it stores + // @status (the process exit code) and the message. SystemExit#status returns it. + systemExit.define("initialize", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*RObject) + status := object.Value(object.Integer(0)) + switch { + case len(args) >= 2: + status = args[0] + o.ivars["@message"] = object.NewString(args[1].ToS()) + case len(args) == 1: + if i, ok := args[0].(object.Integer); ok { + status = i + } else { + o.ivars["@message"] = object.NewString(args[0].ToS()) + } + } + o.ivars["@status"] = status + return object.NilV + }) + systemExit.define("status", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + if s := getIvar(self, "@status"); s != object.NilV { + return s + } + return object.Integer(0) + }) + systemExit.define("success?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + s, ok := getIvar(self, "@status").(object.Integer) + return object.Bool(!ok || s == 0) + }) vm.registerFile() // needs the exception hierarchy (Errno::ENOENT < StandardError) vm.registerIO() // IO/StringIO + $stdout/$stderr/$stdin (needs IOError/EOFError) @@ -302,10 +339,13 @@ func (vm *VM) bootstrap() { vm.registerProcess() // Process module — identity + clock_gettime vm.registerOpenSSL() // OpenSSL (real digest/HMAC/random + PKI/TLS shell); needs StandardError vm.registerNetHTTP() // net/http + net/https loadable shell; needs StandardError + vm.registerResolv() // Resolv (real IPv4/IPv6 parse; DNS sockets stubbed); needs StandardError vm.registerTimeout() // Timeout module (loadable shell); needs RuntimeError vm.registerYAML() // YAML/Psych loadable shell; needs StandardError vm.registerFileUtils() // FileUtils (real fs ops over os); needs Errno (registerFile) vm.registerGetoptLong() // GetoptLong loadable shell; needs StandardError + vm.registerOptParse() // optparse loadable shell (declares; parse raises); needs StandardError + vm.registerRipper() // ripper loadable shell (Ripper.sexp etc. raise); needs StandardError vm.registerSyslog() // Syslog loadable shell (feature probe) vm.registerCGI() // CGI.escape/unescape (real over net/url) + HTML helpers vm.registerMonitor() // Monitor/MonitorMixin (single-thread synchronize); needs StandardError @@ -711,6 +751,9 @@ func (vm *VM) bootstrap() { vm.cSymbol.define("to_sym", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { return self }) + 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) + }) 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) @@ -3651,6 +3694,29 @@ func strArg(v object.Value) string { return "" } +// pathArg coerces a path-like argument to a String the way MRI's File/IO entry +// points do: a String is taken directly; otherwise the value is converted via +// #to_path (Pathname and friends), falling back to #to_str. Anything that +// responds to neither raises TypeError, matching MRI. +func pathArg(vm *VM, v object.Value) string { + if s, ok := v.(*object.String); ok { + return s.Str() + } + for _, m := range []string{"to_path", "to_str"} { + if vm.respondsTo(v, m) { + r := vm.send(v, m, nil, nil) + s, ok := r.(*object.String) + if !ok { + raise("TypeError", "can't convert %s to String (%s#%s gives %s)", + vm.classOf(v).name, vm.classOf(v).name, m, vm.classOf(r).name) + } + return s.Str() + } + } + raise("TypeError", "no implicit conversion of %s into String", vm.classOf(v).name) + return "" +} + // scrubUTF8 returns s with every invalid UTF-8 byte sequence replaced by repl, // so the result is valid UTF-8 (backing String#scrub). Valid runs are copied // verbatim; each invalid byte collapses to repl, as MRI does. diff --git a/internal/vm/file.go b/internal/vm/file.go index 87eb1830..4b989d27 100644 --- a/internal/vm/file.go +++ b/internal/vm/file.go @@ -5,6 +5,7 @@ import ( "path" // always '/'-separated, as Ruby's File is — not path/filepath "strings" + gotime "github.com/go-composites/time/src" "github.com/go-embedded-ruby/ruby/internal/object" ) @@ -38,8 +39,8 @@ func (vm *VM) registerFile() { cFile.consts["NULL"] = object.NewString("/dev/null") def := func(name string, fn NativeFn) { cFile.smethods[name] = &Method{name: name, owner: cFile, native: fn} } - def("basename", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - base := path.Base(strArg(args[0])) + def("basename", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + base := path.Base(pathArg(vm, args[0])) if len(args) > 1 { suf := strArg(args[1]) if suf == ".*" { @@ -52,17 +53,17 @@ func (vm *VM) registerFile() { } return object.NewString(base) }) - def("dirname", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - return object.NewString(path.Dir(strArg(args[0]))) + def("dirname", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(path.Dir(pathArg(vm, args[0]))) }) - def("extname", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - return object.NewString(fileExt(path.Base(strArg(args[0])))) + def("extname", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(fileExt(path.Base(pathArg(vm, args[0])))) }) - def("split", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - p := strArg(args[0]) + def("split", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + p := pathArg(vm, args[0]) return &object.Array{Elems: []object.Value{object.NewString(path.Dir(p)), object.NewString(path.Base(p))}} }) - def("join", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + def("join", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { // MRI's File.join flattens nested Array arguments, so File.join([a, b], c) // behaves like File.join(a, b, c). var parts []string @@ -74,65 +75,76 @@ func (vm *VM) registerFile() { } return } - parts = append(parts, strArg(v)) + parts = append(parts, pathArg(vm, v)) } for _, a := range args { add(a) } return object.NewString(fileJoin(parts)) }) - def("expand_path", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - return object.NewString(fileExpand(strArg(args[0]), args[1:])) + def("expand_path", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(fileExpand(pathArg(vm, args[0]), args[1:])) }) // absolute_path resolves a path to an absolute one against an optional base // directory (defaulting to the CWD), like expand_path but without ~ expansion. // Puppet uses it with relative paths and an explicit base, where the two agree. - def("absolute_path", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - return object.NewString(fileExpand(strArg(args[0]), args[1:])) + def("absolute_path", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(fileExpand(pathArg(vm, args[0]), args[1:])) }) - def("absolute_path?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - return object.Bool(path.IsAbs(toSlash(strArg(args[0])))) + def("absolute_path?", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.Bool(path.IsAbs(toSlash(pathArg(vm, args[0])))) }) - def("exist?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - _, err := os.Stat(strArg(args[0])) + def("exist?", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + _, err := os.Stat(pathArg(vm, args[0])) return object.Bool(err == nil) }) - def("file?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - fi, err := os.Stat(strArg(args[0])) + def("file?", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + fi, err := os.Stat(pathArg(vm, args[0])) return object.Bool(err == nil && fi.Mode().IsRegular()) }) - def("directory?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - fi, err := os.Stat(strArg(args[0])) + def("directory?", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + fi, err := os.Stat(pathArg(vm, args[0])) return object.Bool(err == nil && fi.IsDir()) }) - def("read", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - p := strArg(args[0]) + def("read", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + p := pathArg(vm, args[0]) b, err := os.ReadFile(p) if err != nil { raise("Errno::ENOENT", "No such file or directory @ rb_sysopen - %s", p) } return object.NewString(string(b)) }) - def("write", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - p := strArg(args[0]) + def("write", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + p := pathArg(vm, args[0]) data := []byte(strArg(args[1])) if err := os.WriteFile(p, data, 0o644); err != nil { raise("Errno::ENOENT", "No such file or directory @ rb_sysopen - %s", p) } return object.Integer(len(data)) }) - def("size", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - p := strArg(args[0]) + def("size", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + p := pathArg(vm, args[0]) fi, err := os.Stat(p) if err != nil { raise("Errno::ENOENT", "No such file or directory @ rb_file_s_stat - %s", p) } return object.Integer(fi.Size()) }) - delete := func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + // File.mtime returns the file's last-modification Time (whole-second + // resolution, matching the Time class's granularity). A missing file raises + // Errno::ENOENT, as in MRI. + def("mtime", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + p := pathArg(vm, args[0]) + fi, err := os.Stat(p) + if err != nil { + raise("Errno::ENOENT", "No such file or directory @ rb_file_s_stat - %s", p) + } + return &Time{t: gotime.FromUnix(fi.ModTime().Unix())} + }) + delete := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { for _, a := range args { - p := strArg(a) + p := pathArg(vm, a) if err := os.Remove(p); err != nil { raise("Errno::ENOENT", "No such file or directory @ apply2files - %s", p) } diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 796ea259..22b6ba28 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -394,12 +394,30 @@ func cvarOwner(c *RClass, name string) *RClass { } // lookupSMethod finds a singleton (class) method, walking the superclass chain -// so a subclass inherits its ancestors' class methods. +// so a subclass inherits its ancestors' class methods. At each level it also +// consults the modules mixed into that class's singleton (metaclass) — i.e. +// `class << self; include M; end` / `extend M` — so a module's instance methods +// become class methods, matching MRI's singleton-class ancestor order +// (prepends, then own smethods, then includes). func lookupSMethod(c *RClass, name string) *Method { for ; c != nil; c = c.super { + if c.meta != nil { + for i := len(c.meta.prepends) - 1; i >= 0; i-- { + if m := lookupOwnOrIncluded(c.meta.prepends[i], name); m != nil { + return m + } + } + } if m, ok := c.smethods[name]; ok { return m } + if c.meta != nil { + for i := len(c.meta.includes) - 1; i >= 0; i-- { + if m := lookupOwnOrIncluded(c.meta.includes[i], name); m != nil { + return m + } + } + } } return nil } diff --git a/internal/vm/optparse.go b/internal/vm/optparse.go new file mode 100644 index 00000000..04377b58 --- /dev/null +++ b/internal/vm/optparse.go @@ -0,0 +1,79 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerOptParse installs a loadable shell for the optparse standard library +// (require "optparse"). A complete OptionParser is a substantial parser in its +// own right; for now the class and its error tree exist so `require "optparse"` +// completes and load-time references resolve (Puppet requires optparse at load +// but only builds and runs a parser from method bodies). The declaration surface +// that merely records options without parsing — on / on_tail / on_head / +// separator / banner= — is real and chainable, while the methods that actually +// consume argv (parse / parse! / order / order! / permute / permute!) raise +// NotImplementedError until the real parser lands. +func (vm *VM) registerOptParse() { + std := vm.consts["StandardError"].(*RClass) + + op := newClass("OptionParser", vm.cObject) + vm.consts["OptionParser"] = op + // MRI also exposes the short alias OptParse for OptionParser. + vm.consts["OptParse"] = op + + // Error tree: ParseError < StandardError, with the concrete subclasses raised + // by a real parse (so `rescue OptionParser::InvalidOption` resolves). + parseErr := newClass("OptionParser::ParseError", std) + op.consts["ParseError"] = parseErr + for _, name := range []string{ + "InvalidOption", "MissingArgument", "InvalidArgument", + "AmbiguousOption", "AmbiguousArgument", "NeedlessArgument", + } { + op.consts[name] = newClass("OptionParser::"+name, parseErr) + } + + op.smethods["new"] = &Method{name: "new", owner: op, + native: func(_ *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + o := &RObject{class: op, ivars: map[string]object.Value{}} + if len(args) > 0 { + o.ivars["@banner"] = object.NewString(args[0].ToS()) + } + // A block form (OptionParser.new { |opts| ... }) yields the parser, as in + // MRI, so option declarations inside the block run. + if blk != nil { + vm.callBlock(blk, []object.Value{o}) + } + return o + }} + + // Declaration methods: record nothing meaningful yet but accept any arguments + // and a block, returning self so chaining works. on captures the block so a + // future real parser can invoke it. + for _, m := range []string{"on", "on_tail", "on_head", "separator", "accept", "reject"} { + op.define(m, func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return self + }) + } + op.define("banner", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@banner") + }) + op.define("banner=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*RObject).ivars["@banner"] = args[0] + return args[0] + }) + + // Parsing methods need real argv handling — the large subsystem — so they raise + // until that lands. + notImpl := func(what string) NativeFn { + return func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return raise("NotImplementedError", "OptionParser#%s not yet supported (real argv parser pending)", what) + } + } + for _, m := range []string{"parse", "parse!", "order", "order!", "permute", "permute!", "to_a", "help"} { + op.define(m, notImpl(m)) + } +} diff --git a/internal/vm/prelude.rb b/internal/vm/prelude.rb index 52138296..4ad454a6 100644 --- a/internal/vm/prelude.rb +++ b/internal/vm/prelude.rb @@ -1372,3 +1372,26 @@ def URI(uri) end module_function :URI end + +# Singleton turns its includer into a single-instance class (require "singleton"). +# Mixing it in privatizes .new/.allocate and adds a memoizing .instance, matching +# MRI's lib/singleton.rb behavior on the surface code relies on (Puppet::Runtime +# is `include Singleton`). +module Singleton + # The class methods Singleton grafts onto the includer via extend. + module SingletonClassMethods + # instance returns the one-and-only instance, building it on first call and + # caching it on the class thereafter. + def instance + @__singleton_instance__ ||= new + end + end + + # included is the mix-in hook: it extends the includer with the class methods + # and makes the constructors private, so the instance is reachable only via + # .instance — calling .new raises NoMethodError, as in MRI. + def self.included(klass) + klass.extend(SingletonClassMethods) + klass.private_class_method(:new, :allocate) + end +end diff --git a/internal/vm/prelude_frozen_gen.go b/internal/vm/prelude_frozen_gen.go index 6454b694..6149ca1b 100644 --- a/internal/vm/prelude_frozen_gen.go +++ b/internal/vm/prelude_frozen_gen.go @@ -10,8 +10,8 @@ import ( func embeddedPrelude() *bytecode.ISeq { return &bytecode.ISeq{ Name: "
", - Insns: []bytecode.Instr{{Op: 43}, {Op: 9}, {Op: 43, A: 1, B: 1}, {Op: 9}, {Op: 42, A: 2, B: 2}, {Op: 9}, {Op: 42, A: 3, B: 3}, {Op: 9}, {Op: 42, A: 4, B: 4}, {Op: 9}, {Op: 42, A: 5, B: 5}, {Op: 9}, {Op: 42, A: 6, B: 6}, {Op: 9}, {Op: 42, A: 7, B: 7}, {Op: 9}, {Op: 43, A: 8, B: 8}, {Op: 9}, {Op: 42, A: 9, B: 9}, {Op: 9}, {Op: 43, A: 10, B: 10}, {Op: 9}, {Op: 43, A: 11, B: 11}, {Op: 9}, {Op: 43, A: 12, B: 12}, {Op: 9}, {Op: 42, A: 13, B: 13}, {Op: 9}, {Op: 42, A: 14, B: 14}, {Op: 9}, {Op: 44, A: 15, B: 15}, {Op: 9}, {Op: 42, A: 16, B: 16}, {Op: 9}, {Op: 43, A: 17, B: 17}, {Op: 9}, {Op: 43, A: 18, B: 18}, {Op: 53}}, - Names: []string{"Comparable", "Enumerable", "Numeric", "String", "Symbol", "Array", "Range", "Hash", "Gem", "OpenStruct", "Benchmark", "Forwardable", "SingleForwardable", "Delegator", "SimpleDelegator", "DelegateClass", "Pathname", "URI", "Kernel"}, + Insns: []bytecode.Instr{{Op: 43}, {Op: 9}, {Op: 43, A: 1, B: 1}, {Op: 9}, {Op: 42, A: 2, B: 2}, {Op: 9}, {Op: 42, A: 3, B: 3}, {Op: 9}, {Op: 42, A: 4, B: 4}, {Op: 9}, {Op: 42, A: 5, B: 5}, {Op: 9}, {Op: 42, A: 6, B: 6}, {Op: 9}, {Op: 42, A: 7, B: 7}, {Op: 9}, {Op: 43, A: 8, B: 8}, {Op: 9}, {Op: 42, A: 9, B: 9}, {Op: 9}, {Op: 43, A: 10, B: 10}, {Op: 9}, {Op: 43, A: 11, B: 11}, {Op: 9}, {Op: 43, A: 12, B: 12}, {Op: 9}, {Op: 42, A: 13, B: 13}, {Op: 9}, {Op: 42, A: 14, B: 14}, {Op: 9}, {Op: 44, A: 15, B: 15}, {Op: 9}, {Op: 42, A: 16, B: 16}, {Op: 9}, {Op: 43, A: 17, B: 17}, {Op: 9}, {Op: 43, A: 18, B: 18}, {Op: 9}, {Op: 43, A: 19, B: 19}, {Op: 53}}, + Names: []string{"Comparable", "Enumerable", "Numeric", "String", "Symbol", "Array", "Range", "Hash", "Gem", "OpenStruct", "Benchmark", "Forwardable", "SingleForwardable", "Delegator", "SimpleDelegator", "DelegateClass", "Pathname", "URI", "Kernel", "Singleton"}, NumRequired: 0, SplatIndex: -1, KwRestSlot: -1, @@ -3682,6 +3682,60 @@ func embeddedPrelude() *bytecode.ISeq { }, Super: "", }, + &bytecode.ISeq{ + Name: "", + Insns: []bytecode.Instr{{Op: 43}, {Op: 9}, {Op: 45, A: 1, B: 1}, {Op: 53}}, + Names: []string{"SingletonClassMethods", "included"}, + NumRequired: 0, + SplatIndex: -1, + KwRestSlot: -1, + BlockSlot: -1, + NumLocals: 0, + Children: []*bytecode.ISeq{ + &bytecode.ISeq{ + Name: "", + Insns: []bytecode.Instr{{Op: 44}, {Op: 53}}, + Names: []string{"instance"}, + NumRequired: 0, + SplatIndex: -1, + KwRestSlot: -1, + BlockSlot: -1, + NumLocals: 0, + Children: []*bytecode.ISeq{ + &bytecode.ISeq{ + Name: "instance", + Insns: []bytecode.Instr{{Op: 31}, {Op: 10}, {Op: 27, A: 6}, {Op: 9}, {Op: 5}, {Op: 30, A: 1}, {Op: 32}, {Op: 53}}, + Names: []string{"@__singleton_instance__", "new"}, + NumRequired: 0, + SplatIndex: -1, + KwRestSlot: -1, + BlockSlot: -1, + NumLocals: 0, + Super: "", + }, + }, + Super: "", + }, + &bytecode.ISeq{ + Name: "included", + Insns: []bytecode.Instr{{Op: 11}, {Op: 33}, {Op: 30, A: 1, B: 1, Flags: 1}, {Op: 9}, {Op: 11}, {Op: 1}, {Op: 1, A: 1}, {Op: 30, A: 2, B: 2, Flags: 1}, {Op: 53}}, + Consts: []object.Value{ + object.Symbol("new"), + object.Symbol("allocate"), + }, + Names: []string{"SingletonClassMethods", "extend", "private_class_method"}, + Params: []string{"klass"}, + NumRequired: 1, + SplatIndex: -1, + KwRestSlot: -1, + BlockSlot: -1, + NumLocals: 1, + Locals: []string{"klass"}, + Super: "", + }, + }, + Super: "", + }, }, Super: "", } diff --git a/internal/vm/require.go b/internal/vm/require.go index 5d10f0ac..76ed1b9a 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -23,13 +23,15 @@ var preloadedFeatures = map[string]bool{"set": true, "rubygems": true} var providedFeatures = map[string]bool{ "date": true, "time": true, "bigdecimal": true, "bag": true, "base64": true, "digest": true, "json": true, "zlib": true, + "digest/md5": true, "digest/sha1": true, "digest/sha2": true, "stringio": true, "securerandom": true, "English": true, "ostruct": true, "benchmark": true, "forwardable": true, "delegate": true, "pathname": true, "uri": true, "tmpdir": true, "openssl": true, "timeout": true, "rbconfig": true, "yaml": true, "fileutils": true, "getoptlong": true, "etc": true, "concurrent": true, "syslog": true, "cgi": true, "monitor": true, - "net/http": true, "net/https": true, + "net/http": true, "net/https": true, "resolv": true, "singleton": true, + "optparse": true, "ripper": true, } // registerRequire installs Kernel#require and #require_relative — the runtime @@ -42,6 +44,41 @@ func (vm *VM) registerRequire() { vm.cObject.define("require_relative", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { return vm.doRequire(requireName(vm, args), true) }) + // Kernel#load re-executes a file every call (unlike require, which loads once) + // and returns true. Exposed as a private instance method (a bare `load`) and as + // a module method on Kernel (`Kernel.load`, the form Puppet's autoloader uses). + loadFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.doLoad(requireName(vm, args)) + } + vm.cObject.define("load", loadFn) + if kernel, ok := vm.consts["Kernel"].(*RClass); ok { + kernel.smethods["load"] = &Method{name: "load", owner: kernel, native: loadFn} + } +} + +// doLoad implements Kernel#load: it locates name (an explicit path, or a file +// searched on $LOAD_PATH and the CWD) and executes it, always re-running it +// regardless of any prior load/require. The .rb suffix is NOT auto-appended (MRI +// loads the literal name), and a missing file raises LoadError. +func (vm *VM) doLoad(name string) object.Value { + for _, cand := range vm.requireCandidates(name, false) { + src, err := os.ReadFile(cand) + if err != nil { + continue // not found / unreadable — try the next candidate + } + abs, _ := filepath.Abs(cand) + iseq, cerr := parseCompileFn(string(src)) + if cerr != nil { + return raise("SyntaxError", "%s", cerr.Error()) + } + iseq.Name = abs + setISeqFile(iseq, abs) + vm.requireDirs = append(vm.requireDirs, filepath.Dir(abs)) + defer func() { vm.requireDirs = vm.requireDirs[:len(vm.requireDirs)-1] }() + vm.exec(iseq, vm.main, nil, vm.cObject, "", nil, nil, nil) + return object.Bool(true) + } + return raise("LoadError", "cannot load such file -- %s", name) } // requireName extracts the String feature name, raising TypeError otherwise. diff --git a/internal/vm/resolv.go b/internal/vm/resolv.go new file mode 100644 index 00000000..18361b86 --- /dev/null +++ b/internal/vm/resolv.go @@ -0,0 +1,225 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "net" + "strings" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerResolv installs the Resolv standard library (require "resolv"). The +// address-parsing surface is real and cheap: Resolv::IPv4 / Resolv::IPv6 parse, +// validate (via the ::Regex constants) and render dotted/colon addresses, and +// Resolv.getaddress resolves a literal IP without any networking. The DNS +// resolver itself needs real UDP/TCP sockets (a later round), so Resolv::DNS is +// constructable but its query methods (getresource(s)/getaddress/getname/each_*) +// raise NotImplementedError, and Resolv.getaddress raises ResolvError for a name +// that is not already a literal address. The constant/class tree Puppet walks +// (Resolv::DNS::Resource::IN::{A,AAAA,SRV,...} and the error classes) exists so +// load-time references resolve. +func (vm *VM) registerResolv() { + std := vm.consts["StandardError"].(*RClass) + + resolv := newClass("Resolv", nil) + resolv.isModule = true + vm.consts["Resolv"] = resolv + + // --- Error classes ---------------------------------------------------------- + resolvErr := newClass("Resolv::ResolvError", std) + resolv.consts["ResolvError"] = resolvErr + resolv.consts["ResolvTimeout"] = newClass("Resolv::ResolvTimeout", resolvErr) + + // --- Resolv::IPv4 ----------------------------------------------------------- + ipv4 := newClass("Resolv::IPv4", vm.cObject) + resolv.consts["IPv4"] = ipv4 + // Regex matches a dotted-quad with each octet 0..255 (anchored), accepting the + // same set as MRI's Resolv::IPv4::Regex without reproducing its byte layout. + ipv4.consts["Regex"] = vm.compileRegexp(`\A(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\z`, "") + ipv4.smethods["create"] = &Method{name: "create", owner: ipv4, + native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return newIPv4(ipv4, parseIPv4(strArg(args[0]))) + }} + ipv4.smethods["new"] = ipv4.smethods["create"] + ipv4.define("to_s", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@addr") + }) + ipv4.define("to_str", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@addr") + }) + ipv4.define("inspect", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString("#") + }) + ipv4.define("==", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o, ok := args[0].(*RObject) + if !ok || o.class != ipv4 { + return object.Bool(false) + } + return object.Bool(strArg(getIvar(self, "@addr")) == strArg(getIvar(o, "@addr"))) + }) + + // --- Resolv::IPv6 ----------------------------------------------------------- + ipv6 := newClass("Resolv::IPv6", vm.cObject) + resolv.consts["IPv6"] = ipv6 + // Regex accepts the textual IPv6 forms; the create path re-validates via Go's + // net.ParseIP, so the Regex only needs to be permissive over the valid set. + ipv6.consts["Regex"] = vm.compileRegexp(`\A[0-9A-Fa-f:]+(?:%[-0-9A-Za-z._~]+)?\z`, "") + ipv6.smethods["create"] = &Method{name: "create", owner: ipv6, + native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return newIPv6(ipv6, parseIPv6(strArg(args[0]))) + }} + ipv6.smethods["new"] = ipv6.smethods["create"] + ipv6.define("to_s", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@addr") + }) + ipv6.define("to_str", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@addr") + }) + ipv6.define("inspect", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString("#") + }) + ipv6.define("==", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o, ok := args[0].(*RObject) + if !ok || o.class != ipv6 { + return object.Bool(false) + } + return object.Bool(strArg(getIvar(self, "@addr")) == strArg(getIvar(o, "@addr"))) + }) + + // --- Resolv::Hosts (constructable shell; lookups need /etc/hosts I/O) -------- + hosts := newClass("Resolv::Hosts", vm.cObject) + resolv.consts["Hosts"] = hosts + hosts.consts["DefaultFileName"] = object.NewString("/etc/hosts") + hosts.smethods["new"] = &Method{name: "new", owner: hosts, + native: func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return &RObject{class: hosts, ivars: map[string]object.Value{}} + }} + + // --- Resolv::DNS and its Resource constant tree ----------------------------- + registerResolvDNS(vm, resolv) + + // --- Module-level resolution helpers ---------------------------------------- + resolveNotImpl := func(what string) NativeFn { + return func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return raise("NotImplementedError", "Resolv.%s needs real DNS sockets (not yet supported)", what) + } + } + // getaddress is real for a literal IP — pure computation, no networking — and + // raises ResolvError for a name that would require an actual DNS query. + resolv.smethods["getaddress"] = &Method{name: "getaddress", owner: resolv, + native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + name := strArg(args[0]) + if isLiteralIP(name) { + return object.NewString(name) + } + return raise("Resolv::ResolvError", "no address for %s", name) + }} + resolv.smethods["getaddresses"] = &Method{name: "getaddresses", owner: resolv, + native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + name := strArg(args[0]) + if isLiteralIP(name) { + return &object.Array{Elems: []object.Value{object.NewString(name)}} + } + return &object.Array{Elems: nil} // MRI returns [] when nothing resolves + }} + for _, m := range []string{"getname", "getnames", "each_address", "each_name"} { + resolv.smethods[m] = &Method{name: m, owner: resolv, native: resolveNotImpl(m)} + } +} + +// registerResolvDNS builds Resolv::DNS (a constructable resolver whose query +// methods raise NotImplementedError) and the Resource constant tree that Puppet +// names (Resolv::DNS::Resource::IN::{A,AAAA,SRV,...}). +func registerResolvDNS(vm *VM, resolv *RClass) { + dns := newClass("Resolv::DNS", vm.cObject) + resolv.consts["DNS"] = dns + dns.smethods["new"] = &Method{name: "new", owner: dns, + native: func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return &RObject{class: dns, ivars: map[string]object.Value{}} + }} + dns.smethods["open"] = dns.smethods["new"] + queryNotImpl := func(what string) NativeFn { + return func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return raise("NotImplementedError", "Resolv::DNS#%s needs real DNS sockets (not yet supported)", what) + } + } + for _, m := range []string{ + "getresource", "getresources", "getaddress", "getaddresses", + "getname", "getnames", "each_resource", "each_address", "each_name", "close", + } { + dns.define(m, queryNotImpl(m)) + } + + // Resource hierarchy: Resolv::DNS::Resource and the IN class set. Each leaf is + // a plain class so `is_a?`/`==` against the constant works; instances are not + // constructed by the (stubbed) query path yet. + resource := newClass("Resolv::DNS::Resource", vm.cObject) + dns.consts["Resource"] = resource + in := newClass("Resolv::DNS::Resource::IN", resource) + resource.consts["IN"] = in + for _, rec := range []string{"A", "AAAA", "SRV", "CNAME", "NS", "MX", "PTR", "TXT", "SOA"} { + in.consts[rec] = newClass("Resolv::DNS::Resource::IN::"+rec, resource) + } + + // Resolv::DNS::Name — constructable from a dotted name (real, cheap). + name := newClass("Resolv::DNS::Name", vm.cObject) + dns.consts["Name"] = name + name.smethods["create"] = &Method{name: "create", owner: name, + native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + o := &RObject{class: name, ivars: map[string]object.Value{}} + o.ivars["@name"] = object.NewString(strArg(args[0])) + return o + }} + name.define("to_s", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@name") + }) +} + +// newIPv4 builds a Resolv::IPv4 RObject storing the canonical dotted string. +func newIPv4(cls *RClass, addr string) object.Value { + o := &RObject{class: cls, ivars: map[string]object.Value{}} + o.ivars["@addr"] = object.NewString(addr) + return o +} + +// newIPv6 builds a Resolv::IPv6 RObject storing the canonical colon string. +func newIPv6(cls *RClass, addr string) object.Value { + o := &RObject{class: cls, ivars: map[string]object.Value{}} + o.ivars["@addr"] = object.NewString(addr) + return o +} + +// parseIPv4 validates a dotted-quad and returns its canonical form, raising +// ArgumentError on anything that is not a valid IPv4 address (matching MRI's +// Resolv::IPv4.create, which rejects malformed input). +func parseIPv4(s string) string { + ip := net.ParseIP(s) + if ip == nil || ip.To4() == nil || strings.Contains(s, ":") { + raise("ArgumentError", "cannot interpret as IPv4 address: %s", s) + } + return ip.To4().String() +} + +// parseIPv6 validates a textual IPv6 address and returns its canonical form, +// raising ArgumentError otherwise. +func parseIPv6(s string) string { + // Strip an optional zone id (%eth0) before parsing, re-appending it after. + base, zone := s, "" + if i := strings.IndexByte(s, '%'); i >= 0 { + base, zone = s[:i], s[i:] + } + ip := net.ParseIP(base) + if ip == nil || ip.To4() != nil || ip.To16() == nil { + raise("ArgumentError", "cannot interpret as IPv6 address: %s", s) + } + return ip.To16().String() + zone +} + +// isLiteralIP reports whether name is already a numeric IPv4 or IPv6 address, in +// which case Resolv.getaddress can answer without any DNS query. +func isLiteralIP(name string) bool { + return net.ParseIP(name) != nil +} diff --git a/internal/vm/ripper.go b/internal/vm/ripper.go new file mode 100644 index 00000000..1bbb1778 --- /dev/null +++ b/internal/vm/ripper.go @@ -0,0 +1,30 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerRipper installs a loadable shell for the ripper standard library +// (require "ripper"). Ripper is a full Ruby-source S-expression/event parser — a +// large subsystem of its own; for now the class exists so `require "ripper"` +// completes and load-time references resolve (Puppet requires ripper at load but +// only calls Ripper.sexp from a method body). The class methods that actually +// parse source (sexp / sexp_raw / lex / tokenize / parse / slice) raise +// NotImplementedError until a real Ripper front-end lands. +func (vm *VM) registerRipper() { + rip := newClass("Ripper", vm.cObject) + vm.consts["Ripper"] = rip + + notImpl := func(what string) NativeFn { + return func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return raise("NotImplementedError", "Ripper.%s not yet supported (real Ripper front-end pending)", what) + } + } + for _, m := range []string{"sexp", "sexp_raw", "lex", "tokenize", "parse", "slice"} { + rip.smethods[m] = &Method{name: m, owner: rip, native: notImpl(m)} + } +} diff --git a/internal/vm/stdlib_resolv_batch_test.go b/internal/vm/stdlib_resolv_batch_test.go new file mode 100644 index 00000000..2f67a260 --- /dev/null +++ b/internal/vm/stdlib_resolv_batch_test.go @@ -0,0 +1,443 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm_test + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// This file covers the "puppet-resolv-batch" round: Symbol#intern, the Resolv / +// optparse / ripper loadable stdlib, the SystemExit and friends exception +// classes, the Singleton prelude module, Kernel#load, $LOADED_FEATURES, +// File.mtime / Pathname path coercion, and the singleton-class `include` method +// lookup fix. Behaviour is asserted against MRI Ruby 4.0.5. + +// TestSymbolIntern covers Symbol#intern, MRI's alias of Symbol#to_sym (returns +// self), which Puppet relies on so :sym.intern does not fall through to a +// user-defined Object#intern. +func TestSymbolIntern(t *testing.T) { + cases := []struct{ src, want string }{ + {`p :foo.intern`, ":foo\n"}, + {`p :foo.intern == :foo`, "true\n"}, + {`p :foo.intern.equal?(:foo)`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestResolvRequire covers require "resolv" returning true then false. +func TestResolvRequire(t *testing.T) { + if got := eval(t, `p require "resolv"`); got != "true\n" { + t.Errorf("first require got %q", got) + } + if got := eval(t, `require "resolv"; p require "resolv"`); got != "false\n" { + t.Errorf("second require got %q", got) + } +} + +// TestResolvIPv4 covers Resolv::IPv4: create/new, to_s/to_str, inspect, ==, the +// Regex constant, and the ArgumentError raised for a malformed address. +func TestResolvIPv4(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "resolv"; p Resolv::IPv4.create("1.2.3.4").to_s`, "\"1.2.3.4\"\n"}, + {`require "resolv"; p Resolv::IPv4.new("10.0.0.1").to_str`, "\"10.0.0.1\"\n"}, + {`require "resolv"; p Resolv::IPv4.create("1.2.3.4").inspect`, "\"#\"\n"}, + {`require "resolv"; a=Resolv::IPv4.create("1.2.3.4"); b=Resolv::IPv4.create("1.2.3.4"); p(a==b)`, "true\n"}, + {`require "resolv"; a=Resolv::IPv4.create("1.2.3.4"); b=Resolv::IPv4.create("4.3.2.1"); p(a==b)`, "false\n"}, + {`require "resolv"; p(Resolv::IPv4.create("1.2.3.4") == "1.2.3.4")`, "false\n"}, + {`require "resolv"; p(Resolv::IPv4::Regex.match?("192.168.0.1"))`, "true\n"}, + {`require "resolv"; p(Resolv::IPv4::Regex.match?("not-an-ip"))`, "false\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } + // Malformed input raises ArgumentError. + if class, _ := evalErr(t, `require "resolv"; Resolv::IPv4.create("999.1")`); class != "ArgumentError" { + t.Errorf("bad IPv4 raised %q, want ArgumentError", class) + } + // An IPv6 string is not a valid IPv4 address. + if class, _ := evalErr(t, `require "resolv"; Resolv::IPv4.create("::1")`); class != "ArgumentError" { + t.Errorf("ipv6-as-ipv4 raised %q, want ArgumentError", class) + } +} + +// TestResolvIPv6 covers Resolv::IPv6: create/new, to_s/to_str, inspect, ==, the +// Regex constant, the zone-id form, and the ArgumentError for bad input. +func TestResolvIPv6(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "resolv"; p Resolv::IPv6.create("::1").to_s`, "\"::1\"\n"}, + {`require "resolv"; p Resolv::IPv6.new("fe80::1").to_str.start_with?("fe80")`, "true\n"}, + {`require "resolv"; p Resolv::IPv6.create("::1").inspect`, "\"#\"\n"}, + {`require "resolv"; a=Resolv::IPv6.create("::1"); b=Resolv::IPv6.create("::1"); p(a==b)`, "true\n"}, + {`require "resolv"; a=Resolv::IPv6.create("::1"); b=Resolv::IPv6.create("::2"); p(a==b)`, "false\n"}, + {`require "resolv"; p(Resolv::IPv6.create("::1") == "::1")`, "false\n"}, + {`require "resolv"; p(Resolv::IPv6::Regex.match?("::1"))`, "true\n"}, + {`require "resolv"; p(Resolv::IPv6::Regex.match?("zzz"))`, "false\n"}, + // Zone id is preserved through create. + {`require "resolv"; p Resolv::IPv6.create("fe80::1%eth0").to_s.end_with?("%eth0")`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } + if class, _ := evalErr(t, `require "resolv"; Resolv::IPv6.create("not-ipv6")`); class != "ArgumentError" { + t.Errorf("bad IPv6 raised %q, want ArgumentError", class) + } + // An IPv4 string is not a valid IPv6 address. + if class, _ := evalErr(t, `require "resolv"; Resolv::IPv6.create("1.2.3.4")`); class != "ArgumentError" { + t.Errorf("ipv4-as-ipv6 raised %q, want ArgumentError", class) + } +} + +// TestResolvModuleMethods covers Resolv.getaddress / getaddresses (real for a +// literal IP), the ResolvError for a hostname, and the NotImplementedError for +// the socket-backed helpers. +func TestResolvModuleMethods(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "resolv"; p Resolv.getaddress("127.0.0.1")`, "\"127.0.0.1\"\n"}, + {`require "resolv"; p Resolv.getaddress("::1")`, "\"::1\"\n"}, + {`require "resolv"; p Resolv.getaddresses("127.0.0.1")`, "[\"127.0.0.1\"]\n"}, + {`require "resolv"; p Resolv.getaddresses("example.invalid")`, "[]\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } + if class, _ := evalErr(t, `require "resolv"; Resolv.getaddress("example.invalid")`); class != "Resolv::ResolvError" { + t.Errorf("getaddress(name) raised %q, want Resolv::ResolvError", class) + } + for _, m := range []string{"getname", "getnames", "each_address", "each_name"} { + if class, _ := evalErr(t, `require "resolv"; Resolv.`+m+`("x")`); class != "NotImplementedError" { + t.Errorf("Resolv.%s raised %q, want NotImplementedError", m, class) + } + } +} + +// TestResolvDNS covers Resolv::DNS (constructable, query methods raise), the +// Resource constant tree, and Resolv::DNS::Name and Resolv::Hosts. +func TestResolvDNS(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "resolv"; p Resolv::DNS.new.class`, "Resolv::DNS\n"}, + {`require "resolv"; p Resolv::DNS.open.class`, "Resolv::DNS\n"}, + {`require "resolv"; p Resolv::DNS::Resource::IN::SRV.name`, "\"Resolv::DNS::Resource::IN::SRV\"\n"}, + {`require "resolv"; p Resolv::DNS::Resource::IN::A.name`, "\"Resolv::DNS::Resource::IN::A\"\n"}, + {`require "resolv"; p defined?(Resolv::DNS::Resource::IN::AAAA)`, "\"constant\"\n"}, + {`require "resolv"; p Resolv::DNS::Name.create("example.com").to_s`, "\"example.com\"\n"}, + {`require "resolv"; p Resolv::Hosts.new.class`, "Resolv::Hosts\n"}, + {`require "resolv"; p Resolv::Hosts::DefaultFileName`, "\"/etc/hosts\"\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } + for _, m := range []string{ + "getresource", "getresources", "getaddress", "getaddresses", + "getname", "getnames", "each_resource", "each_address", "each_name", "close", + } { + if class, _ := evalErr(t, `require "resolv"; Resolv::DNS.new.`+m+`("x")`); class != "NotImplementedError" { + t.Errorf("Resolv::DNS#%s raised %q, want NotImplementedError", m, class) + } + } +} + +// TestOptParse covers the optparse loadable shell: require, OptionParser.new +// (with and without a banner and a block), the chainable declaration methods, +// banner accessors, the error class tree, and the NotImplementedError parsing +// methods. +func TestOptParse(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "optparse"`, "true\n"}, + {`require "optparse"; p OptionParser.new.class`, "OptionParser\n"}, + {`require "optparse"; p OptParse.equal?(OptionParser)`, "true\n"}, + {`require "optparse"; p OptionParser.new("usage").banner`, "\"usage\"\n"}, + // Block form yields the parser. + {`require "optparse"; OptionParser.new { |o| p o.class }`, "OptionParser\n"}, + // Declaration methods return self (chainable) and the block-capturing on. + {`require "optparse"; o=OptionParser.new; p o.on("-x").equal?(o)`, "true\n"}, + {`require "optparse"; o=OptionParser.new; p o.separator("--").equal?(o)`, "true\n"}, + {`require "optparse"; o=OptionParser.new; p o.on_tail("-h").equal?(o)`, "true\n"}, + {`require "optparse"; o=OptionParser.new; p o.on_head("-v").equal?(o)`, "true\n"}, + {`require "optparse"; o=OptionParser.new; p o.accept(Integer).equal?(o)`, "true\n"}, + {`require "optparse"; o=OptionParser.new; p o.reject(Integer).equal?(o)`, "true\n"}, + // banner=/banner round-trip. + {`require "optparse"; o=OptionParser.new; o.banner="b"; p o.banner`, "\"b\"\n"}, + // Error tree. + {`require "optparse"; p OptionParser::InvalidOption.ancestors.include?(OptionParser::ParseError)`, "true\n"}, + {`require "optparse"; p OptionParser::ParseError.ancestors.include?(StandardError)`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } + for _, m := range []string{"parse", "parse!", "order", "order!", "permute", "permute!", "to_a", "help"} { + if class, _ := evalErr(t, `require "optparse"; OptionParser.new.`+m+`([])`); class != "NotImplementedError" { + t.Errorf("OptionParser#%s raised %q, want NotImplementedError", m, class) + } + } +} + +// TestRipper covers the ripper loadable shell: require and the +// NotImplementedError class methods. +func TestRipper(t *testing.T) { + if got := eval(t, `p require "ripper"`); got != "true\n" { + t.Errorf("require got %q", got) + } + if got := eval(t, `require "ripper"; p Ripper.class`); got != "Class\n" { + t.Errorf("Ripper.class got %q", got) + } + for _, m := range []string{"sexp", "sexp_raw", "lex", "tokenize", "parse", "slice"} { + if class, _ := evalErr(t, `require "ripper"; Ripper.`+m+`("1+1")`); class != "NotImplementedError" { + t.Errorf("Ripper.%s raised %q, want NotImplementedError", m, class) + } + } +} + +// TestSystemExitFamily covers SystemExit (status/success?/message) and the other +// non-StandardError exception classes added this round. +func TestSystemExitFamily(t *testing.T) { + cases := []struct{ src, want string }{ + // SystemExit hierarchy + default status. + {`p SystemExit.ancestors.include?(Exception)`, "true\n"}, + {`p SystemExit.ancestors.include?(StandardError)`, "false\n"}, + {`p SystemExit.new.status`, "0\n"}, + {`p SystemExit.new(2).status`, "2\n"}, + {`p SystemExit.new(2).success?`, "false\n"}, + {`p SystemExit.new(0).success?`, "true\n"}, + {`p SystemExit.new.success?`, "true\n"}, + // (status, message) form. + {`p SystemExit.new(3, "bye").status`, "3\n"}, + {`p SystemExit.new(3, "bye").message`, "\"bye\"\n"}, + // String-only argument is the message, status defaults to 0. + {`p SystemExit.new("oops").message`, "\"oops\"\n"}, + {`p SystemExit.new("oops").status`, "0\n"}, + // The other new classes sit under Exception, not StandardError. + {`p NoMemoryError.ancestors.include?(Exception)`, "true\n"}, + {`p SecurityError.ancestors.include?(Exception)`, "true\n"}, + {`p Interrupt.ancestors.include?(SignalException)`, "true\n"}, + {`p SignalException.ancestors.include?(Exception)`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestSingletonModule covers the Singleton prelude module: require, the memoized +// .instance, and the privatized .new/.allocate. +func TestSingletonModule(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "singleton"`, "true\n"}, + {`require "singleton"; class C; include Singleton; def initialize; @n=7; end; def n; @n; end; end; p C.instance.n`, "7\n"}, + {`require "singleton"; class C2; include Singleton; end; p C2.instance.equal?(C2.instance)`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } + // .new is private after include Singleton. + if class, _ := evalErr(t, `require "singleton"; class C3; include Singleton; end; C3.new`); class != "NoMethodError" { + t.Errorf("C3.new raised %q, want NoMethodError", class) + } + if class, _ := evalErr(t, `require "singleton"; class C4; include Singleton; end; C4.allocate`); class != "NoMethodError" { + t.Errorf("C4.allocate raised %q, want NoMethodError", class) + } +} + +// TestSingletonClassInclude covers the VM fix that makes a module included into +// `class << self` (or via extend) supply class methods, including inheritance by +// a subclass — the mechanism Puppet's InstanceLoader uses. +func TestSingletonClassInclude(t *testing.T) { + cases := []struct{ src, want string }{ + // include into class << self -> class method. + {`module M; def hi(x); "hi #{x}"; end; end +class A; class << self; include M; end; end +p A.hi("there")`, "\"hi there\"\n"}, + // subclass inherits it. + {`module M; def hi(x); "hi #{x}"; end; end +class A; class << self; include M; end; end +class B < A; end +p B.hi("sub")`, "\"hi sub\"\n"}, + // extend gives the same result. + {`module M2; def yo; 42; end; end +class C; extend M2; end +p C.yo`, "42\n"}, + // prepend into the singleton class wins over an own class method. + {`module P; def who; "module"; end; end +class D + class << self + prepend P + def who; "class"; end + end +end +p D.who`, "\"module\"\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestKernelLoad covers Kernel#load (and Kernel.load): re-executing a file every +// call, returning true, resolving via the script directory, and raising +// LoadError for a missing file. +func TestKernelLoad(t *testing.T) { + dir := t.TempDir() + write(t, dir, "side.rb", "$counter ||= 0\n$counter += 1\n") + + // load runs the file each time and returns true; the side effect accumulates. + out, err := runInDir(t, dir, `p load("side.rb") +p load("side.rb") +p $counter`) + if err != nil { + t.Fatalf("load: %v", err) + } + if out != "true\ntrue\n2\n" { + t.Errorf("load got %q", out) + } + + // Kernel.load works as a module method too. + out, err = runInDir(t, dir, `Kernel.load("side.rb"); p $counter`) + if err != nil { + t.Fatalf("Kernel.load: %v", err) + } + if out != "1\n" { + t.Errorf("Kernel.load got %q", out) + } + + // An absolute path loads directly. + abs := filepath.Join(dir, "side.rb") + out, err = runInDir(t, dir, `p load(`+strconv.Quote(abs)+`)`) + if err != nil { + t.Fatalf("abs load: %v", err) + } + if out != "true\n" { + t.Errorf("abs load got %q", out) + } + + // A missing file raises LoadError. + if class, _ := evalErr(t, `load("definitely-not-here.rb")`); class != "LoadError" { + t.Errorf("missing load raised %q, want LoadError", class) + } + + // A syntax error in the loaded file raises SyntaxError. + write(t, dir, "bad.rb", "def (") + _, err = runInDir(t, dir, `load("bad.rb")`) + if err == nil || !strings.Contains(err.Error(), "SyntaxError") { + t.Errorf("bad load err=%v, want SyntaxError", err) + } +} + +// TestLoadedFeatures covers the $LOADED_FEATURES / $" mutable Array. +func TestLoadedFeatures(t *testing.T) { + cases := []struct{ src, want string }{ + {`p $LOADED_FEATURES.class`, "Array\n"}, + {`$LOADED_FEATURES << "x"; p $LOADED_FEATURES.include?("x")`, "true\n"}, + // $" is the same object. + {`p $".equal?($LOADED_FEATURES)`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestFileMtime covers File.mtime returning a Time, and the Errno::ENOENT for a +// missing file. +func TestFileMtime(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + if err := os.WriteFile(p, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + out, rerr := runInDir(t, dir, `p File.mtime(`+strconv.Quote(p)+`).class`) + if rerr != nil { + t.Fatalf("mtime: %v", rerr) + } + if out != "Time\n" { + t.Errorf("File.mtime class got %q", out) + } + if class, _ := evalErr(t, `File.mtime("/no/such/file/here")`); class != "Errno::ENOENT" { + t.Errorf("missing mtime raised %q, want Errno::ENOENT", class) + } +} + +// TestFilePathnameCoercion covers File path-argument coercion via #to_path / +// #to_str (Pathname), across the path-manipulation and filesystem-test methods, +// plus the TypeError for a non-coercible argument. +func TestFilePathnameCoercion(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "g.txt") + if err := os.WriteFile(p, []byte("data!"), 0o644); err != nil { + t.Fatal(err) + } + cases := []struct{ src, want string }{ + // Pathname (responds to to_path) is accepted by File.exist?. + {`require "pathname"; p File.exist?(Pathname.new(` + strconv.Quote(p) + `))`, "true\n"}, + {`require "pathname"; p File.file?(Pathname.new(` + strconv.Quote(p) + `))`, "true\n"}, + {`require "pathname"; p File.directory?(Pathname.new(` + strconv.Quote(dir) + `))`, "true\n"}, + {`require "pathname"; p File.size(Pathname.new(` + strconv.Quote(p) + `))`, "5\n"}, + {`require "pathname"; p File.read(Pathname.new(` + strconv.Quote(p) + `))`, "\"data!\"\n"}, + {`require "pathname"; p File.basename(Pathname.new("/a/b/c.rb"))`, "\"c.rb\"\n"}, + {`require "pathname"; p File.dirname(Pathname.new("/a/b/c.rb"))`, "\"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/a/b\"\n"}, + {`require "pathname"; p File.extname(Pathname.new("/a/b/c.rb"))`, "\".rb\"\n"}, + {`require "pathname"; p File.split(Pathname.new("/a/b/c.rb"))`, "[\"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/a/b\", \"c.rb\"]\n"}, + {`require "pathname"; p File.join(Pathname.new("a"), Pathname.new("b"))`, "\"a/b\"\n"}, + {`require "pathname"; p File.expand_path(Pathname.new("/a/./b"))`, "\"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/a/b\"\n"}, + {`require "pathname"; p File.absolute_path?(Pathname.new("/a"))`, "true\n"}, + {`require "pathname"; p File.absolute_path(Pathname.new("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/a/b"))`, "\"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/a/b\"\n"}, + } + for _, c := range cases { + out, rerr := runInDir(t, dir, c.src) + if rerr != nil { + t.Fatalf("src=%q err=%v", c.src, rerr) + } + if out != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, out, c.want) + } + } + // An object that responds to to_str (but not to_path) is coerced via the + // to_str fallback arm. + if got := eval(t, `class OnlyStr; def to_str; "/x"; end; end; p File.basename(OnlyStr.new)`); got != "\"x\"\n" { + t.Errorf("to_str coercion got %q", got) + } + // A non-coercible argument raises TypeError (the no-to_path/to_str arm). + if class, _ := evalErr(t, `File.exist?(123)`); class != "TypeError" { + t.Errorf("File.exist?(Integer) raised %q, want TypeError", class) + } + // A to_path that returns a non-String raises TypeError (the !ok arm). + if class, _ := evalErr(t, `class BadPath; def to_path; 42; end; end; File.exist?(BadPath.new)`); class != "TypeError" { + t.Errorf("File.exist?(bad to_path) raised %q, want TypeError", class) + } + // write/delete also coerce. + out, rerr := runInDir(t, dir, `require "pathname" +pn = Pathname.new(`+strconv.Quote(filepath.Join(dir, "w.txt"))+`) +File.write(pn, "abc") +v = File.read(pn) +File.delete(pn) +p [v, File.exist?(pn)]`) + if rerr != nil { + t.Fatalf("write/delete: %v", rerr) + } + if out != "[\"abc\", false]\n" { + t.Errorf("write/delete got %q", out) + } +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 88232808..b01e4796 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -366,6 +366,12 @@ func New(out io.Writer) *VM { loadPath := &object.Array{} vm.globals["$LOAD_PATH"] = loadPath vm.globals["$:"] = loadPath + // $LOADED_FEATURES (alias $") is a real, mutable Array of the files already + // loaded — code that consults or appends to it (Puppet's autoloader does + // `$LOADED_FEATURES << file`) works. + loadedFeatures := &object.Array{} + vm.globals["$LOADED_FEATURES"] = loadedFeatures + vm.globals[`$"`] = loadedFeatures vm.installPrelude() vm.registerEnumerator() // after the prelude so it can mix in Enumerable vm.registerLazy() // after Enumerator (Enumerator::Lazy is built on it)