Skip to content

Latest commit

 

History

History
201 lines (157 loc) · 9.17 KB

File metadata and controls

201 lines (157 loc) · 9.17 KB

Roundhouse logo — a turntable at the center, six colored tracks radiating outward

Roundhouse

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.

Pipeline

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

Current state

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.

See it for yourself

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.

Workflow runner (bin/rh)

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 into downloads/<target>/.
  • bin/rh fixture — generate the Rails source fixture via rails new + scaffold.

Build (requires Rust):

  • bin/rh transpile <target> — build fixtures/real-blog into build/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.

Supporting pieces worth knowing

  • 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) — DatabaseAdapter trait behind which effect classification and async-suspension decisions live. SqliteAdapter / SqliteAsyncAdapter today; 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.

Running the tests

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.

Documentation

  • DEVELOPMENT.md — day-to-day dev loop, the roundhouse-ast debugging 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.

Prior art

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

Contributing

Issues and discussion are welcome. Architecture is still forming — a quick conversation before a PR is usually the most helpful path.

License

Dual-licensed under either of

at your option.