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
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>.
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/andcore/are swept. ruby/spec'slibrary/,optional/andsecurity/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, someargf/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 runA 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
.rbfiles). 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
.rbfiles — 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 realpuppet applyCLI runs end-to-end under rbgo (see Running Puppet below), convergingnotify,fileandexecresources; the broader resource providers (package/servicehost 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.
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-basedException#backtrace/set_backtrace/full_message, interpolated regexp literals, non-local blockreturn,Symbol#intern,NilClassconversions,Arrayslice-assignment,Module.new(real anonymous module running its block as a body),extendtransitivity (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 realOptionParser(optparse),File::Stat/FileTest+ on-disk filesystem operations,objspace, and more — eachrequire-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 applyCLI convergingnotify,fileandexecresources through the full pipeline (boot → parse → compile → transaction / RAL → output → clean exit). The next frontier is the broader resource providers —user/groupare provider-ready, whilepackage/serviceneed 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 (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 literals0x/0o/ bare-0-octal/0b/0dwith underscores), floats, strings, symbols (incl. operator-method symbols:+/:<</:[]=/:<=>, usable withreduce(:+)/inject/send(:+, x)), arrays, hashes, ranges (incl. beginless/endless),ComplexandRationalnumbers,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. modifierrescue,expr rescue fallback),begin/rescue/else/ensure/retry,break/next,Kernel#loop, andFiber(cooperative coroutines —Fiber.new/resume/Fiber.yield/alive?/transfer, plusFiber#raise(raising into a suspended fiber),Fiber#kill, fiber storage (Fiber[]/Fiber[]=) andFiber#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?) andQueue(blockingpush/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#backtraceanswers for the current thread; asking another thread for its backtrace raisesNotImplementedError, 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::Statusthrough$?),kill, process groups (getpgid/setpgid/getpgrp), scheduling priorities (getpriority/setpriority) and resource limits (getrlimit/setrlimitwith theRLIMIT_*constants), pluspid/ppid/uid/gid/euid/egid. There is noProcess.fork(Process.respond_to?(:fork)isfalse) — Go's runtime cannot be forked safely;spawnis the supported way to start a child.Kernel#forkis 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=> namebinding suffix, guards (if/unless), the one-line formsexpr => patternandexpr in pattern, andNoMatchingPatternError— via thedeconstruct/deconstruct_keysprotocols. - 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/&blockparameters, 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…endclosures,yield,block_given?,&blockcapture, block params with destructuring (|(a, b)|) and rest (|*rest|,|head, *rest|), numbered params (_1/_2) andit,Proc/lambda/stabby->(){},&procblock-pass andSymbol#to_proc(the&:symshorthand). - 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-chainsuperthrough 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 — includingdefined?(super), which answers"super"only when an ancestor actually defines the method —module_function, stringeval(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 hooksinherited/included/prepended/method_added/extended. - Runtime loading:
require/require_relativeload, compile and run a.rbfile once (relative + search-path resolution,LoadErroron miss,true/falsereturn) — the embedded front-end loading code at runtime — andautoload/autoload?on bothObjectand anyModule, 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/$stdinand$>;$:/$LOAD_PATH/$-Iand$"/$LOADED_FEATURES;$?;$.and$_;$VERBOSE($-v/$-w) and$DEBUG($-d) — both readingfalse, notnil, 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), andtrace_var/untrace_varhook assignment to a global. - Strings: mutable (reference semantics) with
<</concat/replace/prepend/ insert/[]=/slice!/the bang methods andfreeze/FrozenError; interpolation, heredocs (<</<<-/<<~),%w/%iand%q/%Q/%W/%Iliterals, the\a/\b/\v/\f/\s/\n/\t/\r/\e/\0escapes,%/format/sprintf, case/strip/split/each_char/lines/succ(next) and friends. - Regular expressions:
/re/imxliterals,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) andRegexp.new(src, timeout:)(per-Regexp, overriding that default in either direction, as MRI does) are both enforced, and a match that exceeds its limit raisesRegexp::TimeoutErrorrather 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, andZlib(crc32/adler32+Deflate/Inflate) — eachrequire-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), raisingErrno::ENOENTfor missing paths;Random— a bit-exact reimplementation of MRI's seeded MT19937, soRandom.new(seed)/srand+randreproduce MRI's sequence. - IO:
IOwith$stdout/$stderr/STDOUT/STDERR/$stdinas 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), andKernel#warn.Kernel#puts/print/pwrite through the current$stdout, so reassigning it to aStringIOcaptures output, as in MRI.File.open(modesr/w/a/r+, block-scoped auto-close) returns a file-backed IO (File<IO) with the same read/write protocol, plusFile.readlines/File.foreach. Dir:entries/children/glob/[],exist?/empty?,pwd/home,mkdir/rmdir/chdir(block-scoped),each_child/foreach, raisingErrno::ENOENT/Errno::EEXISTas 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#reopenandset_encoding/external_encoding. - Filesystem predicates and canonical paths:
File.realpathandFile.realdirpath(symlink and..resolution), andFileTest— 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 andworld_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/nulland/dev/disk0: 252 assertions, 0 disagreements. Errno: MRI's full 158-constant table, with the same class-per-number identity —Errno.constants.sizeis 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/…) andComparable, both written once in embedded Ruby; Array bang methods (map!/sort!/select!/reject!/compact!/uniq!/reverse!), structural/combinatorial ops (transpose/product/combination/to_h), theHash[…]constructor, String ranges (("a".."e")iterating viaString#succ), andRange#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 anEnumerator(MRI semantics) withnext/peek/rewind/size/to_a,with_index/each_with_index,Kernel#enum_for/to_enum, and fullEnumerablechaining ([1,2,3].map.with_index { |x, i| … }); plusEnumerator::Lazy(lazy) — deferredmap/collect,select/filter,reject,filter_map,flat_map/collect_concat,grep/grep_v,zip,uniq,compact,with_index,take/take_while,drop/drop_whileover finite or infinite ((1..Float::INFINITY).lazy) sources, chained without materialising and pulled byfirst(n)/each/to_a/force. - Numeric tower:
Integer/Float/Rational/Complexunder a sharedNumeric(carryingComparable);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
Mathmodule (sqrt/cbrt/exp/log/log2/log10, the trig and hyperbolic functions,atan2/hypot/pow) withMath::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; viewsreshape(with-1)/transpose/T/flatten/squeeze/expand_dims, anddup/copy(a copy of the data); NumPy basic indexing througha[...]— an Integer,truefor:, a Range (1...3,1..,...3; inclusive ranges too) or an ArithmeticSequence ((0..) % 2,(-1..) % -1for::-1); element-wise+ - * / **with broadcasting (a number on either side),sqrt/exp/log/sin/cos/abs/square/clip,NDArray.maximum/minimum; reductionssum/mean/max/min/prod/argmax/argmin(whole array, or(axis, keepdims: true)) andcumsum;matmul/dot/NDArray.outer,NDArray.concatenate/stack(axis:);shape/ndim/size/to_a(flat), andp aprints NumPy's repr (array([[0., 1.], ...])). Every argument is checked: a wrong count or type raisesArgumentError/TypeError, an index or axis out of rangeIndexError. - Image: a scikit-image-style image processor —
Image.new/load/save, pixelget/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
FFTmodule — 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, anumpy.fft-style transform with no cgo / no FFTW, returningComplexspectra. - Special: a
Specialmodule — all 67 functions of go-numerics/special, ascipy.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 scaledi0e/i1e/k0e/k1e, arbitrary orderjv/yv/iv/kv/ive/kve, integer orderjn/yn/in/kn, andjn_zeros) and the complete elliptic integrals (ellipk/ellipkm1/ellipe). Each is a ufunc, as scipy's is: a Float answers a Float, anNDArrayanswers anNDArrayof the same shape, and a Float mixed with anNDArraybroadcasts. Most names are scipy's own, taken from the correspondence the Go module measures itself against — which is whysoftplusandgammaln, 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:gammasgnandknexist in scipy but are unpaired in the module,log_factorialis scipy'sgammaln(n+1),loggamma_signhas no scipy counterpart, andinis not a scipy name at all — scipy cannot have one,inbeing a Python keyword, soiv(0, x)is the faithful spelling andinanswers exactly the same. - Stats: a
Statsmodule — the 32 univariate distributions of go-statistics/stats, ascipy.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, thenloc, thenscale(continuous) — each optional with scipy's default. Every distribution answers the same method set,Stats::ContinuousandStats::Discretediffering only in the density:pdf/logpdfagainstpmf/logpmf, pluscdf/logcdf/sf/logsf/ppf/isf, the momentsmean/var/std/skew/kurtosis/median/entropy/moment,interval/support, andrvs(size…, seed:)— a Float with no size, anNDArraywith one. The per-point methods are ufuncs too, over a Float or anNDArray. A parameter outside its domain is refused at construction withArgumentError, where scipy raisesValueError. - Integrate: an
Integratemodule — scipy.integrate's quadrature from go-numerics/integrate, with no cgo / no SciPy: adaptivequad(finite or infinite limits,points:for a known kink,epsabs:/epsrel:/limit:), the fixed rulesfixed_quadandromberg(tol:/rtol:/divmax:), the sampled rulestrapezoid/simpson/romb/cumulative_trapezoid(x:ordx:,initial:), the multiple integralsdblquadandnquad, andgauss_legendrefor a rule's nodes and weights. The integrand is a block, which also takes&ffor a Proc or lambda;dblquad's inner limits may be numbers or callables. - Optimize: an
Optimizemodule — scipy.optimize's root finders and minimisers from go-numerics/optimize, with no cgo / no SciPy: the bracketing findersbrentq/brenth/bisect/ridderandnewton(secant, Newton–Raphson withfprime:, Halley withfprime2:),minimize_scalar(method:brent/golden/bounded,bracket:,bounds:) andfminbound,minimizeover several variables (method:BFGS/CG/Nelder-Mead/Powell/L-BFGS-B, withbounds:),curve_fit,approx_fprimeandbracket. Return shapes are scipy's: one number answers a Float, a tuple an Array (curve_fitis scipy's(popt, pcov)), and anOptimizeResultorRootResultsa Hash with symbol keys — whichfull_output: trueasks 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 ownobject.Hash, exactly as MRI'sset.rbis 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,+/-/<</>>andnext_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,Resultand preparedStatement, over go-ruby-mysql, a pure-Go mysql2 overgo-sql-driver/mysql);mongo+bson(require "mongo"—Mongo::Client/Database/Collection/Cursorwith orderedBSONdocuments andBSON::ObjectId, over go-ruby-mongodb);etcd/etcdv3(require "etcd"— the etcd v3 KV client: get/put/txn/lease/watch/lock, over go-ruby-etcd); andbolt(require "bolt"— the embedded bbolt B+tree store:DB/Tx/Bucket/Cursor, files interoperable with reference bbolt tooling, over go-ruby-bbolt overgo.etcd.io/bbolt). -
Messaging:
nats(require "nats"— NATS publish / subscribe / request with headers, over go-ruby-nats); andkafka(require "kafka"— a ruby-kafka-faithful producer / consumer / admin client, over go-ruby-kafka overtwmb/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-compatibleencode/decode/encode_json, well-known types and the descriptor pool, over go-ruby-protobuf);arrow(require "arrow"— Apache ArrowDataType/Field/Schema/Array/RecordBatch/Table, over go-ruby-arrow); andparquet(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 drivingapp.callunder the emulated GVL, over go-ruby-puma);graphql(require "graphql"— a graphql-ruby-flavoured type system + query execution preserving the exactdata/errorsshape, over go-ruby-graphql);saml/ruby-saml(require "saml"— SAML SSO auth requests / responses / metadata / logout, exposed asSAMLandOneLogin::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 EncryptAcme::Clientaccount / order / authorization / challenge / certificate flow, over go-ruby-acme);rbnacl(require "rbnacl"— NaCl / libsodiumSecretBox/Box, Ed25519 / Curve25519 keys, AEAD, hashing and password hashing, over go-ruby-sodium); andage(require "age"— age file encryption with X25519 and scrypt recipients, interoperable with the referenceageCLI, over go-ruby-age overfilippo.io/age). -
Documents & observability:
prawn(require "prawn"— the Prawn PDF document DSL, emitting well-formed PDF 1.3+, over go-ruby-prawn overgo-pdf/fpdf);bleve(require "bleve"— full-text search: index / mapping / query / facets, over go-ruby-bleve); andopentelemetry(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 unboxedint64kernels with an overflow/÷0deopt back to the interpreter — the generatedfib(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 buildreports whicheval/requirecalls (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 undercmd/wasmand theJSbridge) is built and run in node byweb/smoke.mjs. See WebAssembly.
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
syscallconstants 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.gonow fails if the two disagree again — including, as it happens, if this very note quotes the old wording back. - "
$stderris not a separate stream". The VM takes two writers (vm.NewWithStderr), andrbgo -e '$stdout.print "O"; $stderr.print "E"'now sends one byte to each;warngoes to stderr. Covered byinternal/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.3sThis 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: ranOtherwise:
| 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 |
.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.
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.)
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'
3Run 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
6765rbgo 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
6765Add --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
6765The 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.BlockEverything 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.
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.
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.
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 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.0v0.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,$stderrandwarnall land inout: 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. Runningraise ArgumentError, "bad"returns an error whose message isArgumentError: bad, and writes nothing toout. 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
Stringmonkey-patch set in oneRunare all gone in the next. Process-global state is shared, with the host and with every otherRun:ENV, the working directory, open files, signal handlers and the filesystem. A script that runsENV["PATH"] = "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/attacker/bin"andDir.chdir("/tmp")changes them for the host Go program, and they stay changed afterRunreturns — measured: a host whosePATHwas 860 bytes and whose cwd was its build directory came back with a 13-bytePATHand a cwd of/private/tmp. Becauserequire_relativeresolves against that same working directory (next bullet), oneRuncan also write a file that a laterRunloads. (Holding a VM open, pre-loading code, calling Ruby from Go and passing Go values in is whatInterpis for.) - A program that exits is reported as success.
exit,exit!andabortstop the program andRunreturnsnil, so an embedder cannot tell “finished” from “stopped”:abort "fatal"writesfataltooutand reports no error, andexit 3loses the 3. Only a Ruby exception becomes a Go error. TherbgoCLI recovers the status throughinternal/vm, which is unreachable from outside, soRuncannot report it;Interp.Evalreturns it as an*ErrorwhoseExitStatus()holds it. require_relativeresolves against the process working directory, not against a script, becauseRunhas no file to anchor to. From a different working directory the same program fails withLoadError: 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.
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.popenandProcess.spawnall 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.symlinkworks, so a readable path can be pointed anywhere. - The network. A script opened a listening
TCPServer, connected to it withTCPSocketand exchanged bytes, all inside oneRun. - Any Ruby file on disk:
requireandloadtake 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.
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'
55The 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.wasmOn 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.
-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 --corecmd/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.
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
go test ./...
go test -coverpkg=./internal/... -coverprofile=cov.out ./internal/...
go tool cover -func=cov.out | tail -1If a parent go.work is present, prefix commands with GOWORK=off.
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.shPer-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.
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.
BSD-3-Clause. See LICENSE.
