Wado

Wado Compiler

The Wado compiler (wado-compiler/) translates .wado source into a Wasm component binary. This document gives a high-level overview of the architecture; deeper topics live in their own docs:

Pipeline

Source (.wado)
  → Lex → Parse → Bind                    (per module, in loader)
  → Annotate (Analyze + Resolve + lower TIR)
  → Default-purity Check
  → Synthesis (auto-derives, template, From, serde, pre-CM effect dispatch, CM bindings)
  → Effect Check → Stores Check
  → Effect Dispatch (post-check: WithHandler / Resume desugaring)
  → Link (Package → FlatPackage)
  → Monomorphize → Erase Newtypes & Flags
  → Lower
  → Optimize
  → WIR Build → WIR Optimize → Codegen
  → Wasm component bytes

The driver is compile_after_load in src/lib.rs.

Phase Output Module(s)
Lex / Parse AST lexer.rs, parser.rs, token.rs, syntax.rs
Bind AST + bindings bind.rs
Loader All modules loader.rs
Annotate TIR + facts semantics.rs, analyze.rs, elaborator/
Default-purity Check (validation) effect_check.rs::check_default_purity
Synthesis TIR (extended) synthesis/
Effect / Stores TIR (validated) effect_check.rs
Effect Dispatch (post) TIR synthesis/effect_dispatch.rs
Link FlatPackage link.rs
Monomorphize FlatPackage monomorphize/
Lower NirPackage lower/
Optimize NirPackage optimize/
WIR Build WirPackage wir_build/
WIR Optimize WirPackage wir_optimize/
Codegen Component bytes codegen/

Compilation Units and IRs

Unit Layer
Module (ast.rs) Surface AST. Preserves source-level syntax to support wado format.
TirModule (tir.rs) Typed IR. One per source module after annotate.
Package (package.rs) Per-module compilation context, used from synthesis through link.
FlatPackage (flat_package.rs) Flat list of all functions, types, and globals; used from monomorphize through the lower pipeline's planner.
NirPackage (nir_package.rs) Normalized IR. Output of lower, input to optimize / WIR build / codegen.
WirPackage (wir.rs) Wasm IR — closer to Wasm core instructions, used for emit-time optimization and codegen.

Codegen takes &NirPackage + &WirPackage (emit_wasm) and has no knowledge of earlier phases — the rule that keeps the back end decoupled from the front.

Frontend (per-module)

The loader runs lexer → parser → bind on every loaded module:

The AST is parser-immutable from this point on. The desugar-replacement surface rewrites — compound assignment (x += yx = x + y), while / C-style for → explicit loop, for x of expr iteration (the .into_iter() / .next() dispatch and the match Some(x) => body, _ => break shape), the assert statement, the matches operator, the comparison chain a < b < c, template-string interpolations, use … namespace prefix stripping (helper::foo), and Self::method / T::method (T bound to concrete) static-call dispatch — happen inside the elaborator and are built TIR-direct: each rewrite resolves the user AST and constructs TirExpr / TirStmt nodes directly without producing synthetic AST. The implementations live in elaborator/{stmt,operators,assert,matches}.rs (resolve_while, resolve_for, resolve_iterator_for_of, resolve_compound_assign, desugar_assert, desugar_matches_expr, desugar_comparison_chain), Elaborator::strip_ns_prefix in elaborator.rs, and CalleeIdentKind / classify_call_callee in elaborator/call.rs (the prefix is resolved to its concrete type name before parameter-type lookup so argument resolution runs once with the correct expected-type hints). Synthetic call sites that need to dispatch a method on an already-resolved receiver TIR (the for-of .into_iter() / .next() calls today) reuse the AST-driven method dispatch via Elaborator::resolve_method_call_with (elaborator/method_call.rs) — that helper takes a pre-resolved receiver plus a method name and signals "no source AST" with method_id: None so no use→def edge is recorded against the synthesis site. Keeping the AST parser-shaped is what lets LSP queries land on the user's text rather than on a synthesised replacement.

Annotate (Analyze + Resolve + TIR Lowering)

