Skip to content

Embedding API: an Interp that keeps state, exchanges values with Go, and lets Go define Ruby - #823

Merged
tannevaled merged 4 commits into
mainfrom
embed-api
Oct 18, 2026
Merged

tannevaled merged 4 commits into
mainfrom
embed-api

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

An embedding API that keeps state

Until now the public API was one function, ruby.Run(src, out), which runs a program on a fresh VM. This adds Interp, for hosts that evaluate Ruby piece by piece, exchange values with it and define Ruby from Go. Its first users are the go-scikit Ruby bindings and the Ruby cells of go-notebook.

  • State: New(WithStdout(w), WithStderr(w)) *Interp. Eval(src, file, line) (Value, error) evaluates top-level code. Its top-level locals persist from one call to the next, as one binding, and methods, constants and globals persist in the VM as before. SetStdout/SetStderr switch the two streams, which are kept separate. Close runs the at_exit handlers.

  • Errors: a Ruby exception, a SyntaxError included, comes back as an *ruby.Error with Class, Message, Backtrace, File and Line. File and Line are those of the innermost frame in the evaluated file, so an exception raised deep in a method is placed at the line of the source that led to it. exit is an *Error whose ExitStatus() holds the status, and the interpreter stays usable after any error.

  • Values: Set/Get/Lookup/Locals/ValueOf convert between Go and Ruby:

    • nil, bool and the integers; a Bignum becomes a *big.Int;
    • float, complex, *big.Rat, string/[]byte, ruby.Symbol;
    • slices to Array and maps to Hash;
    • *ndarray.Array to NDArray, as the same array;
    • any other Go value as an opaque object that comes back as itself, of the class Module.Bind names for its type, or of GoValue.

    Value has Go, Inspect, String, ClassName and Call.

  • Defining Ruby from Go:

    • DefineModule and DefineClass take qualified names; DefineMethod, DefineSingletonMethod, SetConst, Bind, Wrap.
    • A Func(*Call) receives Self, Args, Kwargs and a Block. Block.Call returns a Ruby exception as an *Error; returning it re-raises the same exception.
    • break, throw and return in the block unwind through the Go code to where Ruby expects them.
    • The operators of a host class are its methods: binary ones, unary minus, and ==. == keeps its result, so a data frame's == can answer a mask.
  • Scope of the change: the VM side is internal/vm/embed.go, reached only through an Interp. On the shared path there are three small changes, none of which changes how existing Ruby code runs:

    • callNative keeps the call site's keyword verdict for a host method to read;
    • classOf gets a case for the host object type;
    • binaryOp and OpNeg dispatch when the operand is a host object, and nothing else can be one.

    The README's "Embedding in a Go program" section is rewritten for it.

Verified locally, every step of ci.yml, before the push

  • go vet ./..., go build ./..., GOOS=js and GOOS=wasip1 builds.
  • go test -race -coverpkg=./internal/... ./...: all green. The coverage ratchet is OK, with no function newly below 100%.
  • rbgo_core: vet, the internal/vm + cmd/wasm suite, the 23.2 MB module and web/smoke.mjs --core; the default module's smoke too.
  • RBGO_BUILD_IT=1 go test ./cmd/rbgo: ok.

Found while doing this, not changed here

A user-defined -@ on a plain Ruby class is never called: class A; def -@ = 42; end; p(-A.new) raises NoMethodError. OpNeg goes to negate(), which knows only built-in values. Fixing it changes how existing Ruby programs behave, so it belongs in a conformance PR of its own.

🤖 Generated with Claude Code

tannevaled and others added 4 commits October 18, 2026 15:17
…and lets Go define Ruby

New(...) *Interp with Eval(src, file, line), whose top-level locals persist
across calls as one binding; errors as *ruby.Error carrying class, message,
file:line and backtrace; separate, switchable stdout and stderr.
Set/Get convert nil, bool, integers (Bignum <-> *big.Int), floats, complex,
rationals, strings, symbols, slices, maps, and *ndarray.Array <-> NDArray as
the same array; other Go values cross as opaque objects that come back as
themselves. DefineModule/DefineClass/DefineMethod/DefineSingletonMethod take Go
funcs receiving self, positional args, keyword args and a block Go can call.

The VM side is internal/vm/embed.go, reached only through an Interp; the one
change on the shared path is that callNative keeps the call site's keyword
verdict for a host method to read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Binary operators, unary minus and == on an object of a host-defined class
dispatch to the methods the class defines; == keeps its result as it is (a
data frame's == answers a mask), and != is its negation unless the class
defines != too. Reported by the go-scikit/ruby bindings, where Series + 1
raised "can't be coerced".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e host

go-ruby-parser v0.13.1 panics (index out of range) on an unterminated string
that ends in a backslash. Kernel#eval is protected by callNative; Interp.Eval
reached the parser directly, so the panic killed the host. Found by fuzzing the
notebook's Ruby-cell analysis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tannevaled

Copy link
Copy Markdown
Contributor Author

Rebased on #819, whose classOf cases sit next to this PR's. Added a commit: a panic of go-ruby-parser on a malformed source (go-ruby-parser/parser#54) is now a SyntaxError from Interp.Eval rather than a crash of the host. The full local run of ci.yml is green again on the rebased branch.

@tannevaled
tannevaled merged commit a3c1f9a into main Oct 18, 2026
10 checks passed
@tannevaled
tannevaled deleted the embed-api branch October 18, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant