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
+13
View File
@@ -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<e, a>`); first combinator with three type vars
/// (`either : (e -> c) -> (a -> c) -> Either<e, a> -> c`).
+54 -8
View File
@@ -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<Diagnostic> {
// 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<Diagnostic> {
/// 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<Diagnostic> {
// 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<CheckedModule> {
// 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<CheckedModule> {
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
+17
View File
@@ -129,6 +129,9 @@ type Result<T> = std::result::Result<T, CodegenError>;
/// 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<String> {
// 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<String> {
/// Use [`emit_ir`] for the single-file shortcut when there are no
/// imports.
pub fn lower_workspace(ws: &Workspace) -> Result<String> {
// 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<String, Vec<(String, String)>> = BTreeMap::new();
+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;
+98
View File
@@ -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<String>` 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.
+1
View File
@@ -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"}
+30
View File
@@ -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)))))))))