semantics_of (semantics.rs) is the entry point shared by LSP and batch compilation. It runs analyze.rs for symbol-table construction and elaborator/ for type checking; bodies are then lowered into TIR.

The result, Semantics, carries the TIR modules plus an AstIndex and a use→def map (AstId → AstId, globally-unique ids). This is what makes the architecture LSP-friendly: facts are attached to AST nodes without mutating them, so cross-file navigation, hover, and rename all fall out of the same data the batch compiler uses. See the LSP section below.

The elaborator covers trait selection, generic inference, method dispatch, coercion, and effect typing. All trait calls are resolved statically — by the end of the pipeline every call targets a concrete monomorphized function. There is no runtime vtable.

Synthesis

synthesis::synthesize (synthesis.rs) generates synthetic TIR that the user does not write:

Sub-pass File Output
Trait auto-derives synthesis/traits.rs Eq / Ord / Default for bound-driven requests (below); Inspect / InspectAlt unconditionally (total); Display for plain enums (bare case name); DisplayAlt wherever Display exists. Newtypes inherit their base's format traits at the call site (peel_transparent_newtype in synthesis/template.rs), except Inspect/InspectAlt which they override for the as Name tag
From adapters synthesis/from_synth.rs From impls from impl From<T> for U; declarations
Serde synthesis/serde_synth.rs Serialize / Deserialize for bound-driven requests (below; body-less markers record there too)
Template strings synthesis/template.rs Expands template strings into Display::fmt / Inspect::inspect calls
Effect dispatch synthesis/effect_dispatch.rs Per-effect dispatch infrastructure for handler resolution
CM bindings synthesis/cm_binding/ Component Model boundary adapters (lift / lower / async export)

Synthesized impls are recorded back into the shared TraitEnv so subsequent phases query a single source of truth.

Bound-driven requests (WEP 2026-06-25-trait-derivation) originate during Annotate, from two funnels into the shared TypeTable::bound_driven_synth_requests set (one per TypeTable, project-wide, since each module gets a fresh Elaborator):

The format traits (Inspect/InspectAlt/Display/DisplayAlt) are total (WEP): classify_on_bound_trait classifies them, type_implements_trait_inner short-circuits true for any type, and generation stays unconditional — so a T: Inspect/T: Display bound always holds and impl Inspect for T; markers are accepted, without gating. They record no demand request.

Two passes read the demand set as a snapshot, not a drain (consuming it would starve whichever runs second): serde_synth::synthesize_serde claims Serialize/Deserialize; traits::synthesize_traits claims Eq/Ord/Default, gating generation on set membership (SynthesisCtx::should_synthesize) instead of its former unconditional sweep.

A body-less marker is itself an Item::Impl and lands in TraitEnv's impl indexes like any other, so every gate that means "a real impl already covers this" must count only methodful impls: SynthesisCtx::has_real_impl (module-scoped, for Eq/Ord/Default generation dedup), has_methodful_impl_anywhere (module-agnostic, so a body-less format marker never suppresses the eager format body while a real impl Display for String does), and Elaborator::has_real_trait_impl_for_type (module-agnostic — a serde impl legitimately lives outside the type's module, e.g. core:serde's impl Serialize for i128).

Effect and Stores Checks

check_effects and check_stores (effect_check.rs) run after synthesis and before monomorphize. They validate that every function declares the effects and reference stores it actually requires. Synthesized CM boundary code is exempted. A separate check_default_purity runs earlier, immediately before synthesis, to gate auto-derived Default::default() bodies on pure field defaults.

Effect Dispatch (post-check)

synthesis::effect_dispatch::synthesize_post_check runs between the effect/stores checks and link. It desugars WithHandler / Resume constructs into the per-effect dispatch infrastructure (struct, mut global, dispatch wrappers). This pass is split out of the main synthesis stage so that effect-check sees the original WithHandler shape and can validate which effects are satisfied locally.

Lower

lower.rs runs FlatPackage (TIR-shaped) → NirPackage as planner + translator: the planner ([lower::plan::plan]) runs the TIR-mutating sub-passes and produces a LowerPlan of facts; the translator ([lower::translate::translate]) is a single fold from TIR to NIR. See docs/wep-2026-05-11-nir.md.

