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:
2026-05-07 20:33:48 +02:00
parent ee5807d73c
commit 8860600e37
11 changed files with 930 additions and 38 deletions
+154
View File
@@ -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).