Iter 16b.2 — LetRec captures of fn-params (path-1 safe subset)

Lift 16b.1's no-capture restriction. The desugar pass's `scope`
becomes a `BTreeMap<String, ScopeEntry>` carrying `KnownType` for
fn/Lam-params, sentinels for Let-/Match-bindings. LetRec captures
of `KnownType` names are lifted: the synthetic top-level fn's
signature gets the capture types appended, and every call site of
the LetRec name is rewritten via `subst_call_with_extras` to pass
the captures positionally. Captures from let-bindings, match-arm
patterns, polymorphic enclosing fns, and name-as-value uses are
rejected at desugar with panics pointing at 16b.3-16b.7.

Demo: `sum_below(n)` recurses via `(let-rec loop (params i) ...
(body ... uses n ...) (in (app loop 1)))`. Lifts to
`loop$lr_0(i: Int, n: Int) -> Int` with `(app loop X)` rewritten
to `(app loop$lr_0 X n)`. Outputs 0, 10, 45.

Tests: 103 -> 106 (+1 e2e local_rec_capture_demo, +2 desugar unit;
the 16b.1 capture-panic test was repurposed into the positive
fn-param-capture test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 21:00:49 +02:00
parent 3bee7d4c44
commit d5f63bc3e5
5 changed files with 776 additions and 54 deletions
+157
View File
@@ -3540,3 +3540,160 @@ planning only, awaiting user input on path 1 vs path 2), 16d
(planning needed — pick path a vs b), 16e (`==` extension),
17a (gated). All implementation work in this session-arc is
suspended at this planning checkpoint.
## Iter 16b.2 — LetRec captures of fn-params (path-1 safe subset)
**Goal.** Lift 16b.1's no-capture restriction for the **path-1 safe
subset** described in the 16b.2 planning entry: support a
`(let-rec ...)` whose body captures one or more names from the
enclosing scope, **provided every capture comes from a fn-param or
Lam-param** (whose types are statically declared at desugar time).
The lifted top-level fn's signature gets the capture types appended
to `params`; every call site of the LetRec name is rewritten to
pass the captures positionally as extra args. Anything outside the
safe subset is rejected at desugar time with a panic that names the
violation and points at the follow-up iter that will handle it
(16b.3 / 16b.4 / 16b.5 / 16b.6 / 16b.7).
**Path-1 restrictions in force (rejected at desugar with a
follow-up-iter pointer).**
- **Let-binding capture** (`Term::Let`-bound name, type unknown at
desugar). Queued for **16b.3**.
- **Match-arm pattern-binding capture** (constructor-field
substitution from the scrutinee's ADT not done at desugar).
Queued for **16b.4**.
- **Name-as-value use** (the LetRec name `f` appears anywhere
except as the callee of a `Term::App` — e.g. `(let g f ...)` or
`(app some_hof f)`). Needs closure conversion. Queued for
**16b.5**.
- **Polymorphic enclosing fn** (`Type::Forall`). Captured fn-param
types may mention outer type vars; constructing the lifted
signature's `Forall` is error-prone. Encoded as
`ScopeEntry::LetBound` for every fn-param of a `Forall`-typed
enclosing fn — caught at the same site as let-binding captures.
Queued for **16b.6**.
- **Nested LetRec mutual capture** (an inner LetRec captures the
outer LetRec's name or params). Queued for **16b.7**.
**What shipped.**
- `crates/ailang-core/src/desugar.rs` (1341 → 1853 LOC, +512). The
hot changes:
- New `enum ScopeEntry { KnownType(Type), LetBound, MatchArm,
EnclosingLetRec }`. The `scope` parameter threaded through
`desugar_term` and helpers became
`&BTreeMap<String, ScopeEntry>` (was `&BTreeSet<String>`).
Every binder ([`Term::Let`], [`Term::Lam`], [`Term::Match`]
arm, [`Term::LetRec`]) inserts the appropriate variant;
`desugar_module` seeds fn-param entries from `f.ty` (peeled
`Forall`).
- `Term::LetRec` arm rewritten end-to-end: peel `ty` to its
inner `Type::Fn` to learn the LetRec's own param types,
extend body-scope with `EnclosingLetRec` for `name` +
`KnownType(_)` for params, recurse on body and in_term, run
`free_vars_in_term` against `{name} params`, intersect
with the outer scope's keys, classify each capture's
`ScopeEntry` (KnownType → accept; everything else → panic
with the follow-up iter pointer), validate via
`find_non_callee_use` that `name` only appears as a callee,
build the augmented `Type::Fn` with capture types appended,
rewrite call sites via `subst_call_with_extras`, then
`subst_var` for any non-call references (defensive — none
survive the validator), append the lifted `FnDef`.
- Two new free helpers (~150 LOC together):
`subst_call_with_extras(t, name, lifted, extras)` rewrites
every `Term::App { callee = Var{name} }` to
`Term::App { callee = Var{lifted}, args ++ extras_as_vars }`,
walking through every variant; `find_non_callee_use(t, name)`
walks `t` and returns `Some(t)` at the first `Term::Var {
name == name }` reference in a non-callee position, `None`
otherwise. Plus `peel_forall_to_fn` (one-liner).
- The 16b.1 panic is gone for fn-param captures (replaced by
the lifter); it persists for every other capture kind, with
a sharper message.
- `crates/ail/tests/e2e.rs` (+18): `local_rec_capture_demo` —
e2e count 38 → 39. Asserts that the `local_rec_capture` fixture
prints `0\n10\n45\n`.
- `examples/local_rec_capture.ailx` + `.ail.json` (35 LOC source):
`sum_below(n)` returns the sum of integers `1 + ... + (n-1)`.
Body uses an inner `(let-rec loop (params i) ... (body ... (app
>= i n) ... (app loop (app + i 1))) (in (app loop 1)))`. The
helper captures `n` from `sum_below`'s param list; the desugar
pass lifts it to `loop$lr_0(i: Int, n: Int) -> Int` and rewrites
every `(app loop X)` to `(app loop$lr_0 X n)`. `main` drives
`sum_below` at 1, 5, 10 → outputs `0`, `10`, `45`.
- `crates/ailang-core/src/desugar.rs::tests` (3 new):
`let_rec_capture_fn_param_lifts_with_extra_arg` (positive),
`let_rec_capture_let_binding_panics`
(`#[should_panic(expected = "16b.3")]`),
`let_rec_name_as_value_panics`
(`#[should_panic(expected = "16b.5")]`). The 16b.1
`let_rec_with_capture_panics` test was repurposed into the
positive `let_rec_capture_fn_param_lifts_with_extra_arg`: its
fixture (a fn-param capture) is now legal and produces the
expected augmented signature.
**The augmented-signature mechanism.** Given
`(let-rec f (params p1..pk) (type Fn(t1..tk) -> tr) (body B) (in I))`
inside a fn whose scope contains
`{c1: T1, ..., cm: Tm}` (KnownType entries) used as free vars in
`B`:
- Lifted signature: `f$lr_N : Fn(t1..tk, T1..Tm) -> tr` with the
same `effects` set as the original.
- Lifted param-name list: `p1..pk, c1..cm` (capture names appended
unchanged so the lifted body's references resolve directly).
- Body rewrite: every `(app f a1..an)` in `B` → `(app f$lr_N
a1..an c1..cm)`. The `c1..cm` are `Term::Var` references that
resolve, in the lifted body, to the appended params with the
same names. Free uses of `f` in non-callee position are
pre-rejected by `find_non_callee_use`.
- In-term rewrite: every `(app f a1..an)` in `I` → `(app f$lr_N
a1..an c1..cm)`. The `c1..cm` are `Term::Var` references that
resolve, in the in-term's enclosing fn, to the captured names
themselves (still in scope).
The rewrite is order-sensitive: `subst_call_with_extras` runs
**before** `subst_var`, so a recursive self-call inside `B` first
becomes `(app f$lr_N ... c1..cm)`, then any leftover bare `Var{f}`
(none in 16b.2 — pre-rejected) gets renamed to `Var{f$lr_N}`. The
second pass is defensive.
**Tests: 102 → 106 (+4).**
- e2e: 38 → 39 (`local_rec_capture_demo`).
- `ailang-core::desugar::tests`: 6 → 9. Net +3 because the
16b.1-era `let_rec_with_capture_panics` test was repurposed
rather than removed (its fn-param-capture fixture is now a
positive lift), and two pure-negative tests were added.
- All other crates unchanged.
**Cumulative state, post-16b.2.**
- Stdlib unchanged (5 modules, 29 combinators).
- `Term` enum: 11 variants (unchanged; LetRec is still
desugar-eliminated, no schema impact). All pre-16b.2 fixtures
hash bit-identically.
- 16a desugar pass now does three jobs: nested-ctor-pattern
flattening (16a), literal-pattern lowering (16c), and LetRec
lift (16b.1 → 16b.2 path-1). Single AST→AST hop between
`load_module` and typecheck.
- The `unreachable!("Term::LetRec eliminated by desugar")` arms
in `ailang-check` × 2 and `ailang-codegen` × 4 remain correct
— every LetRec is still gone before either stage runs.
**Queue update post-16b.2 path-1.** 16b.2 path-1 done. Open:
**16b.3** (LetRec captures of `Term::Let`-bound names — would
need a post-typecheck re-run of the lift, or a small inference
on the let-value's type), **16b.4** (LetRec captures of match-arm
pattern bindings — needs ADT-def lookup + constructor-field
substitution), **16b.5** (closure conversion — lifts the
"name-as-value-only" restriction for both LetRec and Lam),
**16b.6** (LetRec inside a `Type::Forall`-quantified fn — needs
synthesised `Forall` for the lifted signature), **16b.7**
(nested LetRec mutual capture — generalised lifting that
accumulates captures across nesting). 16d (chain-machinery
exhaustiveness or `__unreachable__` — planning needed), 16e
(`==` extension to Bool/Str/Unit), 17a (per-fn arena, gated)
unchanged.