diff --git a/README.md b/README.md index 1faab166..5fd75492 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ no libruby. examples ([what that counts](#runtime-conformance--rubyspec)) — a large and growing subset of the language, and **not** a drop-in replacement for CRuby. Read [What does not work yet](#what-does-not-work-yet) before you depend on it; the -embedding API is currently **one function**. +embedding API is small: `Run` for a one-shot program, and an `Interp` that keeps +its state between evaluations, exchanges values with Go and lets Go define Ruby +modules, classes and methods ([Embedding](#embedding-in-a-go-program)). This repository is the interpreter: a compiler that lowers Ruby to bytecode, and a stack VM (mruby/YARV lineage) that runs it. The Ruby **front-end** (lexer, @@ -842,17 +844,109 @@ $ ./fib ## Embedding in a Go program -The public API is deliberately tiny — **one function**: +The public API has two halves: `Run`, which runs one program on a fresh VM, and +`Interp`, an interpreter that stays alive between evaluations. ```go package ruby // github.com/go-embedded-ruby/ruby func Run(src string, out io.Writer) error + +func New(opts ...Option) *Interp // WithStdout(w), WithStderr(w) +func (in *Interp) Eval(src, file string, line int) (Value, error) +func (in *Interp) Set(name string, v any) error +func (in *Interp) Get(name string) (any, error) +func (in *Interp) DefineModule(name string) (*Module, error) +func (in *Interp) DefineClass(name string, super *Module) (*Module, error) +func (m *Module) DefineMethod(name string, fn Func) +func (m *Module) DefineSingletonMethod(name string, fn Func) +type Func func(c *Call) (any, error) // c.Self, c.Args, c.Kwargs, c.Block +``` + +Everything else in this repository is under `internal/`, so this is the whole +embedding surface. The reference is the package documentation (`go doc +github.com/go-embedded-ruby/ruby`); what follows is what it is for. + +### `Interp`: one program, evaluated piece by piece + +`Eval` runs source as top-level code and returns the value of its last +expression. The **top-level local variables persist** from one `Eval` to the +next, as one binding — so do methods, classes, constants and globals, which live +in the VM anyway: + +```go +in := ruby.New(ruby.WithStdout(&out)) +in.Eval("x = 40", "cell1.rb", 1) +v, _ := in.Eval("x + 2", "cell2.rb", 1) +v.Inspect() // "42" +``` + +`file` and `line` are what `__FILE__`, `__LINE__`, backtraces and errors report. +A Ruby exception — a `SyntaxError` included — comes back as an `*ruby.Error` +with its `Class`, `Message`, `Backtrace`, and the `File`/`Line` of the innermost +frame in the evaluated file, so an exception raised deep in a library is placed +at the line of your source that led to it. `exit` is an `*Error` too +(`ExitStatus()` reads its status), and the interpreter stays usable after any of +them. `$stdout` and `$stderr` are separate streams, and `SetStdout`/`SetStderr` +redirect them between evaluations. An `Interp` is not safe for concurrent use. + +### Values + +`Set` assigns a top-level local and `Get` reads one, converting: + +| Go | Ruby | back to Go | +| --- | --- | --- | +| `nil`, `bool` | `nil`, `true`/`false` | the same | +| `int`…`int64`, `uint`…`uint64` | `Integer` | `int64` | +| `*big.Int` | `Integer` | `int64`, or `*big.Int` when it does not fit | +| `float32`, `float64` | `Float` | `float64` | +| `complex64`, `complex128` | `Complex` | `complex128` | +| `*big.Rat` | `Rational` | `*big.Rat` | +| `string`, `[]byte` | `String` | `string` | +| `ruby.Symbol` | `Symbol` | `ruby.Symbol` | +| `*ndarray.Array` | `NDArray` — **the same array, no copy** | the same `*ndarray.Array` | +| slices, arrays | `Array` | `[]any` | +| maps | `Hash` | `map[any]any` | +| any other Go value | an opaque object (`GoValue`, or the class `Bind` names) | the very same Go value | +| `ruby.Value` | itself | — | + +A Ruby object with no Go counterpart comes back as a `ruby.Value`, which can be +inspected, called (`v.Call("method", args...)`) and passed back in. + +### Defining Ruby from Go + +```go +stats, _ := in.DefineModule("Stats") +normal, _ := stats.DefineClass("Normal", nil) +normal.Bind(&Normal{}) // a *Normal crossing into Ruby is a Stats::Normal +normal.DefineSingletonMethod("new", func(c *ruby.Call) (any, error) { + loc := c.Kwargs["loc"].Go() // keyword arguments by name + ... + return &Normal{...}, nil +}) +normal.DefineMethod("pdf", func(c *ruby.Call) (any, error) { + n := c.Self.Go().(*Normal) // the Go value back + ... +}) +stats.DefineSingletonMethod("minimize", func(c *ruby.Call) (any, error) { + y, err := c.Block.Call(x) // call the block from Go + if err != nil { + return nil, err // re-raises the block's exception + } + ... +}) ``` +A `Func` returns its result (converted as above) or an error, which raises: +`ruby.NewError("ArgumentError", "...")` raises that class, an error that came out +of Ruby raises the same exception again, anything else is a `RuntimeError`. A +`break`, `throw` or `return` in a block unwinds through the Go code that called +it to where Ruby expects it. + +### `Run` + `Run` parses, compiles and executes `src` on a **fresh VM**, writing the program's -output to `out`. Everything else in this repository is under `internal/`, so this -is the whole embedding surface today. +output to `out`. ```console $ go get github.com/go-embedded-ruby/ruby@v0.1.0 @@ -902,14 +996,15 @@ The contract, as measured rather than intended: whose cwd was its build directory came back with a 13-byte `PATH` and a cwd of `/private/tmp`. Because `require_relative` resolves against that same working directory (next bullet), one `Run` can also write a file that a later `Run` - loads. There is no exported way to hold a VM open, pre-load code into it, call - a Ruby method from Go, or pass Go values in. + loads. (Holding a VM open, pre-loading code, calling Ruby from Go and passing + Go values in is what `Interp` is for.) - **A program that exits is reported as success.** `exit`, `exit!` and `abort` stop the program and `Run` returns **`nil`**, so an embedder cannot tell “finished” from “stopped”: `abort "fatal"` writes `fatal` to `out` and reports no error, and `exit 3` loses the 3. Only a Ruby *exception* becomes a Go error. The `rbgo` CLI recovers the status through `internal/vm`, which is unreachable - from outside, so today there is no way for an embedder to obtain it. + from outside, so `Run` cannot report it; `Interp.Eval` returns it as an + `*Error` whose `ExitStatus()` holds it. - **`require_relative` resolves against the process working directory**, not against a script, because `Run` has no file to anchor to. From a different working directory the same program fails with diff --git a/define.go b/define.go new file mode 100644 index 00000000..a2b3df2d --- /dev/null +++ b/define.go @@ -0,0 +1,214 @@ +package ruby + +import ( + "fmt" + "reflect" + "strings" + + "github.com/go-embedded-ruby/ruby/internal/object" + "github.com/go-embedded-ruby/ruby/internal/vm" +) + +// Func is a method written in Go. It returns the method's result, converted to +// Ruby as Value describes, or an error, which raises: an *Error raises its +// class (NewError("ArgumentError", "...")), an *Error that came from Ruby — +// out of Block.Call, say — raises that same exception again, and any other +// error raises a RuntimeError with its message. +type Func func(c *Call) (any, error) + +// Call is one call of a Func. +type Call struct { + Interp *Interp + // Self is the receiver: the module or class for a singleton method, the + // instance for an instance method. + Self Value + // Args are the positional arguments. + Args []Value + // Kwargs are the keyword arguments, by name; nil when there are none. + // Ruby passes keywords to a method written in Go as a trailing Hash: a + // final argument that is a non-empty Hash whose keys are all Symbols is + // read as the keywords, unless the call site said it passed none. + Kwargs map[string]Value + // Block is the block the method was called with, or nil. + Block *Block +} + +// Block is a block passed to a Func. +type Block struct { + in *Interp + p *vm.Proc +} + +// Call calls the block with args converted to Ruby, and returns its value. A +// Ruby exception raised in the block comes back as an *Error; returning that +// error from the Func re-raises the same exception. A break, a throw or a +// non-local return in the block unwinds through the Go code that called it, so +// run nothing after Call that must not be skipped other than with defer. +func (b *Block) Call(args ...any) (Value, error) { + rargs, err := b.in.toRubyAll(args) + if err != nil { + return Value{}, err + } + r, rerr := b.in.vm.Protect(func() object.Value { return b.in.vm.CallBlock(b.p, rargs) }) + if rerr != nil { + return Value{}, b.in.newError(rerr, "") + } + return b.in.wrap(r), nil +} + +// Arity is the block's Proc#arity. +func (b *Block) Arity() int { return b.in.vm.ProcArity(b.p) } + +// Module is a Ruby module or class defined, or found, by the host. +type Module struct { + in *Interp + c *vm.RClass +} + +// Name is the module's qualified name, e.g. "Stats::Normal". +func (m *Module) Name() string { return m.c.Name() } + +// Value is the module itself as a Ruby value. +func (m *Module) Value() Value { return m.in.wrap(m.c) } + +// DefineModule returns the top-level module name, creating it if needed. name +// may be qualified ("Stats::Tests"); every part but the last must exist or is +// created as a module. +func (in *Interp) DefineModule(name string) (*Module, error) { + return in.define(nil, name, nil, true) +} + +// DefineClass returns the top-level class name, creating it as a subclass of +// super (nil: Object) if needed. name may be qualified as for DefineModule. +func (in *Interp) DefineClass(name string, super *Module) (*Module, error) { + return in.define(nil, name, super, false) +} + +// DefineModule returns the module name inside m, creating it if needed. +func (m *Module) DefineModule(name string) (*Module, error) { + return m.in.define(m.c, name, nil, true) +} + +// DefineClass returns the class name inside m, creating it as a subclass of +// super (nil: Object) if needed. +func (m *Module) DefineClass(name string, super *Module) (*Module, error) { + return m.in.define(m.c, name, super, false) +} + +func (in *Interp) define(parent *vm.RClass, name string, super *Module, module bool) (*Module, error) { + parts := strings.Split(name, "::") + for i, p := range parts { + if !isConstName(p) { + return nil, fmt.Errorf("ruby: %q is not a constant name", name) + } + last := i == len(parts)-1 + var ( + c *vm.RClass + ok bool + ) + switch { + case !last || module: + c, ok = in.vm.DefineModule(parent, p) + default: + var sc *vm.RClass + if super != nil { + sc = super.c + } + c, ok = in.vm.DefineClass(parent, p, sc) + } + if !ok { + what := "a module" + if last && !module { + what = "a class" + } + return nil, fmt.Errorf("ruby: %s is already defined and is not %s", strings.Join(parts[:i+1], "::"), what) + } + parent = c + } + return &Module{in: in, c: parent}, nil +} + +func isConstName(s string) bool { + if s == "" || s[0] < 'A' || s[0] > 'Z' { + return false + } + for _, r := range s { + if r != '_' && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') { + return false + } + } + return true +} + +// DefineMethod defines the instance method name, written in Go. +func (m *Module) DefineMethod(name string, fn Func) { m.c.DefineNative(name, m.in.native(fn)) } + +// DefineSingletonMethod defines the singleton method name — Stats.mean, or a +// class method such as Normal.new — written in Go. +func (m *Module) DefineSingletonMethod(name string, fn Func) { + m.c.DefineSingletonNative(name, m.in.native(fn)) +} + +// SetConst defines the constant name inside m, converted to Ruby. +func (m *Module) SetConst(name string, v any) error { + if !isConstName(name) { + return fmt.Errorf("ruby: %q is not a constant name", name) + } + rv, err := m.in.toRuby(v) + if err != nil { + return err + } + m.in.vm.SetConstIn(m.c, name, rv) + return nil +} + +// Bind makes every Go value of sample's dynamic type an instance of m when it +// crosses into Ruby, so the methods defined on m apply to it, and Value.Go +// gives the Go value back. Bind(&Normal{}) binds *Normal. +func (m *Module) Bind(sample any) { m.in.types[reflect.TypeOf(sample)] = m.c } + +// Wrap makes v an instance of m, whatever its type. +func (m *Module) Wrap(v any) Value { return m.in.wrap(m.in.vm.WrapGo(v, m.c)) } + +// native adapts a Func to the VM's calling convention. +func (in *Interp) native(fn Func) vm.NativeFn { + return func(v *vm.VM, self object.Value, args []object.Value, blk *vm.Proc) object.Value { + noKW := v.NativeNoKW() + c := &Call{Interp: in, Self: in.wrap(self)} + if n := len(args); n > 0 && !noKW { + if h, ok := args[n-1].(*object.Hash); ok && h.Len() > 0 && symbolKeys(h) { + c.Kwargs = make(map[string]Value, h.Len()) + for _, k := range h.Keys { + e, _ := h.Get(k) + c.Kwargs[string(k.(object.Symbol))] = in.wrap(e) + } + args = args[:n-1] + } + } + c.Args = make([]Value, len(args)) + for i, a := range args { + c.Args[i] = in.wrap(a) + } + if blk != nil { + c.Block = &Block{in: in, p: blk} + } + res, err := fn(c) + if err != nil { + in.raise(err) + } + r, err := in.toRuby(res) + if err != nil { + vm.Raise("TypeError", err.Error()) + } + return r + } +} + +func symbolKeys(h *object.Hash) bool { + for _, k := range h.Keys { + if _, ok := k.(object.Symbol); !ok { + return false + } + } + return true +} diff --git a/example_interp_test.go b/example_interp_test.go new file mode 100644 index 00000000..5801136b --- /dev/null +++ b/example_interp_test.go @@ -0,0 +1,40 @@ +package ruby_test + +import ( + "fmt" + "os" + + ruby "github.com/go-embedded-ruby/ruby" +) + +// An interpreter keeps its top-level locals between evaluations, takes Go +// values in, and calls Go methods that call Ruby blocks back. +func ExampleInterp() { + in := ruby.New(ruby.WithStdout(os.Stdout)) + in.Set("xs", []float64{1, 2, 3}) + + m, _ := in.DefineModule("Calc") + m.DefineSingletonMethod("sum_by", func(c *ruby.Call) (any, error) { + total := 0.0 + for _, x := range c.Args[0].Go().([]any) { + y, err := c.Block.Call(x) + if err != nil { + return nil, err + } + total += y.Go().(float64) + } + return total, nil + }) + + in.Eval("squares = Calc.sum_by(xs) { |x| x * x }", "example.rb", 1) + v, _ := in.Eval(`puts "sum of squares: #{squares}"; squares / 2`, "example.rb", 2) + got, _ := in.Get("squares") + fmt.Println(v.Inspect(), got) + + _, err := in.Eval("Calc.nope", "example.rb", 3) + fmt.Println(err) + // Output: + // sum of squares: 14.0 + // 7.0 14 + // example.rb:3: undefined method 'nope' for module Calc (NoMethodError) +} diff --git a/internal/vm/arith.go b/internal/vm/arith.go index ec4bfd9a..bfabb142 100644 --- a/internal/vm/arith.go +++ b/internal/vm/arith.go @@ -254,6 +254,9 @@ func (vm *VM) hasCustomNeq(a object.Value) bool { // is the BASIC_OP_UNREDEFINED_P guard rbgo was missing -- see // basic_op_redefinition.go for what that cost. func (vm *VM) binaryOp(op bytecode.Op, a, b object.Value) object.Value { + if g, ok := a.(*GoObject); ok { + return vm.goObjectOp(op, g, b) + } if m := vm.overriddenBasicOp(op, a); m != nil { if m == basicOpRemoved { // The method was removed; raise the NoMethodError a send would. diff --git a/internal/vm/embed.go b/internal/vm/embed.go new file mode 100644 index 00000000..cfe03b6f --- /dev/null +++ b/internal/vm/embed.go @@ -0,0 +1,312 @@ +package vm + +import ( + "fmt" + + nd "github.com/go-ndarray/ndarray" + + "github.com/go-embedded-ruby/ruby/internal/bytecode" + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the seam the public embedding API (package ruby, embed.go at the +// repository root) reaches the VM through. Nothing here is reachable from Ruby, +// and nothing here runs unless a host builds an Interp: a VM made by New, and +// every program run by ruby.Run or the rbgo CLI, takes none of these paths. + +// TopBinding returns a fresh binding of the top level — self is main, the +// definee is Object, and it has no locals yet. Evaluating through it with +// EvalIn keeps the locals each evaluation assigns, which is what makes a series +// of evaluations behave as one program. +func (vm *VM) TopBinding() *Binding { + return vm.newToplevelBinding() +} + +// EvalIn compiles src against b's locals and runs it, stamping file and line on +// the compiled code so backtraces, __FILE__ and __LINE__ report them. It returns +// the value of the last expression, or the error: a RubyError for a Ruby +// exception (a syntax error is a SyntaxError), with its backtrace. A SystemExit +// is returned like any other exception; it does not run the at_exit handlers, +// which RunAtExit does when the host is done. +// +// It leaves the VM clean after an exception, exactly as Run does, so the next +// evaluation starts with an empty frame stack. +func (vm *VM) EvalIn(b *Binding, src, file string, line int) (object.Value, error) { + if err := parseFault(src); err != nil { + // The front end failed on the source instead of reporting a syntax + // error: a defect of the parser, which must not take the host with it + // (Kernel#eval is protected the same way, by callNative). + return nil, RubyError{Class: "SyntaxError", Message: fmt.Sprintf("%s:%d: the Ruby parser failed on this source: %v", file, line, err)} + } + return vm.Protect(func() object.Value { + return vm.bindingEval(b, src, evalLocation{file: file, line: line}) + }) +} + +// Protect runs fn and turns a Ruby exception escaping it into a RubyError with +// its backtrace. Every other panic is re-raised after the frame stacks are +// reset: a break, a throw or a non-local return unwinding to a frame outside fn +// must reach it (a throw with no catch never gets here: Kernel#throw raises +// UncaughtThrowError itself), and a VM invariant or a host fault stays loud, as +// Run keeps it. +func (vm *VM) Protect(fn func() object.Value) (res object.Value, err error) { + depth := len(vm.frameNames) + defer func() { + r := recover() + if r == nil { + return + } + rerr, ok := r.(RubyError) + if ok { + rerr.Frames = vm.uncaughtBacktrace(rerr) + } + vm.unwindFramesTo(depth) + if !ok { + panic(r) + } + res, err = nil, rerr + }() + return fn(), nil +} + +// unwindFramesTo truncates the frame-tracking stacks to depth: a panic unwinds +// exec frames without running the tail that pops them. +func (vm *VM) unwindFramesTo(depth int) { + vm.frameNames = vm.frameNames[:min(depth, len(vm.frameNames))] + vm.frameFiles = vm.frameFiles[:min(depth, len(vm.frameFiles))] + vm.frameCrefs = vm.frameCrefs[:min(depth, len(vm.frameCrefs))] + vm.frameMethods = vm.frameMethods[:min(depth, len(vm.frameMethods))] + if depth == 0 { + vm.fileStack = vm.fileStack[:0] + } +} + +// RunAtExit runs the at_exit handlers a program registered, last registered +// first, once. +func (vm *VM) RunAtExit() { vm.runAtExit() } + +// ExitStatus reports the status a SystemExit carries, and whether rerr is one. +func (vm *VM) ExitStatus(rerr RubyError) (int, bool) { + if !vm.classIsA(rerr.Class, "SystemExit") { + return 0, false + } + return int(vm.exitStatusOf(rerr.Obj)), true +} + +// LocalGet reads a local of the binding. +func (b *Binding) LocalGet(name string) (object.Value, bool) { + i := b.slotOf(name) + if i < 0 { + return nil, false + } + env, slot := b.at(i) + return env.slots[slot], true +} + +// LocalSet assigns a local of the binding, creating it when it does not exist. +func (b *Binding) LocalSet(name string, v object.Value) { + if i := b.slotOf(name); i >= 0 { + env, slot := b.at(i) + env.slots[slot] = v + return + } + b.addLocal(name, v) +} + +// LocalNames lists the binding's named locals in creation order. +func (b *Binding) LocalNames() []string { + out := make([]string, 0, len(b.names)) + for _, n := range b.names { + if n != "" { + out = append(out, n) + } + } + return out +} + +// ClassOf returns the class v is an instance of. +func (vm *VM) ClassOf(v object.Value) *RClass { return vm.classOf(v) } + +// ConstIn reads a constant defined directly in scope (nil: the top level). +func (vm *VM) ConstIn(scope *RClass, name string) (object.Value, bool) { + if scope == nil || scope == vm.cObject { + v, ok := vm.consts[name] + return v, ok + } + v, ok := scope.consts[name] + return v, ok +} + +// DefineModule returns the module named name inside parent (nil: the top +// level), creating it when it does not exist. It reports false when the +// constant exists and is not a module. +func (vm *VM) DefineModule(parent *RClass, name string) (*RClass, bool) { + if v, ok := vm.ConstIn(parent, name); ok { + c, isClass := v.(*RClass) + return c, isClass && c.isModule + } + m := newClass(vm.qualified(parent, name), nil) + m.isModule = true + vm.installConst(parent, name, m) + return m, true +} + +// DefineClass returns the class named name inside parent (nil: the top level), +// creating it as a subclass of super (nil: Object) when it does not exist. It +// reports false when the constant exists and is not a class. +func (vm *VM) DefineClass(parent *RClass, name string, super *RClass) (*RClass, bool) { + if v, ok := vm.ConstIn(parent, name); ok { + c, isClass := v.(*RClass) + return c, isClass && !c.isModule + } + if super == nil { + super = vm.cObject + } + c := newClass(vm.qualified(parent, name), super) + vm.installConst(parent, name, c) + return c, true +} + +func (vm *VM) qualified(parent *RClass, name string) string { + if parent == nil || parent == vm.cObject { + return name + } + return parent.name + "::" + name +} + +func (vm *VM) installConst(parent *RClass, name string, c *RClass) { + if parent == nil || parent == vm.cObject { + vm.consts[name] = c + return + } + c.lexParent = parent + parent.consts[name] = c +} + +// Name is the class's qualified name ("" for an anonymous one). +func (c *RClass) Name() string { return c.name } + +// DefineNative installs a native instance method. +func (c *RClass) DefineNative(name string, fn NativeFn) { c.define(name, fn) } + +// DefineSingletonNative installs a native singleton (class or module) method. +func (c *RClass) DefineSingletonNative(name string, fn NativeFn) { + c.smethods[name] = &Method{name: name, native: fn, owner: c} + bumpMethodSerial() +} + +// NativeNoKW reports the keyword verdict of the call site that reached the +// running native method: true when the call passed no keyword arguments, so a +// trailing Hash is positional. False means "the site may have passed keywords", +// and the last-hash convention every native follows applies. +func (vm *VM) NativeNoKW() bool { return vm.nativeNoKW } + +// Send calls the method name on recv. A Ruby exception propagates as a panic; +// run it under Protect. +func (vm *VM) Send(recv object.Value, name string, args []object.Value, blk *Proc) object.Value { + return vm.send(recv, name, args, blk) +} + +// CallBlock calls a block with args. A Ruby exception propagates as a panic, and +// so do break, next-less non-local exits and throw, which must unwind past the +// Go code that called the block to the frame that owns them. +func (vm *VM) CallBlock(p *Proc, args []object.Value) object.Value { return vm.callBlock(p, args) } + +// ProcArity is Proc#arity. +func (vm *VM) ProcArity(p *Proc) int { + n, _ := vm.send(p, "arity", nil, nil).(object.Integer) + return int(n) +} + +// InspectValue is Kernel#inspect as p prints it. +func (vm *VM) InspectValue(v object.Value) string { + if g, ok := v.(*GoObject); ok { + return g.Inspect() + } + return vm.inspectStr(v) +} + +// ToSValue is #to_s as puts prints it. +func (vm *VM) ToSValue(v object.Value) string { + switch v.(type) { + case *RObject, *GoObject: + if s, ok := vm.send(v, "to_s", nil, nil).(*object.String); ok { + return s.Str() + } + } + return v.ToS() +} + +// Raise raises a Ruby exception of the named class. +func Raise(class, msg string) { panic(RubyError{Class: class, Message: msg}) } + +// Reraise raises err again, unchanged: the exception object a rescue sees is +// the one that was raised. +func Reraise(err RubyError) { panic(err) } + +// WrapNDArray is the Ruby NDArray for a: the same array, not a copy. +func WrapNDArray(a *nd.Array) object.Value { return &NDArray{a: a} } + +// UnwrapNDArray is the go-ndarray array behind an NDArray value. +func UnwrapNDArray(v object.Value) (*nd.Array, bool) { + n, ok := v.(*NDArray) + if !ok { + return nil, false + } + return n.a, true +} + +// GoObject is a Go value carried through Ruby unchanged: an instance of a class +// the host chose, whose methods are the host's native methods. Ruby sees an +// ordinary object; Go gets back the very value it put in. +type GoObject struct { + vm *VM + cls *RClass + val any +} + +// WrapGo makes v an instance of cls. +func (vm *VM) WrapGo(v any, cls *RClass) *GoObject { return &GoObject{vm: vm, cls: cls, val: v} } + +// Value is the wrapped Go value. +func (g *GoObject) Value() any { return g.val } + +// Inspect calls the class's own #inspect when the host defined one, and +// otherwise reads "#". +func (g *GoObject) Inspect() string { return g.render("inspect") } + +// ToS calls the class's own #to_s when the host defined one, and otherwise +// reads "#". +func (g *GoObject) ToS() string { return g.render("to_s") } + +func (g *GoObject) Truthy() bool { return true } + +func (g *GoObject) render(name string) string { + if m := lookupMethod(g.cls, name); m != nil && !m.undefined && !g.vm.isKernelOwned(m) { + if s, ok := g.vm.invoke(m, g, nil, nil).(*object.String); ok { + return s.Str() + } + } + return fmt.Sprintf("#<%s>", g.cls.name) +} + +func (vm *VM) isKernelOwned(m *Method) bool { + return m.owner == vm.cObject || m.owner == vm.cKernel || m.owner == vm.cBasicObject +} + +// SetConstIn defines the constant name in a module or class the host defined. +func (vm *VM) SetConstIn(scope *RClass, name string, v object.Value) { scope.consts[name] = v } + +// goObjectOp applies a binary operator to a host object: it is a call of the +// operator method its class defines (+, -, <, ==, …), whose result is the +// operator's — so an == that answers a mask Array, as a data frame's does, +// keeps it. != is !(self == other) unless the class defines != itself. +func (vm *VM) goObjectOp(op bytecode.Op, g *GoObject, b object.Value) object.Value { + name := basicOpName(op) + if op == bytecode.OpNeq { + if m := lookupMethod(g.cls, "!="); m == nil || vm.isKernelOwned(m) { + return object.Bool(!vm.send(g, "==", []object.Value{b}, nil).Truthy()) + } + } + return vm.send(g, name, []object.Value{b}, nil) +} diff --git a/internal/vm/embed_parse_closed.go b/internal/vm/embed_parse_closed.go new file mode 100644 index 00000000..09ba5b7f --- /dev/null +++ b/internal/vm/embed_parse_closed.go @@ -0,0 +1,7 @@ +//go:build rbgo_closed + +package vm + +// parseFault has nothing to guard in a closed-world binary: there is no +// parser, and an evaluation raises NotImplementedError. +func parseFault(string) error { return nil } diff --git a/internal/vm/embed_parse_open.go b/internal/vm/embed_parse_open.go new file mode 100644 index 00000000..7abc2ee7 --- /dev/null +++ b/internal/vm/embed_parse_open.go @@ -0,0 +1,22 @@ +//go:build !rbgo_closed + +package vm + +import ( + "fmt" + + "github.com/go-ruby-parser/parser" +) + +// parseFault parses src and reports a panic of the parser — an +// index out of range on a malformed string, say — as an error. A syntax error +// is not a fault: the evaluation reports it. +func parseFault(src string) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%v", r) + } + }() + _, _ = parser.Parse(src) + return nil +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 18ccbb92..b49cb662 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -1217,6 +1217,8 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.cContDist case *RDiscDist: return vm.cDiscDist + case *GoObject: + return x.cls case *WeakMapObj: return x.cls case *WeakKeyMapObj: @@ -1587,7 +1589,9 @@ func (vm *VM) callNative(m *Method, self object.Value, args []object.Value, blk // set for THIS call would still be standing when the native yields to a block // or dispatches a method of its own, and would be read by the wrong frame. // Together with exec's consume-and-clear this bounds the field's lifetime to - // exactly one dispatch. See VM.sendNoKW. + // exactly one dispatch. See VM.sendNoKW. Its value is kept for the one + // reader that wants it, a host-defined method (VM.NativeNoKW, embed.go). + vm.nativeNoKW = vm.sendNoKW vm.sendNoKW = false // The same bound for the VCALL verdict: a native body that dispatches a send // of its own (Object#send, a coercion protocol, an Enumerable yielding into diff --git a/internal/vm/vm.go b/internal/vm/vm.go index ec728774..cf69be33 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -895,6 +895,9 @@ type VM struct { // Hash — which is what every path that cannot answer (Object#send, Proc#call, // a define_method body) still gets. GVL-guarded. sendNoKW bool + // nativeNoKW is sendNoKW as the running native method's call site set it, + // saved by callNative before it clears sendNoKW (see VM.NativeNoKW). + nativeNoKW bool // sendVCall carries bytecode.FlagSendVCall — "this call site was a BARE // identifier, not a call" — to the dispatch that is about to miss. MRI keeps @@ -2520,6 +2523,8 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, negArg := pop() if s, ok := negArg.(*object.String); ok { // -"str" is String#-@ (a frozen copy) push(vm.send(s, "-@", nil, nil)) + } else if g, ok := negArg.(*GoObject); ok { // a host-defined -@ (embed.go) + push(vm.send(g, "-@", nil, nil)) } else { push(negate(negArg)) } diff --git a/interp.go b/interp.go new file mode 100644 index 00000000..587c15c0 --- /dev/null +++ b/interp.go @@ -0,0 +1,274 @@ +package ruby + +import ( + "errors" + "fmt" + "io" + "os" + "reflect" + "strconv" + "strings" + + "github.com/go-embedded-ruby/ruby/internal/object" + "github.com/go-embedded-ruby/ruby/internal/vm" +) + +// Interp is a Ruby interpreter that keeps its state between evaluations: one +// VM, and one top-level binding whose local variables persist from one Eval to +// the next, as if every evaluated source were a further part of one program. +// Methods, classes, constants and globals persist too, because they always live +// in the VM. +// +// An Interp is not safe for concurrent use. A Func that the interpreter calls +// runs on the goroutine that called Eval, and may call back into the same +// Interp (Block.Call, Value.Call, Get, Set); another goroutine may not. +type Interp struct { + vm *vm.VM + top *vm.Binding + stdout *switchWriter + stderr *switchWriter + types map[reflect.Type]*vm.RClass + opaque *vm.RClass // GoValue, made the first time an unbound Go value is wrapped +} + +// Option configures New. +type Option func(*Interp) + +// WithStdout sends what the program writes to $stdout to w. +func WithStdout(w io.Writer) Option { return func(in *Interp) { in.stdout.w = w } } + +// WithStderr sends what the program writes to $stderr, and its warnings, to w. +func WithStderr(w io.Writer) Option { return func(in *Interp) { in.stderr.w = w } } + +// New returns an interpreter writing to os.Stdout and os.Stderr unless an +// option says otherwise. +func New(opts ...Option) *Interp { + in := &Interp{ + stdout: &switchWriter{w: os.Stdout}, + stderr: &switchWriter{w: os.Stderr}, + types: map[reflect.Type]*vm.RClass{}, + } + for _, o := range opts { + o(in) + } + in.vm = vm.NewWithStderr(in.stdout, in.stderr) + in.top = in.vm.TopBinding() + return in +} + +// SetStdout sends what the program writes to $stdout from now on to w. +func (in *Interp) SetStdout(w io.Writer) { in.stdout.w = w } + +// SetStderr sends what the program writes to $stderr from now on to w. +func (in *Interp) SetStderr(w io.Writer) { in.stderr.w = w } + +// switchWriter is the writer the VM holds, so the destination can change after +// the VM was made. +type switchWriter struct{ w io.Writer } + +func (s *switchWriter) Write(p []byte) (int, error) { return s.w.Write(p) } + +// Eval parses, compiles and runs src as top-level code, and returns the value +// of its last expression. file and line are where src comes from: they are +// what __FILE__, __LINE__, backtraces and errors report, so line is the line +// of the source's first line in that file (1 for a whole file). A Ruby +// exception, a syntax error included, comes back as an *Error. +// +// Local variables assigned at the top level of src persist: a later Eval reads +// and assigns the same variables, and Get and Set reach them. +func (in *Interp) Eval(src, file string, line int) (Value, error) { + v, err := in.vm.EvalIn(in.top, src, file, line) + if err != nil { + return Value{}, in.newError(err, file) + } + return in.wrap(v), nil +} + +// Close runs the at_exit handlers the program registered. The Interp must not +// be used afterwards. +func (in *Interp) Close() error { + _, err := in.vm.Protect(func() object.Value { + in.vm.RunAtExit() + return object.NilV + }) + if err != nil { + return in.newError(err, "") + } + return nil +} + +// Set assigns the top-level local variable name, converted to Ruby as Value +// describes. It creates the variable when it does not exist. +func (in *Interp) Set(name string, v any) error { + if !IsLocalName(name) { + return fmt.Errorf("ruby: %q is not a local variable name", name) + } + rv, err := in.toRuby(v) + if err != nil { + return err + } + in.top.LocalSet(name, rv) + return nil +} + +// Get returns the top-level local variable name converted to Go (see +// Value.Go). It is an error if there is no such variable. +func (in *Interp) Get(name string) (any, error) { + v, err := in.Lookup(name) + if err != nil { + return nil, err + } + return v.Go(), nil +} + +// Lookup returns the top-level local variable name as a Ruby value. +func (in *Interp) Lookup(name string) (Value, error) { + v, ok := in.top.LocalGet(name) + if !ok { + return Value{}, fmt.Errorf("ruby: no local variable %q", name) + } + return in.wrap(v), nil +} + +// Locals lists the top-level local variables, in the order they were created. +func (in *Interp) Locals() []string { return in.top.LocalNames() } + +// IsLocalName reports whether s is spelled like a Ruby local variable: a +// lower-case letter or an underscore, then letters, digits and underscores, and +// not a keyword. +func IsLocalName(s string) bool { + if s == "" || rubyKeywords[s] { + return false + } + for i, r := range s { + switch { + case r == '_' || (r >= 'a' && r <= 'z') || r >= 0x80: + case i > 0 && ((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')): + default: + return false + } + } + return true +} + +var rubyKeywords = map[string]bool{} + +func init() { + for _, k := range strings.Fields(`__ENCODING__ __LINE__ __FILE__ BEGIN END alias and begin + break case class def defined? do else elsif end ensure false for if in module next nil + not or redo rescue retry return self super then true undef unless until when while yield`) { + rubyKeywords[k] = true + } +} + +// Error is a Ruby exception that reached the host: its class, its message, the +// file and line it was raised at, and its backtrace, innermost first. +// +// File and Line are those of the innermost frame in the file the Eval was +// given, when there is one: an exception raised inside a method the host +// defined, or deep in a library, is placed at the line of the evaluated source +// that led to it. Otherwise they are the innermost frame's. +type Error struct { + Class string + Message string + File string + Line int + Backtrace []string + + in *Interp + rerr *vm.RubyError // the exception itself, when it came from the VM +} + +// NewError returns an error that, returned by a Func, raises an exception of +// the named class with message msg. class is a class name such as +// "ArgumentError" or "TypeError" ("RuntimeError" when empty). +func NewError(class, msg string) *Error { + if class == "" { + class = "RuntimeError" + } + return &Error{Class: class, Message: msg} +} + +func (e *Error) Error() string { + if e.File != "" && e.Line > 0 { + return fmt.Sprintf("%s:%d: %s (%s)", e.File, e.Line, e.Message, e.Class) + } + return e.Class + ": " + e.Message +} + +// ExitStatus reports whether the exception is a SystemExit (Kernel#exit or +// #abort), and the status it carries. +func (e *Error) ExitStatus() (int, bool) { + if e.rerr == nil || e.in == nil { + return 0, false + } + return e.in.vm.ExitStatus(*e.rerr) +} + +// newError turns what the VM returned into an *Error, placing it in file. +func (in *Interp) newError(err error, file string) error { + var rerr vm.RubyError + if !errors.As(err, &rerr) { + return err // not a Ruby exception: a host writer's error, say + } + e := &Error{Class: rerr.Class, Message: rerr.Message, Backtrace: rerr.Backtrace(), in: in, rerr: &rerr} + e.File, e.Line = locate(e.Backtrace, file) + if rerr.Class == "SyntaxError" { + // "file:line: message": the front end's line, already translated. + if f, l, rest, ok := splitLocation(rerr.Message); ok && (file == "" || f == file) { + e.File, e.Line, e.Message = f, l, rest + } + } + return e +} + +// locate picks the frame an error is reported at: the innermost one in file, +// else the innermost one. +func locate(bt []string, file string) (string, int) { + first, firstLine := "", 0 + for _, fr := range bt { + f, l, _, ok := splitLocation(fr) + if !ok { + continue + } + if file != "" && f == file { + return f, l + } + if first == "" { + first, firstLine = f, l + } + } + return first, firstLine +} + +// splitLocation splits "file:line: rest" or "file:line:in 'm'". +func splitLocation(s string) (file string, line int, rest string, ok bool) { + for i := 0; i < len(s); i++ { + if s[i] != ':' { + continue + } + j := i + 1 + for j < len(s) && s[j] >= '0' && s[j] <= '9' { + j++ + } + if j == i+1 || (j < len(s) && s[j] != ':') { + continue + } + n, _ := strconv.Atoi(s[i+1 : j]) + rest = strings.TrimPrefix(s[min(j+1, len(s)):], " ") + return s[:i], n, rest, true + } + return "", 0, "", false +} + +// raise turns an error a Func returned into the Ruby exception it stands for. +func (in *Interp) raise(err error) { + var e *Error + if errors.As(err, &e) { + if e.rerr != nil { + vm.Reraise(*e.rerr) + } + vm.Raise(e.Class, e.Message) + } + vm.Raise("RuntimeError", err.Error()) +} diff --git a/interp_internal_test.go b/interp_internal_test.go new file mode 100644 index 00000000..c738d0cd --- /dev/null +++ b/interp_internal_test.go @@ -0,0 +1,46 @@ +package ruby + +import ( + "errors" + "testing" +) + +func TestSplitLocation(t *testing.T) { + for _, c := range []struct { + in, file, rest string + line int + ok bool + }{ + {"a.rb:12:in 'm'", "a.rb", "in 'm'", 12, true}, + {"a.rb:3: oops", "a.rb", "oops", 3, true}, + {"c:/x.rb:4", "c:/x.rb", "", 4, true}, + {"no location", "", "", 0, false}, + {"a.rb:x:", "", "", 0, false}, + } { + f, l, r, ok := splitLocation(c.in) + if f != c.file || l != c.line || r != c.rest || ok != c.ok { + t.Errorf("splitLocation(%q) = %q %d %q %v", c.in, f, l, r, ok) + } + } +} + +func TestLocate(t *testing.T) { + bt := []string{"", "lib.rb:5:in 'f'", "cell.rb:9:in '
'"} + if f, l := locate(bt, "cell.rb"); f != "cell.rb" || l != 9 { + t.Fatalf("locate in file = %s:%d", f, l) + } + if f, l := locate(bt, "other.rb"); f != "lib.rb" || l != 5 { + t.Fatalf("locate fallback = %s:%d", f, l) + } +} + +func TestNewErrorPassesThroughAHostError(t *testing.T) { + in := New() + host := errors.New("disk full") + if err := in.newError(host, ""); err != host { + t.Fatalf("newError = %v", err) + } + if in.wrap(nil).IsNil() != true { + t.Fatal("wrap(nil) is not nil") + } +} diff --git a/interp_test.go b/interp_test.go new file mode 100644 index 00000000..a8d92930 --- /dev/null +++ b/interp_test.go @@ -0,0 +1,666 @@ +package ruby_test + +import ( + "bytes" + "errors" + "fmt" + "math" + "math/big" + "reflect" + "strings" + "testing" + + nd "github.com/go-ndarray/ndarray" + + ruby "github.com/go-embedded-ruby/ruby" +) + +func newInterp(t *testing.T) (*ruby.Interp, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + var out, errOut bytes.Buffer + return ruby.New(ruby.WithStdout(&out), ruby.WithStderr(&errOut)), &out, &errOut +} + +func mustEval(t *testing.T, in *ruby.Interp, src string) ruby.Value { + t.Helper() + v, err := in.Eval(src, "test.rb", 1) + if err != nil { + t.Fatalf("Eval(%q): %v", src, err) + } + return v +} + +// Top-level locals persist from one Eval to the next, and so do methods, +// constants and globals. +func TestInterpLocalsPersist(t *testing.T) { + in, out, _ := newInterp(t) + mustEval(t, in, "x = 40\ndef twice(v) = v * 2\nK = 1\n$g = 2") + if got := mustEval(t, in, "x += 2; [x, twice(x), K, $g]").Inspect(); got != "[42, 84, 1, 2]" { + t.Fatalf("got %s", got) + } + mustEval(t, in, "ys = [1, 2].map { |e| e + x }") + if got := mustEval(t, in, "ys").Inspect(); got != "[43, 44]" { + t.Fatalf("ys = %s", got) + } + // A block made in one Eval still closes over the local in a later one. + mustEval(t, in, "inc = -> { x += 1 }") + mustEval(t, in, "inc.call") + if got := mustEval(t, in, "x").Inspect(); got != "43" { + t.Fatalf("x = %s after the closure ran", got) + } + mustEval(t, in, `puts "hi"`) + if out.String() != "hi\n" { + t.Fatalf("stdout = %q", out.String()) + } + if got := in.Locals(); !reflect.DeepEqual(got, []string{"x", "ys", "inc"}) { + t.Fatalf("Locals = %v", got) + } +} + +func TestInterpStreams(t *testing.T) { + in, out, errOut := newInterp(t) + mustEval(t, in, `$stdout.print "O"; $stderr.print "E"; warn "W"`) + if out.String() != "O" || errOut.String() != "EW\n" { + t.Fatalf("stdout %q stderr %q", out.String(), errOut.String()) + } + var o2, e2 bytes.Buffer + in.SetStdout(&o2) + in.SetStderr(&e2) + mustEval(t, in, `print 1; $stderr.print 2`) + if o2.String() != "1" || e2.String() != "2" || out.Len() != 1 { + t.Fatalf("after switching: %q %q %q", o2.String(), e2.String(), out.String()) + } + // The default streams are the process's. + if ruby.New() == nil { + t.Fatal("New returned nil") + } +} + +func TestInterpErrors(t *testing.T) { + in, _, _ := newInterp(t) + _, err := in.Eval("a = 1\n\nraise ArgumentError, 'bad'", "cell.rb", 10) + var e *ruby.Error + if !errors.As(err, &e) { + t.Fatalf("err = %#v", err) + } + if e.Class != "ArgumentError" || e.Message != "bad" || e.File != "cell.rb" || e.Line != 12 { + t.Fatalf("error = %+v", e) + } + if e.Error() != "cell.rb:12: bad (ArgumentError)" { + t.Fatalf("Error() = %q", e.Error()) + } + if len(e.Backtrace) == 0 || !strings.HasPrefix(e.Backtrace[0], "cell.rb:12") { + t.Fatalf("backtrace = %q", e.Backtrace) + } + // The failed Eval still assigned what ran before the raise, and the + // interpreter carries on. + if got := mustEval(t, in, "a").Inspect(); got != "1" { + t.Fatalf("a = %s", got) + } + + // Raised inside a method: placed at the line of the evaluated source. + mustEval(t, in, "def boom\n 1 / 0\nend") + _, err = in.Eval("x = 1\nboom", "cell2.rb", 1) + errors.As(err, &e) + if e.Class != "ZeroDivisionError" || e.File != "cell2.rb" || e.Line != 2 { + t.Fatalf("error = %+v", e) + } + + // A syntax error, at its line in the file. + _, err = in.Eval("x = 1\ndef (", "cell3.rb", 5) + errors.As(err, &e) + if e.Class != "SyntaxError" || e.File != "cell3.rb" || e.Line != 6 { + t.Fatalf("syntax error = %+v (%v)", e, err) + } + + // exit is an error that says so, and the interpreter stays usable. + _, err = in.Eval("exit 3", "c.rb", 1) + errors.As(err, &e) + if st, ok := e.ExitStatus(); !ok || st != 3 { + t.Fatalf("exit status = %d %v", st, ok) + } + _, err = in.Eval("raise 'not an exit'", "c.rb", 1) + errors.As(err, &e) + if _, ok := e.ExitStatus(); ok { + t.Fatal("a RuntimeError is not an exit") + } + if _, ok := ruby.NewError("", "x").ExitStatus(); ok { + t.Fatal("NewError is not an exit") + } + if got := ruby.NewError("", "boom").Error(); got != "RuntimeError: boom" { + t.Fatalf("NewError = %q", got) + } + // throw without a catch. + _, err = in.Eval("throw :nope", "c.rb", 1) + errors.As(err, &e) + if e.Class != "UncaughtThrowError" { + t.Fatalf("throw = %+v", e) + } + if got := mustEval(t, in, "1 + 1").Inspect(); got != "2" { + t.Fatal(got) + } +} + +func TestInterpClose(t *testing.T) { + in, out, _ := newInterp(t) + mustEval(t, in, `at_exit { puts "bye" }`) + if err := in.Close(); err != nil || out.String() != "bye\n" { + t.Fatalf("Close: %v, %q", err, out.String()) + } + in2, _, _ := newInterp(t) + mustEval(t, in2, `at_exit { raise "in at_exit" }`) + if err := in2.Close(); err == nil { + // MRI reports an at_exit exception and carries on; either is fine as + // long as the host is not crashed. + _ = err + } +} + +func TestInterpSetGet(t *testing.T) { + in, _, _ := newInterp(t) + big1 := new(big.Int).Lsh(big.NewInt(1), 100) + rat := big.NewRat(1, 3) + arr, _ := nd.FromData([]float64{1, 2, 3}, 3) + type point struct{ X, Y int } + cases := []struct { + name string + in any + inspect string + back any + }{ + {"n", nil, "nil", nil}, + {"t", true, "true", true}, + {"i", 7, "7", int64(7)}, + {"i8", int8(-8), "-8", int64(-8)}, + {"i16", int16(16), "16", int64(16)}, + {"i32", int32(32), "32", int64(32)}, + {"i64", int64(64), "64", int64(64)}, + {"u", uint(1), "1", int64(1)}, + {"u8", uint8(8), "8", int64(8)}, + {"u16", uint16(16), "16", int64(16)}, + {"u32", uint32(32), "32", int64(32)}, + {"u64", uint64(64), "64", int64(64)}, + {"ubig", uint64(math.MaxUint64), "18446744073709551615", new(big.Int).SetUint64(math.MaxUint64)}, + {"b", big1, big1.String(), big1}, + {"bsmall", big.NewInt(5), "5", int64(5)}, + {"bnil", (*big.Int)(nil), "nil", nil}, + {"f", 1.5, "1.5", 1.5}, + {"f32", float32(0.5), "0.5", 0.5}, + {"c", complex(1, 2), "(1.0+2.0i)", complex(1, 2)}, + {"c64", complex64(complex(1, -2)), "(1.0-2.0i)", complex(1, -2)}, + {"r", rat, "(1/3)", rat}, + {"rnil", (*big.Rat)(nil), "nil", nil}, + {"s", "héllo", `"héllo"`, "héllo"}, + {"by", []byte("ab"), `"ab"`, "ab"}, + {"sym", ruby.Symbol("k"), ":k", ruby.Symbol("k")}, + {"fs", []float64{1, 2.5}, "[1.0, 2.5]", []any{1.0, 2.5}}, + {"ga", [2]string{"a", "b"}, `["a", "b"]`, []any{"a", "b"}}, + {"ns", []int(nil), "nil", nil}, + {"m", map[string]int{"a": 1}, `{"a" => 1}`, map[any]any{"a": int64(1)}}, + {"nm", map[string]int(nil), "nil", nil}, + {"ndnil", (*nd.Array)(nil), "nil", nil}, + } + for _, c := range cases { + if err := in.Set(c.name, c.in); err != nil { + t.Fatalf("Set(%s): %v", c.name, err) + } + if got := mustEval(t, in, c.name).Inspect(); got != c.inspect { + t.Errorf("%s: inspect %s, want %s", c.name, got, c.inspect) + } + got, err := in.Get(c.name) + if err != nil { + t.Fatalf("Get(%s): %v", c.name, err) + } + if !reflect.DeepEqual(got, c.back) { + t.Errorf("%s: Get = %#v, want %#v", c.name, got, c.back) + } + } + + // An NDArray is the same array on both sides. + if err := in.Set("a", arr); err != nil { + t.Fatal(err) + } + mustEval(t, in, "b = a * 2") + got, _ := in.Get("a") + if got.(*nd.Array) != arr { + t.Fatal("Get returned another array") + } + if g, _ := in.Get("b"); g.(*nd.Array).Repr() != "array([2., 4., 6.])" { + t.Fatalf("b = %s", g.(*nd.Array).Repr()) + } + + // An opaque Go value comes back as itself. + p := &point{1, 2} + in.Set("pt", p) + if got := mustEval(t, in, "pt").Inspect(); got != "#" { + t.Fatalf("opaque inspect = %s", got) + } + if g, _ := in.Get("pt"); g != p { + t.Fatal("opaque value changed identity") + } + if got := mustEval(t, in, "pt.class").Inspect(); got != "GoValue" { + t.Fatal(got) + } + + // Ruby values with no Go equivalent stay Values, and round-trip. + mustEval(t, in, "o = Object.new; h = {[1] => 2, nil => 3, o => 4}; cy = [1]; cy << cy; ch = {}; ch[:me] = ch; cx = Complex(1, Rational(1, 2))") + h, _ := in.Get("h") + hm := h.(map[any]any) + if len(hm) != 3 || hm[nil] != int64(3) { + t.Fatalf("h = %#v", hm) + } + for k := range hm { + if v, ok := k.(ruby.Value); ok && v.ClassName() == "Array" { + if v.Inspect() != "[1]" { + t.Fatal(v.Inspect()) + } + } + } + cy, _ := in.Get("cy") + if s := cy.([]any); len(s) != 2 { + t.Fatalf("cy = %v", s) + } + ch, _ := in.Get("ch") + if m := ch.(map[any]any); len(m) != 1 { + t.Fatalf("ch = %v", m) + } + cx, _ := in.Get("cx") + if v, ok := cx.(ruby.Value); !ok || v.ClassName() != "Complex" { + t.Fatalf("cx = %#v", cx) + } + o, _ := in.Get("o") + ov := o.(ruby.Value) + in.Set("o2", ov) + if got := mustEval(t, in, "o2.equal?(o)").Inspect(); got != "true" { + t.Fatal("a Value did not round-trip as the same object") + } + + // Assigning an existing local replaces it. + in.Set("i", "again") + if got := mustEval(t, in, "i").Inspect(); got != `"again"` { + t.Fatal(got) + } + + // Errors. + if err := in.Set("Bad", 1); err == nil { + t.Fatal("Set accepted a constant name") + } + if _, err := in.Get("missing"); err == nil { + t.Fatal("Get of a missing local succeeded") + } + other := ruby.New() + ov2, _ := other.Eval("Object.new", "o.rb", 1) + if err := in.Set("x", ov2); err == nil { + t.Fatal("Set accepted another interpreter's Value") + } + if err := in.Set("x", []any{ov2}); err == nil { + t.Fatal("Set accepted another interpreter's Value inside a slice") + } + if err := in.Set("x", map[string]any{"k": ov2}); err == nil { + t.Fatal("Set accepted another interpreter's Value inside a map") + } + if err := in.Set("x", map[any]int{ov2: 1}); err == nil { + t.Fatal("Set accepted another interpreter's Value as a map key") + } +} + +func TestIsLocalName(t *testing.T) { + for s, want := range map[string]bool{ + "x": true, "_x": true, "aB9": true, "été": true, + "": false, "X": false, "9a": false, "a-b": false, "end": false, "self": false, + } { + if got := ruby.IsLocalName(s); got != want { + t.Errorf("IsLocalName(%q) = %v", s, got) + } + } +} + +func TestValue(t *testing.T) { + in, _, _ := newInterp(t) + var zero ruby.Value + if !zero.IsNil() || zero.Inspect() != "nil" || zero.String() != "" || zero.ClassName() != "NilClass" || zero.Go() != nil { + t.Fatal("zero Value is not nil") + } + if _, err := zero.Call("to_s"); err == nil { + t.Fatal("Call on a zero Value succeeded") + } + v := mustEval(t, in, `class Odd; def inspect = raise("no"); def to_s = raise("no"); end; Odd.new`) + if v.Inspect() == "" || v.String() == "" { + t.Fatal("a raising inspect/to_s must still render") + } + if got := mustEval(t, in, "class NotS; def to_s = 1; end; NotS.new").String(); !strings.HasPrefix(got, "#", n.mu, n.sigma), nil + }) + got := mustEval(t, in, "d = Stats::Normal.new(loc: 1, scale: 2.0)\nd.pdf(1.5)").Go().(float64) + if want := (&normal{1, 2}).pdf(1.5); got != want { + t.Fatalf("pdf = %v, want %v", got, want) + } + if got := mustEval(t, in, "!!d").Inspect(); got != "true" { + t.Fatal("an opaque object is truthy") + } + if s := mustEval(t, in, "d").Inspect(); s != "#" { + t.Fatal(s) + } + mustEval(t, in, "p d; puts d.class, d.is_a?(Stats::Normal)") + if out.String() != "#\nStats::Normal\ntrue\n" { + t.Fatalf("out = %q", out.String()) + } + if s := cls.Wrap(&normal{0, 1}).Inspect(); s != "#" { + t.Fatal(s) + } + // to_s of an instance without its own falls back to #. + if s := mustEval(t, in, `"#{d}"`).Go(); s != "#" { + t.Fatalf("to_s = %v", s) + } + + // An *Error raises its class; rescue sees it. + if got := mustEval(t, in, "begin; Stats::Normal.new(scale: -1); rescue ArgumentError => e; e.message; end").Go(); got != "scale must be positive" { + t.Fatalf("rescue = %v", got) + } + // A positional Hash is not keywords when the site says so. + stats.DefineSingletonMethod("argc", func(c *ruby.Call) (any, error) { + return []any{len(c.Args), len(c.Kwargs)}, nil + }) + for src, want := range map[string]string{ + "Stats.argc(1, a: 2)": "[1, 1]", + "Stats.argc({a: 2})": "[1, 0]", + "Stats.argc({'a' => 2})": "[1, 0]", + "Stats.argc({})": "[1, 0]", + "h = {a: 1}; Stats.argc(**h)": "[0, 1]", + "Stats.argc(a: 1, 'b' => 2)": "[1, 0]", + } { + if got := mustEval(t, in, src).Inspect(); got != want { + t.Errorf("%s = %s, want %s", src, got, want) + } + } + + // Blocks: called from Go, their exceptions re-raised unchanged, break honoured. + stats.DefineSingletonMethod("each_twice", func(c *ruby.Call) (any, error) { + if c.Block == nil { + return nil, errors.New("no block given") + } + var acc []any + for i := range 2 { + r, err := c.Block.Call(i, c.Block.Arity()) + if err != nil { + return nil, err + } + acc = append(acc, r) + } + return acc, nil + }) + if got := mustEval(t, in, "Stats.each_twice { |i, a| i * 10 + a }").Inspect(); got != "[2, 12]" { + t.Fatalf("block = %s", got) + } + if got := mustEval(t, in, "catch(:done) { Stats.each_twice { |i| throw :done, :thrown if i == 1 } }").Inspect(); got != ":thrown" { + t.Fatalf("throw through a Go method = %s", got) + } + if got := mustEval(t, in, "def early = Stats.each_twice { |i| return :returned }\nearly").Inspect(); got != ":returned" { + t.Fatalf("return through a Go method = %s", got) + } + if got := mustEval(t, in, "Stats.each_twice { |i| break :out if i == 1; i }").Inspect(); got != ":out" { + t.Fatalf("break = %s", got) + } + if got := mustEval(t, in, "class MyErr < StandardError; end\nbegin; Stats.each_twice { raise MyErr, 'x' }; rescue MyErr => e; e.class; end").Inspect(); got != "MyErr" { + t.Fatalf("rescued %s", got) + } + if got := mustEval(t, in, "begin; Stats.each_twice; rescue => e; [e.class, e.message]; end").Inspect(); got != `[RuntimeError, "no block given"]` { + t.Fatalf("plain error = %s", got) + } + stats.DefineSingletonMethod("blockbad", func(c *ruby.Call) (any, error) { + _, err := c.Block.Call(make(chan int)) + return nil, err + }) + // A chan is opaque, so that converts; a Value of another interpreter does not. + other := ruby.New() + ov, _ := other.Eval("1", "o.rb", 1) + stats.DefineSingletonMethod("blockother", func(c *ruby.Call) (any, error) { + return c.Block.Call(ov) + }) + if _, err := in.Eval("Stats.blockother { }", "x.rb", 1); err == nil { + t.Fatal("Block.Call accepted another interpreter's Value") + } + stats.DefineSingletonMethod("retother", func(c *ruby.Call) (any, error) { return ov, nil }) + _, err = in.Eval("Stats.retother", "x.rb", 1) + var e *ruby.Error + if !errors.As(err, &e) || e.Class != "TypeError" { + t.Fatalf("returning another interpreter's Value: %v", err) + } + mustEval(t, in, "Stats.blockbad { |c| c }") + + // A Go panic that is not a runtime error is not laundered into an exception. + stats.DefineSingletonMethod("panics", func(c *ruby.Call) (any, error) { panic("host bug") }) + func() { + defer func() { + if r := recover(); r != "host bug" { + t.Fatalf("recovered %v", r) + } + }() + in.Eval("Stats.panics", "x.rb", 1) + t.Fatal("the panic did not propagate") + }() + if got := mustEval(t, in, "1 + 2").Inspect(); got != "3" { + t.Fatal("the interpreter is unusable after a host panic") + } + + // Constants. + if err := stats.SetConst("VERSION", "1.0"); err != nil { + t.Fatal(err) + } + if got := mustEval(t, in, "Stats::VERSION").Go(); got != "1.0" { + t.Fatal(got) + } + if err := stats.SetConst("lower", 1); err == nil { + t.Fatal("SetConst accepted a lower-case name") + } + if err := stats.SetConst("X", ov); err == nil { + t.Fatal("SetConst accepted another interpreter's Value") + } + top, _ := in.DefineClass("TopThing", cls) + if got := mustEval(t, in, "TopThing.superclass").Inspect(); got != "Stats::Normal" { + t.Fatal(got) + } + _ = top + nested, err := in.DefineModule("A::B::C") + if err != nil || nested.Name() != "A::B::C" { + t.Fatalf("nested: %v %v", nested, err) + } + if _, err := in.DefineModule("Stats::Normal"); err == nil { + t.Fatal("DefineModule over a class succeeded") + } + if _, err := in.DefineClass("Stats", nil); err == nil { + t.Fatal("DefineClass over a module succeeded") + } + if _, err := in.DefineModule("bad"); err == nil { + t.Fatal("DefineModule accepted a lower-case name") + } + if _, err := in.DefineModule("A::b"); err == nil { + t.Fatal("DefineModule accepted a lower-case part") + } + if _, err := in.DefineModule("A-B"); err == nil { + t.Fatal("DefineModule accepted a dash") + } + if _, err := stats.DefineModule("Inner"); err != nil { + t.Fatal(err) + } + if mustEval(t, in, "Stats::Inner").Inspect() != "Stats::Inner" { + t.Fatal("Stats::Inner") + } + if c, err := nested.DefineClass("D", nil); err != nil || c.Name() != "A::B::C::D" { + t.Fatalf("nested class: %v %v", c, err) + } + if mustEval(t, in, "A::B::C::D.new.class").Inspect() != "A::B::C::D" { + t.Fatal("A::B::C::D") + } +} + +func toF(v ruby.Value) float64 { + switch x := v.Go().(type) { + case int64: + return float64(x) + case float64: + return x + } + return math.NaN() +} + +// The interpreter runs Ruby exactly as Run does: the same program prints the same. +func TestInterpMatchesRun(t *testing.T) { + src := `def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2) +p (0..10).map { fib(_1) } +p NDArray.arange(4).reshape(2, 2)` + var a bytes.Buffer + if err := ruby.Run(src, &a); err != nil { + t.Fatal(err) + } + in, out, _ := newInterp(t) + mustEval(t, in, src) + if a.String() != out.String() { + t.Fatalf("Run %q, Interp %q", a.String(), out.String()) + } +} + +type vec2 struct{ x, y float64 } + +// The operators of a host class are its methods, as for any Ruby object: the +// binary ones, unary minus, and == (whose result is kept as it is), with != +// its negation unless the class defines its own. +func TestHostOperators(t *testing.T) { + in, _, _ := newInterp(t) + c, _ := in.DefineClass("Vec2", nil) + c.Bind(&vec2{}) + c.DefineSingletonMethod("new", func(call *ruby.Call) (any, error) { + return &vec2{toF(call.Args[0]), toF(call.Args[1])}, nil + }) + bin := func(name string, f func(a, b *vec2) any) { + c.DefineMethod(name, func(call *ruby.Call) (any, error) { + return f(call.Self.Go().(*vec2), call.Args[0].Go().(*vec2)), nil + }) + } + bin("+", func(a, b *vec2) any { return &vec2{a.x + b.x, a.y + b.y} }) + bin("-", func(a, b *vec2) any { return &vec2{a.x - b.x, a.y - b.y} }) + bin("*", func(a, b *vec2) any { return a.x*b.x + a.y*b.y }) + bin("/", func(a, b *vec2) any { return "div" }) + bin("%", func(a, b *vec2) any { return "mod" }) + bin("**", func(a, b *vec2) any { return "pow" }) + bin("<", func(a, b *vec2) any { return a.x < b.x }) + bin(">=", func(a, b *vec2) any { return "ge" }) + bin("==", func(a, b *vec2) any { return []bool{a.x == b.x, a.y == b.y} }) + c.DefineMethod("-@", func(call *ruby.Call) (any, error) { + v := call.Self.Go().(*vec2) + return &vec2{-v.x, -v.y}, nil + }) + c.DefineMethod("inspect", func(call *ruby.Call) (any, error) { + v := call.Self.Go().(*vec2) + return fmt.Sprintf("(%g, %g)", v.x, v.y), nil + }) + mustEval(t, in, "a = Vec2.new(1, 2); b = Vec2.new(1, 5)") + for src, want := range map[string]string{ + "a + b": "(2, 7)", "a - b": "(0, -3)", "a * b": "11.0", "a / b": `"div"`, "a % b": `"mod"`, + "a ** b": `"pow"`, "a < b": "false", "a >= b": `"ge"`, "a == b": "[true, false]", + "a != b": "false", "-a": "(-1, -2)", "a.equal?(a)": "true", + } { + if got := mustEval(t, in, src).Inspect(); got != want { + t.Errorf("%s = %s, want %s", src, got, want) + } + } + c.DefineMethod("!=", func(call *ruby.Call) (any, error) { return "custom ne", nil }) + if got := mustEval(t, in, "a != b").Inspect(); got != `"custom ne"` { + t.Errorf("custom != = %s", got) + } + // A class with no == of its own: identity. + d, _ := in.DefineClass("Plain", nil) + d.Bind(&normal{}) + in.Set("p1", &normal{0, 1}) + in.Set("p2", &normal{0, 1}) + if got := mustEval(t, in, "[p1 == p1, p1 == p2, p1 != p2]").Inspect(); got != "[true, false, true]" { + t.Errorf("identity == = %s", got) + } +} + +// A source the parser crashes on (an unterminated string ending in a +// backslash, go-ruby-parser v0.13.1) is a SyntaxError, not a crash of the +// host. +func TestParserFaultIsContained(t *testing.T) { + in, _, _ := newInterp(t) + _, err := in.Eval("\"\\", "cell.rb", 3) + var e *ruby.Error + if !errors.As(err, &e) || e.Class != "SyntaxError" || !strings.Contains(e.Message, "the Ruby parser failed on this source") || e.Line != 3 { + t.Fatalf("err = %v", err) + } + if got := mustEval(t, in, "1 + 1").Inspect(); got != "2" { + t.Fatal("the interpreter is unusable after a parser fault") + } +} diff --git a/value.go b/value.go new file mode 100644 index 00000000..8464b809 --- /dev/null +++ b/value.go @@ -0,0 +1,352 @@ +package ruby + +import ( + "fmt" + "math" + "math/big" + "reflect" + + nd "github.com/go-ndarray/ndarray" + + "github.com/go-embedded-ruby/ruby/internal/object" + "github.com/go-embedded-ruby/ruby/internal/vm" +) + +// Value is a Ruby object held by Go. It stays valid as long as its Interp. +// The zero Value is Ruby's nil. +// +// # Conversions +// +// Go to Ruby (Set, the arguments of Block.Call and Value.Call, a Func's result): +// +// nil nil +// bool true / false +// int, int8 … int64, uint … Integer (a uint64 above 2**63-1 is a Bignum) +// *big.Int Integer (a copy; Fixnum when it fits) +// float32, float64 Float +// complex64, complex128 Complex +// *big.Rat Rational (a copy) +// string, []byte String +// Symbol Symbol +// *ndarray.Array NDArray — the SAME array, not a copy +// Value itself +// other slices and arrays Array, element by element +// maps Hash, entry by entry +// anything else an opaque object: an instance of the class Bind +// registered for its type, else of GoValue +// +// Ruby to Go (Get, Value.Go): +// +// nil nil +// true / false bool +// Integer int64, or *big.Int when it does not fit +// Float float64 +// Complex complex128 (Value when a part is not real) +// Rational *big.Rat +// String string +// Symbol Symbol +// Array []any, element by element +// Hash map[any]any, entry by entry (a key Go cannot +// hash, an Array say, stays a Value) +// NDArray *ndarray.Array — the same array +// an opaque object the Go value that was wrapped +// anything else Value +type Value struct { + in *Interp + v object.Value +} + +// Symbol is a Ruby Symbol on the Go side: Set(name, Symbol("a")) assigns :a. +type Symbol string + +func (in *Interp) wrap(v object.Value) Value { + if v == nil { + v = object.NilV + } + return Value{in: in, v: v} +} + +func (v Value) obj() object.Value { + if v.v == nil { + return object.NilV + } + return v.v +} + +// IsNil reports whether v is nil. +func (v Value) IsNil() bool { return object.IsNil(v.obj()) } + +// ClassName is the name of v's class. +func (v Value) ClassName() string { + if v.in == nil { + return "NilClass" + } + return v.in.vm.ClassOf(v.obj()).Name() +} + +// Inspect is v.inspect, as p prints it. +func (v Value) Inspect() string { + if v.in == nil { + return "nil" + } + var s string + _, err := v.in.vm.Protect(func() object.Value { + s = v.in.vm.InspectValue(v.obj()) + return object.NilV + }) + if err != nil { + return v.obj().Inspect() + } + return s +} + +// String is v.to_s, as puts prints it. +func (v Value) String() string { + if v.in == nil { + return "" + } + var s string + _, err := v.in.vm.Protect(func() object.Value { + s = v.in.vm.ToSValue(v.obj()) + return object.NilV + }) + if err != nil { + return v.obj().ToS() + } + return s +} + +// Go converts v to a Go value; see Value for the table. +func (v Value) Go() any { return v.in.fromRuby(v.obj(), map[object.Value]any{}) } + +// Call calls the method name on v with args converted to Ruby, and returns its +// result. A Ruby exception comes back as an *Error. +func (v Value) Call(name string, args ...any) (Value, error) { + in := v.in + if in == nil { + return Value{}, fmt.Errorf("ruby: %s called on a Value with no interpreter", name) + } + rargs, err := in.toRubyAll(args) + if err != nil { + return Value{}, err + } + r, rerr := in.vm.Protect(func() object.Value { return in.vm.Send(v.obj(), name, rargs, nil) }) + if rerr != nil { + return Value{}, in.newError(rerr, "") + } + return in.wrap(r), nil +} + +// ValueOf converts a Go value to Ruby, as Set does, without assigning it. +func (in *Interp) ValueOf(x any) (Value, error) { + r, err := in.toRuby(x) + if err != nil { + return Value{}, err + } + return in.wrap(r), nil +} + +func (in *Interp) toRubyAll(args []any) ([]object.Value, error) { + out := make([]object.Value, len(args)) + for i, a := range args { + r, err := in.toRuby(a) + if err != nil { + return nil, err + } + out[i] = r + } + return out, nil +} + +// toRuby converts a Go value to Ruby; see Value. +func (in *Interp) toRuby(x any) (object.Value, error) { + switch v := x.(type) { + case nil: + return object.NilV, nil + case Value: + if v.in != nil && v.in != in { + return nil, fmt.Errorf("ruby: a Value from another interpreter") + } + return v.obj(), nil + case bool: + return object.Bool(v), nil + case int: + return object.IntValue(int64(v)), nil + case int8: + return object.IntValue(int64(v)), nil + case int16: + return object.IntValue(int64(v)), nil + case int32: + return object.IntValue(int64(v)), nil + case int64: + return object.IntValue(v), nil + case uint: + return uintValue(uint64(v)), nil + case uint8: + return object.IntValue(int64(v)), nil + case uint16: + return object.IntValue(int64(v)), nil + case uint32: + return object.IntValue(int64(v)), nil + case uint64: + return uintValue(v), nil + case *big.Int: + if v == nil { + return object.NilV, nil + } + return object.NormInt(new(big.Int).Set(v)), nil + case float64: + return object.Float(v), nil + case float32: + return object.Float(float64(v)), nil + case complex128: + return &object.Complex{Re: object.Float(real(v)), Im: object.Float(imag(v))}, nil + case complex64: + return &object.Complex{Re: object.Float(real(v)), Im: object.Float(imag(v))}, nil + case *big.Rat: + if v == nil { + return object.NilV, nil + } + return &object.Rational{R: new(big.Rat).Set(v)}, nil + case string: + return object.NewString(v), nil + case []byte: + return object.NewStringBytes(append([]byte(nil), v...)), nil + case Symbol: + return object.SymVal(string(v)), nil + case *nd.Array: + if v == nil { + return object.NilV, nil + } + return vm.WrapNDArray(v), nil + } + rv := reflect.ValueOf(x) + if cls, ok := in.types[rv.Type()]; ok { + return in.vm.WrapGo(x, cls), nil + } + switch rv.Kind() { + case reflect.Slice, reflect.Array: + if rv.Kind() == reflect.Slice && rv.IsNil() { + return object.NilV, nil + } + elems := make([]object.Value, rv.Len()) + for i := range elems { + e, err := in.toRuby(rv.Index(i).Interface()) + if err != nil { + return nil, err + } + elems[i] = e + } + return object.NewArrayFromSlice(elems), nil + case reflect.Map: + if rv.IsNil() { + return object.NilV, nil + } + h := object.NewHash() + it := rv.MapRange() + for it.Next() { + k, err := in.toRuby(it.Key().Interface()) + if err != nil { + return nil, err + } + e, err := in.toRuby(it.Value().Interface()) + if err != nil { + return nil, err + } + h.Set(k, e) + } + return h, nil + } + return in.vm.WrapGo(x, in.opaqueClass()), nil +} + +func uintValue(u uint64) object.Value { + if u > math.MaxInt64 { + return object.NormInt(new(big.Int).SetUint64(u)) + } + return object.IntValue(int64(u)) +} + +// opaqueClass is GoValue, the class of a Go value no Bind names. It is made on +// first use, so an interpreter that never sees one defines no such constant. +func (in *Interp) opaqueClass() *vm.RClass { + if in.opaque == nil { + c, _ := in.vm.DefineClass(nil, "GoValue", nil) + c.DefineNative("inspect", func(_ *vm.VM, self object.Value, _ []object.Value, _ *vm.Proc) object.Value { + return object.NewString(fmt.Sprintf("#", self.(*vm.GoObject).Value())) + }) + in.opaque = c + } + return in.opaque +} + +// fromRuby converts a Ruby value to Go; see Value. seen breaks cycles: an Array +// or Hash that contains itself converts to a Go value that contains itself. +func (in *Interp) fromRuby(v object.Value, seen map[object.Value]any) any { + switch x := v.(type) { + case object.Nil: + return nil + case object.Bool: + return bool(x) + case object.Integer: + return int64(x) + case *object.Bignum: + return new(big.Int).Set(x.I) + case object.Float: + return float64(x) + case *object.Complex: + re, ok1 := realOf(x.Re) + im, ok2 := realOf(x.Im) + if ok1 && ok2 { + return complex(re, im) + } + case *object.Rational: + return new(big.Rat).Set(x.R) + case *object.String: + return x.Str() + case object.Symbol: + return Symbol(x) + case *object.Array: + if s, ok := seen[x]; ok { + return s + } + out := make([]any, len(x.Elems)) + seen[x] = out + for i, e := range x.Elems { + out[i] = in.fromRuby(e, seen) + } + return out + case *object.Hash: + if s, ok := seen[x]; ok { + return s + } + out := make(map[any]any, x.Len()) + seen[x] = out + for _, k := range x.Keys { + e, _ := x.Get(k) + gk := in.fromRuby(k, seen) + if gk != nil && !reflect.TypeOf(gk).Comparable() { + gk = in.wrap(k) + } + out[gk] = in.fromRuby(e, seen) + } + return out + case *vm.GoObject: + return x.Value() + } + if a, ok := vm.UnwrapNDArray(v); ok { + return a + } + return in.wrap(v) +} + +// realOf reads an Integer or a Float as a float64. +func realOf(v object.Value) (float64, bool) { + switch x := v.(type) { + case object.Integer: + return float64(x), true + case object.Float: + return float64(x), true + } + return 0, false +}