Iter 16c — literal patterns via desugar

Desugar `Pattern::Lit` (top-level and nested in Ctor) to
`Term::If { cond = (== sv lit), then = body, else_ = fall_k }`.
After 16c, no Pattern::Lit survives the desugar pass; codegen
and typechecker never see one. `is_flat` reclassifies Lit as
non-flat to force the chain machinery; new `build_eq` helper
constructs the equality test per literal kind.

Tests: 99 → 103 (+1 e2e lit_pat_demo, +3 desugar unit). Demo
fixture exercises top-level lit arms (classify) and nested
lit-in-Ctor (categorize_first) with a local IntList; output
100/200/999/-1/0/7.

Known limitations documented in DESIGN.md and JOURNAL: (a)
chain machinery's Unit terminator means non-Wild-terminated
exhaustive matches with lit arms still need a trailing `_`
catch-all to type-check; (b) `==` is currently Int-only, so
Bool/Str/Unit lit patterns desugar correctly but error at
typecheck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:47:21 +02:00
parent 8860600e37
commit 4683af6114
6 changed files with 467 additions and 34 deletions
+15
View File
@@ -960,3 +960,18 @@ fn check_json_unbound_var() {
"expected at least one error diagnostic with code unbound-var; got: {stdout}"
);
}
/// Iter 16c: literal patterns at top level and inside Ctor
/// sub-patterns. Property protected: the 16a desugar pass rewrites
/// every `Pattern::Lit` to a `Term::If` against the scrutinee/field
/// before either typecheck or codegen sees it. Without 16c, the
/// codegen would emit a Match arm with a Lit pattern and produce
/// either a wrong result or an `unreachable!` panic depending on
/// the path. The fixture exercises both sites: `classify` (top-
/// level lit arms) and `categorize_first` (nested lit-in-Ctor).
#[test]
fn lit_pat_demo() {
let stdout = build_and_run("lit_pat.ail.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["100", "200", "999", "-1", "0", "7"]);
}
+257 -22
View File
@@ -35,9 +35,9 @@
//! 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::Lit { lit }` → `Term::If { cond = (== s_var lit),
//! then = arm.body, else_ = fall_k }` (Iter 16c). After this
//! rewrite, no `Pattern::Lit` reaches typecheck or codegen.
//! - `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
@@ -47,7 +47,10 @@
//! `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),
//! - `Lit` (Iter 16c) → `Term::If { cond = (== fv lit), then = body,
//! else_ = fall_k }`. Same shape as the top-level case but on the
//! field-bound fresh variable.
//! - `Ctor` → recursively `desugar_match(Var(fv), [(sub, body),
//! (Wild, fall_k)])`. The recursion is what handles arbitrary
//! nesting depth (`Cons a (Cons b (Cons c _))`).
//!
@@ -497,19 +500,17 @@ impl Desugarer {
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::Lit { lit } => {
// Iter 16c: a top-level lit arm desugars to `if (== s_var lit)
// then arm.body else fall_k`. Eliminates `Pattern::Lit` from
// the desugared output entirely; codegen never sees it.
let cmp = build_eq(s_var.clone(), lit);
Term::If {
cond: Box::new(cmp),
then: Box::new(arm.body.clone()),
else_: Box::new(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
@@ -559,7 +560,19 @@ impl Desugarer {
}),
body: Box::new(body),
},
Pattern::Ctor { .. } | Pattern::Lit { .. } => self.desugar_match(
Pattern::Lit { lit } => {
// Iter 16c: a lit sub-pattern desugars to `if (== fv lit) body
// else fall_k`. Same shape as the top-level case, but on the
// field-bound fresh variable rather than the original
// scrutinee.
let cmp = build_eq(Term::Var { name: fv.to_string() }, lit);
Term::If {
cond: Box::new(cmp),
then: Box::new(body),
else_: Box::new(fall_k.clone()),
}
}
Pattern::Ctor { .. } => self.desugar_match(
Term::Var {
name: fv.to_string(),
},
@@ -579,18 +592,51 @@ impl Desugarer {
}
/// 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.
/// 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.
///
/// Iter 16c: [`Pattern::Lit`] is **not** flat — it always desugars to a
/// [`Term::If`] via [`build_eq`], so it must take the chain path even
/// when no other arm needs flattening. Otherwise the early-return in
/// [`Desugarer::desugar_match`] would leak a `Pattern::Lit` arm to
/// typecheck/codegen, which the codegen rejects with an internal error.
fn is_flat(p: &Pattern) -> bool {
match p {
Pattern::Wild | Pattern::Var { .. } | Pattern::Lit { .. } => true,
Pattern::Wild | Pattern::Var { .. } => true,
// Iter 16c: lit patterns are not flat; they desugar to If.
Pattern::Lit { .. } => false,
Pattern::Ctor { fields, .. } => fields
.iter()
.all(|f| matches!(f, Pattern::Var { .. } | Pattern::Wild)),
}
}
/// Iter 16c: Build an equality-test term for a lit pattern. The shape is
/// `(app == scrutinee lit_term)` for Int/Bool/Str. Unit literals are
/// degenerate — every Unit value is equal — so an emitted `true` literal
/// stands in.
///
/// Note: as of Iter 16c, the `==` builtin is typed `(Int, Int) -> Bool`
/// only. Bool- and Str-lit patterns therefore reach typecheck as a
/// type error rather than a clean rewrite. They remain authorable at the
/// AST level (the desugar never refuses) but are not yet usable end-to-
/// end. Lifting that gate is bound to extending `==` to those types.
fn build_eq(scrutinee: Term, lit: &Literal) -> Term {
match lit {
Literal::Unit => Term::Lit {
lit: Literal::Bool { value: true },
},
Literal::Int { .. } | Literal::Bool { .. } | Literal::Str { .. } => Term::App {
callee: Box::new(Term::Var {
name: "==".to_string(),
}),
args: vec![scrutinee, Term::Lit { lit: lit.clone() }],
tail: false,
},
}
}
/// Iter 16b.1: collect free variable names of `t`, with `bound` being
/// the set of names lexically bound at this point. A
/// [`Term::Var`] `{ name }` is "free" iff `name` is not in `bound`.
@@ -1103,4 +1149,193 @@ mod tests {
};
let _ = desugar_module(&m);
}
/// Iter 16c: walks a term, returns true iff any [`Pattern::Lit`] is
/// reachable in any [`Term::Match`] arm. After 16c desugaring, the
/// invariant is `false` for every desugared output — `Pattern::Lit`
/// is gone before typecheck/codegen runs.
fn any_lit_pattern(t: &Term) -> bool {
fn pat_has_lit(p: &Pattern) -> bool {
match p {
Pattern::Wild | Pattern::Var { .. } => false,
Pattern::Lit { .. } => true,
Pattern::Ctor { fields, .. } => fields.iter().any(pat_has_lit),
}
}
match t {
Term::Lit { .. } | Term::Var { .. } => false,
Term::App { callee, args, .. } => {
any_lit_pattern(callee) || args.iter().any(any_lit_pattern)
}
Term::Let { value, body, .. } => any_lit_pattern(value) || any_lit_pattern(body),
Term::If { cond, then, else_ } => {
any_lit_pattern(cond) || any_lit_pattern(then) || any_lit_pattern(else_)
}
Term::Do { args, .. } => args.iter().any(any_lit_pattern),
Term::Ctor { args, .. } => args.iter().any(any_lit_pattern),
Term::Match { scrutinee, arms } => {
if any_lit_pattern(scrutinee) {
return true;
}
for a in arms {
if pat_has_lit(&a.pat) {
return true;
}
if any_lit_pattern(&a.body) {
return true;
}
}
false
}
Term::Lam { body, .. } => any_lit_pattern(body),
Term::Seq { lhs, rhs } => any_lit_pattern(lhs) || any_lit_pattern(rhs),
Term::LetRec { body, in_term, .. } => {
any_lit_pattern(body) || any_lit_pattern(in_term)
}
}
}
/// Iter 16c: a top-level lit arm desugars to a `Term::If` whose
/// condition is `(== s_var lit)`. The desugared body must contain
/// no `Pattern::Lit` anywhere.
#[test]
fn top_level_lit_desugars_to_if() {
// (match n
// (case (pat-lit 0) 100)
// (case _ 999))
let body_match = Term::Match {
scrutinee: Box::new(Term::Var { name: "n".into() }),
arms: vec![
Arm {
pat: Pattern::Lit {
lit: Literal::Int { value: 0 },
},
body: Term::Lit {
lit: Literal::Int { value: 100 },
},
},
Arm {
pat: Pattern::Wild,
body: Term::Lit {
lit: Literal::Int { value: 999 },
},
},
],
};
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!["n".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_lit_pattern(body),
"desugarer must remove every Pattern::Lit from the term tree; got: {body:#?}"
);
// The desugar wraps the match in `let $mp_N = scrutinee in <chain>`,
// and the chain head must be a `Term::If` (the lit arm).
let chain = match body {
Term::Let { body, .. } => body.as_ref(),
other => panic!("expected outer Let from chain machinery, got {other:?}"),
};
assert!(
matches!(chain, Term::If { .. }),
"expected Term::If at the chain head, got {chain:?}"
);
}
/// Iter 16c: a `(pat-ctor Cons (pat-lit 0) _)` sub-pattern desugars
/// to a tree with no `Pattern::Lit` anywhere — the lit is rewritten
/// to a `Term::If` against the field-bound fresh variable.
#[test]
fn nested_lit_in_ctor_desugars_to_if() {
// (match xs
// (case (pat-ctor Cons (pat-lit 0) _) 0)
// (case _ -1))
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::Lit {
lit: Literal::Int { value: 0 },
},
Pattern::Wild,
],
},
body: Term::Lit {
lit: Literal::Int { value: 0 },
},
},
Arm {
pat: Pattern::Wild,
body: Term::Lit {
lit: Literal::Int { value: -1 },
},
},
],
};
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_lit_pattern(body),
"desugarer must remove every Pattern::Lit from the term tree; got: {body:#?}"
);
assert!(
!any_nested_ctor(body),
"desugarer must also produce no nested ctor sub-patterns; got: {body:#?}"
);
}
/// Iter 16c regression guard: `is_flat` must classify
/// `Pattern::Lit` as **not** flat, so the chain machinery in
/// [`Desugarer::desugar_match`] is always invoked for lit-arms
/// (rather than the early-return that would leak the lit pattern
/// through to typecheck/codegen).
#[test]
fn flat_arm_with_lit_is_no_longer_flat() {
let p = Pattern::Lit {
lit: Literal::Int { value: 0 },
};
assert!(
!is_flat(&p),
"Pattern::Lit must be classified as non-flat after 16c"
);
}
}