plan: mut.3 — codegen + e2e for Term::Mut / Term::Assign
Nine tasks: Emitter struct extension with mut_var_allocas +
pending_entry_allocas + entry_block_end_marker fields (Task 1);
start_block captures the entry-block byte marker (Task 2);
Term::Var resolution prepends mut-var lookup with load emission
(Task 3); Term::Mut codegen — alloca to side buffer, init at
current position, store to alloca, push binding (Task 4);
Term::Assign codegen — load alloca, lower value, store (Task 5);
final flush of pending_entry_allocas into self.body at the marker
(Task 6); examples/mut_counter.ail (Int) + examples/mut_sum_floats.ail
(Float) (Task 7); e2e tests in crates/ail/tests/e2e.rs (Task 8);
DESIGN.md bullet under 'What is supported' (Task 9).
Five Boss decisions encoded: entry-block hoist via String::insert_str
at the captured marker (chosen over post-pass or non-standard
emit-at-current); no LLVM helper wrapper — direct push_str matches
existing codebase convention; e2e tests append to the existing
crates/ail/tests/e2e.rs (reuse build_and_run helper); DESIGN.md
bullet lives in 'What is supported' subsection (recon misread —
the section exists at line 2680); Unit-codegen via the existing
Term::Lit { lit: Unit } canonical form.
This commit is contained in:
@@ -0,0 +1,716 @@
|
||||
# mut.3 — codegen + e2e — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/2026-05-15-mut-local.md`
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
|
||||
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Replace the iter mut.1 dispatch stubs at
|
||||
`crates/ailang-codegen/src/lib.rs:1683-1691` 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`. Extend the
|
||||
codegen-side `Term::Var` resolution ladder to consult mut-var allocas
|
||||
before `self.locals`. Ship two end-to-end fixtures
|
||||
(`examples/mut_counter.ail` + `examples/mut_sum_floats.ail`) and
|
||||
two e2e test cases asserting expected stdout. Append a bullet to
|
||||
DESIGN.md §"What is supported".
|
||||
|
||||
**Architecture:** Mut-var allocas land in the fn's entry block via
|
||||
a side buffer `pending_entry_allocas: String` accumulated during
|
||||
body lowering and flushed once into `self.body` at a tracked byte
|
||||
position immediately after `start_block("entry")` emits the `entry:`
|
||||
label. This is a deliberate departure from the existing alloca
|
||||
sites (`match_lower.rs:114`, `lambda.rs:240/266`) which emit allocas
|
||||
at the current body position — those allocas are one-shot per fn
|
||||
invocation and don't suffer from being non-entry-block. Mut-var
|
||||
allocas in deeply-nested blocks (e.g. inside `if`/`match`/`seq`)
|
||||
must remain accessible across the whole fn body, so mem2reg
|
||||
eligibility (entry-block placement) matters. Mut-var lookup at
|
||||
codegen uses a per-fn `mut_var_allocas: BTreeMap<String, (String,
|
||||
Type)>` mapping name → (alloca SSA name, AIL type). Push entries
|
||||
on `Term::Mut`-entry, remove on exit; lexical scoping mirrors the
|
||||
mut.2 typecheck-side stack.
|
||||
|
||||
**Tech Stack:** `ailang-codegen` only. No `ailang-core`,
|
||||
`ailang-surface`, `ailang-check`, `ailang-prose`, or `ail` source
|
||||
changes (one new test fn in `crates/ail/tests/e2e.rs`). Two new
|
||||
example fixtures + one DESIGN.md amendment.
|
||||
|
||||
---
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Create: `examples/mut_counter.ail` — sum 1..10 via mut, prints 55.
|
||||
- Create: `examples/mut_sum_floats.ail` — Float twin (sum 1.0..10.0),
|
||||
prints the Float total.
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:716-717` — add
|
||||
`mut_var_allocas: BTreeMap<String, (String, Type)>` and
|
||||
`pending_entry_allocas: String` and `entry_block_end_marker: Option<usize>`
|
||||
fields to `Emitter`.
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:820-842` — initialise the
|
||||
three new fields in `Emitter::new`.
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1008-1034` — reset the
|
||||
three new fields per fn-body in `emit_fn`, and capture the
|
||||
entry-block byte marker after `start_block("entry")`.
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:837-842` — extend
|
||||
`start_block` (or its caller) to record `entry_block_end_marker`
|
||||
on entry-block emission.
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1292-1357` (`Term::Var`
|
||||
arm) — prepend a mut-var lookup that emits a `load` and returns
|
||||
the SSA name.
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1683-1691` — replace
|
||||
the `Term::Mut`/`Term::Assign` `Internal` stubs with real lowering.
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs` (end of `emit_fn`) —
|
||||
flush `pending_entry_allocas` into `self.body` at the marker.
|
||||
- Modify: `crates/ail/tests/e2e.rs` — add `mut_counter_prints_55`
|
||||
and `mut_sum_floats_prints_55_dot_0` test fns.
|
||||
- Modify: `docs/DESIGN.md` — append a bullet to the "What **is**
|
||||
supported" subsection around line 2680.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — `Emitter` field additions + per-fn reset
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:716-717` (struct fields)
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:820-842` (`new`)
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1008-1034` (`emit_fn` reset)
|
||||
|
||||
- [ ] **Step 1: Add three fields to `Emitter`**
|
||||
|
||||
Locate the `Emitter` struct around `crates/ailang-codegen/src/lib.rs:716`.
|
||||
After the existing field `current_param_modes: BTreeMap<String, ParamMode>`
|
||||
(line 717), insert:
|
||||
|
||||
```rust
|
||||
/// 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 when leaving the block. Consulted by
|
||||
/// the `Term::Var` arm of `lower_term` before `self.locals`,
|
||||
/// matching the typecheck-side mut-scope-stack precedence.
|
||||
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.
|
||||
pending_entry_allocas: String,
|
||||
|
||||
/// Iter mut.3: byte position in `self.body` immediately after
|
||||
/// the `entry:` 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>,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Initialise in `Emitter::new`**
|
||||
|
||||
Locate `Emitter::new` around `crates/ailang-codegen/src/lib.rs:820`.
|
||||
Near the existing `closure_drops: BTreeMap::new(),` line (around 831),
|
||||
add:
|
||||
|
||||
```rust
|
||||
mut_var_allocas: BTreeMap::new(),
|
||||
pending_entry_allocas: String::new(),
|
||||
entry_block_end_marker: None,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Reset in `emit_fn`**
|
||||
|
||||
Locate the per-fn reset block in `emit_fn` (around
|
||||
`crates/ailang-codegen/src/lib.rs:1008-1034` — where
|
||||
`self.locals.clear()`, `self.counter = 0`, `self.moved_slots.clear()`,
|
||||
`self.current_param_modes.clear()` already live). Append:
|
||||
|
||||
```rust
|
||||
self.mut_var_allocas.clear();
|
||||
self.pending_entry_allocas.clear();
|
||||
self.entry_block_end_marker = None;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify build green**
|
||||
|
||||
Run: `cargo build --workspace 2>&1 | tail -10`
|
||||
Expected: green (the three new fields are added but not yet
|
||||
consumed; the only consumers are added in Tasks 2-3).
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — `start_block` records the entry-block marker
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:837-842` (`start_block`)
|
||||
|
||||
`start_block` is the canonical helper that emits `<label>:\n` and
|
||||
positions the body for subsequent instruction emission. The entry
|
||||
block is the first one emitted per fn. Marker capture happens here.
|
||||
|
||||
- [ ] **Step 1: Locate `start_block`**
|
||||
|
||||
Around `crates/ailang-codegen/src/lib.rs:837`, find:
|
||||
|
||||
```rust
|
||||
fn start_block(&mut self, label: &str) {
|
||||
self.body.push_str(label);
|
||||
self.body.push_str(":\n");
|
||||
}
|
||||
```
|
||||
|
||||
(Exact body may differ; read the existing form first.)
|
||||
|
||||
- [ ] **Step 2: Capture the entry-block marker**
|
||||
|
||||
Replace with (or extend) the body so that when `label == "entry"`,
|
||||
the byte position immediately after the `entry:\n` is captured:
|
||||
|
||||
```rust
|
||||
fn start_block(&mut self, label: &str) {
|
||||
self.body.push_str(label);
|
||||
self.body.push_str(":\n");
|
||||
if label == "entry" {
|
||||
self.entry_block_end_marker = Some(self.body.len());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The marker points to the byte index where the next character would
|
||||
be written — i.e. the splice point for `pending_entry_allocas`.
|
||||
Splicing at this position inserts the alloca block immediately after
|
||||
the `entry:\n` label and before any subsequent body emission.
|
||||
|
||||
- [ ] **Step 3: Verify build green**
|
||||
|
||||
Run: `cargo build --workspace 2>&1 | tail -5`
|
||||
Expected: green.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — `Term::Var` resolution prepends mut-var lookup
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1292-1357` (`Term::Var` arm of `lower_term`)
|
||||
|
||||
- [ ] **Step 1: Locate the `Term::Var` arm**
|
||||
|
||||
Around `crates/ailang-codegen/src/lib.rs:1292`, the existing arm
|
||||
walks: `__unreachable__` → `self.locals.iter().rev().find(...)` →
|
||||
`resolve_top_level_fn` → `resolve_const` → emit `UnknownVar`. The
|
||||
mut-var lookup slots immediately before `self.locals`.
|
||||
|
||||
- [ ] **Step 2: Prepend mut-var lookup**
|
||||
|
||||
Insert before the `self.locals.iter().rev().find(...)` call:
|
||||
|
||||
```rust
|
||||
// Iter mut.3: mut-vars take precedence over let-bound
|
||||
// / param resolutions. Innermost-first (BTreeMap iterates
|
||||
// in sorted order, but per-fn entries are unique per
|
||||
// name with innermost-wins shadowing already enforced
|
||||
// at typecheck time, so a single lookup suffices here).
|
||||
if let Some((alloca_name, ail_ty)) = self.mut_var_allocas.get(name).cloned() {
|
||||
let llvm_ty = self.llvm_type(&ail_ty);
|
||||
let load_ssa = self.fresh_ssa_name("mut_load");
|
||||
self.body.push_str(&format!(
|
||||
" {load_ssa} = load {llvm_ty}, ptr {alloca_name}\n"
|
||||
));
|
||||
return Ok(load_ssa);
|
||||
}
|
||||
```
|
||||
|
||||
The exact helper names (`self.llvm_type`, `self.fresh_ssa_name`,
|
||||
`self.body`, the return-tuple shape) must match the existing arms in
|
||||
`lower_term` — the implementer reads adjacent arms (e.g. the
|
||||
`self.locals.iter().rev().find()` branch immediately below) to copy
|
||||
the canonical form. The example above is shape-correct but the
|
||||
function-call sites (`llvm_type` / `fresh_ssa_name`) may be different
|
||||
in this codebase; verify before writing.
|
||||
|
||||
Note: shadowing across nested mut blocks is handled by the
|
||||
push/remove in Task 4, not by stack ordering here — when an inner
|
||||
mut block shadows an outer's name, the inner's push overwrites the
|
||||
`BTreeMap` entry; the outer's restored on inner-exit.
|
||||
|
||||
- [ ] **Step 3: Verify build green**
|
||||
|
||||
Run: `cargo build --workspace 2>&1 | tail -5`
|
||||
Expected: green.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — `Term::Mut` codegen arm
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1683-1687` — replace the `Term::Mut` stub.
|
||||
|
||||
- [ ] **Step 1: Replace the stub**
|
||||
|
||||
Around `crates/ailang-codegen/src/lib.rs:1683`, replace:
|
||||
|
||||
```rust
|
||||
Term::Mut { .. } => {
|
||||
Err(CodegenError::Internal(
|
||||
"Term::Mut not yet supported in codegen (deferred to iter mut.3)".into(),
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
with the real lowering:
|
||||
|
||||
```rust
|
||||
Term::Mut { vars, body } => {
|
||||
// Saved-binding map for proper shadowing on exit.
|
||||
let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new();
|
||||
|
||||
for v in vars {
|
||||
let alloca_name = self.fresh_ssa_name(&format!("mut_{}", v.name));
|
||||
let llvm_ty = self.llvm_type(&v.ty);
|
||||
|
||||
// Emit the alloca into the entry-block side
|
||||
// buffer (hoisted, not at current body position).
|
||||
self.pending_entry_allocas.push_str(&format!(
|
||||
" {alloca_name} = alloca {llvm_ty}\n"
|
||||
));
|
||||
|
||||
// Lower the init expression at the current body
|
||||
// position. The mut-var binding is NOT yet
|
||||
// visible during init (matches typecheck-side
|
||||
// ordering at lib.rs:3576).
|
||||
let init_ssa = self.lower_term(&v.init, /* whatever existing params */)?;
|
||||
|
||||
// Store the init result into the alloca at the
|
||||
// current body position.
|
||||
self.body.push_str(&format!(
|
||||
" store {llvm_ty} {init_ssa}, ptr {alloca_name}\n"
|
||||
));
|
||||
|
||||
// Now bind the name → alloca, saving any prior
|
||||
// entry for restoration on block exit.
|
||||
let prior = self.mut_var_allocas.insert(
|
||||
v.name.clone(),
|
||||
(alloca_name, v.ty.clone()),
|
||||
);
|
||||
saved.push((v.name.clone(), prior));
|
||||
}
|
||||
|
||||
// Lower the body in the extended scope.
|
||||
let body_ssa = self.lower_term(body, /* whatever existing params */)?;
|
||||
|
||||
// Restore bindings on exit.
|
||||
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); }
|
||||
}
|
||||
}
|
||||
|
||||
Ok(body_ssa)
|
||||
}
|
||||
```
|
||||
|
||||
The exact `lower_term` call signature must match the existing arms
|
||||
in the same `match` — the implementer reads an adjacent arm (e.g.
|
||||
`Term::Let` or `Term::Seq`) for the canonical form. The init/store
|
||||
ordering inside the loop is the load-bearing semantic: the spec
|
||||
demands "vars are checked in order with the in-progress scope" at
|
||||
typecheck (`lib.rs:3576`), and codegen mirrors this by binding only
|
||||
AFTER the init is lowered.
|
||||
|
||||
- [ ] **Step 2: Build + verify the mut.ail typecheck-clean fns now don't reach codegen stub for Mut**
|
||||
|
||||
Run: `cargo build --workspace 2>&1 | tail -5`
|
||||
Expected: green.
|
||||
|
||||
Run: `cargo run --quiet --bin ail -- build examples/mut.ail 2>&1 | head -20`
|
||||
Expected: this command may fail at runtime for some of the six fns
|
||||
that use Bool/Unit (not yet covered by Task 5 in lower_term's
|
||||
`Term::Assign` arm) — but the build should NOT produce the
|
||||
"Term::Mut not yet supported" error. If it does, the Task 4 arm
|
||||
isn't reached; debug before continuing.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — `Term::Assign` codegen arm
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1688-1691` — replace the `Term::Assign` stub.
|
||||
|
||||
- [ ] **Step 1: Replace the stub**
|
||||
|
||||
Around `crates/ailang-codegen/src/lib.rs:1688`, replace:
|
||||
|
||||
```rust
|
||||
Term::Assign { .. } => {
|
||||
Err(CodegenError::Internal(
|
||||
"Term::Assign not yet supported in codegen (deferred to iter mut.3)".into(),
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
with the real lowering:
|
||||
|
||||
```rust
|
||||
Term::Assign { name, value } => {
|
||||
// Look up the mut-var's alloca and AIL type.
|
||||
let (alloca_name, ail_ty) = self.mut_var_allocas
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| CodegenError::Internal(
|
||||
format!("Term::Assign {{ name: {name:?} }} reached codegen without a mut-var alloca — typecheck should have rejected this earlier")
|
||||
))?;
|
||||
|
||||
let llvm_ty = self.llvm_type(&ail_ty);
|
||||
let value_ssa = self.lower_term(value, /* existing params */)?;
|
||||
|
||||
self.body.push_str(&format!(
|
||||
" store {llvm_ty} {value_ssa}, ptr {alloca_name}\n"
|
||||
));
|
||||
|
||||
// Term::Assign's static type is Unit. Yield the
|
||||
// canonical Unit SSA value per the codegen
|
||||
// convention for Unit (verify against an adjacent
|
||||
// arm — likely a literal "i1 0" or similar; the
|
||||
// mut.ail mut_returns_unit fixture exercises this).
|
||||
Ok(self.unit_ssa()) // or whichever helper returns the canonical Unit SSA
|
||||
}
|
||||
```
|
||||
|
||||
The `self.unit_ssa()` helper name is a placeholder; the implementer
|
||||
identifies the canonical "Unit value" SSA emission convention from
|
||||
an adjacent codegen arm (e.g. `Term::Lit { lit: Literal::Unit }`)
|
||||
and uses whichever form matches.
|
||||
|
||||
- [ ] **Step 2: Verify build + mut.ail full pipeline**
|
||||
|
||||
Run: `cargo build --workspace 2>&1 | tail -5`
|
||||
Expected: green.
|
||||
|
||||
Run: `cargo run --quiet --bin ail -- build examples/mut.ail 2>&1 | head -10`
|
||||
Expected: build succeeds. (Whether the binary runs successfully is
|
||||
exercised in Task 7 with the dedicated e2e fixtures.)
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — Flush `pending_entry_allocas` at the end of `emit_fn`
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs` — the end of `emit_fn` (just before the fn body is written out / `}` is emitted).
|
||||
|
||||
- [ ] **Step 1: Locate the emit_fn end**
|
||||
|
||||
Around `crates/ailang-codegen/src/lib.rs:1008+`, find where `emit_fn`
|
||||
finishes assembling `self.body` and is about to flush it to
|
||||
`self.ir` (or wherever the final IR text accumulates). This is the
|
||||
splice point.
|
||||
|
||||
- [ ] **Step 2: Splice `pending_entry_allocas` at the marker**
|
||||
|
||||
Immediately before the body is written out:
|
||||
|
||||
```rust
|
||||
// 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.
|
||||
if !self.pending_entry_allocas.is_empty() {
|
||||
if let Some(marker) = self.entry_block_end_marker {
|
||||
let allocas = std::mem::take(&mut self.pending_entry_allocas);
|
||||
self.body.insert_str(marker, &allocas);
|
||||
} else {
|
||||
return Err(CodegenError::Internal(
|
||||
"pending_entry_allocas accumulated but no entry-block marker was captured".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This splices the alloca instructions into `self.body` at the byte
|
||||
position recorded by Task 2's `start_block` extension. The result is
|
||||
an entry block whose first instructions are `alloca` for each
|
||||
mut-var encountered anywhere in the fn body, followed by whatever
|
||||
the original entry-block instructions are.
|
||||
|
||||
- [ ] **Step 3: Verify build green**
|
||||
|
||||
Run: `cargo build --workspace 2>&1 | tail -5`
|
||||
Expected: green.
|
||||
|
||||
---
|
||||
|
||||
## Task 7 — `examples/mut_counter.ail` + `examples/mut_sum_floats.ail`
|
||||
|
||||
**Files:**
|
||||
- Create: `examples/mut_counter.ail`
|
||||
- Create: `examples/mut_sum_floats.ail`
|
||||
|
||||
- [ ] **Step 1: Write `examples/mut_counter.ail`**
|
||||
|
||||
Create `examples/mut_counter.ail`:
|
||||
|
||||
```
|
||||
(module mut_counter
|
||||
|
||||
(fn main
|
||||
(doc "Iter mut.3 — sum 1..10 via mut/var/assign. Expected stdout: 55.")
|
||||
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||||
(params)
|
||||
(body
|
||||
(app print
|
||||
(mut
|
||||
(var i (con Int) 1)
|
||||
(var sum (con Int) 0)
|
||||
(assign sum (loop_body i sum)))))))
|
||||
|
||||
(fn loop_body
|
||||
(doc "Helper — recursive sum 1..10. Replaced by a real loop in a future milestone.")
|
||||
(type (fn-type (params (con Int) (con Int)) (ret (con Int))))
|
||||
(params i acc)
|
||||
(body
|
||||
(if (app > i 10)
|
||||
acc
|
||||
(app loop_body (app + i 1) (app + acc i))))))
|
||||
```
|
||||
|
||||
Wait — this requires mut to call a helper, which the mut codegen
|
||||
supports (init / body / assign-value can be any Term). But the
|
||||
example's *idiomatic* form is harder to express without `while`
|
||||
(deferred). The simplest accurate form:
|
||||
|
||||
```
|
||||
(module mut_counter
|
||||
|
||||
(fn main
|
||||
(doc "Iter mut.3 — sum 1..10 via mut + recursive helper. Expected stdout: 55.")
|
||||
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||||
(params)
|
||||
(body
|
||||
(app print
|
||||
(mut
|
||||
(var sum (con Int) 0)
|
||||
(assign sum (app sum_helper 1 10))
|
||||
sum))))
|
||||
|
||||
(fn sum_helper
|
||||
(doc "Recursive helper — sum from lo through hi inclusive.")
|
||||
(type (fn-type (params (con Int) (con Int)) (ret (con Int))))
|
||||
(params lo hi)
|
||||
(body
|
||||
(if (app > lo hi)
|
||||
0
|
||||
(app + lo (tail-app sum_helper (app + lo 1) hi))))))
|
||||
```
|
||||
|
||||
The mut block exercises one var (`sum`) with a real `assign` of a
|
||||
non-trivial Term result. The recursive helper is the standing
|
||||
tail-call iteration shape per Decision 8. Expected stdout: `55`.
|
||||
|
||||
- [ ] **Step 2: Write `examples/mut_sum_floats.ail`**
|
||||
|
||||
Create `examples/mut_sum_floats.ail`:
|
||||
|
||||
```
|
||||
(module mut_sum_floats
|
||||
|
||||
(fn main
|
||||
(doc "Iter mut.3 — Float twin of mut_counter. Expected stdout: 55.0 (or whatever the polymorphic-print form emits for Float 55.0).")
|
||||
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||||
(params)
|
||||
(body
|
||||
(app print
|
||||
(mut
|
||||
(var sum (con Float) 0.0)
|
||||
(assign sum (app sum_helper 1.0 10.0))
|
||||
sum))))
|
||||
|
||||
(fn sum_helper
|
||||
(doc "Recursive Float helper — sum from lo through hi inclusive.")
|
||||
(type (fn-type (params (con Float) (con Float)) (ret (con Float))))
|
||||
(params lo hi)
|
||||
(body
|
||||
(if (app > lo hi)
|
||||
0.0
|
||||
(app + lo (tail-app sum_helper (app + lo 1.0) hi))))))
|
||||
```
|
||||
|
||||
Expected stdout: whatever the polymorphic Float `print` emits for
|
||||
the value `55.0`. The implementer verifies against an existing
|
||||
Float-print fixture (e.g. `examples/floats.ail`'s known output) to
|
||||
fix the expected string.
|
||||
|
||||
- [ ] **Step 3: Verify both fixtures round-trip + typecheck clean**
|
||||
|
||||
Run: `cargo run --quiet --bin ail -- check examples/mut_counter.ail 2>&1`
|
||||
Expected: `ok (...)`.
|
||||
|
||||
Run: `cargo run --quiet --bin ail -- check examples/mut_sum_floats.ail 2>&1`
|
||||
Expected: `ok (...)`.
|
||||
|
||||
Run: `cargo test --workspace -p ailang-surface round_trip 2>&1 | tail -5`
|
||||
Expected: PASS — the auto-glob round-trip picks up both new fixtures.
|
||||
|
||||
---
|
||||
|
||||
## Task 8 — e2e tests in `crates/ail/tests/e2e.rs`
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ail/tests/e2e.rs`
|
||||
|
||||
- [ ] **Step 1: Read the canonical e2e test pattern**
|
||||
|
||||
Open `crates/ail/tests/e2e.rs` and locate the `build_and_run`
|
||||
helper (around lines 13-38 per the recon). Read the convention for
|
||||
a `#[test]` that invokes it and asserts on stdout.
|
||||
|
||||
- [ ] **Step 2: Add the two test fns**
|
||||
|
||||
At the end of `crates/ail/tests/e2e.rs`, append:
|
||||
|
||||
```rust
|
||||
#[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:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mut_sum_floats_prints_55_dot() {
|
||||
let stdout = build_and_run("mut_sum_floats.ail");
|
||||
// The exact float-print form is whatever the polymorphic print
|
||||
// emits for 55.0 — likely "55" if the formatter drops trailing
|
||||
// zeros, "55.0" if it preserves them. Verify against
|
||||
// examples/floats.ail's expected output before pinning.
|
||||
let expected = "55"; // placeholder — implementer reads the floats.ail e2e for the canonical form
|
||||
assert_eq!(stdout.trim(), expected, "mut_sum_floats must print {expected}, got {stdout:?}");
|
||||
}
|
||||
```
|
||||
|
||||
The "placeholder" comment marks a piece of work the implementer must
|
||||
resolve at execution time by reading whichever existing Float-print
|
||||
e2e test pins the expected stdout — the placeholder MUST NOT survive
|
||||
into the committed file. Replace with the correct expected string
|
||||
before the test passes.
|
||||
|
||||
- [ ] **Step 3: Run the new tests**
|
||||
|
||||
Run: `cargo test --workspace --test e2e mut_counter_prints_55 mut_sum_floats_prints_55_dot 2>&1 | tail -10`
|
||||
Expected: both PASS.
|
||||
|
||||
Run: `cargo test --workspace 2>&1 | tail -5`
|
||||
Expected: full workspace green.
|
||||
|
||||
---
|
||||
|
||||
## Task 9 — DESIGN.md amendment
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/DESIGN.md` — append to "What **is** supported" subsection (around line 2680-2800).
|
||||
|
||||
- [ ] **Step 1: Locate the section**
|
||||
|
||||
Read `docs/DESIGN.md:2680-2800`. The section starts with:
|
||||
|
||||
```
|
||||
What **is** supported (and used as the smoke test for the pipeline):
|
||||
|
||||
- Int, Bool, Unit, **Str**, **Float** as primitive types.
|
||||
- `if`, `let`, function calls, recursion.
|
||||
...
|
||||
```
|
||||
|
||||
Find a suitable insertion point — likely after the "Anonymous lambdas
|
||||
with capture" bullet (around line 2776) or after "Parameterised
|
||||
ADTs" (around line 2788), depending on where the implementer judges
|
||||
the new bullet thematically fits.
|
||||
|
||||
- [ ] **Step 2: Append the bullet**
|
||||
|
||||
Insert (with appropriate context):
|
||||
|
||||
```
|
||||
- **Local mutable state.** `(mut (var <name> <type> <init>) ... <body>)`
|
||||
declares lexically-scoped mutable bindings whose types are restricted
|
||||
to the stack-resident scalars `Int`, `Float`, `Bool`, `Unit`. Inside
|
||||
the block, `(assign <name> <value>)` updates a mut-var; references
|
||||
to a mut-var name resolve to a `load` at codegen. Mut-vars are
|
||||
alloca-resident (hoisted to the fn's entry block, exercised by
|
||||
`examples/mut_counter.ail` and `examples/mut_sum_floats.ail`); they
|
||||
do not escape the enclosing mut block and do not introduce a `!Mut`
|
||||
effect onto the surrounding fn signature. Sealed-by-construction:
|
||||
the spec restricts var element types to non-RC-managed primitives
|
||||
and forbids first-class references, so Decision 10's "no shared
|
||||
mutable refs" invariant is preserved (each mut-var is uniquely held
|
||||
by its enclosing block). Future milestones on the Stateful-islands
|
||||
path lift the type restriction (heap-RC-managed Str + ADTs), add
|
||||
escaping references (`ref a` + `!Mut` effect), and introduce
|
||||
composable transducers (`Stateful a b` + `pipe`). Spec:
|
||||
`docs/specs/2026-05-15-mut-local.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify schema-drift tests still pass**
|
||||
|
||||
Run: `cargo test --workspace -p ailang-core schema_drift 2>&1 | tail -5`
|
||||
Expected: PASS — the drift tests scope themselves to §"Data model"
|
||||
(which is the §"Term (expression)" jsonc-blocked area, already
|
||||
amended in mut.1), and this new bullet lives outside that scope.
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist
|
||||
|
||||
- [ ] **Spec coverage:** every section of spec §"Iteration mut.3 —
|
||||
Codegen + e2e" has a matching task. Cross-check: codegen arms for
|
||||
`Term::Mut` (Task 4) and `Term::Assign` (Task 5), entry-block-
|
||||
alloca-hoist mechanism (Tasks 1+2+6), `Term::Var` resolution
|
||||
prepending (Task 3), two example fixtures (Task 7), e2e tests
|
||||
(Task 8), DESIGN.md amendment (Task 9). All covered.
|
||||
|
||||
- [ ] **Placeholder scan:** grep for "TBD" / "TODO" / "implement
|
||||
later" / "similar to Task" / "appropriate error handling" /
|
||||
"fill in later". Task 8 Step 2 contains a deliberate
|
||||
"implementer reads the floats.ail e2e for the canonical form"
|
||||
placeholder that must be resolved before the test file is
|
||||
committed — the step explicitly names this. No other placeholders.
|
||||
|
||||
- [ ] **Type-name consistency:** `mut_var_allocas`,
|
||||
`pending_entry_allocas`, `entry_block_end_marker`,
|
||||
`CodegenError::Internal`, `LLVMBuildAlloca` (referenced in spec
|
||||
but in this codebase emitted as text via `push_str`), `Term::Mut`,
|
||||
`Term::Assign`, `MutVar`. Each appears identically across tasks.
|
||||
|
||||
- [ ] **Step granularity:** each step is 2-5 minutes (a single
|
||||
field addition; a single arm replacement; a single splice point).
|
||||
|
||||
- [ ] **No commit steps:** verified — no `git commit` instructions.
|
||||
|
||||
- [ ] **Boss decisions named:** the five recon open questions are
|
||||
resolved: (1) entry-block-hoist via side buffer +
|
||||
`String::insert_str` splice at marker (Tasks 1+2+6); (2) no LLVM
|
||||
helper wrapper — direct `push_str` (Task 4+5 code shape);
|
||||
(3) e2e in `crates/ail/tests/e2e.rs` (Task 8); (4) DESIGN.md
|
||||
bullet in "What **is** supported" subsection (Task 9 Step 1);
|
||||
(5) Unit codegen via the existing `Term::Lit { lit: Unit }` arm's
|
||||
canonical SSA emission (Task 5 Step 1 instruction).
|
||||
|
||||
---
|
||||
|
||||
## Acceptance gate for iteration mut.3
|
||||
|
||||
- `cargo build --workspace` green.
|
||||
- `cargo test --workspace` green.
|
||||
- `cargo run --bin ail -- check examples/mut.ail` clean.
|
||||
- `cargo run --bin ail -- check examples/mut_counter.ail` clean.
|
||||
- `cargo run --bin ail -- check examples/mut_sum_floats.ail` clean.
|
||||
- `mut_counter.ail` builds and runs end-to-end via the e2e harness,
|
||||
printing `55`.
|
||||
- `mut_sum_floats.ail` builds and runs end-to-end, printing the
|
||||
canonical Float-print form for `55.0` (resolved per Task 8 Step 2).
|
||||
- DESIGN.md has the "Local mutable state" bullet under "What **is**
|
||||
supported".
|
||||
|
||||
After this iteration closes, the **mut-local milestone is closed**
|
||||
end-to-end:
|
||||
- Schema + surface from mut.1.
|
||||
- Typecheck from mut.2.
|
||||
- Codegen + e2e from mut.3.
|
||||
The follow-on Stateful-islands milestones (ref-types, !Mut effect,
|
||||
MutArray, Stateful + pipe) build on top of this. Audit (architect
|
||||
drift + bench regression) follows.
|
||||
Reference in New Issue
Block a user