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.
This commit is contained in:
2026-05-15 01:56:26 +02:00
parent b057789b55
commit 03fb633d85
9 changed files with 454 additions and 10 deletions
+30
View File
@@ -126,6 +126,17 @@ impl<'a> Emitter<'a> {
// tracking — the outer fn's binders are not in scope inside
// the thunk body (only its captures + params). Save and reset.
let saved_moved = std::mem::take(&mut self.moved_slots);
// Iter mut.3: a lambda thunk is its own fn frame for the
// mut-var bookkeeping introduced in iter mut.3 — the outer
// fn's mut-vars are not in scope inside the thunk (a lambda
// body cannot reference enclosing mut-vars; the spec
// disallows capture of mut-vars by lambdas via the
// `mut_var_allocas` map being thunk-local). Save and reset
// the three fields so the thunk emits its own hoisted-
// alloca block at the thunk's entry, not the outer fn's.
let saved_mut_allocas = std::mem::take(&mut self.mut_var_allocas);
let saved_pending_allocas = std::mem::take(&mut self.pending_entry_allocas);
let saved_entry_marker = self.entry_block_end_marker.take();
// Iter 18d.4 fix: a lambda thunk is its own fn frame for
// param-mode lookup. The outer fn's params are not in scope
// inside the thunk; the thunk's own params are pushed below
@@ -208,6 +219,21 @@ impl<'a> Emitter<'a> {
self.body.push_str("}\n\n");
}
// Iter mut.3: splice any hoisted mut-var allocas into the
// thunk's entry block at the marker captured during the
// thunk's `start_block("entry")` above. Empty `pending` is
// a no-op (the thunk had no `Term::Mut` in its body).
if !self.pending_entry_allocas.is_empty() {
let marker = self.entry_block_end_marker.ok_or_else(|| {
CodegenError::Internal(
"lambda thunk: pending_entry_allocas accumulated but no entry-block \
marker was captured".into(),
)
})?;
let allocas = std::mem::take(&mut self.pending_entry_allocas);
self.body.insert_str(marker, &allocas);
}
// Park the thunk text in the deferred queue and restore outer
// emitter state.
let thunk_text = std::mem::take(&mut self.body);
@@ -221,6 +247,10 @@ impl<'a> Emitter<'a> {
self.non_escape = saved_non_escape;
self.moved_slots = saved_moved;
self.current_param_modes = saved_param_modes;
// Iter mut.3: restore outer fn's mut-var bookkeeping.
self.mut_var_allocas = saved_mut_allocas;
self.pending_entry_allocas = saved_pending_allocas;
self.entry_block_end_marker = saved_entry_marker;
// 3. Emit allocation + capture filling + closure-pair packing
// in the OUTER body. Captures use 8 bytes each; closure-pair