Replaces the iter mut.1 dispatch stubs in lower_term with real LLVM
lowering: mut-vars become alloca slots hoisted to the fn's entry
block, assign lowers to store, mut-var reads lower to load. Two
example fixtures run end-to-end printing 55 (Int via mut_counter,
Float via mut_sum_floats).
Concretely:
- Emitter struct gains three new fields: mut_var_allocas:
BTreeMap<String, (String, Type)> for per-fn name→(alloca SSA,
AIL type) tracking; pending_entry_allocas: String as a side
buffer accumulating alloca instructions during body lowering;
entry_block_end_marker: Option<usize> recording the byte position
in self.body immediately after the entry: label.
- start_block(label) captures the marker when label == 'entry'.
emit_fn resets all three new fields per fn body. At the end of
emit_fn, String::insert_str splices pending_entry_allocas into
self.body at the marker — alloca instructions land in the entry
block regardless of where in the source tree Term::Mut was
encountered (mem2reg eligibility preserved).
- Term::Mut arm: per var, fresh-name an alloca SSA, push the
alloca instruction into pending_entry_allocas, lower the init at
the current body position, emit a store to bind. The mut-var
binding is NOT visible during init (matches typecheck-side
ordering at lib.rs:3576). After all vars are bound, lower the
body in the extended scope. On block exit, restore any prior
bindings via a per-block save stack — supports nested
shadowing correctly.
- Term::Assign arm: look up the alloca + type via
mut_var_allocas.get(name); lower the value; emit store; yield
the canonical Unit SSA per the existing Term::Lit { lit:
Literal::Unit } convention.
- Term::Var arm: prepended with a mut-var lookup that emits a
load <ty>, ptr <alloca> and returns the load SSA. Innermost-wins
shadowing falls out of the BTreeMap's insert-overwrites
semantics combined with the per-block save/restore.
- examples/mut_counter.ail + examples/mut_sum_floats.ail:
recursive sum_helper accumulates 1..10 (Int) or 1.0..10.0 (Float)
inside a mut block whose single var is assigned the helper's
result. Both run end-to-end printing 55.
- crates/ail/tests/e2e.rs: mut_counter_prints_55 and
mut_sum_floats_prints_55 #[test] fns using the existing
build_and_run helper.
- DESIGN.md 'What is supported' subsection: 'Local mutable state'
bullet describes the new construct, points at the two example
fixtures, and reaffirms the seal-by-construction invariant under
Decision 10.
Beyond-plan adjustments absorbed in this iter:
- synth_with_extras's parallel Term::Var arm in
ailang-codegen/src/lib.rs needed the same mut-var lookup as
lower_term's. Plan only specified lower_term's arm.
- crates/ailang-codegen/src/lambda.rs needed save/restore of all
three new Emitter fields across the lambda-body boundary plus
in-thunk splice of pending_entry_allocas at the lambda's own
entry marker, so mut-blocks inside a closure body hoist into
the closure's entry block (not the outer fn's).
mut-local milestone end-to-end status:
- mut.1 (7b92719): AST + Form A surface.
- mut.2 (b24718a): typecheck.
- mut.3 (this commit): codegen + e2e.
Audit follows.
Tests: 592 → 594 green; cargo build green; both fixtures execute
and print 55.
Journal: docs/journals/2026-05-15-iter-mut.3.md.
Refs: docs/specs/2026-05-15-mut-local.md, docs/plans/2026-05-15-iter-mut.3.md.
6.8 KiB
iter mut.3 — codegen + e2e for Term::Mut / Term::Assign
Date: 2026-05-15
Started from: b057789b55
Status: DONE
Tasks completed: 9 of 9
Summary
Replaced the iter mut.1 codegen stubs for Term::Mut and Term::Assign
with real LLVM lowering: mut-vars become alloca slots hoisted to the
fn's entry block via a per-fn side buffer pending_entry_allocas
spliced into self.body at a byte marker captured during
start_block("entry"); Term::Assign lowers to store; references to
a mut-var name lower to load through a new lookup precedence in both
lower_term's Term::Var arm and synth_with_extras's Term::Var
arm (precedence: extras → mut-vars → locals → globals → builtins,
symmetric to the typecheck-side mut_scope_stack walk in
ailang-check). Lambda emission saves/restores the three new
Emitter fields across the thunk boundary and splices the thunk's own
hoisted allocas at its own entry marker so nested mut blocks inside
lambdas hoist into the lambda's entry, not the outer fn's. Two e2e
fixtures shipped (mut_counter.ail Int / mut_sum_floats.ail Float),
both printing 55 end-to-end (Float's %g formatter strips the .0).
DESIGN.md gains a "Local mutable state" bullet under "What is
supported". Closes the mut-local milestone end-to-end; the audit step
follows.
Per-task notes
- iter mut.3.1: three
Emitterfields (mut_var_allocas,pending_entry_allocas,entry_block_end_marker) added with doc comments; initialised inEmitter::new; cleared per-fn at the top ofemit_fnnext to the existing per-fn-reset block. - iter mut.3.2:
start_blockextended — whenlabel == "entry"it capturesself.body.len()asentry_block_end_marker(the splice point forpending_entry_allocas). - iter mut.3.3:
Term::Vararm oflower_termgained a mut-var lookup before theself.localswalk — on hit, emits%v_N = load <llvm_ty>, ptr <alloca_ssa>and returns the load SSA- LLVM type.
- iter mut.3.4:
Term::Mutarm oflower_termreal lowering — for each var: alloca into the side buffer, lower init in outer-plus- earlier-vars scope (matches typecheck ordering at lib.rs:3578), store init into alloca, then install the binding (saving any prior entry for restoration on block exit). Lowers body, restores saved bindings (unconditional restoration even on error path). - iter mut.3.5:
Term::Assignarm oflower_termreal lowering — look up the alloca inmut_var_allocas, lower value, emit store, return the canonical Unit SSA form("0", "i8")matchingLiteral::Unitin the samematch. Tasks 4 and 5 were applied in oneEditcall because both arms were adjacent stubs that needed simultaneous replacement (thematcharm signature changed from{ .. }to{ name, value }/{ vars, body }); the review separation still held — each arm was spec-checked independently. - iter mut.3.6: splice of
pending_entry_allocasat the marker — inserted intoself.bodyat the end ofemit_fn(after body finalisation, before the deferred-thunks flush). Marker absence whilependingis non-empty firesCodegenError::Internalwith the fn name. - iter mut.3.7:
examples/mut_counter.ail(Int) andexamples/mut_sum_floats.ail(Float) authored; both use an accumulator-style tail-recursive helper since while-loops are deferred. Initial draft used the natural(app + lo (tail-app ...))shape and was rejected by the tail-call analysis ("call markedtailis not in tail position") — restructured to passaccas a third arg and yield it directly in the base case. Both fixtures typecheck clean and print55(Float via%gstrips.0). - iter mut.3.8:
mut_counter_prints_55+mut_sum_floats_prints_55appended tocrates/ail/tests/e2e.rs. The plan's placeholderexpected = "55"resolved by inspection ofexamples/floats.ailcrates/ail/tests/floats_e2e.rs(where1.5 + 2.5prints as4, confirming%g-driven trailing-zero strip — so55.0prints as55); test pinned to"55".
- iter mut.3.9: DESIGN.md "Local mutable state" bullet appended after the "Parameterised ADTs" bullet at the end of the "What is supported" subsection. Schema-drift tests pass (the bullet lives outside §"Data model").
Two beyond-plan adjustments emerged during execution and were absorbed inline (recorded as Concerns below):
synth_with_extras'sTerm::Vararm also needed mut-var lookup precedence. The plan's Task 3 only touchedlower_term'sTerm::Vararm; without the parallelsynth_with_extrasupdate, every polymorphic-call-site arg-type derivation that reaches a mut-var reference fails withUnknownVar. Discovered by runningexamples/mut.ailthrough codegen and observing themut_single_varcase fail at(app + x 1).- Lambda emission (
lambda.rs) needed save/restore of the three new fields PLUS an in-thunk splice ofpending_entry_allocasat the thunk's own entry marker. Without this, aTerm::Mutinside a lambda body would push its alloca into the outer fn's side buffer (or worse, leak into whichever fn's body the splice runs on). Mirrors howlambda.rsalready saves/restoresnon_escape/moved_slots/current_param_modesacross the thunk boundary.
Concerns
- Synth-side
Term::Varmut-var-lookup parallel was not in the plan but is structurally required. Recorded as a plan defect to feed into the iter-mut.4 lessons (if any) and into the milestone- close audit. - Lambda-boundary save/restore + in-thunk splice was not in the
plan but is structurally required for correctness of any
Term::Mutinside a lambda body. Same defect category as above.
Known debt
- None for the milestone-local scope. The spec's deferred items
(while-loops, heap-RC mut-var element types,
ref a+!Muteffect,Stateful a b+pipe) are explicitly named as future milestones in the new DESIGN.md bullet.
Blocked detail
n/a — Status: DONE.
Files touched
crates/ailang-codegen/src/lib.rs— threeEmitterfields, init- per-fn reset,
start_blockmarker capture,Term::Vararm mut-var precedence (bothlower_termandsynth_with_extras),Term::Mut+Term::Assignarms inlower_term, splice at end ofemit_fn.
- per-fn reset,
crates/ailang-codegen/src/lambda.rs— save/restore of the three new fields across the thunk boundary, in-thunk splice ofpending_entry_allocas.crates/ail/tests/e2e.rs— two new tests pinningmut_counter.ail→"55"andmut_sum_floats.ail→"55".docs/DESIGN.md— "Local mutable state" bullet at end of "What is supported" subsection.examples/mut_counter.ail— new Int fixture.examples/mut_sum_floats.ail— new Float fixture.
Stats
bench/orchestrator-stats/2026-05-15-iter-mut.3.json