iter loop-recur.1: additive Term::Loop / Term::Recur / LoopBinder foundation
First of three iterations of the standalone loop/recur milestone
(spec 97c1ed1). loop/recur become real parseable / printable /
round-trippable / hash-stable strictly-additive AST nodes across
every no-wildcard exhaustive Term match in all six crates, plus
parse/print, prose arms, DESIGN.md + form_a.md schema blocks,
drift/coverage/hash anchors, and the spec's worked sum_to program
as a green round-trip fixture. NO typecheck semantics and NO real
codegen this iter by design: synth and lower_term are stubbed
CheckError::Internal / CodegenError::Internal exactly as mut.1
(real typecheck = iter 2, real loop-header/phi/back-edge = iter 3).
Two binding Boss design calls recorded in the plan header and the
journal: (1) verify_tail_positions "byte-unchanged" = its tail-app
verification role is unchanged, not its source — it is an
exhaustive no-wildcard match so two descent-only arms are
mandatory; acceptance evidence is the tail-app non-regression test.
(2) codegen is in iter-1 scope as stubs/pass-throughs because the
workspace must compile for cargo test --workspace.
Three plan-pseudo-vs-reality substitutions (serde Type::Con
literal, bare Int -> (con Int), unqualified LoopBinder) and one
recon-undercount integration-test site, all intent-preserving and
journalled. Boss forward-fix folded in: the committed specs
themselves wrote bare Int in the loop-binder snippets (parses as
Type::Var) — corrected the three headline snippets to (con Int) for
doc-honesty (no design/schema change).
cargo test --workspace 600 -> 608 / 0 red (Boss-reran
independently); iter13a + new loop_recur hash pins green.
This commit is contained in:
@@ -540,6 +540,33 @@ pub enum Term {
|
||||
name: String,
|
||||
value: Box<Term>,
|
||||
},
|
||||
/// loop-recur iter 1: a strict iteration block. `binders`
|
||||
/// declares one or more loop parameters (name, type, init),
|
||||
/// evaluated in order on loop entry; `body` is evaluated with
|
||||
/// all binders in scope. The loop's value is `body`'s value on
|
||||
/// the iteration that exits via a non-`recur` branch. Strictly
|
||||
/// additive: pre-existing fixtures hash bit-identically because
|
||||
/// none carry the `"t":"loop"` tag. `binders` has no
|
||||
/// `skip_serializing_if` (mirrors `Term::Mut.vars` — the field
|
||||
/// is part of the shape). Typecheck binder/recur semantics land
|
||||
/// in loop-recur iter 2 (`synth` stubs with `CheckError::Internal`
|
||||
/// in this iter); codegen (loop-header + per-binder phi +
|
||||
/// back-edge) in iter 3 (`lower_term` stubs with
|
||||
/// `CodegenError::Internal`). No totality claim — an infinite
|
||||
/// loop is legal. See `docs/specs/2026-05-17-loop-recur.md`.
|
||||
Loop {
|
||||
binders: Vec<LoopBinder>,
|
||||
body: Box<Term>,
|
||||
},
|
||||
/// loop-recur iter 1: re-enter the lexically innermost enclosing
|
||||
/// `Term::Loop`, rebinding its binders positionally to `args`.
|
||||
/// Transfers control (no fall-through); valid only in tail
|
||||
/// position of its enclosing loop — enforced at typecheck in
|
||||
/// iter 2 (`RecurNotInTailPosition`). Additive `"t":"recur"`
|
||||
/// tag; pre-existing fixtures hash bit-identically.
|
||||
Recur {
|
||||
args: Vec<Term>,
|
||||
},
|
||||
}
|
||||
|
||||
/// One arm of a [`Term::Match`].
|
||||
@@ -583,6 +610,27 @@ pub struct MutVar {
|
||||
pub init: Term,
|
||||
}
|
||||
|
||||
/// loop-recur iter 1: one binder of a [`Term::Loop`]. Mirrors
|
||||
/// [`MutVar`]'s `(name, type, init)` triple exactly so the Form-A
|
||||
/// surface vocabulary is shared (`(NAME TYPE INIT)`); it is a
|
||||
/// nested field of `Term::Loop`, not a first-class `Term` (loop
|
||||
/// binders cannot escape the enclosing loop). `recur` rebinds these
|
||||
/// positionally per iteration. The `ty` JSON field is `"type"`,
|
||||
/// matching `MutVar` and the spec schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoopBinder {
|
||||
/// The binder's lexical name. Within the enclosing `Term::Loop`,
|
||||
/// a `Term::Var { name }` resolves to this binding.
|
||||
pub name: String,
|
||||
/// The binder's declared type.
|
||||
#[serde(rename = "type")]
|
||||
pub ty: Type,
|
||||
/// Initial value, evaluated once on loop entry in scope of the
|
||||
/// outer environment plus already-declared binders of the same
|
||||
/// `Term::Loop` (declaration order).
|
||||
pub init: Term,
|
||||
}
|
||||
|
||||
/// A match pattern.
|
||||
///
|
||||
/// The JSON discriminator is the `p` field. Patterns are linear: each
|
||||
@@ -949,4 +997,59 @@ mod tests {
|
||||
other => panic!("variant mismatch: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// loop-recur iter 1: pin the canonical-bytes shape of a
|
||||
/// `Term::Loop` with one binder. Mirrors the mut-empty-vars pin:
|
||||
/// `binders` stays present (no `skip_serializing_if`).
|
||||
#[test]
|
||||
fn term_loop_one_binder_serialises_with_explicit_binders_field() {
|
||||
let t = Term::Loop {
|
||||
binders: vec![LoopBinder {
|
||||
name: "i".into(),
|
||||
ty: Type::int(),
|
||||
init: Term::Lit {
|
||||
lit: Literal::Int { value: 0 },
|
||||
},
|
||||
}],
|
||||
body: Box::new(Term::Var { name: "i".into() }),
|
||||
};
|
||||
let bytes = serde_json::to_string(&t).expect("serialise");
|
||||
assert_eq!(
|
||||
bytes,
|
||||
r#"{"t":"loop","binders":[{"name":"i","type":{"k":"con","name":"Int"},"init":{"t":"lit","lit":{"kind":"int","value":0}}}],"body":{"t":"var","name":"i"}}"#,
|
||||
);
|
||||
let back: Term = serde_json::from_str(&bytes).expect("deserialise");
|
||||
match back {
|
||||
Term::Loop { binders, body } => {
|
||||
assert_eq!(binders.len(), 1);
|
||||
assert_eq!(binders[0].name, "i");
|
||||
match *body {
|
||||
Term::Var { name } => assert_eq!(name, "i"),
|
||||
other => panic!("body mismatch: {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("variant mismatch: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// loop-recur iter 1: round-trip a `Term::Recur` through JSON.
|
||||
/// Pins `{ "t": "recur", "args": [...] }`.
|
||||
#[test]
|
||||
fn term_recur_round_trips_through_json() {
|
||||
let t = Term::Recur {
|
||||
args: vec![Term::Lit {
|
||||
lit: Literal::Int { value: 1 },
|
||||
}],
|
||||
};
|
||||
let bytes = serde_json::to_string(&t).expect("serialise");
|
||||
assert_eq!(
|
||||
bytes,
|
||||
r#"{"t":"recur","args":[{"t":"lit","lit":{"kind":"int","value":1}}]}"#,
|
||||
);
|
||||
let back: Term = serde_json::from_str(&bytes).expect("deserialise");
|
||||
match back {
|
||||
Term::Recur { args } => assert_eq!(args.len(), 1),
|
||||
other => panic!("variant mismatch: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,37 @@ impl Desugarer {
|
||||
name: name.clone(),
|
||||
value: Box::new(self.desugar_term(value, scope)),
|
||||
},
|
||||
Term::Loop { binders, body } => {
|
||||
// loop-recur iter 1: structural recursion mirroring
|
||||
// the Term::Mut arm. Each binder's init is desugared
|
||||
// in scope of the outer env plus already-declared
|
||||
// binders; the body sees all binders. The LetBound
|
||||
// sentinel keeps generated fresh names off binder
|
||||
// names.
|
||||
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,
|
||||
}
|
||||
})
|
||||
.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 +1277,22 @@ 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 } => {
|
||||
// loop-recur iter 1: binder names bind inside the loop —
|
||||
// each init sees the outer env plus already-declared
|
||||
// binders; the body sees all. Mirrors the Term::Mut arm.
|
||||
let mut b = bound.clone();
|
||||
for bd in binders {
|
||||
free_vars_in_term(&bd.init, &b, out);
|
||||
b.insert(bd.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 +1486,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 } => {
|
||||
// loop-recur iter 1: binders lexically shadow outer
|
||||
// names, symmetric to the Term::Mut arm — once a binder
|
||||
// named `from` is declared, later inits and the body
|
||||
// stop being substituted.
|
||||
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,
|
||||
}
|
||||
})
|
||||
.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 +1664,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: 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 +1753,13 @@ pub fn find_non_callee_use(t: &Term, name: &str) -> Option<Term> {
|
||||
find_non_callee_use(value, name)
|
||||
}
|
||||
}
|
||||
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 +1807,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 +1841,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 +2966,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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1259,6 +1259,19 @@ where
|
||||
walk_term_embedded_types(body, f)
|
||||
}
|
||||
Term::Assign { value, .. } => walk_term_embedded_types(value, f),
|
||||
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 +1405,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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user