Sub-pass Stage File What it does
Pattern lowering planner lower/plan/pattern.rs LetDestructure / IfLet → explicit Let + If; dense integer MatchSwitch
Global extract planner lower/plan/globals.rs Extracts non-constant global initializers into a per-module __initialize_module function (one per source module; disambiguated by module_source)
Boxing planner lower/plan/boxing.rs &primitive / &mut primitiveBox<T> struct operations
Closure planner lower/plan/closure.rs Closures → __Closure_N functor structs with __call methods; produces fn-param specialized callees; emits ClosurePlan { functor_infos }
Initialize modules planner lower/plan/globals.rs Combines per-module init functions into the top-level __initialize_modules
Value copy planner lower/plan/value_copy/ Decides move / copy / share per consumption site (freshness, last-use, confinement, read-only-share) and inserts $value_copy$T only at copy sites; synthesizes the helpers
String collection planner lower/plan/string.rs Collects literals and per-function DCE maps for the data section; emits StringPlan
TIR → NIR translator lower/translate.rs Single fold over TIR producing NirPackage. Special-cases that consume LowerPlan: wide-int Match → if-else chain, ClosureClosureToCanonical, copy_value::<T> → helper

Optimize

optimize/ runs a fixed-point loop of NIR-level passes (inlining, copy propagation, SROA, LICM, DCE, …). Local rewrites run on a worklist engine (nir_engine.rs) over the arena Body — a node is revisited only when an edit might have made it reducible — with the position-flexible rules sharing one session (optimize/peephole.rs). A per-function dirty-set gate (optimize/gate.rs) lets each pass skip functions unchanged since it last ran. See optimizer.md and WEP: NIR Rewrite Engine.

WIR Build

wir_build/build_wir_package translates a NirPackage into a WirPackage in three stages: register types, collect function signatures, then translate each function body via FunctionTranslator. The pass is split across sibling files by concern:

File Concern
context.rs WirContext — accumulates types, functions, tables
types.rs Stage 1: register TIR types as WIR type definitions
functions.rs Stage 2: collect and register function signatures
component_plan.rs ComponentPlan: CM-level structure (imports, exports, adapters)
translate.rs Stage 3 driver and dispatch (translate_expr / translate_stmt / translate_block)
primitive_ops.rs Literals, binary / unary operators, casts, array indexing
calls.rs Function-ref resolution, builtin intrinsics, indirect calls, closure-to-canonical
pattern_match.rs match / if let / switch lowering, variant construct / test / payload

Each helper module calls back into translate.rs for sub-expression translation; cross-module access uses pub(super) on shared fields.

CM canonical operations (stream / future read + write, waitable-set, error-context) carry no wir_build code: they are lowered entirely in the synthesis phase (synthesis/cm_binding/) into ordinary TIR that translates like any other function.

WIR Optimize

wir_optimize/ runs Wasm-shape-specific passes that need WIR's lower-level view: peephole, variant-return SROA, init-guard removal, struct elision, array data promotion, parameter SROA, nullable-ref folding, constant forwarding, DCE, and final cleanup. Tuple- and user-struct-return ABIs are decided by the TIR-level optimize::multi_value_return pass before WIR build.

Codegen

codegen::emit_wasm produces the final component bytes:

  1. emit.rs emits core Wasm bytes from WIR.
  2. component.rs wraps the core module in a Component Model envelope (imports, exports, adapters, optional WIT bundling, embedded data).
  3. postprocess.rs adds branch-hint sections and other post-emission custom sections.

Output is validated with wasmparser unless --no-validate is set.

Module Loading and Names

Module Sources

name.rs::ModuleSource distinguishes where a module originated:

Variant Origin
Core Embedded core stdlib (core:prelude, core:cli, …)
Wasi Embedded WASI bindings (wasi:cli, wasi:io, …)
Local Path relative to project root (./geometry.wado)
Remote http(s)://… URL, fetched via host.load_remote()
EntryPoint The main file being compiled
Redirected Module routed through a Kiln invocation index
Wasm A .wat / .wasm asset imported via use … with { type: … }

