iter remove-mut-var-assign.1: atomic removal of mut/var/assign

mut/var/assign removed from AILang entirely and atomically. Deleted:
Term::Mut/Term::Assign/struct MutVar; the three Form-A keywords +
parse_mut/parse_assign + grammar EBNF; the 4 mut CheckError variants;
the mut_scope_stack synth threading (param dropped from synth + every
internal/external/test caller); the two lower_term arms; and every
exhaustive no-_ Term::Mut/Term::Assign match arm across 17 source
files — cut in lockstep with DESIGN.md, fixtures, the drift trio,
carve-out and roadmap so the schema is honest at every commit. No
catch-all wildcard introduced (verified). loop/recur + let/if are
the surviving forms.

The shared codegen alloca machinery survives (loop reuses it):
mut_var_allocas renamed binder_allocas (representation-only, loop
codegen byte-identical) and the shared Term::Lam escape guard
simplified to !loop_stack.is_empty() with the loop half
(LoopBinderCapturedByLambda) byte-equivalent. Feature-acceptance
applied inverted: the removed feature fails clause 2 (redundant)
and clause 3 (IS the iterated-mutable-state bug class).

Behaviour preservation is executable: mut_counter/mut_sum_floats
still print 55 after the faithful let/if rewrite. The removal is
made executable by the new mut_removed_pin.rs (4 must-fail pins).
Independent verification: cargo test --workspace 605/0, zero
residual mut symbols in any crate source, loop/recur non-regression
all green (55 / 500000500000 / infinite-compiles / the
lambda_capturing_loop_binder pin), roundtrip_cli PASS.

One DONE_WITH_CONCERNS: a 4th recurrence of the recon-undercount
class (in-source mod tests + a drift-pin fn + 5 orphaned mut
.ail.json carve-outs + a non-enumerated E0599); all resolved within
implementer remit, no behaviour change. Milestone-close audit then
fieldtest remain.

