Skip to content

linyiru/rubyrs

rubyrs (workspace)

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.

rubyrs

CI License: MIT OR Apache-2.0 Rust Status: experimental

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!

Positioning

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.

Build

cargo build --release
./target/release/rubyrs your_script.rb

Per-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.rb

Any 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.

Embedding

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 embed

Status

Experimental. 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.

Subset coverage (gapscan)

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

License

Dual-licensed under either of

at your option.

About

A tiny Ruby-subset interpreter in Rust, built on top of Prism

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors