Rails as a specification; deployment is a build flag.
Roundhouse reads Ruby source — specifically, Rails applications — and produces standalone projects in other target languages. The deployment target (Rust or Swift binary, TypeScript bundle, Crystal or Go service, Elixir OTP app, Kotlin/JVM or C#/.NET service, Python project, browser bundle, or Spinel-compiled Ruby) becomes a compiler flag rather than a runtime choice.
A roundhouse is the circular hub in a rail yard where engines rotate and route onto different tracks. That's the pipeline shape: one Ruby source at the center, analyzed and dispatched to one of N target tracks.
For the case for doing this at all — the constraints that push successful Rails apps off CRuby, and the option value of preserving the choice — see WHY.md.
ingest analyze lower emit
Ruby ────▶ AST ─────▶ typed IR ────▶ IR ─────▶ target project
│ │
▼ ▼
diagnostics runtime/<target>/
Ingest normalizes Ruby + ERB into a small typed IR. Analyze annotates
every expression with a type and effect set, flowing types along the
edges Rails conventions already draw (schema → models, associations,
before_action, render → view, partials). Lower expands Rails-dialect
nodes into target-neutral IR — validations become Check enums, routes
become a flat dispatch table, controller bodies become a walker-ready
LoweredAction. Additional passes canonicalize controller idioms
(params.to_h, redirect_to, path helpers, association builders) and
query DSL (order, includes, where) into shapes each emitter
consumes directly. Emit walks the IR per target, consulting each
expression's type and effect where the target needs it, and each emitted
project links a small hand-written runtime/<target>/ library for the
bits that don't belong in generated code (DB connection, HTTP server,
Action Cable).
Diagnostics surface anything the analyzer couldn't type or
intentionally left gradual — the subset of programs we can transpile
is defined by "zero error diagnostics" (RBS-declared untyped sites
surface as warnings; strict-target emitters elevate to errors at emit
time).
The analyzer fully types the Phase-1 Rails 8 MVC fixture
(fixtures/real-blog) without annotations — schema-derived attributes,
associations, controller actions, before_action flow, views,
partials, and collection rendering all resolve to concrete types.
A test enforces zero error diagnostics on every commit. The framework
runtime (runtime/ruby/) is held to the same bar via
every_runtime_method_body_is_fully_typed — no inference gaps in any
method body.
Ten target emitters are live and DOM-equivalent against Rails on
real-blog as a CI invariant — Rust, TypeScript, Crystal, Elixir, Go,
Kotlin, Swift, Python, C#/.NET, and Spinel-shape Ruby. Each boots an
HTTP + Action Cable server, serves the generated blog with working
forms, validation error display, Turbo streams, and Tailwind styling. A
compare-<target> job in .github/workflows/ci.yml runs on every push
to main (JRuby is compared too, serving the same emit on the JVM), so
any drift turns CI red.
Cross-runtime correctness is enforced by tools/compare/, which
fetches the same URL from Rails and from any roundhouse-emitted runtime
and diffs the canonicalized DOM trees. A new ERB pattern that renders
differently between Rails and a target is a bug.
Meet the fixture. rubys.github.io/roundhouse/demo
describes the fixtures/real-blog app every target is built and tested
against — how scripts/create-blog scaffolds it, which Rails features
it exercises (associations, nested routes, Turbo Streams, Action
Cable, Tailwind), and the three test layers (per-target model/controller
tests, DOM-equivalence compare, and Playwright E2E).
Browse the emitted outputs. rubys.github.io/roundhouse/browse
shows what every target emitter produces from fixtures/real-blog,
updated on each push to main — Rust, TypeScript, Crystal, Elixir,
Go, Kotlin, Swift, Python, C#/.NET, plus Ruby and JRuby, and Spinel
(the lowered output that runs as the demo below).
Compare performance. rubys.github.io/roundhouse/bench
plots throughput, memory, latency, and req/sec/GB across the live
targets on the same fixtures/real-blog, run on a fixed Hetzner box.
Run the demo. A working transpiled blog — articles, comments,
real-time Turbo Stream broadcasts over WebSocket, SQLite persistence,
Tailwind styling, create + destroy flows — in two bin/rh commands:
git clone https://github.com/rubys/roundhouse
cd roundhouse
bin/rh fixture # generate the Rails fixture (~60s)
bin/rh dev ruby # transpile + assets + serve on :3000 (~3-5min cold)Run bin/rh doctor first to see which prerequisites are installed and
which subcommands are available without a Rust toolchain (bin/rh fetch <target> downloads pre-transpiled archives).
Prerequisites and the architecture of what gets generated:
runtime/spinel/scaffold/README.md.
bin/rh is the single entry point for every workflow below. Ruby is
the only prerequisite for the onboarding subcommands; the build
subcommands shell out to cargo. Run bin/rh --help for the full
surface and bin/rh <command> --help for per-command options.
Onboarding (no Rust required):
bin/rh doctor— check prerequisites; list which subcommands work today.bin/rh fetch <target>— download a pre-transpiled archive intodownloads/<target>/.bin/rh fixture— generate the Rails source fixture viarails new+ scaffold.
Build (requires Rust):
bin/rh transpile <target>— buildfixtures/real-blogintobuild/transpiled-blog-<target>/.bin/rh dev | test | run <target>— transpile, then run the emitted tree's dev/test/run action (ruby today).bin/rh compare [<target>]— fetch the same URL from Rails and the target, diff canonicalized DOM.bin/rh bench [<target>...]— HTTP throughput + RSS benchmark across targets.bin/rh site— build the full multi-target Pages site (the one linked above).
Cleanup: bin/rh clean <target | fixture>.
Targets: spinel, ruby, jruby, crystal, csharp, elixir, go,
kotlin, python, rust, swift, typescript, typescript-worker.
- Method catalog (
src/catalog/) — one IDL-shaped table declaring effect class, chain semantics, and return-type facets for every AR method the compiler recognizes. Single source of truth; replaced five scattered places. - Database adapter (
src/adapter.rs) —DatabaseAdaptertrait behind which effect classification and async-suspension decisions live.SqliteAdapter/SqliteAsyncAdaptertoday; Postgres / IndexedDB / D1 / Neon land as sibling impls. - Per-target runtimes (
runtime/<target>/) — hand-written glue (DB connection, HTTP, view helpers, Action Cable, test support) included verbatim by the matching emitter.
cargo test # unit + analyze + ingest + emit
cargo test --test real_blog # the Phase-1 forcing functions
cargo test --test rust_toolchain -- --ignored # Rust end-to-end boot
The real-blog fixture is generated on demand — bin/rh fixture runs
scripts/create-blog and materializes it under fixtures/real-blog/.
CI regenerates the fixture once per run and shares it across the unit
job and each per-target toolchain job.
DEVELOPMENT.md— day-to-day dev loop, theroundhouse-astdebugging tool, adding a new IR variant.docs/data/— the compiler's inputs, one doc each for Ruby + ERB, schema/routes/seeds, the method catalog, and the database adapter.docs/pipeline/— pipeline internals: analyze, lower, emit, runtime integration, verification.
- railcar — the Crystal-based predecessor; taught us which bets were worth keeping and where the shape needed to change.
- ruby2js — transpiles Ruby to JavaScript; originator of the filter/escape-hatch pattern for per-app transformations.
- Juntos — ruby2js extension that transpiles entire Rails apps; validated the multi-target ambition against Basecamp's Writebook.
Issues and discussion are welcome. Architecture is still forming — a quick conversation before a PR is usually the most helpful path.
Dual-licensed under either of
at your option.