Files
AILang/docs/journals/2026-05-15-iter-mut.3.md
T
Brummel 03fb633d85 iter mut.3: codegen + e2e — mut-local milestone end-to-end closed
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.
2026-05-15 01:56:26 +02:00

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 Emitter fields (mut_var_allocas, pending_entry_allocas, entry_block_end_marker) added with doc comments; initialised in Emitter::new; cleared per-fn at the top of emit_fn next to the existing per-fn-reset block.
  • iter mut.3.2: start_block extended — when label == "entry" it captures self.body.len() as entry_block_end_marker (the splice point for pending_entry_allocas).
  • iter mut.3.3: Term::Var arm of lower_term gained a mut-var lookup before the self.locals walk — on hit, emits %v_N = load <llvm_ty>, ptr <alloca_ssa> and returns the load SSA
    • LLVM type.
  • iter mut.3.4: Term::Mut arm of lower_term real 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::Assign arm of lower_term real lowering — look up the alloca in mut_var_allocas, lower value, emit store, return the canonical Unit SSA form ("0", "i8") matching Literal::Unit in the same match. Tasks 4 and 5 were applied in one Edit call because both arms were adjacent stubs that needed simultaneous replacement (the match arm 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_allocas at the marker — inserted into self.body at the end of emit_fn (after body finalisation, before the deferred-thunks flush). Marker absence while pending is non-empty fires CodegenError::Internal with the fn name.
  • iter mut.3.7: examples/mut_counter.ail (Int) and examples/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 marked tail is not in tail position") — restructured to pass acc as a third arg and yield it directly in the base case. Both fixtures typecheck clean and print 55 (Float via %g strips .0).
  • iter mut.3.8: mut_counter_prints_55 + mut_sum_floats_prints_55 appended to crates/ail/tests/e2e.rs. The plan's placeholder expected = "55" resolved by inspection of examples/floats.ail
    • crates/ail/tests/floats_e2e.rs (where 1.5 + 2.5 prints as 4, confirming %g-driven trailing-zero strip — so 55.0 prints as 55); 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's Term::Var arm also needed mut-var lookup precedence. The plan's Task 3 only touched lower_term's Term::Var arm; without the parallel synth_with_extras update, every polymorphic-call-site arg-type derivation that reaches a mut-var reference fails with UnknownVar. Discovered by running examples/mut.ail through codegen and observing the mut_single_var case fail at (app + x 1).
  • Lambda emission (lambda.rs) needed save/restore of the three new fields PLUS an in-thunk splice of pending_entry_allocas at the thunk's own entry marker. Without this, a Term::Mut inside 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 how lambda.rs already saves/restores non_escape / moved_slots / current_param_modes across the thunk boundary.

Concerns

  • Synth-side Term::Var mut-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::Mut inside 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 + !Mut effect, 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 — three Emitter fields, init
    • per-fn reset, start_block marker capture, Term::Var arm mut-var precedence (both lower_term and synth_with_extras), Term::Mut + Term::Assign arms in lower_term, splice at end of emit_fn.
  • crates/ailang-codegen/src/lambda.rs — save/restore of the three new fields across the thunk boundary, in-thunk splice of pending_entry_allocas.
  • crates/ail/tests/e2e.rs — two new tests pinning mut_counter.ail"55" and mut_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