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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user