Iter 16b.4: LetRec captures of match-arm pattern bindings

Flips the desugar's MatchArm-capture rejection from panic to defer.
The 16b.3 lift_letrecs pass already handled match-arm bindings via
type_check_pattern_for_lift (Pattern::Var, Pattern::Ctor with sub-
patterns, ctor-field substitution against scrutinee args), so this
iter is a single-line classification change in desugar plus the
fixture and tests that exercise it.

- desugar.rs: MatchArm classification now defers (same arm as
  LetBound); EnclosingLetRec panic remains for 16b.7.
- examples/local_rec_match_capture.{ailx,ail.json}: enclosing fn
  pattern-matches on Pair<Int,Int>, inner LetRec captures both
  match-arm bindings simultaneously. Lifts to
  loop$lr_0(i: Int, threshold: Int, n: Int) -> Int.
- e2e + check + desugar tests: 110 → 113 (+3).

First fixture lifting more than one capture; subst_call_with_extras
already handled it generically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 22:16:12 +02:00
parent ca30606aec
commit c0668178bb
6 changed files with 478 additions and 33 deletions
+118
View File
@@ -3887,3 +3887,121 @@ the post-order traversal in `lift_letrecs` but the mutual case
is genuinely harder). 16d (chain-machinery exhaustiveness or
`__unreachable__`), 16e (`==` extension to Bool/Str/Unit),
17a (per-fn arena, gated) unchanged.
## Iter 16b.4 — LetRec captures of match-arm pattern bindings
**Goal.** Lift the 16b.3-era "match-arm capture rejected" panic. A
`(let-rec ...)` whose body captures a name bound by a `Term::Match`
arm pattern (a `Pattern::Var`, or a `Pattern::Var` sub-pattern of
a `Pattern::Ctor`) is now legal. Same architectural path as 16b.3:
desugar defers it; `lift_letrecs` resolves the capture's type from
the enclosing match's scrutinee type, applying constructor-field
substitution against the matched ctor's declared field types.
**What changed in desugar.** Exactly one classification arm. The
LetRec capture loop used to treat `ScopeEntry::MatchArm` as a hard
panic (queued for 16b.4); it now sets `needs_defer = true` and
falls through to the same defer-arm 16b.3 wrote for `LetBound`.
The `has_let_bound` boolean was renamed to `needs_defer` so it
covers both LetBound and MatchArm captures uniformly. Mixed scopes
(some KnownType + some MatchArm, or some MatchArm + some LetBound)
defer just like the 16b.3 mixed case — the all-or-nothing decision
is preserved. `EnclosingLetRec` still panics (queued for 16b.7).
Total desugar diff: ~25 LOC of comment/classification changes; no
new helpers, no new traversals.
**What `lift_letrecs` already supported.** The post-typecheck pass
needed **zero** code changes. 16b.3's `type_check_pattern_for_lift`
was already written to handle the full pattern grammar:
- `Pattern::Wild` / `Pattern::Lit`: bind nothing (no-op).
- `Pattern::Var`: binds the entire scrutinee's type.
- `Pattern::Ctor`: looks up the ADT in `env.types` (or
`env.module_types` for a qualified `module.Type`), validates ctor
arity, builds the type-var → arg substitution from
`td.vars` zip `s_ty.args`, applies it to each `cdef.fields[i]`,
and recurses on each sub-pattern with the substituted field
type. The recursion handles arbitrarily-nested Ctor patterns
for free (though desugar's 16a flattening means lift_letrecs
rarely sees deeply-nested ctor patterns in practice).
The only reason 16b.3 left the MatchArm path under a `panic!` was
to keep 16b.3's scope tight — the machinery for resolving the
type was already in place and exercised by 16b.3's own test
suite, just gated behind the desugar panic.
**The fixture: `examples/local_rec_match_capture.ailx` (+ `.ail.json`).**
Defines `data Pair (vars a b) (ctor MkPair a b)`. `count_below(p)`
takes a `Pair Int Int` (threshold, n), pattern-matches with
`(pat-ctor MkPair threshold n)` (so `threshold` and `n` are both
match-arm bindings of type `Int` — the ADT's `[a, b]` ctor fields
substituted against the scrutinee's `[Int, Int]` type args), and
inside that arm runs a recursive `loop` that captures BOTH
match-arm bindings to count integers in `1..=n` strictly less than
`threshold`. The lift produces a synthetic top-level fn
`loop$lr_0(i: Int, threshold: Int, n: Int) -> Int` and rewrites
every `(app loop ARGS)` in the arm body to
`(app loop$lr_0 ARGS threshold n)`. Drives at `MkPair 10 0`,
`MkPair 10 5`, `MkPair 10 15` → output `0\n5\n9\n` (the same
numbers as the 16b.3 fixture so the comparison is direct).
The fixture exercises the full 16b.4 chain: parameterised ADT
construction, match on the ctor, two simultaneous match-arm
captures (not just one), and `lift_letrecs` resolving both via
constructor-field substitution.
**What this iter exercises that 16b.3 did not.**
- The MatchArm branch of `type_check_pattern_for_lift` (was
unreachable in real programs because desugar always panicked
before the lift saw such captures; covered by the 16b.3 test
suite synthetically but never end-to-end).
- Multi-capture LetRec (16b.2 used a single fn-param capture;
16b.3 used a single Let-capture; this is the first fixture with
two captures appended in the lifted signature, in a fixed
order driven by the BTreeSet's deterministic iteration).
- Constructor-field substitution at the lift site (the type args
of the scrutinee type `Pair Int Int` flow into the synthetic
fn's signature via the substitution map).
**Tests: 113 → 116 (+3).**
- e2e: 40 → 41 (`local_rec_match_capture_demo`, asserts the
`0/5/9` stdout from the new fixture).
- `ailang-check::tests`: 27 → 28
(`lift_letrecs_on_match_arm_capture_produces_synthetic_fn`:
builds a Pair-using module, asserts the lifted FnDef's name
starts with `helper$lr_`, params are `[z, x]`, and the type is
`(Int, Int) -> Int`).
- `ailang-core::desugar::tests`: 9 → 10
(`let_rec_capture_match_arm_is_deferred_to_post_typecheck`:
asserts no fn was lifted at desugar time and a Term::LetRec
still survives in the outer body for the post-typecheck pass).
**Cumulative state, post-16b.4.**
- Stdlib unchanged (5 modules, 29 combinators).
- `Term` enum: 11 variants (unchanged). All pre-16b.4 fixture
hashes bit-identical (verified via `ail manifest` on the four
prior LetRec/lit_pat fixtures: hashes match pre-16b.4 values).
- Compiler stages: load → desugar → typecheck → lift_letrecs →
codegen (unchanged from 16b.3).
- The `unreachable!` arms in `ailang-codegen` and the typing rule
in `ailang-check` are unchanged.
- Compiler bugs surfaced and fixed in dogfood since 14a: 5/5
(unchanged).
**Queue update post-16b.4.** 16b.4 done. Open: **16b.5** (closure
conversion — lifts the "name-as-value-only" restriction for both
LetRec and Lam; needs ABI-level work, out of scope for the 16b.x
sweep that's been about scope-of-capture restrictions only),
**16b.6** (LetRec inside a `Type::Forall`-quantified fn — needs a
synthesised `Forall` for the lifted signature plus rigid-var
threading; the `lift_letrecs` panic at the Forall-LetRec arm is
the entry point), **16b.7** (nested LetRec mutual capture —
generalised lifting that accumulates captures across nesting;
the post-order traversal in `lift_letrecs` already handles the
strictly-nested non-mutual case but the mutual case needs joint
lifting). 16d (chain-machinery exhaustiveness or
`__unreachable__`), 16e (`==` extension to Bool/Str/Unit),
17a (per-fn arena, gated) unchanged.