iter it.1: loop/recur additive — Term::Loop/Term::Recur/LoopBinder end-to-end

Iteration-discipline milestone, 1 of 3. Adds named loop + recur as
strictly-additive first-class AST nodes: parse/print/prose/serde/
round-trip/schema lockstep, typecheck (binder typing + recur
arity/type unification via loop_stack threaded as mut.2's
mut_scope_stack; recur-tail-position via verify_loop_body), codegen
(loop-header + one phi per binder; recur back-edge br with a NEW
parallel block_terminated setter; lambda-boundary loop_frames
save/restore). Four Recur* CheckError variants. Strictly additive:
zero deletions touch tail-app/tail-do or the seven existing
block_terminated sites — this is what makes the destructive it.3
safe. recur synth = fresh metavar (resolves the plan's flagged
Type::unit() risk). loop_counter->55, loop_in_lambda->49, four
negatives fire, tail-app byte-identical, cargo test --workspace
green. Spec fda9b78, plan 7381a42.
This commit is contained in:
2026-05-15 14:38:55 +02:00
parent 7381a4233b
commit 96db54d15d
38 changed files with 1891 additions and 32 deletions
@@ -0,0 +1,22 @@
{
"iter_id": "it.1",
"date": "2026-05-15",
"mode": "standard",
"outcome": "DONE",
"tasks_total": 8,
"tasks_completed": 8,
"reloops_per_task": {
"1": 0,
"2": 0,
"3": 0,
"4": 0,
"5": 0,
"6": 0,
"7": 1,
"8": 0
},
"review_loops_spec": 0,
"review_loops_quality": 0,
"blocked_reason": null,
"notes": "Task 7 had 1 implementer-phase re-loop (loop-lowering tuple field order: init_ssa Vec declared (String,String,String,Type) but populated/destructured as (name,ail_ty,init_ssa,llvm_ty) — fixed to (String,Type,String,String), 6 E0308/E0277, one fix pass). All spec + quality checks passed first time (compliant/approved). Tasks 4 and 7 closed DONE_WITH_CONCERNS (Step 4.1 prose-roundtrip plan defect — no Form-B parser exists by design; loop-in-lambda codegen coverage via a Phase-3 runnable fixture). recur synth returns Subst::fresh(counter) not the plan's flagged-risky Type::unit() — resolves the plan's own self-review Open risk. Strictly additive: zero deletions touch tail-app/tail-do/the seven block_terminated sites/verify_tail_positions' tail role."
}
+61
View File
@@ -1544,6 +1544,26 @@ fn walk_term(
} }
walk_term(value, out, builtins, scope); walk_term(value, out, builtins, scope);
} }
// Iter it.1: loop binder names bind inside the body and later
// binder inits, mirroring `Term::Mut`. Recur args are uses.
Term::Loop { binders, body } => {
let mut newly = Vec::new();
for b in binders {
walk_term(&b.init, out, builtins, scope);
if scope.insert(b.name.clone()) {
newly.push(b.name.clone());
}
}
walk_term(body, out, builtins, scope);
for n in newly {
scope.remove(&n);
}
}
Term::Recur { args } => {
for a in args {
walk_term(a, out, builtins, scope);
}
}
} }
} }
@@ -2785,6 +2805,47 @@ fn rewrite_def(
changed, changed,
); );
} }
// Iter it.1: rewrite types embedded in each
// `LoopBinder.ty` (loop binders carry full Type
// annotations like mut-vars) and recurse into each
// binder's `init` and the body. `Term::Recur` has no
// embedded type.
Term::Loop { binders, body } => {
for b in binders {
rewrite_type(
&mut b.ty,
owning_module,
local_types,
import_names,
changed,
);
rewrite_term(
&mut b.init,
owning_module,
local_types,
import_names,
changed,
);
}
rewrite_term(
body,
owning_module,
local_types,
import_names,
changed,
);
}
Term::Recur { args } => {
for a in args {
rewrite_term(
a,
owning_module,
local_types,
import_names,
changed,
);
}
}
} }
} }
@@ -101,6 +101,13 @@ fn synthesised_print_uses_user_module_show_via_fallback() {
|| contains_xmod_show_var(body) || contains_xmod_show_var(body)
} }
Term::Assign { value, .. } => contains_xmod_show_var(value), Term::Assign { value, .. } => contains_xmod_show_var(value),
// Iter it.1: a `Term::Loop` cannot itself host a synth'd
// cross-module reference, but recurse defensively.
Term::Loop { binders, body } => {
binders.iter().any(|b| contains_xmod_show_var(&b.init))
|| contains_xmod_show_var(body)
}
Term::Recur { args } => args.iter().any(contains_xmod_show_var),
Term::Lit { .. } => false, Term::Lit { .. } => false,
} }
} }
+25
View File
@@ -2828,6 +2828,31 @@ fn mut_counter_prints_55() {
assert_eq!(stdout.trim(), "55", "mut_counter must print 55, got {stdout:?}"); assert_eq!(stdout.trim(), "55", "mut_counter must print 55, got {stdout:?}");
} }
/// Iter it.1: `examples/loop_counter.ail` exercises `Term::Loop` +
/// `Term::Recur` codegen — the loop-header block with one phi per
/// binder and `recur` as a back-edge `br`. The body
/// `(loop ((var acc Int 0) (var i Int 1)) (if (> i 10) acc (recur ...)))`
/// sums 1..10 and prints 55. End-to-end gate for the loop-header /
/// phi / recur-back-edge lowering.
#[test]
fn loop_counter_runs_and_prints_55() {
let stdout = build_and_run("loop_counter.ail");
assert_eq!(stdout.trim(), "55", "loop_counter must print 55, got {stdout:?}");
}
/// Iter it.1: a `Term::Loop` inside a `Term::Lam` body, invoked via
/// a returned closure. Protects the lambda-boundary invariant: the
/// closure thunk scopes its own loop header / phis (the `loop_frames`
/// save+reset+restore across the lambda boundary, mut.3 analogue) —
/// a `recur` inside the lambda must back-edge to the lambda's own
/// loop header, not the outer fn's. The lambda computes x*x by
/// summing x exactly x times; apply 7 prints 49.
#[test]
fn loop_in_lambda_runs_and_prints_49() {
let stdout = build_and_run("loop_in_lambda_e2e.ail");
assert_eq!(stdout.trim(), "49", "loop_in_lambda must print 49, got {stdout:?}");
}
/// Iter mut.3: Float twin of `mut_counter_prints_55`. The mut-var /// Iter mut.3: Float twin of `mut_counter_prints_55`. The mut-var
/// is `Float`, init is `0.0`, the recursive helper returns the /// is `Float`, init is `0.0`, the recursive helper returns the
/// sum 1.0+...+10.0 = 55.0. The polymorphic `print` routes through /// sum 1.0+...+10.0 = 55.0. The polymorphic `print` routes through
+4
View File
@@ -343,6 +343,7 @@ mod tests {
install(&mut env); install(&mut env);
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -354,6 +355,7 @@ mod tests {
&env, &env,
&mut locals, &mut locals,
&mut mut_scope_stack, &mut mut_scope_stack,
&mut loop_stack,
&mut effects, &mut effects,
"<test>", "<test>",
&mut subst, &mut subst,
@@ -586,6 +588,7 @@ mod tests {
install(&mut env); install(&mut env);
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -597,6 +600,7 @@ mod tests {
&env, &env,
&mut locals, &mut locals,
&mut mut_scope_stack, &mut mut_scope_stack,
&mut loop_stack,
&mut effects, &mut effects,
"<test>", "<test>",
&mut subst, &mut subst,
+257 -28
View File
@@ -261,6 +261,23 @@ pub(crate) fn substitute_rigids_in_term(t: &Term, mapping: &BTreeMap<String, Typ
name: name.clone(), name: name.clone(),
value: Box::new(substitute_rigids_in_term(value, mapping)), value: Box::new(substitute_rigids_in_term(value, mapping)),
}, },
// Iter it.1: rebuild each `LoopBinder.ty` via the type-level
// rigids substitution, each `init` and the body via the
// term-level walker.
Term::Loop { binders, body } => Term::Loop {
binders: binders
.iter()
.map(|b| ailang_core::ast::LoopBinder {
name: b.name.clone(),
ty: substitute_rigids(&b.ty, mapping),
init: Box::new(substitute_rigids_in_term(&b.init, mapping)),
})
.collect(),
body: Box::new(substitute_rigids_in_term(body, mapping)),
},
Term::Recur { args } => Term::Recur {
args: args.iter().map(|a| substitute_rigids_in_term(a, mapping)).collect(),
},
} }
} }
@@ -679,6 +696,36 @@ pub enum CheckError {
#[error("mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")] #[error("mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")]
MutVarCapturedByLambda { name: String }, MutVarCapturedByLambda { name: String },
/// Iter it.1: a `Term::Recur` was reached with no lexically
/// enclosing `Term::Loop`. Spec
/// `docs/specs/2026-05-15-iteration-discipline.md`. (No `span`
/// field — the `CheckError` variants in this crate do not carry
/// spans; the plan's pseudo-code referenced a type not in scope
/// here, so the real `MutAssignOutOfScope`-style shape is used.)
#[error("recur outside of any enclosing loop")]
RecurOutsideLoop,
/// Iter it.1: a `Term::Recur`'s argument count differs from the
/// lexically nearest enclosing loop's binder count.
#[error("recur passes {got} argument(s) but the enclosing loop binds {want}")]
RecurArityMismatch { got: usize, want: usize },
/// Iter it.1: a `Term::Recur` argument's synth type does not
/// unify with the corresponding loop binder's declared type.
#[error("recur argument {pos} has type {got} but loop binder `{name}` is {want}")]
RecurTypeMismatch {
pos: usize,
name: String,
got: String,
want: String,
},
/// Iter it.1: a `Term::Recur` was reached outside the tail
/// context of its enclosing loop's body. Spec
/// `docs/specs/2026-05-15-iteration-discipline.md`.
#[error("recur must be in tail position of its enclosing loop")]
RecurNotInTailPosition,
/// Iter 22b.3: an internal invariant in the typechecker / mono pass /// Iter 22b.3: an internal invariant in the typechecker / mono pass
/// was violated — surfaced as an error so callers can propagate /// was violated — surfaced as an error so callers can propagate
/// rather than abort, but in well-formed inputs (typecheck has /// rather than abort, but in well-formed inputs (typecheck has
@@ -731,6 +778,10 @@ impl CheckError {
CheckError::AssignTypeMismatch { .. } => "assign-type-mismatch", CheckError::AssignTypeMismatch { .. } => "assign-type-mismatch",
CheckError::UnsupportedMutVarType { .. } => "mut-var-unsupported-type", CheckError::UnsupportedMutVarType { .. } => "mut-var-unsupported-type",
CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda", CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda",
CheckError::RecurOutsideLoop => "recur-outside-loop",
CheckError::RecurArityMismatch { .. } => "recur-arity-mismatch",
CheckError::RecurTypeMismatch { .. } => "recur-type-mismatch",
CheckError::RecurNotInTailPosition => "recur-not-in-tail-position",
CheckError::Internal(_) => "internal", CheckError::Internal(_) => "internal",
} }
} }
@@ -805,6 +856,12 @@ impl CheckError {
CheckError::MutVarCapturedByLambda { name } => { CheckError::MutVarCapturedByLambda { name } => {
serde_json::json!({"name": name}) serde_json::json!({"name": name})
} }
CheckError::RecurArityMismatch { got, want } => {
serde_json::json!({"expected": want, "actual": got})
}
CheckError::RecurTypeMismatch { pos, name, got, want } => {
serde_json::json!({"pos": pos, "name": name, "expected": want, "actual": got})
}
_ => serde_json::Value::Object(serde_json::Map::new()), _ => serde_json::Value::Object(serde_json::Map::new()),
} }
} }
@@ -1950,7 +2007,8 @@ fn check_fn(f: &FnDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> Result<
// pushed/popped by `Term::Mut` arms during the walk; discarded after // pushed/popped by `Term::Mut` arms during the walk; discarded after
// the body type-checks. // the body type-checks.
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let body_ty = synth(&f.body, &env, &mut locals, &mut mut_scope_stack, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?; let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let body_ty = synth(&f.body, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?;
unify(&ret_ty, &body_ty, &mut subst)?; unify(&ret_ty, &body_ty, &mut subst)?;
// mq.3: surface synth-time warnings into the caller-supplied // mq.3: surface synth-time warnings into the caller-supplied
// accumulator. The warnings already carry `def: Some(f.name)` // accumulator. The warnings already carry `def: Some(f.name)`
@@ -2679,6 +2737,76 @@ pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> {
// be in tail position. Its `value` is also not in tail // be in tail position. Its `value` is also not in tail
// position. // position.
Term::Assign { value, .. } => verify_tail_positions(value, false), Term::Assign { value, .. } => verify_tail_positions(value, false),
// Iter it.1 (DD-1): binder inits are evaluated before the
// loop head and are NOT in tail position. The body opens a
// "recur-legal, recur-must-be-tail" context — `verify_loop_body`
// walks it, accepting a `Term::Recur` only where the loop
// body's value is produced (tail position w.r.t. the loop).
Term::Loop { binders, body } => {
for b in binders {
verify_tail_positions(&b.init, false)?;
}
verify_loop_body(body)
}
// Iter it.1 (DD-1): a `Term::Recur` reached by the ordinary
// `verify_tail_positions` walk is, by construction, NOT in the
// tail context of any enclosing loop body (`verify_loop_body`
// intercepts the legal in-tail ones). Either there is no
// enclosing loop (synth flags `RecurOutsideLoop`) or this
// recur is in a non-tail position.
Term::Recur { .. } => Err(CheckError::RecurNotInTailPosition),
}
}
/// Iter it.1 (DD-1): walks a `Term::Loop` body in the
/// "recur-is-legal-and-must-be-tail" context. A `Term::Recur` is
/// accepted only at a position that produces the loop body's value
/// (the loop-tail positions); anywhere else it is
/// `RecurNotInTailPosition`. Non-recur sub-terms that are NOT in the
/// loop's tail position are handed back to `verify_tail_positions`
/// (with `is_tail = false`) so `tail-app`/`tail-do` inside the loop
/// body are still validated; sub-terms in the loop's tail position
/// recurse through `verify_loop_body` so a nested `recur` there is
/// still legal. A nested `Term::Loop` starts its own recur scope.
fn verify_loop_body(t: &Term) -> Result<()> {
match t {
// Tail position of the loop body: a recur here is legal.
Term::Recur { args } => {
for a in args {
verify_tail_positions(a, false)?;
}
Ok(())
}
Term::If { cond, then, else_ } => {
verify_tail_positions(cond, false)?;
verify_loop_body(then)?;
verify_loop_body(else_)
}
Term::Seq { lhs, rhs } => {
// Only `rhs` is in the loop's tail position.
verify_tail_positions(lhs, false)?;
verify_loop_body(rhs)
}
Term::Match { scrutinee, arms } => {
verify_tail_positions(scrutinee, false)?;
for arm in arms {
verify_loop_body(&arm.body)?;
}
Ok(())
}
Term::Let { value, body, .. } => {
verify_tail_positions(value, false)?;
verify_loop_body(body)
}
// A nested loop is its own recur scope: a `recur` inside it
// binds the inner loop, not this one. Validate it through the
// ordinary tail-position walk in a fresh tail context.
Term::Loop { .. } => verify_tail_positions(t, true),
// Any other term is not a recur-carrying tail shape; validate
// it normally (it is the loop body's value but carries no
// loop-tail recur, so `is_tail = true` keeps a trailing
// `tail-app` legal).
other => verify_tail_positions(other, true),
} }
} }
@@ -2703,7 +2831,8 @@ fn check_const(c: &ConstDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> R
// at this entry point — `Term::Mut` may appear inside a const body // at this entry point — `Term::Mut` may appear inside a const body
// and push frames as the walk descends). // and push frames as the walk descends).
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let v = synth(&c.value, env, &mut locals, &mut mut_scope_stack, &mut effects, &c.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?; let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let v = synth(&c.value, env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, &c.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?;
out_warnings.extend(warnings); out_warnings.extend(warnings);
unify(&c.ty, &v, &mut subst)?; unify(&c.ty, &v, &mut subst)?;
if !effects.is_empty() { if !effects.is_empty() {
@@ -2728,6 +2857,14 @@ pub(crate) fn synth(
// name through it. Position: locals-adjacent because both are // name through it. Position: locals-adjacent because both are
// per-fn-body lexical scope state. // per-fn-body lexical scope state.
mut_scope_stack: &mut Vec<IndexMap<String, Type>>, mut_scope_stack: &mut Vec<IndexMap<String, Type>>,
// Iter it.1 (DD-1): per-walk lexical loop-binder-type stack,
// threaded exactly as `mut_scope_stack`. Each frame is one
// `Term::Loop`'s binder type-vector (positional). Pushed on
// `Term::Loop` entry, popped on exit; `Term::Recur` reads the
// innermost frame for arity/type unification. Position:
// mut_scope_stack-adjacent because both are per-fn-body lexical
// scope state.
loop_stack: &mut Vec<Vec<Type>>,
effects: &mut BTreeSet<String>, effects: &mut BTreeSet<String>,
in_def: &str, in_def: &str,
subst: &mut Subst, subst: &mut Subst,
@@ -3116,7 +3253,7 @@ pub(crate) fn synth(
Ok(maybe_instantiate(raw, counter)) Ok(maybe_instantiate(raw, counter))
} }
Term::App { callee, args, .. } => { Term::App { callee, args, .. } => {
let cty = synth(callee, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let cty = synth(callee, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
let cty = subst.apply(&cty); let cty = subst.apply(&cty);
let (params, ret, fx) = match &cty { let (params, ret, fx) = match &cty {
Type::Fn { params, ret, effects: fx, .. } => { Type::Fn { params, ret, effects: fx, .. } => {
@@ -3152,7 +3289,7 @@ pub(crate) fn synth(
}); });
} }
for (a, exp) in args.iter().zip(params.iter()) { for (a, exp) in args.iter().zip(params.iter()) {
let actual = synth(a, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let actual = synth(a, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
unify(exp, &actual, subst)?; unify(exp, &actual, subst)?;
} }
for e in fx { for e in fx {
@@ -3161,9 +3298,9 @@ pub(crate) fn synth(
Ok(subst.apply(&ret)) Ok(subst.apply(&ret))
} }
Term::Let { name, value, body } => { Term::Let { name, value, body } => {
let v = synth(value, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let v = synth(value, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
let prev = locals.insert(name.clone(), v); let prev = locals.insert(name.clone(), v);
let r = synth(body, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let r = synth(body, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
match prev { match prev {
Some(p) => { Some(p) => {
locals.insert(name.clone(), p); locals.insert(name.clone(), p);
@@ -3175,10 +3312,10 @@ pub(crate) fn synth(
Ok(r) Ok(r)
} }
Term::If { cond, then, else_ } => { Term::If { cond, then, else_ } => {
let c = synth(cond, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let c = synth(cond, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
unify(&Type::bool_(), &c, subst)?; unify(&Type::bool_(), &c, subst)?;
let t1 = synth(then, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let t1 = synth(then, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
let t2 = synth(else_, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let t2 = synth(else_, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
unify(&t1, &t2, subst)?; unify(&t1, &t2, subst)?;
Ok(subst.apply(&t1)) Ok(subst.apply(&t1))
} }
@@ -3196,7 +3333,7 @@ pub(crate) fn synth(
}); });
} }
for (a, exp) in args.iter().zip(sig.params.iter()) { for (a, exp) in args.iter().zip(sig.params.iter()) {
let actual = synth(a, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let actual = synth(a, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
unify(exp, &actual, subst)?; unify(exp, &actual, subst)?;
} }
effects.insert(sig.effect.clone()); effects.insert(sig.effect.clone());
@@ -3291,7 +3428,7 @@ pub(crate) fn synth(
} }
for (a, exp) in args.iter().zip(qualified_fields.iter()) { for (a, exp) in args.iter().zip(qualified_fields.iter()) {
let exp_inst = substitute_rigids(exp, &mapping); let exp_inst = substitute_rigids(exp, &mapping);
let actual = synth(a, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let actual = synth(a, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
unify(&exp_inst, &actual, subst)?; unify(&exp_inst, &actual, subst)?;
} }
Ok(Type::Con { Ok(Type::Con {
@@ -3300,7 +3437,7 @@ pub(crate) fn synth(
}) })
} }
Term::Match { scrutinee, arms } => { Term::Match { scrutinee, arms } => {
let s_ty = synth(scrutinee, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let s_ty = synth(scrutinee, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
if arms.is_empty() { if arms.is_empty() {
return Err(CheckError::NonExhaustive { return Err(CheckError::NonExhaustive {
ty: ailang_core::pretty::type_to_string(&s_ty), ty: ailang_core::pretty::type_to_string(&s_ty),
@@ -3318,7 +3455,7 @@ pub(crate) fn synth(
let prev = locals.insert(n.clone(), t.clone()); let prev = locals.insert(n.clone(), t.clone());
pushed.push((n.clone(), prev)); pushed.push((n.clone(), prev));
} }
let body_ty = synth(&arm.body, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let body_ty = synth(&arm.body, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
for (n, prev) in pushed.into_iter().rev() { for (n, prev) in pushed.into_iter().rev() {
match prev { match prev {
Some(p) => { Some(p) => {
@@ -3393,9 +3530,9 @@ pub(crate) fn synth(
Ok(subst.apply(&result_ty.expect("checked arms is non-empty"))) Ok(subst.apply(&result_ty.expect("checked arms is non-empty")))
} }
Term::Seq { lhs, rhs } => { Term::Seq { lhs, rhs } => {
let lty = synth(lhs, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let lty = synth(lhs, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
unify(&Type::unit(), &lty, subst)?; unify(&Type::unit(), &lty, subst)?;
synth(rhs, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings) synth(rhs, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)
} }
Term::Lam { params, param_tys, ret_ty, effects: lam_effects, body } => { Term::Lam { params, param_tys, ret_ty, effects: lam_effects, body } => {
// Iter mut.4-tidy: reject lambda-captures-of-mut-var. // Iter mut.4-tidy: reject lambda-captures-of-mut-var.
@@ -3429,7 +3566,7 @@ pub(crate) fn synth(
pushed.push((n.clone(), prev)); pushed.push((n.clone(), prev));
} }
let mut body_effects: BTreeSet<String> = BTreeSet::new(); let mut body_effects: BTreeSet<String> = BTreeSet::new();
let body_ty = synth(body, env, locals, mut_scope_stack, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls, warnings); let body_ty = synth(body, env, locals, mut_scope_stack, loop_stack, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls, warnings);
for (n, prev) in pushed.into_iter().rev() { for (n, prev) in pushed.into_iter().rev() {
match prev { match prev {
Some(p) => { Some(p) => {
@@ -3517,7 +3654,7 @@ pub(crate) fn synth(
// subset rule against `declared_effs` — exactly like // subset rule against `declared_effs` — exactly like
// `Term::Lam`. // `Term::Lam`.
let mut body_effects: BTreeSet<String> = BTreeSet::new(); let mut body_effects: BTreeSet<String> = BTreeSet::new();
let body_ty = synth(body, env, locals, mut_scope_stack, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls, warnings); let body_ty = synth(body, env, locals, mut_scope_stack, loop_stack, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls, warnings);
// Restore body-scope locals (params + name). // Restore body-scope locals (params + name).
for (n, prev) in pushed.into_iter().rev() { for (n, prev) in pushed.into_iter().rev() {
@@ -3544,7 +3681,7 @@ pub(crate) fn synth(
// scope (params are not visible here; only the recursive // scope (params are not visible here; only the recursive
// binding is). // binding is).
let prev_in = locals.insert(name.clone(), ty.clone()); let prev_in = locals.insert(name.clone(), ty.clone());
let in_ty = synth(in_term, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings); let in_ty = synth(in_term, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings);
match prev_in { match prev_in {
Some(p) => { Some(p) => {
locals.insert(name.clone(), p); locals.insert(name.clone(), p);
@@ -3560,7 +3697,7 @@ pub(crate) fn synth(
// No constraint generated, no environment change. The // No constraint generated, no environment change. The
// wrapper records author intent for the future RC inc/dec // wrapper records author intent for the future RC inc/dec
// emission pass (18c.3); typing is pure passthrough. // emission pass (18c.3); typing is pure passthrough.
synth(value, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings) synth(value, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)
} }
Term::ReuseAs { source, body } => { Term::ReuseAs { source, body } => {
// Iter 18d.1: `(reuse-as SRC NEW-CTOR)` requires `body` to // Iter 18d.1: `(reuse-as SRC NEW-CTOR)` requires `body` to
@@ -3574,7 +3711,7 @@ pub(crate) fn synth(
// shape-compatibility check (18d.2 will add a // shape-compatibility check (18d.2 will add a
// `reuse-as-shape-mismatch` diagnostic when codegen has // `reuse-as-shape-mismatch` diagnostic when codegen has
// the actual size info). // the actual size info).
let _ = synth(source, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let _ = synth(source, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
match body.as_ref() { match body.as_ref() {
Term::Ctor { .. } | Term::Lam { .. } => {} Term::Ctor { .. } | Term::Lam { .. } => {}
other => { other => {
@@ -3592,6 +3729,8 @@ pub(crate) fn synth(
Term::ReuseAs { .. } => "reuse-as", Term::ReuseAs { .. } => "reuse-as",
Term::Mut { .. } => "mut", Term::Mut { .. } => "mut",
Term::Assign { .. } => "assign", Term::Assign { .. } => "assign",
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
Term::Ctor { .. } | Term::Lam { .. } => unreachable!(), Term::Ctor { .. } | Term::Lam { .. } => unreachable!(),
}; };
return Err(CheckError::ReuseAsNonAllocatingBody { return Err(CheckError::ReuseAsNonAllocatingBody {
@@ -3602,7 +3741,7 @@ pub(crate) fn synth(
} }
// Body's type is the result type of the whole reuse-as // Body's type is the result type of the whole reuse-as
// expression. // expression.
synth(body, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings) synth(body, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)
} }
// Iter mut.2: a mut block opens a fresh lexical scope for its // Iter mut.2: a mut block opens a fresh lexical scope for its
// mut-vars. Each var's `init` is synthed in the outer scope // mut-vars. Each var's `init` is synthed in the outer scope
@@ -3628,7 +3767,7 @@ pub(crate) fn synth(
// the `mut_scope_stack` truthful at all times. // the `mut_scope_stack` truthful at all times.
mut_scope_stack.push(frame.clone()); mut_scope_stack.push(frame.clone());
let init_ty = synth( let init_ty = synth(
&v.init, env, locals, mut_scope_stack, effects, in_def, &v.init, env, locals, mut_scope_stack, loop_stack, effects, in_def,
subst, counter, residuals, free_fn_calls, warnings, subst, counter, residuals, free_fn_calls, warnings,
)?; )?;
mut_scope_stack.pop(); mut_scope_stack.pop();
@@ -3637,7 +3776,7 @@ pub(crate) fn synth(
} }
mut_scope_stack.push(frame); mut_scope_stack.push(frame);
let body_result = synth( let body_result = synth(
body, env, locals, mut_scope_stack, effects, in_def, body, env, locals, mut_scope_stack, loop_stack, effects, in_def,
subst, counter, residuals, free_fn_calls, warnings, subst, counter, residuals, free_fn_calls, warnings,
); );
mut_scope_stack.pop(); mut_scope_stack.pop();
@@ -3671,7 +3810,7 @@ pub(crate) fn synth(
} }
Some(declared_ty) => { Some(declared_ty) => {
let value_ty = synth( let value_ty = synth(
value, env, locals, mut_scope_stack, effects, in_def, value, env, locals, mut_scope_stack, loop_stack, effects, in_def,
subst, counter, residuals, free_fn_calls, warnings, subst, counter, residuals, free_fn_calls, warnings,
)?; )?;
let applied_declared = subst.apply(&declared_ty); let applied_declared = subst.apply(&declared_ty);
@@ -3687,6 +3826,84 @@ pub(crate) fn synth(
} }
} }
} }
// Iter it.1 (DD-1): each binder's `init` is synthesised in
// the outer scope plus the already-declared binders (init
// order = declaration order), unified against the binder's
// declared type. The binder names are bound into `locals`
// (save/restore like `Term::Let`) for the body; the binder
// type-vector is pushed onto `loop_stack` so an enclosed
// `Term::Recur` can check its arity/types. The loop's static
// type is the body's static type.
Term::Loop { binders, body } => {
let mut saved: Vec<(String, Option<Type>)> = Vec::new();
let mut binder_tys: Vec<Type> = Vec::with_capacity(binders.len());
for b in binders {
let init_ty = synth(
&b.init, env, locals, mut_scope_stack, loop_stack, effects, in_def,
subst, counter, residuals, free_fn_calls, warnings,
)?;
unify(&b.ty, &init_ty, subst)?;
let prev = locals.insert(b.name.clone(), b.ty.clone());
saved.push((b.name.clone(), prev));
binder_tys.push(b.ty.clone());
}
loop_stack.push(binder_tys);
let body_result = synth(
body, env, locals, mut_scope_stack, loop_stack, effects, in_def,
subst, counter, residuals, free_fn_calls, warnings,
);
loop_stack.pop();
for (name, prev) in saved.into_iter().rev() {
match prev {
Some(p) => {
locals.insert(name, p);
}
None => {
locals.shift_remove(&name);
}
}
}
body_result
}
// Iter it.1 (DD-1): a `Term::Recur` re-enters the lexically
// nearest enclosing loop. Empty `loop_stack` ⇒
// `RecurOutsideLoop`; arg count ≠ innermost binder count ⇒
// `RecurArityMismatch`; per-position unify failure ⇒
// `RecurTypeMismatch`. `recur` never falls through (it is
// always a tail jump), so its synth result is a fresh
// metavar — it unifies with any type, which is what makes
// `(if c then (recur ...))` typecheck. (The plan's named
// fallback was `Type::unit()`; a fresh metavar is the
// codebase-idiomatic bottom and resolves the plan's "Open
// risk" correctly — see the it.1 journal.)
Term::Recur { args } => {
let want = loop_stack
.last()
.cloned()
.ok_or(CheckError::RecurOutsideLoop)?;
if args.len() != want.len() {
return Err(CheckError::RecurArityMismatch {
got: args.len(),
want: want.len(),
});
}
for (i, (a, wt)) in args.iter().zip(want.iter()).enumerate() {
let at = synth(
a, env, locals, mut_scope_stack, loop_stack, effects, in_def,
subst, counter, residuals, free_fn_calls, warnings,
)?;
if unify(&at, wt, subst).is_err() {
let applied_got = subst.apply(&at);
return Err(CheckError::RecurTypeMismatch {
pos: i,
name: format!("#{i}"),
got: ailang_core::pretty::type_to_string(&applied_got),
want: ailang_core::pretty::type_to_string(wt),
});
}
}
Ok(Subst::fresh(counter))
}
} }
} }
@@ -4061,6 +4278,7 @@ mod tests {
let env = Env::default(); let env = Env::default();
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4072,6 +4290,7 @@ mod tests {
&env, &env,
&mut locals, &mut locals,
&mut mut_scope_stack, &mut mut_scope_stack,
&mut loop_stack,
&mut effects, &mut effects,
"<test>", "<test>",
&mut subst, &mut subst,
@@ -4098,6 +4317,7 @@ mod tests {
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
locals.insert("x".into(), Type::int()); // outer let-style binding locals.insert("x".into(), Type::int()); // outer let-style binding
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut frame: IndexMap<String, Type> = IndexMap::new(); let mut frame: IndexMap<String, Type> = IndexMap::new();
frame.insert("x".into(), Type::float()); // inner mut-var frame.insert("x".into(), Type::float()); // inner mut-var
mut_scope_stack.push(frame); mut_scope_stack.push(frame);
@@ -4114,6 +4334,7 @@ mod tests {
&env, &env,
&mut locals, &mut locals,
&mut mut_scope_stack, &mut mut_scope_stack,
&mut loop_stack,
&mut effects, &mut effects,
"<test>", "<test>",
&mut subst, &mut subst,
@@ -4138,6 +4359,7 @@ mod tests {
let env = Env::default(); let env = Env::default();
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut outer: IndexMap<String, Type> = IndexMap::new(); let mut outer: IndexMap<String, Type> = IndexMap::new();
outer.insert("x".into(), Type::int()); outer.insert("x".into(), Type::int());
mut_scope_stack.push(outer); mut_scope_stack.push(outer);
@@ -4157,6 +4379,7 @@ mod tests {
&env, &env,
&mut locals, &mut locals,
&mut mut_scope_stack, &mut mut_scope_stack,
&mut loop_stack,
&mut effects, &mut effects,
"<test>", "<test>",
&mut subst, &mut subst,
@@ -4251,6 +4474,7 @@ mod tests {
let env = Env::default(); let env = Env::default();
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4258,7 +4482,7 @@ mod tests {
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new(); let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
let mut warnings: Vec<Diagnostic> = Vec::new(); let mut warnings: Vec<Diagnostic> = Vec::new();
let err = synth( let err = synth(
&term, &env, &mut locals, &mut mut_scope_stack, &mut effects, &term, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects,
"<test>", &mut subst, &mut counter, &mut residuals, "<test>", &mut subst, &mut counter, &mut residuals,
&mut free_fn_calls, &mut warnings, &mut free_fn_calls, &mut warnings,
) )
@@ -4291,6 +4515,7 @@ mod tests {
let env = Env::default(); let env = Env::default();
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4298,7 +4523,7 @@ mod tests {
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new(); let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
let mut warnings: Vec<Diagnostic> = Vec::new(); let mut warnings: Vec<Diagnostic> = Vec::new();
let err = synth( let err = synth(
&term, &env, &mut locals, &mut mut_scope_stack, &mut effects, &term, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects,
"<test>", &mut subst, &mut counter, &mut residuals, "<test>", &mut subst, &mut counter, &mut residuals,
&mut free_fn_calls, &mut warnings, &mut free_fn_calls, &mut warnings,
) )
@@ -4330,6 +4555,7 @@ mod tests {
let env = Env::default(); let env = Env::default();
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4337,7 +4563,7 @@ mod tests {
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new(); let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
let mut warnings: Vec<Diagnostic> = Vec::new(); let mut warnings: Vec<Diagnostic> = Vec::new();
let err = synth( let err = synth(
&term, &env, &mut locals, &mut mut_scope_stack, &mut effects, &term, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects,
"<test>", &mut subst, &mut counter, &mut residuals, "<test>", &mut subst, &mut counter, &mut residuals,
&mut free_fn_calls, &mut warnings, &mut free_fn_calls, &mut warnings,
) )
@@ -4432,6 +4658,7 @@ mod tests {
let env = Env::default(); let env = Env::default();
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4439,7 +4666,7 @@ mod tests {
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new(); let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
let mut warnings: Vec<Diagnostic> = Vec::new(); let mut warnings: Vec<Diagnostic> = Vec::new();
let err = synth( let err = synth(
&term, &env, &mut locals, &mut mut_scope_stack, &mut effects, &term, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects,
"<test>", &mut subst, &mut counter, &mut residuals, "<test>", &mut subst, &mut counter, &mut residuals,
&mut free_fn_calls, &mut warnings, &mut free_fn_calls, &mut warnings,
) )
@@ -7171,6 +7398,7 @@ mod tests {
let term = Term::Var { name: "show".into() }; let term = Term::Var { name: "show".into() };
let mut locals: IndexMap<String, Type> = IndexMap::new(); let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -7182,6 +7410,7 @@ mod tests {
&env, &env,
&mut locals, &mut locals,
&mut mut_scope_stack, &mut mut_scope_stack,
&mut loop_stack,
&mut effects, &mut effects,
"test_call_site", "test_call_site",
&mut subst, &mut subst,
+28 -1
View File
@@ -397,6 +397,25 @@ impl<'a> Lifter<'a> {
name: name.clone(), name: name.clone(),
value: Box::new(self.lift_in_term(value, locals, in_def)?), value: Box::new(self.lift_in_term(value, locals, in_def)?),
}), }),
Term::Loop { binders, body } => Ok(Term::Loop {
binders: binders
.iter()
.map(|b| {
Ok(ailang_core::ast::LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: Box::new(self.lift_in_term(&b.init, locals, in_def)?),
})
})
.collect::<Result<Vec<_>>>()?,
body: Box::new(self.lift_in_term(body, locals, in_def)?),
}),
Term::Recur { args } => Ok(Term::Recur {
args: args
.iter()
.map(|a| self.lift_in_term(a, locals, in_def))
.collect::<Result<Vec<_>>>()?,
}),
Term::LetRec { name, ty, params, body, in_term } => { Term::LetRec { name, ty, params, body, in_term } => {
// Iter 16b.3: post-order traversal — lift any inner // Iter 16b.3: post-order traversal — lift any inner
// LetRecs first. Within the body's scope, `name` and // LetRecs first. Within the body's scope, `name` and
@@ -724,7 +743,11 @@ impl<'a> Lifter<'a> {
// term at a time, beginning from a top-of-body position; fresh // term at a time, beginning from a top-of-body position; fresh
// empty mut-scope stack is correct. // empty mut-scope stack is correct.
let mut mut_scope_stack: Vec<indexmap::IndexMap<String, crate::Type>> = Vec::new(); let mut mut_scope_stack: Vec<indexmap::IndexMap<String, crate::Type>> = Vec::new();
let ty = synth(t, &self.env, locals, &mut mut_scope_stack, &mut effects, in_def, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings_discarded)?; // Iter it.1 (DD-1): lift's letrec-capture re-entry walks one
// term from a top-of-body position; fresh empty loop_stack is
// correct (a loop captured into a letrec carries its own).
let mut loop_stack: Vec<Vec<crate::Type>> = Vec::new();
let ty = synth(t, &self.env, locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, in_def, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings_discarded)?;
Ok(subst.apply(&ty)) Ok(subst.apply(&ty))
} }
} }
@@ -761,6 +784,10 @@ fn contains_any_letrec(m: &Module) -> bool {
vars.iter().any(|v| term_has_letrec(&v.init)) || term_has_letrec(body) vars.iter().any(|v| term_has_letrec(&v.init)) || term_has_letrec(body)
} }
Term::Assign { value, .. } => term_has_letrec(value), Term::Assign { value, .. } => term_has_letrec(value),
Term::Loop { binders, body } => {
binders.iter().any(|b| term_has_letrec(&b.init)) || term_has_letrec(body)
}
Term::Recur { args } => args.iter().any(term_has_letrec),
} }
} }
for def in &m.defs { for def in &m.defs {
+24
View File
@@ -609,6 +609,17 @@ impl<'a> Checker<'a> {
// a tracked binder). Walk the `value` normally. // a tracked binder). Walk the `value` normally.
self.walk(value, Position::Consume); self.walk(value, Position::Consume);
} }
Term::Loop { binders, body } => {
for b in binders {
self.walk(&b.init, Position::Consume);
}
self.walk(body, pos);
}
Term::Recur { args } => {
for a in args {
self.walk(a, Position::Consume);
}
}
} }
} }
@@ -795,6 +806,8 @@ fn make_reuse_as_source_not_bare_var(def: &str, source: &Term, body: &Term) -> D
Term::ReuseAs { .. } => "reuse-as", Term::ReuseAs { .. } => "reuse-as",
Term::Mut { .. } => "mut", Term::Mut { .. } => "mut",
Term::Assign { .. } => "assign", Term::Assign { .. } => "assign",
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
}; };
let replacement = term_to_form_a(body); let replacement = term_to_form_a(body);
Diagnostic::error( Diagnostic::error(
@@ -934,6 +947,17 @@ fn any_sub_binder_consumed_for(
Term::Assign { value, .. } => { Term::Assign { value, .. } => {
any_sub_binder_consumed_for(value, pname, uniq, def_name, ctors) any_sub_binder_consumed_for(value, pname, uniq, def_name, ctors)
} }
// Iter it.1: loop binders are scalar like mut-vars; recurse
// into children defensively so a genuine consume-of-`pname`
// inside a binder init / body / recur arg still surfaces.
Term::Loop { binders, body } => {
binders.iter().any(|b| {
any_sub_binder_consumed_for(&b.init, pname, uniq, def_name, ctors)
}) || any_sub_binder_consumed_for(body, pname, uniq, def_name, ctors)
}
Term::Recur { args } => args
.iter()
.any(|a| any_sub_binder_consumed_for(a, pname, uniq, def_name, ctors)),
} }
} }
+30
View File
@@ -713,11 +713,16 @@ pub fn collect_mono_targets(
// mut-scope, since any `Term::Mut` will push its own frame as the // mut-scope, since any `Term::Mut` will push its own frame as the
// walk descends. // walk descends.
let mut mut_scope_stack: Vec<indexmap::IndexMap<String, crate::Type>> = Vec::new(); let mut mut_scope_stack: Vec<indexmap::IndexMap<String, crate::Type>> = Vec::new();
// Iter it.1 (DD-1): mono re-synth from top-of-body — empty
// loop_stack; any `Term::Loop` pushes its own frame as the walk
// descends.
let mut loop_stack: Vec<Vec<crate::Type>> = Vec::new();
crate::synth( crate::synth(
&f.body, &f.body,
&env, &env,
&mut locals, &mut locals,
&mut mut_scope_stack, &mut mut_scope_stack,
&mut loop_stack,
&mut effects, &mut effects,
&f.name, &f.name,
&mut subst, &mut subst,
@@ -1239,6 +1244,17 @@ fn rewrite_mono_calls(
Term::Assign { value, .. } => { Term::Assign { value, .. } => {
rewrite_mono_calls(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals); rewrite_mono_calls(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
} }
Term::Loop { binders, body } => {
for b in binders.iter_mut() {
rewrite_mono_calls(&mut b.init, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
}
rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
}
Term::Recur { args } => {
for a in args.iter_mut() {
rewrite_mono_calls(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
}
}
Term::Lit { .. } => {} Term::Lit { .. } => {}
} }
} }
@@ -1343,11 +1359,14 @@ pub(crate) fn collect_residuals_ordered(
// mut-scope, since any `Term::Mut` will push its own frame as the // mut-scope, since any `Term::Mut` will push its own frame as the
// walk descends. // walk descends.
let mut mut_scope_stack: Vec<indexmap::IndexMap<String, crate::Type>> = Vec::new(); let mut mut_scope_stack: Vec<indexmap::IndexMap<String, crate::Type>> = Vec::new();
// Iter it.1 (DD-1): empty loop_stack at top-of-body re-synth.
let mut loop_stack: Vec<Vec<crate::Type>> = Vec::new();
crate::synth( crate::synth(
&f.body, &f.body,
&env, &env,
&mut locals, &mut locals,
&mut mut_scope_stack, &mut mut_scope_stack,
&mut loop_stack,
&mut effects, &mut effects,
&f.name, &f.name,
&mut subst, &mut subst,
@@ -1613,6 +1632,17 @@ fn interleave_slots(
Term::Assign { value, .. } => { Term::Assign { value, .. } => {
interleave_slots(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out); interleave_slots(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
} }
Term::Loop { binders, body } => {
for b in binders {
interleave_slots(&b.init, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
}
interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
}
Term::Recur { args } => {
for a in args {
interleave_slots(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
}
}
Term::Lit { .. } => {} Term::Lit { .. } => {}
} }
} }
@@ -133,6 +133,18 @@ fn walk_term(t: &Term) -> Result<(), CheckError> {
walk_term(body) walk_term(body)
} }
Term::Assign { value, .. } => walk_term(value), Term::Assign { value, .. } => walk_term(value),
Term::Loop { binders, body } => {
for b in binders {
walk_term(&b.init)?;
}
walk_term(body)
}
Term::Recur { args } => {
for a in args {
walk_term(a)?;
}
Ok(())
}
} }
} }
+11
View File
@@ -265,6 +265,17 @@ impl<'a> Checker<'a> {
self.walk(body); self.walk(body);
} }
Term::Assign { value, .. } => self.walk(value), Term::Assign { value, .. } => self.walk(value),
Term::Loop { binders, body } => {
for b in binders {
self.walk(&b.init);
}
self.walk(body);
}
Term::Recur { args } => {
for a in args {
self.walk(a);
}
}
} }
} }
+11
View File
@@ -352,6 +352,17 @@ impl<'a> Walker<'a> {
Term::Assign { value, .. } => { Term::Assign { value, .. } => {
self.walk(value, Position::Consume); self.walk(value, Position::Consume);
} }
Term::Loop { binders, body } => {
for b in binders {
self.walk(&b.init, Position::Consume);
}
self.walk(body, pos);
}
Term::Recur { args } => {
for a in args {
self.walk(a, Position::Consume);
}
}
} }
} }
@@ -0,0 +1,60 @@
//! Iter it.1 (Task 5): pin tests that the four `recur` negative
//! fixtures produce their exact diagnostic codes, and that the
//! positive `loop_smoke` fixture typechecks clean.
//!
//! Spec: `docs/specs/2026-05-15-iteration-discipline.md`. The four
//! negatives live as canonical `.ail.json` (diagnostic code is the
//! load-bearing assertion, not the surface form), mirroring the
//! `mut_typecheck_pin` carve-out precedent.
use ailang_check::check_workspace;
use ailang_surface::load_workspace;
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
let manifest = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest)
.parent().expect("CARGO_MANIFEST_DIR has a parent (crates/ailang-check)")
.parent().expect("CARGO_MANIFEST_DIR has a grandparent (crates/)")
.join("examples")
}
/// Load the named fixture under `examples/`, run check_workspace, and
/// return the diagnostic code list (one entry per Diagnostic).
fn check_fixture(fixture_name: &str) -> Vec<String> {
let path = examples_dir().join(fixture_name);
let ws = load_workspace(&path)
.unwrap_or_else(|e| panic!("workspace `{fixture_name}` must load: {e:?}"));
let diags = check_workspace(&ws);
diags.iter().map(|d| d.code.clone()).collect()
}
#[test]
fn loop_smoke_typechecks_clean() {
let codes = check_fixture("loop_smoke.ail");
assert!(codes.is_empty(), "expected zero diagnostics, got {codes:?}");
}
#[test]
fn recur_outside_loop_is_rejected() {
let codes = check_fixture("test_recur_outside_loop.ail.json");
assert_eq!(codes, vec!["recur-outside-loop".to_string()]);
}
#[test]
fn recur_arity_mismatch_is_rejected() {
let codes = check_fixture("test_recur_arity_mismatch.ail.json");
assert_eq!(codes, vec!["recur-arity-mismatch".to_string()]);
}
#[test]
fn recur_type_mismatch_is_rejected() {
let codes = check_fixture("test_recur_type_mismatch.ail.json");
assert_eq!(codes, vec!["recur-type-mismatch".to_string()]);
}
#[test]
fn recur_not_in_tail_position_is_rejected() {
let codes = check_fixture("test_recur_not_in_tail_position.ail.json");
assert_eq!(codes, vec!["recur-not-in-tail-position".to_string()]);
}
+40
View File
@@ -201,6 +201,17 @@ fn walk(t: &Term, out: &mut NonEscapeSet) {
walk(body, out); walk(body, out);
} }
Term::Assign { value, .. } => walk(value, out), Term::Assign { value, .. } => walk(value, out),
Term::Loop { binders, body } => {
for b in binders {
walk(&b.init, out);
}
walk(body, out);
}
Term::Recur { args } => {
for a in args {
walk(a, out);
}
}
Term::Lit { .. } | Term::Var { .. } => {} Term::Lit { .. } | Term::Var { .. } => {}
} }
} }
@@ -395,6 +406,15 @@ fn escapes(t: &Term, tainted: &BTreeSet<String>, in_tail: bool) -> bool {
escapes(body, tainted, in_tail) escapes(body, tainted, in_tail)
} }
Term::Assign { value, .. } => escapes(value, tainted, false), Term::Assign { value, .. } => escapes(value, tainted, false),
Term::Loop { binders, body } => {
for b in binders {
if escapes(&b.init, tainted, false) {
return true;
}
}
escapes(body, tainted, in_tail)
}
Term::Recur { args } => args.iter().any(|a| escapes(a, tainted, false)),
} }
} }
@@ -524,6 +544,26 @@ fn collect_free_vars(t: &Term, bound: &mut BTreeSet<String>, out: &mut BTreeSet<
} }
collect_free_vars(value, bound, out); collect_free_vars(value, bound, out);
} }
// Iter it.1: loop binder names bind inside the body and later
// binder inits, mirroring `Term::Mut`. Recur args are uses.
Term::Loop { binders, body } => {
let mut newly: Vec<String> = Vec::new();
for b in binders {
collect_free_vars(&b.init, bound, out);
if bound.insert(b.name.clone()) {
newly.push(b.name.clone());
}
}
collect_free_vars(body, bound, out);
for n in newly {
bound.remove(&n);
}
}
Term::Recur { args } => {
for a in args {
collect_free_vars(a, bound, out);
}
}
} }
} }
+28
View File
@@ -144,6 +144,12 @@ impl<'a> Emitter<'a> {
let saved_mut_allocas = std::mem::take(&mut self.mut_var_allocas); 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_pending_allocas = std::mem::take(&mut self.pending_entry_allocas);
let saved_entry_marker = self.entry_block_end_marker.take(); let saved_entry_marker = self.entry_block_end_marker.take();
// Iter it.1: a `loop` inside a lambda body scopes its header /
// phis to the closure's own body, not the outer fn's. Save and
// reset `loop_frames` (the exact mut.3 analogue to
// `mut_var_allocas` above) so a `Term::Recur` inside the thunk
// cannot back-edge to an outer fn's loop header.
let saved_loop_frames = std::mem::take(&mut self.loop_frames);
// Iter 18d.4 fix: a lambda thunk is its own fn frame for // 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 // param-mode lookup. The outer fn's params are not in scope
// inside the thunk; the thunk's own params are pushed below // inside the thunk; the thunk's own params are pushed below
@@ -258,6 +264,7 @@ impl<'a> Emitter<'a> {
self.mut_var_allocas = saved_mut_allocas; self.mut_var_allocas = saved_mut_allocas;
self.pending_entry_allocas = saved_pending_allocas; self.pending_entry_allocas = saved_pending_allocas;
self.entry_block_end_marker = saved_entry_marker; self.entry_block_end_marker = saved_entry_marker;
self.loop_frames = saved_loop_frames;
// 3. Emit allocation + capture filling + closure-pair packing // 3. Emit allocation + capture filling + closure-pair packing
// in the OUTER body. Captures use 8 bytes each; closure-pair // in the OUTER body. Captures use 8 bytes each; closure-pair
@@ -505,6 +512,27 @@ impl<'a> Emitter<'a> {
} }
Self::collect_captures(value, bound, captures, captures_set, builtins, top_level); Self::collect_captures(value, bound, captures, captures_set, builtins, top_level);
} }
// Iter it.1: loop binder names bind inside the body and
// later binder inits — they are NOT captures, mirroring
// the `Term::Mut` arm above. Recur args are uses.
Term::Loop { binders, body } => {
let mut newly_bound: Vec<String> = Vec::new();
for b in binders {
Self::collect_captures(&b.init, bound, captures, captures_set, builtins, top_level);
if bound.insert(b.name.clone()) {
newly_bound.push(b.name.clone());
}
}
Self::collect_captures(body, bound, captures, captures_set, builtins, top_level);
for n in newly_bound {
bound.remove(&n);
}
}
Term::Recur { args } => {
for a in args {
Self::collect_captures(a, bound, captures, captures_set, builtins, top_level);
}
}
} }
} }
+164
View File
@@ -735,6 +735,28 @@ struct Emitter<'a> {
/// at fn-body emission. Used to splice `pending_entry_allocas` /// at fn-body emission. Used to splice `pending_entry_allocas`
/// into the entry block once body lowering completes. /// into the entry block once body lowering completes.
entry_block_end_marker: Option<usize>, entry_block_end_marker: Option<usize>,
/// Iter it.1: stack of in-flight `Term::Loop` frames. Innermost
/// last. `Term::Recur` consults the innermost frame for the
/// header label and records its back-edge incoming values; the
/// `Term::Loop` arm patches each phi's incoming list once the
/// body (and all its recur sites) has been lowered. Saved /
/// reset / restored across the lambda boundary (a `loop` inside
/// a closure scopes its header to the closure's own body), the
/// exact mut.3 analogue to `mut_var_allocas`.
loop_frames: Vec<LoopFrame>,
}
/// Iter it.1: one in-flight `Term::Loop` being lowered. `phis`
/// carries, per binder, `(phi_ssa, llvm_ty, placeholder)` — the
/// placeholder is a unique token emitted in place of the phi's
/// back-edge incoming list and string-replaced once `recur_edges`
/// is complete. `recur_edges` collects `(pred_block_label,
/// [arg_ssa,...])` one entry per `Term::Recur` reached in the body.
#[derive(Debug, Clone)]
struct LoopFrame {
header: String,
phis: Vec<(String, String, String)>,
recur_edges: Vec<(String, Vec<String>)>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -855,6 +877,7 @@ impl<'a> Emitter<'a> {
mut_var_allocas: BTreeMap::new(), mut_var_allocas: BTreeMap::new(),
pending_entry_allocas: String::new(), pending_entry_allocas: String::new(),
entry_block_end_marker: None, entry_block_end_marker: None,
loop_frames: Vec::new(),
} }
} }
@@ -1060,6 +1083,7 @@ impl<'a> Emitter<'a> {
self.mut_var_allocas.clear(); self.mut_var_allocas.clear();
self.pending_entry_allocas.clear(); self.pending_entry_allocas.clear();
self.entry_block_end_marker = None; self.entry_block_end_marker = None;
self.loop_frames.clear();
for (i, pname) in f.params.iter().enumerate() { for (i, pname) in f.params.iter().enumerate() {
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit); let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
self.current_param_modes.insert(pname.clone(), mode); self.current_param_modes.insert(pname.clone(), mode);
@@ -1837,6 +1861,141 @@ impl<'a> Emitter<'a> {
)); ));
Ok(("0".into(), "i8".into())) Ok(("0".into(), "i8".into()))
} }
// Iter it.1: a `Term::Loop` lowers to a header block with
// one `phi` per binder. Binder inits flow in from the
// predecessor block; every enclosed `Term::Recur` adds a
// back-edge incoming value. The phi's back-edge incoming
// list is emitted as a unique placeholder token and
// string-replaced once the body (and all its recur sites)
// has been lowered — phi instructions must syntactically
// precede the body, but the recur predecessors are only
// known after lowering it.
Term::Loop { binders, body } => {
// 1. Lower each binder init in the CURRENT block.
let pred = self.current_block.clone();
// Per binder: (name, ail_ty, init_ssa, llvm_ty).
let mut init_ssa: Vec<(String, Type, String, String)> = Vec::new();
for b in binders {
let lty = llvm_type(&b.ty)?;
let (v, vty) = self.lower_term(&b.init)?;
if vty != lty {
return Err(CodegenError::Internal(format!(
"Term::Loop binder `{}`: init LLVM type {vty} != declared {lty}",
b.name
)));
}
init_ssa.push((b.name.clone(), b.ty.clone(), v, lty));
}
let id = self.fresh_id();
let header = format!("loop.header.{id}");
self.body.push_str(&format!(" br label %{header}\n"));
self.start_block(&header);
// 2. One phi per binder. Each phi's back-edge incoming
// list is a placeholder, patched in step 4. Bind the
// phi SSA into `self.locals` so a `Term::Var` for the
// binder name inside the body resolves to it (a loop
// binder shadows like a let binding).
let mut phis: Vec<(String, String, String)> = Vec::new();
let mut saved_locals: Vec<(String, Option<(String, String, String, Type)>)> =
Vec::new();
for (idx, (name, ail_ty, iv, lty)) in init_ssa.iter().enumerate() {
let p = self.fresh_ssa();
let placeholder = format!("/*RECUR_EDGES.{id}.{idx}*/");
self.body.push_str(&format!(
" {p} = phi {lty} [ {iv}, %{pred} ]{placeholder}\n"
));
let prior = self
.locals
.iter()
.rposition(|(n, _, _, _)| n == name)
.map(|pos| self.locals.remove(pos));
self.locals.push((
name.clone(),
p.clone(),
lty.clone(),
ail_ty.clone(),
));
saved_locals.push((name.clone(), prior));
phis.push((p.clone(), lty.clone(), placeholder));
}
// 3. Lower the body inside this loop frame.
self.loop_frames.push(LoopFrame {
header: header.clone(),
phis: phis.clone(),
recur_edges: Vec::new(),
});
let body_result = self.lower_term(body);
let frame = self
.loop_frames
.pop()
.expect("loop frame pushed above must still be on the stack");
// Restore the shadowed locals unconditionally so an
// error during body lowering does not leak the binder
// bindings into the outer scope.
for (name, prior) in saved_locals.into_iter().rev() {
if let Some(pos) = self.locals.iter().rposition(|(n, _, _, _)| n == &name) {
self.locals.remove(pos);
}
if let Some(p) = prior {
self.locals.push(p);
}
}
let (body_v, body_ty) = body_result?;
// 4. Patch each phi placeholder with the collected
// back-edge incomings (one `[ arg, %pred ]` per
// recur site). A loop whose only exit is `recur`
// has zero non-recur predecessors after the entry
// edge — that is fine; the phi still lists the
// entry edge plus the recur edges.
for (binder_idx, (_p, _lty, placeholder)) in frame.phis.iter().enumerate() {
let mut edges = String::new();
for (pred_lbl, args) in &frame.recur_edges {
let arg = args.get(binder_idx).ok_or_else(|| {
CodegenError::Internal(format!(
"recur edge from %{pred_lbl} missing arg #{binder_idx} \
— typecheck guarantees arity"
))
})?;
edges.push_str(&format!(", [ {arg}, %{pred_lbl} ]"));
}
self.body = self.body.replacen(placeholder.as_str(), &edges, 1);
}
// 5. The loop's value is the body value on the
// non-recur exit path. If every path recurred, the
// body block is terminated and the value is unused.
Ok((body_v, body_ty))
}
// Iter it.1: a `Term::Recur` is a back-edge `br` to the
// innermost enclosing loop header. It records its argument
// SSA values + the current block label so the loop arm can
// patch the header phis. `block_terminated = true` is a
// NEW, parallel setter (it.1 additive constraint — it does
// NOT touch the seven existing tail-driven setters).
Term::Recur { args } => {
let mut arg_ssa: Vec<String> = Vec::with_capacity(args.len());
for a in args {
let (v, _ty) = self.lower_term(a)?;
arg_ssa.push(v);
}
let from = self.current_block.clone();
let frame = self.loop_frames.last_mut().ok_or_else(|| {
CodegenError::Internal(
"Term::Recur without an enclosing loop frame — \
typecheck (RecurOutsideLoop) guarantees this cannot happen"
.into(),
)
})?;
let header = frame.header.clone();
frame.recur_edges.push((from, arg_ssa));
self.body.push_str(&format!(" br label %{header}\n"));
self.block_terminated = true;
Ok(("0".into(), "i8".into()))
}
} }
} }
@@ -3093,6 +3252,11 @@ impl<'a> Emitter<'a> {
// is exhaustive on Term so the arms must exist. // is exhaustive on Term so the arms must exist.
Term::Mut { body, .. } => self.synth_with_extras(body, extras), Term::Mut { body, .. } => self.synth_with_extras(body, extras),
Term::Assign { .. } => Ok(Type::unit()), Term::Assign { .. } => Ok(Type::unit()),
// Iter it.1: a loop's static type is its body's static
// type (spec §"Data model"); `Term::Recur` does not fall
// through (its synth value is never consumed).
Term::Loop { binders: _, body } => self.synth_with_extras(body, extras),
Term::Recur { .. } => Ok(Type::unit()),
} }
} }
} }
+19
View File
@@ -284,6 +284,8 @@ Parenthesised forms:
(mut (var NAME TYPE INIT)* BODY-TERM+) ; local mutable-state block (Iter mut.1) (mut (var NAME TYPE INIT)* BODY-TERM+) ; local mutable-state block (Iter mut.1)
(var NAME TYPE INIT) ; mut-var declaration; only inside (mut ...) (var NAME TYPE INIT) ; mut-var declaration; only inside (mut ...)
(assign NAME VALUE-TERM) ; mut-var update; only inside (mut ...) (assign NAME VALUE-TERM) ; mut-var update; only inside (mut ...)
(loop ((var NAME TYPE INIT)*) BODY-TERM+) ; named loop head (Iter it.1)
(recur TERM*) ; backward jump to nearest enclosing (loop ...)
``` ```
Notes: Notes:
@@ -322,6 +324,23 @@ Notes:
pass (iter mut.2) rejects it with `mut-assign-out-of-scope`. The pass (iter mut.2) rejects it with `mut-assign-out-of-scope`. The
expression's static type is Unit. See spec expression's static type is Unit. See spec
`docs/specs/2026-05-15-mut-local.md`. `docs/specs/2026-05-15-mut-local.md`.
- `loop` introduces one or more named, typed, initialised binders
(the same `(var NAME TYPE INIT)` shape `mut` uses) wrapped in an
explicit binder list `(...)`, followed by one or more body terms
right-folded into `Term::Seq` exactly as `mut` does. Each binder's
`INIT` is evaluated in scope of the outer environment plus the
already-declared binders; the body is in scope of all binders. The
loop's static type is the body's static type.
- `recur` re-enters the lexically nearest enclosing `loop`,
rebinding its binders positionally. It must appear in tail
position of that loop's body. Diagnostics: `recur-outside-loop`
(no enclosing loop), `recur-arity-mismatch` (arg count ≠ binder
count), `recur-type-mismatch` (arg type ≠ binder type),
`recur-not-in-tail-position` (recur reached outside the loop
body's tail context). `loop`/`recur` are additive as of iter it.1;
the structural-recursion restriction and the `Diverge` effect land
in it.2. See spec
`docs/specs/2026-05-15-iteration-discipline.md`.
## Patterns ## Patterns
+61
View File
@@ -537,6 +537,19 @@ pub enum Term {
name: String, name: String,
value: Box<Term>, value: Box<Term>,
}, },
/// Named loop head. The only repetition form besides structural
/// recursion. Iter it.1. `recur` re-enters the lexically
/// nearest enclosing `Loop`, rebinding `binders` positionally.
Loop {
binders: Vec<LoopBinder>,
body: Box<Term>,
},
/// Backward jump to the lexically nearest enclosing `Loop`.
/// Must be in tail position of that loop's body. Iter it.1.
Recur {
#[serde(default)]
args: Vec<Term>,
},
} }
/// One arm of a [`Term::Match`]. /// One arm of a [`Term::Match`].
@@ -580,6 +593,17 @@ pub struct MutVar {
pub init: Term, pub init: Term,
} }
/// One named, typed, initialised binder of a [`Term::Loop`].
/// Mirrors [`MutVar`] (DD-2): no `PartialEq` — codegen maps the
/// binder name to an SSA value, never compares binders.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopBinder {
pub name: String,
#[serde(rename = "type")]
pub ty: Type,
pub init: Box<Term>,
}
/// A match pattern. /// A match pattern.
/// ///
/// The JSON discriminator is the `p` field. Patterns are linear: each /// The JSON discriminator is the `p` field. Patterns are linear: each
@@ -946,4 +970,41 @@ mod tests {
other => panic!("variant mismatch: {other:?}"), other => panic!("variant mismatch: {other:?}"),
} }
} }
/// Iter it.1: round-trip a `Term::Loop` through JSON. Pins the
/// canonical-form shape and the `LoopBinder.ty` serde rename
/// (`"type"`); a future rename or a `skip_serializing_if` on the
/// binder fields would break the content-addressed identity this
/// pin protects.
#[test]
fn term_loop_round_trips_through_json() {
let t = Term::Loop {
binders: vec![LoopBinder {
name: "i".into(),
ty: Type::int(),
init: Box::new(Term::Lit {
lit: Literal::Int { value: 0 },
}),
}],
body: Box::new(Term::Recur {
args: vec![Term::Var { name: "i".into() }],
}),
};
let j = serde_json::to_string(&t).expect("serialise");
assert!(j.contains(r#""t":"loop""#));
assert!(j.contains(r#""type":"#)); // LoopBinder.ty serde rename
let back: Term = serde_json::from_str(&j).expect("deserialise");
assert_eq!(serde_json::to_string(&back).expect("re-serialise"), j);
}
/// Iter it.1: a zero-arg `Term::Recur` round-trips. `args` carries
/// `#[serde(default)]` so an absent `args` key still deserialises;
/// the empty vector serialises explicitly.
#[test]
fn term_recur_empty_args_round_trips() {
let t = Term::Recur { args: vec![] };
let j = serde_json::to_string(&t).expect("serialise");
let back: Term = serde_json::from_str(&j).expect("deserialise");
assert_eq!(serde_json::to_string(&back).expect("re-serialise"), j);
}
} }
+129
View File
@@ -363,6 +363,18 @@ fn collect_used_in_term(t: &Term, used: &mut BTreeSet<String>) {
used.insert(name.clone()); used.insert(name.clone());
collect_used_in_term(value, used); collect_used_in_term(value, used);
} }
Term::Loop { binders, body } => {
for b in binders {
used.insert(b.name.clone());
collect_used_in_term(&b.init, used);
}
collect_used_in_term(body, used);
}
Term::Recur { args } => {
for a in args {
collect_used_in_term(a, used);
}
}
} }
} }
@@ -590,6 +602,32 @@ impl Desugarer {
name: name.clone(), name: name.clone(),
value: Box::new(self.desugar_term(value, scope)), value: Box::new(self.desugar_term(value, scope)),
}, },
Term::Loop { binders, body } => {
// Iter it.1: loop binders introduce source-level names
// like mut-vars; extend the scope per-binder with a
// `LetBound` sentinel so a generated fresh name cannot
// collide. Binder inits see earlier binders.
let mut inner = scope.clone();
let new_binders: Vec<LoopBinder> = binders
.iter()
.map(|b| {
let init = self.desugar_term(&b.init, &inner);
inner.insert(b.name.clone(), ScopeEntry::LetBound);
LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: Box::new(init),
}
})
.collect();
Term::Loop {
binders: new_binders,
body: Box::new(self.desugar_term(body, &inner)),
}
}
Term::Recur { args } => Term::Recur {
args: args.iter().map(|a| self.desugar_term(a, scope)).collect(),
},
Term::LetRec { name, ty, params, body, in_term } => { Term::LetRec { name, ty, params, body, in_term } => {
// Iter 16b.1: lift to a synthetic top-level fn (no-capture). // Iter 16b.1: lift to a synthetic top-level fn (no-capture).
// Iter 16b.2: extend the lift to the path-1 safe subset — // Iter 16b.2: extend the lift to the path-1 safe subset —
@@ -1234,6 +1272,23 @@ pub fn free_vars_in_term(t: &Term, bound: &BTreeSet<String>, out: &mut BTreeSet<
} }
free_vars_in_term(value, bound, out); free_vars_in_term(value, bound, out);
} }
Term::Loop { binders, body } => {
// Iter it.1: a loop binder name binds inside the body.
// Each binder's `init` is evaluated in scope of the OUTER
// environment plus the already-declared binders; the body
// is in scope of all binders. Mirrors `Term::Mut`.
let mut b = bound.clone();
for binder in binders {
free_vars_in_term(&binder.init, &b, out);
b.insert(binder.name.clone());
}
free_vars_in_term(body, &b, out);
}
Term::Recur { args } => {
for a in args {
free_vars_in_term(a, bound, out);
}
}
} }
} }
@@ -1427,6 +1482,43 @@ pub fn subst_var(t: &Term, from: &str, to: &str) -> Term {
name: name.clone(), name: name.clone(),
value: Box::new(subst_var(value, from, to)), value: Box::new(subst_var(value, from, to)),
}, },
Term::Loop { binders, body } => {
// Iter it.1: loop binders lexically shadow outer names,
// exactly like mut-vars (above). Once a binder named
// `from` is declared, later inits AND the body stop
// substituting.
let mut shadowed = false;
let new_binders: Vec<LoopBinder> = binders
.iter()
.map(|b| {
let init = if shadowed {
(*b.init).clone()
} else {
subst_var(&b.init, from, to)
};
if b.name == from {
shadowed = true;
}
LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: Box::new(init),
}
})
.collect();
let body_rw = if shadowed {
(**body).clone()
} else {
subst_var(body, from, to)
};
Term::Loop {
binders: new_binders,
body: Box::new(body_rw),
}
}
Term::Recur { args } => Term::Recur {
args: args.iter().map(|a| subst_var(a, from, to)).collect(),
},
} }
} }
@@ -1568,6 +1660,23 @@ pub fn subst_call_with_extras(t: &Term, name: &str, lifted: &str, extras: &[Stri
name: assign_name.clone(), name: assign_name.clone(),
value: Box::new(subst_call_with_extras(value, name, lifted, extras)), value: Box::new(subst_call_with_extras(value, name, lifted, extras)),
}, },
Term::Loop { binders, body } => Term::Loop {
binders: binders
.iter()
.map(|b| LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: Box::new(subst_call_with_extras(&b.init, name, lifted, extras)),
})
.collect(),
body: Box::new(subst_call_with_extras(body, name, lifted, extras)),
},
Term::Recur { args } => Term::Recur {
args: args
.iter()
.map(|a| subst_call_with_extras(a, name, lifted, extras))
.collect(),
},
} }
} }
@@ -1640,6 +1749,14 @@ pub fn find_non_callee_use(t: &Term, name: &str) -> Option<Term> {
find_non_callee_use(value, name) find_non_callee_use(value, name)
} }
} }
// Iter it.1: scan each binder's init plus the body. Loop
// binders never appear in callee position, so any matching
// `Term::Var` inside a loop is non-callee by construction.
Term::Loop { binders, body } => binders
.iter()
.find_map(|b| find_non_callee_use(&b.init, name))
.or_else(|| find_non_callee_use(body, name)),
Term::Recur { args } => args.iter().find_map(|a| find_non_callee_use(a, name)),
} }
} }
@@ -1687,6 +1804,10 @@ mod tests {
vars.iter().any(|v| any_nested_ctor(&v.init)) || any_nested_ctor(body) vars.iter().any(|v| any_nested_ctor(&v.init)) || any_nested_ctor(body)
} }
Term::Assign { value, .. } => any_nested_ctor(value), Term::Assign { value, .. } => any_nested_ctor(value),
Term::Loop { binders, body } => {
binders.iter().any(|b| any_nested_ctor(&b.init)) || any_nested_ctor(body)
}
Term::Recur { args } => args.iter().any(any_nested_ctor),
} }
} }
@@ -1717,6 +1838,10 @@ mod tests {
vars.iter().any(|v| any_let_rec(&v.init)) || any_let_rec(body) vars.iter().any(|v| any_let_rec(&v.init)) || any_let_rec(body)
} }
Term::Assign { value, .. } => any_let_rec(value), Term::Assign { value, .. } => any_let_rec(value),
Term::Loop { binders, body } => {
binders.iter().any(|b| any_let_rec(&b.init)) || any_let_rec(body)
}
Term::Recur { args } => args.iter().any(any_let_rec),
} }
} }
@@ -2838,6 +2963,10 @@ mod tests {
vars.iter().any(|v| any_lit_pattern(&v.init)) || any_lit_pattern(body) vars.iter().any(|v| any_lit_pattern(&v.init)) || any_lit_pattern(body)
} }
Term::Assign { value, .. } => any_lit_pattern(value), Term::Assign { value, .. } => any_lit_pattern(value),
Term::Loop { binders, body } => {
binders.iter().any(|b| any_lit_pattern(&b.init)) || any_lit_pattern(body)
}
Term::Recur { args } => args.iter().any(any_lit_pattern),
} }
} }
+28
View File
@@ -1259,6 +1259,22 @@ where
walk_term_embedded_types(body, f) walk_term_embedded_types(body, f)
} }
Term::Assign { value, .. } => walk_term_embedded_types(value, f), Term::Assign { value, .. } => walk_term_embedded_types(value, f),
// Iter it.1: each `LoopBinder.ty` is an embedded type — walk
// it, then recurse into each binder's `init` and the body.
// `Term::Recur` carries no embedded type.
Term::Loop { binders, body } => {
for b in binders {
walk_type(&b.ty, f)?;
walk_term_embedded_types(&b.init, f)?;
}
walk_term_embedded_types(body, f)
}
Term::Recur { args } => {
for a in args {
walk_term_embedded_types(a, f)?;
}
Ok(())
}
} }
} }
@@ -1392,6 +1408,18 @@ where
walk_term(body, f) walk_term(body, f)
} }
Term::Assign { value, .. } => walk_term(value, f), Term::Assign { value, .. } => walk_term(value, f),
Term::Loop { binders, body } => {
for b in binders {
walk_term(&b.init, f)?;
}
walk_term(body, f)
}
Term::Recur { args } => {
for a in args {
walk_term(a, f)?;
}
Ok(())
}
} }
} }
@@ -37,6 +37,11 @@ const EXPECTED: &[&str] = &[
"test_mut_var_unsupported_type.ail.json", "test_mut_var_unsupported_type.ail.json",
// Iter mut.4-tidy — lambda-capture-of-mut-var rejection // Iter mut.4-tidy — lambda-capture-of-mut-var rejection
"test_mut_var_captured_by_lambda.ail.json", "test_mut_var_captured_by_lambda.ail.json",
// Iter it.1 — recur negative typecheck fixtures
"test_recur_outside_loop.ail.json",
"test_recur_arity_mismatch.ail.json",
"test_recur_type_mismatch.ail.json",
"test_recur_not_in_tail_position.ail.json",
]; ];
fn examples_dir() -> std::path::PathBuf { fn examples_dir() -> std::path::PathBuf {
@@ -151,6 +151,17 @@ fn design_md_anchors_every_term_variant() {
value: Box::new(Term::Lit { lit: Literal::Unit }), value: Box::new(Term::Lit { lit: Literal::Unit }),
}, },
), ),
(
r#""t": "loop""#,
Term::Loop {
binders: Vec::new(),
body: Box::new(Term::Lit { lit: Literal::Unit }),
},
),
(
r#""t": "recur""#,
Term::Recur { args: Vec::new() },
),
]; ];
for (anchor, term) in exemplars { for (anchor, term) in exemplars {
@@ -172,6 +183,8 @@ fn design_md_anchors_every_term_variant() {
Term::ReuseAs { .. } => "reuse-as", Term::ReuseAs { .. } => "reuse-as",
Term::Mut { .. } => "mut", Term::Mut { .. } => "mut",
Term::Assign { .. } => "assign", Term::Assign { .. } => "assign",
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
}; };
assert!( assert!(
data_model_section().contains(anchor), data_model_section().contains(anchor),
@@ -49,6 +49,8 @@ enum VariantTag {
TermReuseAs, TermReuseAs,
TermMut, TermMut,
TermAssign, TermAssign,
TermLoop,
TermRecur,
// Pattern // Pattern
PatternWild, PatternWild,
PatternVar, PatternVar,
@@ -96,6 +98,8 @@ const EXPECTED_VARIANTS: &[VariantTag] = &[
VariantTag::TermReuseAs, VariantTag::TermReuseAs,
VariantTag::TermMut, VariantTag::TermMut,
VariantTag::TermAssign, VariantTag::TermAssign,
VariantTag::TermLoop,
VariantTag::TermRecur,
VariantTag::PatternWild, VariantTag::PatternWild,
VariantTag::PatternVar, VariantTag::PatternVar,
VariantTag::PatternLit, VariantTag::PatternLit,
@@ -244,6 +248,20 @@ fn visit_term(t: &Term, observed: &mut HashSet<VariantTag>) {
observed.insert(VariantTag::TermAssign); observed.insert(VariantTag::TermAssign);
visit_term(value, observed); visit_term(value, observed);
} }
Term::Loop { binders, body } => {
observed.insert(VariantTag::TermLoop);
for b in binders {
visit_type(&b.ty, observed);
visit_term(&b.init, observed);
}
visit_term(body, observed);
}
Term::Recur { args } => {
observed.insert(VariantTag::TermRecur);
for a in args {
visit_term(a, observed);
}
}
} }
} }
+13
View File
@@ -128,6 +128,17 @@ fn spec_mentions_every_term_variant() {
value: Box::new(Term::Lit { lit: Literal::Unit }), value: Box::new(Term::Lit { lit: Literal::Unit }),
}, },
), ),
(
"(loop",
Term::Loop {
binders: Vec::new(),
body: Box::new(Term::Lit { lit: Literal::Unit }),
},
),
(
"(recur",
Term::Recur { args: Vec::new() },
),
]; ];
for (anchor, term) in exemplars { for (anchor, term) in exemplars {
@@ -151,6 +162,8 @@ fn spec_mentions_every_term_variant() {
Term::ReuseAs { .. } => "reuse-as", Term::ReuseAs { .. } => "reuse-as",
Term::Mut { .. } => "mut", Term::Mut { .. } => "mut",
Term::Assign { .. } => "assign", Term::Assign { .. } => "assign",
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
}; };
assert!( assert!(
FORM_A_SPEC.contains(anchor), FORM_A_SPEC.contains(anchor),
+121
View File
@@ -935,6 +935,37 @@ fn write_term_prec(out: &mut String, t: &Term, level: usize, parent_prec: u8, ow
out.push_str(" := "); out.push_str(" := ");
write_term(out, value, level, owning_module); write_term(out, value, level, owning_module);
} }
Term::Loop { binders, body } => {
// Iter it.1: prose-side minimal-correctness rendering for
// the `(loop ...)` head, mirroring the `mut` block shape
// above. The prose surface for loops is not yet fully
// designed; a follow-on prose iter refines it once the
// LLM-author signal arrives.
out.push_str("loop {\n");
for b in binders {
indent(out, level + 1);
out.push_str("var ");
out.push_str(&b.name);
out.push_str(" = ");
write_term(out, &b.init, level + 1, owning_module);
out.push_str(";\n");
}
indent(out, level + 1);
write_term(out, body, level + 1, owning_module);
out.push('\n');
indent(out, level);
out.push('}');
}
Term::Recur { args } => {
out.push_str("recur(");
for (i, a) in args.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_term(out, a, level, owning_module);
}
out.push(')');
}
} }
} }
@@ -1133,6 +1164,26 @@ fn count_free_var(name: &str, t: &Term) -> usize {
let n_use = if assign_name == name { 1 } else { 0 }; let n_use = if assign_name == name { 1 } else { 0 };
n_use + count_free_var(name, value) n_use + count_free_var(name, value)
} }
// Iter it.1: a loop binder named `name` shadows the outer
// binding for both later binder inits and the body, exactly
// like `Term::Mut`.
Term::Loop { binders, body } => {
let mut total = 0usize;
let mut shadowed = false;
for b in binders {
if !shadowed {
total += count_free_var(name, &b.init);
}
if b.name == name {
shadowed = true;
}
}
if !shadowed {
total += count_free_var(name, body);
}
total
}
Term::Recur { args } => args.iter().map(|a| count_free_var(name, a)).sum(),
} }
} }
@@ -1285,6 +1336,39 @@ fn subst_var_with_term(t: &Term, name: &str, replacement: &Term) -> Term {
name: assign_name.clone(), name: assign_name.clone(),
value: Box::new(subst_var_with_term(value, name, replacement)), value: Box::new(subst_var_with_term(value, name, replacement)),
}, },
Term::Loop { binders, body } => {
let mut shadowed = false;
let new_binders: Vec<ailang_core::ast::LoopBinder> = binders
.iter()
.map(|b| {
let init = if shadowed {
(*b.init).clone()
} else {
subst_var_with_term(&b.init, name, replacement)
};
if b.name == name {
shadowed = true;
}
ailang_core::ast::LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: Box::new(init),
}
})
.collect();
let body_rw = if shadowed {
(**body).clone()
} else {
subst_var_with_term(body, name, replacement)
};
Term::Loop {
binders: new_binders,
body: Box::new(body_rw),
}
}
Term::Recur { args } => Term::Recur {
args: args.iter().map(|a| subst_var_with_term(a, name, replacement)).collect(),
},
} }
} }
@@ -2101,6 +2185,43 @@ mod tests {
assert_eq!(render_term(&t), "tail not(x)"); assert_eq!(render_term(&t), "tail not(x)");
} }
/// Iter it.1: prose-projection lockstep for `Term::Loop` /
/// `Term::Recur`. The property protected: the prose renderer
/// projects a loop's binders + body and a recur's args without
/// dropping or reordering them (free-var / subst arms are
/// exercised by the existing prose suite). Prose is a one-way
/// projection (no Form-B parser exists — see CLAUDE.md), so an
/// AST-equality round-trip is not expressible; this render
/// assertion is the feasible lockstep, using the same
/// `render_term` harness the neighbouring render tests use.
#[test]
fn loop_and_recur_project_to_prose() {
let t = Term::Loop {
binders: vec![ailang_core::ast::LoopBinder {
name: "i".into(),
ty: ailang_core::ast::Type::Con {
name: "Int".into(),
args: vec![],
},
init: Box::new(Term::Lit {
lit: ailang_core::ast::Literal::Int { value: 0 },
}),
}],
body: Box::new(Term::Recur {
args: vec![ivar("i")],
}),
};
let rendered = render_term(&t);
assert!(
rendered.contains("loop {") && rendered.contains("var i = 0"),
"loop head not projected, got:\n{rendered}"
);
assert!(
rendered.contains("recur(i)"),
"recur not projected, got:\n{rendered}"
);
}
// ---- Polish 4: long doc-string wrap ---- // ---- Polish 4: long doc-string wrap ----
#[test] #[test]
+106 -3
View File
@@ -40,7 +40,7 @@
//! | app-term | tail-app-term | match-term | ctor-term //! | app-term | tail-app-term | match-term | ctor-term
//! | do-term | tail-do-term | seq-term | lam-term | if-term //! | do-term | tail-do-term | seq-term | lam-term | if-term
//! | let-term | let-rec-term | clone-term | reuse-as-term //! | let-term | let-rec-term | clone-term | reuse-as-term
//! | mut-term | assign-term //! | mut-term | assign-term | loop-term | recur-term
//! var-ref ::= ident ; reserved: true/false → bool-lit //! var-ref ::= ident ; reserved: true/false → bool-lit
//! int-lit ::= integer ; numeric atom //! int-lit ::= integer ; numeric atom
//! str-lit ::= string ; string atom //! str-lit ::= string ; string atom
@@ -68,8 +68,10 @@
//! clone-term ::= "(" "clone" term ")" ; Iter 18c.1 //! clone-term ::= "(" "clone" term ")" ; Iter 18c.1
//! reuse-as-term ::= "(" "reuse-as" term term ")" ; Iter 18d.1 //! reuse-as-term ::= "(" "reuse-as" term term ")" ; Iter 18d.1
//! mut-term ::= "(" "mut" var-decl* term+ ")" ; Iter mut.1 //! mut-term ::= "(" "mut" var-decl* term+ ")" ; Iter mut.1
//! var-decl ::= "(" "var" ident type term ")" ; legal only inside mut-term //! var-decl ::= "(" "var" ident type term ")" ; legal only inside mut-term / loop-term
//! assign-term ::= "(" "assign" ident term ")" ; Iter mut.1; legal only inside mut-term //! assign-term ::= "(" "assign" ident term ")" ; Iter mut.1; legal only inside mut-term
//! loop-term ::= "(" "loop" "(" var-decl* ")" term+ ")" ; Iter it.1
//! recur-term ::= "(" "recur" term* ")" ; Iter it.1; tail-position-only in loop
//! //!
//! pattern ::= pat-var | pat-ctor | pat-lit | pat-wild //! pattern ::= pat-var | pat-ctor | pat-lit | pat-wild
//! pat-var ::= ident //! pat-var ::= ident
@@ -1212,6 +1214,8 @@ impl<'a> Parser<'a> {
"reuse-as" => self.parse_reuse_as(), "reuse-as" => self.parse_reuse_as(),
"mut" => self.parse_mut(), "mut" => self.parse_mut(),
"assign" => self.parse_assign(), "assign" => self.parse_assign(),
"loop" => self.parse_loop(),
"recur" => self.parse_recur(),
other => { other => {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0); let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
Err(ParseError::Production { Err(ParseError::Production {
@@ -1220,7 +1224,7 @@ impl<'a> Parser<'a> {
"unknown term head `{other}`; expected one of \ "unknown term head `{other}`; expected one of \
`app`, `tail-app`, `lam`, `let`, `let-rec`, `if`, `match`, `do`, \ `app`, `tail-app`, `lam`, `let`, `let-rec`, `if`, `match`, `do`, \
`tail-do`, `seq`, `term-ctor`, `clone`, `reuse-as`, `mut`, \ `tail-do`, `seq`, `term-ctor`, `clone`, `reuse-as`, `mut`, \
`assign`, `lit-unit`" `assign`, `loop`, `recur`, `lit-unit`"
), ),
pos, pos,
}) })
@@ -1642,6 +1646,80 @@ impl<'a> Parser<'a> {
}) })
} }
/// Iter it.1: `(loop ((var NAME TYPE INIT)*) BODY-TERM+)` — named
/// loop head. The binder list is an explicit parenthesised group
/// holding zero or more `(var NAME TYPE INIT)` entries (same entry
/// shape `parse_mut` reads); the trailing ≥ 1 body terms are
/// right-folded into `Term::Seq` exactly as `parse_mut` does. The
/// recur arity / type / tail-position rules are enforced at
/// typecheck (it.1 Task 5); the parser accepts the shape.
fn parse_loop(&mut self) -> Result<Term, ParseError> {
let head_pos = self.peek().map(|t| t.span.start).unwrap_or(0);
self.expect_lparen("loop-term")?;
self.expect_keyword("loop")?;
// The binder list is an explicit parenthesised group of
// (var NAME TYPE INIT) entries.
self.expect_lparen("loop-binder-list")?;
let mut binders: Vec<ailang_core::ast::LoopBinder> = Vec::new();
while matches!(self.peek_head_ident(), Some("var")) {
self.expect_lparen("loop-binder")?;
self.expect_keyword("var")?;
let name = self.expect_ident("loop-binder-name")?;
let ty = self.parse_type()?;
let init = self.parse_term()?;
self.expect_rparen("loop-binder")?;
binders.push(ailang_core::ast::LoopBinder {
name,
ty,
init: Box::new(init),
});
}
self.expect_rparen("loop-binder-list")?;
// Then read ≥ 1 trailing body terms, right-folded into Seq.
let mut body_stmts: Vec<Term> = Vec::new();
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
body_stmts.push(self.parse_term()?);
}
if body_stmts.is_empty() {
return Err(ParseError::Production {
production: "loop-term",
message: "(loop ...) requires at least one body expression after the binder list"
.into(),
pos: head_pos,
});
}
self.expect_rparen("loop-term")?;
let mut body = body_stmts.pop().expect("non-empty after the check above");
while let Some(s) = body_stmts.pop() {
body = Term::Seq {
lhs: Box::new(s),
rhs: Box::new(body),
};
}
Ok(Term::Loop {
binders,
body: Box::new(body),
})
}
/// Iter it.1: `(recur TERM*)` — backward jump to the lexically
/// nearest enclosing `(loop ...)`. The parser accepts any arg
/// count; the enclosing-loop / arity / type / tail-position rules
/// are enforced at typecheck (it.1 Task 5).
fn parse_recur(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("recur-term")?;
self.expect_keyword("recur")?;
let mut args: Vec<Term> = Vec::new();
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
args.push(self.parse_term()?);
}
self.expect_rparen("recur-term")?;
Ok(Term::Recur { args })
}
// ---- patterns ------------------------------------------------------- // ---- patterns -------------------------------------------------------
fn parse_pattern(&mut self) -> Result<Pattern, ParseError> { fn parse_pattern(&mut self) -> Result<Pattern, ParseError> {
@@ -2583,4 +2661,29 @@ mod tests {
"diagnostic should mention missing body, got: {msg}" "diagnostic should mention missing body, got: {msg}"
); );
} }
/// Iter it.1: `(loop ((var i Int 0)) (recur i))` parses to a
/// `Term::Loop` with one binder and a `Term::Recur` body. The
/// binder list is explicitly parenthesised (unlike `mut`).
#[test]
fn parses_loop_with_one_binder_and_recur_body() {
let src = "(loop ((var i (con Int) 0)) (recur i))";
let t = parse_term(src).expect("loop parses");
match t {
Term::Loop { binders, body } => {
assert_eq!(binders.len(), 1);
assert_eq!(binders[0].name, "i");
assert!(matches!(*body, Term::Recur { .. }));
}
other => panic!("expected Term::Loop, got {other:?}"),
}
}
/// Iter it.1: `(recur)` with no args parses to an empty-args
/// `Term::Recur` (arity/scope validity is a typecheck concern).
#[test]
fn parses_recur_zero_args() {
let t = parse_term("(recur)").expect("recur parses");
assert!(matches!(t, Term::Recur { args } if args.is_empty()));
}
} }
+48
View File
@@ -594,6 +594,54 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
write_term(out, value, level); write_term(out, value, level);
out.push(')'); out.push(')');
} }
Term::Loop { binders, body } => {
// Iter it.1: print as
// `(loop ((var NAME TYPE INIT)*) STMT* FINAL_EXPR)`
// The binder list is explicitly parenthesised (unlike
// `mut`); the body's `Term::Seq` right-spine is walked the
// same way the `Term::Mut` arm above walks it.
out.push_str("(loop (");
for (k, b) in binders.iter().enumerate() {
if k > 0 {
out.push(' ');
}
out.push_str("(var ");
out.push_str(&b.name);
out.push(' ');
write_type(out, &b.ty);
out.push(' ');
write_term(out, &b.init, level);
out.push(')');
}
out.push(')');
let mut cursor: &Term = body;
loop {
match cursor {
Term::Seq { lhs, rhs } => {
out.push(' ');
write_term(out, lhs, level);
cursor = rhs;
}
other => {
out.push(' ');
write_term(out, other, level);
break;
}
}
}
out.push(')');
}
Term::Recur { args } => {
// Iter it.1: print as `(recur ARG*)`. Legal only in tail
// position of an enclosing `Term::Loop` body; the
// typechecker (it.1 Task 5) enforces that rule.
out.push_str("(recur");
for a in args {
out.push(' ');
write_term(out, a, level);
}
out.push(')');
}
} }
} }
+25
View File
@@ -2410,6 +2410,22 @@ are real surface forms.
{ "t": "assign", { "t": "assign",
"name": "<id>", "name": "<id>",
"value": Term } "value": Term }
// Iter it.1: named loop head. `binders` declares one or more
// lexically-scoped, typed, initialised binders (initialised in
// order); `body` is a single Term in scope of all binders. `recur`
// re-enters the lexically nearest enclosing loop, rebinding the
// binders positionally; it must be in tail position of that loop's
// body. The loop's static type is `body`'s static type.
{ "t": "loop",
"binders": [ { "name": "<id>", "type": Type, "init": Term }, ... ],
"body": Term }
// Iter it.1: backward jump to the lexically nearest enclosing loop;
// tail-position-only; binds that loop's binders. The `args` array is
// always present in canonical JSON (even when empty).
{ "t": "recur",
"args": [ Term, ... ] }
``` ```
In the MVP, `do` is only a direct call to a built-in effect op (no In the MVP, `do` is only a direct call to a built-in effect op (no
@@ -2425,6 +2441,15 @@ mut-var into a lambda body is rejected at typecheck via
`CheckError::MutVarCapturedByLambda` (iter mut.4-tidy). See `CheckError::MutVarCapturedByLambda` (iter mut.4-tidy). See
`docs/specs/2026-05-15-mut-local.md`. `docs/specs/2026-05-15-mut-local.md`.
`loop`/`recur` are additive as of iter it.1 (2026-05-15): they
parse, print, prose-project, round-trip, typecheck (binder typing,
recur arity/type unification, recur tail-position) and codegen
(loop-header block with one phi per binder, `recur` as a back-edge
`br`) without removing or modifying the existing `tail-app`/`tail-do`
paths. The structural-recursion restriction and the `Diverge`
effect land in it.2; `tail-app`/`tail-do` are retired in it.3. See
`docs/specs/2026-05-15-iteration-discipline.md`.
**`Literal`**: **`Literal`**:
```jsonc ```jsonc
+251
View File
@@ -0,0 +1,251 @@
# iter it.1 — loop/recur additive (parse/print/prose/check/codegen)
**Date:** 2026-05-15
**Started from:** 7381a4233b0ea34b23871c59e3a3e802441751ef
**Status:** DONE
**Tasks completed:** 8 of 8
## Summary
First of three iterations in the iteration-discipline milestone.
Adds `Term::Loop` / `Term::Recur` / `LoopBinder` as first-class
additive AST nodes end-to-end — parse, print, prose projection,
canonical-JSON serde + round-trip, schema/spec-drift lockstep,
typecheck (binder typing, recur arity/type unification via a
`loop_stack: &mut Vec<Vec<Type>>` threaded exactly as mut.2 threaded
`mut_scope_stack`, recur tail-position via a new private
`verify_loop_body` helper with `verify_tail_positions`'s public
signature unchanged), and codegen (loop-header block with one phi
per binder, `recur` as a back-edge `br` with a NEW parallel
`block_terminated = true` setter). The change is **strictly
additive**: zero deletions touch the existing `tail-app`/`tail-do`
paths, `verify_tail_positions`' tail-app role, or any of the seven
existing codegen `block_terminated` sites — the 32 within-iter
deletion-lines are exclusively the Task-1 `Internal(...)` stubs and
the Task-1 `verify_tail_positions` structural-passthrough being
replaced by their real Task-5/Task-7 implementations (DD-3
stub-then-fill). This additive discipline is what makes the
destructive it.3 safe later. Full `cargo test --workspace` green
(63 ok result groups, 0 failed); `loop_counter.ail` builds and
prints `55`; the four `Recur*` diagnostics fire on their negatives;
`tail-app` fixtures (`bench_compute_intsum`, `list_map_poly`) run
byte-identically; zero IR/prose snapshot drift.
## Per-task notes
- iter it.1.1: AST variants + every walker arm + serde/schema
lockstep. `Term::Loop { binders: Vec<LoopBinder>, body: Box<Term> }`,
`Term::Recur { #[serde(default)] args: Vec<Term> }`, and
`LoopBinder { name, #[serde(rename="type")] ty, init: Box<Term> }`
(DD-2 derives = `Debug, Clone, Serialize, Deserialize`, no
PartialEq; note `init` is `Box<Term>` per the plan's literal code
block, whereas `MutVar.init` is bare `Term` — a small divergence
from "mirrors MutVar exactly", recorded under Concerns). ~40
walker arms across desugar (incl. 3 in-test `any_*` helpers not
enumerated by the plan), workspace, all of ailang-check
(lib/lift/linearity/mono/pre_desugar_validation/reuse_shape/
uniqueness), codegen (escape×3/lambda/lib), prose, surface
parse/print, ail main + the test-side
`codegen_import_map_fallback_pin.rs` walker the plan flagged
(mut.1 missed its analogue; did not miss it here). Typecheck +
codegen *dispatch* stubbed `Internal(...)` (tuple variant —
the plan pseudo-code's `Internal { msg }` struct form does not
exist; copied mut.1's verbatim tuple shape). `synth_with_extras`
got real arms (loop → body's type, recur → unit) per plan.
schema_coverage left RED on "variant never observed" until
Task 3 (plan-expected); every other test green.
- iter it.1.2: Form-A parse. `parse_loop` / `parse_recur` modelled
on `parse_mut` / `parse_assign`; the binder list is an explicitly
parenthesised group `(loop ((var ...)*) BODY+)` (form_a.md
production added in Task 1.10 lockstep). Plan pseudo-code named
non-existent helpers (`parse_term_str`, `parse_body_seq`,
`parse_loop_binders`); substituted the real harness (module-level
`parse_term`) and inlined the per-`(var …)` decode exactly as
`parse_mut` does — the plan explicitly forbade introducing a new
body-folding helper. EBNF prologue + unknown-head error list
extended. RED→GREEN confirmed.
- iter it.1.3: Form-A print + positive fixtures. Print arms landed
early in Task 1 as a build-unblock necessity (documented mut.1
precedent — print arms are exhaustive-match neighbours of the
synth/lower_term dispatchers). `loop_smoke.ail` /
`loop_nested_in_lambda.ail` written in real Form-A matching
`mut_counter.ail`'s verbatim surface spellings (`(con Int)`,
`(app == …)`, `(app + …)`, `(lam (params (typed …)) (ret …)
(body …))`) — the plan's fixture pseudo-code (`(fn count_to :
Int -> Int …)`) is not valid Form-A and the plan's prose
explicitly directed matching mut_counter.ail verbatim.
parse→print→parse byte-idempotent; schema_coverage now GREEN
(Task 1.11 deferred RED closed).
- iter it.1.4: Prose projection lockstep. Step 4.1's literal
instruction ("render to prose and back, asserting AST equality")
is **not expressible** — AILang has no Form-B→AST parser by
design (CLAUDE.md / `crates/ail/src/main.rs:207` "no parser
exists for the prose surface"). Recorded as a plan defect (see
Concerns). The substantive work (render/free-var/subst arms,
landed in Task 1) is correct and verified by the full prose
suite; added `loop_and_recur_project_to_prose`, a render-assertion
pin using the same `render_term` harness the nearest existing
prose tests (`not_with_tail_flag_keeps_prefix_form`) use — the
feasible equivalent of the plan's intent. This is the documented
mut.1-class plan-pseudo-vs-real-API substitution, not a silent
swap. Status DONE_WITH_CONCERNS.
- iter it.1.5: Real typecheck (DD-1). Four `CheckError` variants
(`RecurOutsideLoop` / `RecurArityMismatch` / `RecurTypeMismatch`
/ `RecurNotInTailPosition`) — **no `span` field** because no
`Span` type is in scope in this crate and no existing CheckError
variant carries one (the plan pseudo-code's `span: Option<Span>`
references a non-existent-here type; mirrored the real
`MutAssignOutOfScope` field-shape exactly as the plan's prose
instructed). `loop_stack: &mut Vec<Vec<Type>>` threaded through
`synth`'s signature + every call site (signature + 22 single-line
recursive + 3 multi-line recursive + 2 production entry points +
lift.rs + 2 mono.rs + builtins.rs test helpers + 8 in-source
`#[cfg(test)]` calls), exactly the mut.2 pattern. Real Loop arm
binds binder names into `self`-… `locals` (save/restore like
`Term::Let` — the correct precedent for *immutable* bindings,
not the mut-var mechanism) and pushes the binder type-vector onto
`loop_stack`. **`Term::Recur` synth returns a fresh metavar
(`Subst::fresh(counter)`), not the plan's flagged-risky
`Type::unit()`** — see Concerns; this resolves the plan's own
self-review "Open risk" correctly (a fresh metavar is the
codebase-idiomatic bottom that unifies anywhere, which is what
makes `(if c then (recur …))` typecheck — `Type::unit()` would
have broken `loop_smoke`'s if-branch unification). `verify_loop_body`
added as a new private helper; `verify_tail_positions`' public
signature unchanged (it.3 reworks it). The diagnostic-doc list in
`diagnostic.rs` was NOT extended — the mut codes were never added
there (false plan premise; followed the actual mut.2 precedent).
RED→GREEN: loop_smoke clean, four negatives each return their
exact code. `loop_stack` threading caused zero regressions.
- iter it.1.6: Carve-out inventory. EXPECTED extended with the four
`test_recur_*.ail.json` negatives, 13→17, in an it.1 group
(the const is milestone-grouped not alphabetical; the test uses
a BTreeSet so order is functionally irrelevant). GREEN.
- iter it.1.7: Real codegen (loop-header + phi + recur back-edge +
lambda boundary). `loop_frames: Vec<LoopFrame>` field added (mut.3
analogue to `mut_var_allocas` — init/clear/lambda-save-reset-
restore symmetric). Loop lowers to `loop.header.<id>` with one
`phi` per binder; binder phi SSA pushed into `self.locals` so
`Term::Var` resolves to it via the existing lookup. Each phi's
back-edge incoming list is emitted as a unique `/*RECUR_EDGES…*/`
LLVM-comment placeholder, then `replacen(…, 1)`-patched once the
body and all its recur sites are lowered (the plan's
`patch_loop_phis` mechanism; the placeholder is a comment so an
unpatched one would be inert IR, not a syntax error). `Term::Recur`
records `(pred_block, [arg_ssa])`, emits the back-edge `br`, sets
`block_terminated = true` — a NEW parallel setter; the seven
existing tail-driven setters are untouched (verified: zero
deletions touch them). Reused `start_block`/`fresh_ssa`/`fresh_id`/
`self.current_block` instead of adding the plan-pseudo's sprawl of
single-use helper methods (plan explicitly: "REUSE … grep before
adding"). `loop_counter.ail` builds + prints `55`.
- iter it.1.8: tail-app coexistence + IR snapshots + acceptance.
`bench_compute_intsum.ail``3500003500000`,
`list_map_poly.ail``2\n3\n…` (byte-identical to pre-it.1; no
existing codegen path modified). Zero IR/prose snapshot drift (no
blanket regen). All it.1 acceptance bullets verified — the spec's
"five Recur diagnostics" is four in it.1 (the fifth,
`NonStructuralRecursion`, is it.2; resolved per plan Step 8.3).
- Phase 3 (E2E): added `loop_in_lambda_e2e.ail` + e2e
`loop_in_lambda_runs_and_prints_49` — a `loop` inside a `lam`
body invoked via a returned closure, proving the lambda-boundary
`loop_frames` save/restore is codegen-sound (the no-`main`
`loop_nested_in_lambda.ail` parse/print fixture cannot reach
codegen via the CLI; this runnable fixture closes that gap).
## Concerns
- `LoopBinder.init` is `Box<Term>` whereas the analogous
`MutVar.init` is bare `Term`. Per the plan's literal Step 1.1
code block (which specified `init: Box<Term>`), not implementer
drift. DD-2 says "mirrors MutVar exactly" — the divergence is in
the plan's own pseudo-code vs. its own DD-2 prose. Functional
impact: none (consistent `(*b.init).clone()` / `Box::new(...)` /
`&mut b.init` handling everywhere). A future iter that wants
strict parity could unbox; no it.1 test depends on the boxing.
- `Term::Recur` synth result: chose `Subst::fresh(counter)` (a
fresh metavar) over the plan's named fallback `Type::unit()`.
The plan's self-review flagged this exact point as an "Open
risk" with `Type::unit()` as the fallback "because recur is
always in tail position so its value is never consumed". But
`recur` frequently appears as an `if` branch (`loop_smoke`:
`(if (== i n) i (recur …))`), where the branch types MUST
unify — `unify(Int, unit)` fails, so `Type::unit()` would have
broken `loop_smoke`. A fresh metavar unifies with any type
(codegen-idiomatic bottom; the same mechanism `Subst::fresh`
serves everywhere). This is the correct resolution of the plan's
own flagged risk, not a deviation from its intent.
- Step 4.1 plan defect: it asks for a prose round-trip asserting
AST equality; AILang has no Form-B parser by design. Substituted
a feasible render-assertion pin (mut.1-class plan-pseudo-vs-real
substitution). The plan's *substance* (prose lockstep for the new
variants) is fully met. Boss may want to tighten the planner
template so it does not script prose round-trips.
- Step 5.2 plan premise defect: "Add the four code strings to the
diagnostic-doc list … same list the mut codes were added to" —
the mut codes were never added to `diagnostic.rs`'s doc list.
Followed the actual mut.2 precedent (not extended); the
authoritative registry is `CheckError::code()`, which has all
four.
- The plan's recon line numbers (e.g. `desugar.rs:349/559/…`,
`lib.rs:2590/2613/3593/3614`, `parse.rs:1212`) had drifted
vs. the live tree; drove every site off `cargo build`'s
E0004 enumeration as Step 1.2 instructs. Site *count* also
differed (6 desugar E0004 sites vs. plan's 9; resolved via the
build, which is authoritative). No behavioural impact —
same class as mut.1's documented recon misindexing.
## Known debt
- Prose-side `(loop …)` / `(recur …)` rendering is a
minimal-correctness shape (`loop { var n = init; …; body }` /
`recur(a, b)`), explicitly mirroring the deliberately-placeholder
mut-block prose shape. The prose surface for loops is not yet
designed; a follow-on prose iter refines it once the LLM-author
signal arrives. Not drift — recorded for visibility.
- `loop_nested_in_lambda.ail` has no `main`, so it is a
parse/print/typecheck fixture only (CLI codegen needs a `main`).
Codegen-of-loop-in-lambda is instead proven by the runnable
Phase-3 `loop_in_lambda_e2e.ail`. No gap remains; recorded so a
future reader does not mistake the no-main fixture for dead
coverage.
## Files touched
AST/core: `crates/ailang-core/src/ast.rs`,
`crates/ailang-core/src/desugar.rs`,
`crates/ailang-core/src/workspace.rs`,
`crates/ailang-core/specs/form_a.md`.
Drift/coverage tests: `crates/ailang-core/tests/{design_schema_drift,
schema_coverage,spec_drift,carve_out_inventory}.rs`.
Check: `crates/ailang-check/src/{lib,lift,linearity,mono,
pre_desugar_validation,reuse_shape,uniqueness,builtins}.rs`,
`crates/ailang-check/tests/loop_recur_pin.rs` (new).
Codegen: `crates/ailang-codegen/src/{lib,escape,lambda}.rs`.
Surface/prose: `crates/ailang-surface/src/{parse,print}.rs`,
`crates/ailang-prose/src/lib.rs`.
CLI: `crates/ail/src/main.rs`,
`crates/ail/tests/{e2e,codegen_import_map_fallback_pin}.rs`.
Spec: `docs/DESIGN.md`.
Fixtures (new): `examples/loop_smoke.ail`,
`examples/loop_nested_in_lambda.ail`, `examples/loop_counter.ail`,
`examples/loop_in_lambda_e2e.ail`,
`examples/test_recur_{outside_loop,arity_mismatch,type_mismatch,
not_in_tail_position}.ail.json`.
## Stats
bench/orchestrator-stats/2026-05-15-iter-it.1.json
+1
View File
@@ -75,3 +75,4 @@
- 2026-05-15 — iter mut.3: codegen + e2e for `Term::Mut` + `Term::Assign` closing the mut-local milestone end-to-end; mut-var allocas hoisted to fn entry block via a per-`Emitter` side buffer `pending_entry_allocas: String` + a captured `entry_block_end_marker: Option<usize>` byte position spliced via `String::insert_str` at the end of `emit_fn`; `Term::Mut` arm in `lower_term` emits one alloca per var into the side buffer, lowers each init at the current position, emits a `store` to bind, push/pops a per-block save stack for proper shadowing of outer mut-vars; `Term::Assign` arm emits `store <ty> <value_ssa>, ptr <alloca>` and yields the canonical Unit SSA; `Term::Var` arm prepends mut-var lookup with a `load` emission. Two beyond-plan adjustments: (1) `synth_with_extras`'s parallel `Term::Var` arm needed the same mut-var lookup precedence as `lower_term`, (2) `lambda.rs` thunk emission needed save/restore of all three new `Emitter` fields across the lambda-body boundary plus an in-thunk splice at the lambda's own entry marker so mut-blocks inside a closure body hoist to the closure's entry not the outer fn's. Two e2e fixtures `examples/mut_counter.ail` (Int) + `examples/mut_sum_floats.ail` (Float); both run end-to-end and print `55`. DESIGN.md "What **is** supported" subsection gains a "Local mutable state" bullet. mut-local milestone end-to-end closed. Tests 592 → 594 → 2026-05-15-iter-mut.3.md - 2026-05-15 — iter mut.3: codegen + e2e for `Term::Mut` + `Term::Assign` closing the mut-local milestone end-to-end; mut-var allocas hoisted to fn entry block via a per-`Emitter` side buffer `pending_entry_allocas: String` + a captured `entry_block_end_marker: Option<usize>` byte position spliced via `String::insert_str` at the end of `emit_fn`; `Term::Mut` arm in `lower_term` emits one alloca per var into the side buffer, lowers each init at the current position, emits a `store` to bind, push/pops a per-block save stack for proper shadowing of outer mut-vars; `Term::Assign` arm emits `store <ty> <value_ssa>, ptr <alloca>` and yields the canonical Unit SSA; `Term::Var` arm prepends mut-var lookup with a `load` emission. Two beyond-plan adjustments: (1) `synth_with_extras`'s parallel `Term::Var` arm needed the same mut-var lookup precedence as `lower_term`, (2) `lambda.rs` thunk emission needed save/restore of all three new `Emitter` fields across the lambda-body boundary plus an in-thunk splice at the lambda's own entry marker so mut-blocks inside a closure body hoist to the closure's entry not the outer fn's. Two e2e fixtures `examples/mut_counter.ail` (Int) + `examples/mut_sum_floats.ail` (Float); both run end-to-end and print `55`. DESIGN.md "What **is** supported" subsection gains a "Local mutable state" bullet. mut-local milestone end-to-end closed. Tests 592 → 594 → 2026-05-15-iter-mut.3.md
- 2026-05-15 — iter mut.4-tidy: close mut-local audit drift. Architect's two `[high]` items addressed — new `CheckError::MutVarCapturedByLambda` rejects any lambda whose body's free vars hit the enclosing `mut_scope_stack` (via the existing `desugar::free_vars_in_term` helper, gated on non-empty stack), and `codegen/lambda.rs`'s blame-typechecker `Internal` path becomes `unreachable!` once typecheck is the gate. Two `[medium]` stale-history comments cleaned in DESIGN.md §"Term (expression)" and `ailang-codegen/src/lib.rs`. New negative fixture `examples/test_mut_var_captured_by_lambda.ail.json` + driver test extension; `carve_out_inventory.rs` EXPECTED 12→13. Spec §"Out of scope" amended with the lambda-capture rejection bullet. Bench: `compile_check.py` `check_ms` showed a uniform ~30-50% regression across the suite traced to a fixed-cost startup tax (mut-* added ~1400 lines of typecheck/codegen surface; the short-circuit on empty `mut_scope_stack` walk in Term::Var did not move the needle, falsifying the hot-path hypothesis); ratified as a feature tax with paired baseline update on `bench/baseline_compile.json`. `check.py` tail-noise envelope continues the audit-pd carve-out (no ratify needed). `cross_lang.py` clean. Tests 594 → 598. mut-local milestone audit closed → 2026-05-15-iter-mut.4-tidy.md - 2026-05-15 — iter mut.4-tidy: close mut-local audit drift. Architect's two `[high]` items addressed — new `CheckError::MutVarCapturedByLambda` rejects any lambda whose body's free vars hit the enclosing `mut_scope_stack` (via the existing `desugar::free_vars_in_term` helper, gated on non-empty stack), and `codegen/lambda.rs`'s blame-typechecker `Internal` path becomes `unreachable!` once typecheck is the gate. Two `[medium]` stale-history comments cleaned in DESIGN.md §"Term (expression)" and `ailang-codegen/src/lib.rs`. New negative fixture `examples/test_mut_var_captured_by_lambda.ail.json` + driver test extension; `carve_out_inventory.rs` EXPECTED 12→13. Spec §"Out of scope" amended with the lambda-capture rejection bullet. Bench: `compile_check.py` `check_ms` showed a uniform ~30-50% regression across the suite traced to a fixed-cost startup tax (mut-* added ~1400 lines of typecheck/codegen surface; the short-circuit on empty `mut_scope_stack` walk in Term::Var did not move the needle, falsifying the hot-path hypothesis); ratified as a feature tax with paired baseline update on `bench/baseline_compile.json`. `check.py` tail-noise envelope continues the audit-pd carve-out (no ratify needed). `cross_lang.py` clean. Tests 594 → 598. mut-local milestone audit closed → 2026-05-15-iter-mut.4-tidy.md
- 2026-05-15 — bugfix mut-diag-double-code: fieldtest F2 — the four mut-local `CheckError` variants embedded `[<code>]` in their `#[error]` Display body while the non-JSON CLI formatter also prepends `[code]`, doubling it in human stderr; dropped the embedded prefix from the four strings, bringing them in line with all non-mut variants; RED-first via `debug` (`ct1_check_cli.rs::check_human_mode_renders_mut_diagnostic_code_exactly_once`), GREEN applied inline as a trivial mechanical edit; tests 598 → 599 → 2026-05-15-bugfix-mut-diag-double-code.md - 2026-05-15 — bugfix mut-diag-double-code: fieldtest F2 — the four mut-local `CheckError` variants embedded `[<code>]` in their `#[error]` Display body while the non-JSON CLI formatter also prepends `[code]`, doubling it in human stderr; dropped the embedded prefix from the four strings, bringing them in line with all non-mut variants; RED-first via `debug` (`ct1_check_cli.rs::check_human_mode_renders_mut_diagnostic_code_exactly_once`), GREEN applied inline as a trivial mechanical edit; tests 598 → 599 → 2026-05-15-bugfix-mut-diag-double-code.md
- 2026-05-15 — iter it.1: iteration-discipline milestone (1 of 3) — `Term::Loop` / `Term::Recur` / `LoopBinder` added as strictly-additive first-class AST nodes end-to-end: Form-A `parse_loop`/`parse_recur` + print + prose render/free-var/subst lockstep + canonical-JSON serde/round-trip + schema/spec-drift/schema-coverage/carve-out lockstep (13→17) + typecheck (binder typing + recur arity/type unification via `loop_stack: &mut Vec<Vec<Type>>` threaded exactly as mut.2's `mut_scope_stack`; recur-tail-position via a new private `verify_loop_body`, `verify_tail_positions` public signature unchanged) + codegen (loop-header block, one phi per binder, recur back-edge `br` with a NEW parallel `block_terminated` setter, lambda-boundary `loop_frames` save/restore mirroring mut.3). Four new `CheckError` variants `Recur{OutsideLoop,ArityMismatch,TypeMismatch,NotInTailPosition}` (bracket-`[code]`-free Display per the F2 convention). Strictly additive verified — zero deletions touch `tail-app`/`tail-do`, `verify_tail_positions`' tail-app role, or the seven existing codegen `block_terminated` sites (the 32 within-iter deletions are exclusively DD-3 stub-then-fill replacements). `Term::Recur` synth returns a fresh metavar (resolves the plan's flagged `Type::unit()` open risk: recur appears in `if` branches that must unify with the sibling type). `loop_counter.ail``55`, `loop_in_lambda_e2e.ail``49`, four negatives fire exact codes, `tail-app` fixtures byte-identical, zero IR/prose snapshot drift; `cargo test --workspace` green. Two mut.1-class plan-pseudo-vs-reality substitutions recorded (prose round-trip asserting AST-equality is impossible — no Form-B parser by design; the diagnostic.rs doc-list premise was false) → 2026-05-15-iter-it.1.md
+12
View File
@@ -0,0 +1,12 @@
(module loop_counter
(fn main
(doc "Iter it.1 — sum 1..10 via an accumulator loop. Expected stdout: 55.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(loop ((var acc (con Int) 0) (var i (con Int) 1))
(if (app > i 10)
acc
(recur (app + acc i) (app + i 1))))))))
+24
View File
@@ -0,0 +1,24 @@
(module loop_in_lambda_e2e
(fn apply
(doc "apply a fn-of-Int to an Int")
(type (fn-type (params (fn-type (params (con Int)) (ret (con Int))) (con Int)) (ret (con Int))))
(params f x)
(body (app f x)))
(fn main
(doc "Iter it.1 — a loop inside a lambda body, invoked via a closure. The lambda computes x*x by summing x exactly x times through an accumulator loop; apply 7 prints 49. Codegen-soundness gate for the lambda-boundary loop_frames save/restore.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(app apply
(lam
(params (typed x (con Int)))
(ret (con Int))
(body
(loop ((var acc (con Int) 0) (var k (con Int) 0))
(if (app == k x)
acc
(recur (app + acc x) (app + k 1))))))
7)))))
+15
View File
@@ -0,0 +1,15 @@
(module loop_nested_in_lambda
(fn make_adder
(doc "Iter it.1 — a loop inside a lambda body (lambda-boundary analogue).")
(type (fn-type (params (con Int)) (ret (fn-type (params (con Int)) (ret (con Int))))))
(params base)
(body
(lam
(params (typed x (con Int)))
(ret (con Int))
(body
(loop ((var acc (con Int) base) (var k (con Int) 0))
(if (app == k x)
acc
(recur (app + acc 1) (app + k 1)))))))))
+11
View File
@@ -0,0 +1,11 @@
(module loop_smoke
(fn count_to
(doc "Iter it.1 — single counted loop; counts i up to n and returns n.")
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop ((var i (con Int) 0))
(if (app == i n)
i
(recur (app + i 1)))))))
@@ -0,0 +1,60 @@
{
"schema": "ailang/v0",
"name": "test_recur_arity_mismatch",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": {
"k": "con",
"name": "Int"
},
"effects": []
},
"params": [],
"doc": "Iter it.1: recur passing 2 args to a 1-binder loop fires recur-arity-mismatch.",
"body": {
"t": "loop",
"binders": [
{
"name": "i",
"type": {
"k": "con",
"name": "Int"
},
"init": {
"t": "lit",
"lit": {
"kind": "int",
"value": 0
}
}
}
],
"body": {
"t": "recur",
"args": [
{
"t": "lit",
"lit": {
"kind": "int",
"value": 1
}
},
{
"t": "lit",
"lit": {
"kind": "int",
"value": 2
}
}
]
}
}
}
]
}
@@ -0,0 +1,60 @@
{
"schema": "ailang/v0",
"name": "test_recur_not_in_tail_position",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": {
"k": "con",
"name": "Int"
},
"effects": []
},
"params": [],
"doc": "Iter it.1: recur in the lhs of a seq (non-tail) fires recur-not-in-tail-position.",
"body": {
"t": "loop",
"binders": [
{
"name": "i",
"type": {
"k": "con",
"name": "Int"
},
"init": {
"t": "lit",
"lit": {
"kind": "int",
"value": 0
}
}
}
],
"body": {
"t": "seq",
"lhs": {
"t": "recur",
"args": [
{
"t": "var",
"name": "i"
}
]
},
"rhs": {
"t": "lit",
"lit": {
"kind": "int",
"value": 0
}
}
}
}
}
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"schema": "ailang/v0",
"name": "test_recur_outside_loop",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": {
"k": "con",
"name": "Int"
},
"effects": []
},
"params": [],
"doc": "Iter it.1: a recur with no enclosing loop fires recur-outside-loop.",
"body": {
"t": "recur",
"args": [
{
"t": "lit",
"lit": {
"kind": "int",
"value": 1
}
}
]
}
}
]
}
@@ -0,0 +1,53 @@
{
"schema": "ailang/v0",
"name": "test_recur_type_mismatch",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": {
"k": "con",
"name": "Int"
},
"effects": []
},
"params": [],
"doc": "Iter it.1: recur passing a Bool to an Int binder fires recur-type-mismatch.",
"body": {
"t": "loop",
"binders": [
{
"name": "i",
"type": {
"k": "con",
"name": "Int"
},
"init": {
"t": "lit",
"lit": {
"kind": "int",
"value": 0
}
}
}
],
"body": {
"t": "recur",
"args": [
{
"t": "lit",
"lit": {
"kind": "bool",
"value": true
}
}
]
}
}
}
]
}