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
+53
View File
@@ -190,6 +190,17 @@ fn walk(t: &Term, out: &mut NonEscapeSet) {
walk(source, out);
walk(body, out);
}
// Iter mut.1: mut-vars are alloca-resident scalars (Int /
// Float / Bool / Unit) and never participate in heap escape.
// Their `init` and `body` and assign `value` terms may still
// contain Let-allocation candidates, so walk through.
Term::Mut { vars, body } => {
for v in vars {
walk(&v.init, out);
}
walk(body, out);
}
Term::Assign { value, .. } => walk(value, out),
Term::Lit { .. } | Term::Var { .. } => {}
}
}
@@ -367,6 +378,23 @@ fn escapes(t: &Term, tainted: &BTreeSet<String>, in_tail: bool) -> bool {
}
escapes(body, tainted, in_tail)
}
// Iter mut.1: mut-vars are alloca-resident scalars and do
// not themselves contribute to heap escape. Var inits are
// evaluated for side-effect (their values are stored into
// allocas, not used as the block's value), so they are
// walked in non-tail mode. The body IS the block's value,
// so it inherits `in_tail`. An assign's value is stored
// into an alloca, not used as a tail value — walk
// non-tail.
Term::Mut { vars, body } => {
for v in vars {
if escapes(&v.init, tainted, false) {
return true;
}
}
escapes(body, tainted, in_tail)
}
Term::Assign { value, .. } => escapes(value, tainted, false),
}
}
@@ -471,6 +499,31 @@ fn collect_free_vars(t: &Term, bound: &mut BTreeSet<String>, out: &mut BTreeSet<
collect_free_vars(source, bound, out);
collect_free_vars(body, bound, out);
}
// Iter mut.1: a mut-var name binds inside the block. Var
// inits before the binding see the outer scope; later inits
// and the body see the var as bound. Mirrors `Term::Let`'s
// add-then-walk-then-remove pattern, extended over N vars.
Term::Mut { vars, body } => {
let mut newly: Vec<String> = Vec::new();
for v in vars {
collect_free_vars(&v.init, bound, out);
if bound.insert(v.name.clone()) {
newly.push(v.name.clone());
}
}
collect_free_vars(body, bound, out);
for n in newly {
bound.remove(&n);
}
}
// Iter mut.1: the assigned `name` is a free var unless
// bound by an enclosing `Term::Mut`.
Term::Assign { name, value } => {
if !bound.contains(name) {
out.insert(name.clone());
}
collect_free_vars(value, bound, out);
}
}
}
+37
View File
@@ -430,6 +430,43 @@ impl<'a> Emitter<'a> {
Self::collect_captures(source, bound, captures, captures_set, builtins, top_level);
Self::collect_captures(body, bound, captures, captures_set, builtins, top_level);
}
// Iter mut.1: a mut-var name binds inside the block. Var
// inits before the binding see the outer scope; later
// inits and the body see the var as bound. Mut-vars
// cannot be captured by an inner lambda in mut.1 (the
// typecheck pass at mut.2 will enforce this); for the
// capture-collection scan the var bindings are tracked
// identically to let-bound names.
Term::Mut { vars, body } => {
let mut newly_bound: Vec<String> = Vec::new();
for v in vars {
Self::collect_captures(&v.init, bound, captures, captures_set, builtins, top_level);
if bound.insert(v.name.clone()) {
newly_bound.push(v.name.clone());
}
}
Self::collect_captures(body, bound, captures, captures_set, builtins, top_level);
for n in newly_bound {
bound.remove(&n);
}
}
Term::Assign { name, value, .. } => {
// The assigned `name` is a use of a mut-var binding.
// If it is bound in scope (it should be, by spec), it
// is not a capture. Otherwise — well-formed inputs do
// not produce this case (the typecheck pass at mut.2
// rejects it via `MutAssignOutOfScope`); we
// conservatively treat it as a possible capture so an
// ill-formed input still flows through codegen.
if !bound.contains(name)
&& !builtins.contains(name.as_str())
&& !top_level.contains(name)
&& captures_set.insert(name.clone())
{
captures.push(name.clone());
}
Self::collect_captures(value, bound, captures, captures_set, builtins, top_level);
}
}
}
+28
View File
@@ -1669,6 +1669,26 @@ impl<'a> Emitter<'a> {
};
self.lower_reuse_as_rc(source, body_type_name, body_ctor, body_args)
}
// Iter mut.1: codegen recognition of `Term::Mut` and
// `Term::Assign` is deferred to iter mut.3 (see
// `docs/specs/2026-05-15-mut-local.md` §"Iteration mut.1"
// out-of-iteration boundary). The dispatch entry here
// stubs with `CodegenError::Internal` so a well-formed
// mut block reaching codegen in mut.1 fails fast with a
// human-readable message. The typecheck pass also stubs
// at `synth` (iter mut.1, same spec subsection), so the
// codegen stub is a defense-in-depth — under normal
// flow, typecheck has already rejected.
Term::Mut { .. } => {
Err(CodegenError::Internal(
"Term::Mut not yet supported in codegen (deferred to iter mut.3)".into(),
))
}
Term::Assign { .. } => {
Err(CodegenError::Internal(
"Term::Assign not yet supported in codegen (deferred to iter mut.3)".into(),
))
}
}
}
@@ -2911,6 +2931,14 @@ impl<'a> Emitter<'a> {
// type. The source is dropped at codegen.
self.synth_with_extras(body, extras)
}
// Iter mut.1: `Term::Mut`'s static type is the body's
// static type (spec §"JSON-AST schema"). `Term::Assign`'s
// is Unit (spec §"Form A surface"). These won't be hit
// along the shipping codegen path because the dispatcher
// at `lower_term` stubs both — but `synth_with_extras`
// is exhaustive on Term so the arms must exist.
Term::Mut { body, .. } => self.synth_with_extras(body, extras),
Term::Assign { .. } => Ok(Type::unit()),
}
}
}