Skip to content
go-embedded-rubyPublic

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

996 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-embedded-ruby/ruby

ruby — go-embedded-ruby

Docs License Go Release ruby/spec

A Ruby interpreter written in pure Go, with cgo disabled — so you can embed it in a Go program with import "github.com/go-embedded-ruby/ruby", or ship it as a single static binary that cross-compiles wherever Go does, with no C toolchain and no libruby.

How complete is it? It runs 23,511 of ruby/spec's language/ + core/ examples (what that counts) — a large and growing subset of the language, and not a drop-in replacement for CRuby. Read What does not work yet before you depend on it; the 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).

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, parser, AST) is the standalone pure-Go go-ruby-parser module, which this interpreter imports. The front-end is embedded in the binary, so eval and runtime require keep working. Ruby objects are Go heap objects, so Go's garbage collector is reused. Dispatch goes through mutable per-class method tables, which is what makes monkey-patching, define_method and method_missing free.

🌐 Website · 📚 Documentation · 🧭 Roadmap

Ecosystem — rbgo composes the go-ruby-* family

rbgo is increasingly assembled from a family of standalone pure-Go (CGO=0), MRI-compatible libraries — each its own org with 100% coverage and 6-arch CI — that the interpreter composes and binds as native modules. The design principle is a clean seam: a stdlib piece whose work is pure compute and needs no interpreter — a regexp matcher, an ERB compiler, a YAML emitter, a Marshal codec, an OptionParser argv engine — becomes a reusable standalone library, while only the thin interpreter-dependent glue (the Ruby-object binding) stays in rbgo. The same lever that produced the front-end (go-ruby-parser) is now applied across the stdlib. The benefit cuts both ways: rbgo still ships as a single CGO=0 static binary, and every extracted piece is independently reusable, tested and 6-arch by any Go program — no interpreter required.

The go-ruby-* family is a growing set of standalone pure-Go modules — all CI-green, 100% coverage, 6-arch — each its own org. The go.mod currently binds 178 such modules across 176 orgs into rbgo as native modules (count: grep -oE 'github\.com/go-ruby-[a-z0-9-]*/[a-z0-9-]*' go.mod | sort -u | wc -l; go-ruby-widgets contributes three):

Library Role Org · landing
go-ruby-parser Ruby lexer / parser / AST front-end (embedded, so eval/require keep working) org · site
go-ruby-regexp Onigmo-compatible regexp engine (incl. \b/\B, char-class literals) org · site
go-ruby-erb ERB template compiler org · site
go-ruby-marshal Marshal (dump/load), byte-exact with MRI org · site
go-ruby-yaml Psych-compatible YAML emitter + loader org · site
go-ruby-format sprintf / % / format engine org · site
go-ruby-optparse OptionParser argv engine org · site
go-ruby-strscan StringScanner (strscan) org · site

The full bound family (alphabetical, all native modules in go.mod): aasm, abbrev, acme, actioncable, actionmailer, actionpack, actionview, activejob, activemodel, activerecord, activestorage, activesupport, addressable, age, arrow, async, augeas, base64, bbolt, bcrypt, benchmark, bigdecimal, bleve, builder, bundler, cancancan, capistrano, capybara, cgi, chronic, cmath, commonmark, concurrent-ruby, confd, connection-pool, csv, date, deep-merge, devise, did-you-mean, digest, dotenv, dry-struct, dry-types, dry-validation, erb, erubi, etcd, excon, facter, factory-bot, faker, faraday, fast-gettext, find, format, friendly-id, getoptlong, grape, graphql, grpc, haml, hanami, hcl2, hiera, hocon, http, httparty, i18n, images, ipaddr, irb, jbuilder, jekyll, json, jwt, kafka, kaminari, kramdown, liquid, logger, mail, marshal, matrix, mime-types, minitest, money, mongodb, msgpack, multi-json, mustache, mysql, nats, net-ftp, net-http, net-imap, net-pop, net-sftp, net-smtp, nokogiri, oauth2, observer, oidc, omniauth, openbao, openstack, opentelemetry, opentype, optparse, ostruct, pagy, paper-trail, parquet, parser, pathname, pg, prawn, prettyprint, prime, protobuf, pstore, public-suffix, puma, pundit, puppet, puppet-resource-api, racc, rack, rails, railties, rake, ransack, rdoc, redis, regexp, reline, resolv, resque, rexml, roda, rolify, rouge, rqrcode, rspec, rss, rubocop, rubygems, saml, sass, scanf, securerandom, semantic-puppet, sequel, shellwords, shrine, sidekiq, simplecov, sinatra, slim, sodium, sqlite3, strscan, thor, timecop, toml, tsort, typhoeus, tzinfo, unicode-normalize, uri, vcr, warden, webauthn, webmock, webrick, widgets, yaml, zeitwerk, zlib, plus activeldap, ldap and fast-gettext-locale — each at github.com/go-ruby-<name>/<name>.

Seven more are bound from outside the go-ruby-* naming convention, at github.com/go-<name>/<name>: commonmark, kramdown, liquid, mustache, nokogiri, rouge and xslt. (set is not bound at all — see Set under Supported today: it is implemented in-tree.)

Beyond the go-ruby-* family, the scientific / container stack binds the pure-Go go-ndarray, go-fft, go-images, go-numerics/special, go-statistics/stats, go-numerics/integrate, go-numerics/optimize and go-composites libraries the same way (see Supported today below), and the pure-Go go-widgets UI toolkit is bound through three adapters: require "widgets" (pixel-blitting GUI toolkit), require "tui" (terminal-cell toolkit) and require "mvvm" (data-binding layer) — each at github.com/go-ruby-widgets/<name>.

Status

Runtime conformance — ruby/spec

The honest summary: rbgo runs a large and growing subset of Ruby, and is not a drop-in replacement for CRuby. The number below is the best evidence available, and it is worth understanding exactly what it counts.

What is measured. The language/ and core/ suites of ruby/spec — the executable specification of the Ruby language and its core library — run through rbgo by scripts/conformance/rubyspec/run.sh. Each spec file runs in its own rbgo process under a minimal MSpec-compatible shim that ships with this repo.

passing examples 23,471
failing / erroring examples 918 / 329
skipped (a matcher our shim does not implement) 491
examples that actually ran (pass + fail + error) 24,718
share of those that passed 95.0 %
spec files 2,202 of 2,206 produced a result; 4 produced none
enforced by CI a per-file baseline (BASELINE), not a single number

The badge and the summary above it count BASELINE, the per-file record CI enforces, which moves with every merge. The table is one dated full sweep, so the two differ by whatever has landed since — 10 examples at the time of writing. Only the baseline is machine-checked against this file (TestReadmeConformanceCountMatchesTheBaseline); the table's other rows come from the sweep named below and are not derivable from the baseline, which records passing counts and nothing else.

