iter mut.1: AST extension + Form A surface for local mutable state

First iteration of the mut-local milestone (foundation step on the
Stateful-islands roadmap path). Lands the schema + surface tier:
Term::Mut, Term::Assign, and the nested MutVar struct become
first-class AST nodes that round-trip cleanly through Form A.
Typecheck and codegen recognition are deferred to mut.2 and mut.3
per the spec's out-of-iteration boundary; reaching either dispatch
entry point with these variants produces CheckError::Internal /
CodegenError::Internal with a 'deferred to iter mut.{2,3}' message.

Concretely:

- crates/ailang-core/src/ast.rs: two new Term variants behind
  #[serde(tag = 't')]; pub struct MutVar { name, ty, init } adjacent
  to Arm. Two canonical-bytes pin tests for the explicit-empty-vars
  serialisation and the assign round-trip.

- ~25 substantive Term-walker arms across ailang-core/desugar,
  ailang-core/workspace, ailang-check (lib + lift + linearity + mono
  + pre_desugar_validation + reuse_shape + uniqueness),
  ailang-codegen (escape + lambda + lib), ailang-prose, and
  crates/ail/src/main.rs. Universal policy: substantive recurse-into-
  children at every site; only the two dispatch entry points
  (synth in ailang-check, lower_term in ailang-codegen) stub with
  Internal-error. One test-side walker arm in
  crates/ail/tests/codegen_import_map_fallback_pin.rs not
  enumerated by the plan was added as well (defensive recursion).

- ailang-surface: parse_mut + parse_assign helpers; Term::Mut
  body desugared from a flat statement sequence into a right-folded
  Term::Seq chain inside the JSON-AST. Print arms in print.rs match
  the parser convention. EBNF prologue + crates/ailang-core/specs/
  form_a.md productions updated. Four new parser pin tests cover
  the empty-mut, single-var, body-required, and vars-only-no-body
  cases.

- Drift + coverage tests extended: design_schema_drift.rs adds two
  exemplars + match arms; schema_coverage.rs adds two VariantTag
  entries + EXPECTED_VARIANTS + visit_term arms; spec_drift.rs adds
  two exemplars + match arms. DESIGN.md §'Term (expression)' gets
  jsonc-blocked schemas for the two new variants.

- examples/mut.ail: six-fn round-trip fixture exercising empty mut,
  single-var, two-var, nested-shadow, and the four supported scalar
  return types (Int, Float, Bool, Unit). The round_trip auto-glob
  and schema_coverage corpus walker both pick it up.

Plan deviation: the plan named lib.rs:2572 as the typecheck
dispatch stub site, but that line is actually verify_tail_positions
(substantive walker). The real dispatch is synth (3403-area, stub
at 3489); the orchestrator routed correctly.

Tests: 564 → 579 green; cargo build green; round-trip green for
the new fixture; all drift + coverage tests green.

Journal: docs/journals/2026-05-15-iter-mut.1.md.

Refs: docs/specs/2026-05-15-mut-local.md, docs/plans/2026-05-15-iter-mut.1.md.
This commit is contained in:
2026-05-15 01:10:56 +02:00
parent 60e4559e31
commit 7b92719244
27 changed files with 1404 additions and 2 deletions
+162 -2
View File
@@ -39,7 +39,8 @@
//! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit
//! | 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
//! | 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
@@ -66,6 +67,9 @@
//! "(" "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
@@ -1206,6 +1210,8 @@ 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(),
other => {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
Err(ParseError::Production {
@@ -1213,7 +1219,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`, `lit-unit`"
`tail-do`, `seq`, `term-ctor`, `clone`, `reuse-as`, `mut`, \
`assign`, `lit-unit`"
),
pos,
})
@@ -1565,6 +1572,76 @@ 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),
})
}
// ---- patterns -------------------------------------------------------
fn parse_pattern(&mut self) -> Result<Pattern, ParseError> {
@@ -2423,4 +2500,87 @@ mod tests {
other => panic!("expected Term::Lit::Float, got {other:?}"),
}
}
/// 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}"
);
}
}
+46
View File
@@ -548,6 +548,52 @@ 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(')');
}
}
}