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
+174 -10
View File
@@ -714,6 +714,27 @@ struct Emitter<'a> {
/// dec'ing, because Implicit and Borrow do not carry the "caller
/// handed off ownership" signal that makes the dec safe.
current_param_modes: BTreeMap<String, ParamMode>,
/// Iter mut.3: per-fn map of mut-var name → (alloca SSA name,
/// AIL element type). Populated when entering a `Term::Mut`
/// block; entries removed (or restored to their prior binding)
/// when leaving the block. Consulted by the `Term::Var` arm of
/// `lower_term` before `self.locals`, matching the typecheck-
/// side mut-scope-stack precedence (`synth` in `ailang-check`
/// walks `mut_scope_stack` innermost-first before falling
/// through to locals/globals).
mut_var_allocas: BTreeMap<String, (String, Type)>,
/// Iter mut.3: side buffer for `alloca` instructions emitted
/// during body lowering but hoisted to the fn's entry block.
/// Flushed once into `self.body` at `entry_block_end_marker`
/// after `lower_term` completes. Entry-block placement is
/// what makes the allocas mem2reg-eligible regardless of how
/// deeply nested the originating `Term::Mut` block is.
pending_entry_allocas: String,
/// Iter mut.3: byte position in `self.body` immediately after
/// the `entry:\n` label, captured during `start_block("entry")`
/// at fn-body emission. Used to splice `pending_entry_allocas`
/// into the entry block once body lowering completes.
entry_block_end_marker: Option<usize>,
}
#[derive(Debug, Clone)]
@@ -831,12 +852,23 @@ impl<'a> Emitter<'a> {
closure_drops: BTreeMap::new(),
moved_slots: BTreeMap::new(),
current_param_modes: BTreeMap::new(),
mut_var_allocas: BTreeMap::new(),
pending_entry_allocas: String::new(),
entry_block_end_marker: None,
}
}
pub(crate) fn start_block(&mut self, label: &str) {
self.body.push_str(label);
self.body.push_str(":\n");
// Iter mut.3: capture the entry-block splice point for
// `pending_entry_allocas`. The marker points to the byte
// position immediately after the `entry:\n` label — the
// splice (in `emit_fn`'s tail) inserts the hoisted allocas
// there, before any subsequent body emission.
if label == "entry" {
self.entry_block_end_marker = Some(self.body.len());
}
self.current_block = label.to_string();
self.block_terminated = false;
}
@@ -1021,6 +1053,13 @@ impl<'a> Emitter<'a> {
// consulted by `lower_match`'s Iter A gate to skip arm-close
// pattern-binder dec when the scrutinee is a non-Own param.
self.current_param_modes.clear();
// Iter mut.3: per-fn-body mut-var bookkeeping. The map is
// populated/restored by the `Term::Mut` arm of `lower_term`;
// the side buffer collects alloca instructions for hoisting;
// the marker is captured by `start_block("entry")` below.
self.mut_var_allocas.clear();
self.pending_entry_allocas.clear();
self.entry_block_end_marker = None;
for (i, pname) in f.params.iter().enumerate() {
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
self.current_param_modes.insert(pname.clone(), mode);
@@ -1203,6 +1242,27 @@ impl<'a> Emitter<'a> {
}
} // close `if !body_was_intercepted`
// Iter mut.3: splice any deferred entry-block allocas at the
// captured marker (immediately after the `entry:\n` label).
// If no mut-vars were emitted for this fn,
// `pending_entry_allocas` is empty and this is a no-op. The
// splice runs after body lowering finalises but BEFORE the
// deferred-thunks flush — thunks are already self-contained
// strings parked in `deferred_thunks`, with their own splice
// already performed inside the lambda emission boundary in
// `lambda.rs`.
if !self.pending_entry_allocas.is_empty() {
let marker = self.entry_block_end_marker.ok_or_else(|| {
CodegenError::Internal(format!(
"fn `{}`: pending_entry_allocas accumulated but no entry-block \
marker was captured",
f.name
))
})?;
let allocas = std::mem::take(&mut self.pending_entry_allocas);
self.body.insert_str(marker, &allocas);
}
// Iter 8b: flush lambda thunks collected while lowering this fn's
// body. They go after the closing `}` of the parent fn, before
// the adapter, so the parent fn is contiguous.
@@ -1317,6 +1377,23 @@ impl<'a> Emitter<'a> {
self.block_terminated = true;
return Ok(("0".into(), "i8".into()));
}
// Iter mut.3: mut-vars take precedence over let-bound
// / param resolutions, symmetric to the typecheck-side
// mut-scope-stack walk in `synth`'s `Term::Var` arm.
// Shadowing across nested mut blocks is handled by the
// `Term::Mut` arm's push-and-restore on the BTreeMap
// entry, so a single `get` suffices here (the entry
// visible at this point is the innermost binding).
if let Some((alloca_name, ail_ty)) =
self.mut_var_allocas.get(name).cloned()
{
let llvm_ty = llvm_type(&ail_ty)?;
let load_ssa = self.fresh_ssa();
self.body.push_str(&format!(
" {load_ssa} = load {llvm_ty}, ptr {alloca_name}\n"
));
return Ok((load_ssa, llvm_ty));
}
if let Some((_, ssa, ty, _)) =
self.locals.iter().rev().find(|(n, _, _, _)| n == name)
{
@@ -1679,15 +1756,96 @@ impl<'a> Emitter<'a> {
// at `synth` (iter mut.1, same spec subsection), so the
// codegen stub is a defense-in-depth — under normal
// flow, typecheck has already rejected.
Term::Mut { .. } => {
Err(CodegenError::Internal(
"Term::Mut not yet supported in codegen (deferred to iter mut.3)".into(),
))
// Iter mut.3: lowering of a `Term::Mut` block. Each var
// gets an `alloca` emitted into the entry-block side
// buffer (hoisted; mem2reg-eligible regardless of nesting
// depth) plus a `store` of its init at the current body
// position. The map binding (name → alloca, AIL type) is
// installed AFTER the init is lowered, mirroring the
// typecheck-side ordering in `synth`'s `Term::Mut` arm:
// a var's own init does NOT see its own binding (uses
// the outer scope plus any earlier vars of the same
// block, which ARE bound because we install them as we
// go through the loop). On block exit we restore the
// prior binding (if any) — innermost-wins shadowing
// across nested mut blocks.
Term::Mut { vars, body } => {
let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new();
for v in vars {
let alloca_name = format!("%mut_{}_{}", v.name, self.fresh_id());
let llvm_ty = llvm_type(&v.ty)?;
// Hoisted alloca — goes into the side buffer,
// spliced into the entry block at the end of
// emit_fn.
self.pending_entry_allocas.push_str(&format!(
" {alloca_name} = alloca {llvm_ty}\n"
));
// Init runs in the outer scope plus any vars
// already pushed into `saved` (i.e. earlier vars
// of this same `Term::Mut`), mirroring the
// typecheck-side ordering at lib.rs:3578.
let (init_ssa, init_ty) = self.lower_term(&v.init)?;
if init_ty != llvm_ty {
return Err(CodegenError::Internal(format!(
"Term::Mut var `{}`: init LLVM type {init_ty} != declared {llvm_ty}",
v.name
)));
}
self.body.push_str(&format!(
" store {llvm_ty} {init_ssa}, ptr {alloca_name}\n"
));
let prior = self
.mut_var_allocas
.insert(v.name.clone(), (alloca_name, v.ty.clone()));
saved.push((v.name.clone(), prior));
}
let body_result = self.lower_term(body);
// Restore prior bindings — done unconditionally so an
// error during body lowering doesn't leak inner-frame
// entries into the outer scope (defensive; `lower_term`
// returns `Result`, and we propagate after restoring).
for (name, prior) in saved.into_iter().rev() {
match prior {
Some(p) => {
self.mut_var_allocas.insert(name, p);
}
None => {
self.mut_var_allocas.remove(&name);
}
}
}
body_result
}
Term::Assign { .. } => {
Err(CodegenError::Internal(
"Term::Assign not yet supported in codegen (deferred to iter mut.3)".into(),
))
// Iter mut.3: lowering of a `Term::Assign`. Resolves the
// target name through `mut_var_allocas` (the typecheck
// pass has already pinned the lexical-scope invariant,
// so a miss here is an internal error — the assign would
// have been rejected with `MutAssignOutOfScope`). The
// assign's static type is Unit; we return the canonical
// Unit SSA form `("0", "i8")` matching `Literal::Unit`
// in this same `match` (line ~1289).
Term::Assign { name, value } => {
let (alloca_name, ail_ty) = self
.mut_var_allocas
.get(name)
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"Term::Assign `{name}` reached codegen without a mut-var alloca \
— typecheck should have rejected this earlier"
))
})?;
let llvm_ty = llvm_type(&ail_ty)?;
let (value_ssa, value_ty) = self.lower_term(value)?;
if value_ty != llvm_ty {
return Err(CodegenError::Internal(format!(
"Term::Assign `{name}`: value LLVM type {value_ty} != declared {llvm_ty}"
)));
}
self.body.push_str(&format!(
" store {llvm_ty} {value_ssa}, ptr {alloca_name}\n"
));
Ok(("0".into(), "i8".into()))
}
}
}
@@ -2710,13 +2868,19 @@ impl<'a> Emitter<'a> {
}),
Term::Var { name } => {
// Lookup precedence: extras (let-bindings introduced
// during this synth walk) → emitter locals → globals
// → builtins. Mirrors typechecker shadowing.
// during this synth walk) → mut-var allocas → emitter
// locals → globals → builtins. Mirrors typechecker
// shadowing and the lower_term-side precedence
// (mut.3: mut-vars in scope take precedence over
// outer let-bound / param resolutions).
for (n, ty) in extras.iter().rev() {
if n == name {
return Ok(ty.clone());
}
}
if let Some((_, ail_ty)) = self.mut_var_allocas.get(name) {
return Ok(ail_ty.clone());
}
if let Some((_, _, _, ail)) =
self.locals.iter().rev().find(|(n, _, _, _)| n == name)
{