intertwingly

It’s just data

Introducing Roundhouse


Before railways had engine depots in the modern sense, they had roundhouses: circular buildings with a turntable at the center and bays radiating outward. A locomotive rolled in through a single entry, the turntable spun, and the engine was routed onto whichever track led wherever it needed to go next.

It's a decent metaphor for what this project does. One Ruby source comes in, gets analyzed and typed at the center, and is dispatched down one of several target tracks — Rust, TypeScript, Elixir, Go, Python, Crystal. Same engine; different destinations.

The name also has a lineage. The predecessor was called railcar — a single vehicle that moved on a track. Roundhouse is where the railcars live: the hub that handles many of them, routes them, and sends them on their way.

What subset of Ruby we aim to support

Roundhouse reads Rails applications. Specifically, it reads the subset of Ruby that Rails apps actually use — plus some that most apps don't.

In:

Out:

The cut isn't "no metaprogramming." It's "no metaprogramming over runtime data."

Most Ruby metaprogramming looks runtime-dynamic but isn't. A class macro whose arguments are literal symbols at the call site is an expansion of known-at-compile-time code, even if it uses define_method and send to do its work. An iteration over attachment_reflections.each is a loop over a set declared by has_one_attached statements elsewhere in the class body — which the source already contains. The distinction matters for whether something can be transpiled; the next section walks through why.

Why that subset

Two reasons — one empirical, one architectural. The empirical one first.

An earlier post walked through running Basecamp's Writebook — a real Rails 8 application, not a demo — through Juntos, ruby2js's Rails-transpile layer. Writebook is an upper-end-of-the-curve Rails app: delegated types, Active Storage with attachment reflections, concerns with macro-generated methods, Turbo Streams broadcasts, bcrypt authentication, full-text search. It's the kind of code Basecamp writes — which is to say, it leans into the Rails doctrine of expressive metaprogramming rather than shying from it.

Writebook's user-written metaprogramming footprint: one send, one class macro. That's it.

The macro is positioned_within, in app/models/concerns/positionable.rb:

def positioned_within(parent, association:, filter:)
  define_method :positioning_parent do
    send(parent)
  end

  define_method :all_positioned_siblings do
    positioning_parent.send(association).send(filter).positioned
  end
  # ...
end

Three send calls, all dispatching on symbols captured from the macro's literal arguments. When a model calls positioned_within :book, association: :leaves, filter: :published, those symbols are known. A transpiler walks the macro body, substitutes the literals, and emits three explicit method definitions with the send calls replaced by direct dispatches. No dynamic dispatch survives to runtime.

The send is in app/models/leaf/editable.rb:

leafable.attachment_reflections.each do |name, _|
  new.send(name).attach(leafable.send(name).blob)
end

attachment_reflections is an Active Storage reflection method that returns the has_one_attached / has_many_attached declarations on the class. The set of attachments is fully determined at class-declaration time — each has_one_attached :cover contributes one entry. The .each iterates over a statically-known set; name is each attachment symbol in turn. A transpiler unrolls the loop by walking the class body, collecting the declarations, and emitting per-attachment code. Again, no dynamic dispatch survives.

Both cases look like runtime reflection. Both are statically determinable from the source code the transpiler can see.

If Writebook — at the upper end of Rails metaprogramming taste — has one send and one macro, the long tail of average Rails applications has even less. Public reports from Stripe's and Shopify's Sorbet adoptions both noted that the overwhelming majority of typical Rails application code types with minimal annotation. Most application-level metaprogramming lives in gems, not apps, and gems are handled separately — via dialect knowledge of the Rails framework itself, or via external type signatures (RBS). The subset we exclude is the rare residue.

That's the empirical case. The architectural case follows.

When metaprogramming is transpile-time resolvable, every method call in the IR has a statically-known target. That lets the analyzer type every expression — not just the literals, but the results of method calls, the variables bound through blocks and partials, the instance variables threaded across controller before_action callbacks into view templates. And if the analyzer can type every expression, typed target languages — Rust, Crystal, Go, TypeScript with strict inference — can emit correctly for each one.

Rails has been carrying a declarative type system in its DSL for twenty years. t.string :title declares a String-valued column. belongs_to :article declares a non-null Article association. validates :title, presence: true asserts a constraint. Routes declare the controller-action mapping. before_action :set_article, only: [:show, :edit, :update, :destroy] declares which ivars are in scope where. None of this requires annotation — it's already there, in the code the developer wrote.

Roundhouse's job is to read that structure. The subset we exclude is exactly the part that would break the inference — not because our algorithm is limited, but because the information isn't available until runtime. Everything else types.

Current status

Roundhouse started two days ago. Here's where it is.

The analyzer types a basic Rails 8 MVC fixture (two models, two controllers, before_action, views, partials, collection rendering, strong params) to completion, with zero annotations. Every expression — ivar reads, local variables, block parameters, partial locals, method dispatches — resolves to a concrete type. A test enforces this on every commit: if a diagnostic appears, the test fails. The subset is self-enforcing.

Six target-language emitters are scaffolded. None currently produces runnable output. That's deliberate — the foundations (typed IR, dialect channels, diagnostic infrastructure) landed first, because those are the invariants that are expensive to retrofit once downstream emitter code depends on the old shape. Day two is still the cheap time to shape the substrate.

The next concrete milestone is the first typed target producing runnable code against the MVC fixture — taking the generated Rust (or Crystal, or Go) through its real toolchain and confirming the output compiles, runs, and serves HTTP. When that lands, the "six targets scaffolded" line gets replaced with a more honest one.

Beyond that, the progression follows real applications. Writebook is one candidate for the next probe; others may follow. Each real app surfaces patterns the MVC fixture doesn't, which either extend the dialect (when the pattern is general) or surface a new forcing function (when it isn't). Either way, the improvements compound.

The source is at github.com/rubys/roundhouse. Dual-licensed MIT / Apache-2.0. Issues and discussion welcome.