Wado

WEP: Normalized IR (NIR) Layer

Context

The compiler pipeline from lower to wir_build runs entirely on FlatPackage with TIR-shaped function bodies:

... → link → monomorphize → erase → lower → optimize → wir_build → ...
                                    └─── FlatPackage (TIR bodies) ───┘

lower/ is a series of FlatPackage → FlatPackage transformations (wide_int, pattern, globals, boxing, closure, string, value_copy). The output is structurally the same type as the input.

This produces three problems that compound over time:

A second pressure reinforces the first. Removing the AST-level desugar phase (see LSP architecture) would push additional variants into TIR. Without a downstream type boundary, every optimize pass would grow further. A boundary at lower contains the blast radius.

Decision

Introduce NIR (Normalized IR) — a separately-typed body IR. lower/'s output type changes from FlatPackage to NirPackage. optimize/ and wir_build/ consume NirPackage.

What NIR Is

NIR is structurally derived from TIR. At the moment NIR lands, its expression, statement, type, and container shapes are identical to their TIR counterparts. They are distinct Rust types — separate enums, separate visitor traits — but they represent the same data.

The content of this Decision is the type boundary, not a shape redesign:

What NIR Is Not

Why "Normalized"

The name signals that NIR is the canonical form of a function body after lowering has run. "Normalized" captures the subtractive direction: only variants that survive lowering are part of NIR's enum, and TIR is free to drop variants that exist solely for post-lower passes. Both enums shrink toward their minimum useful shapes, while semantic behaviour stays unchanged.

(The Mesa shader compiler also names its IR NIR. The collision is in a different domain.)

Implementation

Source Files

File Description
nir.rs NIR data structures: NirExpr, NirStmt, NirPattern, NirPackage. Initially direct copies of TIR's.
nir_visitor.rs NIR visitor trait. Initially a direct copy of tir_visitor.rs.
nir_unparse.rs NIR → pseudo-Wado renderer for wado dump --nir.

Container

NirPackage mirrors FlatPackage: function table, type table reference, global table, exports, imports. Field shapes match initially.

Data Sharing With TIR

NIR reuses without copying:

NIR owns its own:

This follows the convention WirPackage already uses: WirTypeId borrows TIR-level type identity, while WirInstr is a fresh enum.

Lower Phase Role

lower/ continues to perform the same semantic work. Only its output type changes:

// Before
fn lower(package: FlatPackage) -> FlatPackage

// After
fn lower(package: FlatPackage) -> NirPackage

Because NIR is initially structurally identical to TIR, the cross-type conversion at lower's exit is mechanical: field-for-field reconstruction. The internal sub-passes (wide_int, pattern, globals, boxing, closure, string, value_copy) keep their current implementations.

Inspection

wado dump --nir renders NirPackage as pseudo-Wado, paralleling --tir and --wir.

Migration Plan

NIR started structurally identical to TIR; the migration is a sequence of in-place renames plus the lower-phase restructuring of Phase 10. The plan was reset on 2026-05-22 (variant-trio rewrite abandoned), then again on 2026-05-23 (Step 5 redesigned against the actual codebase — the earlier "must migrate as a cluster" framing was overcautious). What survives is small — see Phase 10 below.

Phases 1-9 — done

Phase 10: Match / Switch / variant-trio split

Three pattern-derived forms coexist in NIR, each with a distinct role:

The variant trio is permanent in both TIR and NIR. Rewriting it into Match was considered and rejected: ~30 optimize passes (labeled_block_fusion, inline, ref_elim, store_load_forward, dce, …) pattern-match the trio directly. Folding it into Match arms scatters the scalar fact across statements and forces every pass to re-derive it — a measured regression. cm_binding's variant_test / variant_tag / variant_payload uses (the CM discriminant byte at offset 0, and statically-known payload extractions in lift/lower helpers) are expression-value uses of exactly this kind; they are correct and final, not a migration debt.

Final NIR shape:

Step 1: Match → Switch is an optimiser pass — done

Step 2: Pattern lowering into the translator — done

Step 3: Synthesis migration to canonical Match — done

Step 4: Drop TirStmtKind::IfLet and TirExprKind::Switch — done

TIR now carries no variant that exists solely to be lowered away; lower's input and wir_build's input are each at their minimum useful shape.

Step 5: Plan sub-passes become analysis-only

Goal: lower::plan(flat) only mutates the type table, pushes additive synthesized entities into FlatPackage, and edits function declaration shapes (parameter shadows, lifted mut bindings). Every TIR body rewrite — boxing's &local / *ref forms, closure's Closure → StructLiteral, value_copy's copy_value insertion — runs inside the translate fold, consulting plan structs that the analysis built. After this step, TIR is read-only between lower::plan and translate at the expression-shape level, and ~1100 LoC of redundant TIR walkers collapse into fold rules.

Design findings from reading the code (2026-05-23):

Migration phases (each with a green e2e checkpoint at the end):

Phase D (lift_muttranslate::pattern preamble) was considered and dropped. After Phase A, value-copy helpers are seeded by a read-only walker that runs before the fold. The lifted Let mut original = fresh_local statements lift_mut::lift_mut_match_bindings synthesises are themselves wrap-eligible — the predicate fires on the lifted Let's value when the binding's type is value-semantic. Moving lift_mut into the fold would make those Lets invisible to the seed walker, so the fold would either panic (helper not registered) or have to flag the lifted Lets skip_value_copy: true (no wrap — a real semantic change). Neither is acceptable. lift_mut therefore remains a lower::analyze step. The invariant Phase D was chasing — "every func.body mutator out of analyze" — costs more than it pays for: lift_mut is small (191 LoC), idempotent, and operates on pattern shapes that the fold also consumes.

Out of scope for this WEP:

Consequences

Benefits

Trade-offs

Additions

NIR's node set is not frozen. The migration landed with no new nodes (a scoping constraint for the type-boundary work, now lifted); going forward, NIR's expression set may grow when a new node makes its canonical form more analyzable.

NirExprKind::ArrayLiteral (proposed)

List literals are coerced into SequenceLiteralBuilder push sequences during elaboration (see Iterator-Based Literal Coercion), so an array constant reaches NIR as imperative with_capacity + push statements; a fixed const-array shape only re-materializes at WIR (collapse_array_push_sequencesarray.new_fixed). NIR-level analysis of constant arrays is therefore impossible today.

Add NirExprKind::ArrayLiteral { elements } plus a collapse that recognizes the const builder push-sequence and rewrites it to the literal, early in the optimize loop. This is normalization, not a lowered-concept re-introduction: it gives constant arrays the same first-class, analyzable shape StructLiteral / TupleLiteral already have. It is shared infrastructure, not specific to one pass:

Status: landed. NirExprKind::ArrayLiteral is materialized by optimize::array_literal and lowered by wir_build to array.new_fixed; the WIR collapse_array_push_sequences it subsumed has been retired. See NirExprKind::ArrayLiteral — a NIR-Materialized List Node.

See Also