The primary input to roundhouse is a Rails application directory. This doc covers how source languages arrive at the IR: Ruby, which the compiler reads directly, and the template engines — ERB is the worked example here — that compile to Ruby first.
*.rb ──────────────────► Prism ──► ingest::ingest_expr ──► Expr / App
▲
*.erb ──► compile_erb ──► Ruby ────────┤
(src/erb.rs) │
*.haml ──► compile_haml ──► Ruby ────────┘
(src/haml.rs)
Ruby is parsed by Prism via the
ruby-prism crate. Templates are compiled into Ruby source first, then
fed through the same Prism + ingest path — there is no second parser.
The ingester is a single switch over Prism node kinds with one arm per
supported ExprNode.
ingest_app(dir)— walks a full Rails directory (src/ingest/app.rs). Calls the per-concern helpers below: the classic order first — schema → models → controllers → routes → views → tests → fixtures → seeds — plus a growing set of secondary inputs (db/migrate/as a fallback whenschema.rbis absent,config/application.rband initializers,app/helpers/,config/routes/split files,config/importmap.rb,sig/**/*.rbssidecars, shared test helpers, …).src/ingest/app.rsis the authority on what the walk covers.ingest_model,ingest_controller,ingest_view,ingest_routes,ingest_schema,ingest_test_file,ingest_fixture_file— per-concern front doors, one module each undersrc/ingest/(seesrc/ingest/mod.rsfor the full roster). Each returns anIngestResult<T>. Whole-program helpers likeingest_ruby_program(used for seeds and fixture preambles) live insidesrc/ingest/expr.rsrather than in a module of their own.ingest_expr(src/ingest/expr.rs) — the core recursive descent. One arm per supportedExprNodekind.
Unsupported constructs return IngestError::Unsupported { file, message } rather than silently dropping them. "Loud by design" — a
missing arm is a signal that either the IR needs a new variant or the
recognizer needs to widen. See Adding a new IR
variant for the
development checklist.
That's the strict path, and it's the default. Survey mode —
roundhouse-check --continue, or ROUNDHOUSE_INGEST_SURVEY=1 —
records each gap and substitutes or skips instead of aborting, so one
unsupported construct doesn't hide every gap behind it. See
src/ingest/survey.rs (unwrap_or_record).
Expression-level round-trip (ingest → emit-ruby → ingest ≡ identity,
checked via roundhouse-ast --round-trip on Ruby input — it rejects
ERB) forces surface detail — which brace style an array used, whether
a call was parenthesized, whether a symbol array was written
[:a, :b] or %i[a b] — to live in the IR as a dedicated field
(e.g. ArrayStyle, parenthesized: bool, BlockStyle).
Whole-app source equivalence was retired as a goal: the Ruby emitter
consumes lowered IR only, and compile-equivalence via Spinel replaced
source-equivalence (see the header of src/emit/ruby.rs). The live
guards are tests/real_blog.rs (ingests_without_errors,
type_analysis_coverage) on the ingest side and
tests/lowered_ruby_emit.rs + tests/spinel_toolchain.rs on the
emit side.
Any time you're tempted to normalize at ingest, check first whether the emit side can reconstruct the original surface. If not, preserve the distinction in the IR rather than losing it.
Comments attach to the item they precede: every ControllerBodyItem
variant carries leading_comments: Vec<Comment> plus a
leading_blank_line flag — see Comment and ControllerBodyItem in
src/dialect.rs.
ERB's control-flow tags (<% if ... %>, <% each do %>, <% end %>)
are Ruby fragments interleaved with template text. Rather than write a
second parser that understands both, src/erb.rs::compile_erb produces
an equivalent Ruby source program:
<h1><%= article.title %></h1>
<% if article.comments.any? %>
<ul>...</ul>
<% end %>compiles to roughly:
_buf = ""
_buf = _buf + "<h1>"
_buf = _buf + (article.title).to_s
_buf = _buf + "</h1>\n"
if article.comments.any?
_buf = _buf + " <ul>...</ul>\n"
end
_bufThe compiled Ruby is handed to Prism like any other source file, and
the existing ingest pipeline takes it from there. <% %> control flow
becomes regular Ruby AST; views inherit every recognizer the
controller/model paths already have.
_buf = _buf + X, not_buf += X. The ingester already handlesLocalVariableWriteNode;LocalVariableOperatorWriteNodewould add a second path for no gain. Commit the simpler lowering.- Block-expression output tags —
<%= form_with(x) do |f| %>…<% end %>— use a compile-time block stack. The opener emits_buf = _buf + (form_with(x) do |f|(no closing paren); the matching<% end %>emitsend).to_s. Ordinary<% ... do %>blocks push aPassmarker and close with a plainend. See theBlockKindenum insrc/erb.rs. - Comment tags (
<%# ... %>) drop silently without flushing the pending-text buffer. That's what lets adjacent text chunks merge into a single string literal, which in turn lets round-trip succeed across comment-bearing ERB. - Erubi trim semantics. Under Rails' default trim mode,
<%-opens like a plain<%, a closing-%>on an output tag drops the trailing newline, and a code or comment tag alone on its line contributes nothing to the output — its indentation and its newline both vanish. See the trim rule insrc/erb.rs.
compile_erb_mapped returns the compiled Ruby plus a segment table
(ErbSegment) mapping compiled-Ruby byte ranges back to template byte
ranges; translate_spans re-anchors every span in the ingested IR to
template coordinates (both in src/erb.rs, applied by
ingest_template in src/ingest/view.rs). Diagnostics against a view
therefore point at the template, not at the synthesized _buf
program.
Byte-for-byte source round-trip of a template is not a checked
property (see "Surface preservation" above — the Ruby emitter is
lowered-IR-only). When a template ingests wrong, use
roundhouse-ast --stage compile-erb path.erb to see the compiler's
Ruby output, then --stage ingest (or --stages) to see where the
divergence lands in the IR.
ERB is the worked example, not the only engine. src/ingest/view.rs
owns the shared seam: ViewEngine maps a file extension (Erb,
Haml) to its compile function, and ingest_template runs the
common body — compile to Ruby, ingest, translate spans back to
template coordinates. Adding an engine = one ViewEngine arm + its
compile_*_mapped fn. HAML rides this today via
src/haml.rs::compile_haml_mapped (the disciplined subset Mastodon
actually uses, producing the same _buf output shape).
Jbuilder deliberately bypasses the seam: a .json.jbuilder source is
already Ruby, so src/ingest/app.rs dispatches it separately to
src/ingest/jbuilder.rs with no compile step.
*.md.erb templates use the ERB handler and the md format.
The lowerer names their methods <action>_md, separate from their HTML siblings.
An explicit render formats: :md or a request format of md selects the Markdown template.
The response uses text/markdown; charset=utf-8.
A YAML header remains ordinary template text. The compiler does not parse or remove it.
ERB expressions in the header and body use normal HTML escaping.
Expressions marked html_safe and literal template text remain unchanged.
The Markdown regression in tests/emit_and_run.rs
checks both render paths and the HTML sibling against emitted Ruby.
| File | What it does |
|---|---|
src/ingest/ |
Prism → IR; one module per concern (app, expr, model, controller, view, routes, …) — see src/ingest/mod.rs for the full list |
src/ingest/view.rs |
The template-engine seam (ViewEngine, ingest_template) |
src/ingest/survey.rs |
Survey mode — record gaps instead of aborting |
src/ingest/roda_app.rs |
Alternate front door for Roda apps (with src/ingest/sequel_model.rs + src/ingest/sequel_migration.rs); whole-app dispatch in src/ingest/app.rs |
src/erb.rs |
ERB → Ruby source, plus the span segment table |
src/haml.rs |
HAML → Ruby source (Mastodon subset) |
src/expr.rs |
Expr, ExprNode, surface-preservation fields |
src/dialect.rs |
Rails-level structures (Model, Controller, …) |
src/emit/ruby.rs |
Lowered-IR → spinel-shape Ruby (the emit-side partner) |
schema-routes-seeds.md— how particular Ruby files underdb/andconfig/feed non-code structures.../pipeline/analyze.md— what happens once the IR is built.../pipeline/verification.md— how the pipeline's output is verified.