plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


docs/superpowers/specs/2026-07-20-closures-design.md
4f1f2b6 1
# Closures (`|params| body`)
4f1f2b6 2
4f1f2b6 3
## Problem
4f1f2b6 4
4f1f2b6 5
`closure` (`|params| body`) already exists as a tree-sitter grammar rule but is unreachable from
4f1f2b6 6
any parse path — it's referenced only in a comment inside `expression`'s choice list. There is
4f1f2b6 7
also no function-value type annotation syntax at all (something like `cb: fn(v: a) -> Bool`,
4f1f2b6 8
as used in the aspirational `libs/std/list.plum`, doesn't parse today). Without closures, any
4f1f2b6 9
higher-order method (`each`, `map`, `filter`, `reduce`, `sort`, ...) on a future `List`/`Map` has
4f1f2b6 10
no way to accept a callback, and `list.plum`'s actual usage (`this.each() |v| { res.write(v.to_str(),
4f1f2b6 11
sep) }`, capturing `res`/`sep` from the enclosing method) can't be expressed at all.
4f1f2b6 12
4f1f2b6 13
## Scope
4f1f2b6 14
4f1f2b6 15
Full, capturing closures — not just non-capturing function values. `list.plum`'s real usage reads
4f1f2b6 16
enclosing-scope variables from inside a closure literal, so a "closures" feature that can't
4f1f2b6 17
capture wouldn't actually unblock the motivating use case.
4f1f2b6 18
4f1f2b6 19
**Capture semantics: snapshot by value**, not live/shared mutable capture. A captured variable's
4f1f2b6 20
*current value* is copied into the closure's environment at the moment the closure literal is
4f1f2b6 21
evaluated; a later mutation to that variable in the enclosing scope is not visible inside an
4f1f2b6 22
already-created closure. This is simpler (no heap-boxed shared cells needed for ordinary locals)
4f1f2b6 23
and matches every known real usage (reading `res`/`sep`, not reassigning them from inside the
4f1f2b6 24
closure). Live/shared capture is explicitly out of scope — flagged here as a real semantic choice,
4f1f2b6 25
not an oversight, in case a future use case needs it.
4f1f2b6 26
83ce3b8 27
**Call syntax: ordinary-argument only**, not trailing-closure syntax. `list.plum`'s draft usage
83ce3b8 28
(`this.each() |v| { ... }` — the closure attached *after* a completed call, Ruby/Kotlin-style) is a
83ce3b8 29
distinct, more elaborate grammar feature (a call expression optionally followed by a closure) than
83ce3b8 30
a closure used as a normal expression. Since `list.plum` itself gets rewritten to standard syntax
83ce3b8 31
in a later, separate follow-up, this feature only needs to support a closure passed as an ordinary
83ce3b8 32
call argument — `each(|v| ...)` — not the trailing-block form.
83ce3b8 33
4f1f2b6 34
## Design
4f1f2b6 35
4f1f2b6 36
### 1. Grammar (`tooling/tree-sitter-plum`)
4f1f2b6 37
4f1f2b6 38
- Uncomment `$.closure` in `expression`'s choice list (the rule itself — `"|" params "|" body` —
4f1f2b6 39
  already exists and needs no changes).
4f1f2b6 40
- Add a new function-value type rule for param type annotations, using **positional types only**
4f1f2b6 41
  (no param names in the annotation — types don't need names, and this stays consistent with the
4f1f2b6 42
  existing type-list convention `List(a)`/`Pair(a, b)`): `fn(Int) -> Bool`, `fn(a) -> b`. Usable
4f1f2b6 43
  wherever a param type currently appears (`each(cb: fn(a))`).
4f1f2b6 44
4f1f2b6 45
### 2. AST (`plum-core`)
4f1f2b6 46
4f1f2b6 47
- New `ast::Closure { params: Vec<String>, body: Block }`, added as `Expr::Closure(Box<Closure>)`.
4f1f2b6 48
- New `ast::ParamType::Fn(Vec<Type>, Option<Box<Type>>)` variant (param types, param positions
4f1f2b6 49
  only — a class field storing a callback value is out of scope, not needed by any current
4f1f2b6 50
  example or by `list.plum`'s actual usage, which only ever passes closures as call arguments).
4f1f2b6 51
4f1f2b6 52
### 3. Checker (`plum-checker`)
4f1f2b6 53
4f1f2b6 54
- `PlumType::TFun(Vec<PlumType>, Box<PlumType>)` already exists and models a closure's type
4f1f2b6 55
  directly — no new `PlumType` variant needed.
4f1f2b6 56
- `infer_expr`'s new `Closure` arm: bind each closure param to a fresh `TVar` (matching this
4f1f2b6 57
  checker's existing permissive style — it already never really unifies generic parameters, just
4f1f2b6 58
  accepts them), infer the body against that environment, return `TFun(param_types, body_type)`.
4f1f2b6 59
- Calling a closure-typed parameter by name (`cb(x)`) already type-checks via the **existing**
4f1f2b6 60
  `FnCall` inference path unmodified — `lookup(env, &call.name)` resolves any `TFun`-typed binding
4f1f2b6 61
  in scope, regardless of whether it came from a top-level function or a local closure-typed
4f1f2b6 62
  param. No changes needed there.
4f1f2b6 63
4f1f2b6 64
### 4. Codegen (`plum-wasm-codegen`)
4f1f2b6 65
4f1f2b6 66
This is the substantial part — wasm has no closures natively, so it needs:
4f1f2b6 67
4f1f2b6 68
- **New wasm sections**: a function table and an elements segment. `WasmModule` needs
4f1f2b6 69
  `add_table`/element-segment support, which doesn't exist yet.
4f1f2b6 70
- **Compiling a closure literal**: every closure literal becomes its own real wasm function,
4f1f2b6 71
  registered as a table element, with an **implicit first parameter** — the boxed
4f1f2b6 72
  captured-environment pointer — exactly mirroring how a method already receives `self` as an
4f1f2b6 73
  implicit first parameter. Inside the closure's compiled body, each captured variable is loaded
4f1f2b6 74
  from a fixed offset within that env struct (same load mechanics already used for class fields).
4f1f2b6 75
- **Free-variable analysis**: at the point a closure literal appears in source, walk its body and
4f1f2b6 76
  collect every referenced name that is NOT one of the closure's own declared params, a global, or
4f1f2b6 77
  a function name — these are the captured variables, snapshotted by value.
4f1f2b6 78
- **Constructing the closure value**: at the closure literal's site, bump-allocate an env struct
4f1f2b6 79
  sized to hold the captured variables' current values (same bump-allocator convention as class
4f1f2b6 80
  instances), then bump-allocate a small `{table_index: i32, env_pointer: i32}` pair. The pointer
4f1f2b6 81
  to that pair **is** the closure's runtime value — a single `i32`, consistent with every other
4f1f2b6 82
  reference-shaped value in this compiler (class instances, payload-carrying enum variants).
4f1f2b6 83
- **Calling a closure value**: load `table_index` and `env_pointer` from the pointer, push
4f1f2b6 84
  `env_pointer` then the real call arguments, then `call_indirect`. The wasm type index for
4f1f2b6 85
  `call_indirect` is resolved from the **caller's** own already-concrete (monomorphized) call
4f1f2b6 86
  site — a closure's internal capture layout is opaque to its caller, so calling a higher-order
4f1f2b6 87
  function like `each` needs no new monomorphization mechanism: `each`'s own concrete
4f1f2b6 88
  instantiation (via the existing generics pass) already fixes what signature any closure passed
4f1f2b6 89
  to it must have, and the checker already enforces that at the call site.
4f1f2b6 90
4f1f2b6 91
## Testing plan
4f1f2b6 92
4f1f2b6 93
- **Grammar**: corpus tests for a closure literal parsing in expression position, and a
4f1f2b6 94
  `fn(...)`-typed param annotation parsing correctly (both bracket-free positional-type forms:
4f1f2b6 95
  `fn(Int) -> Bool`, `fn(a) -> b`).
4f1f2b6 96
- **Checker**: a closure's inferred `TFun` type flows correctly into a `TFun`-typed binding;
4f1f2b6 97
  calling a closure-typed param type-checks via the unmodified `FnCall` path.
4f1f2b6 98
- **Codegen** (compiled and executed via `wasmtime`, matching existing style):
4f1f2b6 99
  - A non-capturing closure passed to a function and called.
4f1f2b6 100
  - A closure capturing one enclosing local, called after that local's value has changed in the
4f1f2b6 101
    enclosing scope — proving the capture is a value snapshot at creation time, not a live
4f1f2b6 102
    reference (the closure must see the OLD value, not the new one).
4f1f2b6 103
  - A closure passed through an already-generic higher-order function (proving the two features
4f1f2b6 104
    compose — the outer function is monomorphized per its own generic param as usual, and any
4f1f2b6 105
    closure passed to it is called correctly via `call_indirect` regardless).
4f1f2b6 106
- **Examples**: a minimal, real end-to-end usage (e.g. a small `each`-like helper function taking
4f1f2b6 107
  a `fn(a)` callback, called with both a non-capturing and a capturing closure).
4f1f2b6 108
4f1f2b6 109
## Out of scope
4f1f2b6 110
4f1f2b6 111
- A class field storing a closure value.
4f1f2b6 112
- Live/shared mutable capture (a closure seeing a later mutation to a captured variable) — see
4f1f2b6 113
  "Capture semantics" above; this is a deliberate simplification, not a deferred requirement.
4f1f2b6 114
- Multiple closures with genuinely different captured-variable layouts needing different wasm
4f1f2b6 115
  function *signatures* at the same `call_indirect` site — not expected to arise given the design
4f1f2b6 116
  (the env pointer is always a single opaque `i32` regardless of what it points to), but noted as
4f1f2b6 117
  an edge to watch during implementation.
4f1f2b6 118
- Rewriting `libs/std/list.plum`/`map.plum` to actually use this — a separate, subsequent
4f1f2b6 119
  follow-up once closures exist.