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:
- Optimize and
wir_buildsee the union of pre-lower and post-lower TIR variants. Several passes carry match arms for variants thatlowerhas, in practice, eliminated. These arms are required for exhaustiveness but never taken at runtime. - The "lower has run" precondition is implicit. A newly written pass can rely on a pre-lower shape; the type system does not catch it.
- Variants that pre-lower passes (annotate, synthesis, monomorphize) need but post-lower passes do not, and vice versa, share one enum. Neither side can shrink without breaking the other.
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:
- TIR is what flows from
annotatethroughsynthesis/effect_check/link/monomorphize/eraseintolower's input. - NIR is what flows out of
lowerthroughoptimizeintowir_build. - A function signature naming one of these never names the other.
What NIR Is Not
- Not a redesign. This WEP introduces no new node kinds, removes no variants, and changes no semantic behaviour. Wasm output is bit-identical before and after.
- Not a CFG or SSA representation. NIR keeps TIR's tree-structured shape. Wado has no borrow check, and Wasm has structured control flow; the tree shape is appropriate.
- Not the home for sugar removal. Sugar elimination is
desugar's responsibility today, on AST. Ifdesugaris later removed, sugar elimination moves tobuild_tir(AST → TIR). Neither path produces sugar that flows into NIR. - Not a replacement for FlatPackage. FlatPackage continues to be the pre-lower container; NirPackage is the post-lower container. Both exist after this WEP.
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:
TypeTable(post-erase): types are interned globally, not per IR. NirPackage holds a reference in the same shape FlatPackage does.SymbolKey,FunctionId,GlobalId, name interners.Literal: value representation is independent of expression shape.
NIR owns its own:
- Expression / statement / pattern enums and their inline metadata.
- Visitor trait.
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
- [x] Phases 1-5 — NIR types (
nir.rs/nir_visitor.rs/nir_unparse.rs,NirPackage) created as renamed TIR copies;lower/switched to returnNirPackage;optimize/andwir_build/migrated pass by pass; the temporaryNirPackage → FlatPackageadapter added then removed;wado dump --nirwired; full NIR pipeline green. - [x] Phase 6 — pre-lower-only variants trimmed from NIR
(
NirExprKind:Closure,Capture,FuncRef,TemplateString,WithHandler,Resume,TupleSpread,TupleZip,TypePackExpansion;NirStmtKind:IfLet,TaskReturn,VariadicForOf);LetDestructurekept for the multi-value core-builtin optimisation;ClosureToCanonicalemitted directly as aNirExprKind. - [x] Phases 7+9 — every TIR-mutating sub-pass moved into
lower::plan::*;lower::lowerreduced toplan + translate; wide-int and closure call-site rewriters folded into the translator;temp_modulewrapping and vestigial body-IR fields dropped; theCell-based translator slot replaced with aFunctionTranslator<'a,'p>sub-context.
Phase 10: Match / Switch / variant-trio split
Three pattern-derived forms coexist in NIR, each with a distinct role:
Match— the canonical control-flow pattern-matching form. Preserves scrutinee identity, arm order, payload bindings, or-patterns, and guards. Compiled directly toref.test+ branch bywir_build::pattern_match.Switch— the dense-int / dense-enumbr_tableform. Optimiser-materialised only: emitted byoptimize::match_to_switch, never bylower. Mirrorsselect_lowering— a late codegen-oriented lowering, not baked into the IR by lowering.VariantTag/VariantTest/VariantPayload— the canonical expression-value form of variant introspection: read a discriminant, test a discriminant, project a payload. Each is a scalar expression, not control flow.
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:
NirExprKind::Match— canonical control-flow pattern matching.NirExprKind::Switch— dense form, emitted only byoptimize::match_to_switch.NirExprKind::{VariantTag, VariantTest, VariantPayload}— canonical expression-value variant introspection; kept.
Step 1: Match → Switch is an optimiser pass — done
- [x]
optimize::match_to_switchwalks NIR and materialisesSwitchfor dense-int / dense-enum scrutinees; the translator only ever emits canonicalMatch.
Step 2: Pattern lowering into the translator — done
- [x]
IfLet/LetDestructure/ or-patterns → explicitLet+Matchlowering relocated from alower::plansub-pass intotranslate::pattern, run over every function before the fold; box-aware scrutinee peeling (Box<T>struct wrappers peeled via.valuealongsideRef/MutRef);stringliteral collection folded intotranslateafter pattern lowering (string-literal patterns synthesise string-literal expressions the data section must register); themut-payload-binding lift extracted intolift_mut;boxingsplit intoprepare_typesandlower_bodies. Full e2e suite green at O0/O1/O2/O3/Os;mise run test-wadogreen.
Step 3: Synthesis migration to canonical Match — done
- [x] Synthesis control-flow migrated to canonical TIR
Match(arms useTirPattern::Variant { bindings: [Binding] }, payloads bound to generator-allocated locals):synthesis/traits.rs(Inspect/InspectAlt/Eq),serde_synth.rs,effect_dispatch.rs, and all ofcm_binding; shared helperssynthesis::common::match_variant_test/match_variant_payloadcover the common shapes. The remainingvariant_test/variant_tag/variant_payloadreferences incm_bindingare expression-value uses, not control flow — final, see the variant-trio decision above.
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.
- [x] Dropped
TirExprKind::Switch— dead.match_to_switchis a NIR-only optimiser pass; nothing constructed a TIRSwitch, so thetranslate.rsSwitch → Switcharm and every TIR walker'sSwitcharm were unreachable. - [x] Dropped
TirStmtKind::IfLet.elaborator::stmtlowersif let/ let-chain conditions directly to a two-armMatch(<pattern> => <then>,_ => <else>) instead ofIfLet;translate::patternalready handlesMatch, so the oldIfLet → Matchconverter is gone. The scrutinee stays inline throughsynthesissoresource_cleanup's resource-flow analysis sees the same shape it saw forIfLet; the(Let, Match)temp-local hoist thatlabeled_block_fusionkeys on is re-applied later, intranslate::pattern, afterresource_cleanuphas run.task_returnsynthesis gainedMatch-arm descent —task returninside anif letbody now lands in aMatcharm, which itsIf/Loopwalker missed. TirExprKind::{VariantTag, VariantTest, VariantPayload}are NOT dropped — see the variant-trio decision above.NirExprKindkeeps the trio as well; the original plan to drop it from NIR is abandoned.TirStmtKind::LetDestructurestays — the elaborator emits it and NIR preserves the multi-value core-builtin optimisation.
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):
- The earlier framing "passes are interdependent — must migrate as
a cluster" was overcautious. The hard dependency is on
boxing::prepare_types(type-table redefinition), which stays as a pre-pass anyway. The body rewrites ofboxing/closure/value_copyare independent and can each migrate in their own phase, each with a green checkpoint. TirFunction.address_taken_localsis already populated by the elaborator; the fold reads it directly.BoxPlancarries only type-level facts (box payload mappings,Box<T>struct ids).- The closure pass keeps its
__callbody synthesis (Phase 1, including theCapture → FieldAccessrewrite that runs during the body clone). Only the user-sideClosure → StructLiteral at Letrewrite (Phase 3) moves to the fold; the remaining Phase 3 rewrites (IndirectCall → MethodCall, specializedLocalretag, unspecializedClosure → ClosureToCanonical) are already intranslate. value_copy's insertion predicateis_fresh_in_contextis pure and shape-local. The fold and the analysis walker share it viavalue_copy::analyze::should_wrap. The analysis walker collects every TypeId the fold would wrap; the synthesizer generates helpers for that set transitively, before the fold runs.globals::extractandbuild_initialize_modulesare purely additive (they push__initialize_module/__initialize_modulesfunctions). No body-rewrite component to migrate.- The Deref-assign field expansion is a 1-stmt → N-stmts rewrite;
convert_blockflat-mapsconvert_stmtso the boxing arm can emit a vector.
Migration phases (each with a green e2e checkpoint at the end):
- [x] Phase A —
value_copy::insert→ fold.insert.rsreplaced byvalue_copy::analyze(read-only walker that collectsTypeIds via the sharedshould_wrap/is_source_immutable/is_fresh_valuepredicates).value_copy::synthesizetakes the seed as input and still closes the transitive set across nested helper bodies. The translator'sconvert_stmt_kind(Let / LetDestructure arms),convert_call_arg,IndirectCallarg loop, andAssignarm emitCall($value_copy$T, ...)wraps directly. Per- functionimmutable_localslives onFunctionTranslator;fresh_localsis not threaded through the fold because the pre-Phase-A predicate only used it inside theLabeledBlockbreak-freshness sub-walk, which is shape-local. SynthesizedLets at translate time (wide-int rewrite, pattern lowering) now carryskip_value_copy: trueso the fold does not look up helpers that were never seeded. Full e2e suite green at O0/O2 (2592 passed, 0 failed);mise run test-wadogreen (787 compile, 2825 tests passed). - [x] Phase B —
boxing::lower_bodies→ fold.boxingis nowprepare_types(type table; unchanged) +shadow_params(per-function parameter-shadow prelude injection plus a local-table retag for non-param address-taken locals; still a pre-pass because prelude injection is function-scoped). Every body rewrite landed in the translator:try_boxing_rewritehandlesLocal(addr-taken) → FieldAccess(.value),Unary(Ref|MutRef, _)(including the&FieldAccess(.value) → Local(box)collapse and the&primitive → Box{value: x}literal), andUnary(Deref, Box) → FieldAccess(.value). The Let arm wraps the initial value of an address-taken primitive local inBox{value: x}and retypes the binding'stype_id.try_expand_deref_struct_assignrecognises*ref_to_struct = valueand emits a per-field expansion(let __deref_ref, let __deref_val, ref.f0 = val.f0, …);convert_blockconsumes statements asVec<NirStmt>so the expansion plugs in cleanly. The closure planner now propagates the original closure'saddress_taken_localsto the synthesized__callmethod (with a+1shift to make room for theselfparam) so the fold's boxing arm fires inside cloned closure bodies — previously latent because pre-Phase-B boxing ran before the closure clone. Full e2e at O0/O2 green (2592 passed, 0 failed);mise run test-wadogreen (790 compile, 2825 tests passed). - [x] Phase C —
Closure → StructLiteral(specialisable) → fold.ClosureCallSiteLowerer::visit_expr'sClosurearm no longer rewritesTirExprKind::ClosureintoStructLiteralin place; the rewrite happens at the fold'sClosurearm, which readsClosurePlan::specializable(now exposed on the plan) to decide between a rawStructLiteral(specialisable functor — the call site can dispatch directly to the per-functor__call) and aClosureToCanonicalwrap (other functor — the call site goes through the canonicalfn(...)vtable). Position no longer matters: the fold emits the right shape regardless of whether theClosurelives at aLetvalue, aCallarg slot, a struct field default, or aReturn.CollectedClosurecarries the closure'saddress_taken_locals; the synthesized__callfunction receives the set (shifted by+1for the syntheticself) so the boxing fold rule fires correctly inside cloned closure bodies. The remainingClosureCallSiteLowererresponsibilities —try_redirect_to_specialized_callee(Call → specialised callee + argClosure → StructLiteral),try_redirect_inspect_to_functor, theLet.type_idretype, andupdate_local_types— stay as pre-pass synthesis: they touchFunctionRef/Let.type_id/func.locals[i].type_id, not the expression-shape rewrite Phase C targeted. Full e2e at O0/O2 green (2592 passed, 0 failed);mise run test-wadogreen.
Phase D (lift_mut → translate::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:
- Removing the AST-level
desugarphase. - Folding
boxing::shadow_paramsorclosure::Phase 0into the fold (would require multi-pass or per-function preamble buffers inFunctionTranslator; not justified by the saved walker LoC).
Consequences
Benefits
- Type boundary at
lower. The "lower has run" precondition is enforced statically. - Risk concentrates on
lower. Optimize andwir_buildmigrations are mechanical renames; the semantically interesting work stays in the smallest of the three phases. - Enables variant trimming on both sides. With two distinct enums, variants only meaningful pre-lower can leave NIR, and variants only meaningful post-lower can leave TIR. Each trim is a small PR enforced by the type system.
- Enables removal of the AST-level
desugarphase. Adding variants to TIR (the consequence of moving sugar elimination intobuild_tir) does not propagate into optimize.
Trade-offs
- Code duplication on landing. NIR's expression / statement / visitor / unparse files start as copies of TIR's. The duplication is intentional and is paid down in subsequent WEPs as the two diverge. Copying is mechanical and reviewable in one sitting.
- A short window during the transition where both
FlatPackageandNirPackageflow through downstream code. The Phase 2 adapter absorbs this; Phase 5 removes it. - One more
wado dumpmode to maintain.
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_sequences → array.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:
csecan deduplicate identical constant arrays (today it returnsNonefor aggregates).niri/const_foldcan foldIndex(ArrayLiteral, const)to the element.- Bounds-check elimination knows the static length.
- Constant Object Globalization can globalize constant arrays and strings.
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
- WEP 2026-02-14: Wasm IR (WIR) Layer — the precedent for a separately-typed body IR. NIR follows WIR's data-sharing conventions (shared type / symbol identity, independent expression enum).
- WEP 2026-04-18: LSP Architecture — the LSP-driven motivation for removing the AST-level
desugarphase, which becomes safe under NIR.
