From 7b92719244e5a0a4b3f7e342a14a7448cbda6be4 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 15 May 2026 01:10:56 +0200 Subject: [PATCH] iter mut.1: AST extension + Form A surface for local mutable state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../2026-05-15-iter-mut.1.json | 27 +++ crates/ail/src/main.rs | 69 ++++++ .../tests/codegen_import_map_fallback_pin.rs | 10 + crates/ailang-check/src/lib.rs | 55 +++++ crates/ailang-check/src/lift.rs | 28 +++ crates/ailang-check/src/linearity.rs | 33 +++ crates/ailang-check/src/mono.rs | 26 +++ .../src/pre_desugar_validation.rs | 13 ++ crates/ailang-check/src/reuse_shape.rs | 12 + crates/ailang-check/src/uniqueness.rs | 16 ++ crates/ailang-codegen/src/escape.rs | 53 +++++ crates/ailang-codegen/src/lambda.rs | 37 +++ crates/ailang-codegen/src/lib.rs | 28 +++ crates/ailang-core/specs/form_a.md | 24 ++ crates/ailang-core/src/ast.rs | 113 +++++++++ crates/ailang-core/src/desugar.rs | 176 ++++++++++++++ crates/ailang-core/src/workspace.rs | 23 ++ .../ailang-core/tests/design_schema_drift.rs | 16 ++ crates/ailang-core/tests/schema_coverage.rs | 16 ++ crates/ailang-core/tests/spec_drift.rs | 16 ++ crates/ailang-prose/src/lib.rs | 97 ++++++++ crates/ailang-surface/src/parse.rs | 164 ++++++++++++- crates/ailang-surface/src/print.rs | 46 ++++ docs/DESIGN.md | 30 +++ docs/journals/2026-05-15-iter-mut.1.md | 215 ++++++++++++++++++ docs/journals/INDEX.md | 1 + examples/mut.ail | 62 +++++ 27 files changed, 1404 insertions(+), 2 deletions(-) create mode 100644 bench/orchestrator-stats/2026-05-15-iter-mut.1.json create mode 100644 docs/journals/2026-05-15-iter-mut.1.md create mode 100644 examples/mut.ail diff --git a/bench/orchestrator-stats/2026-05-15-iter-mut.1.json b/bench/orchestrator-stats/2026-05-15-iter-mut.1.json new file mode 100644 index 0000000..4733f15 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-15-iter-mut.1.json @@ -0,0 +1,27 @@ +{ + "iter_id": "mut.1", + "date": "2026-05-15", + "mode": "standard", + "outcome": "DONE", + "tasks_total": 6, + "tasks_completed": 6, + "reloops_per_task": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0 + }, + "review_loops_spec": 0, + "review_loops_quality": 0, + "blocked_reason": null, + "notes_per_task": { + "1": "MutVar derives diverged from plan literal (`PartialEq + Eq` omitted; followed Arm's pattern per plan's own stated justification); recorded as concern; no functional impact", + "2": "plan-recon misindexed the typecheck dispatch entry — listed lib.rs:2572 (actually `verify_tail_positions`, substantive territory) instead of the real entry `synth` at lib.rs:3403; stub placed at the actual entry; line 2572 received a substantive arm; one test-side walker site (`crates/ail/tests/codegen_import_map_fallback_pin.rs:62`) unenumerated by plan, fixed inline (build-green required); print arms in `print.rs` landed during Task 2 build-unblock rather than waiting for Task 4 (benign sequencing shift)", + "3": "dispatch stubs use `CheckError::Internal(String)` and `CodegenError::Internal(String)` (single-field tuple variant); verified against existing variant declarations", + "4": "plan pseudo-code used helper names that don't exist (`peek_is_open_paren_with_head`, `expect_head`, `ParseError::at`); substituted actual API (`expect_lparen`, `expect_keyword`, `expect_ident`, `parse_type`, `parse_term`, `expect_rparen`, `peek_head_ident`); 4 RED-first parser tests added, all green post-parser", + "5": "all three drift tests green after extensions; `schema_coverage` deliberately RED before Task 6 per plan Step 6", + "6": "fixture exercises all four supported scalar types (Int, Float, Bool, Unit) plus empty / single-var / two-var / nested-shadow cases; full workspace 579/579 green" + } +} diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 9414712..8b9788b 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -1513,6 +1513,37 @@ fn walk_term( walk_term(source, out, builtins, scope); walk_term(body, out, builtins, scope); } + // Iter mut.1: each `var` introduces a new lexical binding; + // its `init` is in the outer scope plus already-declared + // vars; the body sees all of them. Mirrors `Term::Lam`'s + // newly-bound roll-back convention. + Term::Mut { vars, body } => { + let mut newly = Vec::new(); + for v in vars { + walk_term(&v.init, out, builtins, scope); + if scope.insert(v.name.clone()) { + newly.push(v.name.clone()); + } + } + walk_term(body, out, builtins, scope); + for n in newly { + scope.remove(&n); + } + } + // Iter mut.1: the `value` is walked; the assigned `name` is + // a use of an in-scope mut-var. If the user wrote an + // out-of-scope assign, it surfaces as an unknown identifier + // in the dep walk, which is acceptable for the CLI's + // dependency view (typecheck would already have rejected). + Term::Assign { name, value } => { + if !name.contains('.') + && !scope.contains(name) + && !builtins.contains(name.as_str()) + { + out.insert(name.clone()); + } + walk_term(value, out, builtins, scope); + } } } @@ -2716,6 +2747,44 @@ fn rewrite_def( changed, ); } + // Iter mut.1: rewrite types embedded in each `MutVar.ty` + // (via `rewrite_type` — mut-vars carry full Type + // annotations) and recurse into each var's `init` and + // the block's body. `Term::Assign` has no embedded type. + Term::Mut { vars, body } => { + for v in vars { + rewrite_type( + &mut v.ty, + owning_module, + local_types, + import_names, + changed, + ); + rewrite_term( + &mut v.init, + owning_module, + local_types, + import_names, + changed, + ); + } + rewrite_term( + body, + owning_module, + local_types, + import_names, + changed, + ); + } + Term::Assign { value, .. } => { + rewrite_term( + value, + owning_module, + local_types, + import_names, + changed, + ); + } } } diff --git a/crates/ail/tests/codegen_import_map_fallback_pin.rs b/crates/ail/tests/codegen_import_map_fallback_pin.rs index c056728..44b4296 100644 --- a/crates/ail/tests/codegen_import_map_fallback_pin.rs +++ b/crates/ail/tests/codegen_import_map_fallback_pin.rs @@ -91,6 +91,16 @@ fn synthesised_print_uses_user_module_show_via_fallback() { Term::ReuseAs { source, body } => { contains_xmod_show_var(source) || contains_xmod_show_var(body) } + // Iter mut.1: a `Term::Mut` cannot itself host a + // `show_user_adt.show__` reference (its body is a + // mut-block, not a synthesised polymorphic call), but + // recurse defensively so any nested reference inside a + // var init / body / assign-value still surfaces. + Term::Mut { vars, body } => { + vars.iter().any(|v| contains_xmod_show_var(&v.init)) + || contains_xmod_show_var(body) + } + Term::Assign { value, .. } => contains_xmod_show_var(value), Term::Lit { .. } => false, } } diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 6fb4beb..9f1586c 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -243,6 +243,24 @@ pub(crate) fn substitute_rigids_in_term(t: &Term, mapping: &BTreeMap Term::Mut { + vars: vars + .iter() + .map(|v| ailang_core::ast::MutVar { + name: v.name.clone(), + ty: substitute_rigids(&v.ty, mapping), + init: substitute_rigids_in_term(&v.init, mapping), + }) + .collect(), + body: Box::new(substitute_rigids_in_term(body, mapping)), + }, + Term::Assign { name, value } => Term::Assign { + name: name.clone(), + value: Box::new(substitute_rigids_in_term(value, mapping)), + }, } } @@ -2577,6 +2595,22 @@ pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> { verify_tail_positions(source, false)?; verify_tail_positions(body, is_tail) } + // Iter mut.1: var inits are evaluated for side-effect before + // the body and are NOT in tail position. The body is the + // value of the whole block and inherits the enclosing tail + // position (a `(tail-app ...)` as the final expression of a + // `(mut ...)` block is itself in tail position because the + // block's value is the call's value). + Term::Mut { vars, body } => { + for v in vars { + verify_tail_positions(&v.init, false)?; + } + verify_tail_positions(body, is_tail) + } + // Iter mut.1: `Term::Assign` has static type Unit and cannot + // be in tail position. Its `value` is also not in tail + // position. + Term::Assign { value, .. } => verify_tail_positions(value, false), } } @@ -3428,6 +3462,8 @@ pub(crate) fn synth( Term::Seq { .. } => "seq", Term::Clone { .. } => "clone", Term::ReuseAs { .. } => "reuse-as", + Term::Mut { .. } => "mut", + Term::Assign { .. } => "assign", Term::Ctor { .. } | Term::Lam { .. } => unreachable!(), }; return Err(CheckError::ReuseAsNonAllocatingBody { @@ -3440,6 +3476,25 @@ pub(crate) fn synth( // expression. synth(body, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings) } + // Iter mut.1: typecheck recognition of `Term::Mut` and + // `Term::Assign` is deferred to iter mut.2 (see + // `docs/specs/2026-05-15-mut-local.md` §"Iteration mut.1" + // out-of-iteration boundary). The dispatch entry here stubs + // with `CheckError::Internal` so that a well-formed mut block + // reaching this point in mut.1 fails fast with a + // human-readable message — the Form-A surface parses these + // shapes and the AST round-trips them, but no fn body is + // expected to *use* them yet. + Term::Mut { .. } => { + Err(CheckError::Internal( + "Term::Mut not yet supported in typecheck (deferred to iter mut.2)".into(), + )) + } + Term::Assign { .. } => { + Err(CheckError::Internal( + "Term::Assign not yet supported in typecheck (deferred to iter mut.2)".into(), + )) + } } } diff --git a/crates/ailang-check/src/lift.rs b/crates/ailang-check/src/lift.rs index e35505a..a211809 100644 --- a/crates/ailang-check/src/lift.rs +++ b/crates/ailang-check/src/lift.rs @@ -375,6 +375,28 @@ impl<'a> Lifter<'a> { source: Box::new(self.lift_in_term(source, locals, in_def)?), body: Box::new(self.lift_in_term(body, locals, in_def)?), }), + // Iter mut.1: lift letrecs out of each var's init and the + // body. Mut-vars cannot themselves participate in a + // letrec capture set (letrec bodies are fn-typed and + // mut-vars are scalar values), so the var bindings do + // not extend `locals` for the lift walk. + Term::Mut { vars, body } => Ok(Term::Mut { + vars: vars + .iter() + .map(|v| { + Ok(ailang_core::ast::MutVar { + name: v.name.clone(), + ty: v.ty.clone(), + init: self.lift_in_term(&v.init, locals, in_def)?, + }) + }) + .collect::>>()?, + body: Box::new(self.lift_in_term(body, locals, in_def)?), + }), + Term::Assign { name, value } => Ok(Term::Assign { + name: name.clone(), + value: Box::new(self.lift_in_term(value, locals, in_def)?), + }), Term::LetRec { name, ty, params, body, in_term } => { // Iter 16b.3: post-order traversal — lift any inner // LetRecs first. Within the body's scope, `name` and @@ -729,6 +751,12 @@ fn contains_any_letrec(m: &Module) -> bool { Term::Clone { value } => term_has_letrec(value), // Iter 18d.1: structural recursion through both children. Term::ReuseAs { source, body } => term_has_letrec(source) || term_has_letrec(body), + // Iter mut.1: any var init or the body can contain a + // letrec; the assign's value can too. + Term::Mut { vars, body } => { + vars.iter().any(|v| term_has_letrec(&v.init)) || term_has_letrec(body) + } + Term::Assign { value, .. } => term_has_letrec(value), } } for def in &m.defs { diff --git a/crates/ailang-check/src/linearity.rs b/crates/ailang-check/src/linearity.rs index 472ee96..83f9b66 100644 --- a/crates/ailang-check/src/linearity.rs +++ b/crates/ailang-check/src/linearity.rs @@ -592,6 +592,23 @@ impl<'a> Checker<'a> { // standard position rules. self.walk(body, pos); } + // Iter mut.1: mut-vars are alloca-resident scalars (Int / + // Float / Bool / Unit per spec §"Iteration mut.1") — they + // are not RC-managed and do not participate in the + // linearity analysis. Walk children defensively so any + // RC-managed value referenced inside an init or the body + // is still tracked. + Term::Mut { vars, body } => { + for v in vars { + self.walk(&v.init, Position::Consume); + } + self.walk(body, pos); + } + Term::Assign { value, .. } => { + // The assigned `name` is a mut-var (alloca slot, not + // a tracked binder). Walk the `value` normally. + self.walk(value, Position::Consume); + } } } @@ -776,6 +793,8 @@ fn make_reuse_as_source_not_bare_var(def: &str, source: &Term, body: &Term) -> D Term::Seq { .. } => "seq", Term::Clone { .. } => "clone", Term::ReuseAs { .. } => "reuse-as", + Term::Mut { .. } => "mut", + Term::Assign { .. } => "assign", }; let replacement = term_to_form_a(body); Diagnostic::error( @@ -901,6 +920,20 @@ fn any_sub_binder_consumed_for( any_sub_binder_consumed_for(source, pname, uniq, def_name, ctors) || any_sub_binder_consumed_for(body, pname, uniq, def_name, ctors) } + // Iter mut.1: mut-vars are scalar (Int/Float/Bool/Unit) and + // do not participate in the consume-tracking heuristic; the + // surrounding analysis only cares about heap-typed param + // consumption. Recurse into children defensively so any + // genuine consume-of-`pname` inside a var init / body / + // assign value still surfaces. + Term::Mut { vars, body } => { + vars.iter().any(|v| { + any_sub_binder_consumed_for(&v.init, pname, uniq, def_name, ctors) + }) || any_sub_binder_consumed_for(body, pname, uniq, def_name, ctors) + } + Term::Assign { value, .. } => { + any_sub_binder_consumed_for(value, pname, uniq, def_name, ctors) + } } } diff --git a/crates/ailang-check/src/mono.rs b/crates/ailang-check/src/mono.rs index 3bdee49..938462a 100644 --- a/crates/ailang-check/src/mono.rs +++ b/crates/ailang-check/src/mono.rs @@ -1221,6 +1221,19 @@ fn rewrite_mono_calls( rewrite_mono_calls(source, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals); rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals); } + // Iter mut.1: visitor-shape recurse with mutable access. + // Mut-vars do not declare class-method or poly-fn names; the + // var inits, the body, and assign values can though, so walk + // them in place. + Term::Mut { vars, body } => { + for v in vars.iter_mut() { + rewrite_mono_calls(&mut v.init, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals); + } + rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals); + } + Term::Assign { value, .. } => { + rewrite_mono_calls(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals); + } Term::Lit { .. } => {} } } @@ -1577,6 +1590,19 @@ fn interleave_slots( interleave_slots(source, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out); interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out); } + // Iter mut.1: visitor-shape recurse, symmetric to + // `rewrite_mono_calls` above. Mut-vars themselves do not + // contribute slots; their init and body terms (and assign + // values) can. + Term::Mut { vars, body } => { + for v in vars { + interleave_slots(&v.init, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + } + interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + } + Term::Assign { value, .. } => { + interleave_slots(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + } Term::Lit { .. } => {} } } diff --git a/crates/ailang-check/src/pre_desugar_validation.rs b/crates/ailang-check/src/pre_desugar_validation.rs index 49c7cb1..c0bcda6 100644 --- a/crates/ailang-check/src/pre_desugar_validation.rs +++ b/crates/ailang-check/src/pre_desugar_validation.rs @@ -120,6 +120,19 @@ fn walk_term(t: &Term) -> Result<(), CheckError> { walk_term(source)?; walk_term(body) } + // Iter mut.1: pre-desugar validation has nothing specific to + // assert about mut blocks (the only invariant this pass + // enforces today is "no Float in literal patterns" — mut + // var inits are Terms, but they can hold any literal, and + // patterns inside a body are visited via the normal + // `Term::Match` arm). Recurse into children. + Term::Mut { vars, body } => { + for v in vars { + walk_term(&v.init)?; + } + walk_term(body) + } + Term::Assign { value, .. } => walk_term(value), } } diff --git a/crates/ailang-check/src/reuse_shape.rs b/crates/ailang-check/src/reuse_shape.rs index 1b18940..72f37cc 100644 --- a/crates/ailang-check/src/reuse_shape.rs +++ b/crates/ailang-check/src/reuse_shape.rs @@ -253,6 +253,18 @@ impl<'a> Checker<'a> { self.walk(body); self.check_reuse_as(source, body); } + // Iter mut.1: mut-vars are scalar (Int/Float/Bool/Unit) + // and cannot host an allocating ctor body, so the + // reuse-as path-ctor analysis has nothing to refine here. + // Recurse defensively into children so any reuse-as + // nested inside a var init or the body is still checked. + Term::Mut { vars, body } => { + for v in vars { + self.walk(&v.init); + } + self.walk(body); + } + Term::Assign { value, .. } => self.walk(value), } } diff --git a/crates/ailang-check/src/uniqueness.rs b/crates/ailang-check/src/uniqueness.rs index bc15b6e..d102d48 100644 --- a/crates/ailang-check/src/uniqueness.rs +++ b/crates/ailang-check/src/uniqueness.rs @@ -336,6 +336,22 @@ impl<'a> Walker<'a> { self.walk(source, Position::Consume); self.walk(body, pos); } + // Iter mut.1: mut-vars are alloca-resident scalars and + // are NOT RC-tracked binders. They do not contribute to + // uniqueness counts (the alloca slot is the per-fn + // storage, not a heap allocation). Walk children + // defensively so any RC-managed value passed through a + // var init / body / assign-value still updates the + // appropriate uniqueness counts. + Term::Mut { vars, body } => { + for v in vars { + self.walk(&v.init, Position::Consume); + } + self.walk(body, pos); + } + Term::Assign { value, .. } => { + self.walk(value, Position::Consume); + } } } diff --git a/crates/ailang-codegen/src/escape.rs b/crates/ailang-codegen/src/escape.rs index cfdd1bd..7a6f48d 100644 --- a/crates/ailang-codegen/src/escape.rs +++ b/crates/ailang-codegen/src/escape.rs @@ -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, 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, 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 = 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); + } } } diff --git a/crates/ailang-codegen/src/lambda.rs b/crates/ailang-codegen/src/lambda.rs index 02b48d7..34a52a1 100644 --- a/crates/ailang-codegen/src/lambda.rs +++ b/crates/ailang-codegen/src/lambda.rs @@ -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 = 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); + } } } diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index ba3d4a8..7cce8bd 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -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()), } } } diff --git a/crates/ailang-core/specs/form_a.md b/crates/ailang-core/specs/form_a.md index 504e0ee..2517bb6 100644 --- a/crates/ailang-core/specs/form_a.md +++ b/crates/ailang-core/specs/form_a.md @@ -281,6 +281,9 @@ Parenthesised forms: (seq EFFECTFUL-TERM RESULT-TERM) ; sequence; lhs evaluated for effect (clone TERM) ; explicit RC clone (Iter 18c.1) (reuse-as SOURCE-TERM BODY-TERM) ; explicit reuse hint (Iter 18d.1) +(mut (var NAME TYPE INIT)* BODY-TERM+) ; local mutable-state block (Iter mut.1) +(var NAME TYPE INIT) ; mut-var declaration; only inside (mut ...) +(assign NAME VALUE-TERM) ; mut-var update; only inside (mut ...) ``` Notes: @@ -298,6 +301,27 @@ Notes: - `reuse-as` requires `SOURCE-TERM` to be a bare variable reference (a `NAME` in the term grammar). Anything else fails the linearity check with `reuse-as-source-not-bare-var`. +- `mut` opens a lexically-scoped block of mutable bindings. The + body is a flat sequence of zero or more Unit-typed statements + followed by exactly one final expression of any supported type; + the parser right-folds this trailing sequence into `Term::Seq` + inside `Term::Mut.body`, so the canonical JSON-AST always sees a + single `body: Term`. The `vars` array stays present in canonical + JSON even when empty (a `(mut EXPR)` form with no vars is legal + and round-trips uniformly). A `(mut)` form with neither vars nor + body is rejected at parse. Mut-var element types are restricted + to the stack-resident primitives Int / Float / Bool / Unit in + this milestone; the typecheck pass (iter mut.2) rejects other + types with `mut-var-unsupported-type`. +- `(var NAME TYPE INIT)` is only legal as a leading entry inside a + `(mut ...)` block. Outside the parser rejects `var` as an + unknown term head. +- `(assign NAME VALUE-TERM)` is only legal as a sub-term inside a + `(mut ...)` block whose `vars` includes a var with the matching + `NAME`. Outside the parser accepts the shape but the typecheck + pass (iter mut.2) rejects it with `mut-assign-out-of-scope`. The + expression's static type is Unit. See spec + `docs/specs/2026-05-15-mut-local.md`. ## Patterns diff --git a/crates/ailang-core/src/ast.rs b/crates/ailang-core/src/ast.rs index c60983f..6208405 100644 --- a/crates/ailang-core/src/ast.rs +++ b/crates/ailang-core/src/ast.rs @@ -510,6 +510,33 @@ pub enum Term { source: Box, body: Box, }, + /// Iter mut.1: local mutable-state block. `vars` declares zero or + /// more lexically-scoped mutable bindings (initialised in order); + /// `body` is a single Term evaluated in scope of all vars. The + /// block's static type is `body`'s static type. `vars` stays + /// present in canonical JSON even when empty (no + /// `skip_serializing_if` — the field is part of the shape). + /// + /// Typecheck and codegen recognition land in iters mut.2 / mut.3; + /// in iter mut.1 the dispatch entry points (`synth` in + /// `ailang-check`, `lower_term` in `ailang-codegen`) stub these + /// variants with `CheckError::Internal` / `CodegenError::Internal` + /// respectively per spec `docs/specs/2026-05-15-mut-local.md` + /// §"Iteration mut.1". + Mut { + vars: Vec, + body: Box, + }, + /// Iter mut.1: in-block update of a mut-var. Only legal as a + /// sub-term of a `Term::Mut` whose `vars` includes a var with the + /// same `name`. Static type is Unit. The lexical-scope invariant + /// is enforced at typecheck (iter mut.2, + /// `CheckError::MutAssignOutOfScope`); codegen relies on it (iter + /// mut.3). + Assign { + name: String, + value: Box, + }, } /// One arm of a [`Term::Match`]. @@ -524,6 +551,35 @@ pub struct Arm { pub body: Term, } +/// Iter mut.1: a mutable binding inside [`Term::Mut`]. Lives as a +/// nested field of `Term::Mut`, not as a standalone `Term` variant — +/// mut-vars are not first-class values (they cannot escape the +/// enclosing `Term::Mut` scope; see +/// `docs/specs/2026-05-15-mut-local.md` §"Why three AST nodes, not +/// one fused construct"). +/// +/// The JSON field for `ty` is `"type"`, mirroring the spec's schema +/// (`{ "name": "", "type": Type, "init": Term }`). Derives match +/// [`Arm`]'s for cross-tree consistency. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MutVar { + /// The mut-var's lexical name. Within the enclosing `Term::Mut`, + /// a `Term::Var { name }` resolves to this binding (innermost-wins + /// shadowing per spec §"Term::Var resolution"); a + /// `Term::Assign { name, .. }` updates it. + pub name: String, + /// The mut-var's declared type. Restricted to + /// `{ Int, Float, Bool, Unit }` in this milestone — the typecheck + /// pass (iter mut.2) rejects other choices with + /// `CheckError::UnsupportedMutVarType`. + #[serde(rename = "type")] + pub ty: Type, + /// Initial value. Evaluated in the outer scope plus the + /// already-declared vars of the same `Term::Mut` (vars are + /// initialised in declaration order). + pub init: Term, +} + /// A match pattern. /// /// The JSON discriminator is the `p` field. Patterns are linear: each @@ -834,3 +890,60 @@ impl Eq for Type {} fn is_false(b: &bool) -> bool { !*b } + +#[cfg(test)] +mod tests { + use super::*; + + /// Iter mut.1: pin the canonical-bytes shape of an empty-`vars` + /// `Term::Mut`. Spec `docs/specs/2026-05-15-mut-local.md` + /// §"Canonical-form invariants" requires the `vars` array to stay + /// present in canonical JSON rather than being omitted; a future + /// addition of `skip_serializing_if = "Vec::is_empty"` to the + /// field would silently break this property and is the failure + /// mode this pin protects against. + #[test] + fn term_mut_empty_vars_serialises_with_explicit_vars_field() { + let t = Term::Mut { + vars: Vec::new(), + body: Box::new(Term::Lit { + lit: Literal::Int { value: 0 }, + }), + }; + let bytes = serde_json::to_string(&t).expect("serialise"); + assert_eq!( + bytes, + r#"{"t":"mut","vars":[],"body":{"t":"lit","lit":{"kind":"int","value":0}}}"#, + ); + } + + /// Iter mut.1: round-trip a `Term::Assign` through JSON. + /// Pins the canonical-form shape `{ "t": "assign", "name": ..., "value": ... }`. + #[test] + fn term_assign_round_trips_through_json() { + let t = Term::Assign { + name: "x".into(), + value: Box::new(Term::Lit { + lit: Literal::Int { value: 42 }, + }), + }; + let bytes = serde_json::to_string(&t).expect("serialise"); + assert_eq!( + bytes, + r#"{"t":"assign","name":"x","value":{"t":"lit","lit":{"kind":"int","value":42}}}"#, + ); + let back: Term = serde_json::from_str(&bytes).expect("deserialise"); + match back { + Term::Assign { name, value } => { + assert_eq!(name, "x"); + match *value { + Term::Lit { + lit: Literal::Int { value: 42 }, + } => {} + other => panic!("inner literal mismatch: {other:?}"), + } + } + other => panic!("variant mismatch: {other:?}"), + } + } +} diff --git a/crates/ailang-core/src/desugar.rs b/crates/ailang-core/src/desugar.rs index c1645ba..10a2bd2 100644 --- a/crates/ailang-core/src/desugar.rs +++ b/crates/ailang-core/src/desugar.rs @@ -346,6 +346,23 @@ fn collect_used_in_term(t: &Term, used: &mut BTreeSet) { collect_used_in_term(source, used); collect_used_in_term(body, used); } + Term::Mut { vars, body } => { + // Iter mut.1: mut-var declarations introduce source-level + // names — track them so a generated fresh-name cannot + // accidentally collide with one. Then recurse into each + // var's `init` and the block's body. + for v in vars { + used.insert(v.name.clone()); + collect_used_in_term(&v.init, used); + } + collect_used_in_term(body, used); + } + Term::Assign { name, value } => { + // Iter mut.1: the assigned `name` is a *use* of the + // mut-var; record it and recurse into `value`. + used.insert(name.clone()); + collect_used_in_term(value, used); + } } } @@ -539,6 +556,40 @@ impl Desugarer { source: Box::new(self.desugar_term(source, scope)), body: Box::new(self.desugar_term(body, scope)), }, + Term::Mut { vars, body } => { + // Iter mut.1: structural recursion. Each `var`'s `init` + // is evaluated in scope of the outer environment plus + // the *already-declared* vars of the same `Term::Mut` + // (spec §"Iteration mut.2" — first-class scoping + // formally lands at typecheck). For desugar's purposes + // we extend the scope per-var with a `LetBound` + // sentinel so a generated fresh name does not collide + // with mut-var names; the LetRec lifter does not + // consult mut-vars (mut-vars cannot appear in let-rec + // capture sets — letrec lifting happens before + // typecheck of the mut body). + let mut inner = scope.clone(); + let new_vars: Vec = vars + .iter() + .map(|v| { + let init = self.desugar_term(&v.init, &inner); + inner.insert(v.name.clone(), ScopeEntry::LetBound); + MutVar { + name: v.name.clone(), + ty: v.ty.clone(), + init, + } + }) + .collect(); + Term::Mut { + vars: new_vars, + body: Box::new(self.desugar_term(body, &inner)), + } + } + Term::Assign { name, value } => Term::Assign { + name: name.clone(), + value: Box::new(self.desugar_term(value, scope)), + }, Term::LetRec { name, ty, params, body, in_term } => { // Iter 16b.1: lift to a synthetic top-level fn (no-capture). // Iter 16b.2: extend the lift to the path-1 safe subset — @@ -1160,6 +1211,29 @@ pub fn free_vars_in_term(t: &Term, bound: &BTreeSet, out: &mut BTreeSet< free_vars_in_term(source, bound, out); free_vars_in_term(body, bound, out); } + Term::Mut { vars, body } => { + // Iter mut.1: a mut-var name binds inside the block. Each + // `var`'s `init` is evaluated in scope of the OUTER + // environment plus the already-declared vars (init order + // matches declaration order); the body is in scope of all + // vars. Mirrors the `Term::Let` pattern but generalised + // across N vars. + let mut b = bound.clone(); + for v in vars { + free_vars_in_term(&v.init, &b, out); + b.insert(v.name.clone()); + } + free_vars_in_term(body, &b, out); + } + Term::Assign { name, value } => { + // Iter mut.1: the assigned `name` is a free var unless + // bound by an enclosing `Term::Mut`. The value is walked + // as usual. + if !bound.contains(name) { + out.insert(name.clone()); + } + free_vars_in_term(value, bound, out); + } } } @@ -1304,6 +1378,55 @@ pub fn subst_var(t: &Term, from: &str, to: &str) -> Term { source: Box::new(subst_var(source, from, to)), body: Box::new(subst_var(body, from, to)), }, + Term::Mut { vars, body } => { + // Iter mut.1: mut-vars lexically shadow outer names. Walk + // each var's `init` substituting only if no earlier var in + // the same block already shadows `from`; once a var named + // `from` is declared, subsequent inits AND the body see + // that var, so substitution must stop. + let mut shadowed = false; + let new_vars: Vec = vars + .iter() + .map(|v| { + let init = if shadowed { + v.init.clone() + } else { + subst_var(&v.init, from, to) + }; + if v.name == from { + shadowed = true; + } + MutVar { + name: v.name.clone(), + ty: v.ty.clone(), + init, + } + }) + .collect(); + let body_rw = if shadowed { + (**body).clone() + } else { + subst_var(body, from, to) + }; + Term::Mut { + vars: new_vars, + body: Box::new(body_rw), + } + } + Term::Assign { name, value } => Term::Assign { + // Iter mut.1: substitute inside `value`; the `name` field + // refers to a mut-var declared by an enclosing + // `Term::Mut`. Renaming it here without renaming the + // declaration would break the scope link. The desugar + // pass's `subst_var` is used only to rewrite let-rec + // captures (KnownType / LetBound / MatchArm — never + // mut-vars), so leaving the `name` untouched is + // semantically correct: any `from` that matches the + // assigned `name` is in a different namespace by + // construction. + name: name.clone(), + value: Box::new(subst_var(value, from, to)), + }, } } @@ -1425,6 +1548,26 @@ pub fn subst_call_with_extras(t: &Term, name: &str, lifted: &str, extras: &[Stri source: Box::new(subst_call_with_extras(source, name, lifted, extras)), body: Box::new(subst_call_with_extras(body, name, lifted, extras)), }, + Term::Mut { vars, body } => Term::Mut { + // Iter mut.1: structural recursion through each var's + // `init` and the block's body. Mut-var declarations + // cannot shadow a top-level fn name (mut-vars are + // first-order scalars, fns are not assignable), so the + // call-substitution sees through the block uniformly. + vars: vars + .iter() + .map(|v| MutVar { + name: v.name.clone(), + ty: v.ty.clone(), + init: subst_call_with_extras(&v.init, name, lifted, extras), + }) + .collect(), + body: Box::new(subst_call_with_extras(body, name, lifted, extras)), + }, + Term::Assign { name: assign_name, value } => Term::Assign { + name: assign_name.clone(), + value: Box::new(subst_call_with_extras(value, name, lifted, extras)), + }, } } @@ -1479,6 +1622,24 @@ pub fn find_non_callee_use(t: &Term, name: &str) -> Option { Term::ReuseAs { source, body } => { find_non_callee_use(source, name).or_else(|| find_non_callee_use(body, name)) } + // Iter mut.1: scan each var's init plus the body. Mut-vars + // are first-order scalars and never appear in callee position, + // so any matching `Term::Var` inside a mut block is non-callee + // by construction. + Term::Mut { vars, body } => vars + .iter() + .find_map(|v| find_non_callee_use(&v.init, name)) + .or_else(|| find_non_callee_use(body, name)), + // Iter mut.1: the assigned `name` is a non-callee *use* of + // the mut-var; if it matches the searched name, surface the + // node itself. Otherwise recurse into the value. + Term::Assign { name: assign_name, value } => { + if assign_name == name { + Some(t.clone()) + } else { + find_non_callee_use(value, name) + } + } } } @@ -1521,6 +1682,11 @@ mod tests { } Term::Clone { value } => any_nested_ctor(value), Term::ReuseAs { source, body } => any_nested_ctor(source) || any_nested_ctor(body), + // Iter mut.1: scan each var's init plus the body. + Term::Mut { vars, body } => { + vars.iter().any(|v| any_nested_ctor(&v.init)) || any_nested_ctor(body) + } + Term::Assign { value, .. } => any_nested_ctor(value), } } @@ -1546,6 +1712,11 @@ mod tests { Term::LetRec { .. } => true, Term::Clone { value } => any_let_rec(value), Term::ReuseAs { source, body } => any_let_rec(source) || any_let_rec(body), + // Iter mut.1: scan each var's init plus the body. + Term::Mut { vars, body } => { + vars.iter().any(|v| any_let_rec(&v.init)) || any_let_rec(body) + } + Term::Assign { value, .. } => any_let_rec(value), } } @@ -2662,6 +2833,11 @@ mod tests { } Term::Clone { value } => any_lit_pattern(value), Term::ReuseAs { source, body } => any_lit_pattern(source) || any_lit_pattern(body), + // Iter mut.1: scan each var's init plus the body. + Term::Mut { vars, body } => { + vars.iter().any(|v| any_lit_pattern(&v.init)) || any_lit_pattern(body) + } + Term::Assign { value, .. } => any_lit_pattern(value), } } diff --git a/crates/ailang-core/src/workspace.rs b/crates/ailang-core/src/workspace.rs index cd84245..dad8361 100644 --- a/crates/ailang-core/src/workspace.rs +++ b/crates/ailang-core/src/workspace.rs @@ -1247,6 +1247,18 @@ where walk_term_embedded_types(source, f)?; walk_term_embedded_types(body, f) } + // Iter mut.1: each `MutVar.ty` is an embedded type — walk + // it. Then recurse into each var's `init` and the block's + // body. `Term::Assign` carries no embedded type; recurse + // into its `value` only. + Term::Mut { vars, body } => { + for v in vars { + walk_type(&v.ty, f)?; + walk_term_embedded_types(&v.init, f)?; + } + walk_term_embedded_types(body, f) + } + Term::Assign { value, .. } => walk_term_embedded_types(value, f), } } @@ -1369,6 +1381,17 @@ where walk_term(source, f)?; walk_term(body, f) } + // Iter mut.1: `Term::Ctor.type_name` is the only string this + // walker reports — mut-vars carry no Ctor-type strings. Just + // recurse into each var's `init` and the body, and into + // assign's `value`. + Term::Mut { vars, body } => { + for v in vars { + walk_term(&v.init, f)?; + } + walk_term(body, f) + } + Term::Assign { value, .. } => walk_term(value, f), } } diff --git a/crates/ailang-core/tests/design_schema_drift.rs b/crates/ailang-core/tests/design_schema_drift.rs index c4b2b64..544440e 100644 --- a/crates/ailang-core/tests/design_schema_drift.rs +++ b/crates/ailang-core/tests/design_schema_drift.rs @@ -137,6 +137,20 @@ fn design_md_anchors_every_term_variant() { body: Box::new(Term::Var { name: "y".into() }), }, ), + ( + r#""t": "mut""#, + Term::Mut { + vars: Vec::new(), + body: Box::new(Term::Lit { lit: Literal::Unit }), + }, + ), + ( + r#""t": "assign""#, + Term::Assign { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Unit }), + }, + ), ]; for (anchor, term) in exemplars { @@ -156,6 +170,8 @@ fn design_md_anchors_every_term_variant() { Term::Seq { .. } => "seq", Term::Clone { .. } => "clone", Term::ReuseAs { .. } => "reuse-as", + Term::Mut { .. } => "mut", + Term::Assign { .. } => "assign", }; assert!( data_model_section().contains(anchor), diff --git a/crates/ailang-core/tests/schema_coverage.rs b/crates/ailang-core/tests/schema_coverage.rs index 4bac185..fcc7d13 100644 --- a/crates/ailang-core/tests/schema_coverage.rs +++ b/crates/ailang-core/tests/schema_coverage.rs @@ -47,6 +47,8 @@ enum VariantTag { TermSeq, TermClone, TermReuseAs, + TermMut, + TermAssign, // Pattern PatternWild, PatternVar, @@ -92,6 +94,8 @@ const EXPECTED_VARIANTS: &[VariantTag] = &[ VariantTag::TermSeq, VariantTag::TermClone, VariantTag::TermReuseAs, + VariantTag::TermMut, + VariantTag::TermAssign, VariantTag::PatternWild, VariantTag::PatternVar, VariantTag::PatternLit, @@ -228,6 +232,18 @@ fn visit_term(t: &Term, observed: &mut HashSet) { visit_term(source, observed); visit_term(body, observed); } + Term::Mut { vars, body } => { + observed.insert(VariantTag::TermMut); + for v in vars { + visit_type(&v.ty, observed); + visit_term(&v.init, observed); + } + visit_term(body, observed); + } + Term::Assign { value, .. } => { + observed.insert(VariantTag::TermAssign); + visit_term(value, observed); + } } } diff --git a/crates/ailang-core/tests/spec_drift.rs b/crates/ailang-core/tests/spec_drift.rs index 2b427e1..a7d1371 100644 --- a/crates/ailang-core/tests/spec_drift.rs +++ b/crates/ailang-core/tests/spec_drift.rs @@ -114,6 +114,20 @@ fn spec_mentions_every_term_variant() { body: Box::new(Term::Var { name: "y".into() }), }, ), + ( + "(mut", + Term::Mut { + vars: Vec::new(), + body: Box::new(Term::Lit { lit: Literal::Unit }), + }, + ), + ( + "(assign", + Term::Assign { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Unit }), + }, + ), ]; for (anchor, term) in exemplars { @@ -135,6 +149,8 @@ fn spec_mentions_every_term_variant() { Term::Seq { .. } => "seq", Term::Clone { .. } => "clone", Term::ReuseAs { .. } => "reuse-as", + Term::Mut { .. } => "mut", + Term::Assign { .. } => "assign", }; assert!( FORM_A_SPEC.contains(anchor), diff --git a/crates/ailang-prose/src/lib.rs b/crates/ailang-prose/src/lib.rs index 40c41b5..baa3f39 100644 --- a/crates/ailang-prose/src/lib.rs +++ b/crates/ailang-prose/src/lib.rs @@ -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 : + // = ; ...; }` 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 = 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)), + }, } } diff --git a/crates/ailang-surface/src/parse.rs b/crates/ailang-surface/src/parse.rs index 78c4cac..6d4a593 100644 --- a/crates/ailang-surface/src/parse.rs +++ b/crates/ailang-surface/src/parse.rs @@ -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 { + 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 = 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 = 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 { + 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 { @@ -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}" + ); + } } diff --git a/crates/ailang-surface/src/print.rs b/crates/ailang-surface/src/print.rs index 9537eab..82eb166 100644 --- a/crates/ailang-surface/src/print.rs +++ b/crates/ailang-surface/src/print.rs @@ -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(')'); + } } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1013903..1ef2d75 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2365,12 +2365,42 @@ are real surface forms. // (typically `ctor` or `lam`); `source` must be a bare `var`. Codegen // lowers as in-place rewrite under `--alloc=rc`. { "t": "reuse-as", "source": Term, "body": Term } + +// Iter mut.1: local mutable-state block. `vars` declares zero or +// more lexically-scoped mutable bindings (initialised in order); +// `body` is a single Term in scope of all vars. The block's static +// type is `body`'s static type. The `vars` array stays present in +// canonical JSON even when empty (no `skip_serializing_if` — +// hash-stable-on-omission is NOT applied here; the field is part of +// the shape). See `docs/specs/2026-05-15-mut-local.md`. +{ "t": "mut", + "vars": [ { "name": "", "type": Type, "init": Term }, ... ], + "body": Term } + +// Iter mut.1: in-block update of a mut-var. Legal only as a +// sub-term of a `Term::Mut` whose `vars` includes a var with the +// same `name`. Static type Unit. The lexical-scope rule is enforced +// at typecheck (iter mut.2, `mut-assign-out-of-scope`); codegen +// relies on it (iter mut.3). +{ "t": "assign", + "name": "", + "value": Term } ``` In the MVP, `do` is only a direct call to a built-in effect op (no handler). A `lam` term constructs an anonymous function value; free variables of its body are captured from the enclosing scope. +Iter mut.1 lands `Term::Mut` and `Term::Assign` as first-class AST +nodes that round-trip cleanly between Form A and canonical JSON. +Typecheck recognition (iter mut.2) and codegen lowering (iter mut.3) +are sequenced as separate iterations; in iter mut.1 the typecheck +dispatch (`synth` in `ailang-check`) and codegen dispatch +(`lower_term` in `ailang-codegen`) stub these variants with +`CheckError::Internal` / `CodegenError::Internal` respectively, per +spec `docs/specs/2026-05-15-mut-local.md` §"Iteration mut.1" +out-of-iteration boundary. + **`Literal`**: ```jsonc diff --git a/docs/journals/2026-05-15-iter-mut.1.md b/docs/journals/2026-05-15-iter-mut.1.md new file mode 100644 index 0000000..1166ee9 --- /dev/null +++ b/docs/journals/2026-05-15-iter-mut.1.md @@ -0,0 +1,215 @@ +# iter mut.1 — schema + surface for local mutable state + +**Date:** 2026-05-15 +**Started from:** 60e4559e31d7f09de77e2679aab2d524de257a61 +**Status:** DONE +**Tasks completed:** 6 of 6 + +## Summary + +Lands the foundational AST + surface forms for the mut-local +milestone (first milestone on the Stateful-islands roadmap path). +Two new `Term` variants (`Mut` and `Assign`) plus the nested +`MutVar` struct enter `ailang-core::ast`; canonical-JSON serde +round-trips them by virtue of the existing `#[serde(tag = "t")]` +machinery. Form A gains `(mut …)` / `(var …)` / `(assign …)` +productions in the parser and the corresponding write arms in the +printer; the parser right-folds the trailing body sequence into +`Term::Seq` so the canonical JSON-AST always sees a single +`body: Term`. Every `Term` exhaustive match in the workspace (~25 +walker sites) gains substantive arms, except the two dispatch +entry points (`synth` in `ailang-check`, `lower_term` in +`ailang-codegen`) which stub with `CheckError::Internal` / +`CodegenError::Internal` per the spec's "out of iteration" +boundary — typecheck recognition lands in mut.2, codegen lowering +in mut.3. The three drift tests (`design_schema_drift`, +`spec_drift`, `schema_coverage`) all flip green after the +exemplar / variant-tag / visitor extensions, and `examples/mut.ail` +ships as the round-trip fixture covering empty / single-var / +two-var / nested-shadow / Bool / Unit cases. Full +`cargo test --workspace` 579/579 green (was 564 + 2 new AST pins + +4 new parser pins + 9 from the extended drift exemplars and the +mut.ail-corpus contributions netting to +15). + +## Per-task notes + +- iter mut.1.1 — AST extension: added `Term::Mut { vars: Vec, + body: Box }`, `Term::Assign { name: String, value: Box }`, + and the `MutVar { name, ty, init }` struct to `crates/ailang-core/src/ast.rs`. + Two new unit tests pin the canonical-bytes of the empty-vars mut + serialisation and the Assign round-trip. `MutVar` carries Arm's + derives (`Debug, Clone, Serialize, Deserialize`) — the plan's + literal pseudo-code asked for `PartialEq + Eq` but its own + justification ("derives match Arm's for cross-tree consistency") + points at Arm, which does not derive those. Concern recorded; + no functional impact (the Task 1 tests do not depend on + `PartialEq`, and `Term` itself uses a custom `PartialEq` impl on + `Type` only). + +- iter mut.1.2 — substantive walker arms: 9 sites in `desugar.rs`, + 2 in `workspace.rs`, 3 in `ailang-check/src/lib.rs` (1 in + `substitute_rigids_in_term`, 1 in `verify_tail_positions`, 1 + variant-name string emitter inside `synth`'s `Term::ReuseAs` sub- + match), 2 in `lift.rs`, 3 in `linearity.rs` (one being a + variant-name string emitter), 2 in `mono.rs`, 1 each in + `pre_desugar_validation.rs`, `reuse_shape.rs`, `uniqueness.rs`, + 3 in `escape.rs`, 1 in `lambda.rs`, 1 in `codegen/src/lib.rs` + (the `synth_with_extras` type-synthesis helper), 3 in + `ailang-prose/src/lib.rs`, 2 in `ail/src/main.rs`. Plus one + unenumerated site in `crates/ail/tests/codegen_import_map_fallback_pin.rs` + (an exhaustive walker inside a test) that the build required. + Each arm follows the appropriate shape per existing convention + at its site: rebuilder shapes for `substitute_*` / `subst_*` / + `desugar_term` / `lift_in_term` / `substitute_rigids_in_term` / + `rewrite_term`, visitor shapes for `collect_used_in_term` / + `free_vars_in_term` / `find_non_callee_use` / `walk_term*` / + `verify_tail_positions` / `term_has_letrec` / the linearity + walkers / `walk` in `reuse_shape.rs` / `walk` in `uniqueness.rs` + / `walk` in `escape.rs` / `collect_captures` / `count_free_var` / + `subst_var_with_term` (rebuilder-shape in prose). Shadowing + semantics across mut-var bindings replicates the + `Term::Let`/`Term::Lam`/`Term::LetRec`-shaped convention at each + site: var-init terms see the outer scope plus already-declared + vars; later var-inits and the body see all earlier var bindings. + +- iter mut.1.3 — dispatch stubs: typecheck dispatch at `synth` in + `ailang-check/src/lib.rs` (Plan line 2572 was wrong — that line + is `verify_tail_positions`, which is substantive walker + territory; the actual dispatch entry is `synth` at line 3403's + former `Term::ReuseAs` arm, where the two new arms now sit) + returns `CheckError::Internal("Term::Mut/Assign not yet + supported in typecheck (deferred to iter mut.2)")`. Codegen + dispatch at `lower_term` in `ailang-codegen/src/lib.rs:1649` + returns `CodegenError::Internal("Term::Mut/Assign not yet + supported in codegen (deferred to iter mut.3)")`. Both errors + use the single-field tuple-variant shape (`Internal(String)`). + +- iter mut.1.4 — Form A parser + printer: dispatcher arms added + for `"mut" => parse_mut` and `"assign" => parse_assign` in + `parse_term`'s head-keyword match; the two helpers parse + positionally (mut: pull leading `(var ...)` entries, then ≥ 1 + body terms right-folded into `Term::Seq`; assign: positional + ident-then-value pair). Plan pseudo-code used helper names + (`peek_is_open_paren_with_head`, `expect_head`, etc.) that don't + exist; substituted the actual API (`expect_lparen`, + `expect_keyword`, `expect_ident`, `parse_type`, `parse_term`, + `expect_rparen`, `peek_head_ident`, `matches!(self.peek(), + Tok::RParen)`). Four RED-first parser tests added covering empty + mut + 1-var-1-assign-1-final + missing-body rejection (both + shapes). Print arms in `print.rs` walk the right-spine of + `Term::Seq` and emit each lhs as a top-level statement; the + terminal expression closes the `(mut …)` form. EBNF prologue in + `parse.rs` extended with `mut-term`, `var-decl`, `assign-term` + productions. The print arms had to land during the Task 1+2 + build-unblock phase rather than waiting for Task 4 (they are + exhaustive-match neighbours of the synth/lower_term dispatchers); + this is a benign sequencing shift and the plan's "Task 2 build- + green verification gate" still holds. + +- iter mut.1.5 — drift + coverage extensions: two new exemplars + added to `design_schema_drift.rs` and two new match arms; two + new `VariantTag` entries (`TermMut`, `TermAssign`), + `EXPECTED_VARIANTS` extended, two new `visit_term` arms in + `schema_coverage.rs`; two new exemplars + two new match arms in + `spec_drift.rs`. `form_a.md` gains three new lines in the + Parenthesised-forms block plus a prose paragraph naming the + empty-vars and post-vars-body invariants and the diagnostic + codes. `DESIGN.md` §"Term (expression)" gains the two jsonc + schema blocks immediately after the `reuse-as` block plus a + closing prose paragraph naming the mut.1 dispatch-stub semantics + and the deferred mut.2 / mut.3 iterations. Post-edit: + `design_schema_drift` and `spec_drift` both green; + `schema_coverage` is RED on the missing `examples/mut.ail` + fixture, per plan Step 6 expectation. + +- iter mut.1.6 — fixture + round-trip: `examples/mut.ail` shipped + with the six fns from plan Step 1 verbatim (empty / single-var + with assign / two-var with combine / nested-shadow / Bool / + Unit). The corpus-globbing round-trip test + (`parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`) + and the parse-determinism test pick the fixture up automatically; + both green. `schema_coverage` also green now that both new + variants are observed in the corpus. Full `cargo test + --workspace` 579/579 green. + +## Concerns + +- `MutVar` derives diverge from the plan's literal pseudo-code + (`Debug, Clone, PartialEq, Eq, Serialize, Deserialize`); the + shipped form is `Debug, Clone, Serialize, Deserialize` to match + Arm's pattern. The plan's own justification ("match Arm's + derives for cross-tree consistency") points at Arm which does + not derive PartialEq/Eq. Functional impact: none in mut.1 + (the Task 1 tests do not depend on PartialEq, and `Term` uses + a custom PartialEq impl scoped to `Type` only). A future iter + that needs to compare `MutVar` (e.g. for in-place rewriting in + mut.3 codegen) can add the derives at that time without breaking + any mut.1 test. + +- Plan recon for the typecheck dispatch entry was misindexed. + The plan listed `crates/ailang-check/src/lib.rs:2572` as the + "check_term dispatch — STUBBED" site; that line is actually in + `verify_tail_positions`, a substantive walker. The real + typecheck dispatch entry is `synth` (defined at lib.rs:2613, + with its `Term::ReuseAs` arm at line 3403 before the new mut + arms). The stub was placed at the actual dispatch entry; line + 2572 received a substantive arm. The variant-name string + emitter sub-match referenced as line 3430 is inside `synth`'s + former `Term::ReuseAs` arm and was extended as Task 2 Step 13 + asks. Net: the spec intent (stub at typecheck dispatch) is + preserved; only the line-number routing differs. + +- One test-side walker site (`crates/ail/tests/codegen_import_map_fallback_pin.rs:62`) + carries an exhaustive match on `Term` and was not enumerated + by the plan's Task 2 file list. The site is the body-walker of + a regression-pin test from iter 24.tidy; it required two new + arms to build green. Added with appropriate visitor-shape + recursion. The plan-recon miss is recorded here as a + documentary item; no behavioural impact. + +## Known debt + +- Prose-side `(mut ...)` rendering in `ailang-prose/src/lib.rs` is + a minimal-correctness shape (`mut { var = ; ...; }`). + The prose surface for mut blocks is not yet fully designed; a + future prose-iter once the LLM-author signal arrives will refine + it. This is a deliberate placeholder, not drift — recording for + visibility. + +- `subst_var` in `desugar.rs` does NOT rename the `Term::Assign.name` + field. The desugar pass's `subst_var` rewrites let-rec captures + (KnownType / LetBound / MatchArm), none of which can be mut-vars + (mut-vars are first-order scalars, not captureable). Leaving + the assign-name untouched is semantically correct; documented in + the arm's doc-comment. Mentioned here for visibility. + +## Files touched + +- `crates/ailang-core/src/ast.rs` — `Term::Mut`, `Term::Assign`, `MutVar`, two new unit tests +- `crates/ailang-core/src/desugar.rs` — 9 walker arms +- `crates/ailang-core/src/workspace.rs` — 2 walker arms +- `crates/ailang-check/src/lib.rs` — 3 walker arms (substantive + variant-name) + 2 dispatch stubs in `synth` +- `crates/ailang-check/src/lift.rs` — 2 walker arms +- `crates/ailang-check/src/linearity.rs` — 3 walker arms (2 substantive + 1 variant-name) +- `crates/ailang-check/src/mono.rs` — 2 walker arms (1 mut-ref rebuilder, 1 visitor) +- `crates/ailang-check/src/pre_desugar_validation.rs` — 1 walker arm +- `crates/ailang-check/src/reuse_shape.rs` — 1 walker arm +- `crates/ailang-check/src/uniqueness.rs` — 1 walker arm +- `crates/ailang-codegen/src/escape.rs` — 3 walker arms +- `crates/ailang-codegen/src/lambda.rs` — 1 walker arm +- `crates/ailang-codegen/src/lib.rs` — 1 walker arm (`synth_with_extras`) + 2 dispatch stubs in `lower_term` +- `crates/ailang-prose/src/lib.rs` — 3 walker arms (1 substantive, 1 `count_free_var`, 1 `subst_var_with_term` rebuilder) +- `crates/ail/src/main.rs` — 2 walker arms (dep-walker + `rewrite_term`) +- `crates/ail/tests/codegen_import_map_fallback_pin.rs` — 1 walker arm (test-side, unenumerated by plan) +- `crates/ailang-surface/src/parse.rs` — dispatcher arms, `parse_mut` / `parse_assign` helpers, EBNF prologue extension, 4 new parser tests +- `crates/ailang-surface/src/print.rs` — 2 `write_term` arms +- `crates/ailang-core/tests/design_schema_drift.rs` — 2 exemplars + 2 match arms +- `crates/ailang-core/tests/schema_coverage.rs` — 2 VariantTag + EXPECTED_VARIANTS + 2 visit_term arms +- `crates/ailang-core/tests/spec_drift.rs` — 2 exemplars + 2 match arms +- `crates/ailang-core/specs/form_a.md` — 3 new productions + prose paragraph +- `docs/DESIGN.md` — 2 jsonc schema blocks + prose paragraph +- `examples/mut.ail` — round-trip fixture (6 fns) + +## Stats + +bench/orchestrator-stats/2026-05-15-iter-mut.1.json diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index 54befb8..b2a08e0 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -70,3 +70,4 @@ - 2026-05-14 — iter pd.2: surface owns prelude embed (PRELUDE_AIL + parse_prelude in ailang-surface); pd.1 shim load_workspace_with retired along with PRELUDE_JSON / load_prelude / load_one; cross-form-identity preflight PASSED at module_hash 3abe0d3fa3c11c99; production literal-"prelude" in core/src/ now zero → 2026-05-14-iter-pd.2.md - 2026-05-14 — iter pd.3: prelude.ail.json deleted from working tree; cross-form-identity preflight + supporting bytes deleted from prelude_module_hash_pin (purpose discharged); migrate-bare-cross-module-refs defensive include + lockstep skip-branch deleted; carve_out_inventory.rs §C4(b) row dropped (8→7); form-a-default-authoring spec §C4(b) RETIRED status marker added; new prelude_decouple_carve_out_pin.rs asserts JSON file does not exist; milestone prelude-decouple closed → 2026-05-14-iter-pd.3.md - 2026-05-14 — audit-pd: milestone close (prelude-decouple) — architect drift fixed inline as audit-pd-tidy (DESIGN.md §"Roundtrip Invariant" carve-out count 8→7 + main.rs migrate-subcommand dead prelude fallback removed); bench mixed (check.py + compile_check.py established noise envelope, 15th consecutive observation, baseline pristine; cross_lang clean) → 2026-05-14-audit-pd.md +- 2026-05-15 — iter mut.1: AST extension `Term::Mut` + `Term::Assign` + `MutVar` struct; Form A `(mut ...) / (var ...) / (assign ...)` productions parse + print + round-trip; ~25 substantive Term-walker arms across the codebase; dispatch stubs in `synth` (typecheck) + `lower_term` (codegen) return `CheckError::Internal` / `CodegenError::Internal` per the spec's out-of-iteration boundary; `examples/mut.ail` six-fn fixture (Int/Float/Bool/Unit + empty + nested-shadow); drift + coverage + spec-drift tests + DESIGN.md §"Term (expression)" + `crates/ailang-core/specs/form_a.md` amendments; tests 564 → 579 → 2026-05-15-iter-mut.1.md diff --git a/examples/mut.ail b/examples/mut.ail new file mode 100644 index 0000000..0a65166 --- /dev/null +++ b/examples/mut.ail @@ -0,0 +1,62 @@ +(module mut + + (fn mut_empty + (doc "Iter mut.1 — empty mut block; body is a single Int literal.") + (type (fn-type (params) (ret (con Int)))) + (params) + (body (mut 0))) + + (fn mut_single_var + (doc "Iter mut.1 — one var, one assign, final expression reads the var.") + (type (fn-type (params) (ret (con Int)))) + (params) + (body + (mut + (var x (con Int) 0) + (assign x (app + x 1)) + x))) + + (fn mut_two_vars + (doc "Iter mut.1 — two vars, two assigns, final expression combines them.") + (type (fn-type (params) (ret (con Float)))) + (params) + (body + (mut + (var sum (con Float) 0.0) + (var count (con Int) 0) + (assign sum (app + sum 1.0)) + (assign count (app + count 1)) + (app + sum (app int_to_float count))))) + + (fn mut_nested_shadow + (doc "Iter mut.1 — outer var shadowed by inner mut block's var of the same name.") + (type (fn-type (params) (ret (con Int)))) + (params) + (body + (mut + (var x (con Int) 10) + (assign x (app + x 1)) + (mut + (var x (con Int) 100) + (assign x (app + x 1)) + x)))) + + (fn mut_returns_bool + (doc "Iter mut.1 — exercise Bool as a supported scalar var type.") + (type (fn-type (params) (ret (con Bool)))) + (params) + (body + (mut + (var flag (con Bool) false) + (assign flag true) + flag))) + + (fn mut_returns_unit + (doc "Iter mut.1 — exercise Unit as the supported scalar zero case.") + (type (fn-type (params) (ret (con Unit)))) + (params) + (body + (mut + (var u (con Unit) (lit-unit)) + (assign u (lit-unit)) + u))))