Iter 16a: nested constructor patterns via AST desugaring

Lifts the gate that rejected `(pat-ctor Cons a (pat-ctor Cons b _))`.
Approach: a pure AST → AST pass `ailang_core::desugar::desugar_module`
that flattens nested-Ctor sub-patterns into chains of single-level
matches with let-bound fresh vars and duplicate fall-through. Both
ailang-check and ailang-codegen call the pass at pipeline entry.
Hash-relevant canonical bytes are untouched: the pass runs after
load_module, in memory only. `check`'s returned CheckedModule.symbols
still carries hashes of the original defs so `ail diff` / `ail manifest`
see on-disk identities.

Lit sub-patterns inside a Ctor still rejected — that's the narrower
scope of `nested-ctor-pattern-not-allowed` post-16a; the variant doc
reflects the new meaning.

Fresh-name safety: `$` is a valid ident character in form (A), so
the Desugarer pre-walks every Term::Var / Pattern::Var name into a
BTreeSet and bumps its counter past any user-spelled `$mp_N`.

Already-flat matches go through `is_flat()` and emit identical AST
shapes; every existing fixture produces the same output as before.

New: examples/nested_pat.{ailx,ail.json} prints 30 from
first_two_sum on [10, 20, 30]. e2e test
`nested_ctor_pattern_first_two_sum` guards the path.

