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:
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
@@ -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)))))))))
|
||||
Reference in New Issue
Block a user