Iter 16b.3: post-typecheck lift for LetRec with Let-bound captures

Adds path-2 from the 16b.2 planning entry. Desugar now defers
LetRecs whose captures include Term::Let-bound names; a new
ailang-check::lift_letrecs pass runs after typecheck and uses the
elaborated env to resolve capture types. ailang-check learns a real
Term::LetRec typing rule in synth and verify_tail_positions.

- desugar: Term::LetRec arm gains a defer-arm; helpers promoted to
  pub for reuse by the lift pass; find_non_callee_use moved before
  classification.
- ailang-check::synth/verify_tail_positions: real LetRec rules
  (effect-subset, locals install, recursive name in body+in_term).
- ailang-check::lift.rs (new, 720 LOC): post-typecheck lift with
  post-order traversal, env-walk for capture-type resolution,
  fast-path skip when no LetRec is present.
- ail::main.rs: build path now does load → check → desugar →
  lift_letrecs → codegen.
- examples/local_rec_let_capture.{ailx,ail.json}: new fixture
  capturing a let-bound `threshold` in a recursive helper.
- e2e + check unit tests: 106 → 110 (+4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 22:07:05 +02:00
parent d5f63bc3e5
commit ca30606aec
9 changed files with 1552 additions and 68 deletions
+405 -11
View File
@@ -239,8 +239,10 @@ fn unify(a: &Type, b: &Type, subst: &mut Subst) -> Result<()> {
pub mod builtins;
pub mod diagnostic;
pub mod lift;
pub use diagnostic::{Diagnostic, Severity};
pub use lift::lift_letrecs;
/// Internal error type produced by the typechecker.
///
@@ -419,7 +421,7 @@ pub enum CheckError {
},
}
type Result<T> = std::result::Result<T, CheckError>;
pub(crate) type Result<T> = std::result::Result<T, CheckError>;
impl CheckError {
/// Stable kebab-case code for machine consumption (`ail check --json`).
@@ -687,6 +689,31 @@ pub fn check(m: &Module) -> Result<CheckedModule> {
Ok(CheckedModule { symbols })
}
/// Iter 16b.3: typecheck `m` and return both the [`CheckedModule`]
/// (for tooling that wants the original on-disk symbol identities)
/// AND the lifted module ready for codegen — i.e. the desugared
/// module with every surviving `Term::LetRec` replaced by a
/// synthetic top-level `Def::Fn`.
///
/// The check phase runs unchanged: same desugar, same typecheck,
/// same `CheckedModule.symbols` (built from the original `m`'s
/// defs). On success, the desugared module is fed through
/// [`lift_letrecs`] and the result is returned alongside.
///
/// `build` / `run` go through this entry; the `check` subcommand
/// stays on the legacy [`check`] entry (no lift needed for
/// type-checking only).
pub fn check_and_lift(m: &Module) -> Result<(CheckedModule, Module)> {
let cm = check(m)?;
// Run desugar exactly as `check` does. The `check` call already
// ran desugar internally, but its result is discarded (only the
// CheckedModule survives), so we have to re-run it here to get
// the post-desugar form for the lift.
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = lift_letrecs(&desugared)?;
Ok((cm, lifted))
}
/// Iter 15a: builds the ADT type-def table per module. Sibling of
/// [`build_module_globals`]: gives the body checker O(1) lookup of any
/// type declared anywhere in the workspace, keyed by module name.
@@ -1083,10 +1110,16 @@ pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> {
// Entering a Lam body opens a fresh tail scope.
verify_tail_positions(body, true)
}
Term::LetRec { .. } => {
// Iter 16b.1: `Term::LetRec` is eliminated by the desugar
// pass before `check` runs, so reaching it here is a bug.
unreachable!("Term::LetRec eliminated by desugar")
Term::LetRec { body, in_term, .. } => {
// Iter 16b.3: `Term::LetRec` may now survive the desugar
// pass (when it captures `Term::Let`-bound names whose
// types are only known after typecheck). The body is the
// body of a fn-typed binding; the recursive name is what
// gets tail-called, so `body` is NOT in tail position. The
// in-clause IS in tail position iff the enclosing context
// is — same propagation rule as `Term::Let.body`.
verify_tail_positions(body, false)?;
verify_tail_positions(in_term, is_tail)
}
}
}
@@ -1112,7 +1145,7 @@ fn check_const(c: &ConstDef, env: &Env) -> Result<()> {
Ok(())
}
fn synth(
pub(crate) fn synth(
t: &Term,
env: &Env,
locals: &mut IndexMap<String, Type>,
@@ -1488,10 +1521,104 @@ fn synth(
effects: lam_effects.clone(),
})
}
Term::LetRec { .. } => {
// Iter 16b.1: `Term::LetRec` is eliminated by the desugar
// pass before `synth` runs, so reaching it here is a bug.
unreachable!("Term::LetRec eliminated by desugar")
Term::LetRec { name, ty, params, body, in_term } => {
// Iter 16b.3: a `Term::LetRec` reaches `synth` only when
// the desugar pass deferred it (some capture is
// `Term::Let`-bound and its type is only knowable here).
// We synthesize it as if it were a recursive fn-typed
// local binding:
// 1. Peel any `Forall` defensively (16b.6 still rejects
// Forall-typed LetRecs at desugar — this is just for
// shape uniformity with `check_fn`).
// 2. Validate that `params.len() == ty.params.len()`.
// 3. Extend `locals` with `name: ty` for the body
// (recursive self-reference) and each
// `params[i]: ty.params[i]`.
// 4. Synth the body, unify against `ty.ret`, check
// effects-subset against `ty.effects`.
// 5. Restore locals; extend with `name: ty`; synth
// `in_term`. Restore. Return `in_term`'s type.
let inner_ty = match ty {
Type::Forall { body, .. } => (**body).clone(),
other => other.clone(),
};
let (param_tys, ret_ty, declared_effs) = match &inner_ty {
Type::Fn { params: ps, ret, effects } => {
(ps.clone(), (**ret).clone(), effects.clone())
}
other => {
return Err(CheckError::FnTypeRequired(
name.clone(),
ailang_core::pretty::type_to_string(other),
));
}
};
if param_tys.len() != params.len() {
return Err(CheckError::ParamCountMismatch {
name: name.clone(),
ty_count: param_tys.len(),
param_count: params.len(),
});
}
// Validate the LetRec's declared param/ret types against
// the type env (catches malformed types like ADT arity
// mismatches before they leak into the body).
for p in &param_tys {
check_type_well_formed(p, env)?;
}
check_type_well_formed(&ret_ty, env)?;
// Save and extend locals: name + params for the body's scope.
let mut pushed: Vec<(String, Option<Type>)> = Vec::new();
let prev_name = locals.insert(name.clone(), ty.clone());
pushed.push((name.clone(), prev_name));
for (n, t) in params.iter().zip(param_tys.iter()) {
let prev = locals.insert(n.clone(), t.clone());
pushed.push((n.clone(), prev));
}
// Body effects are tracked separately so we can check the
// subset rule against `declared_effs` — exactly like
// `Term::Lam`.
let mut body_effects: BTreeSet<String> = BTreeSet::new();
let body_ty = synth(body, env, locals, &mut body_effects, in_def, subst, counter);
// Restore body-scope locals (params + name).
for (n, prev) in pushed.into_iter().rev() {
match prev {
Some(p) => {
locals.insert(n, p);
}
None => {
locals.shift_remove(&n);
}
}
}
let body_ty = body_ty?;
unify(&ret_ty, &body_ty, subst)?;
let declared: BTreeSet<String> = declared_effs.into_iter().collect();
for e in &body_effects {
if !declared.contains(e) {
return Err(CheckError::UndeclaredEffect(e.clone()));
}
}
// Now extend locals with `name: ty` for the in-clause's
// scope (params are not visible here; only the recursive
// binding is).
let prev_in = locals.insert(name.clone(), ty.clone());
let in_ty = synth(in_term, env, locals, effects, in_def, subst, counter);
match prev_in {
Some(p) => {
locals.insert(name.clone(), p);
}
None => {
locals.shift_remove(name);
}
}
in_ty
}
}
}
@@ -1794,7 +1921,7 @@ pub struct CtorRef {
}
impl Env {
fn new() -> Self {
pub(crate) fn new() -> Self {
Self::default()
}
}
@@ -2884,4 +3011,271 @@ mod tests {
};
check(&m).expect("tail-call in tail position should typecheck");
}
/// Iter 16b.3: a `Term::LetRec` that captures a `Term::Let`-bound
/// name (whose type is known only after typecheck) reaches `synth`
/// because the desugar pass leaves it in place. The new typing
/// rule for `Term::LetRec` accepts it: extends locals with `name`
/// and the params, synths the body, unifies against the declared
/// return type, then synths the in-clause.
#[test]
fn letrec_with_let_binding_capture_typechecks() {
// fn outer : (Int) -> Int = \n.
// let threshold = + 5 5
// in let-rec loop : (Int) -> Int = \i.
// if (>= i threshold) then 0 else loop (+ i 1)
// in loop 0
let body = Term::Let {
name: "threshold".into(),
value: Box::new(Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Lit { lit: Literal::Int { value: 5 } },
Term::Lit { lit: Literal::Int { value: 5 } },
],
tail: false,
}),
body: Box::new(Term::LetRec {
name: "loop".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
params: vec!["i".into()],
body: Box::new(Term::If {
cond: Box::new(Term::App {
callee: Box::new(Term::Var { name: ">=".into() }),
args: vec![
Term::Var { name: "i".into() },
Term::Var { name: "threshold".into() },
],
tail: false,
}),
then: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
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: "i".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: 0 } }],
tail: false,
}),
}),
};
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![fn_def(
"outer",
Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
vec!["n"],
body,
)],
};
check(&m).expect("LetRec with Let-binding capture should typecheck");
}
/// Iter 16b.3: a LetRec whose body returns the wrong type (Bool
/// instead of the declared Int) is caught by the new typing rule.
/// Property protected: the new arm in `synth` for `Term::LetRec`
/// runs `unify(&ret_ty, &body_ty, subst)` just like `Term::Lam`
/// and `check_fn`.
#[test]
fn letrec_body_wrong_return_type_is_rejected() {
// (let-rec wrong (params x) (type (Int) -> Int) (body true)
// (in (app wrong 0)))
let letrec = Term::LetRec {
name: "wrong".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
params: vec!["x".into()],
body: Box::new(Term::Lit { lit: Literal::Bool { value: true } }),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "wrong".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 0 } }],
tail: false,
}),
};
// Wrap in a Let so the desugar pass defers the LetRec (its
// body would otherwise lift cleanly with no captures, since
// `true` doesn't reference `x`).
let body = Term::Let {
name: "y".into(),
value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
// Reference `y` inside the LetRec body so it captures.
body: Box::new(Term::LetRec {
name: "wrong".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
params: vec!["x".into()],
body: Box::new(Term::Seq {
// Use `y` so the LetRec captures it (forces deferral).
lhs: Box::new(Term::Lit { lit: Literal::Unit }),
rhs: Box::new(Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "y".into() },
Term::Lit { lit: Literal::Bool { value: true } },
],
tail: false,
}),
}),
in_term: Box::new(Term::App {
callee: Box::new(Term::Var { name: "wrong".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 0 } }],
tail: false,
}),
}),
};
// (Suppress dead-code warning on the unused unwrapped letrec.)
let _ = letrec;
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![fn_def(
"outer",
Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
},
vec![],
body,
)],
};
let err = check(&m).expect_err("LetRec body returning Bool but declared Int must error");
let msg = format!("{err}");
assert!(
msg.contains("type mismatch"),
"expected a type-mismatch diagnostic, got: {msg}"
);
}
/// Iter 16b.3: `lift_letrecs` on a module containing a deferred
/// LetRec produces a module whose defs include a synthetic
/// `<hint>$lr_N` FnDef and whose original fn body has rewritten
/// call sites.
#[test]
fn lift_letrecs_on_let_capture_produces_synthetic_fn() {
// fn outer : () -> Int = \.
// let y = 7
// in let-rec helper : (Int) -> Int = \x. + x y
// in helper 1
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![fn_def(
"outer",
Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
},
vec![],
Term::Let {
name: "y".into(),
value: Box::new(Term::Lit { lit: Literal::Int { value: 7 } }),
body: Box::new(Term::LetRec {
name: "helper".into(),
ty: Type::Fn {
params: vec![Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
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,
}),
}),
},
)],
};
// Typecheck must succeed (the new LetRec rule accepts this).
check(&m).expect("typecheck before lift");
let desugared = ailang_core::desugar::desugar_module(&m);
// After desugar the LetRec is still present (Let-binding capture).
// After lift_letrecs it must be gone, replaced by a synthetic
// top-level fn with the capture appended to its signature.
let lifted = lift_letrecs(&desugared).expect("lift_letrecs");
assert_eq!(lifted.defs.len(), 2, "expected one synthetic fn appended");
let synth = match &lifted.defs[1] {
Def::Fn(f) => f,
_ => panic!("expected synthetic FnDef"),
};
assert!(
synth.name.starts_with("helper$lr_"),
"lifted name `{}` should start with `helper$lr_`",
synth.name
);
assert_eq!(
synth.ty,
Type::Fn {
params: vec![Type::int(), Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
"lifted ty should have capture appended; got {:?}",
synth.ty
);
assert_eq!(synth.params, vec!["x".to_string(), "y".to_string()]);
// `outer`'s body must have the `(app helper 1)` rewritten to
// `(app helper$lr_0 1 y)`. The body is now
// `let y = 7 in (app helper$lr_0 1 y)`.
let outer_body = match &lifted.defs[0] {
Def::Fn(f) => &f.body,
_ => unreachable!(),
};
let inner = match outer_body {
Term::Let { body, .. } => body.as_ref(),
other => panic!("expected outer Let, got {other:?}"),
};
match inner {
Term::App { callee, args, .. } => {
match callee.as_ref() {
Term::Var { name } => assert_eq!(name, &synth.name),
other => panic!("expected lifted callee, got {other:?}"),
}
assert_eq!(args.len(), 2, "expected 2 args (1 original + 1 capture)");
match &args[1] {
Term::Var { name } => assert_eq!(name, "y"),
other => panic!("expected y as second arg, got {other:?}"),
}
}
other => panic!("expected App after Let, got {other:?}"),
}
}
}
+720
View File
@@ -0,0 +1,720 @@
//! Iter 16b.3: post-typecheck `Term::LetRec` lift.
//!
//! Background. The 16a desugar pass (`ailang-core::desugar::desugar_module`)
//! eliminates most `Term::LetRec` nodes by lifting them to synthetic
//! top-level fns. That works as long as every captured name has a
//! statically-known type at desugar time — fn-params and Lam-params
//! qualify (16b.2). When a capture is bound by a `Term::Let`, the
//! let-value's type is inferred at typecheck and the desugar pass
//! cannot resolve it. In that case the desugar pass leaves the
//! `Term::LetRec` in place; this module's [`lift_letrecs`] picks them
//! up afterwards and lifts them using the typechecker's resolved
//! types.
//!
//! Pipeline placement. After [`crate::check`] succeeds. The lifter
//! produces a new `Module` whose `defs` may have additional synthetic
//! `Def::Fn` entries appended. The module's existing top-level defs
//! are NOT renamed; only the `body` of each `Def::Fn` (and the
//! `value` of each `Def::Const`, defensively) is rewritten.
//!
//! Symbol-hashing invariant. Synthetic FnDefs added by `lift_letrecs`
//! must NOT appear in `CheckedModule.symbols` — that table is built
//! from the original on-disk module, preserving the canonical-bytes
//! identity that `ail diff` and `ail manifest` rely on. The 16b.2
//! lift in desugar already follows the same convention. Concretely:
//! `check` keeps building symbols as today (from the input module);
//! `lift_letrecs` runs separately and returns the possibly-larger
//! module that goes to codegen.
use ailang_core::ast::*;
use ailang_core::desugar::{
find_non_callee_use, free_vars_in_term, subst_call_with_extras, subst_var,
};
use indexmap::IndexMap;
use std::collections::{BTreeMap, BTreeSet};
use crate::{builtins, synth, CheckError, Env, Result, Subst};
/// Iter 16b.3: post-typecheck pass that eliminates every surviving
/// `Term::LetRec` from `m` by lifting it to a synthetic top-level
/// `Def::Fn`. Returns a new module that goes to codegen.
///
/// Pre-condition: `m` has been typechecked (i.e. `check(m)` returned
/// `Ok`). The lifter calls `synth` to resolve capture types, but
/// only on sub-terms that were already typechecked successfully — it
/// does not perform new type checking, only type queries.
///
/// The synthetic FnDefs are appended to `Module.defs` after every
/// pre-existing def. Their names follow the `<hint>$lr_N` convention
/// from 16b.2; the lifter's counter starts past the highest `*$lr_N`
/// suffix already present in `m.defs` to avoid collisions with
/// 16b.2's lifts.
pub fn lift_letrecs(m: &Module) -> Result<Module> {
// Fast path: if the module contains no `Term::LetRec`, the lift
// pass has nothing to do. Returning the input module verbatim
// also means we don't build an env or run any sub-term type
// synthesis — important because cross-module references in
// typical fixtures would fail to resolve under the
// single-module env we build below (a deliberate scope choice:
// `lift_letrecs` is per-module, but cross-module info would only
// ever be needed if a deferred LetRec were present).
if !contains_any_letrec(m) {
return Ok(m.clone());
}
// Build the full env once (matches `check_in_workspace`'s setup),
// so we can re-synthesize sub-terms during the walk.
//
// `lift_letrecs` is a single-module pass; cross-module info isn't
// needed because the LetRec capture set comes from the *enclosing*
// fn's locals (which are always local to this module). Capture
// types may mention foreign type-cons (e.g. `std_list.List Int`),
// but those flow through verbatim — the lifter never needs to
// resolve them.
let mut env = Env::new();
builtins::install(&mut env);
// Type defs of this module.
for def in &m.defs {
if let Def::Type(td) = def {
for c in &td.ctors {
env.ctor_index.insert(
c.name.clone(),
crate::CtorRef {
type_name: td.name.clone(),
},
);
}
env.types.insert(td.name.clone(), td.clone());
}
}
// Top-level globals (fn / const types, including any 16b.2-lifted
// synthetic fns that already live in the desugared module).
for def in &m.defs {
let ty = match def {
Def::Fn(f) => f.ty.clone(),
Def::Const(c) => c.ty.clone(),
Def::Type(_) => Type::Con {
name: def.name().to_string(),
args: vec![],
},
};
env.globals.insert(def.name().to_string(), ty);
}
// Imports (used by qualified-ref synth, even though LetRec captures
// resolve through `locals`).
let mut import_map: BTreeMap<String, String> = BTreeMap::new();
for imp in &m.imports {
let key = imp.alias.clone().unwrap_or_else(|| imp.module.clone());
import_map.insert(key, imp.module.clone());
}
env.imports = import_map;
env.current_module = m.name.clone();
// Counter init: scan existing def names for the highest `*$lr_N`
// suffix so a new lift never collides with a 16b.2 lift.
let mut counter: u64 = highest_lr_suffix(&m.defs).map(|n| n + 1).unwrap_or(0);
// Pre-collect every existing top-level name so freshly-generated
// names cannot shadow them.
let mut module_names: BTreeSet<String> = BTreeSet::new();
for def in &m.defs {
module_names.insert(def.name().to_string());
}
// The walk.
let mut lifter = Lifter {
env,
counter,
module_names: &mut module_names,
lifted: Vec::new(),
};
let _ = &mut counter; // counter lives in lifter from here on
let mut out = m.clone();
for def in &mut out.defs {
match def {
Def::Fn(f) => {
// Build the locals scope for this fn (params with
// their declared types, peeling Forall like check_fn
// does).
let inner_ty = match &f.ty {
Type::Forall { body, .. } => (**body).clone(),
other => other.clone(),
};
let mut locals: IndexMap<String, Type> = IndexMap::new();
if let Type::Fn { params: ptys, .. } = &inner_ty {
if ptys.len() == f.params.len() {
for (n, t) in f.params.iter().zip(ptys.iter()) {
locals.insert(n.clone(), t.clone());
}
}
}
// Iter 13a: install rigid vars from a Forall enclosing
// fn so check_type_well_formed inside synth doesn't
// reject them. Save and restore so the lifter env is
// clean across defs.
let mut rigids_added: Vec<String> = Vec::new();
if let Type::Forall { vars, .. } = &f.ty {
for v in vars {
if lifter.env.rigid_vars.insert(v.clone()) {
rigids_added.push(v.clone());
}
}
}
f.body = lifter.lift_in_term(&f.body, &mut locals, &f.name)?;
for v in rigids_added {
lifter.env.rigid_vars.remove(&v);
}
}
Def::Const(c) => {
let mut locals: IndexMap<String, Type> = IndexMap::new();
c.value = lifter.lift_in_term(&c.value, &mut locals, &c.name)?;
}
Def::Type(_) => {}
}
}
out.defs.extend(lifter.lifted.into_iter());
Ok(out)
}
/// State for a single-module lift pass. Mirrors `Desugarer` in
/// `ailang-core::desugar` but resolves capture types via `synth`
/// instead of relying on a `ScopeEntry` map.
struct Lifter<'a> {
env: Env,
counter: u64,
module_names: &'a mut BTreeSet<String>,
lifted: Vec<Def>,
}
impl<'a> Lifter<'a> {
/// Walk `t`, lifting any `Term::LetRec` that survived desugar.
/// Maintains `locals` parallel to the term's lexical scope so we
/// can resolve capture types via `synth`.
fn lift_in_term(
&mut self,
t: &Term,
locals: &mut IndexMap<String, Type>,
in_def: &str,
) -> Result<Term> {
match t {
Term::Lit { .. } | Term::Var { .. } => Ok(t.clone()),
Term::App { callee, args, tail } => {
let new_callee = self.lift_in_term(callee, locals, in_def)?;
let mut new_args = Vec::with_capacity(args.len());
for a in args {
new_args.push(self.lift_in_term(a, locals, in_def)?);
}
Ok(Term::App {
callee: Box::new(new_callee),
args: new_args,
tail: *tail,
})
}
Term::Let { name, value, body } => {
let v = self.lift_in_term(value, locals, in_def)?;
// Synth the value's type (after lifting any nested
// LetRecs inside it), so the body's lift sees a
// resolved type for `name`.
let v_ty = self.synth_type(&v, locals, in_def)?;
let prev = locals.insert(name.clone(), v_ty);
let b = self.lift_in_term(body, locals, in_def)?;
match prev {
Some(p) => {
locals.insert(name.clone(), p);
}
None => {
locals.shift_remove(name);
}
}
Ok(Term::Let {
name: name.clone(),
value: Box::new(v),
body: Box::new(b),
})
}
Term::If { cond, then, else_ } => Ok(Term::If {
cond: Box::new(self.lift_in_term(cond, locals, in_def)?),
then: Box::new(self.lift_in_term(then, locals, in_def)?),
else_: Box::new(self.lift_in_term(else_, locals, in_def)?),
}),
Term::Do { op, args, tail } => {
let mut new_args = Vec::with_capacity(args.len());
for a in args {
new_args.push(self.lift_in_term(a, locals, in_def)?);
}
Ok(Term::Do {
op: op.clone(),
args: new_args,
tail: *tail,
})
}
Term::Ctor { type_name, ctor, args } => {
let mut new_args = Vec::with_capacity(args.len());
for a in args {
new_args.push(self.lift_in_term(a, locals, in_def)?);
}
Ok(Term::Ctor {
type_name: type_name.clone(),
ctor: ctor.clone(),
args: new_args,
})
}
Term::Match { scrutinee, arms } => {
let s = self.lift_in_term(scrutinee, locals, in_def)?;
// Synthesize the scrutinee's type so we can resolve
// pattern-arm bindings to typed locals.
let s_ty = self.synth_type(&s, locals, in_def)?;
let mut new_arms = Vec::with_capacity(arms.len());
for arm in arms {
let bindings = type_check_pattern_for_lift(&arm.pat, &s_ty, &self.env)?;
let mut pushed: Vec<(String, Option<Type>)> = Vec::new();
for (n, t) in &bindings {
let prev = locals.insert(n.clone(), t.clone());
pushed.push((n.clone(), prev));
}
let body = self.lift_in_term(&arm.body, locals, in_def)?;
for (n, prev) in pushed.into_iter().rev() {
match prev {
Some(p) => {
locals.insert(n, p);
}
None => {
locals.shift_remove(&n);
}
}
}
new_arms.push(Arm {
pat: arm.pat.clone(),
body,
});
}
Ok(Term::Match {
scrutinee: Box::new(s),
arms: new_arms,
})
}
Term::Lam { params, param_tys, ret_ty, effects, body } => {
let mut pushed: Vec<(String, Option<Type>)> = Vec::new();
for (n, t) in params.iter().zip(param_tys.iter()) {
let prev = locals.insert(n.clone(), t.clone());
pushed.push((n.clone(), prev));
}
let new_body = self.lift_in_term(body, locals, in_def)?;
for (n, prev) in pushed.into_iter().rev() {
match prev {
Some(p) => {
locals.insert(n, p);
}
None => {
locals.shift_remove(&n);
}
}
}
Ok(Term::Lam {
params: params.clone(),
param_tys: param_tys.clone(),
ret_ty: ret_ty.clone(),
effects: effects.clone(),
body: Box::new(new_body),
})
}
Term::Seq { lhs, rhs } => Ok(Term::Seq {
lhs: Box::new(self.lift_in_term(lhs, locals, in_def)?),
rhs: Box::new(self.lift_in_term(rhs, locals, in_def)?),
}),
Term::LetRec { name, ty, params, body, in_term } => {
// Iter 16b.3: post-order traversal — lift any inner
// LetRecs first. Within the body's scope, `name` and
// `params` are visible.
//
// Body-scope locals: name + params with their declared
// types (peeled from `ty` if Forall).
let inner_ty = match ty {
Type::Forall { body, .. } => (**body).clone(),
other => other.clone(),
};
let param_tys: Vec<Type> = match &inner_ty {
Type::Fn { params: ps, .. } => ps.clone(),
_ => {
return Err(CheckError::FnTypeRequired(
name.clone(),
ailang_core::pretty::type_to_string(&inner_ty),
));
}
};
let mut body_pushed: Vec<(String, Option<Type>)> = Vec::new();
let prev_name = locals.insert(name.clone(), ty.clone());
body_pushed.push((name.clone(), prev_name));
for (n, t) in params.iter().zip(param_tys.iter()) {
let prev = locals.insert(n.clone(), t.clone());
body_pushed.push((n.clone(), prev));
}
let lifted_body = self.lift_in_term(body, locals, in_def)?;
for (n, prev) in body_pushed.into_iter().rev() {
match prev {
Some(p) => {
locals.insert(n, p);
}
None => {
locals.shift_remove(&n);
}
}
}
// In-clause scope: only `name` is visible.
let prev_in = locals.insert(name.clone(), ty.clone());
let lifted_in = self.lift_in_term(in_term, locals, in_def)?;
match prev_in {
Some(p) => {
locals.insert(name.clone(), p);
}
None => {
locals.shift_remove(name);
}
}
// Defensive non-callee-use check (the desugar pass
// already ran this on the original LetRec, but
// post-lifting of inner LetRecs may have rewritten
// sub-terms — we re-run on the lifted body/in_term to
// be safe).
if find_non_callee_use(&lifted_body, name).is_some() {
panic!(
"Iter 16b.3 invariant: LetRec `{name}` used as a value in its own \
body after sub-term lift; should have been rejected at desugar"
);
}
if find_non_callee_use(&lifted_in, name).is_some() {
panic!(
"Iter 16b.3 invariant: LetRec `{name}` used as a value in its \
in-clause after sub-term lift; should have been rejected at desugar"
);
}
// Recompute captures of the lifted body against the
// current `locals` (deterministic order via BTreeSet).
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(&lifted_body, &local_bound, &mut frees);
let captures: Vec<String> = frees
.iter()
.filter(|f| locals.contains_key(*f))
.cloned()
.collect();
// Resolve each capture's type via the locals table
// (populated as we walked).
let mut capture_types: Vec<(String, Type)> = Vec::new();
for c in &captures {
let t = locals.get(c).cloned().unwrap_or_else(|| {
panic!(
"Iter 16b.3 invariant: LetRec `{name}` capture `{c}` not in \
locals at lift time — capture set inconsistent with env walk"
)
});
capture_types.push((c.clone(), t));
}
// Build the lifted FnDef.
let 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(),
}
}
Type::Forall { .. } => panic!(
"Iter 16b.3 invariant: LetRec `{name}` has Forall type at lift; \
desugar should have rejected (queued for 16b.6)"
),
other => panic!(
"Iter 16b.3 invariant: LetRec `{name}` non-Fn/Forall type {:?}",
other
),
};
let mut augmented_params = params.clone();
for (cn, _) in &capture_types {
augmented_params.push(cn.clone());
}
let lifted_name = self.fresh_lifted(name);
let extras: Vec<String> =
capture_types.iter().map(|(n, _)| n.clone()).collect();
// Body rewrite: every `(app name args)` → `(app
// lifted_name args... cap0 cap1 ...)`. Then rename
// any leftover `Var{name}` (none in practice — already
// ruled out by find_non_callee_use). Symmetrical with
// the 16b.2 desugar lift.
let body_call_rw =
subst_call_with_extras(&lifted_body, name, &lifted_name, &extras);
let body_full = subst_var(&body_call_rw, name, &lifted_name);
// In-clause rewrite: same shape.
let in_call_rw =
subst_call_with_extras(&lifted_in, name, &lifted_name, &extras);
let in_full = subst_var(&in_call_rw, name, &lifted_name);
// Append the lifted FnDef. The doc string makes
// post-mortem debugging easier — anything containing
// `$lr_` plus this string is a 16b.3 lift.
let doc = format!(
"Lifted by 16b.3 from let-rec '{name}' inside '{in_def}'."
);
self.lifted.push(Def::Fn(FnDef {
name: lifted_name.clone(),
ty: augmented_ty.clone(),
params: augmented_params,
body: body_full,
doc: Some(doc),
}));
// Update env globals so any outer LetRec lifted later
// sees the new top-level fn.
self.env
.globals
.insert(lifted_name.clone(), augmented_ty);
self.module_names.insert(lifted_name);
Ok(in_full)
}
}
}
/// Iter 16b.3: produce a fresh `<hint>$lr_N` name not present in
/// `module_names`. Bumps `counter` and `module_names` so a later
/// lift cannot collide.
fn fresh_lifted(&mut self, hint: &str) -> String {
loop {
let candidate = format!("{hint}$lr_{}", self.counter);
self.counter += 1;
if !self.module_names.contains(&candidate) {
self.module_names.insert(candidate.clone());
return candidate;
}
}
}
/// Synthesize the type of an already-lifted sub-term against the
/// current env+locals. Used for `Term::Let.value` (so the body's
/// `name` gets a typed local) and `Term::Match.scrutinee` (so
/// pattern bindings get typed locals).
///
/// We re-run inference on the sub-term — it has already passed
/// the typechecker once before lift, so this is guaranteed to
/// succeed under the same env / locals.
fn synth_type(
&mut self,
t: &Term,
locals: &mut IndexMap<String, Type>,
in_def: &str,
) -> Result<Type> {
let mut subst = Subst::default();
let mut counter: u32 = 0;
let mut effects: BTreeSet<String> = BTreeSet::new();
let ty = synth(t, &self.env, locals, &mut effects, in_def, &mut subst, &mut counter)?;
Ok(subst.apply(&ty))
}
}
/// Iter 16b.3: returns true iff any `Def::Fn` body or `Def::Const`
/// value in `m` reaches a `Term::LetRec`. Used as a fast-path skip:
/// modules without any deferred LetRec need no traversal at all.
fn contains_any_letrec(m: &Module) -> bool {
fn term_has_letrec(t: &Term) -> bool {
match t {
Term::Lit { .. } | Term::Var { .. } => false,
Term::App { callee, args, .. } => {
term_has_letrec(callee) || args.iter().any(term_has_letrec)
}
Term::Let { value, body, .. } => term_has_letrec(value) || term_has_letrec(body),
Term::If { cond, then, else_ } => {
term_has_letrec(cond) || term_has_letrec(then) || term_has_letrec(else_)
}
Term::Do { args, .. } => args.iter().any(term_has_letrec),
Term::Ctor { args, .. } => args.iter().any(term_has_letrec),
Term::Match { scrutinee, arms } => {
term_has_letrec(scrutinee) || arms.iter().any(|a| term_has_letrec(&a.body))
}
Term::Lam { body, .. } => term_has_letrec(body),
Term::Seq { lhs, rhs } => term_has_letrec(lhs) || term_has_letrec(rhs),
Term::LetRec { .. } => true,
}
}
for def in &m.defs {
match def {
Def::Fn(f) if term_has_letrec(&f.body) => return true,
Def::Const(c) if term_has_letrec(&c.value) => return true,
_ => {}
}
}
false
}
/// Iter 16b.3: scan `defs` for the highest existing `*$lr_N` suffix
/// and return that N. Used to seed `Lifter.counter` past any
/// 16b.2-lifted defs that already live in the desugared module.
fn highest_lr_suffix(defs: &[Def]) -> Option<u64> {
let mut max: Option<u64> = None;
for def in defs {
let name = def.name();
if let Some(idx) = name.rfind("$lr_") {
let suffix = &name[idx + "$lr_".len()..];
if let Ok(n) = suffix.parse::<u64>() {
max = Some(max.map(|m| m.max(n)).unwrap_or(n));
}
}
}
max
}
/// Iter 16b.3: minimal pattern → bindings inference for the lift
/// pass. Called only for patterns the typechecker has already
/// accepted, so we propagate just enough to resolve capture types
/// (we don't re-run unification here — the typechecker did that
/// already).
///
/// Mirrors the relevant arms of `crate::type_check_pattern` but only
/// for the side effect we need: returning a mapping
/// `binder_name -> Type`. Returns `Internal` if the pattern shape
/// cannot be handled — every shape that the typechecker accepts
/// reaches here.
fn type_check_pattern_for_lift(
p: &Pattern,
s_ty: &Type,
env: &Env,
) -> Result<Vec<(String, Type)>> {
match p {
Pattern::Wild | Pattern::Lit { .. } => Ok(vec![]),
Pattern::Var { name } => Ok(vec![(name.clone(), s_ty.clone())]),
Pattern::Ctor { ctor, fields } => {
// Resolve the ctor against the scrutinee's type.
let (td, type_args, owner_module): (TypeDef, Vec<Type>, Option<String>) =
match s_ty {
Type::Con { name, args } => {
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
let target_module = env
.imports
.get(prefix)
.cloned()
.or_else(|| {
if env.module_types.contains_key(prefix) {
Some(prefix.to_string())
} else {
None
}
})
.ok_or_else(|| CheckError::UnknownModule {
module: prefix.to_string(),
})?;
let td = env
.module_types
.get(&target_module)
.and_then(|tys| tys.get(suffix))
.cloned()
.ok_or_else(|| CheckError::UnknownType(name.clone()))?;
(td, args.clone(), Some(target_module))
} else {
let td = env
.types
.get(name)
.cloned()
.ok_or_else(|| CheckError::UnknownType(name.clone()))?;
(td, args.clone(), None)
}
}
_ => {
return Err(CheckError::PatternTypeMismatch {
ctor: ctor.clone(),
ty: ailang_core::pretty::type_to_string(s_ty),
});
}
};
let cdef = td
.ctors
.iter()
.find(|c| &c.name == ctor)
.cloned()
.ok_or_else(|| CheckError::UnknownCtor {
ty: td.name.clone(),
ctor: ctor.clone(),
})?;
if fields.len() != cdef.fields.len() {
return Err(CheckError::CtorArity {
ty: td.name.clone(),
ctor: ctor.clone(),
expected: cdef.fields.len(),
got: fields.len(),
});
}
// Substitute the ADT's type vars with the actual args
// from the scrutinee's `Type::Con.args`. Field types may
// contain owning-module-qualified type-cons references
// (the same qualification dance `synth` does for
// `Term::Ctor`), but we don't need to do it here — the
// pattern var's recorded type is consumed only by the
// lifter to seed locals, and any capture's type that
// includes a qualified type-cons gets passed verbatim
// into the lifted FnDef's signature.
let _ = owner_module; // (unused in this minimal lookup)
let mut mapping: BTreeMap<String, Type> = BTreeMap::new();
for (v, a) in td.vars.iter().zip(type_args.iter()) {
mapping.insert(v.clone(), a.clone());
}
let mut out: Vec<(String, Type)> = Vec::new();
for (sub_pat, field_ty) in fields.iter().zip(cdef.fields.iter()) {
let inst_ty = substitute_rigids_local(field_ty, &mapping);
out.extend(type_check_pattern_for_lift(sub_pat, &inst_ty, env)?);
}
Ok(out)
}
}
}
/// Local copy of the substitution helper from `crate`. Inlined here
/// because the original is private to the parent module; the
/// lifter only needs the mono-substitution case.
fn substitute_rigids_local(t: &Type, mapping: &BTreeMap<String, Type>) -> Type {
match t {
Type::Var { name } => mapping.get(name).cloned().unwrap_or_else(|| t.clone()),
Type::Con { name, args } => Type::Con {
name: name.clone(),
args: args.iter().map(|a| substitute_rigids_local(a, mapping)).collect(),
},
Type::Fn { params, ret, effects } => Type::Fn {
params: params
.iter()
.map(|p| substitute_rigids_local(p, mapping))
.collect(),
ret: Box::new(substitute_rigids_local(ret, mapping)),
effects: effects.clone(),
},
Type::Forall { vars, body } => {
let inner: BTreeMap<String, Type> = mapping
.iter()
.filter(|(k, _)| !vars.contains(k))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Type::Forall {
vars: vars.clone(),
body: Box::new(substitute_rigids_local(body, &inner)),
}
}
}
}