spec docs/specs/2026-05-18-remove-mut-var-assign.md (grounding PASS)
plan docs/plans/remove-mut-var-assign.1.md
This commit is contained in:
2026-05-18 11:06:17 +02:00
parent f355899fdf
commit 07f080256c
48 changed files with 331 additions and 2523 deletions
+5 -167
View File
@@ -40,7 +40,6 @@
//! | app-term | tail-app-term | match-term | ctor-term
//! | do-term | tail-do-term | seq-term | lam-term | if-term
//! | let-term | let-rec-term | clone-term | reuse-as-term
//! | mut-term | assign-term
//! var-ref ::= ident ; reserved: true/false → bool-lit
//! int-lit ::= integer ; numeric atom
//! str-lit ::= string ; string atom
@@ -67,9 +66,6 @@
//! "(" "in" term ")" ")"
//! clone-term ::= "(" "clone" term ")" ; Iter 18c.1
//! reuse-as-term ::= "(" "reuse-as" term term ")" ; Iter 18d.1
//! mut-term ::= "(" "mut" var-decl* term+ ")" ; Iter mut.1
//! var-decl ::= "(" "var" ident type term ")" ; legal only inside mut-term
//! assign-term ::= "(" "assign" ident term ")" ; Iter mut.1; legal only inside mut-term
//!
//! pattern ::= pat-var | pat-ctor | pat-lit | pat-wild
//! pat-var ::= ident
@@ -1210,8 +1206,6 @@ impl<'a> Parser<'a> {
"let-rec" => self.parse_let_rec(),
"clone" => self.parse_clone(),
"reuse-as" => self.parse_reuse_as(),
"mut" => self.parse_mut(),
"assign" => self.parse_assign(),
"loop" => self.parse_loop(),
"recur" => self.parse_recur(),
other => {
@@ -1221,8 +1215,8 @@ impl<'a> Parser<'a> {
message: format!(
"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`, `loop`, `recur`, `lit-unit`"
`tail-do`, `seq`, `term-ctor`, `clone`, `reuse-as`, \
`loop`, `recur`, `lit-unit`"
),
pos,
})
@@ -1574,82 +1568,11 @@ impl<'a> Parser<'a> {
})
}
/// Iter mut.1: `(mut (var NAME TYPE INIT)* BODY_TERM+)` — local
/// mutable-state block. Reads zero or more `(var ...)` entries
/// off the front of the body, then ≥ 1 trailing terms. The
/// trailing sequence is right-folded into `Term::Seq` with the
/// final term as the seed (so the JSON-AST always sees a single
/// `body: Term`). Spec `docs/specs/2026-05-15-mut-local.md`
/// §"Form A surface" + §"Canonical-form invariants".
fn parse_mut(&mut self) -> Result<Term, ParseError> {
let head_pos = self.peek().map(|t| t.span.start).unwrap_or(0);
self.expect_lparen("mut-term")?;
self.expect_keyword("mut")?;
// Pull off all leading (var NAME TYPE INIT) entries.
let mut vars: Vec<ailang_core::ast::MutVar> = Vec::new();
while matches!(self.peek_head_ident(), Some("var")) {
self.expect_lparen("mut-var")?;
self.expect_keyword("var")?;
let name = self.expect_ident("mut-var-name")?;
let ty = self.parse_type()?;
let init = self.parse_term()?;
self.expect_rparen("mut-var")?;
vars.push(ailang_core::ast::MutVar { name, ty, init });
}
// Then read ≥ 1 trailing body terms. Right-fold into Seq
// with the final term as the seed.
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: "mut-term",
message: "(mut ...) requires at least one body expression after vars".into(),
pos: head_pos,
});
}
self.expect_rparen("mut-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::Mut {
vars,
body: Box::new(body),
})
}
/// Iter mut.1: `(assign NAME VALUE)` — in-block update of a
/// mut-var. Positional shape: name then value. The lexical-scope
/// rule ("legal only inside a `(mut ...)` whose `vars` includes
/// a var with the same `name`") is enforced at typecheck
/// (mut.2, `CheckError::MutAssignOutOfScope`); the parser
/// accepts the shape unconditionally.
fn parse_assign(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("assign-term")?;
self.expect_keyword("assign")?;
let name = self.expect_ident("assign-name")?;
let value = self.parse_term()?;
self.expect_rparen("assign-term")?;
Ok(Term::Assign {
name,
value: Box::new(value),
})
}
/// 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
/// binder triples (bare, no leading keyword), then ≥ 1
/// trailing terms right-folded into `Term::Seq`. 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
@@ -1673,8 +1596,6 @@ impl<'a> Parser<'a> {
| "let-rec"
| "clone"
| "reuse-as"
| "mut"
| "assign"
| "loop"
| "recur"
)
@@ -2594,89 +2515,6 @@ mod tests {
}
}
/// Iter mut.1: `(mut 0)` parses as a `Term::Mut` with empty `vars`
/// and an `Int 0` body. Spec
/// `docs/specs/2026-05-15-mut-local.md` §"Canonical-form
/// invariants" — the empty-vars form is a legal degenerate case
/// the schema accepts uniformly.
#[test]
fn parses_empty_mut_with_int_body() {
let t = parse_term("(mut 0)").expect("parse");
match t {
Term::Mut { vars, body } => {
assert!(vars.is_empty(), "vars should be empty, got {vars:?}");
match *body {
Term::Lit { lit: Literal::Int { value: 0 } } => {}
other => panic!("expected Lit Int 0, got {other:?}"),
}
}
other => panic!("expected Term::Mut, got {other:?}"),
}
}
/// Iter mut.1: `(mut (var x ...) (assign x ...) x)` parses with
/// the trailing statement-and-final-expression sequence right-
/// folded into a `Term::Seq`. The schema's `Term::Mut.body` is
/// always a single Term; multi-statement bodies are folded
/// through `Seq` at parse time.
#[test]
fn parses_mut_with_one_var_and_one_assign_and_final() {
let src = "(mut (var x (con Int) 0) (assign x (app + x 1)) x)";
let t = parse_term(src).expect("parse");
match t {
Term::Mut { vars, body } => {
assert_eq!(vars.len(), 1, "expected exactly one var");
assert_eq!(vars[0].name, "x");
match *body {
Term::Seq { lhs, rhs } => {
match *lhs {
Term::Assign { name, .. } => assert_eq!(name, "x"),
other => panic!("expected Assign as Seq.lhs, got {other:?}"),
}
match *rhs {
Term::Var { name } => assert_eq!(name, "x"),
other => panic!("expected Var x as Seq.rhs, got {other:?}"),
}
}
other => panic!("expected Seq as Mut.body, got {other:?}"),
}
}
other => panic!("expected Term::Mut, got {other:?}"),
}
}
/// Iter mut.1: `(mut)` with neither vars nor body is rejected at
/// parse — the spec's §"Canonical-form invariants" mandates at
/// least one body expression after the `var` entries.
#[test]
fn rejects_mut_with_no_body() {
let err = parse_term("(mut)").expect_err("must reject empty mut");
let msg = format!("{err}");
assert!(
msg.contains("mut"),
"diagnostic should mention `mut`, got: {msg}"
);
assert!(
msg.contains("body") || msg.contains("expression"),
"diagnostic should mention the missing body, got: {msg}"
);
}
/// Iter mut.1: `(mut (var x ...))` declares a var but has no
/// trailing body expression — also rejected (the rule is "≥ 0
/// vars followed by ≥ 1 body terms"; zero body terms is the
/// rejection trigger).
#[test]
fn rejects_mut_with_only_vars_no_final_expression() {
let err = parse_term("(mut (var x (con Int) 0))")
.expect_err("must reject vars-only mut");
let msg = format!("{err}");
assert!(
msg.contains("body") || msg.contains("expression"),
"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))))";
+1 -48
View File
@@ -548,57 +548,10 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
write_term(out, body, level);
out.push(')');
}
Term::Mut { vars, body } => {
// Iter mut.1: print as
// `(mut (var NAME TYPE INIT)* STMT* FINAL_EXPR)`
// where the body's right-spine of `Term::Seq` is walked
// and each lhs is printed as a top-level statement; the
// terminal expression (the rightmost non-Seq term) is the
// block's value. This is the inverse of the parser's
// right-fold over the trailing sequence.
out.push_str("(mut");
for v in vars {
out.push_str(" (var ");
out.push_str(&v.name);
out.push(' ');
write_type(out, &v.ty);
out.push(' ');
write_term(out, &v.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::Assign { name, value } => {
// Iter mut.1: print as `(assign NAME VALUE)`. Legal only
// inside a `Term::Mut.body`; the parser enforces the
// structural rule, the typechecker (mut.2) enforces the
// scope rule.
out.push_str("(assign ");
out.push_str(name);
out.push(' ');
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.
// — inverse of parse_loop's binder read + Seq right-fold.
out.push_str("(loop");
for b in binders {
out.push_str(" (");