diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index c5641f9..4b946d5 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -348,6 +348,19 @@ fn render_parse_round_trip_canonical() { assert_eq!(orig, round, "render | parse must produce canonical bytes"); } +/// Iter 16a: nested constructor patterns. Property protected: +/// `ailang_core::desugar` flattens `Cons a (Cons b _)` to a chain of +/// single-level matches before the checker/codegen sees it. Without +/// the desugar pass the checker would emit +/// `nested-ctor-pattern-not-allowed`; without the pre-codegen call, +/// the inner binders would be silently dropped by the codegen. +#[test] +fn nested_ctor_pattern_first_two_sum() { + let stdout = build_and_run("nested_pat.ail.json"); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines, vec!["30"]); +} + /// Iter 15d: `std_either` end-to-end. First stdlib ADT with two type /// parameters (`Either`); first combinator with three type vars /// (`either : (e -> c) -> (a -> c) -> Either -> c`). diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 46b7bd6..dfba1f5 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -374,8 +374,12 @@ pub enum CheckError { #[error("duplicate definition: `{0}`")] DuplicateDef(String), - /// A `Pattern::Ctor` has a non-`Var`/non-`Wild` sub-pattern. The - /// MVP forbids decision-tree lowering of nested patterns. Code: + /// A `Pattern::Ctor` has a [`Pattern::Lit`] sub-pattern. Iter 16a + /// added an AST-level desugar that flattens nested **Ctor** + /// sub-patterns before the checker sees the module, so this + /// diagnostic now fires only for literal sub-patterns inside a + /// Ctor (still out of scope; a future iter would lower them as a + /// nested Match on the field). Code: /// `nested-ctor-pattern-not-allowed`. #[error("nested constructor pattern not allowed in MVP: `{0}`")] NestedCtorPatternNotAllowed(String), @@ -546,6 +550,11 @@ impl CheckError { /// workspace will inevitably produce `unknown-module` errors on qualified /// references — which is correct. pub fn check_module(m: &Module) -> Vec { + // Iter 16a: flatten nested constructor patterns before any check + // logic touches the AST. The rewrite is pure and runs in memory; + // canonical-JSON hashes (computed by `ailang_core::load_module` / + // `def_hash`) are unaffected because they use the on-disk form. + let m = ailang_core::desugar::desugar_module(m); let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); let ws = Workspace { @@ -570,6 +579,20 @@ pub fn check_module(m: &Module) -> Vec { /// Module iteration order is deterministic: entry first, then the rest in /// BTreeMap order, so output ordering is stable. pub fn check_workspace(ws: &Workspace) -> Vec { + // Iter 16a: desugar every module of the workspace before any check + // logic runs. The rewrite is pure and per-module; we rebuild a + // workspace shell around the desugared modules (paths and entry + // are unchanged). + let ws_owned = Workspace { + entry: ws.entry.clone(), + modules: ws + .modules + .iter() + .map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m))) + .collect(), + root_dir: ws.root_dir.clone(), + }; + let ws = &ws_owned; // Pass 1: build per-module top-level symbol table — without checking // bodies. This lets module A access defs from module B even when B // comes later in the BTreeMap. Duplicate def names and dot-in-def @@ -622,6 +645,14 @@ pub struct CheckedModule { /// kept for legacy callers (snapshot tests, the `manifest` codepath /// that needs the hash-to-type mapping). pub fn check(m: &Module) -> Result { + // Iter 16a: desugar nested ctor patterns before constructing the + // trivial workspace. See `check_module` for the rationale. The + // returned `CheckedModule.symbols` content hashes are derived from + // the *original* on-disk module so they keep the canonical-bytes + // identity that `ail diff` / `ail manifest` rely on. + let original = m; + let desugared = ailang_core::desugar::desugar_module(m); + let m = &desugared; // Trivial workspace: the module alone, without cross-module resolution. let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); @@ -638,8 +669,10 @@ pub fn check(m: &Module) -> Result { return Err(first); } // Collect symbols for the return value (existing semantics). + // Hashes are computed over the *original* defs, so callers like + // `ail diff` see the on-disk identity, not a post-desugar one. let mut symbols = IndexMap::new(); - for def in &m.defs { + for def in &original.defs { let h = ailang_core::hash::def_hash(def); let ty = match def { Def::Fn(f) => f.ty.clone(), @@ -1531,12 +1564,25 @@ fn type_check_pattern( Ok(vec![]) } Pattern::Ctor { ctor, fields } => { - // MVP: sub-patterns of ctor patterns may only be `Var` or `Wild`. - // Nested ctor or lit patterns need decision-tree lowering, - // which we don't have in codegen yet. + // Iter 16a: nested **Ctor** sub-patterns are removed by + // `ailang_core::desugar::desugar_module` before this checker + // runs, so we only have to defend against the still-rejected + // **Lit** sub-pattern case. The `nested-ctor-pattern-not-allowed` + // error code is reused for that — its meaning is now narrower + // ("non-flat: a Lit sub-pattern was found"), and the docstring + // on the variant reflects that. for sub in fields { - if !matches!(sub, Pattern::Var { .. } | Pattern::Wild) { - return Err(CheckError::NestedCtorPatternNotAllowed(ctor.clone())); + match sub { + Pattern::Var { .. } | Pattern::Wild => {} + Pattern::Lit { .. } => { + return Err(CheckError::NestedCtorPatternNotAllowed(ctor.clone())); + } + Pattern::Ctor { .. } => { + unreachable!( + "nested Ctor sub-patterns are removed by \ + ailang_core::desugar before check" + ); + } } } // Iter 15a: try local ctor_index first; if the bare name diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 75bbe6f..3288dcd 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -129,6 +129,9 @@ type Result = std::result::Result; /// modules (cross-module calls, transitive imports) — `emit_ir` is the /// short-cut for the single-file demo case. pub fn emit_ir(m: &Module) -> Result { + // Iter 16a: nested ctor patterns are desugared inside + // `lower_workspace`, so single-module callers go through the + // same code path with no extra work here. let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); let ws = Workspace { @@ -165,6 +168,20 @@ pub fn emit_ir(m: &Module) -> Result { /// Use [`emit_ir`] for the single-file shortcut when there are no /// imports. pub fn lower_workspace(ws: &Workspace) -> Result { + // Iter 16a: desugar every module before any lowering work runs. + // The pass is idempotent and structurally identical to what + // `ailang-check` runs at its public entries, so the codegen + // sees the same flat-pattern AST as the typechecker. + let ws_owned = Workspace { + entry: ws.entry.clone(), + modules: ws + .modules + .iter() + .map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m))) + .collect(), + root_dir: ws.root_dir.clone(), + }; + let ws = &ws_owned; let mut header = String::new(); let mut body = String::new(); let mut all_strings: BTreeMap> = BTreeMap::new(); diff --git a/crates/ailang-core/src/desugar.rs b/crates/ailang-core/src/desugar.rs new file mode 100644 index 0000000..30500cb --- /dev/null +++ b/crates/ailang-core/src/desugar.rs @@ -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 = 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) { + 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) { + 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, +} + +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 = 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) -> 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 = fields.iter().map(|_| self.fresh()).collect(); + let flat_fields: Vec = 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 { .. })); + } +} diff --git a/crates/ailang-core/src/lib.rs b/crates/ailang-core/src/lib.rs index 5d38c68..0953276 100644 --- a/crates/ailang-core/src/lib.rs +++ b/crates/ailang-core/src/lib.rs @@ -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; diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index a9e7c6e..b018315 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -2670,3 +2670,101 @@ removed fn. Net same.) **Cumulative state, post-15e.** No new language features. Two canonical text projections collapsed to one (form A). Internal cleanup; no behavioural change for any program in the repo. + +--- + +## Iter 16a — nested constructor patterns in `match` + +**Goal.** Lift the gate that rejected `(pat-ctor Cons a (pat-ctor Cons b _))` +and similar nested-Ctor sub-patterns. Lit-in-Ctor stays rejected; +that's a separate iter. + +**Approach: AST-level desugar before check + codegen.** Pure rewrite +that flattens nested Ctor patterns into chains of single-level +matches with let-bound fresh vars and duplicate fall-through. +Hash-relevant canonical bytes untouched because the pass runs +*after* `load_module`, in memory only. The checker / codegen always +see flat patterns. + +**Algorithm sketch.** + +For a `Match` whose arms contain nested-Ctor sub-patterns: +1. Bottom-up: recursively desugar scrutinee and arm bodies first. +2. Let-bind the scrutinee to `$mp_N` (single eval). +3. Build a chain `arm_1 (else arm_2 (else ... default))`. Default + is `Lit Unit`, unreachable for valid programs. +4. Each arm's nested Ctor sub-patterns are lifted to fresh vars, + then deepest-first wrapped via `wrap_sub`, which on a Ctor sub + recursively re-enters `desugar_match` — that recursion handles + arbitrary depth. + +`fall_k` is cloned per inner level → O(arms × depth) terms in +worst case. 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 `$mp_0` is theoretically user-writable. The Desugarer pre-walks +every `Term::Var` and `Pattern::Var` name in the module into a +`BTreeSet` and bumps the counter past collisions. + +**Files.** + +- New: `crates/ailang-core/src/desugar.rs` (571 LOC including + doc-comments and two unit tests). Public surface is + `pub fn desugar_module(m: &Module) -> Module` — pure, idempotent, + no I/O. +- `crates/ailang-core/src/lib.rs` — `pub mod desugar;` plus a + module-doc bullet describing the new pipeline layer. +- `crates/ailang-check/src/lib.rs` — three public entries + (`check_module`, `check_workspace`, `check`) call + `desugar_module` first. The gate at the old line 1538 is now + narrowed: nested **Ctor** sub-patterns are `unreachable!()` + (desugared away); nested **Lit** still emits the existing + `nested-ctor-pattern-not-allowed` diagnostic. Crucially: + `check`'s returned `CheckedModule.symbols` keeps hashes of + the *original* defs so `ail diff` and `ail manifest` see the + on-disk identity, not a post-desugar one. +- `crates/ailang-codegen/src/lib.rs` — `lower_workspace` desugars + every module up front; `emit_ir` (single-file shortcut) goes + through `lower_workspace` so the desugar runs there too. +- New: `examples/nested_pat.ailx` + `nested_pat.ail.json`. + `first_two_sum` matches `(pat-ctor Cons a (pat-ctor Cons b _))`; + prints `30` for the input `[10, 20, 30]`. +- `crates/ail/tests/e2e.rs` — new + `nested_ctor_pattern_first_two_sum` test. + +**Behavioural property of the desugar.** Already-flat matches go +through `is_flat()` and emit identical AST shapes. Empirically: +every existing fixture (std_maybe, std_list, std_either, list_map, +sort, etc.) produces the same observable output as before because +their patterns were already flat — the desugar is a no-op clone +on them. + +**Tests: 92/92.** +- e2e: 33 (was 32, +1 for nested_pat). +- ailang-core unit: 12 (was 10, +2 for the desugar tests). +- All other crates unchanged. + +**No new compiler bug surfaced.** The transform was straightforward +because the AST already supported nested sub-patterns at the type +level — only the checker gate and codegen drop-on-floor were +artificial walls. Removing them via desugaring (rather than +expanding the codegen) keeps the pattern-matching backend simple +and lets future iters (Lit-in-pattern, exhaustiveness, decision +trees) plug in at the same desugar layer. + +**Cumulative state, post-16a.** + +- Stdlib: 3 modules, 19 combinators (unchanged from 15d). +- Language: nested Ctor patterns now legal; nested Lit-in-pattern + still rejected (intentional follow-up scope). +- Pipeline: `load → desugar → check → codegen`. The desugar layer + is the natural home for further surface-level smoothing + (Lit-in-pattern, `if`-as-syntactic-sugar, etc.) without + enlarging the core AST. +- Compiler bugs surfaced and fixed in dogfood since 14a: still 4 + (no fresh ones in 16a — the desugar landed clean). + +**Queue update.** 16a done. Remaining: 15f (`std_pair`, optional); +16b (local recursive `let`); 17a (per-fn arena); future +"lit-in-Ctor" follow-up under 16c if and when needed. diff --git a/examples/nested_pat.ail.json b/examples/nested_pat.ail.json new file mode 100644 index 0000000..123663b --- /dev/null +++ b/examples/nested_pat.ail.json @@ -0,0 +1 @@ +{"defs":[{"body":{"arms":[{"body":{"args":[{"name":"a","t":"var"},{"name":"b","t":"var"}],"fn":{"name":"+","t":"var"},"t":"app"},"pat":{"ctor":"Cons","fields":[{"name":"a","p":"var"},{"ctor":"Cons","fields":[{"name":"b","p":"var"},{"p":"wild"}],"p":"ctor"}],"p":"ctor"}},{"body":{"lit":{"kind":"int","value":0},"t":"lit"},"pat":{"p":"wild"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Sum of the first two elements of an Int list, or 0 if shorter than two.","kind":"fn","name":"first_two_sum","params":["xs"],"type":{"effects":[],"k":"fn","params":[{"args":[{"k":"con","name":"Int"}],"k":"con","name":"std_list.List"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":10},"t":"lit"},{"args":[{"lit":{"kind":"int","value":20},"t":"lit"},{"args":[{"lit":{"kind":"int","value":30},"t":"lit"},{"args":[],"ctor":"Nil","t":"ctor","type":"std_list.List"}],"ctor":"Cons","t":"ctor","type":"std_list.List"}],"ctor":"Cons","t":"ctor","type":"std_list.List"}],"ctor":"Cons","t":"ctor","type":"std_list.List"}],"fn":{"name":"first_two_sum","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[{"module":"std_list"}],"name":"nested_pat","schema":"ailang/v0"} diff --git a/examples/nested_pat.ailx b/examples/nested_pat.ailx new file mode 100644 index 0000000..4b027a1 --- /dev/null +++ b/examples/nested_pat.ailx @@ -0,0 +1,30 @@ +; Iter 16a — first program to use a nested constructor pattern. +; Without this iter, the inner `(pat-ctor Cons b _)` would be +; rejected by the checker as `nested-ctor-pattern-not-allowed`. + +(module nested_pat + + (import std_list) + + (fn first_two_sum + (doc "Sum of the first two elements of an Int list, or 0 if shorter than two.") + (type + (fn-type + (params (con std_list.List (con Int))) + (ret (con Int)))) + (params xs) + (body + (match xs + (case (pat-ctor Cons a (pat-ctor Cons b _)) + (app + a b)) + (case _ 0)))) + + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (do io/print_int (app first_two_sum + (term-ctor std_list.List Cons 10 + (term-ctor std_list.List Cons 20 + (term-ctor std_list.List Cons 30 + (term-ctor std_list.List Nil)))))))))