Wado

WEP: NirExprKind::ArrayLiteral — a NIR-Materialized List Node

Context

This WEP realizes the NirExprKind::ArrayLiteral node proposed, but left unspecified, in the "Additions" section of Normalized IR (NIR) Layer. It fixes the node shape, the pass that materializes it, the downstream consumers, and the migration of the existing WIR-level collapse.

Status: landed. NirExprKind::ArrayLiteral is materialized by optimize::array_literal, lowered by wir_build to array.new_fixed, and the WIR-level collapse_array_push_sequences it subsumes has been retired. Full e2e suite green at O0/O2 with the WIR pass removed.

Why NIR cannot see array literals today

An array literal such as [1, 2, 3] as List<i32> never reaches NIR as a literal. During elaboration it is coerced through the SequenceLiteralBuilder trait path (see Iterator-Based Literal Coercion). After that path is inlined, the construction reaches NIR as an imperative builder sequence over a fresh local:

let arr = List::<i32>::with_capacity(3);
arr.push_literal(1);
arr.push_literal(2);
arr.push_literal(3);

In NIR terms this is one NirStmtKind::Let binding arr to a with_capacity call, followed by N NirStmtKind::Expr(MethodCall …) push statements on that local. The fixed-shape, const-array form only re-materializes much later, at WIR, where wir_optimize::array::collapse_array_push_sequences rewrites the LocalSet(with_capacity) + N pushes window into WirInstr::ArrayNewFixed.

The consequence is a blind spot: every NIR analysis and optimization pass sees an array constant as opaque imperative mutation, not as a value. NIR cannot deduplicate it, cannot index into it at compile time, cannot reason about its length, and cannot globalize it. The capability exists one layer too late.

Why a node, and why in NIR

NIR already gives the other two aggregate constructors a first-class, analyzable value form: NirExprKind::StructLiteral and NirExprKind::TupleLiteral. Arrays are the missing third. Adding ArrayLiteral is normalization in exactly the sense the NIR WEP defines: not re-introducing a lowered concept, but giving a construct its canonical value shape so that shared infrastructure can act on it once instead of each pass re-deriving the fact from a statement window.

There is direct precedent. optimize::string_push is the string analog: it collapses the String::with_capacity + push_str_literal builder window at the NIR level specifically so that later NIR passes (cse, const_folding) see the normalized form — its module doc says so, and that it mirrors the WIR collapse_array_push_sequences but runs earlier on purpose. ArrayLiteral is the same move for List<T>. And match_to_switch is the precedent for the category: a node that lower never emits and that exists only because an optimizer pass materializes it.

Decision

The node

Add one variant to NirExprKind (in nir.rs), shaped to match its sibling TupleLiteral:

/// A fixed-length array value, materialized by `optimize::array_literal`
/// from an inlined `SequenceLiteralBuilder` push sequence. `lower` never
/// emits this; it is an optimizer-materialized normalization, like
/// `Switch`. The element type and array type are carried by the enclosing
/// `NirExpr::type_id` (the `List<T>` struct type), exactly as
/// `TupleLiteral` relies on `type_id` for the tuple's struct type.
ArrayLiteral {
    elements: Vec<NirExpr>,
},

Rationale for the minimal shape:

The pass that materializes it: optimize::array_literal

A new NIR optimizer pass that recognizes the canonical List<T> construction window and rewrites it to ArrayLiteral. It runs after nir/inline in the fixed-point loop: the SequenceLiteralBuilder new_literal / push_literal / build methods — and, for custom builders that wrap an List<T>, the push_literal → self.field.push delegation — must be inlined first so the raw List<T> { array_new(N) } + List::push window is exposed. Later cse / const_fold in the same loop then see the normalized literal.

