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:
@@ -2815,3 +2815,28 @@ fn str_clone_cross_realisation_uniform_abi() {
|
||||
assert_eq!(frees, 3, "expected three matching frees; got frees={frees}");
|
||||
assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
|
||||
}
|
||||
|
||||
/// Iter mut.3: `examples/mut_counter.ail` exercises `Term::Mut` +
|
||||
/// `Term::Assign` codegen with an `Int` mut-var. The body
|
||||
/// `(mut (var sum Int 0) (assign sum (sum_helper 1 10 0)) sum)`
|
||||
/// stores the helper's result into `sum`'s alloca and prints the
|
||||
/// loaded value. End-to-end gate for the entry-block-hoist /
|
||||
/// store / load lowering.
|
||||
#[test]
|
||||
fn mut_counter_prints_55() {
|
||||
let stdout = build_and_run("mut_counter.ail");
|
||||
assert_eq!(stdout.trim(), "55", "mut_counter must print 55, got {stdout:?}");
|
||||
}
|
||||
|
||||
/// Iter mut.3: Float twin of `mut_counter_prints_55`. The mut-var
|
||||
/// is `Float`, init is `0.0`, the recursive helper returns the
|
||||
/// sum 1.0+...+10.0 = 55.0. The polymorphic `print` routes through
|
||||
/// `Show Float.show` → `float_to_str`'s libc `%g` formatter, which
|
||||
/// strips the trailing `.0` — so the canonical stdout for Float
|
||||
/// 55.0 is `"55"` (matches `examples/floats.ail`'s output of
|
||||
/// `4` for `1.5 + 2.5`).
|
||||
#[test]
|
||||
fn mut_sum_floats_prints_55() {
|
||||
let stdout = build_and_run("mut_sum_floats.ail");
|
||||
assert_eq!(stdout.trim(), "55", "mut_sum_floats must print 55, got {stdout:?}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user