Files
AILang/crates/ailang-core/src/desugar.rs
T
Brummel 9339279181 iter prep.3-kernel-tier-modules (DONE 9/9): kernel-tier modules + param-in + stub crate — closes #33
Terminal iteration of the kernel-extension-mechanics milestone. Ships
the four language-level mechanisms named in the spec's § Goal:
Module.kernel + TypeDef.param-in schema, their Form-A surface,
flag-driven kernel-tier auto-injection, and generic param-in checker
enforcement with a new diagnostic.

Schema (Tasks 1+2). Module gains a `kernel: bool` field
(skip_serializing_if = is_false), TypeDef gains a
`param_in: BTreeMap<String, BTreeSet<String>>` field
(skip-if-empty, kebab-renamed to "param-in"). Both fields are
strictly additive — every pre-existing fixture's canonical-JSON hash
is bit-stable except `prelude.ail`, which intentionally gains
`(kernel)`. The struct-literal sweep covered ~104 Module sites and
~35 TypeDef sites across the workspace; the additive serde-default
covers JSON deserialise paths, only Rust struct literals broke.

Form-A surface (Tasks 3+4). `(kernel)` is a bare module-header
attribute; `(param-in (a Int Float) (b Str))` is one outer
TypeDef-body clause carrying one or more inner var-lists (OQ1
decision — mirrors `(ctors …)`, one parser arm, deterministic
BTreeMap iteration). Both round-trip Form-A → JSON → Form-A
bit-identical.

Workspace-load migration (Task 5). The hardcoded `&["prelude"]`
literal at loader.rs:108 became a `modules.values().filter(|m|
m.kernel)` derivation; `parse_prelude()` injection stays because
the prelude has no on-disk manifest in user workspaces. Prelude
now carries `(kernel)` in its source, so the new filter picks it
up automatically. Code-path migration only — observable behaviour
is identical (prelude_free_fns.rs stays green). prelude hash
re-pinned (af372f28c726f29f) with Honesty-Rule provenance comment.
WorkspaceLoadError::ReservedModuleName diagnostic prose
repurposed: any built-in kernel module name is reserved
(currently prelude + kernel_stub), not specifically prelude. CLI
mapping at main.rs updated in lockstep.

Stub crate (Task 6). New `crates/ailang-kernel-stub/` is a
zero-dependency leaf crate carrying only `pub const STUB_AIL:
&str` with the Form-A source of the kernel_stub module (one
parametric TypeDef with param-in, one ctor). The parse hop —
`parse_kernel_stub()` — lives in ailang-surface next to
parse_prelude, keeping the crate-dependency graph acyclic
(`ailang-surface → ailang-kernel-stub → ailang-core`, no
back-edge). The stub is injected unconditionally in all builds as
the ratifying fixture for the kernel-extension mechanism; future
base extensions may add more or retire the stub. Drift-pinned by
`kernel_stub_module_round_trips`.

Checker (Task 7). New `CheckError::ParamNotInRestrictedSet`
variant + code() + ctx() arms + enforcement in
`check_type_well_formed`'s Type::Con arm — generic, data-driven
from the TypeDef, mentions no specific extension type. Two
in-source tests pin both the rejection (`Str` outside `{Int,
Float}`) and the acceptance (`Int` inside) paths.

Workspace-load integration tests (Task 8). New
`workspace_kernel.rs` integration-test crate with three tests:
auto-import without explicit `(import …)` declaration, two
kernel-tier modules co-load, explicit-import-overrides-auto-
import precedence preserved. Loader is import-tree-only so the
auto-import tests use a bridge module that brings the kernel
module into the workspace via the import graph — docstring
captures the reachability nuance for future readers.

Doc-state transitions (Task 9). INDEX.md kernel-extensions row
annotation transitions from "design accepted 2026-05-28; impl in
progress" to "mechanisms milestone closed 2026-05-28; raw-buf and
series milestones pending". Whitepaper STATUS + auto-import +
param-in sections transitioned forward→present for shipped
mechanisms; forward-tense survives only in sections describing
the still-pending raw-buf/series milestones (per Honesty-Rule).
data-model contract gains anchor blocks for both new schema
fields.

Side-effect: every binary's IR snapshot now contains ~52 lines
for `drop_kernel_stub_StubT` because the stub is auto-injected
into every workspace load. Snapshots refreshed; e2e expects 4
modules per workspace (prelude + kernel_stub + entry + zero or
more user modules) instead of the previous 3.

Plan defects scrubbed in the implementation (folded back into
the planner template via the planner's self-review checklist
next time): Task 4 sample test src used fictional
`(ctors (MkT a))` list form (project grammar is per-`(ctor MkT
a)`); Task 6 original wiring would have created a cycle
ailang-surface → ailang-kernel-stub → ailang-surface (inverted —
stub crate is zero-dep, parse hop lives in surface); Task 7 in-
source tests referenced a fictional `check_type_in_module`
helper (used the existing Workspace + check_workspace
convention); Task 8 first integration test expected loader to
auto-load kernel modules from disk (loader is import-tree-only;
tests use a bridge module).

Concern-5 fix folded in pre-commit: workspace.rs ReservedModuleName
doc-prose initially said "in test/dev builds" for kernel_stub —
but stub is unconditionally injected in all builds. Doc copy
tightened to present-state per Honesty-Rule.

Stats: 0 spec-review-loops, 0 quality-review-loops, 2 sweep-script
retries on Task 2 (brace-depth bug on nested vec![Ctor{…}],
recovered via per-file checkout + rewritten anchor-on-existing-
field sweep), 1 e2e-snapshot refresh on Task 6.
2026-05-28 18:43:42 +02:00

