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