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
+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(" := ");
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 };
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(),
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)");
}
/// 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 ----
#[test]