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
+97
View File
@@ -906,6 +906,35 @@ fn write_term_prec(out: &mut String, t: &Term, level: usize, parent_prec: u8, ow
out.push_str(&body_buf);
}
}
Term::Mut { vars, body } => {
// Iter mut.1: prose-side minimal-correctness rendering for
// the `(mut ...)` block. The prose-form for mut blocks is
// not yet fully designed (the prose surface predates this
// milestone); render the block as `mut { var <name>: <ty>
// = <init>; ...; <body> }` so a reader can see the shape
// without overcommitting to a final prose syntax. Refined
// in a follow-on prose iter once the LLM-author signal
// arrives.
out.push_str("mut {\n");
for v in vars {
indent(out, level + 1);
out.push_str("var ");
out.push_str(&v.name);
out.push_str(" = ");
write_term(out, &v.init, level + 1, owning_module);
out.push_str(";\n");
}
indent(out, level + 1);
write_term(out, body, level + 1, owning_module);
out.push('\n');
indent(out, level);
out.push('}');
}
Term::Assign { name, value } => {
out.push_str(name);
out.push_str(" := ");
write_term(out, value, level, owning_module);
}
}
}
@@ -1077,6 +1106,33 @@ fn count_free_var(name: &str, t: &Term) -> usize {
}
Term::Clone { value } => count_free_var(name, value),
Term::ReuseAs { source, body } => count_free_var(name, source) + count_free_var(name, body),
// Iter mut.1: a `Term::Mut` whose `vars` includes a name
// matching `name` shadows the outer binding for both later
// var inits and the body. Var inits before the shadowing one
// still see the outer `name`.
Term::Mut { vars, body } => {
let mut total = 0usize;
let mut shadowed = false;
for v in vars {
if !shadowed {
total += count_free_var(name, &v.init);
}
if v.name == name {
shadowed = true;
}
}
if !shadowed {
total += count_free_var(name, body);
}
total
}
// Iter mut.1: the assigned `name` field is a use of the
// mut-var binding (which may or may not be `name`); the
// value is recursed.
Term::Assign { name: assign_name, value } => {
let n_use = if assign_name == name { 1 } else { 0 };
n_use + count_free_var(name, value)
}
}
}
@@ -1188,6 +1244,47 @@ fn subst_var_with_term(t: &Term, name: &str, replacement: &Term) -> Term {
source: Box::new(subst_var_with_term(source, name, replacement)),
body: Box::new(subst_var_with_term(body, name, replacement)),
},
// Iter mut.1: lexical-shadow semantics for mut-vars symmetric
// to `count_free_var` above: once a var named `name` is
// declared, neither later inits nor the body should be
// rewritten.
Term::Mut { vars, body } => {
let mut shadowed = false;
let new_vars: Vec<ailang_core::ast::MutVar> = vars
.iter()
.map(|v| {
let init = if shadowed {
v.init.clone()
} else {
subst_var_with_term(&v.init, name, replacement)
};
if v.name == name {
shadowed = true;
}
ailang_core::ast::MutVar {
name: v.name.clone(),
ty: v.ty.clone(),
init,
}
})
.collect();
let body_rw = if shadowed {
(**body).clone()
} else {
subst_var_with_term(body, name, replacement)
};
Term::Mut {
vars: new_vars,
body: Box::new(body_rw),
}
}
// Iter mut.1: substitute only inside `value`. The `name`
// field is a binding reference (cf. the desugar.rs
// `subst_var` arm).
Term::Assign { name: assign_name, value } => Term::Assign {
name: assign_name.clone(),
value: Box::new(subst_var_with_term(value, name, replacement)),
},
}
}