3130 lines
130 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 the
//! polymorphic bottom builtin `__unreachable__` (`forall a. a`)
//! — codegen lowers it to LLVM `unreachable`. Valid programs
//! never reach it because the checker requires exhaustiveness
//! (the catch-all arm dominates the chain). An earlier shape used
//! a `Unit` literal as the default, which forced any match whose
//! arms returned a non-Unit type to carry a synthetic `_` arm
//! dominating the terminator. The polymorphic `__unreachable__`
//! removes that workaround.
//! 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 { 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
//! `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`.
//! - `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 _))`).
//!
//! 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.
//!
//! ## Iter 16b.1: local recursive `let` (no-capture)
//!
//! [`Term::LetRec`] is a surface-only AST node introduced in 16b.1. The
//! desugar pass eliminates it before typecheck/codegen by **lifting**
//! the LetRec to a synthetic top-level fn in the same module. The
//! lifted name has the form `<hint>$lr_N`, fresh against both the
//! existing module-top-level def names and the `Desugarer::used` set.
//! Inside the LetRec's `body` and `in_term`, every reference to the
//! original local name is rewritten via [`subst_var`] to the lifted
//! name. The lifted [`FnDef`] is appended to the module's `defs` so
//! the typechecker / codegen see it as if it had been written
//! top-level by hand.
//!
//! Capture is **not** supported in 16b.1: if the LetRec's body's free
//! variables (excluding `{name} params`) intersect the enclosing
//! lexical scope (params of the surrounding fn, let-bound names, or
//! pattern-bound names), the desugar pass panics with a diagnostic
//! that points the author at 16b.2 (closure conversion). Free vars
//! that resolve to module-top-level def names, qualified import names
//! (containing `.`), effect-op names (containing `/`), and builtin
//! operators are all ignored — those reach a lifted top-level fn
//! through the same path they reach any other top-level fn.
//!
//! ## 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.
//! - It does not implement closure conversion for [`Term::LetRec`]
//! bodies that capture from the enclosing scope (queued as 16b.2).
use crate::ast::*;
use std::collections::{BTreeMap, BTreeSet};
/// per-name scope information used by the LetRec lifter.
/// `KnownType(t)` is set for fn-params and Lam-params, where the
/// type is statically declared. Other binder kinds use the
/// placeholder variants and the LetRec lifter rejects captures of
/// those names with a clear error pointing at the follow-up iter
/// that will lift the restriction.
#[derive(Debug, Clone)]
enum ScopeEntry {
/// Fn-param or Lam-param, type known at this point.
KnownType(Type),
/// Bound by `Term::Let` — value's type is inferred at
/// typecheck, unknown at desugar. Captures error → 16b.3.
/// Also used for fn-params of a `Type::Forall`-typed enclosing
/// fn, where the param types may mention outer type vars and
/// the lifted signature would need a synthesized `Forall` —
/// out of scope for 16b.2 (queued for 16b.6).
LetBound,
/// Bound by a `Term::Match` arm pattern — type requires
/// constructor-field substitution from the scrutinee. The
/// desugar pass cannot resolve it (the scrutinee's type may
/// only be known after inference); captures of this kind are
/// deferred to `ailang-check::lift_letrecs` (Iter 16b.4),
/// which uses `type_check_pattern_for_lift` to substitute the
/// scrutinee's type args into the matched ctor's declared
/// field types and extend the locals scope accordingly.
MatchArm,
/// Bound by a `Term::LetRec` (its own name) — fn-typed, but
/// the value flows through a recursive position; supported
/// for fn/Lam-param transitivity but not for nested-LetRec
/// mutual capture. The LetRec lifter rejects captures of a
/// name with this entry → 16b.7.
EnclosingLetRec,
}
/// Rewrite `m` so that every [`Pattern::Ctor`] sub-pattern is a
/// [`Pattern::Var`] or [`Pattern::Wild`] (i.e. flat) and every
/// [`Term::LetRec`] is replaced by a reference to a synthetic
/// top-level fn.
///
/// Pure / total: returns a new [`Module`]; does not mutate `m`.
/// Idempotent: a module that is already flat and LetRec-free 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) => {
used.insert(f.name.clone());
for p in &f.params {
used.insert(p.clone());
}
collect_used_in_term(&f.body, &mut used);
}
Def::Const(c) => {
used.insert(c.name.clone());
collect_used_in_term(&c.value, &mut used);
}
Def::Type(td) => {
used.insert(td.name.clone());
}
// class/instance defs are passthrough for the
// desugar pass — their bodies (default methods, instance
// method bodies) will be desugared once 22b.2 wires the
// class/instance arms into typecheck. For 22b.1 the names
// still go into `used` so the synthetic-name generator
// does not collide.
Def::Class(c) => {
used.insert(c.name.clone());
}
Def::Instance(i) => {
used.insert(i.class.clone());
}
}
}
// pre-collect every top-level def name. The LetRec
// lifter consults this set to (a) know whether a free var of a
// LetRec body resolves to a top-level def (no capture) and
// (b) keep its fresh-name generator from colliding with an
// existing def — including ones lifted earlier in the same pass.
let mut module_top_names: BTreeSet<String> = BTreeSet::new();
for def in &m.defs {
module_top_names.insert(def.name().to_string());
}
let mut d = Desugarer {
counter: 0,
used,
lifted: Vec::new(),
module_top_names,
current_def_forall_vars: Vec::new(),
};
let mut out = m.clone();
for def in &mut out.defs {
match def {
Def::Fn(f) => {
// Build initial scope from fn-params with their
// declared types (peeled out of `f.ty`).
//
// 16b.6: a `Type::Forall { vars, body: Fn(ptys, ...) }`
// enclosing fn is now supported. Its param types may
// mention `vars` — that's fine, the lifted fn becomes
// `Forall(vars, Fn(ptys ++ capture_tys, ...))` so the
// type vars are still bound at the lift site. Fn-params
// of a Forall enclosing fn therefore enter the scope as
// `KnownType`, not `LetBound` (the 16b.2 fallback was
// overly conservative). The enclosing fn's `Forall.vars`
// are stashed in `Desugarer.current_def_forall_vars` for
// the LetRec arm to read.
let mut scope: BTreeMap<String, ScopeEntry> = BTreeMap::new();
let inner_fn_ty: Option<&Type> = match &f.ty {
Type::Fn { .. } => Some(&f.ty),
Type::Forall { body, .. } => match body.as_ref() {
Type::Fn { .. } => Some(body.as_ref()),
_ => None,
},
_ => None,
};
let inner_fn_params: Option<&[Type]> = inner_fn_ty.and_then(|t| match t {
Type::Fn { params, .. } => Some(params.as_slice()),
_ => None,
});
match inner_fn_params {
Some(ptys) if ptys.len() == f.params.len() => {
for (p, pty) in f.params.iter().zip(ptys.iter()) {
scope.insert(p.clone(), ScopeEntry::KnownType(pty.clone()));
}
}
_ => {
// Defensive: malformed fn type (arity mismatch,
// non-Fn). Fall back to LetBound for every param
// so captures are rejected cleanly rather than
// silently mistyped.
for p in &f.params {
scope.insert(p.clone(), ScopeEntry::LetBound);
}
}
}
// stash the enclosing fn's Forall.vars (or
// empty for a mono enclosing fn) for the LetRec arm.
let saved = std::mem::take(&mut d.current_def_forall_vars);
d.current_def_forall_vars = match &f.ty {
Type::Forall { vars, .. } => vars.clone(),
_ => Vec::new(),
};
f.body = d.desugar_term(&f.body, &scope);
d.current_def_forall_vars = saved;
}
Def::Const(c) => {
let scope: BTreeMap<String, ScopeEntry> = BTreeMap::new();
c.value = d.desugar_term(&c.value, &scope);
}
Def::Type(_) => {}
// class/instance defs are not desugared yet.
// Their bodies (default methods, instance method bodies)
// will be processed once 22b.2 lands the class/instance
// arms in typecheck and 22b.3 in codegen.
Def::Class(_) | Def::Instance(_) => {}
}
}
// append every lifted fn to the desugared module.
// Order: original defs first, then lifts in the order they were
// produced by the bottom-up walk. Typecheck/codegen are order-
// insensitive at the def list level (they index by name), so this
// is purely cosmetic.
out.defs.extend(d.lifted);
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);
}
Term::LetRec { name, params, body, in_term, .. } => {
used.insert(name.clone());
for p in params {
used.insert(p.clone());
}
collect_used_in_term(body, used);
collect_used_in_term(in_term, used);
}
Term::Clone { value } => {
// identity for the used-name walk.
collect_used_in_term(value, used);
}
Term::ReuseAs { source, body } => {
// structural recursion through both children.
collect_used_in_term(source, used);
collect_used_in_term(body, used);
}
Term::Loop { binders, body } => {
for b in binders {
used.insert(b.name.clone());
collect_used_in_term(&b.init, used);
}
collect_used_in_term(body, used);
}
Term::Recur { args } => {
for a in args {
collect_used_in_term(a, used);
}
}
Term::New { args, .. } => {
for arg in args {
match arg {
NewArg::Value(v) => collect_used_in_term(v, used),
NewArg::Type(_) => {}
}
}
}
}
}
/// 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.
///
/// LetRec-lift fields:
/// - `lifted` accumulates synthetic top-level fns produced by
/// [`Term::LetRec`] desugaring. Appended to `Module.defs` once the
/// per-def walk finishes.
/// - `module_top_names` mirrors every name reachable as a top-level
/// def at this point in the pass (originals + already-lifted). The
/// capture analyser uses it to classify free vars; the fresh-name
/// generator [`fresh_lifted`](Self::fresh_lifted) consults it so
/// later lifts cannot collide with earlier ones.
///
/// Forall-tracking field:
/// - `current_def_forall_vars` carries the enclosing fn's
/// `Type::Forall.vars` while desugaring its body. Empty for
/// monomorphic enclosing fns. Read by the LetRec lifter to wrap
/// the synthetic lifted fn's signature in `Type::Forall` mirroring
/// the enclosing fn — so the lifted fn enters codegen's
/// monomorphisation queue at every call site of the enclosing fn,
/// specialising at the same type args as its host.
struct Desugarer {
counter: u64,
used: BTreeSet<String>,
lifted: Vec<Def>,
module_top_names: BTreeSet<String>,
/// the enclosing fn's Forall.vars (or empty if mono).
/// Reset on entry to each `Def::Fn`.
current_def_forall_vars: Vec<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;
}
}
}
/// returns a name of the form `<hint>$lr_N` not in
/// `used` and not in `module_top_names`. Bumps both sets so a
/// later lift cannot collide. The `hint` is the source-level
/// LetRec name; it makes lifted bindings traceable through the
/// pipeline (typecheck errors / IR mangling / panic messages).
fn fresh_lifted(&mut self, hint: &str) -> String {
let mut n = 0u64;
loop {
let candidate = format!("{hint}$lr_{n}");
n += 1;
if !self.used.contains(&candidate) && !self.module_top_names.contains(&candidate) {
self.used.insert(candidate.clone());
self.module_top_names.insert(candidate.clone());
return candidate;
}
}
}
/// Recursively rewrites `t`: descends into every child first
/// (bottom-up), then dispatches a [`Term::Match`] to
/// [`desugar_match`](Self::desugar_match) and a [`Term::LetRec`]
/// to the 16b.1 / 16b.2 lifter.
///
/// `scope` maps every name lexically bound at this point to its
/// [`ScopeEntry`] — initialised at the def boundary (fn-params
/// with `KnownType` for monomorphic enclosing fns; `LetBound`
/// for `Type::Forall`-quantified ones; empty for [`Def::Const`])
/// and extended locally by every binder ([`Term::Let`] →
/// `LetBound`; [`Term::Lam`] → `KnownType`; [`Term::Match`] arm
/// pattern bindings → `MatchArm`; [`Term::LetRec`] → its own
/// name as `EnclosingLetRec`, params as `KnownType`). Used by
/// the LetRec lifter to decide between (a) the 16b.2 fast path
/// (all KnownType captures → lift here), (b) the 16b.3/16b.4
/// defer path (any LetBound or MatchArm capture → leave the
/// LetRec in place for `ailang-check::lift_letrecs`), and (c)
/// the 16b.7 panic path (EnclosingLetRec capture).
fn desugar_term(&mut self, t: &Term, scope: &BTreeMap<String, ScopeEntry>) -> Term {
match t {
Term::Lit { .. } | Term::Var { .. } => t.clone(),
Term::App { callee, args, tail } => Term::App {
callee: Box::new(self.desugar_term(callee, scope)),
args: args.iter().map(|a| self.desugar_term(a, scope)).collect(),
tail: *tail,
},
Term::Let { name, value, body } => {
let v = self.desugar_term(value, scope);
let mut inner = scope.clone();
inner.insert(name.clone(), ScopeEntry::LetBound);
let b = self.desugar_term(body, &inner);
Term::Let {
name: name.clone(),
value: Box::new(v),
body: Box::new(b),
}
}
Term::If { cond, then, else_ } => Term::If {
cond: Box::new(self.desugar_term(cond, scope)),
then: Box::new(self.desugar_term(then, scope)),
else_: Box::new(self.desugar_term(else_, scope)),
},
Term::Do { op, args, tail } => Term::Do {
op: op.clone(),
args: args.iter().map(|a| self.desugar_term(a, scope)).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, scope)).collect(),
},
Term::Match { scrutinee, arms } => {
// Recurse into children first (bottom-up).
let scrutinee = self.desugar_term(scrutinee, scope);
let arms: Vec<Arm> = arms
.iter()
.map(|a| {
let mut inner = scope.clone();
let mut pat_binds: BTreeSet<String> = BTreeSet::new();
pattern_binds(&a.pat, &mut pat_binds);
for n in pat_binds {
inner.insert(n, ScopeEntry::MatchArm);
}
Arm {
pat: a.pat.clone(),
body: self.desugar_term(&a.body, &inner),
}
})
.collect();
self.desugar_match(scrutinee, arms)
}
Term::Lam {
params,
param_tys,
ret_ty,
effects,
body,
} => {
let mut inner = scope.clone();
for (p, pty) in params.iter().zip(param_tys.iter()) {
inner.insert(p.clone(), ScopeEntry::KnownType(pty.clone()));
}
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, &inner)),
}
}
Term::Seq { lhs, rhs } => Term::Seq {
lhs: Box::new(self.desugar_term(lhs, scope)),
rhs: Box::new(self.desugar_term(rhs, scope)),
},
Term::Clone { value } => Term::Clone {
// pure structural recursion through the
// wrapper. Same pattern as `Term::Let`'s value branch.
value: Box::new(self.desugar_term(value, scope)),
},
Term::ReuseAs { source, body } => Term::ReuseAs {
// pure structural recursion through both
// children. Same pattern as `Term::Clone`.
source: Box::new(self.desugar_term(source, scope)),
body: Box::new(self.desugar_term(body, scope)),
},
Term::Loop { binders, body } => {
// loop-recur iter 1: structural recursion. Each
// binder's init is desugared in scope of the outer env
// plus already-declared binders; the body sees all
// binders. The LetBound sentinel keeps generated fresh
// names off binder names.
let mut inner = scope.clone();
let new_binders: Vec<LoopBinder> = binders
.iter()
.map(|b| {
let init = self.desugar_term(&b.init, &inner);
inner.insert(b.name.clone(), ScopeEntry::LetBound);
LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init,
}
})
.collect();
Term::Loop {
binders: new_binders,
body: Box::new(self.desugar_term(body, &inner)),
}
}
Term::Recur { args } => Term::Recur {
args: args
.iter()
.map(|a| self.desugar_term(a, scope))
.collect(),
},
Term::LetRec { name, ty, params, body, in_term } => {
// lift to a synthetic top-level fn (no-capture).
// extend the lift to the path-1 safe subset —
// captures of fn-params / Lam-params (whose types are
// known statically).
// when ANY capture is `LetBound` the desugar
// pass cannot resolve its type (the let-value's type is
// only known after typecheck). For that case we LEAVE
// the LetRec in place, with body and in_term recursively
// desugared. A post-typecheck pass (`lift_letrecs` in
// `ailang-check`) walks every surviving LetRec and lifts
// it using the typechecker's resolved types.
// extends the defer path to `MatchArm`
// captures. The post-typecheck pass already walks
// `Term::Match` arms and (since 16b.3) calls
// `type_check_pattern_for_lift` to extend locals with
// pattern bindings — that machinery resolves Match-arm
// capture types via constructor-field substitution
// against the matched ctor's declared field types.
//
// 16b.2's fast path (KnownType-only captures → lift here)
// STILL fires when applicable. Any `LetBound` or
// `MatchArm` capture forces the all-or-nothing defer
// path.
//
// `EnclosingLetRec` captures STILL panic (16b.7 —
// nested mutual recursion needs separate machinery).
//
// The non-callee-use check (16b.5 violation) runs FIRST
// so the diagnostic fires consistently regardless of
// which path the LetRec takes.
// Peel `ty` to its inner Fn so we know the LetRec's
// own param types (for body-scope) and so we can
// detect a Forall LetRec early.
let inner_params_tys: Vec<Type> = match peel_forall_to_fn(ty) {
Some(Type::Fn { params: ps, .. }) => ps.clone(),
_ => panic!(
"Iter 16b.3: LetRec `{}` must have a Fn type (or Forall<Fn>); got {:?}",
name, ty
),
};
if inner_params_tys.len() != params.len() {
panic!(
"Iter 16b.3: LetRec `{}` param count {} != type's param count {}",
name, params.len(), inner_params_tys.len()
);
}
// Body's scope: outer {name → EnclosingLetRec}
// params with their declared types as KnownType.
let mut body_scope = scope.clone();
body_scope.insert(name.clone(), ScopeEntry::EnclosingLetRec);
for (p, pty) in params.iter().zip(inner_params_tys.iter()) {
body_scope.insert(p.clone(), ScopeEntry::KnownType(pty.clone()));
}
let desugared_body = self.desugar_term(body, &body_scope);
// in_term's scope: outer {name → EnclosingLetRec}
// (params are lambda-local to the LetRec's body,
// not visible in `in`).
let mut in_scope = scope.clone();
in_scope.insert(name.clone(), ScopeEntry::EnclosingLetRec);
let desugared_in = self.desugar_term(in_term, &in_scope);
// 16b.2: validate that `name` is only ever used as the
// callee of a Term::App in `body` — never as a value.
// Body-side name-as-value would require an eta-Lam inside
// the body that references the LetRec's own pre-lift name;
// that's a non-trivial extension (the Lam's body would need
// to call the unlifted name, which by then is already
// rewritten to the lifted callee — chicken-and-egg). Stays
// rejected.
//
// 16b.5: in_term-side name-as-value is now SUPPORTED. We
// detect it here but DO NOT panic; instead, the lift logic
// below wraps `in_term'` in a `Let { f, lam(...), in_term' }`
// whose lam eta-expands the lifted fn, supplying the
// captures positionally. Bare-`f` references in `in_term'`
// then resolve to the let-bound lam value. Callee-position
// references in `in_term'` go directly to the lifted fn
// (efficient; no closure indirection).
if let Some(violation) = find_non_callee_use(&desugared_body, name) {
panic!(
"Iter 16b.5: LetRec `{}` appears as a value INSIDE its own body \
(not as the callee of `app`); name-as-value of a LetRec inside \
its own body is not yet supported. Offending term: {:?}",
name, violation
);
}
let in_has_value_use = find_non_callee_use(&desugared_in, name).is_some();
// reject name-as-value in `in_term` when the
// enclosing fn is polymorphic. The 16b.5 wrap synthesises
// a `Term::Lam` whose AST has no `Forall` slot, so
// wrapping a polymorphic lifted fn into a monomorphic Lam
// would lose the type vars. Solving this needs closure
// conversion with polymorphism (queue tag `closure-poly`
// / informally `16b.5b`). Other 16b.5 use cases — name-
// as-value in a MONOMORPHIC enclosing fn — continue to
// work via the eta-Lam wrap.
if in_has_value_use && !self.current_def_forall_vars.is_empty() {
panic!(
"Iter 16b.6: name-as-value of LetRec `{}` is not yet supported in \
a polymorphic enclosing fn — closure conversion with \
polymorphism would be required. Queued separately as \
`closure-poly` (informally 16b.5b).",
name
);
}
// Capture detection (against the *outer* scope, before
// the body-scope extension).
let mut local_bound: BTreeSet<String> = BTreeSet::new();
local_bound.insert(name.clone());
for p in params {
local_bound.insert(p.clone());
}
let mut frees: BTreeSet<String> = BTreeSet::new();
free_vars_in_term(&desugared_body, &local_bound, &mut frees);
let captures: Vec<String> = frees
.iter()
.filter(|f| scope.contains_key(*f))
.cloned()
.collect();
// 16b.4: classify each capture's ScopeEntry.
// - All KnownType → lift here (16b.2 fast path).
// - Any LetBound → defer to `lift_letrecs` (16b.3).
// - Any MatchArm → defer to `lift_letrecs` (16b.4).
// The post-typecheck pass resolves the binding's type
// from the enclosing match's scrutinee type, with
// constructor-field substitution against the matched
// ctor's declared field types.
// - Any EnclosingLetRec → panic (16b.7).
// Mixed KnownType + (LetBound | MatchArm) get deferred:
// any non-KnownType capture forces the all-or-nothing
// defer path. The post-typecheck pass handles every
// supported capture kind uniformly.
let mut needs_defer = false;
for c in &captures {
match scope.get(c).expect("capture-in-scope-by-construction") {
ScopeEntry::KnownType(_) => {}
ScopeEntry::LetBound => {
needs_defer = true;
}
ScopeEntry::MatchArm => {
// 16b.4: defer instead of panic. `lift_letrecs`
// resolves the type via `type_check_pattern_for_lift`
// (added in 16b.3) which already substitutes the
// matched ADT's type args into the ctor's
// declared field types.
needs_defer = true;
}
ScopeEntry::EnclosingLetRec => panic!(
"Iter 16b.7: nested LetRec `{}` captures outer LetRec name \
`{}` — closure conversion of a LetRec inside its own body \
would be required (the capture's value is the outer LetRec's \
pre-lift fn-value, which has no callable form before the \
outer lift completes). Queued as `closure-of-self` (informally \
16b.5-body / closure-poly).",
name, c
),
}
}
// 16b.3/16b.4: defer-arm. At least one capture is LetBound
// or MatchArm-bound, so we can't resolve the lifted
// signature here. Reconstruct the LetRec with desugared
// sub-terms and let `ailang-check::lift_letrecs` handle
// it after typecheck.
if needs_defer {
return Term::LetRec {
name: name.clone(),
ty: ty.clone(),
params: params.clone(),
body: Box::new(desugared_body),
in_term: Box::new(desugared_in),
};
}
// 16b.2 fast path: every capture has KnownType — lift now.
let capture_types: Vec<(String, Type)> = captures
.iter()
.map(|c| match scope.get(c).expect("classified-above") {
ScopeEntry::KnownType(t) => (c.clone(), t.clone()),
_ => unreachable!("non-KnownType filtered above"),
})
.collect();
// Build augmented type: original Fn with capture types
// appended to `params`.
//
// when the ENCLOSING fn is polymorphic
// (`current_def_forall_vars` non-empty), wrap the
// augmented Fn in a `Type::Forall` mirroring the
// enclosing fn's type vars. The capture types may
// mention any of those vars; that's fine — the Forall
// binds them. At every call site of the LetRec name
// inside the enclosing fn's body, codegen's Iter
// 12b/14a monomorphisation specialises `f$lr_N` at the
// same type args as the enclosing fn's current mono.
//
// The LetRec's own `ty` (`Type::Fn` — never `Forall`,
// since LetRec itself doesn't quantify) is the inner
// type.
let inner_augmented_ty = match ty {
Type::Fn { params: ps, ret, effects, .. } => {
let mut new_ps = ps.clone();
for (_, t) in &capture_types {
new_ps.push(t.clone());
}
Type::Fn {
params: new_ps,
ret: ret.clone(),
effects: effects.clone(),
param_modes: vec![],
ret_mode: ParamMode::Implicit,
}
}
Type::Forall { .. } => panic!(
"Iter 16b.6 invariant: LetRec `{}` has a Forall type at its \
own declaration; LetRec doesn't quantify, only the enclosing \
fn does",
name
),
other => panic!(
"Iter 16b.3: LetRec `{}` has non-Fn/Forall type {:?}",
name, other
),
};
let augmented_ty = if self.current_def_forall_vars.is_empty() {
inner_augmented_ty
} else {
Type::Forall {
vars: self.current_def_forall_vars.clone(),
constraints: vec![],
body: Box::new(inner_augmented_ty),
}
};
// Augmented param-name list: original params + capture
// names (using the captured variable names directly so
// the lifted body's references already resolve
// correctly).
let mut augmented_params = params.clone();
for (cn, _) in &capture_types {
augmented_params.push(cn.clone());
}
// Lift.
let lifted_name = self.fresh_lifted(name);
// Rewrite (app name args) to (app lifted_name args... cap0
// cap1 ...) FIRST in body, then substitute name ->
// lifted_name everywhere (handles non-call references,
// but body name-as-value is rejected above so this is
// a defensive belt-and-braces pass).
let extras: Vec<String> =
capture_types.iter().map(|(n, _)| n.clone()).collect();
let body_call_rw =
subst_call_with_extras(&desugared_body, name, &lifted_name, &extras);
let body_full = subst_var(&body_call_rw, name, &lifted_name);
// 16b.5: rewrite call sites in `in_term` to the lifted
// name with captures appended. Do NOT run `subst_var` on
// `in_term` if there's a name-as-value use — we want
// those bare `Var{name}` references to resolve to the
// eta-Lam binding we add below. If there is no
// name-as-value use, we still skip subst_var (no leftover
// bare references exist after subst_call_with_extras).
let in_call_rw =
subst_call_with_extras(&desugared_in, name, &lifted_name, &extras);
// Effects on the LetRec's declared type — the eta-Lam
// inherits them so its body can call the lifted fn (which
// carries the same effects).
let lr_effects: Vec<String> = match peel_forall_to_fn(ty) {
Some(Type::Fn { effects: es, .. }) => es.clone(),
_ => vec![],
};
// Original LetRec param types and ret type — the eta-Lam's
// signature mirrors the LetRec's declared type so a value
// bound by `(let f (lam ...) ...)` has type Fn(t1..tk) -> tr.
let (orig_param_tys, orig_ret_ty): (Vec<Type>, Type) =
match peel_forall_to_fn(ty) {
Some(Type::Fn { params: ps, ret, .. }) => {
(ps.clone(), (**ret).clone())
}
_ => unreachable!("ty shape validated above"),
};
let in_full = if in_has_value_use {
// Build the eta-Lam: `(lam (params P1..Pk -> RT) [effects]
// (app f$lr_N P1..Pk c1..cm))`. Param names reuse the
// LetRec's original param names — fine because they
// shadow only inside the Lam body.
let lam_args: Vec<Term> = params
.iter()
.map(|p| Term::Var { name: p.clone() })
.chain(extras.iter().map(|c| Term::Var { name: c.clone() }))
.collect();
let lam_body = Term::App {
callee: Box::new(Term::Var { name: lifted_name.clone() }),
args: lam_args,
tail: false,
};
let eta_lam = Term::Lam {
params: params.clone(),
param_tys: orig_param_tys,
ret_ty: Box::new(orig_ret_ty),
effects: lr_effects,
body: Box::new(lam_body),
};
Term::Let {
name: name.clone(),
value: Box::new(eta_lam),
body: Box::new(in_call_rw),
}
} else {
in_call_rw
};
self.lifted.push(Def::Fn(FnDef {
name: lifted_name,
ty: augmented_ty,
params: augmented_params,
body: body_full,
suppress: vec![],
doc: None,
export: None,
}));
in_full
}
Term::New { type_name, args } => Term::New {
type_name: type_name.clone(),
args: args
.iter()
.map(|arg| match arg {
NewArg::Value(v) => NewArg::Value(self.desugar_term(v, scope)),
NewArg::Type(t) => NewArg::Type(t.clone()),
})
.collect(),
},
}
}
/// 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() };
// `default` is unreachable for valid programs (the
// typechecker requires either a catch-all arm or exhaustive
// ctor coverage). Use the polymorphic bottom builtin
// `__unreachable__` (`forall a. a`) so the terminator unifies
// against any arm result type without forcing a synthetic
// `Unit`-typed `_` arm to dominate it. Codegen lowers the
// var to LLVM `unreachable`.
let default = Term::Var { name: "__unreachable__".into() };
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 { lit } => {
// 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
// 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::Lit { lit } => {
// 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(),
},
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`],
/// 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.
///
/// [`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 { .. } => true,
// 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)),
}
}
/// Build an equality-test term for a lit pattern. The shape is
/// Builds the equality-test AST node for a literal pattern. Lowers
/// `(pat-lit <lit>)` to `(if (eq sv <lit>) <body> <fall_k>)`. The
/// emitted `eq` resolves via prelude.Eq's class-method dispatch
/// (per design/contracts/0016-method-dispatch.md) — Int/Bool/Str/Unit
/// patterns all route through the corresponding primitive instance.
///
/// Float-Literal patterns are hard-rejected at typecheck
/// (`CheckError::FloatPatternNotAllowed`, per
/// design/contracts/0005-float-semantics.md) before this fn is reached,
/// so the `eq`-dispatch never encounters Float at runtime; the
/// Float arm of the match below remains for AST-level totality.
///
/// Unit literals are degenerate — every Unit value is equal — so an
/// emitted `true` literal stands in (no `eq` call needed).
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 { .. }
| Literal::Float { .. } => Term::App {
callee: Box::new(Term::Var {
name: "eq".to_string(),
}),
args: vec![scrutinee, Term::Lit { lit: lit.clone() }],
tail: false,
},
}
}
/// 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`.
/// Compound binders ([`Term::Let`], [`Term::Lam`], [`Term::Match`]
/// arms, [`Term::LetRec`]) extend `bound` for the sub-walk.
///
/// Used by the [`Term::LetRec`] desugar to decide whether a local
/// recursive let would capture any name from the enclosing scope.
///
/// made `pub` so the post-typecheck `lift_letrecs` pass
/// in `ailang-check` can reuse it (same free-var computation; the
/// post-typecheck pass needs it to recompute captures of LetRec
/// nodes that desugar deferred).
pub fn free_vars_in_term(t: &Term, bound: &BTreeSet<String>, out: &mut BTreeSet<String>) {
match t {
Term::Lit { .. } => {}
Term::Var { name } => {
if !bound.contains(name) {
out.insert(name.clone());
}
}
Term::App { callee, args, .. } => {
free_vars_in_term(callee, bound, out);
for a in args {
free_vars_in_term(a, bound, out);
}
}
Term::Let { name, value, body } => {
free_vars_in_term(value, bound, out);
let mut b = bound.clone();
b.insert(name.clone());
free_vars_in_term(body, &b, out);
}
Term::If { cond, then, else_ } => {
free_vars_in_term(cond, bound, out);
free_vars_in_term(then, bound, out);
free_vars_in_term(else_, bound, out);
}
Term::Do { args, .. } => {
for a in args {
free_vars_in_term(a, bound, out);
}
}
Term::Ctor { args, .. } => {
for a in args {
free_vars_in_term(a, bound, out);
}
}
Term::Match { scrutinee, arms } => {
free_vars_in_term(scrutinee, bound, out);
for arm in arms {
let mut b = bound.clone();
pattern_binds(&arm.pat, &mut b);
free_vars_in_term(&arm.body, &b, out);
}
}
Term::Lam { params, body, .. } => {
let mut b = bound.clone();
for p in params {
b.insert(p.clone());
}
free_vars_in_term(body, &b, out);
}
Term::Seq { lhs, rhs } => {
free_vars_in_term(lhs, bound, out);
free_vars_in_term(rhs, bound, out);
}
Term::LetRec { name, params, body, in_term, .. } => {
// Inside body: name + params are bound.
let mut b_body = bound.clone();
b_body.insert(name.clone());
for p in params {
b_body.insert(p.clone());
}
free_vars_in_term(body, &b_body, out);
// Inside in_term: only name is bound.
let mut b_in = bound.clone();
b_in.insert(name.clone());
free_vars_in_term(in_term, &b_in, out);
}
Term::Clone { value } => {
// `(clone X)` has the same free vars as `X`.
free_vars_in_term(value, bound, out);
}
Term::ReuseAs { source, body } => {
// free vars are the union of source and body.
free_vars_in_term(source, bound, out);
free_vars_in_term(body, bound, out);
}
Term::Loop { binders, body } => {
// loop-recur iter 1: binder names bind inside the loop —
// each init sees the outer env plus already-declared
// binders; the body sees all.
let mut b = bound.clone();
for bd in binders {
free_vars_in_term(&bd.init, &b, out);
b.insert(bd.name.clone());
}
free_vars_in_term(body, &b, out);
}
Term::Recur { args } => {
for a in args {
free_vars_in_term(a, bound, out);
}
}
Term::New { args, .. } => {
for arg in args {
match arg {
NewArg::Value(v) => free_vars_in_term(v, bound, out),
NewArg::Type(_) => {}
}
}
}
}
}
/// collect every name a pattern binds into `out`. Mirrors
/// `Pattern::pattern_bound_names` in `ailang-codegen`; duplicated here
/// because `ailang-core` cannot depend on the codegen crate.
///
/// made `pub` so callers (e.g. `ailang-check::lift_letrecs`)
/// can reproduce the same shadowing semantics during their own
/// scope-aware walks.
pub fn pattern_binds(p: &Pattern, out: &mut BTreeSet<String>) {
match p {
Pattern::Wild | Pattern::Lit { .. } => {}
Pattern::Var { name } => {
out.insert(name.clone());
}
Pattern::Ctor { fields, .. } => {
for sub in fields {
pattern_binds(sub, out);
}
}
}
}
/// rewrite every free [`Term::Var`] `{ name == from }`
/// reference in `t` to [`Term::Var`] `{ name = to }`. Respects
/// shadowing: if a binder rebinds `from`, occurrences inside that
/// binder's scope stop being free and are left alone.
///
/// Used after lifting a [`Term::LetRec`] body to a synthetic top-level
/// fn — every recursive self-call site in the lifted body and every
/// reference in the in-term needs to point at the new global name.
///
/// made `pub` for the same reason as
/// [`subst_call_with_extras`].
pub fn subst_var(t: &Term, from: &str, to: &str) -> Term {
match t {
Term::Lit { .. } => t.clone(),
Term::Var { name } => {
if name == from {
Term::Var { name: to.to_string() }
} else {
t.clone()
}
}
Term::App { callee, args, tail } => Term::App {
callee: Box::new(subst_var(callee, from, to)),
args: args.iter().map(|a| subst_var(a, from, to)).collect(),
tail: *tail,
},
Term::Let { name, value, body } => {
let v = subst_var(value, from, to);
// If this `let` rebinds `from`, leave its body alone.
let b = if name == from {
(**body).clone()
} else {
subst_var(body, from, to)
};
Term::Let {
name: name.clone(),
value: Box::new(v),
body: Box::new(b),
}
}
Term::If { cond, then, else_ } => Term::If {
cond: Box::new(subst_var(cond, from, to)),
then: Box::new(subst_var(then, from, to)),
else_: Box::new(subst_var(else_, from, to)),
},
Term::Do { op, args, tail } => Term::Do {
op: op.clone(),
args: args.iter().map(|a| subst_var(a, from, to)).collect(),
tail: *tail,
},
Term::Ctor { type_name, ctor, args } => Term::Ctor {
type_name: type_name.clone(),
ctor: ctor.clone(),
args: args.iter().map(|a| subst_var(a, from, to)).collect(),
},
Term::Match { scrutinee, arms } => Term::Match {
scrutinee: Box::new(subst_var(scrutinee, from, to)),
arms: arms
.iter()
.map(|a| {
// Pattern-bound vars shadow `from` inside the arm.
let mut binds: BTreeSet<String> = BTreeSet::new();
pattern_binds(&a.pat, &mut binds);
let body = if binds.contains(from) {
a.body.clone()
} else {
subst_var(&a.body, from, to)
};
Arm { pat: a.pat.clone(), body }
})
.collect(),
},
Term::Lam { params, param_tys, ret_ty, effects, body } => {
let body = if params.iter().any(|p| p == from) {
(**body).clone()
} else {
subst_var(body, from, to)
};
Term::Lam {
params: params.clone(),
param_tys: param_tys.clone(),
ret_ty: ret_ty.clone(),
effects: effects.clone(),
body: Box::new(body),
}
}
Term::Seq { lhs, rhs } => Term::Seq {
lhs: Box::new(subst_var(lhs, from, to)),
rhs: Box::new(subst_var(rhs, from, to)),
},
Term::LetRec { name, ty, params, body, in_term } => {
let body_shadowed = name == from || params.iter().any(|p| p == from);
let body_rw = if body_shadowed {
(**body).clone()
} else {
subst_var(body, from, to)
};
let in_shadowed = name == from;
let in_rw = if in_shadowed {
(**in_term).clone()
} else {
subst_var(in_term, from, to)
};
Term::LetRec {
name: name.clone(),
ty: ty.clone(),
params: params.clone(),
body: Box::new(body_rw),
in_term: Box::new(in_rw),
}
}
Term::Clone { value } => Term::Clone {
// structural recursion through the wrapper.
value: Box::new(subst_var(value, from, to)),
},
Term::ReuseAs { source, body } => Term::ReuseAs {
// structural recursion through both children.
source: Box::new(subst_var(source, from, to)),
body: Box::new(subst_var(body, from, to)),
},
Term::Loop { binders, body } => {
// loop-recur iter 1: binders lexically shadow outer
// names — once a binder named `from` is declared, later
// inits and the body stop being substituted.
let mut shadowed = false;
let new_binders: Vec<LoopBinder> = binders
.iter()
.map(|b| {
let init = if shadowed {
b.init.clone()
} else {
subst_var(&b.init, from, to)
};
if b.name == from {
shadowed = true;
}
LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init,
}
})
.collect();
let body_rw = if shadowed {
(**body).clone()
} else {
subst_var(body, from, to)
};
Term::Loop {
binders: new_binders,
body: Box::new(body_rw),
}
}
Term::Recur { args } => Term::Recur {
args: args.iter().map(|a| subst_var(a, from, to)).collect(),
},
Term::New { type_name, args } => Term::New {
type_name: type_name.clone(),
args: args
.iter()
.map(|arg| match arg {
NewArg::Value(v) => NewArg::Value(subst_var(v, from, to)),
NewArg::Type(t) => NewArg::Type(t.clone()),
})
.collect(),
},
}
}
/// peel a `Type::Forall { body: Type::Fn { ... } }` to
/// expose the inner `Type::Fn`. Returns `Some(&Type::Fn)` if the
/// type is `Fn` or `Forall<Fn>`; `None` otherwise.
fn peel_forall_to_fn(t: &Type) -> Option<&Type> {
match t {
Type::Fn { .. } => Some(t),
Type::Forall { body, .. } => peel_forall_to_fn(body),
_ => None,
}
}
/// walk `t` and rewrite every `Term::App { callee =
/// Var{name}, args }` to `Term::App { callee = Var{lifted}, args =
/// args ++ extras_as_vars }`. Recurses into all sub-terms. Does
/// not touch `Term::Var { name }` in non-callee positions — that's
/// flagged separately by [`find_non_callee_use`].
///
/// made `pub` so the post-typecheck `lift_letrecs` pass
/// in `ailang-check` can reuse it (same call-site rewrite shape; the
/// only difference is that the capture types come from the
/// typechecker's env rather than being statically known).
pub fn subst_call_with_extras(t: &Term, name: &str, lifted: &str, extras: &[String]) -> Term {
match t {
Term::Lit { .. } => t.clone(),
Term::Var { .. } => t.clone(),
Term::App { callee, args, tail } => {
let mut new_args: Vec<Term> = args
.iter()
.map(|a| subst_call_with_extras(a, name, lifted, extras))
.collect();
let new_callee = match callee.as_ref() {
Term::Var { name: n } if n == name => {
// Append extras as Var references — at this site,
// the captured variable names are still in scope
// because the lifted fn appends them as extra
// params with the same names.
for ex in extras {
new_args.push(Term::Var { name: ex.clone() });
}
Box::new(Term::Var { name: lifted.to_string() })
}
_ => Box::new(subst_call_with_extras(callee, name, lifted, extras)),
};
Term::App {
callee: new_callee,
args: new_args,
tail: *tail,
}
}
Term::Let { name: n, value, body } => Term::Let {
name: n.clone(),
value: Box::new(subst_call_with_extras(value, name, lifted, extras)),
body: Box::new(subst_call_with_extras(body, name, lifted, extras)),
},
Term::If { cond, then, else_ } => Term::If {
cond: Box::new(subst_call_with_extras(cond, name, lifted, extras)),
then: Box::new(subst_call_with_extras(then, name, lifted, extras)),
else_: Box::new(subst_call_with_extras(else_, name, lifted, extras)),
},
Term::Do { op, args, tail } => Term::Do {
op: op.clone(),
args: args
.iter()
.map(|a| subst_call_with_extras(a, name, lifted, extras))
.collect(),
tail: *tail,
},
Term::Ctor { type_name, ctor, args } => Term::Ctor {
type_name: type_name.clone(),
ctor: ctor.clone(),
args: args
.iter()
.map(|a| subst_call_with_extras(a, name, lifted, extras))
.collect(),
},
Term::Match { scrutinee, arms } => Term::Match {
scrutinee: Box::new(subst_call_with_extras(scrutinee, name, lifted, extras)),
arms: arms
.iter()
.map(|a| Arm {
pat: a.pat.clone(),
body: subst_call_with_extras(&a.body, name, lifted, extras),
})
.collect(),
},
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(subst_call_with_extras(body, name, lifted, extras)),
},
Term::Seq { lhs, rhs } => Term::Seq {
lhs: Box::new(subst_call_with_extras(lhs, name, lifted, extras)),
rhs: Box::new(subst_call_with_extras(rhs, name, lifted, extras)),
},
Term::LetRec { name: n, ty, params, body, in_term } => Term::LetRec {
name: n.clone(),
ty: ty.clone(),
params: params.clone(),
body: Box::new(subst_call_with_extras(body, name, lifted, extras)),
in_term: Box::new(subst_call_with_extras(in_term, name, lifted, extras)),
},
Term::Clone { value } => Term::Clone {
// structural recursion through the wrapper.
value: Box::new(subst_call_with_extras(value, name, lifted, extras)),
},
Term::ReuseAs { source, body } => Term::ReuseAs {
// structural recursion through both children.
source: Box::new(subst_call_with_extras(source, name, lifted, extras)),
body: Box::new(subst_call_with_extras(body, name, lifted, extras)),
},
Term::Loop { binders, body } => Term::Loop {
binders: binders
.iter()
.map(|b| LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: subst_call_with_extras(&b.init, name, lifted, extras),
})
.collect(),
body: Box::new(subst_call_with_extras(body, name, lifted, extras)),
},
Term::Recur { args } => Term::Recur {
args: args
.iter()
.map(|a| subst_call_with_extras(a, name, lifted, extras))
.collect(),
},
Term::New { type_name, args } => Term::New {
type_name: type_name.clone(),
args: args
.iter()
.map(|arg| match arg {
NewArg::Value(v) => {
NewArg::Value(subst_call_with_extras(v, name, lifted, extras))
}
NewArg::Type(t) => NewArg::Type(t.clone()),
})
.collect(),
},
}
}
/// returns `Some(t)` if `t` contains a `Term::Var {
/// name }` reference in a position that is **not** the callee of a
/// `Term::App`. Returns `None` if every reference is in callee
/// position. Used by the LetRec lifter to enforce the "direct-call
/// only" restriction in 16b.2.
///
/// made `pub` for the same reason as
/// [`subst_call_with_extras`].
pub fn find_non_callee_use(t: &Term, name: &str) -> Option<Term> {
match t {
Term::Lit { .. } => None,
Term::Var { name: n } if n == name => Some(t.clone()),
Term::Var { .. } => None,
Term::App { callee, args, .. } => {
// Allowed: callee = Var{name}. Args are checked normally.
// Disallowed: callee != Var{name} but recursing into the
// callee surfaces the name as a non-callee position.
if !matches!(callee.as_ref(), Term::Var { name: n } if n == name) {
if let Some(v) = find_non_callee_use(callee, name) {
return Some(v);
}
}
for a in args {
if let Some(v) = find_non_callee_use(a, name) {
return Some(v);
}
}
None
}
Term::Let { value, body, .. } => {
find_non_callee_use(value, name).or_else(|| find_non_callee_use(body, name))
}
Term::If { cond, then, else_ } => find_non_callee_use(cond, name)
.or_else(|| find_non_callee_use(then, name))
.or_else(|| find_non_callee_use(else_, name)),
Term::Do { args, .. } => args.iter().find_map(|a| find_non_callee_use(a, name)),
Term::Ctor { args, .. } => args.iter().find_map(|a| find_non_callee_use(a, name)),
Term::Match { scrutinee, arms } => find_non_callee_use(scrutinee, name)
.or_else(|| arms.iter().find_map(|a| find_non_callee_use(&a.body, name))),
Term::Lam { body, .. } => find_non_callee_use(body, name),
Term::Seq { lhs, rhs } => {
find_non_callee_use(lhs, name).or_else(|| find_non_callee_use(rhs, name))
}
Term::LetRec { body, in_term, .. } => find_non_callee_use(body, name)
.or_else(|| find_non_callee_use(in_term, name)),
// clone is identity for the non-callee scan.
Term::Clone { value } => find_non_callee_use(value, name),
// scan both source and body.
Term::ReuseAs { source, body } => {
find_non_callee_use(source, name).or_else(|| find_non_callee_use(body, name))
}
Term::Loop { binders, body } => binders
.iter()
.find_map(|b| find_non_callee_use(&b.init, name))
.or_else(|| find_non_callee_use(body, name)),
Term::Recur { args } => {
args.iter().find_map(|a| find_non_callee_use(a, name))
}
Term::New { args, .. } => args.iter().find_map(|arg| match arg {
NewArg::Value(v) => find_non_callee_use(v, name),
NewArg::Type(_) => None,
}),
}
}
#[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),
Term::LetRec { body, in_term, .. } => {
any_nested_ctor(body) || any_nested_ctor(in_term)
}
Term::Clone { value } => any_nested_ctor(value),
Term::ReuseAs { source, body } => any_nested_ctor(source) || any_nested_ctor(body),
Term::Loop { binders, body } => {
binders.iter().any(|b| any_nested_ctor(&b.init)) || any_nested_ctor(body)
}
Term::Recur { args } => args.iter().any(any_nested_ctor),
Term::New { args, .. } => args.iter().any(|arg| match arg {
NewArg::Value(v) => any_nested_ctor(v),
NewArg::Type(_) => false,
}),
}
}
/// Helper: walk a term, return true iff any [`Term::LetRec`] is
/// reachable. After 16b.1 desugaring this should be `false`.
fn any_let_rec(t: &Term) -> bool {
match t {
Term::Lit { .. } | Term::Var { .. } => false,
Term::App { callee, args, .. } => {
any_let_rec(callee) || args.iter().any(any_let_rec)
}
Term::Let { value, body, .. } => any_let_rec(value) || any_let_rec(body),
Term::If { cond, then, else_ } => {
any_let_rec(cond) || any_let_rec(then) || any_let_rec(else_)
}
Term::Do { args, .. } => args.iter().any(any_let_rec),
Term::Ctor { args, .. } => args.iter().any(any_let_rec),
Term::Match { scrutinee, arms } => {
any_let_rec(scrutinee) || arms.iter().any(|a| any_let_rec(&a.body))
}
Term::Lam { body, .. } => any_let_rec(body),
Term::Seq { lhs, rhs } => any_let_rec(lhs) || any_let_rec(rhs),
Term::LetRec { .. } => true,
Term::Clone { value } => any_let_rec(value),
Term::ReuseAs { source, body } => any_let_rec(source) || any_let_rec(body),
Term::Loop { binders, body } => {
binders.iter().any(|b| any_let_rec(&b.init)) || any_let_rec(body)
}
Term::Recur { args } => args.iter().any(any_let_rec),
Term::New { args, .. } => args.iter().any(|arg| match arg {
NewArg::Value(v) => any_let_rec(v),
NewArg::Type(_) => false,
}),
}
}
/// 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(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["xs".into()],
body: body_match,
suppress: vec![],
doc: None,
export: 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(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["xs".into()],
body: original.clone(),
suppress: vec![],
doc: None,
export: 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 { .. }));
}
/// a [`Term::LetRec`] whose body has no captures from
/// the enclosing scope is lifted to a synthetic top-level fn. The
/// resulting module's `defs` grows by one and the original LetRec
/// is gone from the term tree.
fn fact_letrec_term() -> Term {
// (let-rec fact (params n) (type ...) (body ...) (in (app fact 5)))
// Body just returns `n` so the test doesn't depend on `<=`/etc.
Term::LetRec {
name: "fact".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["n".into()],
body: Box::new(Term::Var { name: "n".into() }),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "fact".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 5 } }],
tail: false,
}),
}
}
#[test]
fn let_rec_no_capture_lifts_to_top_level() {
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: fact_letrec_term(),
suppress: vec![],
doc: None,
export: None,
})],
};
let out = desugar_module(&m);
// One original + one lifted = two defs.
assert_eq!(out.defs.len(), 2, "expected one lifted fn appended");
// The new def is a fn whose name starts with the LetRec hint.
let lifted = match &out.defs[1] {
Def::Fn(f) => f,
_ => panic!("expected a lifted FnDef"),
};
assert!(
lifted.name.starts_with("fact$lr_"),
"lifted name `{}` should start with `fact$lr_`",
lifted.name
);
assert_eq!(lifted.params, vec!["n".to_string()]);
// The original main's body must no longer contain a LetRec.
let main_body = match &out.defs[0] {
Def::Fn(f) => &f.body,
_ => unreachable!(),
};
assert!(
!any_let_rec(main_body),
"desugarer must remove every Term::LetRec from the term tree; got: {main_body:#?}"
);
// The in-term of the LetRec was `(app fact 5)`; after the lift
// it should be `(app <lifted_name> 5)`.
match main_body {
Term::App { callee, .. } => match callee.as_ref() {
Term::Var { name } => assert_eq!(name, &lifted.name),
other => panic!("expected lifted Var as callee, got {other:?}"),
},
other => panic!("expected App in main body, got {other:?}"),
}
}
/// a LetRec whose body captures a fn-param of the
/// enclosing scope (here, `outer` is a parameter of `main`) is
/// **lifted** with the capture appended to the lifted fn's
/// signature. The original 16b.1 panic ("would capture") is
/// gone for fn-param captures; this test was renamed and
/// repurposed to assert the lift succeeds and produces the
/// expected augmented signature + call-site rewrite.
#[test]
fn let_rec_capture_fn_param_lifts_with_extra_arg() {
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "outer".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["n".into()],
// (let-rec helper (params x) (type Int -> Int)
// (body (app + x n)) -- captures `n`
// (in (app helper 5)))
body: Term::LetRec {
name: "helper".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["x".into()],
body: Box::new(Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "x".into() },
Term::Var { name: "n".into() },
],
tail: false,
}),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "helper".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 5 } }],
tail: false,
}),
},
suppress: vec![],
doc: None,
export: None,
})],
};
let out = desugar_module(&m);
// (a) two defs: original outer + lifted helper.
assert_eq!(out.defs.len(), 2, "expected one lifted fn appended");
let lifted = match &out.defs[1] {
Def::Fn(f) => f,
_ => panic!("expected lifted FnDef"),
};
// (b) lifted ty has the capture type appended.
assert_eq!(
lifted.ty,
Type::Fn {
params: vec![Type::int(), Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
"lifted fn type should have capture appended; got {:?}",
lifted.ty
);
// (b') param-name list has the capture name appended.
assert_eq!(lifted.params, vec!["x".to_string(), "n".to_string()]);
// (c) the lifted body is `(app + x n)` — `n` is now a
// fn-param of the lifted def, so the body is unchanged.
match &lifted.body {
Term::App { callee, args, .. } => {
match callee.as_ref() {
Term::Var { name } => assert_eq!(name, "+"),
other => panic!("expected `+` callee, got {other:?}"),
}
assert_eq!(args.len(), 2);
match &args[0] {
Term::Var { name } => assert_eq!(name, "x"),
other => panic!("expected x, got {other:?}"),
}
match &args[1] {
Term::Var { name } => assert_eq!(name, "n"),
other => panic!("expected n, got {other:?}"),
}
}
other => panic!("expected lifted body to be App, got {other:?}"),
}
// (d) `outer`'s body is now `(app helper$lr_0 5 n)` —
// call-site got the capture appended.
let outer_body = match &out.defs[0] {
Def::Fn(f) => &f.body,
_ => unreachable!(),
};
match outer_body {
Term::App { callee, args, .. } => {
match callee.as_ref() {
Term::Var { name } => assert_eq!(name, &lifted.name),
other => panic!("expected lifted callee, got {other:?}"),
}
assert_eq!(args.len(), 2, "expected 2 args (1 original + 1 capture)");
match &args[0] {
Term::Lit { lit: Literal::Int { value: 5 } } => {}
other => panic!("expected 5, got {other:?}"),
}
match &args[1] {
Term::Var { name } => assert_eq!(name, "n"),
other => panic!("expected n, got {other:?}"),
}
}
other => panic!("expected outer body to be App, got {other:?}"),
}
}
/// a LetRec whose body captures a `Term::Let`-bound
/// name is no longer rejected at desugar time. Instead the
/// desugar pass leaves the LetRec in place (with body and
/// in_term recursively desugared) so the post-typecheck pass in
/// `ailang-check` can lift it using the typechecker's resolved
/// types. The 16b.1/16b.2 panic for this shape is gone.
#[test]
fn let_rec_capture_let_binding_is_deferred_to_post_typecheck() {
// (let y 7 in (let-rec helper (params x) ... (body (app + x y))
// (in (app helper 1))))
let letrec = Term::LetRec {
name: "helper".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["x".into()],
body: Box::new(Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "x".into() },
Term::Var { name: "y".into() },
],
tail: false,
}),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "helper".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 1 } }],
tail: false,
}),
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Let {
name: "y".into(),
value: Box::new(Term::Lit { lit: Literal::Int { value: 7 } }),
body: Box::new(letrec),
},
suppress: vec![],
doc: None,
export: None,
})],
};
let out = desugar_module(&m);
// No fn was lifted (the LetRec is left in place) — the
// module's defs count is unchanged.
assert_eq!(
out.defs.len(),
1,
"expected no lifted fn (LetRec deferred); got {:?}",
out.defs.iter().map(|d| d.name()).collect::<Vec<_>>()
);
// The original LetRec must STILL be present somewhere in
// main's body.
let main_body = match &out.defs[0] {
Def::Fn(f) => &f.body,
_ => unreachable!(),
};
assert!(
any_let_rec(main_body),
"expected Term::LetRec to still be present in body; got {main_body:#?}"
);
}
/// a LetRec whose body captures a name bound by a
/// `Pattern::Ctor` Var sub-pattern (a `ScopeEntry::MatchArm`
/// binding) is no longer rejected at desugar time. The desugar
/// pass leaves the LetRec in place (with body and in_term
/// recursively desugared) so the post-typecheck pass in
/// `ailang-check` can lift it using the typechecker's resolved
/// types — including constructor-field substitution against
/// the matched ADT's type args. The 16b.3-era panic for this
/// shape is gone.
#[test]
fn let_rec_capture_match_arm_is_deferred_to_post_typecheck() {
// Module-level scope:
// (data Pair (vars a b) (ctor MkPair a b))
// (fn outer : (Pair Int Int) -> Int = \p.
// match p
// case (pat-ctor MkPair x y) ->
// let-rec helper : (Int) -> Int = \z. (+ z x)
// in (app helper 0)
// )
let pair_td = TypeDef {
name: "Pair".into(),
vars: vec!["a".into(), "b".into()],
ctors: vec![Ctor {
name: "MkPair".into(),
fields: vec![
Type::Var { name: "a".into() },
Type::Var { name: "b".into() },
],
}],
doc: None,
drop_iterative: false,
param_in: BTreeMap::new(),
};
let letrec = Term::LetRec {
name: "helper".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["z".into()],
body: Box::new(Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "z".into() },
Term::Var { name: "x".into() },
],
tail: false,
}),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "helper".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 0 } }],
tail: false,
}),
};
let outer_body = Term::Match {
scrutinee: Box::new(Term::Var { name: "p".into() }),
arms: vec![Arm {
pat: Pattern::Ctor {
ctor: "MkPair".into(),
fields: vec![
Pattern::Var { name: "x".into() },
Pattern::Var { name: "y".into() },
],
},
body: letrec,
}],
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![
Def::Type(pair_td),
Def::Fn(FnDef {
name: "outer".into(),
ty: Type::Fn {
params: vec![Type::Con {
name: "Pair".into(),
args: vec![Type::int(), Type::int()],
}],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["p".into()],
body: outer_body,
suppress: vec![],
doc: None,
export: None,
}),
],
};
let out = desugar_module(&m);
// No fn was lifted (the LetRec is deferred) — the module's
// defs count is unchanged at 2 (Pair + outer).
assert_eq!(
out.defs.len(),
2,
"expected no lifted fn (LetRec deferred); got {:?}",
out.defs.iter().map(|d| d.name()).collect::<Vec<_>>()
);
// The original LetRec must STILL be present somewhere in
// outer's body (after match desugar — the match arm has only
// `Var` sub-patterns so it stays flat and the body is reachable).
let outer_body_out = match &out.defs[1] {
Def::Fn(f) => &f.body,
_ => unreachable!(),
};
assert!(
any_let_rec(outer_body_out),
"expected Term::LetRec to still be present in body; got {outer_body_out:#?}"
);
}
/// name-as-value INSIDE the LetRec's own body is
/// still rejected (would require an eta-Lam that calls the
/// pre-lift name — chicken-and-egg with the call-site rewrite).
/// Here `(let g f ...)` binds the LetRec name `f` to a let-bound
/// name `g`, which is a value position — not a callee.
#[test]
#[should_panic(expected = "16b.5")]
fn let_rec_name_as_value_in_body_panics() {
// (let-rec f (params x) (type Int->Int)
// (body (let g f (app g x))) -- f used as a value INSIDE body
// (in (app f 1)))
let letrec = Term::LetRec {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["x".into()],
body: Box::new(Term::Let {
name: "g".into(),
value: Box::new(Term::Var { name: "f".into() }),
body: Box::new(Term::App {
callee: Box::new(Term::Var { name: "g".into() }),
args: vec![Term::Var { name: "x".into() }],
tail: false,
}),
}),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "f".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 1 } }],
tail: false,
}),
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: letrec,
suppress: vec![],
doc: None,
export: None,
})],
};
let _ = desugar_module(&m);
}
/// name-as-value in the in-clause is now SUPPORTED.
/// The desugar pass lifts the LetRec to a synthetic top-level fn
/// AND wraps the rewritten in-term in `(let f (lam ...) ...)`
/// whose lam eta-expands the lifted fn. After desugar, the
/// surviving term tree contains:
/// - one lifted fn `f$lr_N`
/// - inside `main`, a `Term::Let { name: "f", value: Term::Lam,
/// body: <rewritten in_term> }` where the Lam's body calls
/// `f$lr_N` positionally.
#[test]
fn let_rec_name_as_value_in_in_term_wraps_to_eta_lam() {
// (let-rec f (params x) (type Int->Int)
// (body (app f x)) -- only callee uses (legal)
// (in (let g f (app g 1)))) -- f used as a value
let letrec = Term::LetRec {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["x".into()],
body: Box::new(Term::App {
callee: Box::new(Term::Var { name: "f".into() }),
args: vec![Term::Var { name: "x".into() }],
tail: false,
}),
in_term: Box::new(Term::Let {
name: "g".into(),
value: Box::new(Term::Var { name: "f".into() }),
body: Box::new(Term::App {
callee: Box::new(Term::Var { name: "g".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 1 } }],
tail: false,
}),
}),
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: letrec,
suppress: vec![],
doc: None,
export: None,
})],
};
let out = desugar_module(&m);
// Two defs: original `main` + lifted `f$lr_0`.
assert_eq!(
out.defs.len(),
2,
"expected one lifted fn appended; got {:?}",
out.defs.iter().map(|d| d.name()).collect::<Vec<_>>()
);
let lifted_name = out.defs[1].name().to_string();
assert!(
lifted_name.starts_with("f$lr_"),
"unexpected lifted-name shape: {lifted_name}"
);
// main's body must be wrapped in `Let { name: "f", value: Lam, ... }`.
let main_body = match &out.defs[0] {
Def::Fn(fd) => &fd.body,
_ => unreachable!(),
};
match main_body {
Term::Let { name, value, .. } => {
assert_eq!(name, "f", "wrap name must be the original LetRec name");
match value.as_ref() {
Term::Lam { params, body, .. } => {
assert_eq!(params, &vec!["x".to_string()]);
// The lam's body is `(app f$lr_0 x)` (no captures
// here, so just original-params).
match body.as_ref() {
Term::App { callee, args, .. } => {
match callee.as_ref() {
Term::Var { name: cn } => {
assert_eq!(cn, &lifted_name);
}
other => panic!("expected callee Var, got {other:?}"),
}
assert_eq!(args.len(), 1, "no captures expected");
}
other => panic!("expected lam body App, got {other:?}"),
}
}
other => panic!("expected wrap value Lam, got {other:?}"),
}
}
other => panic!("expected wrap Let at main body, got {other:?}"),
}
}
/// a LetRec inside a polymorphic enclosing fn gets its
/// fn-param captures lifted with a `Forall(vars, Fn(...))` augmented
/// signature, mirroring the enclosing fn's type vars. Without 16b.6,
/// the desugar pass would have classified the fn-params as
/// `LetBound` (16b.2 fallback) and deferred to `lift_letrecs`; with
/// 16b.6, the fn-params are `KnownType` and the fast-path lift fires
/// here, producing a `Forall`-typed synthetic FnDef.
#[test]
fn let_rec_capture_under_polymorphic_enclosing_fn_lifts_to_forall_fn() {
// (fn id_n_times : Forall(a). Fn(Int, a) -> a
// (params n x)
// (body
// (let-rec loop : Fn(Int, a) -> a (params k acc)
// (body (if (== k 0) acc (app loop (- k 1) acc)))
// (in (app loop n x)))))
// Captures: none (all references inside the body are to params
// of `loop` itself or top-level builtins). To force a capture
// of an `a`-typed param, use the simpler shape:
//
// (fn rec_id : Forall(a). Fn(a) -> a
// (params x)
// (body
// (let-rec loop : Fn(Int) -> a (params k)
// (body (if (== k 0) x (app loop (- k 1))))
// (in (app loop 1)))))
//
// Here `loop` captures `x: a` from the enclosing fn's params.
// Without 16b.6, `x` would be ScopeEntry::LetBound (because the
// enclosing fn is Forall) and the LetRec would defer; with
// 16b.6, `x` is KnownType and the lift fires here.
let letrec = Term::LetRec {
name: "loop".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::Var { name: "a".into() }),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["k".into()],
// 2026-05-21 operator-routing-eq-ord: keep `==` here (vs
// migrating to `eq`) because this AST literal sits in an
// in-source mod test for desugar with no prelude
// injection; `eq` would be unbound. After Task 7 deletes
// `==`, this test gets deleted as obsolete; the desugar
// shape is exercised end-to-end via the workspace flow.
body: Box::new(Term::If {
cond: Box::new(Term::App {
callee: Box::new(Term::Var { name: "==".into() }),
args: vec![
Term::Var { name: "k".into() },
Term::Lit { lit: Literal::Int { value: 0 } },
],
tail: false,
}),
then: Box::new(Term::Var { name: "x".into() }),
else_: Box::new(Term::App {
callee: Box::new(Term::Var { name: "loop".into() }),
args: vec![Term::App {
callee: Box::new(Term::Var { name: "-".into() }),
args: vec![
Term::Var { name: "k".into() },
Term::Lit { lit: Literal::Int { value: 1 } },
],
tail: false,
}],
tail: false,
}),
}),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "loop".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 1 } }],
tail: false,
}),
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "rec_id".into(),
ty: Type::Forall {
vars: vec!["a".into()],
constraints: vec![],
body: Box::new(Type::Fn {
params: vec![Type::Var { name: "a".into() }],
ret: Box::new(Type::Var { name: "a".into() }),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
}),
},
params: vec!["x".into()],
body: letrec,
suppress: vec![],
doc: None,
export: None,
})],
};
let out = desugar_module(&m);
assert_eq!(
out.defs.len(),
2,
"expected one lifted fn appended; got {:?}",
out.defs.iter().map(|d| d.name()).collect::<Vec<_>>()
);
let lifted = match &out.defs[1] {
Def::Fn(fd) => fd,
_ => unreachable!(),
};
assert!(
lifted.name.starts_with("loop$lr_"),
"unexpected lifted-name shape: {}",
lifted.name
);
// Lifted type must be Forall(a). Fn(Int, a) -> a (k + x capture).
match &lifted.ty {
Type::Forall { vars, constraints: _, body } => {
assert_eq!(vars, &vec!["a".to_string()], "Forall vars must mirror enclosing");
match body.as_ref() {
Type::Fn { params, ret, .. } => {
assert_eq!(params.len(), 2, "expected Int + a-typed capture");
assert!(
matches!(&params[0], Type::Con { name, .. } if name == "Int"),
"first param must be Int (loop's own k); got {:?}",
params[0]
);
assert!(
matches!(&params[1], Type::Var { name } if name == "a"),
"second param must be the captured `x: a`; got {:?}",
params[1]
);
assert!(
matches!(ret.as_ref(), Type::Var { name } if name == "a"),
"ret must be `a`; got {:?}",
ret
);
}
other => panic!("Forall body must be Fn; got {:?}", other),
}
}
other => panic!("lifted type must be Forall; got {:?}", other),
}
// Lifted params: original "k" + captured "x".
assert_eq!(lifted.params, vec!["k".to_string(), "x".to_string()]);
}
/// a LetRec inside a polymorphic enclosing fn whose
/// `in_term` uses the LetRec name as a value (not as a callee) is
/// rejected. The 16b.5 eta-Lam wrap can't quantify `Forall.vars`
/// because `Term::Lam` has no Forall slot — wrapping a polymorphic
/// lifted fn into a monomorphic Lam loses the type vars. Queued
/// separately as `closure-poly` (informally 16b.5b).
#[test]
#[should_panic(expected = "16b.6")]
fn let_rec_name_as_value_in_polymorphic_enclosing_fn_panics() {
// Same shape as `let_rec_name_as_value_in_in_term_wraps_to_eta_lam`
// but the enclosing fn is Forall-quantified.
let letrec = Term::LetRec {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::Var { name: "a".into() }),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["k".into()],
body: Box::new(Term::Var { name: "x".into() }),
in_term: Box::new(Term::Let {
name: "g".into(),
value: Box::new(Term::Var { name: "f".into() }), // value-position f
body: Box::new(Term::App {
callee: Box::new(Term::Var { name: "g".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 1 } }],
tail: false,
}),
}),
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "outer".into(),
ty: Type::Forall {
vars: vec!["a".into()],
constraints: vec![],
body: Box::new(Type::Fn {
params: vec![Type::Var { name: "a".into() }],
ret: Box::new(Type::Var { name: "a".into() }),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
}),
},
params: vec!["x".into()],
body: letrec,
suppress: vec![],
doc: None,
export: None,
})],
};
let _ = desugar_module(&m);
}
/// a nested LetRec whose inner LetRec captures the
/// OUTER LetRec's NAME (not its params or any other local) is
/// rejected at desugar time with the closure-of-self message.
/// Capturing the outer's PARAMS is supported (covered by the
/// `nested_let_rec` fixture / e2e); capturing the NAME is the
/// chicken-and-egg case where the outer's value form does not
/// exist before the outer's lift completes.
#[test]
#[should_panic(expected = "16b.7")]
fn nested_let_rec_inner_captures_outer_name_panics() {
// Outer LetRec body contains an inner LetRec that references
// `outer` (the outer LetRec's own name) inside its body — as
// a callee, even, but the panic fires on the capture
// classification, not on a non-callee check.
//
// Shape inside main:
// (let-rec outer (params i) (type Int->Int)
// (body (let-rec inner (params j) (type Int->Int)
// (body (app outer j)) ;; inner captures outer-NAME
// (in (app inner 0))))
// (in (app outer 1)))
let inner = Term::LetRec {
name: "inner".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["j".into()],
body: Box::new(Term::App {
callee: Box::new(Term::Var { name: "outer".into() }),
args: vec![Term::Var { name: "j".into() }],
tail: false,
}),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "inner".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 0 } }],
tail: false,
}),
};
let outer = Term::LetRec {
name: "outer".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["i".into()],
body: Box::new(inner),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "outer".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 1 } }],
tail: false,
}),
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: outer,
suppress: vec![],
doc: None,
export: None,
})],
};
let _ = desugar_module(&m);
}
/// a nested LetRec whose inner
/// LetRec captures the OUTER LetRec's PARAM (not its name) lifts
/// cleanly through the existing 16b.2 fast path. The outer's
/// params live in the inner's outer-scope as `KnownType`, so the
/// inner classifies them like any other fn-param capture and
/// appends them to its lifted signature. The post-order traversal
/// then lifts the outer next; references to the outer's params
/// inside the inner-call rewrites stay in scope (the outer hasn't
/// renamed its params yet).
#[test]
fn nested_let_rec_inner_captures_outer_param_lifts() {
// Shape inside main:
// (let-rec outer (params i) (type Int->Int)
// (body (let-rec inner (params j) (type Int->Int)
// (body (app + i j)) ;; inner captures outer-PARAM i
// (in (app inner 0))))
// (in (app outer 1)))
let inner = Term::LetRec {
name: "inner".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["j".into()],
body: Box::new(Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "i".into() },
Term::Var { name: "j".into() },
],
tail: false,
}),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "inner".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 0 } }],
tail: false,
}),
};
let outer = Term::LetRec {
name: "outer".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["i".into()],
body: Box::new(inner),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "outer".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 1 } }],
tail: false,
}),
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: outer,
suppress: vec![],
doc: None,
export: None,
})],
};
let out = desugar_module(&m);
// Three defs: original `main` + lifted `inner$lr_0` + lifted
// `outer$lr_1`. Inner is lifted first (post-order), with outer's
// param `i` appended; outer is lifted next, with no captures.
let names: Vec<String> = out.defs.iter().map(|d| d.name().to_string()).collect();
assert_eq!(
names.len(),
3,
"expected main + two lifted fns; got {names:?}"
);
assert_eq!(names[0], "main");
assert!(
names[1].starts_with("inner$lr_") && names[2].starts_with("outer$lr_"),
"expected inner then outer lifted-fn names; got {names:?}"
);
// The lifted inner must have two params: its own (`j`) plus the
// captured outer-param (`i`).
if let Def::Fn(inner_lifted) = &out.defs[1] {
assert_eq!(
inner_lifted.params,
vec!["j".to_string(), "i".to_string()],
"inner lifted params should be [j, i] (own + captured outer param)"
);
} else {
panic!("expected Def::Fn at defs[1]");
}
}
/// 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)
}
Term::Clone { value } => any_lit_pattern(value),
Term::ReuseAs { source, body } => any_lit_pattern(source) || any_lit_pattern(body),
Term::Loop { binders, body } => {
binders.iter().any(|b| any_lit_pattern(&b.init)) || any_lit_pattern(body)
}
Term::Recur { args } => args.iter().any(any_lit_pattern),
Term::New { args, .. } => args.iter().any(|arg| match arg {
NewArg::Value(v) => any_lit_pattern(v),
NewArg::Type(_) => false,
}),
}
}
/// 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(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["n".into()],
body: body_match,
suppress: vec![],
doc: None,
export: 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:?}"
);
}
/// 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(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["xs".into()],
body: body_match,
suppress: vec![],
doc: None,
export: 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:#?}"
);
}
/// `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"
);
}
/// the chain machinery's deepest fall-through is the
/// polymorphic `__unreachable__` builtin (`forall a. a`), not a
/// `Unit` literal. A match with two lit arms and a wildcard
/// catches all paths via the wild-arm body, so the deepest
/// `else_` of the chain is the `__unreachable__` var.
#[test]
fn chain_default_is_unreachable_builtin() {
// (match n (case (pat-lit 0) 100) (case (pat-lit 1) 200))
// Note: no wildcard on purpose — the chain reaches the
// synthetic terminator after both lit arms fail.
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::Lit {
lit: Literal::Int { value: 1 },
},
body: Term::Lit {
lit: Literal::Int { value: 200 },
},
},
],
};
let m = Module {
schema: crate::SCHEMA.to_string(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "f".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["n".into()],
body: body_match,
suppress: vec![],
doc: None,
export: None,
})],
};
let out = desugar_module(&m);
let body = match &out.defs[0] {
Def::Fn(f) => &f.body,
_ => unreachable!(),
};
// Outer is `let $mp_N = scrutinee in <chain>`. The chain is
// `if (== sv 0) then 100 else if (== sv 1) then 200 else __unreachable__`.
let chain = match body {
Term::Let { body, .. } => body.as_ref(),
other => panic!("expected outer Let from chain machinery, got {other:?}"),
};
let inner_else = match chain {
Term::If { else_, .. } => else_.as_ref(),
other => panic!("expected outer If, got {other:?}"),
};
let deepest = match inner_else {
Term::If { else_, .. } => else_.as_ref(),
other => panic!("expected nested If, got {other:?}"),
};
assert!(
matches!(deepest, Term::Var { name } if name == "__unreachable__"),
"deepest chain fall-through must be `__unreachable__`, got {deepest:?}"
);
}
}