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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user