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
+23
View File
@@ -1512,6 +1512,13 @@ fn render_workspace_diff_text(r: &WorkspaceDiffReport) -> String {
/// clang. On typecheck failure, prints diagnostics to stderr and exits
/// the process with code 1. On clang failure, returns a Result error
/// (the .ll path is preserved for post-mortem inspection).
///
/// Iter 16b.3: between `check_workspace` and `lower_workspace` we run
/// `ailang_check::lift_letrecs` per module. The lift eliminates any
/// `Term::LetRec` that the desugar pass left in place (specifically:
/// LetRecs that capture `Term::Let`-bound names, whose types are
/// only known after typecheck). The lifted workspace then goes to
/// codegen unchanged.
fn build_to(path: &Path, out: Option<PathBuf>, opt: &str) -> Result<PathBuf> {
let ws = ailang_core::load_workspace(path)?;
let diags = ailang_check::check_workspace(&ws);
@@ -1533,6 +1540,22 @@ fn build_to(path: &Path, out: Option<PathBuf>, opt: &str) -> Result<PathBuf> {
}
std::process::exit(1);
}
// Iter 16b.3: run `lift_letrecs` per module on the post-desugar
// form. Codegen's internal desugar pass is idempotent on a
// module that contains no `Term::LetRec`, so the lifted output
// can be handed directly to `lower_workspace`.
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.map_err(|e| anyhow::anyhow!("lift_letrecs in module `{mname}`: {e}"))?;
lifted_modules.insert(mname.clone(), lifted);
}
let ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
};
let ir = ailang_codegen::lower_workspace(&ws)?;
let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id()));
std::fs::create_dir_all(&tmpdir)?;