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