Tests: 92/92 (e2e 32→33, ailang-core unit 10→12).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 19:10:01 +02:00
parent 0b1b39f829
commit 0e90709a94
8 changed files with 791 additions and 8 deletions
+571
View File
@@ -0,0 +1,571 @@
//! AST → AST rewriter that runs **after** [`crate::load_module`] and
//! **before** anything that consumes a [`Module`] (typecheck, codegen).
//!
//! ## Iter 16a: nested constructor patterns in `match`
//!
//! Through Iter 15e, a [`Pattern::Ctor`]'s sub-patterns were restricted to
//! [`Pattern::Var`] and [`Pattern::Wild`] — nested ctors like
//! `(pat-ctor Cons a (pat-ctor Cons b _))` were rejected by the
//! checker (`nested-ctor-pattern-not-allowed`) and silently mishandled
//! by the codegen. Iter 16a lifts the gate by **desugaring** nested
//! ctor sub-patterns into chains of single-level matches at the AST
//! level, before either the checker or codegen sees the module. The
//! rewrite is pure (no side effects, no I/O), runs in memory, and
//! produces a [`Module`] semantically equivalent to its input.
//!
//! Lit sub-patterns inside a Ctor remain rejected — that is a separate
//! iter. The [`crate::ast::Pattern`] enum, the JSON schema, and every
//! on-disk hash are unchanged: the desugar runs **after** `load_module`,
//! so canonical bytes computed from the source file are untouched. The
//! checker / codegen consume the desugared form.
//!
//! ## Algorithm
//!
//! For a [`Term::Match`] whose arms contain a non-flat ctor pattern:
//!
//! 1. Bottom-up: recursively desugar the scrutinee and every arm body
//! first (children are normalised before parents).
//! 2. Let-bind the scrutinee to a fresh name `$mp_N` (so that we don't
//! re-evaluate effectful scrutinees per arm).
//! 3. Build a chain of single-level matches via
//! `build_chain(s_var, arms, default)` where `default` is a unit
//! literal — a placeholder for the unreachable fallthrough. Valid
//! programs never reach it because the checker requires
//! exhaustiveness (the catch-all arm dominates the chain).
//! 4. Each arm is lowered via `desugar_one_arm`:
//! - `Pattern::Wild` → arm body (catch-all; later arms are dropped).
//! - `Pattern::Var { name }` → `Let { name = scrutinee_var; body }`.
//! - `Pattern::Lit` → forwarded as a single-arm ctor-pattern match
//! (codegen will reject if reached: lit-in-pattern stays out of
//! scope this iter).
//! - `Pattern::Ctor { ctor, fields }`: lift each field to a fresh
//! var, build a flat outer pattern, walk fields right-to-left
//! wrapping inner sub-patterns via `wrap_sub`. Emit a
//! `Term::Match` with two arms: the flat ctor and a wildcard
//! fall-through into the rest of the chain.
//!
//! `wrap_sub(fv, sub, body, fall_k)` recurses on `sub`:
//! - `Var(n)` → `Let(n, Var(fv), body)`.
//! - `Wild` → `body`.
//! - `Ctor` / `Lit` → recursively `desugar_match(Var(fv), [(sub, body),
//! (Wild, fall_k)])`. The recursion is what handles arbitrary
//! nesting depth (`Cons a (Cons b (Cons c _))`).
//!
//! The fall-through term is cloned per inner match because each branch
//! that fails must continue to the same fallthrough. Worst-case size is
//! O(arms × depth); acceptable for typical patterns.
//!
//! ## Fresh-name safety
//!
//! `$` is a valid identifier character in form (A) (the lexer's `Ident`
//! token is "anything not paren/int/string"), so a user *could* write
//! `$mp_0` themselves. To avoid collisions, the fresh-name generator
//! reads a pre-collected set of every name appearing in any
//! [`crate::ast::Term::Var`] or [`crate::ast::Pattern::Var`] of the
//! module and bumps the counter until it lands on a name not in that
//! set.
//!
//! ## What this module deliberately does not do
//!
//! - It does not alter [`crate::ast::Pattern`] or [`crate::ast::Term`].
//! - It does not introduce a new IR or hashable form.
//! - It does not implement Maranget-style decision trees; the rewrite
//! is a literal-translation chain that the existing single-level
//! match codegen already handles.
use crate::ast::*;
use std::collections::BTreeSet;
/// Rewrite `m` so that every [`Pattern::Ctor`] sub-pattern is a
/// [`Pattern::Var`] or [`Pattern::Wild`] (i.e. flat).
///
/// Pure / total: returns a new [`Module`]; does not mutate `m`.
/// Idempotent: a module that is already flat is returned as-is
/// modulo cloning.
pub fn desugar_module(m: &Module) -> Module {
let mut used: BTreeSet<String> = BTreeSet::new();
for def in &m.defs {
match def {
Def::Fn(f) => collect_used_in_term(&f.body, &mut used),
Def::Const(c) => collect_used_in_term(&c.value, &mut used),
Def::Type(_) => {}
}
}
let mut d = Desugarer { counter: 0, used };
let mut out = m.clone();
for def in &mut out.defs {
match def {
Def::Fn(f) => f.body = d.desugar_term(&f.body),
Def::Const(c) => c.value = d.desugar_term(&c.value),
Def::Type(_) => {}
}
}
out
}
/// Walks a term and inserts every [`Term::Var.name`] and every
/// [`Pattern::Var.name`] into `used`. Used to seed
/// [`Desugarer::fresh`] so a generated name `$mp_N` cannot shadow a
/// source-level identifier.
fn collect_used_in_term(t: &Term, used: &mut BTreeSet<String>) {
match t {
Term::Lit { .. } => {}
Term::Var { name } => {
used.insert(name.clone());
}
Term::App { callee, args, .. } => {
collect_used_in_term(callee, used);
for a in args {
collect_used_in_term(a, used);
}
}
Term::Let { name, value, body } => {
used.insert(name.clone());
collect_used_in_term(value, used);
collect_used_in_term(body, used);
}
Term::If { cond, then, else_ } => {
collect_used_in_term(cond, used);
collect_used_in_term(then, used);
collect_used_in_term(else_, used);
}
Term::Do { args, .. } => {
for a in args {
collect_used_in_term(a, used);
}
}
Term::Ctor { args, .. } => {
for a in args {
collect_used_in_term(a, used);
}
}
Term::Match { scrutinee, arms } => {
collect_used_in_term(scrutinee, used);
for arm in arms {
collect_used_in_pattern(&arm.pat, used);
collect_used_in_term(&arm.body, used);
}
}
Term::Lam { params, body, .. } => {
for p in params {
used.insert(p.clone());
}
collect_used_in_term(body, used);
}
Term::Seq { lhs, rhs } => {
collect_used_in_term(lhs, used);
collect_used_in_term(rhs, used);
}
}
}
/// Walks a pattern and inserts every [`Pattern::Var.name`] into `used`.
fn collect_used_in_pattern(p: &Pattern, used: &mut BTreeSet<String>) {
match p {
Pattern::Wild => {}
Pattern::Var { name } => {
used.insert(name.clone());
}
Pattern::Lit { .. } => {}
Pattern::Ctor { fields, .. } => {
for sub in fields {
collect_used_in_pattern(sub, used);
}
}
}
}
/// State carried across a module-wide desugar pass.
///
/// `counter` is incremented for every fresh-name request; `used`
/// contains every source-level identifier (var-bind or var-reference)
/// in the module so that [`fresh`](Self::fresh) cannot shadow one.
struct Desugarer {
counter: u64,
used: BTreeSet<String>,
}
impl Desugarer {
/// Returns a name of the form `$mp_N` not present in `used`.
/// Increments the counter until it lands on a free name; the
/// generated name itself is added to `used` so a subsequent call
/// returns a distinct one.
fn fresh(&mut self) -> String {
loop {
let n = format!("$mp_{}", self.counter);
self.counter += 1;
if !self.used.contains(&n) {
self.used.insert(n.clone());
return n;
}
}
}
/// Recursively rewrites `t`: descends into every child first
/// (bottom-up), then dispatches a [`Term::Match`] to
/// [`desugar_match`](Self::desugar_match).
fn desugar_term(&mut self, t: &Term) -> Term {
match t {
Term::Lit { .. } | Term::Var { .. } => t.clone(),
Term::App { callee, args, tail } => Term::App {
callee: Box::new(self.desugar_term(callee)),
args: args.iter().map(|a| self.desugar_term(a)).collect(),
tail: *tail,
},
Term::Let { name, value, body } => Term::Let {
name: name.clone(),
value: Box::new(self.desugar_term(value)),
body: Box::new(self.desugar_term(body)),
},
Term::If { cond, then, else_ } => Term::If {
cond: Box::new(self.desugar_term(cond)),
then: Box::new(self.desugar_term(then)),
else_: Box::new(self.desugar_term(else_)),
},
Term::Do { op, args, tail } => Term::Do {
op: op.clone(),
args: args.iter().map(|a| self.desugar_term(a)).collect(),
tail: *tail,
},
Term::Ctor { type_name, ctor, args } => Term::Ctor {
type_name: type_name.clone(),
ctor: ctor.clone(),
args: args.iter().map(|a| self.desugar_term(a)).collect(),
},
Term::Match { scrutinee, arms } => {
// Recurse into children first (bottom-up).
let scrutinee = self.desugar_term(scrutinee);
let arms: Vec<Arm> = arms
.iter()
.map(|a| Arm {
pat: a.pat.clone(),
body: self.desugar_term(&a.body),
})
.collect();
self.desugar_match(scrutinee, arms)
}
Term::Lam {
params,
param_tys,
ret_ty,
effects,
body,
} => Term::Lam {
params: params.clone(),
param_tys: param_tys.clone(),
ret_ty: ret_ty.clone(),
effects: effects.clone(),
body: Box::new(self.desugar_term(body)),
},
Term::Seq { lhs, rhs } => Term::Seq {
lhs: Box::new(self.desugar_term(lhs)),
rhs: Box::new(self.desugar_term(rhs)),
},
}
}
/// Lowers a `match` whose children have already been desugared.
///
/// If every arm is already flat ([`is_flat`] returns true), the
/// match is reconstructed unchanged (no let-binding or chain
/// allocation overhead). Otherwise the scrutinee is let-bound to
/// a fresh `$mp_N` and the arms are translated to a chain of
/// single-level matches over that variable.
fn desugar_match(&mut self, scrutinee: Term, arms: Vec<Arm>) -> Term {
if arms.iter().all(|a| is_flat(&a.pat)) {
return Term::Match {
scrutinee: Box::new(scrutinee),
arms,
};
}
let s = self.fresh();
let s_var = Term::Var { name: s.clone() };
// Document: `default` is unreachable for valid programs because
// the typechecker requires a catch-all arm (`pat-wild` or
// exhaustive ctor coverage). It only exists to give
// `build_chain` a well-typed terminator.
let default = Term::Lit { lit: Literal::Unit };
let chain = self.build_chain(&s_var, &arms, &default);
Term::Let {
name: s,
value: Box::new(scrutinee),
body: Box::new(chain),
}
}
/// Recursively builds a chain of single-arm matches with a shared
/// fall-through. Empty arms ⇒ `default`; otherwise the first arm
/// is desugared with the rest of the chain as its fall-through.
fn build_chain(&mut self, s_var: &Term, arms: &[Arm], default: &Term) -> Term {
if arms.is_empty() {
return default.clone();
}
let head = &arms[0];
let rest = &arms[1..];
let fall_k = self.build_chain(s_var, rest, default);
self.desugar_one_arm(s_var, head, fall_k)
}
/// Lowers one arm into a term. Wild/Var arms drop the chain (the
/// arm matches everything); Lit and Ctor arms emit a `Term::Match`
/// with the desugared head pattern as the first arm and a
/// wildcard fall-through to `fall_k`.
fn desugar_one_arm(&mut self, s_var: &Term, arm: &Arm, fall_k: Term) -> Term {
match &arm.pat {
Pattern::Wild => arm.body.clone(),
Pattern::Var { name } => Term::Let {
name: name.clone(),
value: Box::new(s_var.clone()),
body: Box::new(arm.body.clone()),
},
Pattern::Lit { .. } => Term::Match {
scrutinee: Box::new(s_var.clone()),
arms: vec![
Arm {
pat: arm.pat.clone(),
body: arm.body.clone(),
},
Arm {
pat: Pattern::Wild,
body: fall_k,
},
],
},
Pattern::Ctor { ctor, fields } => {
// Lift each field to a fresh var; build the flat outer
// pattern, then walk right-to-left wrapping each inner
// sub-pattern via `wrap_sub` so the deepest field is
// matched first inside-out.
let fresh_vars: Vec<String> = fields.iter().map(|_| self.fresh()).collect();
let flat_fields: Vec<Pattern> = fresh_vars
.iter()
.map(|n| Pattern::Var { name: n.clone() })
.collect();
let mut inner = arm.body.clone();
for (sub, fv) in fields.iter().zip(fresh_vars.iter()).rev() {
inner = self.wrap_sub(fv, sub, inner, &fall_k);
}
Term::Match {
scrutinee: Box::new(s_var.clone()),
arms: vec![
Arm {
pat: Pattern::Ctor {
ctor: ctor.clone(),
fields: flat_fields,
},
body: inner,
},
Arm {
pat: Pattern::Wild,
body: fall_k,
},
],
}
}
}
}
/// Wraps `body` so that it only runs when the value bound to `fv`
/// matches `sub`. For `Var` / `Wild` the wrap is a let-bind / no-op;
/// for nested `Ctor` / `Lit` the wrap is a recursive
/// [`desugar_match`](Self::desugar_match) — that recursion is what
/// flattens arbitrarily-nested patterns.
fn wrap_sub(&mut self, fv: &str, sub: &Pattern, body: Term, fall_k: &Term) -> Term {
match sub {
Pattern::Wild => body,
Pattern::Var { name } => Term::Let {
name: name.clone(),
value: Box::new(Term::Var {
name: fv.to_string(),
}),
body: Box::new(body),
},
Pattern::Ctor { .. } | Pattern::Lit { .. } => self.desugar_match(
Term::Var {
name: fv.to_string(),
},
vec![
Arm {
pat: sub.clone(),
body,
},
Arm {
pat: Pattern::Wild,
body: fall_k.clone(),
},
],
),
}
}
}
/// True iff `p` is fully shallow: a [`Pattern::Var`], [`Pattern::Wild`],
/// [`Pattern::Lit`], or a [`Pattern::Ctor`] all of whose fields are
/// `Var` or `Wild`. Used by [`Desugarer::desugar_match`] to skip the
/// let-binding and chain construction for already-flat matches.
fn is_flat(p: &Pattern) -> bool {
match p {
Pattern::Wild | Pattern::Var { .. } | Pattern::Lit { .. } => true,
Pattern::Ctor { fields, .. } => fields
.iter()
.all(|f| matches!(f, Pattern::Var { .. } | Pattern::Wild)),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper: walk a term, return true iff any [`Pattern::Ctor`] in
/// any reachable [`Term::Match`] has a non-flat field.
fn any_nested_ctor(t: &Term) -> bool {
match t {
Term::Lit { .. } | Term::Var { .. } => false,
Term::App { callee, args, .. } => {
any_nested_ctor(callee) || args.iter().any(any_nested_ctor)
}
Term::Let { value, body, .. } => any_nested_ctor(value) || any_nested_ctor(body),
Term::If { cond, then, else_ } => {
any_nested_ctor(cond) || any_nested_ctor(then) || any_nested_ctor(else_)
}
Term::Do { args, .. } => args.iter().any(any_nested_ctor),
Term::Ctor { args, .. } => args.iter().any(any_nested_ctor),
Term::Match { scrutinee, arms } => {
if any_nested_ctor(scrutinee) {
return true;
}
for a in arms {
if !is_flat(&a.pat) {
return true;
}
if any_nested_ctor(&a.body) {
return true;
}
}
false
}
Term::Lam { body, .. } => any_nested_ctor(body),
Term::Seq { lhs, rhs } => any_nested_ctor(lhs) || any_nested_ctor(rhs),
}
}
/// Iter 16a: a `match` containing `(Cons a (Cons b _))` desugars to
/// a tree with no nested ctor sub-patterns. Property: the rewriter's
/// output never has a `Pattern::Ctor` whose field is itself a
/// `Pattern::Ctor`.
#[test]
fn nested_cons_cons_flattens() {
// Build: match xs of
// Cons a (Cons b _) -> ...
// _ -> ...
let body_match = Term::Match {
scrutinee: Box::new(Term::Var { name: "xs".into() }),
arms: vec![
Arm {
pat: Pattern::Ctor {
ctor: "Cons".into(),
fields: vec![
Pattern::Var { name: "a".into() },
Pattern::Ctor {
ctor: "Cons".into(),
fields: vec![
Pattern::Var { name: "b".into() },
Pattern::Wild,
],
},
],
},
body: Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "a".into() },
Term::Var { name: "b".into() },
],
tail: false,
},
},
Arm {
pat: Pattern::Wild,
body: Term::Lit { lit: Literal::Int { value: 0 } },
},
],
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
params: vec!["xs".into()],
body: body_match,
doc: None,
})],
};
let out = desugar_module(&m);
let body = match &out.defs[0] {
Def::Fn(f) => &f.body,
_ => unreachable!(),
};
assert!(
!any_nested_ctor(body),
"desugarer must remove every nested-ctor sub-pattern; got: {body:#?}"
);
}
/// A flat-only match round-trips structurally: there should be no
/// extra let-binding or chain wrapping.
#[test]
fn flat_match_is_unchanged_shape() {
let original = Term::Match {
scrutinee: Box::new(Term::Var { name: "xs".into() }),
arms: vec![
Arm {
pat: Pattern::Ctor {
ctor: "Nil".into(),
fields: vec![],
},
body: Term::Lit { lit: Literal::Int { value: 0 } },
},
Arm {
pat: Pattern::Ctor {
ctor: "Cons".into(),
fields: vec![
Pattern::Var { name: "h".into() },
Pattern::Var { name: "t".into() },
],
},
body: Term::Var { name: "h".into() },
},
],
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
params: vec!["xs".into()],
body: original.clone(),
doc: None,
})],
};
let out = desugar_module(&m);
let body = match &out.defs[0] {
Def::Fn(f) => f.body.clone(),
_ => unreachable!(),
};
// Already-flat match must round-trip with the same Match shape
// at the top level (no Let-wrap).
assert!(matches!(body, Term::Match { .. }));
}
}
+7
View File
@@ -16,6 +16,12 @@
//! - **Workspace loading** — see [`workspace`] and [`load_workspace`].
//! Resolves transitive imports of an entry module and returns a
//! [`Workspace`].
//! - **AST → AST desugaring** — see [`desugar`]. Pure rewrite that runs
//! *after* `load_module` and before the typechecker / codegen
//! consume a module. Iter 16a flattens nested constructor patterns
//! in `match` so downstream stages always see single-level patterns;
//! canonical bytes / hashes are unaffected because the pass runs in
//! memory only.
//!
//! ## Place in the pipeline
//!
@@ -50,6 +56,7 @@
pub mod ast;
pub mod canonical;
pub mod desugar;
pub mod hash;
pub mod pretty;
pub mod workspace;