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
+135 -57
View File
@@ -462,11 +462,26 @@ impl Desugarer {
// Iter 16b.1: lift to a synthetic top-level fn (no-capture).
// Iter 16b.2: extend the lift to the path-1 safe subset —
// captures of fn-params / Lam-params (whose types are
// known statically). Captures of Let / MatchArm /
// EnclosingLetRec names, captures from a polymorphic
// enclosing fn, and any non-callee use of `name` are
// still rejected at desugar time, with a follow-up
// iter pointer in the panic message.
// known statically).
// Iter 16b.3: 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.
//
// 16b.2's fast path (KnownType-only captures → lift here)
// STILL fires when applicable. Only when at least one
// capture is `LetBound` does the desugar defer.
//
// `MatchArm` and `EnclosingLetRec` captures STILL panic
// (16b.4 / 16b.7 — separate iters needing extra
// machinery beyond what `lift_letrecs` does).
//
// 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
@@ -474,13 +489,13 @@ impl Desugarer {
let inner_params_tys: Vec<Type> = match peel_forall_to_fn(ty) {
Some(Type::Fn { params: ps, .. }) => ps.clone(),
_ => panic!(
"Iter 16b.2: LetRec `{}` must have a Fn type (or Forall<Fn>); got {:?}",
"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.2: LetRec `{}` param count {} != type's param count {}",
"Iter 16b.3: LetRec `{}` param count {} != type's param count {}",
name, params.len(), inner_params_tys.len()
);
}
@@ -500,6 +515,29 @@ impl Desugarer {
in_scope.insert(name.clone(), ScopeEntry::EnclosingLetRec);
let desugared_in = self.desugar_term(in_term, &in_scope);
// 16b.2/16b.5: validate that `name` is only ever used as
// the callee of a Term::App in body and in_term — never
// as a Term::Var in any value position. Run BEFORE
// capture classification so the diagnostic fires
// regardless of whether we end up lifting here or
// deferring to the post-typecheck pass.
if let Some(violation) = find_non_callee_use(&desugared_body, name) {
panic!(
"Iter 16b.3: LetRec `{}` is used as a value in its own body \
(not as the callee of `app`); not supported. Queued for 16b.5 \
(closure conversion). Offending term: {:?}",
name, violation
);
}
if let Some(violation) = find_non_callee_use(&desugared_in, name) {
panic!(
"Iter 16b.3: LetRec `{}` is used as a value in the in-clause \
(not as the callee of `app`); not supported. Queued for 16b.5. \
Offending term: {:?}",
name, violation
);
}
// Capture detection (against the *outer* scope, before
// the body-scope extension).
let mut local_bound: BTreeSet<String> = BTreeSet::new();
@@ -515,29 +553,30 @@ impl Desugarer {
.cloned()
.collect();
// 16b.2: classify each capture's ScopeEntry. KnownType
// is supported; everything else errors with a follow-up
// iter pointer.
let mut capture_types: Vec<(String, Type)> = Vec::new();
// 16b.3: classify each capture's ScopeEntry.
// - All KnownType → lift here (16b.2 fast path).
// - Any LetBound → defer to `lift_letrecs` (16b.3).
// - Any MatchArm → panic (16b.4).
// - Any EnclosingLetRec → panic (16b.7).
// Mixed KnownType+LetBound get deferred (decision is
// all-or-nothing for a single LetRec; the
// post-typecheck pass handles every capture kind it
// supports uniformly).
let mut has_let_bound = false;
for c in &captures {
match scope.get(c).expect("capture-in-scope-by-construction") {
ScopeEntry::KnownType(t) => {
capture_types.push((c.clone(), t.clone()));
ScopeEntry::KnownType(_) => {}
ScopeEntry::LetBound => {
has_let_bound = true;
}
ScopeEntry::LetBound => panic!(
"Iter 16b.2: LetRec `{}` captures `{}` from a let-binding \
(or polymorphic enclosing fn); not supported. Queued for \
16b.3 (let-binding captures with type info from typecheck).",
name, c
),
ScopeEntry::MatchArm => panic!(
"Iter 16b.2: LetRec `{}` captures `{}` from a match-arm \
"Iter 16b.3: LetRec `{}` captures `{}` from a match-arm \
pattern binding; not supported. Queued for 16b.4 (match-arm \
captures with constructor-field substitution).",
name, c
),
ScopeEntry::EnclosingLetRec => panic!(
"Iter 16b.2: LetRec `{}` captures `{}` from an enclosing \
"Iter 16b.3: LetRec `{}` captures `{}` from an enclosing \
LetRec; nested mutual-capture is not supported. Queued for \
16b.7.",
name, c
@@ -545,29 +584,29 @@ impl Desugarer {
}
}
// 16b.2: validate that `name` is only ever used as the
// callee of a Term::App in body and in_term — never as
// a Term::Var in any value position. This guards
// against `(let g f ...)` bindings and `(app some_hof
// f)` value-passing, which would need closure
// conversion.
if let Some(violation) = find_non_callee_use(&desugared_body, name) {
panic!(
"Iter 16b.2: LetRec `{}` is used as a value in its own body \
(not as the callee of `app`); not supported. Queued for 16b.5 \
(closure conversion). Offending term: {:?}",
name, violation
);
}
if let Some(violation) = find_non_callee_use(&desugared_in, name) {
panic!(
"Iter 16b.2: LetRec `{}` is used as a value in the in-clause \
(not as the callee of `app`); not supported. Queued for 16b.5. \
Offending term: {:?}",
name, violation
);
// 16b.3: defer-arm. At least one capture is LetBound, 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 has_let_bound {
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`. A Forall LetRec is rejected
// (would need synthesised Forall — out of scope).
@@ -584,12 +623,12 @@ impl Desugarer {
}
}
Type::Forall { .. } => panic!(
"Iter 16b.2: LetRec `{}` has a Forall type; captures from a \
"Iter 16b.3: LetRec `{}` has a Forall type; captures from a \
polymorphic context are not supported. Queued for 16b.6.",
name
),
other => panic!(
"Iter 16b.2: LetRec `{}` has non-Fn/Forall type {:?}",
"Iter 16b.3: LetRec `{}` has non-Fn/Forall type {:?}",
name, other
),
};
@@ -830,9 +869,13 @@ fn build_eq(scrutinee: Term, lit: &Literal) -> Term {
/// 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
/// (16b.2 has not shipped yet, so capture is a panic at desugar time).
fn free_vars_in_term(t: &Term, bound: &BTreeSet<String>, out: &mut BTreeSet<String>) {
/// recursive let would capture any name from the enclosing scope.
///
/// Iter 16b.3: 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 } => {
@@ -905,7 +948,11 @@ fn free_vars_in_term(t: &Term, bound: &BTreeSet<String>, out: &mut BTreeSet<Stri
/// Iter 16b.1: 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.
fn pattern_binds(p: &Pattern, out: &mut BTreeSet<String>) {
///
/// Iter 16b.3: 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 } => {
@@ -927,7 +974,10 @@ fn pattern_binds(p: &Pattern, out: &mut BTreeSet<String>) {
/// 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.
fn subst_var(t: &Term, from: &str, to: &str) -> Term {
///
/// Iter 16b.3: 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 } => {
@@ -1046,7 +1096,12 @@ fn peel_forall_to_fn(t: &Type) -> Option<&Type> {
/// 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`].
fn subst_call_with_extras(t: &Term, name: &str, lifted: &str, extras: &[String]) -> Term {
///
/// Iter 16b.3: 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(),
@@ -1142,7 +1197,10 @@ fn subst_call_with_extras(t: &Term, name: &str, lifted: &str, extras: &[String])
/// `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.
fn find_non_callee_use(t: &Term, name: &str) -> Option<Term> {
///
/// Iter 16b.3: 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()),
@@ -1556,12 +1614,14 @@ mod tests {
}
}
/// Iter 16b.2: a LetRec whose body captures a `Term::Let`-bound
/// name has no statically-known type at desugar time; the
/// lifter rejects with a `16b.3` pointer.
/// Iter 16b.3: 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]
#[should_panic(expected = "16b.3")]
fn let_rec_capture_let_binding_panics() {
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 {
@@ -1606,7 +1666,25 @@ mod tests {
doc: None,
})],
};
let _ = desugar_module(&m);
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:#?}"
);
}
/// Iter 16b.2: if the LetRec name is used as a value (not as