This is a Cargo workspace. It currently hosts one crate
(crates/rubyrs/) — the Ruby-subset
interpreter described below. A second crate, rubund (a Rust
implementation of Bundler), is planned and will be added as a
sibling under crates/. rubund is the first real driver of
rubyrs's embedding API — Gemfile and *.gemspec files are
Ruby DSLs, so the Bundler-in-Rust work doubles as in-tree
dogfooding of the interpreter.
A tiny Ruby-subset interpreter written in Rust, built on Prism.
class Greeter
def initialize(name)
@name = name
end
def hello
"Hello, #{@name}!"
end
end
["Ruby", "Rust", "Prism"].each { |w| puts Greeter.new(w).hello }$ rubyrs greet.rb
Hello, Ruby!
Hello, Rust!
Hello, Prism!
rubyrs is not a CRuby replacement. It targets the same niche as mruby: a small, memory-safe, embeddable Ruby-flavored runtime — but written in Rust, with the option of compiling to WebAssembly.
| End-to-end DSL hosting (Brewfile, ~50 lines) | rubyrs | CRuby 3.4 | CRuby + YJIT |
|---|---|---|---|
| Time | 1.8 ms | 74.7 ms | 75.5 ms |
→ rubyrs is 42× faster end-to-end on this shape of workload — the
actual product-niche benchmark. See
examples/brewfile/ for the
simpler tap/brew/cask DSL, or
examples/gemfile/ for an
unmodified Rails-style Gemfile (*splat, **kwargs, multi-symbol
group … do … end blocks, file-scope conditionals — all the
real-world shapes a Bundler Gemfile uses, running in ~0.4 ms
end-to-end).
| Cold start | rubyrs (native) | rubyrs.wasm (raw, JIT) | rubyrs.cwasm (AOT) | CRuby 3.4 |
|---|---|---|---|---|
puts 1+2 |
1.5 ms | 12.7 ms | ~7 ms | 78 ms |
The wasm column is the raw .wasm shipping shape under
wasmtime run; cwasm adds a one-time wasmtime compile
step (and is what perf/wasm_check.sh measures — see
docs/DEVELOPMENT.md for the build pipeline).
| 1M fizzbuzz | rubyrs | CRuby | CRuby + YJIT |
|---|---|---|---|
| Time | 0.33 s (1.76× of CRuby) | 0.19 s | 0.15 s |
| Peak memory | 2.1 MB | 18.4 MB | 19.1 MB |
| Method-heavy (Counter.inc × 1M) | rubyrs | CRuby (no JIT) |
|---|---|---|
| Time | 0.15 s (1.43× of CRuby) | 0.11 s |
If you need Rails, Sinatra, Bundler, or gems — use CRuby.
cargo build --release
./target/release/rubyrs your_script.rbPer-run resource caps (useful when running scripts you don't fully trust):
RUBYRS_FUEL=1000000 \
RUBYRS_MAX_OBJECTS=10000 \
RUBYRS_MAX_FRAMES=128 \
./target/release/rubyrs script.rbAny cap that trips returns a ResourceExhausted trap with a normal
backtrace (no host panic). See
docs/DEVELOPMENT.md for the full list of env
vars and the wasm32-wasip1 build instructions.
rubyrs is also a Rust crate: drop it into a Cargo.toml, build a
Runtime, and run scripts in process.
use rubyrs::{Config, Runtime, Value};
let mut rt = Runtime::with_config(Config {
// Resource caps for untrusted scripts. All optional; None = unlimited.
fuel: Some(1_000_000),
max_heap_objects: Some(10_000),
max_frames: Some(128),
..Default::default()
});
// Expose a host function to the Ruby side.
rt.register_fn("host_pid", |_args| {
Ok(Value::Int(std::process::id() as i64))
});
// Capture stdout into your own sink (defaults to process stdout).
// rt.set_stdout(Box::new(my_writer));
rt.eval(r#"puts "pid is #{host_pid}""#, "inline").unwrap();The runtime is incremental — class and method definitions persist across
eval calls, so you can split DSL setup and script execution into
multiple chunks. See
crates/rubyrs/examples/embed.rs
for the fuller story (captured stdout, persistent classes, Trap
propagation) and
crates/rubyrs/tests/embed.rs
for the pinned API surface.
Run the example:
cargo run --release -p rubyrs --example embedExperimental. See docs/SUBSET.md for what works today
and docs/ROADMAP.md for what's next. The testing
strategy — including our plan to ingest ruby/spec as the quality bar —
is described in docs/TESTING.md.
A second binary in this workspace, rubyrs-gapscan, scans a Ruby
codebase and classifies every AST node as supported, supported-via-
rides-along, or missing. Used as a quantitative quality bar against
real Ruby corpora. Running it against the in-tree Brewfile demo
(crates/rubyrs/examples/brewfile/) gives the canonical
"is the niche we claim to serve actually served?" number:
$ cargo run --release --bin rubyrs-gapscan -- scan crates/rubyrs/examples/brewfile
Files scanned: 2
Total AST nodes: 277
Supported: 195 (70.40%)
RidesAlong: 68 (24.55%)
Missing: 14 (5.05%)
Missing node classes:
GlobalVariableReadNode 10 ($taps)
GlobalVariableWriteNode 4 ($taps = [])
The "missing" 5% is two related nodes — global variables, used only
by the DSL host code (the Brewfile script body itself is 100%
supported). The CI workflow gapscan-pr.yml runs this against
representative corpora on every PR and posts a diff comment so
regressions land visibly.
- docs/SUBSET.md — supported and unsupported semantics
- docs/ARCHITECTURE.md — how the runtime works
- docs/BENCHMARKS.md — performance numbers + how to reproduce
- docs/TESTING.md — testing strategy and
ruby/specingestion - docs/ROADMAP.md — what's next and why
- docs/SECURITY.md — trust model, resource caps, and known attack surface
- docs/PANIC_AUDIT.md — inventory of every
panic!/unwrap/expectand how the CI ratchet works - docs/adr/ — Architecture Decision Records
- CONTRIBUTING.md — PR flow
Dual-licensed under either of
- MIT License (LICENSE-MIT or https://opensource.org/licenses/MIT)
- Apache License, Version 2.0 (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
at your option.