Emit
Each emitter takes the analyzed, lowered IR and produces a complete, runnable project for one target language. The current architecture follows a lower-once, render N-ways bet: most logic moves into target-neutral lowerings that produce a universal IR; per-target emitters render that IR into idiomatic source.
Source: src/emit/<target>.rs plus src/emit/<target>/ — the
entry file and its submodule directory are ONE module, not two
generations. (Rust, Go, and Elixir were rewritten as strangler-fig
*2 modules behind thin shims; the rewrites took over and the *2
names were folded away in the 2026-08-19 rename — each module's
header records the lineage.) Targets: TypeScript (plus a
typescript-worker build via
typescript::emit_with_profile), Crystal, Rust, Go, Kotlin, Python,
Swift, C#/.NET, Elixir, Roda, and the Ruby family (Spinel / Ruby /
JRuby), which rides emit::ruby (emit_spinel / emit_library) plus
a verbatim walk of runtime/spinel/ — there is no emit::ruby::emit.
Target dispatch lives in src/project.rs::target_files;
src/emit/mod.rs holds only the module declarations and
EmittedFile. Generic Expr walkers live as <target>/expr.rs.
Cross-cutting helpers live in src/emit/shared/.
Status
Roundhouse has completed its migration from per-target derivation to
thin emitters that consume the universal post-lowering IR
(LibraryClass | LibraryFunction) for every runtime target except
Python. Spinel/Ruby, TypeScript, Crystal, Rust, Go, and Elixir run on
the thin path — the Rust/Go/Elixir strangler rewrites shipped, became
the default, and now own their targets' names outright — and
the newer Kotlin, Swift, and C#/.NET targets were built on it from
the start. Python's entire app — models, views (jbuilder included),
controllers, dispatch, route table, fixtures, and the LC-emitted
unittest suite — rides its overlay (src/emit/python/overlay.rs,
emitted under app/v2/ + tests/); the per-artifact emitters and
the CtrlWalker trait were deleted 2026-08-19
(docs/python-overlay-plan.md). Roda sits deliberately outside the
universal IR: it is a source-to-source Rails→Roda+Sequel converter
consuming the INGEST-shape App (see src/emit/roda.rs). Futamura sits
outside it too: its residue runs on Rails rather than on the runtime,
and at stage 0 it emits the source tree itself (see
specialization.md).
| Target | Models | Views | Controllers | Tests | Schema/Routes/Seeds | Notes |
|---|---|---|---|---|---|---|
| Spinel / Ruby | thin | thin | thin | thin | thin | Reference shape — drove the universal IR contract; Ruby emit collapsed to lowered-IR-only (2026-05-05) |
| TypeScript | thin | thin (function) | thin | thin | thin (function) | Rip-and-replace complete; tsc green sync + libsql under tests/typescript_toolchain.rs (2026-05-07) |
| Crystal | thin | thin (function) | thin | thin | thin | Rip-and-replace complete; compare-crystal 5/5 + framework_tests 8/8 (2026-05-06 → 2026-05-10) |
Rust (src/emit/rust/) |
thin | thin | thin | thin | thin | Strangler rewrite (rust2) landed 2026-05-20, renamed to rust 2026-08-19 |
Go (src/emit/go/) |
thin | thin | thin | thin | thin | Strangler rewrite (go2) shipped 2026-05-24; entry.rs assembles overlay + go.mod/root main |
Elixir (src/emit/elixir/) |
thin | thin | thin | thin | thin | Sole app-module path since Phase D; entry.rs emits the mix/Db/SchemaSQL shell around the V2.* overlay |
| Kotlin, Swift, C#/.NET | thin | thin | thin | thin | thin | Built on the universal IR from the start |
| Python | thin | thin | thin | thin | thin | Whole app rides the app/v2/ overlay (models, views incl. jbuilder, controllers, dispatch, fixtures, LC-emitted unittest suite); the per-artifact emitters are deleted |
| Roda | — | — | — | — | — | Ingest-shape source-to-source converter (issue #67); not on either path by design |
| Futamura | — | — | — | — | — | Residue runs on Rails; stage 0 is the identity (source tree, no IR) — see specialization.md |
"thin" = consumes the universal IR (LibraryClass or
LibraryFunction) from a *_to_library lowerer. "per-target" =
derives from Rails-shape IR directly (the form being torn down).
The universal IR contract
The bet (see project_universal_post_lowering_ir in auto-memory):
after lowering, every emitter sees the same shape — either a
plain class with explicit method bodies, or a free function with an
explicit module path. No Rails DSL surfaces past the lowerer
boundary.
ingest → analyze → lower → { LibraryClass | LibraryFunction } → emit
│
▼
LibraryClass: name, parent, methods (with receiver)
LibraryFunction: module_path, name, params, body
bodies: Expr (typed)
The two shapes are exhaustive. See lower.md for the
shape contract and which lowerers produce which shape.
Per-target shape dispatch
The IR commits to the semantics; each target picks the surface form that fits its language:
| Shape | Spinel/Crystal/Ruby | TypeScript | Python | Rust | Go | Elixir |
|---|---|---|---|---|---|---|
LibraryClass (with parent) |
class X < Y |
class X extends Y |
class X(Y): |
pub struct X + impl Y for X |
named struct + methods | defmodule X.Y (mixin via use) |
LibraryClass{is_module:true} (no parent, all class methods) |
module X |
(collapses to LibraryFunction) | (collapses) | (collapses) | (collapses) | defmodule X |
LibraryFunction |
module X; def self.f; end; end |
export function f in <x>.ts |
def f in <x>.py |
pub fn f in <x>.rs |
func F in <x>.go |
def f in defmodule |
TypeScript-specific note: TS doesn't have first-class
namespaces that span files. The function-per-file emit form needs
an aggregator (see below) so Views.Articles.foo() call sites
resolve through a single namespace import.
Aggregator pattern (TS-specific)
When an artifact spans multiple files (views: one per template) AND
consumers reach into it via dotted access (Views.Articles.show(x)),
the TS emit lays down a single aggregator file at the top of the
hierarchy:
// app/views.ts (the aggregator)
import { article as articles_article } from "./views/articles/_article.js";
import { index as articles_index } from "./views/articles/index.js";
// ...
export const Views = {
Articles: {
article: articles_article,
index: articles_index,
// ...
},
Comments: { … },
Layouts: { … },
};
Per-template files emit one export function each; the aggregator
re-imports them (with name-mangled aliases to avoid index from
articles colliding with index from comments) and assembles a
nested const whose key structure mirrors the Ruby module path
(Views::Articles::article → Views.Articles.article). Consumer
imports become import { Views } from "../../app/views.js"; the
existing call sites resolve unchanged.
The single-segment module artifacts (RouteHelpers, Importmap,
Schema, Seeds) need no aggregator — emit_module_file writes
all functions plus a trailing export const RouteHelpers = { … }
into one file at the canonical path.
Per-target emitter shape
A thin emitter's common trio is expr.rs / ty.rs / library.rs:
src/emit/<target>/
expr.rs — generic Expr → target syntax (the heavy lifter)
ty.rs — Ty → target type rendering
library.rs — universal IR walker:
- emit_class_file / emit_library_class (LibraryClass)
- emit_function_file (LibraryFunction)
- emit_module_file (LibraryFunction[] — same module_path)
- emit_views_aggregator (TS-specific)
- import resolution (TS: collect_imports /
collect_imports_for_function in
src/emit/typescript/library.rs; go has a
dedicated imports module)
- rewrite_for_class_method / rewrite_for_constructor /
rewrite_for_free_function (see below)
Beyond the trio the file set varies per target — compare
src/emit/crystal/ (adds method.rs, shared.rs) with
src/emit/go/ (adds imports.rs, paths.rs, plus the test emitters)
and src/emit/rust/ (splits expr into a directory and adds
decide/ for borrow/ownership decisions). Packaging is per-ecosystem
rather than a fixed package.rs: TypeScript, Kotlin, and C# carry a
package.rs; Python has pyproject.rs; Elixir's mix project lives in
src/emit/elixir/mix.rs.
expr.rs is the substantive per-target work — it embodies the target
language's expression-level semantics (operator dispatch, string vs.
symbol literals, hash key syntax, async suspension points). Even
under the rip-and-replace policy, expr.rs is the notable exception
worth incremental investment — it already encodes hard-won
target-specific knowledge that no lowerer can absorb.
The other files mostly walk a LibraryClass or LibraryFunction
and emit class/function syntax. They're small and replaceable.
Three rewrite modes for body emission
Body Exprs come out of the lowerer in receiver-less Ruby form (bare
foo(x) rather than self.foo(x)). The TS body emitter applies one
of three rewrites depending on the body's call context:
| Mode | What it does | When to use |
|---|---|---|
rewrite_for_class_method |
Bare Sends → this.method(...); Super { args } → super.<enclosing>(args) |
Instance methods on a LibraryClass |
rewrite_for_constructor |
Like class_method but leaves Super { args } intact (TS spells parent-constructor calls as super(args), not super.initialize(args)) |
The initialize instance method |
rewrite_for_free_function |
Walks for recursion only — no SelfRef injection, no super rewrite | All LibraryFunction bodies (views, route helpers, etc.) |
Kernel calls (raise, puts, print, p, pp) are exempt from
SelfRef injection in all three modes — they keep recv: None so the
emit_send special-cases (raise → throw, puts → console.log) can
fire.
Two-layer runtime
Each target ships with two layers of runtime (see runtime/<target>/
plus the transpiled framework runtime):
- Target primitives (hand-written, small): DB connection lifecycle, HTTP server glue, WebSocket plumbing — anything genuinely target-idiomatic that no IR-level lowering can capture.
- Framework runtime (transpiled from
runtime/ruby/): Rails-shape surface that emitted apps call into —ApplicationRecord,ActionController::Parameters,FormBuilder,link_to, etc. Authored once in Ruby; transpiled per target via the same roundhouse pipeline that compiles user apps.
This split is the architectural commitment behind the unified-IR Phase
1 plan: framework Ruby is a forcing function that any new target must
support. If your emitter can transpile runtime/ruby/, it can
transpile a real Rails app.
For TS, the runtime files ship under the emit output's src/
directory. The authoritative inventory is the include_str! table at
the top of src/emit/typescript.rs: the _base / helper files are
emitter-generated from runtime/ruby/, the rest are hand-written
primitives copied from runtime/typescript/. The
DeploymentProfile selects among db/server variants (db.ts /
db-libsql.ts / db_worker.ts, server.ts / server-libsql.ts /
server-worker.ts) and adds worker-profile extras (client.ts, the
juntos-worker.ts bridge). juntos.ts is the worker bridge entry
point. See runtime.md for the per-target runtime shape.
Generated TS project layout
A complete TS emit for the real-blog fixture:
package.json, tsconfig.json
main.ts — boot shell (Schema + Seeds + startServer)
src/ — framework runtime (see above)
app/
models/<model>.ts — one LibraryClass per file
controllers/<controller>.ts — one LibraryClass per file
views.ts — aggregator namespace const
views/<dir>/<template>.ts — one LibraryFunction per file
route_helpers.ts — RouteHelpers module file
routes.ts — flat route table
importmap.ts — Importmap module file
db/
seeds.ts — Seeds.run module file
test/
_runtime/{minitest,setup}.ts — test runtime adapter
fixtures/<plural>.ts — one LibraryClass per fixture file
<model>.test.ts — one LibraryClass per test class
<controller>.test.ts — controller tests (LibraryClass)
Path conventions are TS-specific; other targets pick layouts that
match their ecosystem (src/main/<target> for Java-shape targets,
top-level for Go's flat package model, etc.).
Working policy: rip-and-replace
When an emitter's shape is wrong relative to the universal IR, the working policy is rebuild from a clean design, not refactor in place. Experience: a fresh emitter against the universal IR shape takes ~1 week; incrementally evolving the existing one takes considerably longer because every step must keep the existing toolchain test green.
Practical consequences:
- Disable the target's CI gate during migration.
- Build the new emitter as a strangler-fig
<target>2module alongside the old one, behind the stablesrc/emit/<target>.rsentry point — the pattern rust, go, and elixir all followed. The public identity (crate::emit::rust::emit, the--targetCLI surface) never moves; the entry file shrinks to a shim once the 2-module carries everything. Temporary migration flags should leave with the old path; inspect the actual readers rather than assuming a historical flag still exists. - Flip the default once the new path is green; delete the old.
expr.rsis the exception — port forward, don't rewrite from scratch.
Ecosystem files (Cargo.toml, package.json, shard.yml,
mix.exs, pyproject.toml) carry no semantic divergence; they're
copied/templated and don't need rip-and-replace treatment.
How emit reaches the file system
pub struct EmittedFile {
pub path: PathBuf,
pub content: String,
}
pub fn emit(app: &App) -> Vec<EmittedFile>;
Most src/emit/<target>.rs entry files expose emit(app) returning a
flat list; the Ruby family instead exposes emit_spinel /
emit_library, which src/project.rs combines with a verbatim walk
of runtime/spinel/. The BuildTarget → emitter mapping is
centralized in src/project.rs::target_files. Callers
(bin/roundhouse — both --target LANG and --site modes — plus
the toolchain tests) write each EmittedFile to disk.
Public surface re-exported from src/emit/<target>.rs
The surface varies per target rather than being a uniform contract:
| Symbol | Role |
|---|---|
emit(&App) -> Vec<EmittedFile> |
Main entry — full project emission. Most targets; the Ruby family exposes emit_spinel / emit_library instead |
emit_method(&MethodDef) -> String |
Standalone typed-method renderer for runtime extraction — present on crystal, go, python, ruby, rust, typescript |
emit_library_class(&LibraryClass) -> Result<String> |
Class-shape renderer; public on most thin targets for tests + cross-target tooling |
emit_library_function(&LibraryFunction) -> Result<String> |
Function-shape renderer — TypeScript-only |
<target>_ty(&Ty) -> String |
Type renderer. The ty submodules are mostly private (mod ty;); a handful re-export (typescript::ts_ty, python::python_ty) and go/rust are pub(crate) |
Per-target type rendering
The three special Ty variants render differently per target (cells
from the <target>/ty.rs renderers — ts_ty, rust_ty, go_ty,
crystal_ty, python_ty):
| Variant | TS | Rust | Go | Crystal | Python |
|---|---|---|---|---|---|
Ty::Var(_) |
any |
serde_json::Value |
interface{} |
String |
object |
Ty::Untyped |
any |
serde_json::Value |
interface{} |
String |
Any |
Ty::Bottom |
never |
! |
interface{} |
NoReturn |
Never |
The Ruby family renders types only into the RBS sidecars
(emit::ruby's ty_to_rbs); Elixir emits untyped Elixir and has no
ty module.
No target elevates Untyped itself to a compile error — even the
strict targets commit to a permissive rendering (serde_json::Value,
interface{}). What actually guards the gaps is the emit diagnostics
sink (src/emit/diagnostics.rs): an unsupported construct both
records a Diagnostic and degrades to a target-appropriate
raise/panic/throw stub at that site. The cost of Untyped still
lands differently per target (see
project_ty_untyped_target_dependent) — a bag type that TS absorbs
silently prices every downstream operation in Rust.
Adding a new target
The lowerers-first bet says: most of the work has already been done. A new target needs:
src/emit/<new>/expr.rs— the per-target expression renderer.src/emit/<new>/ty.rs— type rendering.src/emit/<new>/library.rs— universal-IR walker:emit_class_fileforLibraryClassemit_function_file(andemit_module_fileif multi-function module files are idiomatic)- an import-resolution pass mapping cross-artifact Const refs to
import lines (the models:
collect_imports/collect_imports_for_functioninsrc/emit/typescript/library.rs, or go'simportsmodule)
runtime/<new>/— target primitives only (DB, HTTP). Framework runtime comes free via transpilingruntime/ruby/.tests/<new>_toolchain.rs— the verification gate.
Models-first within a target: models are independent and exercise most of the type-system surface. Web trio (controllers + views + routes) is coordinated and should land together.
Aggregator decision: if the target has first-class namespaces that
span files (Crystal modules, Elixir's defmodule re-opens, Ruby's
open classes), no aggregator needed. If not (TS, Python, Rust, Go),
emit a per-app aggregator file or use the target's namespace
mechanism (Python's __init__.py, Rust's mod.rs).
Key files
| File | Role |
|---|---|
src/emit/mod.rs |
EmittedFile + module declarations (dispatch lives in project.rs) |
src/project.rs::target_files |
BuildTarget → emitter dispatch, plus the Ruby-family runtime walk and tree-shake |
src/emit/<target>.rs |
Per-target entry + emit() pipeline |
src/emit/<target>/expr.rs |
Generic Expr walker (the heavy lifter) |
src/emit/<target>/ty.rs |
Ty rendering |
src/emit/<target>/library.rs |
Universal-IR walker + import resolution |
src/emit/diagnostics.rs |
Thread-local diagnostic sink — unsupported constructs self-report and degrade to a raise/panic stub |
src/emit/typescript/{js_ast,printer,sourcemap}.rs |
TS's JS-AST layer: build an AST, print it, carry source maps |
src/emit/rust/decide/ |
Rust's borrow/ownership decision passes (last-use, parens, string coloring) |
src/emit/shared/ |
Cross-cutting helpers (binop classifiers, schema SQL renderer, etc.) |
src/lower/{model,view,jbuilder,controller,test_module,fixture}_to_library/ |
LibraryClass producers |
src/lower/{routes,importmap,schema,seeds}_to_library/ |
LibraryFunction producers |
Related docs
lower.md— target-neutral lowerings; the producers ofLibraryClassandLibraryFunction.analyze.md— typed IR that lowering consumes.runtime.md— per-target runtime layer.verification.md— toolchain tests + DOM equivalence gate.