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
+61
View File
@@ -537,6 +537,19 @@ pub enum Term {
name: String,
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`].
@@ -580,6 +593,17 @@ pub struct MutVar {
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.
///
/// The JSON discriminator is the `p` field. Patterns are linear: each
@@ -946,4 +970,41 @@ mod tests {
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());
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(),
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 } => {
// Iter 16b.1: lift to a synthetic top-level fn (no-capture).
// 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);
}
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(),
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(),
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)
}
}
// 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)
}
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)
}
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)
}
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)
}
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)
}
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(())
}
}
}