The loader canonicalizes paths (RFC 3986, project-root-relative with / separator) so the same file imported via different paths shares one identity.

Naming Convention

name.rs centralizes mangling so other components do not depend on name shapes:

Name Format Example
Method {file}/{Type}::{method} ./geom.wado/Point::sum
Trait method {file}/{Type}^{Trait}::{method} ./geom.wado/Point^Display::fmt
Effect operation {Effect}::{op} Stdout::write_via_stream
WASI canonical wasi:{pkg}/{iface}::{fn} wasi:cli/stdout::write-via-stream
Mangled generic {Base}$T1$T2… Box$i32, Pair$i32$String

Component Model Registries

Three registries collect declarative information from the standard library and feed both the elaborator and codegen:

LSP

The language server (wado-lsp/) is a thin layer on top of wado_compiler::semantics. The Engine holds open documents and answers LSP queries (diagnostics, hover, go-to-definition, references, document highlight, semantic tokens). Each query:

  1. Composes parse(source)load(parsed, …, invocations, …)semantics_of(loaded, host, …) to obtain a Semantics snapshot (kiln invocation discovery runs against the parsed entry AST between stages).
  2. Uses Semantics::cursor_at(module, line, col) → Cursor to translate a (line, col) into an AstId cursor.
  3. Reads pre-computed facts off Cursor / Semantics (def_key, def_name_span, references_to_def, is_write_target, …).

Engine itself performs no I/O — every query takes an &impl CompilerHost, so the caller decides how imported modules are loaded. wado-lsp ships a FilesystemCompilerHost; embeddings (VS Code Wasm, browser playground) supply their own host. The wado-compiler crate must compile to wasm32-unknown-unknown to support those bundled deployments; CI enforces this. See WEP 2026-04-18: LSP Architecture.

Kiln (Schema-Driven Code Generation)

Kiln is the compiler's mechanism for turning external schemas (.proto, .graphql, .g4, .wit, custom IDLs) into .wado source. A generator is itself an ordinary Wado package that targets the core:kiln/generator world; the compiler builds it to a Wasm component, and the host (wado-cli) executes it via wasmtime to produce .wado source files. Invocations are content-addressed by their schema bytes, options, and the generator's source hash. See WEP 2026-04-12: Kiln.

The compiler-side pieces live in src/kiln/:

Module Concern
invocation.rs Canonical Invocation representation (declaration site + options + schema paths)
inline.rs Collects inline use … with { generator: … } invocations (InvocationIndex)
plan.rs DAG + topological sort of invocations; rejects cycles
cache.rs Cache-key composition over schemas, options, and the generator's identity hash
header.rs Generated-file #![generated] header emission and parsing
metadata.rs Persisted per-invocation cache state (<primary>.kiln.json)
options.rs Extracts an OptionsDescriptor from a generator's pub struct Options
options_check.rs Validates user-supplied options against the descriptor
import_check.rs Refuses wasi:* imports inside generator packages; injects the Request<T> adapter

The pipeline driver (run_generator, file persistence, wado.lock handling) lives in wado-cli; wado-compiler exports only the pure-data pieces above so it stays wasm32-unknown-unknown-clean.

compile_after_load integrates Kiln at three spots:

Standard Library

The compiler bundles the standard library inside its binary (stdlib.rs embeds lib/):

Bundled Math (wado-bundled-libm)

The wado-bundled-libm/ crate compiles a deterministic libm to wasm32-unknown-unknown. The compiler links it as a separate core module inside the produced component, and core:builtin exposes its functions via #[canonical("bundled", …)].

Allocators

Three allocators live in lib/core/allocator.wado, each tagged #[allocator("name")]. The compiler picks one by setting that function's export_name to "realloc":

Mode Default for Behaviour
bump CLI Bump pointer with free-rewind; never reclaims general blocks.
freelist HTTP service worlds First-fit free list with block splitting; falls back to bump.
debug Test world / E2E tests Never reuses freed memory; poisons with 0xFF. Catches use-after-free.

--allocator <name> overrides the defaults.

In Progress

Known Limitations

Not Yet Implemented