Measured 2026-09-28 on 862a9f3, darwin/arm64, against the corpus pinned at SPEC_SHA=87b1631992bd00cf0c4934474766d54dad088191, on an isolated snapshot of it so no concurrent run could swap the shim underneath, and reached through a real path: seven path-identity specs answer differently behind a symlink, which is worth 20 examples and is itself a divergence from MRI (issue #741). Three independent sweeps — two through run.sh and one tallying all four counters — agreed on every one of the 2,206 per-file records except core/random/bytes_spec.rb, which answers 8 or 9 on the same binary and is the 1 separating 23,471 from 23,472. Four files produce no result at all: core/kernel/exit_spec.rb, core/mutex/lock_spec.rb, core/process/exit_spec.rb, core/string/unpack/carret_spec.rb.

What this number is not. It is not "rbgo implements 93 % of Ruby", and there is no honest way to turn it into a percentage of the language:

  • Only language/ and core/ are swept. ruby/spec's library/, optional/ and security/ trees are not run at all. Most of what people mean by "the standard library" is outside this measurement. (rbgo does bind a large set of stdlib and gem replacements — see Supported today — but their conformance is not what this figure reports.)
  • The shim is not MSpec. A few matchers (be_computed_by, ruby_exe, some argf/IO helpers) are stubbed, so the examples that need them are counted as skipped, not passed. That makes the total a conservative lower bound.
  • Skips are outside the ratio. The 93.3 % divides passes by the examples that ran; the 477 skips and the 10 unreadable files are in neither column.
  • An "example" is not a feature. Examples are not weighted, so a heavily specified method contributes far more than a rarely used one.

What CI guarantees is per file, not a total. BASELINE records the passing count of each of the 2,206 spec files, and the run fails if any file passes fewer than its record or stops loading. There is no margin and no single number to quote: the gate and the measurement describe the same thing at the same grain.

It used to be one frozen scalar, and that could not be calibrated. A spec file that fails to load carries its whole count — the largest here is 384 examples (core/encoding/compatible_spec.rb) — so the floor needed a margin wider than the largest file, which would have made it blind to every regression under 384 out of 23,471. The last scalar floor sat 211 below the measured total, and a drop of that size could not say whether one file had stopped loading or hundreds of specs had regressed.

Reproduce it yourself:

scripts/conformance/rubyspec/run.sh     # clones the pinned corpus on first run

Front-end conformance & performance (campaign final, 2026-06-27)

A major conformance + performance campaign measured rbgo's pure-Go front-end (parse + compile) against the MRI 4.0.5 oracle on the two largest real-world Ruby codebases and a suite of popular libraries. All figures are front-end acceptance, not whole-application execution (see the honesty note below). These figures date from 2026-06-27 and have not been re-measured since; reproduce them with scripts/conformance/heavyweight/sweep.sh.

  • Parse: 99.96 % of Rails + Puppet (5577 / 5579 .rb files). The only 2 misses are intentional syntax-error fixtures MRI itself rejects — i.e. 100 % of all valid Ruby in the corpus parses.
  • End-to-end (parse + compile): Rails 99.82 % (3417 / 3423), Puppet 100.00 % (2154 / 2154).
  • 0 over-permissive — rbgo never accepts Ruby that MRI rejects.
  • Library parse-conformance: RuboCop 99.7 %, Sinatra / Jekyll / Thor / Kramdown / dry-struct 100 %, Homebrew 98.7 %, Chef 99.1 %, concurrent-ruby 98.3 %, Asciidoctor 93.8 %.
  • RSpec DSL usage: 10/10 byte-identical to MRI.
  • The journey (Rails end-to-end, across 5 parser rounds + 5 rbgo activation rounds): 20.7 % → 46.3 % → 68.7 % → 81.2 % → 86.7 % → 93.8 % → 98.4 % → 99.82 %.

Features added in the campaign include :: constant-paths (qualified class / module names, superclass, leading ::), paren-less command calls with args/kwargs, class << self (singleton class), masgn to any target (ivar/cvar/gvar/attr/index/constant + nested destructuring), alias/undef, anonymous params + forwarding (def f(*, **, &) / g(...)), special globals ($$ etc.), shorthand hash {x:}, quoted / operator / char-literal symbols, rationals & imaginaries (2r / 3i), unicode identifiers, for…in…end loops, block keyword params, begin-less rescue/ensure, and !~ — on top of the pre-existing core (full object model, metaprogramming, Fiber/Thread, Marshal, regexp, the scientific bindings, and the js/wasm target) detailed below.

Performance: a 6-runtime comparative suite (BENCHMARKS.md) pits rbgo and rbgo+AOT against MRI, MRI+YJIT, JRuby and TruffleRuby. The rbgo interpreter runs ~3–6× MRI on compute and at parity on I/O-bound work; rbgo+AOT beats MRI+YJIT 18–24× on loop/fib (the only runtime here that beats YJIT); TruffleRuby is the compute ceiling.

Honesty note. "99.82 % of Rails" means rbgo's front-end parses and compiles that fraction of Rails's .rb files — not that rbgo runs Rails. Running a full application additionally needs the runtime stdlib surface and C-extension equivalents. That surface is now real enough that the real puppet apply CLI runs end-to-end under rbgo (see Running Puppet below), converging notify, file and exec resources; the broader resource providers (package / service host convergence) remain in progress. What is established is that the Ruby language / front-end is essentially complete on real-world code, and that the runtime is far enough along to boot a real application; whether any given application runs end-to-end remains application-specific work. Details: CONFORMANCE-RAILS-PUPPET.md, CONFORMANCE-LIBRARIES.md, CONFORMANCE-RSPEC.md.

Running Puppet — puppet apply runs end-to-end

Beyond parsing real-world Ruby, rbgo now runs the real Puppet puppet apply CLI end-to-end. require "puppet" fully boots the framework (Puppet 8.11.0) on a pure-Go CGO=0 rbgo — its pure-Ruby gem dependencies (semantic_puppet, concurrent-ruby, deep_merge, fast_gettext, facter, racc, …) load on the $LOAD_PATH — and a manifest then travels the complete Puppet path: the real Puppet::Util::CommandLine → Puppet::Application::Apply entry point (a real OptionParser), every Puppet type + provider loaded, the settings catalog applied (creating Puppet's config dirs on disk), and finally the user catalog applied through the transaction / RAL. Driving the genuine CLI emits real Puppet output and exits 0:

$ rbgo run puppet_apply.rb   # ARGV = apply -e 'notify { "hello": message => "hi from rbgo cli" }'
Notice: Compiled catalog for  in environment production in 0.00 seconds
Notice: hi from rbgo cli
Notice: /Stage[main]/Main/Notify[hello]/message: defined 'message' as 'hi from rbgo cli'

That is the actual notify resource type applying through the transaction and the Resource Abstraction Layer — not a notice(...) evaluator print. Reaching the CLI added a real OptionParser (optparse), File::Stat / FileTest + on-disk filesystem operations, and a batch of deep Ruby fixes (class_eval lexical scope, return inside define_method, class-method super, String#chomp(sep), …).

Three resource types now converge end-to-end through the transaction / RAL: notify, file (the catalog creates / manages a real file on disk), and exec — which runs its command through pure-Go process execution, honouring the onlyif / unless / creates / path guards (so an already-converged exec is correctly skipped). puppet apply exits cleanly, the YAML run report / state round-tripping through the pure-Go Psych emitter / loader.

Reaching this exercised a large slice of the runtime, each gap reduced to a minimal rbgo-vs-MRI repro before fixing:

  • Language / VM conformance: autoload, frame-based Exception#backtrace / set_backtrace / full_message, interpolated regexp literals, non-local block return, Symbol#intern, NilClass conversions, Array slice-assignment, Module.new (real anonymous module running its block as a body), extend transitivity (a module's transitively-included methods become class methods), setter expressions returning their RHS, nested constant namespaces, and method-visibility enforcement — among dozens more.
  • Pure-Go stdlib modules added on the path to boot and run the CLI: ERB (template engine), openssl (real crypto, not a stub), net/http, resolv, tmpdir, Process, StringScanner (strscan), Find, getoptlong, syslog, fileutils, a real OptionParser (optparse), File::Stat / FileTest + on-disk filesystem operations, objspace, and more — each require-able and CGO=0.

This is the C-extension → pure-Go shim strategy in action: a real Ruby application ships as a single static CGO=0 binary because the C-backed gem APIs are backed by pure Go. Puppet validates the approach end to end — its dependency tree is pure Ruby, so it loads as-is.

Frontier (honest): what runs end-to-end is the puppet apply CLI converging notify, file and exec resources through the full pipeline (boot → parse → compile → transaction / RAL → output → clean exit). The next frontier is the broader resource providers — user / group are provider-ready, while package / service need a host package manager / systemd and root. So the language, front-end, boot, CLI/apply machinery and the first convergent providers are real today; the remaining system-state providers are the work in progress.

Supported today

Supported today (every feature differential-tested against MRI Ruby 4.0.5 and JRuby):

  • Values: integers (int64, with automatic Bignum promotion on int64 overflow and arbitrary-precision integer literals, radix literals 0x/0o/ bare-0-octal/0b/0d with underscores), floats, strings, symbols (incl. operator-method symbols :+/:<</:[]=/:<=>, usable with reduce(:+)/inject/send(:+, x)), arrays, hashes, ranges (incl. beginless/endless), Complex and Rational numbers, true/false/ nil, self, Proc/lambda, Regexp/MatchData, Struct.
  • Operators: arithmetic (+ - * / %, Ruby floor division, **), comparison/<=>, ==/===, bitwise/shift (<< >> & | ^ ~, arbitrary precision), &&/||, ternary, ranges, :: constant scope (Math::PI, Foo::BAR); correct negative-literal precedence (-2.abs == 2, -2**2 == -4).
  • Control flow: if/elsif/else, unless, while/until, case/when, statement modifiers (incl. modifier rescue, expr rescue fallback), begin/rescue/else/ensure/retry, break/next, Kernel#loop, and Fiber (cooperative coroutines — Fiber.new/resume/Fiber.yield/alive?/transfer, plus Fiber#raise (raising into a suspended fiber), Fiber#kill, fiber storage (Fiber[]/Fiber[]=) and Fiber#blocking?).
  • Concurrency: Thread (new/start/join/value/status/current/ main/list/pass, thread-locals via []/[]=, exception propagation on join), Mutex (lock/unlock/try_lock/synchronize/owned?) and Queue (blocking push/pop, close), on an emulated GVL — one Ruby thread runs at a time, matching MRI's memory model (race-free under the Go race detector). Thread#backtrace answers for the current thread; asking another thread for its backtrace raises NotImplementedError, because rbgo keeps one frame stack per VM rather than one per thread.
  • Processes: Process — spawn/exec (argv and shell forms), the wait family (wait/wait2/waitpid/waitpid2, Process::Status through $?), kill, process groups (getpgid/setpgid/getpgrp), scheduling priorities (getpriority/setpriority) and resource limits (getrlimit/setrlimit with the RLIMIT_* constants), plus pid/ppid/uid/gid/euid/egid. There is no Process.fork (Process.respond_to?(:fork) is false) — Go's runtime cannot be forked safely; spawn is the supported way to start a child. Kernel#fork is a different story: fork { ... } does exist and runs the block in the same process — see the table under Known divergences, because code that forks to isolate or to drop privileges gets neither here.
  • Pattern matching (case/in): value, variable-binding, class/constant, array (incl. splat and nested), hash (deconstruct_keys, **rest/**nil), find ([*pre, x, *post]), pin (^x) and alternative (a | b) patterns; the => name binding suffix, guards (if/unless), the one-line forms expr => pattern and expr in pattern, and NoMatchingPatternError — via the deconstruct/deconstruct_keys protocols.
  • Assignment: multiple assignment / destructuring (a, b = 1, 2, swap, x, *rest = …, *init, last = …), compound assignment (+= -= *= /= %= <<= ||= &&=), global variables ($g, plain and compound).
  • Methods: required / optional / *splat / keyword (a:, b: 2) / **rest / &block parameters, setter defs (def name=), endless methods (def foo = expr), singleton method defs on any object (def obj.foo / def Const.foo), recursion, return, super.
  • Blocks / Procs / lambdas: { } / do…end closures, yield, block_given?, &block capture, block params with destructuring (|(a, b)|) and rest (|*rest|, |head, *rest|), numbered params (_1/_2) and it, Proc/lambda/stabby ->(){}, &proc block-pass and Symbol#to_proc (the &:sym shorthand).
  • Classes & modules: inheritance, @ivars, @@class variables (shared down the superclass hierarchy), new/initialize, constants and constant assignment, class methods (def self.foo), modules + include/prepend (mixins, with full ancestor-chain super through included/prepended modules and the singleton chain), Module#ancestors/include?, attr_accessor/reader/writer, Struct.new.
  • Metaprogramming: dynamic dispatch via mutable method tables, method_missing, send/public_send, respond_to?, define_method, instance_eval/instance_exec, class_eval/module_eval/class_exec, instance_variable_get/set/defined?, defined? over every form it takes — including defined?(super), which answers "super" only when an ancestor actually defines the method — module_function, string eval (the embedded front-end compiling Ruby at runtime), Binding (binding, Binding#eval, eval(str, binding), local_variable_get/set/defined?, local_variables, receiver — capturing a frame's locals so eval'd code reads and writes them), and the class/module hooks inherited/included/prepended/method_added/extended.
  • Runtime loading: require/require_relative load, compile and run a .rb file once (relative + search-path resolution, LoadError on miss, true/false return) — the embedded front-end loading code at runtime — and autoload/autoload? on both Object and any Module, registering a constant that loads its file on first reference.
  • Predefined globals, each with the setter MRI's variable table gives it rather than one untyped slot: the separators $; ($-F), $, and $/ ($-0); $0/$PROGRAM_NAME; the match globals $~, $`, $', $&, $+ and $1–$9; $! and $@; $stdout/$stderr/$stdin and $>; $:/$LOAD_PATH/$-I and $"/$LOADED_FEATURES; $?; $. and $_; $VERBOSE ($-v/$-w) and $DEBUG ($-d) — both reading false, not nil, as MRI does. The read-only ones raise on assignment ($LOAD_PATH = [] → NameError), the checked ones enforce their type ($stdout = nil → TypeError: $stdout must have write method, NilClass given), and trace_var/untrace_var hook assignment to a global.
  • Strings: mutable (reference semantics) with <</concat/replace/prepend/ insert/[]=/slice!/the bang methods and freeze/FrozenError; interpolation, heredocs (<</<<-/<<~), %w/%i and %q/%Q/%W/%I literals, the \a/\b/\v/\f/\s/\n/\t/\r/\e/\0 escapes, %/format/sprintf, case/strip/split/each_char/lines/succ(next) and friends.
  • Regular expressions: /re/imx literals, Regexp/MatchData, =~ / match / match? / scan / gsub / sub / split, and the match globals $~ / $1..$N / $& / $` / $' — running on the standalone pure-Go go-ruby-regexp engine, so the build stays CGO=0. ReDoS is bounded and the bound is reportable: Regexp.timeout= (the default for every Regexp in this VM — MRI's is process-global, rbgo scopes it to the VM so embedding two is not a shared mutable setting) and Regexp.new(src, timeout:) (per-Regexp, overriding that default in either direction, as MRI does) are both enforced, and a match that exceeds its limit raises Regexp::TimeoutError rather than answering "no match" — so a Regexp used as a validator or a denylist fails closed.
  • Standard library leaves: JSON (generate/dump/pretty_generate/ parse + Object#to_json, with object key order preserved and MRI-matching number/escape formatting), Digest (MD5/SHA1/SHA256/SHA512 — hexdigest/digest/base64digest), Base64, and Zlib (crc32/ adler32 + Deflate/Inflate) — each require-able and pure-Go.
  • Marshal (dump/load/restore + MAJOR_VERSION/MINOR_VERSION) — Ruby's binary serialization, byte-for-byte identical to MRI across Integer/Bignum, Float, Symbol, String, Array, Hash (incl. defaults), and the symbol/object-link tables (shared objects and cycles round-trip). Runs on the standalone pure-Go go-ruby-marshal engine, so the build stays CGO=0.
  • File & Random: File — path helpers (basename/dirname/extname/ join/split/expand_path) and filesystem ops (read/write/exist?/ file?/directory?/size/delete), raising Errno::ENOENT for missing paths; Random — a bit-exact reimplementation of MRI's seeded MT19937, so Random.new(seed) / srand+rand reproduce MRI's sequence.
  • IO: IO with $stdout/$stderr/STDOUT/STDERR/$stdin as real objects (write/<</print/puts/printf/putc/sync/flush/close), StringIO (require "stringio") — an in-memory IO with the full read side (read/gets/getc/readline/readlines/each_line/each_char, pos/seek/rewind/truncate/eof?/string), and Kernel#warn. Kernel#puts/print/p write through the current $stdout, so reassigning it to a StringIO captures output, as in MRI. File.open (modes r/w/ a/r+, block-scoped auto-close) returns a file-backed IO (File < IO) with the same read/write protocol, plus File.readlines/File.foreach.
  • Dir: entries/children/glob/[], exist?/empty?, pwd/home, mkdir/rmdir/chdir (block-scoped), each_child/foreach, raising Errno::ENOENT/Errno::EEXIST as MRI does.
  • Process-facing IO: IO.popen (running a child and reading/writing its pipe, block form included), IO#fcntl, IO.select, IO#read_nonblock/write_nonblock, IO#reopen and set_encoding/external_encoding.
  • Filesystem predicates and canonical paths: File.realpath and File.realdirpath (symlink and .. resolution), and FileTest — all 26 of MRI's predicates, name for name: exist?, file?, directory?, empty?, zero?, size, size?, symlink?, blockdev?, chardev?, pipe?, socket?, identical?, owned?, grpowned?, setuid?, setgid?, sticky?, readable?, writable?, executable?, the *_real? family and world_readable?/world_writable?. Differentially tested against MRI 4.0.5 over a temp tree of regular files, an empty file, a directory, a symlink, an executable, a FIFO, a missing path, /dev/null and /dev/disk0: 252 assertions, 0 disagreements.
  • Errno: MRI's full 158-constant table, with the same class-per-number identity — Errno.constants.size is 158 under both rbgo and MRI 4.0.5. See the limitations below for the 32 constants whose numbers differ on darwin.
  • Collections: Array / Hash / Range with Enumerable (map/select/reduce/ minmax/…) and Comparable, both written once in embedded Ruby; Array bang methods (map!/sort!/select!/reject!/compact!/uniq!/reverse!), structural/combinatorial ops (transpose/product/combination/to_h), the Hash[…] constructor, String ranges (("a".."e") iterating via String#succ), and Range#step/Integer#step (integer and float walks, both directions).
  • Enumerator: every blockless iterator (each/map/select/reject/ each_slice/each_cons/each_with_index/times/upto/each_char/…) returns an Enumerator (MRI semantics) with next/peek/rewind/size/to_a, with_index/each_with_index, Kernel#enum_for/to_enum, and full Enumerable chaining ([1,2,3].map.with_index { |x, i| … }); plus Enumerator::Lazy (lazy) — deferred map/collect, select/filter, reject, filter_map, flat_map/collect_concat, grep/grep_v, zip, uniq, compact, with_index, take/take_while, drop/drop_while over finite or infinite ((1..Float::INFINITY).lazy) sources, chained without materialising and pulled by first(n)/each/to_a/force.
  • Numeric tower: Integer/Float/Rational/Complex under a shared Numeric (carrying Comparable); Module#ancestors/include?, Class#superclass.
  • Objects: dup/clone/freeze/frozen?, equal?, object_id/__id__ (MRI's deterministic immediate-value ids, stable per reference object), instance_variable_get/set.
  • Math: the Math module (sqrt/cbrt/exp/log/log2/log10, the trig and hyperbolic functions, atan2/hypot/pow) with Math::PI/Math::E.
  • NDArray: a NumPy-style n-dimensional array (float64), binding the pure-Go go-ndarray library, no cgo / no NumPy — the API the go-ndarray playground emits for its Ruby column. Creation zeros/ones/full/arange/linspace/eye/from; views reshape (with -1)/transpose/T/flatten/squeeze/expand_dims, and dup/copy (a copy of the data); NumPy basic indexing through a[...] — an Integer, true for :, a Range (1...3, 1.., ...3; inclusive ranges too) or an ArithmeticSequence ((0..) % 2, (-1..) % -1 for ::-1); element-wise + - * / ** with broadcasting (a number on either side), sqrt/exp/log/sin/cos/abs/square/clip, NDArray.maximum/minimum; reductions sum/mean/max/min/prod/ argmax/argmin (whole array, or (axis, keepdims: true)) and cumsum; matmul/dot/NDArray.outer, NDArray.concatenate/stack (axis:); shape/ndim/size/to_a (flat), and p a prints NumPy's repr (array([[0., 1.], ...])). Every argument is checked: a wrong count or type raises ArgumentError/TypeError, an index or axis out of range IndexError.
  • Image: a scikit-image-style image processor — Image.new/load/save, pixel get/set, filters (gaussian_blur/box_blur/median/sharpen), edges (sobel/prewitt/scharr/laplacian/canny), morphology (erode/dilate), geometry (resize/rotate90/crop/flip_*), colour (grayscale/invert/rgb_to_hsv/otsu) — binding the pure-Go go-images library, no cgo.
  • FFT: an FFT module — 1-D (fft/ifft/rfft/irfft), N-D and 2-D (fftn/ifftn/fft2/ifft2), bin-frequency helpers (fftfreq/rfftfreq), window functions (hann/hamming/blackman/blackman_harris/bartlett), and spectral helpers (psd/spectrogram) — binding the pure-Go go-fft library, a numpy.fft-style transform with no cgo / no FFTW, returning Complex spectra.
  • Special: a Special module — all 67 functions of go-numerics/special, a scipy.special-style set with no cgo / no SciPy: gamma (gamma/gammaln/gammasgn/digamma/polygamma/factorial/log_factorial/ factorial2/loggamma_sign), beta (beta/betaln/binom/comb/perm), error and normal (erf/erfc/erfcx/erfinv/erfcinv/ndtr/ndtri/log_ndtr), logistic (expit/logit/log_expit/softplus/xlogy/xlog1py), reductions (logsumexp/softmax/log_softmax), zeta (Riemann, or Hurwitz with a second argument), the incomplete gamma and beta functions with their inverses (gammainc/gammaincc/gammaincinv/gammainccinv, betainc/betaincc/betaincinv/betainccinv), Bessel (j0/j1/y0/y1/i0/i1/k0/k1, the exponentially scaled i0e/i1e/k0e/k1e, arbitrary order jv/yv/iv/kv/ive/kve, integer order jn/yn/in/kn, and jn_zeros) and the complete elliptic integrals (ellipk/ellipkm1/ellipe). Each is a ufunc, as scipy's is: a Float answers a Float, an NDArray answers an NDArray of the same shape, and a Float mixed with an NDArray broadcasts. Most names are scipy's own, taken from the correspondence the Go module measures itself against — which is why softplus and gammaln, not log1pexp and log_gamma. 63 of the 67 carry such a pairing; the four that do not are named from a reading of scipy or from the Go function, and say so: gammasgn and kn exist in scipy but are unpaired in the module, log_factorial is scipy's gammaln(n+1), loggamma_sign has no scipy counterpart, and in is not a scipy name at all — scipy cannot have one, in being a Python keyword, so iv(0, x) is the faithful spelling and in answers exactly the same.
  • Stats: a Stats module — the 32 univariate distributions of go-statistics/stats, a scipy.stats-style set with no cgo / no SciPy. Continuous: norm/cauchy/expon/gumbel_r/halfnorm/laplace/logistic/rayleigh/ uniform/chi/chi2/gamma/invgamma/lognorm/lomax/pareto/t/ triang/vonmises/weibull_min/beta/f/ncx2/reciprocal; discrete: bernoulli/geom/poisson/zipf/binom/nbinom/randint/hypergeom. Arguments follow scipy's order — the shape parameters, then loc, then scale (continuous) — each optional with scipy's default. Every distribution answers the same method set, Stats::Continuous and Stats::Discrete differing only in the density: pdf/logpdf against pmf/logpmf, plus cdf/logcdf/sf/logsf/ppf/isf, the moments mean/var/std/skew/kurtosis/median/entropy/moment, interval/support, and rvs(size…, seed:) — a Float with no size, an NDArray with one. The per-point methods are ufuncs too, over a Float or an NDArray. A parameter outside its domain is refused at construction with ArgumentError, where scipy raises ValueError.
  • Integrate: an Integrate module — scipy.integrate's quadrature from go-numerics/integrate, with no cgo / no SciPy: adaptive quad (finite or infinite limits, points: for a known kink, epsabs:/epsrel:/limit:), the fixed rules fixed_quad and romberg (tol:/rtol:/divmax:), the sampled rules trapezoid/simpson/ romb/cumulative_trapezoid (x: or dx:, initial:), the multiple integrals dblquad and nquad, and gauss_legendre for a rule's nodes and weights. The integrand is a block, which also takes &f for a Proc or lambda; dblquad's inner limits may be numbers or callables.
  • Optimize: an Optimize module — scipy.optimize's root finders and minimisers from go-numerics/optimize, with no cgo / no SciPy: the bracketing finders brentq/brenth/bisect/ridder and newton (secant, Newton–Raphson with fprime:, Halley with fprime2:), minimize_scalar (method: brent/golden/bounded, bracket:, bounds:) and fminbound, minimize over several variables (method: BFGS/CG/Nelder-Mead/ Powell/L-BFGS-B, with bounds:), curve_fit, approx_fprime and bracket. Return shapes are scipy's: one number answers a Float, a tuple an Array (curve_fit is scipy's (popt, pcov)), and an OptimizeResult or RootResults a Hash with symbol keys — which full_output: true asks a root finder for.
  • Set: Ruby's Set — new/[], add(<<)/add?/delete/merge/clear, include?/member?/size/length/count/empty?, subset?/superset?, union(|)/intersection(&)/difference(-), each/to_a/to_set. Implemented in-tree (internal/vm/set.go + the prelude) as a shell over rbgo's own object.Hash, exactly as MRI's set.rb is Hash-backed, so members key by the same #hash/#eql? semantics Hash keys use.
  • Time: Ruby's Time — now/at/parse, arithmetic (+/-/<=>), strftime/strptime, year/month/mday/hour/min/sec/wday, weekday predicates (monday?…sunday?), utc/getutc/zone, to_i/to_f — binding go-composites/time.
  • Date: Ruby's Date — new/parse, +/-/<</>> and next_day/prev_day/next_month/prev_month, year/month/mday/wday/yday/cwday, leap?, comparisons — binding go-ruby-date.
  • BigDecimal: arbitrary-precision decimal — + - * / **, sqrt/abs/ceil/floor/round/pow, to_f/to_i, zero?, comparisons — binding go-ruby-bigdecimal.
  • Bag: a multiset / counter (element → multiplicity) — add(<<)/delete, count/size/distinct/most_common, union/difference/intersection, include?/each/to_a — binding go-composites/bag.

Beyond the stdlib, rbgo binds a set of native capability modules — each require-able and backed by a canonical pure-Go go-ruby-* library, so the build stays CGO=0. These wrap real I/O / network / crypto engines, so their throughput tracks the underlying driver rather than the interpreter:

  • Databases & key-value stores: mysql2 (require "mysql2" — Mysql2::Client, Result and prepared Statement, over go-ruby-mysql, a pure-Go mysql2 over go-sql-driver/mysql); mongo + bson (require "mongo" — Mongo::Client/ Database/Collection/Cursor with ordered BSON documents and BSON::ObjectId, over go-ruby-mongodb); etcd/ etcdv3 (require "etcd" — the etcd v3 KV client: get/put/txn/lease/watch/lock, over go-ruby-etcd); and bolt (require "bolt" — the embedded bbolt B+tree store: DB/Tx/Bucket/Cursor, files interoperable with reference bbolt tooling, over go-ruby-bbolt over go.etcd.io/bbolt).

  • Messaging: nats (require "nats" — NATS publish / subscribe / request with headers, over go-ruby-nats); and kafka (require "kafka" — a ruby-kafka-faithful producer / consumer / admin client, over go-ruby-kafka over twmb/franz-go).

  • RPC, serialization & columnar data: grpc (require "grpc" — a gRPC server and client stub sharing an in-process transport, so one program can be both peers, over go-ruby-grpc); google/protobuf (require "protobuf" — Protocol Buffers with wire-compatible encode/decode/encode_json, well-known types and the descriptor pool, over go-ruby-protobuf); arrow (require "arrow" — Apache Arrow DataType/Field/Schema/Array/ RecordBatch/Table, over go-ruby-arrow); and parquet (require "parquet" — an Arrow-backed Parquet reader / writer, over go-ruby-parquet).

  • Web & security: faraday (require "faraday" — the Faraday HTTP client: connection / request / response / middleware, over go-ruby-faraday); puma (require "puma" — a Rack HTTP server driving app.call under the emulated GVL, over go-ruby-puma); graphql (require "graphql" — a graphql-ruby-flavoured type system + query execution preserving the exact data/errors shape, over go-ruby-graphql); saml/ ruby-saml (require "saml" — SAML SSO auth requests / responses / metadata / logout, exposed as SAML and OneLogin::RubySaml, over go-ruby-saml); webauthn (require "webauthn" — WebAuthn / FIDO2 relying-party registration and authentication, over go-ruby-webauthn); acme (require "acme" — the ACME / Let's Encrypt Acme::Client account / order / authorization / challenge / certificate flow, over go-ruby-acme); rbnacl (require "rbnacl" — NaCl / libsodium SecretBox/Box, Ed25519 / Curve25519 keys, AEAD, hashing and password hashing, over go-ruby-sodium); and age (require "age" — age file encryption with X25519 and scrypt recipients, interoperable with the reference age CLI, over go-ruby-age over filippo.io/age).

  • Documents & observability: prawn (require "prawn" — the Prawn PDF document DSL, emitting well-formed PDF 1.3+, over go-ruby-prawn over go-pdf/fpdf); bleve (require "bleve" — full-text search: index / mapping / query / facets, over go-ruby-bleve); and opentelemetry (require "opentelemetry" — distributed tracing: tracer / span / W3C context and the SDK exporter seam over the OpenTelemetry Go SDK, over go-ruby-opentelemetry).

  • AOT compiler (rbgo build): lowers a program's methods to native Go and links a specialised binary. Pure integer methods become unboxed int64 kernels with an overflow/÷0 deopt back to the interpreter — the generated fib(30) runs ~4× faster than MRI+YJIT while staying correct for every input. See docs/aot-compiler.md.

  • Closed-world binary (rbgo build --closed): bakes the whole program in as bytecode (and loads the prelude from frozen bytecode), then drops the lexer/parser/compiler from the link. The result runs with no source file and no front-end — a smaller, self-contained binary. rbgo build reports which eval/require calls (if any) would raise in the closed binary, since there is no front-end left to compile source at runtime.

  • WebAssembly: both targets build and are gated in CI: WASI (GOOS=wasip1 GOARCH=wasm) is built and run under wazero, and the browser target (GOOS=js GOARCH=wasm, the playground under cmd/wasm and the JS bridge) is built and run in node by web/smoke.mjs. See WebAssembly.

What does not work yet

Every entry below was re-checked on fc8ca1a (darwin/arm64, 2026-10-08) by running it against MRI 4.0.7 on the same host. Entries that no longer reproduced have been removed — two were, on that pass:

  • The two paragraphs saying the browser WebAssembly target was broken and ungated. Both were fixed by #684, which closed the three missing syscall constants and added the gate, so this file was describing a gap that a green lane in the same repository had been contradicting on every pull request. readme_wasm_lane_test.go now fails if the two disagree again — including, as it happens, if this very note quotes the old wording back.
  • "$stderr is not a separate stream". The VM takes two writers (vm.NewWithStderr), and rbgo -e '$stdout.print "O"; $stderr.print "E"' now sends one byte to each; warn goes to stderr. Covered by internal/vm/diagnostic_stream_test.go.

Timeout.timeout never times out. The module and Timeout::Error exist and rescue Timeout::Error resolves, but the block runs to completion and its value is returned: enforcing a deadline needs pre-emptive interruption of a running block, which the scheduler does not do yet.

$ rbgo -e 'require "timeout"; Timeout.timeout(0.3) { sleep 2 }; puts "returned"'
returned                 # MRI 4.0.7: Timeout::Error after 0.3s

This one fails open, which is why it is stated here rather than left in the table below. Timeout.timeout is the ordinary Ruby way to bound work that might hang, so code relying on it for that is unprotected under rbgo — and unprotected silently, because the call returns successfully. The block is also yielded nil where MRI yields the limit.

A backtick command holds the interpreter lock. While `cmd` runs, every other Ruby Thread is stopped; under MRI they keep running. Kernel#sleep does yield, so this is specific to the subprocess path:

$ rbgo -e 't = Thread.new { loop { $n = ($n||0)+1; sleep 0.01 } }; sleep 0.05
          b = $n; `sleep 1`; puts($n > b ? "ran" : "frozen")'
frozen                   # MRI 4.0.7: ran

Otherwise:

how it differs from MRI 4.0.5
RUBY_VERSION rbgo reports "3.4.1"; the differential oracle it is developed against is MRI 4.0.5
Numeric#to_int not defined — Complex(2.9, 0).to_int raises NoMethodError; MRI returns 2
File::Stat#== a == b is false for two stats of the same file (MRI: true); a.==(b) answers true
File#stat on a file unlinked while open raises Errno::ENOENT; MRI answers from the open descriptor. rbgo's streams are buffered by path and hold no descriptor
Errno all 158 of MRI's constant names are present, but 32 report errno 0 where MRI has a real number (EAUTH, EBADRPC, EDEVERR, …). Measured by set difference on darwin: 81 names are zero-valued under rbgo, 49 under MRI, and every MRI zero is also zero under rbgo. Go's portable syscall package does not expose the platform-only names
pp neither Kernel#pp nor require "pp" exists (require "prettyprint" does work)
Process.fork does not exist — Go's runtime cannot be forked safely
Kernel#fork exists, and does not fork. fork { ... } runs the block in the same process, so the “child” mutates the parent's globals, ENV and working directory, and returns a synthetic pid (100001, counting up) while Process.pid is unchanged. Under MRI the block runs in a real child and the parent is untouched. A SystemExit raised inside the block unwinds the whole program
Thread#backtrace for another thread raises NotImplementedError; rbgo keeps one frame stack per VM, not per thread

Platforms

.github/workflows/ci.yml is the authority; this is what it actually runs.

On every pull request:

Lane What runs
linux, macOS, windows the full suite under -race. The 100 % coverage gate is enforced on linux and macOS only — a few POSIX-only paths (opening /dev/null as a character device) are unreachable on windows, so its coverage is structurally below 100 %
amd64, arm64 go test ./... on native runners
wasip1/wasm built with CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm and run under wazero, a pure-Go runtime — verified here: wazero run rbgo.wasm -e 'puts (1..10).sum' prints 55
js/wasm (browser) CGO_ENABLED=0 GOOS=js GOARCH=wasm go build ./... plus go vet over ./cmd/wasm ./cmd/rbgo ./internal/vm, then the playground module is run in node by web/smoke.mjs (wasm (js, browser) in ci.yml). Added with #684, which fixed the three syscall constants the js port does not define
rbgo_core go vet and the internal/vm suite with -tags rbgo_core, then the core playground module is built for js/wasm, its size bounded, and run in node by web/smoke.mjs --core (rbgo_core (core build) in ci.yml). See the rbgo_core build

Not on the pull-request path — validated on a nightly schedule instead: riscv64, loong64, ppc64le and s390x, under QEMU (arch-qemu-nightly.yml) and natively on real hardware (native-arch.yml: the GCC Compile Farm, plus an IBM LinuxONE s390x host).

Every target named above is built by a lane on the pull-request path, including the browser one: an earlier version of this file said GOOS=js GOARCH=wasm had no lane and did not build, which stopped being true at #684 and stayed in the README afterwards.

Try it in one command

No checkout, no toolchain beyond Go itself:

$ go run github.com/go-embedded-ruby/ruby/cmd/rbgo@latest -e 'puts "hello from rbgo"; puts (1..10).sum'
hello from rbgo
55

(The first run compiles the interpreter, which takes a while and produces a large binary. @latest now resolves to the newest release tag rather than the tip of main — v0.1.0 as of this writing — so pass @main explicitly if you want unreleased work.)

Quick start

Requires Go 1.27.2+. From a checkout:

$ go build -o rbgo ./cmd/rbgo          # CGO_ENABLED=0 is the default here
$ ./rbgo -e 'puts 1 + 2'
3

Run a file:

$ cat > fib.rb <<'RB'
def fib(n)
  n < 2 ? n : fib(n - 1) + fib(n - 2)
end
puts fib(20)
RB
$ ./rbgo fib.rb
6765

rbgo build compiles a program's lowerable methods to native Go and links a specialised binary. It must be run from a checkout of this repository, because it shells out to go build against this module — run elsewhere it fails with stat .../cmd/rbgo: directory not found:

$ ./rbgo build -o fib fib.rb
rbgo build: fib (1 method(s) AOT-compiled: Object#fib)
$ ./fib fib.rb
6765

Add --closed to bake the program in as bytecode and drop the front-end entirely, giving a binary that needs no source file:

$ ./rbgo build --closed -o fib fib.rb
rbgo build: fib (1 method(s) AOT-compiled: Object#fib)
rbgo build: closed-world — front-end dropped (no lexer/parser/compiler linked)
rbgo build: binary size 172.5 MiB
$ ./fib
6765

Embedding in a Go program

The public API has two halves: Run, which runs one program on a fresh VM, and Interp, an interpreter that stays alive between evaluations.

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:

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

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.

$ go get github.com/go-embedded-ruby/ruby@v0.1.0

v0.1.0 is the first tagged release; before it the module was consumable only by pseudo-version. It is a v0.x, so the API carries no compatibility promise yet — pin the version rather than tracking @latest, and read the release notes, which list the open security issues rather than leaving them to be discovered.

package main

import (
	"bytes"
	"fmt"

	"github.com/go-embedded-ruby/ruby"
)

func main() {
	var out bytes.Buffer
	if err := ruby.Run(`puts "hello from Ruby"`, &out); err != nil {
		panic(err)
	}
	fmt.Printf("%q\n", out.String()) // "hello from Ruby\n"
}

The contract, as measured rather than intended:

  • One writer, both streams. There is no separate stderr. $stdout, $stderr and warn all land in out: running $stdout.print "O"; $stderr.print "E"; warn "W" fills the buffer with "OEW\n". If you need the two apart, you cannot get it through this API.
  • A Ruby exception becomes a Go error. Running raise ArgumentError, "bad" returns an error whose message is ArgumentError: bad, and writes nothing to out. A syntax error comes back the same way, with a line number: parse error at line 1: ....
  • Every call is a fresh VM — but the same process. The Ruby heap is not shared: a global, a constant and a String monkey-patch set in one Run are all gone in the next. Process-global state is shared, with the host and with every other Run: ENV, the working directory, open files, signal handlers and the filesystem. A script that runs ENV["PATH"] = "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/attacker/bin" and Dir.chdir("/tmp") changes them for the host Go program, and they stay changed after Run returns — measured: a host whose PATH was 860 bytes and 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. (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 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 LoadError: cannot load such file -- /tmp/lib.

The VM stays alive after Run returns as long as something still references it — which is what lets an event-driven embedded program keep running.

Run is not a sandbox

Conformance is the goal, and MRI is not sandboxed either — so neither is this. That is the intended behaviour and not a defect, but it is worth stating plainly, because MRI is normally a CLI a person invokes on their own code, whereas Run is a function someone links into a server. Do not pass untrusted Ruby to it. Measured, through the one public function and nothing else:

  • A shell. system, backticks, %x{}, IO.popen and Process.spawn all spawn real processes as the host's uid, and anything with a shell metacharacter goes to /bin/sh -c, so pipes, ;, $(...), globs and redirection all work.
  • The whole filesystem, read and write, wherever the host's uid can reach: File.read("/etc/passwd") returns it, Dir.glob("~/.ssh/*") lists 34 entries here, and reads and writes outside the working directory are unrestricted. File.symlink works, so a readable path can be pointed anywhere.
  • The network. A script opened a listening TCPServer, connected to it with TCPSocket and exchanged bytes, all inside one Run.
  • Any Ruby file on disk: require and load take absolute paths.
  • The host's environment and working directory, as described above.

Two things it does not reach, deliberately: there is no path from Ruby to the host's Go state (no RubyVM, ObjectSpace is a stub, fiddle does not load, a .so is refused, and CGO is 0), and the host's standard descriptors are not exposed — IO.for_fd(1) raises Errno::EBADF where MRI writes to the real descriptor, so a script cannot write outside the out you passed.

There is also no call-depth limit yet: runaway recursion overflows the Go stack, which is a fatal runtime error that recover() cannot contain, so it takes the host process with it. Tracked in #768.

WebAssembly

WASI (GOOS=wasip1 GOARCH=wasm) works and is gated in CI. The interpreter builds with cgo disabled and runs under any WASI runtime:

$ CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build -o rbgo.wasm ./cmd/rbgo
$ wazero run rbgo.wasm -e 'puts (1..10).sum'
55

The browser target (GOOS=js GOARCH=wasm) builds and is gated in CI (since #684). The wasm (js, browser) lane builds ./... for js/wasm, builds the playground module from ./cmd/wasm, and runs it in node with Go's own wasm_exec.js (web/smoke.mjs), asserting that Ruby — including an NDArray program — actually ran:

$ CGO_ENABLED=0 GOOS=js GOARCH=wasm go build -o web/rbgo.wasm ./cmd/wasm
$ node web/smoke.mjs web/rbgo.wasm

On any wasm target the gem backends that need real sockets or OS facilities are compiled out (grpc, nats, kafka, mysql2, mongo/bson, arrow, parquet, openstack, sidekiq, resque, …); require of one raises a clean Ruby LoadError rather than failing to link.

The rbgo_core build: the language and the numeric stack, no gems

-tags rbgo_core builds an interpreter that keeps the Ruby language and its numeric stack and leaves every gem binding out. It is the build for a page that runs Ruby in the browser — the go-ndarray playground loads it — and it works on every target, natively too:

$ CGO_ENABLED=0 GOOS=js GOARCH=wasm go build -tags rbgo_core -ldflags='-s -w' -o web/rbgo.wasm ./cmd/wasm
$ node web/smoke.mjs web/rbgo.wasm --core
cmd/wasm (js/wasm) default rbgo_core
unstripped 107.4 MB 28.2 MB
stripped (-ldflags='-s -w') 104.5 MB 27.5 MB
stripped, gzip -9 22.2 MB 6.4 MB
Go modules in the build (go list -deps) 256 47, no gem

Measured 2026-10-18 with go1.27.2, on go-ndarray v0.12.0 and go-numerics/{integrate,optimize} v0.2.0. The core column grew from 23.1 MB when the lane was added: Special, Stats, Integrate and Optimize cost +4.3 MB between them, of which Stats alone is 2.7 MB (36 distributions, 27,599 lines of Go) — the per-module attribution is in ci.yml beside the bound it moved, so the figure is answerable rather than absorbed.

What core is (internal/vm/core.go holds the same list as coreFeatures):

in core out of core
the language every core class and module MRI defines without a require: Object, Kernel, Comparable, Enumerable, the numeric tower, String, Symbol, Array, Hash, Range, Struct, Data, Proc, Method, Binding, Regexp, Encoding, the whole Exception tree, Time, Random, Marshal, File/IO/Dir/Process/Signal/ENV, Thread/Fiber, ObjectSpace, GC, Enumerator, TracePoint, … —
preloaded by MRI set, rubygems (Gem), did_you_mean, pp/prettyprint —
numeric stack Math, cmath, bigdecimal, prime, matrix, securerandom, NDArray (go-ndarray), FFT (go-fft), Special (go-numerics/special), Stats (go-statistics/stats), Integrate + Optimize (go-numerics), rbgo's bag Image (go-images)
small standard libraries json, date, csv, digest, base64, strscan, tsort, abbrev, benchmark, scanf, logger, forwardable, delegate, singleton, ostruct, pathname, tempfile, tmpdir, fileutils, find, etc, timeout, monitor, observer, stringio, English, rbconfig, objspace, fiber, fcntl, io/nonblock, unicode_normalize the ones that need a network, a terminal, a server or a document toolchain: openssl, socket, net/*, resolv, ipaddr, uri, yaml, erb, cgi, rexml, rss, rdoc, irb, reline, ripper, optparse, getoptlong, open3, syslog, shellwords, zlib, pstore
gems — every third-party gem: Rails and its frameworks, Rack/Sinatra/Hanami/Roda, the database, queue and cloud clients, the template engines, Nokogiri, Prawn, Sass, Rouge, Kramdown, the go-widgets bindings, RSpec/Minitest, Puppet, …

On the core build a require of anything outside core raises LoadError: cannot load such file -- rails (not available in the rbgo_core build). It raises before $LOAD_PATH is searched, so a rails.rb lying on the load path is not run in its place, and the gem leaves nothing behind: Rails, ERB and the rest stay undefined. Without the tag nothing changes.

How it is cut: a gem binding's file carries //go:build !rbgo_core, and the registrations, classOf cases, equality hooks and per-VM state that core code used to reach directly go through seams (internal/vm/gems*.go). Not calling a gem's register function was not enough: Go links the init of every package a compiled file imports, so the first attempt, which only skipped the calls, still weighed 64 MB.

Layout

cmd/rbgo/            CLI: run, build (+ build --closed [--target wasm]; repl later)
cmd/wasm/            GOOS=js GOARCH=wasm playground front-end (see web/) + native stub
cmd/aotgen/          regenerates the AOT differential suite (go:generate)
cmd/freeze-prelude/  regenerates the frozen prelude bytecode (go:generate)
web/                 browser playground: index.html, build.sh (rbgoEval/rbgoImage)
internal/
  compiler/          AST → bytecode (ISeq), local-slot resolution
  bytecode/          instruction set + ISeq
  vm/                stack-machine interpreter, arithmetic, builtins
                     (front-end isolated behind the rbgo_closed build tag)
  aot/               AOT compiler: bytecode → Go (level-1/3 kernels, FreezeISeq)
  object/            Value interface + concrete value types
docs/                plan-rbgo.md (the roadmap), aot-compiler.md

Testing & conformance

go test ./...
go test -coverpkg=./internal/... -coverprofile=cov.out ./internal/...
go tool cover -func=cov.out | tail -1

If a parent go.work is present, prefix commands with GOWORK=off.

ruby/spec ratchet

Behavioural conformance against Ruby's executable spec suite is tracked as a shrink-only ratchet. scripts/conformance/rubyspec/run.sh runs the ruby/spec language + core suites through rbgo under a minimal MSpec-compatible shim, then judges the sweep file by file against BASELINE: a per-PR CI lane fails if any spec file passes fewer examples than its recorded count, or stops loading. So language + core conformance is measured on every change and can only go up. It complements the differential oracle below: the ratchet is the absolute floor, the oracle catches divergences the specs don't cover.

Judged per file, so the gate and the measurement are the same number. Each of the 2,206 files is compared with its own record, which needs no margin and names whatever moved. A single frozen total could not do that: the largest spec file here carries 384 examples, so a scalar floor safe against one file failing to load would have been blind to every regression under 384 out of 23,471 — and a drop could not say whether one file had stopped loading or hundreds of specs had regressed.

scripts/conformance/rubyspec/run.sh          # judge against the baseline
UPDATE_BASELINE=1 scripts/conformance/rubyspec/run.sh   # re-record after a win
# point it at an existing corpus instead of cloning:
SPECDIR=/path/to/ruby-spec CACHE=/path/to/ruby-spec scripts/conformance/rubyspec/run.sh

CI layering

Per-PR CI is a fast native gate: the -race + 100 %-coverage suite on ubuntu/macos/windows plus native amd64 and arm64 lanes. Because rbgo is pure-Go (CGO=0, no assembly), the four exotic 64-bit targets (riscv64/loong64/ppc64le/s390x) are validated off the per-PR critical path — nightly under QEMU, and on real hardware via the GCC Compile Farm (ppc64le/riscv64/loong64) plus a LinuxONE s390x host. The real-hardware workflow is scheduled + secret-gated (inert until credentials are added) to respect the cfarm acceptable-use policy. See docs/CI.md.

Correctness is judged against independent reference implementations of Ruby 4.0 — MRI (CRuby) 4.0.5 and JRuby, with TruffleRuby being added as a third reference (conformance + performance). The differential oracle scripts/oracle.sh runs a snippet through rbgo, MRI and JRuby and flags any divergence:

scripts/oracle.sh -e 'p (1..10).select(&:even?).map { |x| x**2 }'

Beyond synthetic tests, the bar is real-world Ruby: idioms and test suites from reference applications — Ruby on Rails (ActiveSupport's pure-Ruby core_ext) and OpenVox/Puppet (Ruby-heavy manifest evaluation) — drive the remaining work by demand and double as conformance corpora and performance baselines (pure-Go CGO=0 vs CRuby's C, JRuby's JVM JIT and TruffleRuby's Graal). The heavyweight front-end (parse + compile) conformance results — Rails 99.82 %, Puppet 100 %, ~100 % of all valid Ruby parsed, plus the per-library table — are in CONFORMANCE-RAILS-PUPPET.md, CONFORMANCE-LIBRARIES.md and CONFORMANCE-RSPEC.md; reproduce with scripts/conformance/heavyweight/sweep.sh.

On top of the front-end sweeps, a ruby/spec ratchet runs the language/ and core/ suites of ruby/spec — the executable specification of the language — through rbgo under a minimal MSpec-compatible shim, and gates CI on a per-file baseline (scripts/conformance/rubyspec/, in BASELINE). No file may pass fewer examples than its record or stop loading, so measured language conformance moves in one direction; the total, 23,511, is a derived summary rather than the thing gated. Run it with scripts/conformance/rubyspec/run.sh, and see Runtime conformance under Status for the full breakdown.

Design & roadmap

See docs/plan-rbgo.md for the full architecture, the 9-phase plan (Phase 0 vertical slice → Phase 8 conformance & performance), the risk register, and the decision journal. The regexp engine is developed separately as a pure-Go reimplementation of Onigmo in go-ruby-regexp/regexp.

License

BSD-3-Clause. See LICENSE.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages