Skip to content

M3: Closures (= by-value / := by-reference capture)#36

Merged
assapir merged 2 commits into
mainfrom
m3-closures
Jun 27, 2026
Merged

M3: Closures (= by-value / := by-reference capture)#36
assapir merged 2 commits into
mainfrom
m3-closures

Conversation

@assapir

@assapir assapir commented Jun 27, 2026

Copy link
Copy Markdown
Owner

What

Adds lexical closures to Quilon. The capture mode is inferred entirely from the binding operator — no capture list, no marker — mirroring the language's existing mutability rule:

  • a name bound with = is captured by value (a frozen, read-only snapshot);
  • a name bound with := is captured by reference — a single shared GC-boxed cell, so writes escape the closure and persist/accumulate across calls.

Closures are monomorphic in M3 (concrete-typed params and captures). Capturing a polymorphic value, generic closures, passing a closure as a function parameter, and returning a closure from a function are deferred to M4; a closure used in an unsupported position is rejected at compile time, never miscompiled.

How (pipeline)

  • AST (nodes.rs): new Expr::Lambda function-literal node.
  • Parser (ast_parser.rs): lambda expressions (x => …, (a, b) => …, () => …, with optional -> Type). A one-shot no_lambda guard prevents a for-loop collection (for n <- xs => body) from being mis-parsed as xs => body. Parameter parsing is shared via parse_param_list.
  • Checker (checker.rs): check_lambda infers Type::Function; the body is checked in a scope layered over the enclosing environment, so captures are visible and the immutability gate still applies inside closures.
  • Codegen (generator.rs): a closure value is { ptr fn, ptr env }; the lifted top-level function takes the captured environment as a trailing pointer. = captures are copied by value into the env; := captures are heap-boxed via the existing __alloc intrinsic and shared (writes escape, escape-safe). A non-capturing nested function is emitted as a plain module function so it can recurse; captures thread through arbitrary nesting depth, and a closure value may itself be captured by another closure and called.
  • Capture analysis (ast/captures.rs): free-variable computation parameterized by the enclosing scope, so a := to an outer name is recognized as a reassignment of a captured cell (a use) rather than a fresh local.

Tests / docs

  • examples/closures.ql — a counter that mutates a :=-captured cell across calls (writes escape) plus an adder that captures an = binding by value; ^ returns exit code 42. Wired into tests/examples_test.rs, which runs it under the JIT and native AOT (clang + gcc) and asserts all three agree.
  • tests/closures_test.rs — 12 run tests: := accumulation, frozen = snapshot, shared cells, mixed capture, parameter shadowing, immutability preserved through capture, non-capturing nested recursion, capturing-and-calling another closure, and multi-level capture.
  • LANGUAGE.md — new Closures section, feature-matrix entry, and the M4 boundary.

Green gate

  • cargo build
  • cargo test ✅ (incl. the native-AOT examples gate, run with clang + gcc — not JIT only)
  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings

/code-review and /simplify were run; findings addressed (notably: nested-recursion regression fixed, multi-level mutable capture sharing fixed, capturing-a-closure call fixed, and duplicated signature/return-type/call-result logic factored into shared helpers).

🤖 Generated with Claude Code

assapir and others added 2 commits June 27, 2026 17:50
Add lexical closures whose capture mode is inferred from the binding operator,
mirroring Quilon's existing mutability rule:

- `=`  bindings are captured BY VALUE (a frozen, read-only snapshot).
- `:=` bindings are captured BY REFERENCE — a single shared GC-boxed cell, so
  writes escape the closure and persist/accumulate across calls.

There is no capture list and no marker; the operator that bound the name is the
signal. Closures are monomorphic (concrete-typed params/captures) — generic and
polymorphic-capturing closures are deferred to M4.

Pipeline:
- AST: new `Expr::Lambda` function-literal node.
- Parser: lambda expressions (`x => …`, `(a,b) => …`, `() => …`), with a
  one-shot `no_lambda` guard so a for-loop collection (`for n <- xs => body`)
  is not mis-parsed as `xs => body`.
- Checker: `check_lambda` infers `Type::Function`; the body is checked in a
  layered scope so capture is visible and the mutability gate still applies.
- Codegen: a closure value is `{ ptr fn, ptr env }`; the lifted function takes
  the captured environment as a trailing pointer. `=` captures are copied into
  the env by value; `:=` captures are heap-boxed via `__alloc` and shared.
  A non-capturing nested function is emitted as a plain module function so it
  can recurse; captures are threaded through arbitrary nesting depth, and a
  closure value may itself be captured by another closure and called.

Tests/docs:
- examples/closures.ql (counter via `:=` cell + adder via `=` value -> exit 42),
  wired into the examples gate (JIT + native AOT under clang and gcc).
- tests/closures_test.rs covers accumulation, frozen-snapshot, shared cells,
  mixed capture, shadowing, nested recursion, and multi-level capture.
- LANGUAGE.md: Closures section + feature matrix + limitations boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	LANGUAGE.md
#	src/codegen/generator.rs
#	src/typechecker/checker.rs
#	tests/examples_test.rs
@assapir assapir merged commit 9b5d905 into main Jun 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant