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:
2026-05-17 23:00:15 +02:00
parent a5eebcd5fd
commit a179ec30a0
30 changed files with 1113 additions and 4 deletions
+118 -1
View File
@@ -1212,6 +1212,8 @@ impl<'a> Parser<'a> {
"reuse-as" => self.parse_reuse_as(),
"mut" => self.parse_mut(),
"assign" => self.parse_assign(),
"loop" => self.parse_loop(),
"recur" => self.parse_recur(),
other => {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
Err(ParseError::Production {
@@ -1220,7 +1222,7 @@ impl<'a> Parser<'a> {
"unknown term head `{other}`; expected one of \
`app`, `tail-app`, `lam`, `let`, `let-rec`, `if`, `match`, `do`, \
`tail-do`, `seq`, `term-ctor`, `clone`, `reuse-as`, `mut`, \
`assign`, `lit-unit`"
`assign`, `loop`, `recur`, `lit-unit`"
),
pos,
})
@@ -1642,6 +1644,97 @@ impl<'a> Parser<'a> {
})
}
/// loop-recur iter 1: `(loop (NAME TYPE INIT)* BODY_TERM+)` —
/// strict iteration block. Reads zero or more `(NAME TYPE INIT)`
/// binder triples (bare, no leading keyword — unlike `(var ...)`,
/// the loop binder triple has no `var` keyword), then ≥ 1
/// trailing terms right-folded into `Term::Seq` (mirrors
/// `parse_mut`). The binder/body boundary is disambiguated by the
/// inner head: a binder's first inner token is an ident NAME
/// (never a term-head keyword), whereas a parenthesised body form
/// (`(if …)`, `(recur …)`, `(app …)`, …) opens with a term-head
/// keyword — so a leading `(` is treated as a binder only while
/// its inner head is not a term-head keyword.
fn parse_loop(&mut self) -> Result<Term, ParseError> {
fn is_term_head_kw(h: &str) -> bool {
matches!(
h,
"lit-unit"
| "app"
| "tail-app"
| "term-ctor"
| "match"
| "do"
| "tail-do"
| "seq"
| "lam"
| "if"
| "let"
| "let-rec"
| "clone"
| "reuse-as"
| "mut"
| "assign"
| "loop"
| "recur"
)
}
let head_pos = self.peek().map(|t| t.span.start).unwrap_or(0);
self.expect_lparen("loop-term")?;
self.expect_keyword("loop")?;
let mut binders: Vec<ailang_core::ast::LoopBinder> = Vec::new();
while matches!(self.peek_head_ident(), Some(h) if !is_term_head_kw(h)) {
self.expect_lparen("loop-binder")?;
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 });
}
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 binders".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),
})
}
/// loop-recur iter 1: `(recur ARG*)` — re-enter the enclosing
/// loop. Positional args. The tail-position rule is enforced at
/// typecheck (iter 2, `recur-not-in-tail-position`); the parser
/// accepts the shape unconditionally.
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 -------------------------------------------------------
fn parse_pattern(&mut self) -> Result<Pattern, ParseError> {
@@ -2583,4 +2676,28 @@ mod tests {
"diagnostic should mention missing body, got: {msg}"
);
}
#[test]
fn parse_loop_and_recur_round_trip_via_term() {
let src = "(loop (acc (con Int) 0) (i (con Int) 1) (if (app > i n) acc (recur (app + acc i) (app + i 1))))";
let t = parse_term(src).expect("parse loop");
match t {
ailang_core::ast::Term::Loop { binders, body } => {
assert_eq!(binders.len(), 2);
assert_eq!(binders[0].name, "acc");
assert_eq!(binders[1].name, "i");
// body is an `if` whose else-branch is a `recur` of 2 args
match *body {
ailang_core::ast::Term::If { else_, .. } => match *else_ {
ailang_core::ast::Term::Recur { args } => {
assert_eq!(args.len(), 2)
}
other => panic!("else not recur: {other:?}"),
},
other => panic!("body not if: {other:?}"),
}
}
other => panic!("not a loop: {other:?}"),
}
}
}
+40
View File
@@ -594,6 +594,46 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
write_term(out, value, level);
out.push(')');
}
Term::Loop { binders, body } => {
// loop-recur iter 1: print as
// `(loop (NAME TYPE INIT)* STMT* FINAL_EXPR)`
// — inverse of parse_loop's binder read + Seq right-fold,
// mirroring the Term::Mut printer.
out.push_str("(loop");
for b in binders {
out.push_str(" (");
out.push_str(&b.name);
out.push(' ');
write_type(out, &b.ty);
out.push(' ');
write_term(out, &b.init, level);
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 } => {
out.push_str("(recur");
for a in args {
out.push(' ');
write_term(out, a, level);
}
out.push(')');
}
}
}