The matched window, on a block's statement list, is:

  1. An init statement (Let or Assign-to-local) whose value embeds one or more List<T> { repr: array_new(N), used: 0 } structs. The struct may be the bound value directly (a direct List<T> literal) or a field of a wrapper struct (SeqVec { items: List<T> }, Bag { keys, values }), reached by a field-index path from the bound local. The matcher also descends the { …; *__b } block tail that direct literals carry.
  2. The following statements, until each target array has received exactly its array_new capacity in List::push calls rooted at the bound local (place.push(e) or place.field.push(e), peeling the &mut). Pushes to different array fields may interleave (Bag). Inlining push_literal leaves single-use, pure element temps (let v = e; place.push(v)) between the pushes; these are resolved to their value and consumed with the window.

On a match, each List<T> { array_new(N), used: 0 } struct is rewritten in place to ArrayLiteral { elements }, and the consumed push (and resolved temp) statements are removed. The enclosing init keeps its shape, so a wrapper builder's output struct is preserved with its array field now a literal.

List::push is matched by membership in the set of its CompilerItem::ListPush monomorphizations (each element type is a distinct NirFunction), not by canonical path — mirroring how string_push identifies its methods. array_new is matched by its builtin generic name.

Safety conditions:

Placement is step!("nir/array_literal", …) immediately after step!("nir/inline", …). Like every loop pass it returns bool (changed), so the fixed point reconverges if a later pass plants a fresh window.

Lowering: ArrayLiteralArrayNewFixed

wir_build::expr gains an arm beside the TupleLiteral arm: ArrayLiteral { elements } builds each element and emits WirInstr::ArrayNewFixed { type_id: <array struct type from expr.type_id>, elements }. This reuses the WIR node that already exists and is already handled by every downstream WIR consumer (wir_unparse, dce, promote_constant_arrays_to_data, split_large_array_literals). No new WIR node is introduced.

Migration of the WIR collapse

wir_optimize::array::collapse_array_push_sequences exists only because the fixed-array shape previously had nowhere to live before WIR. Once ArrayLiteral is materialized at NIR and wir_build lowers it to ArrayNewFixed, that reason is gone: the array arrives at WIR already collapsed, and the WIR matcher has nothing left to match. The end state is retirement, not coexistencecollapse_array_push_sequences is deleted once ArrayLiteral is in place.

This is not speculative. The string analog already made exactly this move: there was once a WIR-level string-push collapse, and it was retired when optimize::string_push took over at NIR — string_push's own doc calls it "the former WIR pass". Arrays follow the same path. Keeping a permanent WIR safety net would be the kind of duplicated, drift-prone workaround the project's design rules reject; a "two options, decide by measurement" hedge here is the same overcaution the parent NIR WEP flagged and corrected in its own migration plan.

Sequencing (each step with a green checkpoint), as landed:

The implementation went through two designs; the second is what landed:

What is not retired: the downstream WIR passes that consume ArrayNewFixedpromote_constant_arrays_to_data (→ ArrayNewData), split_large_array_literals, and rewrite_constant_array_indexing. They key on ArrayNewFixed, which wir_build keeps emitting from ArrayLiteral, so they are unaffected by the matcher's removal.

Consumers

ArrayLiteral is shared infrastructure; it pays for itself across passes rather than for one. Each consumer is additive and can land after the node:

Touch Points

A new NirExprKind variant is exhaustively matched across the NIR machinery. As landed, the variant is handled in:

niri needs no dedicated arm yet: nothing evaluates an ArrayLiteral through the interpreter on the landed paths. An Index(ArrayLiteral, k) const-fold consumer would add one.

Five aggregate walkers handled TupleLiteral by recursing into its elements but let ArrayLiteral fall into a _ => catch-all — silent under- approximation, because the catch-all suppressed the non-exhaustive-match error that flagged the other sites. Each now descends into ArrayLiteral elements identically: store_load_forward's two modified-locals walkers, const_folding's aliased-field invalidation, value_copy_demote's self-taint, and labeled_block_fusion's free-loop-exit check. (elide_local and cse keep their conservative _ => false / None defaults — a missed optimization for arrays, not a bug.)

The discipline from the NIR WEP applies: because ArrayLiteral is optimizer-materialized and never produced by lower, no pre-optimize code (TIR, lower) needs to know about it.

Consequences

Benefits

Trade-offs

Alternatives Considered

See Also