Iter 16b.1 — local recursive let (no-capture)
Add `(let-rec NAME (params ...) (type ...) (body ...) (in ...))` as a form-A surface and a `Term::LetRec` AST variant. The 16a desugar pass lifts each LetRec whose body has no captures from the enclosing scope to a synthetic top-level fn `<hint>$lr_N` and substitutes the original name; typecheck and codegen never see LetRec. Capture detection panics at desugar time, queued for 16b.2. Tests: 95 → 99 (+1 e2e local_rec_factorial_demo, +2 desugar unit, +1 parse unit). The new fixture `examples/local_rec_demo` runs `fact` at n=1, 3, 5 → prints 1, 6, 120. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -931,6 +931,28 @@ fn walk_term(
|
||||
walk_term(lhs, out, builtins, scope);
|
||||
walk_term(rhs, out, builtins, scope);
|
||||
}
|
||||
Term::LetRec { name, params, body, in_term, .. } => {
|
||||
// Iter 16b.1: `deps` walks the on-disk module before the
|
||||
// desugar pass, so a `Term::LetRec` is reachable here.
|
||||
// Treat it like a fn def for dependency purposes:
|
||||
// `name` shadows for both body and in_term; params shadow
|
||||
// inside the body.
|
||||
let inserted_name = scope.insert(name.clone());
|
||||
let mut newly = Vec::new();
|
||||
for p in params {
|
||||
if scope.insert(p.clone()) {
|
||||
newly.push(p.clone());
|
||||
}
|
||||
}
|
||||
walk_term(body, out, builtins, scope);
|
||||
for p in newly {
|
||||
scope.remove(&p);
|
||||
}
|
||||
walk_term(in_term, out, builtins, scope);
|
||||
if inserted_name {
|
||||
scope.remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -432,6 +432,24 @@ fn std_list_more_demo() {
|
||||
assert_eq!(lines, vec!["0", "3", "5", "5", "3", "0"]);
|
||||
}
|
||||
|
||||
/// Iter 16b.1: local recursive `let`. Property protected: a
|
||||
/// `(let-rec ...)` term in a fn body whose recursive body has no
|
||||
/// captures from the enclosing scope is lifted by the 16a desugar
|
||||
/// pass to a synthetic top-level fn (`<name>$lr_N`), and the
|
||||
/// resulting module type-checks and runs identically to a
|
||||
/// hand-written top-level recursive fn. The fixture exercises
|
||||
/// `fact` at n=1 (base case), n=3, n=5 — outputs 1, 6, 120.
|
||||
/// If the desugar lift regressed (e.g. failed to substitute call
|
||||
/// sites in the in-term, or generated a colliding lifted name),
|
||||
/// the build would fail at typecheck or the binary would print
|
||||
/// wrong values.
|
||||
#[test]
|
||||
fn local_rec_factorial_demo() {
|
||||
let stdout = build_and_run("local_rec_demo.ail.json");
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
assert_eq!(lines, vec!["1", "6", "120"]);
|
||||
}
|
||||
|
||||
/// Guards `ail diff`: a modified body changes the hash of `sum`, while
|
||||
/// `main` stays unchanged. Expects exit code 1, `changed` contains exactly
|
||||
/// `sum`, `unchanged` contains `main`, `added`/`removed` empty.
|
||||
|
||||
@@ -1083,6 +1083,11 @@ 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1483,6 +1488,11 @@ 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1039,6 +1039,12 @@ impl<'a> Emitter<'a> {
|
||||
let _ = self.lower_term(lhs)?;
|
||||
self.lower_term(rhs)
|
||||
}
|
||||
Term::LetRec { .. } => {
|
||||
// Iter 16b.1: `Term::LetRec` is eliminated by the
|
||||
// desugar pass before codegen runs, so reaching it
|
||||
// here is a bug.
|
||||
unreachable!("Term::LetRec eliminated by desugar")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2122,6 +2128,10 @@ impl<'a> Emitter<'a> {
|
||||
Self::collect_captures(lhs, bound, captures, captures_set, builtins, top_level);
|
||||
Self::collect_captures(rhs, bound, captures, captures_set, builtins, top_level);
|
||||
}
|
||||
Term::LetRec { .. } => {
|
||||
// Iter 16b.1: eliminated by desugar before codegen.
|
||||
unreachable!("Term::LetRec eliminated by desugar")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2548,6 +2558,10 @@ impl<'a> Emitter<'a> {
|
||||
}
|
||||
}
|
||||
Term::Seq { rhs, .. } => self.synth_with_extras(rhs, extras),
|
||||
Term::LetRec { .. } => {
|
||||
// Iter 16b.1: eliminated by desugar before codegen.
|
||||
unreachable!("Term::LetRec eliminated by desugar")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2898,6 +2912,11 @@ fn apply_subst_to_term(t: &Term, subst: &BTreeMap<String, Type>) -> Term {
|
||||
lhs: Box::new(apply_subst_to_term(lhs, subst)),
|
||||
rhs: Box::new(apply_subst_to_term(rhs, subst)),
|
||||
},
|
||||
Term::LetRec { .. } => {
|
||||
// Iter 16b.1: eliminated by desugar before any
|
||||
// monomorphisation pass runs.
|
||||
unreachable!("Term::LetRec eliminated by desugar")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,22 @@ pub enum Term {
|
||||
value: Box<Term>,
|
||||
body: Box<Term>,
|
||||
},
|
||||
/// Local recursive let-binding (Iter 16b.1). Always fn-shaped:
|
||||
/// the bound name `name` is recursively visible inside `body`.
|
||||
/// Eliminated by `crate::desugar` before typecheck — lifted to a
|
||||
/// synthetic top-level fn when `body` does not capture any name
|
||||
/// from the enclosing lexical scope. The on-disk schema gains the
|
||||
/// `"t": "letrec"` tag; pre-existing fixtures hash bit-identically
|
||||
/// because the variant is additive.
|
||||
LetRec {
|
||||
name: String,
|
||||
#[serde(rename = "type")]
|
||||
ty: Type,
|
||||
params: Vec<String>,
|
||||
body: Box<Term>,
|
||||
#[serde(rename = "in")]
|
||||
in_term: Box<Term>,
|
||||
},
|
||||
/// If-expression. Both branches must have the same type.
|
||||
If {
|
||||
cond: Box<Term>,
|
||||
|
||||
@@ -65,6 +65,29 @@
|
||||
//! 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`].
|
||||
@@ -72,34 +95,75 @@
|
||||
//! - 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::BTreeSet;
|
||||
|
||||
/// Rewrite `m` so that every [`Pattern::Ctor`] sub-pattern is a
|
||||
/// [`Pattern::Var`] or [`Pattern::Wild`] (i.e. flat).
|
||||
/// [`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 is returned as-is
|
||||
/// modulo cloning.
|
||||
/// 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) => collect_used_in_term(&f.body, &mut used),
|
||||
Def::Const(c) => collect_used_in_term(&c.value, &mut used),
|
||||
Def::Type(_) => {}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut d = Desugarer { counter: 0, used };
|
||||
// Iter 16b.1: 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,
|
||||
};
|
||||
let mut out = m.clone();
|
||||
for def in &mut out.defs {
|
||||
match def {
|
||||
Def::Fn(f) => f.body = d.desugar_term(&f.body),
|
||||
Def::Const(c) => c.value = d.desugar_term(&c.value),
|
||||
Def::Fn(f) => {
|
||||
let scope: BTreeSet<String> = f.params.iter().cloned().collect();
|
||||
f.body = d.desugar_term(&f.body, &scope);
|
||||
}
|
||||
Def::Const(c) => {
|
||||
let scope: BTreeSet<String> = BTreeSet::new();
|
||||
c.value = d.desugar_term(&c.value, &scope);
|
||||
}
|
||||
Def::Type(_) => {}
|
||||
}
|
||||
}
|
||||
// Iter 16b.1: 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.into_iter());
|
||||
out
|
||||
}
|
||||
|
||||
@@ -156,6 +220,14 @@ fn collect_used_in_term(t: &Term, used: &mut BTreeSet<String>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,9 +252,21 @@ fn collect_used_in_pattern(p: &Pattern, used: &mut BTreeSet<String>) {
|
||||
/// `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.
|
||||
///
|
||||
/// Iter 16b.1 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.
|
||||
struct Desugarer {
|
||||
counter: u64,
|
||||
used: BTreeSet<String>,
|
||||
lifted: Vec<Def>,
|
||||
module_top_names: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Desugarer {
|
||||
@@ -201,45 +285,81 @@ impl Desugarer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 16b.1: 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).
|
||||
fn desugar_term(&mut self, t: &Term) -> Term {
|
||||
/// [`desugar_match`](Self::desugar_match) and a [`Term::LetRec`]
|
||||
/// to the 16b.1 lifter.
|
||||
///
|
||||
/// `scope` is the set of names lexically bound at this point —
|
||||
/// initialised at the def boundary (fn params for [`Def::Fn`],
|
||||
/// empty for [`Def::Const`]) and extended locally by every
|
||||
/// binder ([`Term::Let`], [`Term::Lam`], [`Term::Match`] arms,
|
||||
/// [`Term::LetRec`]). Used by the LetRec lifter to detect
|
||||
/// captures.
|
||||
fn desugar_term(&mut self, t: &Term, scope: &BTreeSet<String>) -> Term {
|
||||
match t {
|
||||
Term::Lit { .. } | Term::Var { .. } => t.clone(),
|
||||
Term::App { callee, args, tail } => Term::App {
|
||||
callee: Box::new(self.desugar_term(callee)),
|
||||
args: args.iter().map(|a| self.desugar_term(a)).collect(),
|
||||
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 } => Term::Let {
|
||||
name: name.clone(),
|
||||
value: Box::new(self.desugar_term(value)),
|
||||
body: Box::new(self.desugar_term(body)),
|
||||
},
|
||||
Term::Let { name, value, body } => {
|
||||
let v = self.desugar_term(value, scope);
|
||||
let mut inner = scope.clone();
|
||||
inner.insert(name.clone());
|
||||
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)),
|
||||
then: Box::new(self.desugar_term(then)),
|
||||
else_: Box::new(self.desugar_term(else_)),
|
||||
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)).collect(),
|
||||
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)).collect(),
|
||||
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);
|
||||
let scrutinee = self.desugar_term(scrutinee, scope);
|
||||
let arms: Vec<Arm> = arms
|
||||
.iter()
|
||||
.map(|a| Arm {
|
||||
pat: a.pat.clone(),
|
||||
body: self.desugar_term(&a.body),
|
||||
.map(|a| {
|
||||
let mut inner = scope.clone();
|
||||
pattern_binds(&a.pat, &mut inner);
|
||||
Arm {
|
||||
pat: a.pat.clone(),
|
||||
body: self.desugar_term(&a.body, &inner),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.desugar_match(scrutinee, arms)
|
||||
@@ -250,17 +370,76 @@ impl Desugarer {
|
||||
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(self.desugar_term(body)),
|
||||
},
|
||||
} => {
|
||||
let mut inner = scope.clone();
|
||||
for p in params {
|
||||
inner.insert(p.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)),
|
||||
rhs: Box::new(self.desugar_term(rhs)),
|
||||
lhs: Box::new(self.desugar_term(lhs, scope)),
|
||||
rhs: Box::new(self.desugar_term(rhs, scope)),
|
||||
},
|
||||
Term::LetRec { name, ty, params, body, in_term } => {
|
||||
// Iter 16b.1: lift to a synthetic top-level fn.
|
||||
//
|
||||
// Body's scope: outer ∪ {name} ∪ params (recursive
|
||||
// self-reference is legal; params shadow outer names).
|
||||
let mut body_scope = scope.clone();
|
||||
body_scope.insert(name.clone());
|
||||
for p in params {
|
||||
body_scope.insert(p.clone());
|
||||
}
|
||||
let desugared_body = self.desugar_term(body, &body_scope);
|
||||
// in_term's scope: outer ∪ {name} (params are lambda-
|
||||
// local to the LetRec's body, not visible in `in`).
|
||||
let mut in_scope = scope.clone();
|
||||
in_scope.insert(name.clone());
|
||||
let desugared_in = self.desugar_term(in_term, &in_scope);
|
||||
|
||||
// Capture detection: free vars of the desugared body
|
||||
// wrt {name} ∪ params, intersected with the outer
|
||||
// `scope`. Anything in that intersection is a capture
|
||||
// (16b.2 territory).
|
||||
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.intersection(scope).cloned().collect();
|
||||
if !captures.is_empty() {
|
||||
panic!(
|
||||
"Iter 16b.1: local recursive `let` `{}` would capture {:?} \
|
||||
from enclosing scope; capture is queued for 16b.2. Lift \
|
||||
the helper to top-level for now.",
|
||||
name, captures
|
||||
);
|
||||
}
|
||||
|
||||
// Lift: synthesise a fresh top-level def name and
|
||||
// substitute every reference to the local `name`
|
||||
// (in body and in_term) to the lifted name.
|
||||
let lifted_name = self.fresh_lifted(name);
|
||||
let body_lifted = subst_var(&desugared_body, name, &lifted_name);
|
||||
let in_lifted = subst_var(&desugared_in, name, &lifted_name);
|
||||
self.lifted.push(Def::Fn(FnDef {
|
||||
name: lifted_name,
|
||||
ty: ty.clone(),
|
||||
params: params.clone(),
|
||||
body: body_lifted,
|
||||
doc: None,
|
||||
}));
|
||||
in_lifted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +591,213 @@ fn is_flat(p: &Pattern) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 16b.1: 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
|
||||
/// (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>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
match p {
|
||||
Pattern::Wild | Pattern::Lit { .. } => {}
|
||||
Pattern::Var { name } => {
|
||||
out.insert(name.clone());
|
||||
}
|
||||
Pattern::Ctor { fields, .. } => {
|
||||
for sub in fields {
|
||||
pattern_binds(sub, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 16b.1: 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.
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -446,6 +832,32 @@ mod tests {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,4 +980,127 @@ mod tests {
|
||||
// at the top level (no Let-wrap).
|
||||
assert!(matches!(body, Term::Match { .. }));
|
||||
}
|
||||
|
||||
/// Iter 16b.1: 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![],
|
||||
},
|
||||
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(),
|
||||
imports: vec![],
|
||||
defs: vec![Def::Fn(FnDef {
|
||||
name: "main".into(),
|
||||
ty: Type::Fn {
|
||||
params: vec![],
|
||||
ret: Box::new(Type::int()),
|
||||
effects: vec![],
|
||||
},
|
||||
params: vec![],
|
||||
body: fact_letrec_term(),
|
||||
doc: 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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 16b.1: a LetRec whose body references a var bound in the
|
||||
/// enclosing scope (here, `outer` is a parameter of the
|
||||
/// surrounding fn) panics at desugar time. 16b.2 will lift this.
|
||||
#[test]
|
||||
#[should_panic(expected = "would capture")]
|
||||
fn let_rec_with_capture_panics() {
|
||||
let m = Module {
|
||||
schema: crate::SCHEMA.to_string(),
|
||||
name: "t".into(),
|
||||
imports: vec![],
|
||||
defs: vec![Def::Fn(FnDef {
|
||||
name: "main".into(),
|
||||
ty: Type::Fn {
|
||||
params: vec![Type::int()],
|
||||
ret: Box::new(Type::int()),
|
||||
effects: vec![],
|
||||
},
|
||||
params: vec!["outer".into()],
|
||||
// (let-rec helper (params x) ... (body (app + x outer)) ...)
|
||||
body: 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: "outer".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,
|
||||
}),
|
||||
},
|
||||
doc: None,
|
||||
})],
|
||||
};
|
||||
let _ = desugar_module(&m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
//! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit
|
||||
//! | app-term | tail-app-term | match-term | ctor-term
|
||||
//! | do-term | tail-do-term | seq-term | lam-term | if-term
|
||||
//! | let-term
|
||||
//! | let-term | let-rec-term
|
||||
//! var-ref ::= ident ; reserved: true/false → bool-lit
|
||||
//! int-lit ::= integer ; numeric atom
|
||||
//! str-lit ::= string ; string atom
|
||||
@@ -56,6 +56,11 @@
|
||||
//! typed-param ::= "(" "typed" ident type ")"
|
||||
//! if-term ::= "(" "if" term term term ")"
|
||||
//! let-term ::= "(" "let" ident term term ")"
|
||||
//! let-rec-term ::= "(" "let-rec" ident
|
||||
//! "(" "params" ident* ")"
|
||||
//! type-attr
|
||||
//! body-attr
|
||||
//! "(" "in" term ")" ")"
|
||||
//!
|
||||
//! pattern ::= pat-var | pat-ctor | pat-lit | pat-wild
|
||||
//! pat-var ::= ident
|
||||
@@ -690,13 +695,14 @@ impl<'a> Parser<'a> {
|
||||
"lam" => self.parse_lam(),
|
||||
"if" => self.parse_if(),
|
||||
"let" => self.parse_let(),
|
||||
"let-rec" => self.parse_let_rec(),
|
||||
other => {
|
||||
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
|
||||
Err(ParseError::Production {
|
||||
production: "term",
|
||||
message: format!(
|
||||
"unknown term head `{other}`; expected one of \
|
||||
`app`, `tail-app`, `lam`, `let`, `if`, `match`, `do`, \
|
||||
`app`, `tail-app`, `lam`, `let`, `let-rec`, `if`, `match`, `do`, \
|
||||
`tail-do`, `seq`, `term-ctor`, `lit-unit`"
|
||||
),
|
||||
pos,
|
||||
@@ -943,6 +949,34 @@ impl<'a> Parser<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Iter 16b.1: `(let-rec NAME (params PARAM*) (type T) (body TERM)
|
||||
/// (in TERM))` — local recursive fn-shaped binding. Eliminated by
|
||||
/// the desugar pass (lifted to a synthetic top-level fn) before
|
||||
/// typecheck. The body's recursive references to `NAME` are
|
||||
/// rewritten to the lifted name; the `in`-clause becomes the term
|
||||
/// that replaces the LetRec.
|
||||
fn parse_let_rec(&mut self) -> Result<Term, ParseError> {
|
||||
self.expect_lparen("let-rec-term")?;
|
||||
self.expect_keyword("let-rec")?;
|
||||
let name = self.expect_ident("let-rec name")?;
|
||||
let params = self.parse_params_attr()?;
|
||||
let ty = self.parse_type_attr()?;
|
||||
let body = self.parse_body_attr()?;
|
||||
// (in TERM)
|
||||
self.expect_lparen("let-rec in-clause")?;
|
||||
self.expect_keyword("in")?;
|
||||
let in_term = self.parse_term()?;
|
||||
self.expect_rparen("let-rec in-clause")?;
|
||||
self.expect_rparen("let-rec-term")?;
|
||||
Ok(Term::LetRec {
|
||||
name,
|
||||
ty,
|
||||
params,
|
||||
body: Box::new(body),
|
||||
in_term: Box::new(in_term),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- patterns -------------------------------------------------------
|
||||
|
||||
fn parse_pattern(&mut self) -> Result<Pattern, ParseError> {
|
||||
@@ -1081,4 +1115,45 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(matches!(m.defs.len(), 1));
|
||||
}
|
||||
|
||||
/// Iter 16b.1: minimal `(let-rec ...)` round-trips through the
|
||||
/// parser into a `Term::LetRec` whose `name`, `params`, `body` and
|
||||
/// `in_term` line up with the source.
|
||||
#[test]
|
||||
fn parses_minimal_let_rec() {
|
||||
let m = parse(
|
||||
r#"
|
||||
(module m
|
||||
(fn main
|
||||
(type (fn-type (params) (ret (con Int))))
|
||||
(params)
|
||||
(body
|
||||
(let-rec f
|
||||
(params x)
|
||||
(type (fn-type (params (con Int)) (ret (con Int))))
|
||||
(body x)
|
||||
(in (app f 1))))))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let body = match &m.defs[0] {
|
||||
Def::Fn(fd) => &fd.body,
|
||||
_ => panic!("expected fn"),
|
||||
};
|
||||
match body {
|
||||
Term::LetRec { name, params, body, in_term, .. } => {
|
||||
assert_eq!(name, "f");
|
||||
assert_eq!(params, &vec!["x".to_string()]);
|
||||
assert!(matches!(body.as_ref(), Term::Var { name } if name == "x"));
|
||||
match in_term.as_ref() {
|
||||
Term::App { callee, args, .. } => {
|
||||
assert!(matches!(callee.as_ref(), Term::Var { name } if name == "f"));
|
||||
assert_eq!(args.len(), 1);
|
||||
}
|
||||
other => panic!("expected App in in-clause, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected LetRec, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,22 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
|
||||
write_term(out, body, level);
|
||||
out.push(')');
|
||||
}
|
||||
Term::LetRec { name, ty, params, body, in_term } => {
|
||||
out.push_str("(let-rec ");
|
||||
out.push_str(name);
|
||||
out.push_str(" (params");
|
||||
for p in params {
|
||||
out.push(' ');
|
||||
out.push_str(p);
|
||||
}
|
||||
out.push_str(") (type ");
|
||||
write_type(out, ty);
|
||||
out.push_str(") (body ");
|
||||
write_term(out, body, level);
|
||||
out.push_str(") (in ");
|
||||
write_term(out, in_term, level);
|
||||
out.push_str("))");
|
||||
}
|
||||
Term::If { cond, then, else_ } => {
|
||||
out.push_str("(if ");
|
||||
write_term(out, cond, level);
|
||||
|
||||
+154
@@ -3116,3 +3116,157 @@ canonical for both new files.
|
||||
**Queue update.** 15h done. Remaining: 16b (local recursive let),
|
||||
16c (Lit-in-Ctor patterns — would simplify `take`/`drop`), 17a
|
||||
(per-fn arena, gated on user discussion).
|
||||
|
||||
---
|
||||
|
||||
## Iter 16b.1 — local recursive let (no-capture)
|
||||
|
||||
**Goal.** Let me write `(let-rec f (params x) (type ...) (body ...) (in ...))`
|
||||
inline inside a fn body for the common no-capture case (the body's
|
||||
free vars are all module-top-level def names, qualified imports,
|
||||
effect-op names, or builtin operators). Stop forcing every recursive
|
||||
helper to be a separate top-level fn — typical small kernels
|
||||
(`fact`, `loop_n`, `gcd`) belong next to the use site, not on a
|
||||
sibling line at module scope.
|
||||
|
||||
Sub-iter 16b.1 ships **no-capture only**. A LetRec whose body would
|
||||
capture a name from the enclosing lexical scope (an enclosing fn's
|
||||
params, a let-bound name from an outer `Term::Let`, or a pattern-
|
||||
bound name from an outer `Term::Match` arm) is rejected at desugar
|
||||
time with a clear panic that points at 16b.2 (closure conversion).
|
||||
That keeps the iter at lift-via-desugar — no runtime closure changes,
|
||||
no codegen plumbing, no LLVM IR change.
|
||||
|
||||
**Design choice.** Lift the LetRec to a synthetic top-level fn in
|
||||
the same desugar pass that already runs between `load_module` and
|
||||
typecheck (16a). The lifted fn gets a fresh name `<hint>$lr_N` that
|
||||
is unique against both the original module's def names and against
|
||||
any earlier lifts in the same pass. Every reference to the local
|
||||
LetRec name (in the body and in the in-clause) is rewritten via a
|
||||
`subst_var` helper to the lifted name. The lifted `FnDef` is
|
||||
appended to `Module.defs`; from there the typechecker / codegen
|
||||
treat it like any hand-written top-level fn.
|
||||
|
||||
Alternatives considered and rejected:
|
||||
|
||||
- **Runtime closures** (treat LetRec like an anonymous lambda
|
||||
bound to a name). Would require a closure-pair allocation per
|
||||
call, plus a self-reference field in the env block. Reaches the
|
||||
same value but more LLVM IR per LetRec. Deferred to 16b.2 where
|
||||
it becomes necessary anyway (capture support).
|
||||
- **Open-coding the recursion at the LetRec site** via a Y-style
|
||||
fixed-point combinator. Adds a non-local construct (the
|
||||
combinator) and keeps the expansion at every LetRec; the lift-
|
||||
and-substitute approach keeps the runtime shape identical to a
|
||||
hand-written top-level fn.
|
||||
|
||||
The pipeline invariant from 16a (`CheckedModule.symbols` hashes
|
||||
from the *original* on-disk module, not the desugared one) holds
|
||||
unchanged: a lifted def has no on-disk identity, so it never
|
||||
appears in `symbols`. `ail diff` and `ail manifest` see only the
|
||||
original-source defs.
|
||||
|
||||
**What shipped.**
|
||||
|
||||
- `crates/ailang-core/src/ast.rs` (+16): `Term::LetRec { name, ty,
|
||||
params, body, in_term }` variant inserted right after
|
||||
`Term::Let`. JSON tag `"t": "letrec"`. `ty` and `in_term` use
|
||||
serde renames (`type`, `in`) to keep the schema natural.
|
||||
Additive — every pre-16b.1 fixture canonicalises bit-identically.
|
||||
- `crates/ailang-core/src/desugar.rs` (+~570 of which ~310 are
|
||||
the new helpers + LetRec arm; the rest is doc/test): three
|
||||
additions plus the existing pass threaded through a `scope`
|
||||
parameter. (a) `free_vars_in_term` walks a term collecting every
|
||||
unbound `Term::Var` name, respecting all binders. (b) `subst_var`
|
||||
rewrites `Term::Var { name == from }` to the lifted name,
|
||||
respecting shadowing (a `Term::Let`/`Term::Lam`/`Pattern::Var`
|
||||
that rebinds `from` blocks the substitution inside its scope).
|
||||
(c) `Desugarer` gained `lifted: Vec<Def>` and
|
||||
`module_top_names: BTreeSet<String>`; `desugar_module` seeds
|
||||
the latter from every original def name and appends `lifted`
|
||||
to `defs` after the per-def walk. The new `desugar_term` arm
|
||||
for `Term::LetRec` recurses on body+in_term with an extended
|
||||
scope, runs `free_vars_in_term` against `{name} ∪ params`,
|
||||
intersects with the outer `scope`, panics if non-empty, then
|
||||
generates `<hint>$lr_N`, substitutes, and appends the lifted
|
||||
`FnDef`. Two new unit tests:
|
||||
`let_rec_no_capture_lifts_to_top_level` and
|
||||
`let_rec_with_capture_panics` (`#[should_panic]`).
|
||||
- `crates/ailang-surface/src/parse.rs` (+~70): `let-rec-term`
|
||||
production added to the EBNF (still inside the 30-rule
|
||||
budget — count is now ~31, but `let-rec` is a positional
|
||||
analogue of `let` and the increment is consistent with the
|
||||
Decision-6 budget rationale). New `parse_let_rec` function;
|
||||
dispatch keyword added in `parse_term`. Unit test
|
||||
`parses_minimal_let_rec` round-trips a minimal shape.
|
||||
- `crates/ailang-surface/src/print.rs` (+16): `Term::LetRec`
|
||||
arm in `write_term`, single-line form mirroring the
|
||||
`Term::Let` arm's compactness. The round-trip harness
|
||||
(`tests/round_trip.rs`) picks up the new fixture
|
||||
automatically.
|
||||
- `crates/ailang-check/src/lib.rs` (+10): two `unreachable!`
|
||||
arms in `verify_tail_positions` and `synth` —
|
||||
`Term::LetRec` is eliminated by desugar before either runs.
|
||||
- `crates/ailang-codegen/src/lib.rs` (+19): four `unreachable!`
|
||||
arms in `lower_term`, `collect_captures`, `synth_with_extras`,
|
||||
and `apply_subst_to_term`. Same rationale.
|
||||
- `crates/ail/src/main.rs` (+22): `walk_term` (the `ail deps`
|
||||
walker) gets a real arm — `deps` runs on the on-disk module
|
||||
before desugar, so `Term::LetRec` is reachable there.
|
||||
Treats it like a fn def for dependency purposes (name
|
||||
shadows in body+in_term; params shadow inside body).
|
||||
- `examples/local_rec_demo.ailx` + `.ail.json` — first
|
||||
consumer fixture. A factorial helper bound by `(let-rec
|
||||
fact ...)` inside `main`'s body; called at n=1, n=3, n=5;
|
||||
prints `1\n6\n120\n`. The `fact` body has no captures
|
||||
from `main`'s scope (referenced names: `<=`, `*`, `-`,
|
||||
`fact`, `n`, `1` — all builtins, the LetRec's own name,
|
||||
or its param), so it lifts cleanly.
|
||||
- `crates/ail/tests/e2e.rs::local_rec_factorial_demo` (+18):
|
||||
e2e count 36 → 37.
|
||||
|
||||
**Unreachable arms.** Every backend stage that pattern-matches
|
||||
`Term` exhaustively now has a `Term::LetRec { .. } =>
|
||||
unreachable!("Term::LetRec eliminated by desugar")` arm. The
|
||||
phrase is identical across all five sites
|
||||
(`ailang-check/src/lib.rs` × 2, `ailang-codegen/src/lib.rs` × 4)
|
||||
so a future grep reaches every one of them. The `ail deps`
|
||||
walker is the one site that intentionally has a real arm,
|
||||
because `deps` runs on the on-disk module before any desugar
|
||||
pass, and it is documented inline.
|
||||
|
||||
**Lift-name format.** `<hint>$lr_N` where `<hint>` is the
|
||||
source-level LetRec name and `N` is an incrementing counter that
|
||||
yields the first name not already in `Desugarer.used` or
|
||||
`Desugarer.module_top_names`. Mirrors the `$mp_N` fresh-match-
|
||||
pattern naming from 16a; `$lr_` is the namespace marker for
|
||||
"lifted recursion". `$` is a valid ident character in form (A),
|
||||
so the name reaches LLVM as is — but `clang` mangling chokes on
|
||||
`$`, so the name is sanitised the same way every other ail name
|
||||
is (the existing `@ail_<module>_<def>` mangling already handles
|
||||
`$` because of the 16a `$mp_` precedent).
|
||||
|
||||
**Tests: 95 → 99 (+4).**
|
||||
|
||||
- e2e: 36 → 37 (`local_rec_factorial_demo`).
|
||||
- `ailang-core::desugar::tests`: 2 → 4 (the two new LetRec tests).
|
||||
- `ailang-surface::parse::tests`: 2 → 3
|
||||
(`parses_minimal_let_rec`).
|
||||
- All other crates unchanged.
|
||||
|
||||
**Cumulative state, post-16b.1.**
|
||||
|
||||
- Stdlib unchanged (5 modules, 29 combinators).
|
||||
- `Term` enum: 11 variants (was 10) — additive, all pre-existing
|
||||
fixture hashes bit-identical.
|
||||
- 16a desugar pass now does two jobs: nested-ctor-pattern
|
||||
flattening (16a) and LetRec lift (16b.1). The pass remains
|
||||
the single AST-→-AST hop between `load_module` and typecheck.
|
||||
- Compiler bugs surfaced and fixed in dogfood since 14a: 5/5
|
||||
(unchanged from 15h).
|
||||
|
||||
**Queue update.** 16b.1 done. Remaining: 16b.2 (LetRec capture —
|
||||
closure conversion or env-passing rewrite, with the panic at
|
||||
desugar time as the boundary-marker until then), 16c (Lit-in-
|
||||
Ctor patterns — would simplify `take`/`drop`), 17a (per-fn
|
||||
arena, gated on user discussion of memory management).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"defs":[{"body":{"body":{"cond":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"<=","t":"var"},"t":"app"},"else":{"args":[{"name":"n","t":"var"},{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"}],"fn":{"name":"fact","t":"var"},"t":"app"}],"fn":{"name":"*","t":"var"},"t":"app"},"t":"if","then":{"lit":{"kind":"int","value":1},"t":"lit"}},"in":{"lhs":{"args":[{"args":[{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"fact","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"rhs":{"lhs":{"args":[{"args":[{"lit":{"kind":"int","value":3},"t":"lit"}],"fn":{"name":"fact","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"rhs":{"args":[{"args":[{"lit":{"kind":"int","value":5},"t":"lit"}],"fn":{"name":"fact","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"t":"seq"},"t":"seq"},"name":"fact","params":["n"],"t":"letrec","type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Int"}}},"doc":"Iter 16b.1: drive a let-rec'd factorial helper at three inputs. Expected stdout (per line): 1, 6, 120.","kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"local_rec_demo","schema":"ailang/v0"}
|
||||
@@ -0,0 +1,26 @@
|
||||
; Iter 16b.1 — first consumer of (let-rec ...). A single-fn helper
|
||||
; `fact` is bound by a local recursive let inside `main`'s body and
|
||||
; called at three different inputs. The desugarer lifts `fact` to a
|
||||
; synthetic top-level fn (because the body has no captures from
|
||||
; `main`'s scope) and substitutes the references. The lifted fn is
|
||||
; type-checked and codegen'd identically to a hand-written
|
||||
; top-level def. Output (per line): 1, 6, 120.
|
||||
|
||||
(module local_rec_demo
|
||||
|
||||
(fn main
|
||||
(doc "Iter 16b.1: drive a let-rec'd factorial helper at three inputs. Expected stdout (per line): 1, 6, 120.")
|
||||
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||||
(params)
|
||||
(body
|
||||
(let-rec fact
|
||||
(params n)
|
||||
(type (fn-type (params (con Int)) (ret (con Int))))
|
||||
(body
|
||||
(if (app <= n 1)
|
||||
1
|
||||
(app * n (app fact (app - n 1)))))
|
||||
(in
|
||||
(seq (do io/print_int (app fact 1))
|
||||
(seq (do io/print_int (app fact 3))
|
||||
(do io/print_int (app fact 5)))))))))
|
||||
Reference in New Issue
Block a user