plum

#treesitter#compiler#wasm

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

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


038eebdPeter John 2026-07-20T21:25:13+05:30
docs: add implementation plan for closures
docs/superpowers/plans/2026-07-20-closures.md ADDED
@@ -0,0 +1,942 @@
1
+ # Closures Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `|params| body` closure literals — full, capturing closures with snapshot-by-value semantics, usable as an ordinary call argument — parse, type-check, and compile to working wasm.
6
+
7
+ **Architecture:** Grammar wires the already-existing (but unreachable) `closure` rule into expression position and adds a new `fn(...)` function-value type annotation. The AST gains `Expr::Closure` and `ParamType::Fn`. The checker's existing `PlumType::TFun` models a closure's type directly, and calling a closure-typed binding by name already works through the unmodified `FnCall` inference path. Codegen is the substantial part: wasm has no native closures, so every closure literal compiles to its own real wasm function (registered in a new function table, with an implicit first parameter — the captured-environment pointer, mirroring how a method already receives `self`), and a closure *value* at runtime is a single `i32` pointer to a heap-allocated `{table_index, env_pointer}` pair. Calling a closure value loads both fields and does `call_indirect`.
8
+
9
+ **Tech Stack:** Rust (workspace: `plum-core`, `plum-checker`, `plum-wasm-codegen`), tree-sitter grammar, `wasm-encoder` (already a dependency; this plan uses its `TableSection`/`ElementSection`/`Elements`/`Instruction::CallIndirect`, none of which this codebase uses yet).
10
+
11
+ ## Global Constraints
12
+
13
+ - **Scope**: closures as expressions, passable as an ordinary call argument (`each(|v| ...)`) — NOT the trailing-closure calling convention (`each() |v| ...`) shown in `libs/std/list.plum`'s aspirational draft; NOT a class field storing a closure value. Capture is snapshot-by-value (a captured variable's value at closure-creation time), not live/shared mutation.
14
+ - Function-type annotations use **positional types only** — `fn(Int) -> Bool`, `fn(a) -> b` — no param names inside the annotation.
15
+ - Task 5 (closure discovery + codegen) is substantial new algorithmic code, not a modification of existing tested logic. Its design is sound but should be validated primarily through that task's own tests (TDD), the same posture the generics-monomorphization plan took for its hardest task — expect to need judgment calls during implementation, and report BLOCKED/NEEDS_CONTEXT rather than papering over a genuine design gap, exactly as that plan's precedent established.
16
+ - Follow existing code style: terse one-line "why" comments only where non-obvious; error messages use the existing `"codegen: ..."` / `"monomorphize: ..."` prefixes as appropriate.
17
+ - Every task must leave `cargo test --workspace` and `npx --yes tree-sitter-cli test` (from `tooling/tree-sitter-plum/`) green before moving to the next task.
18
+
19
+ ---
20
+
21
+ ### Task 1: Grammar — wire closures into expression position, add function-value type syntax
22
+
23
+ **Files:**
24
+ - Modify: `tooling/tree-sitter-plum/grammar.js`
25
+ - Test: `tooling/tree-sitter-plum/test/corpus/` (new cases)
26
+
27
+ **Interfaces:**
28
+ - Consumes: nothing from other tasks.
29
+ - Produces: a `closure` node reachable from `expression`, and a new `fn_value_type` node reachable from `param`'s type field. Neither `plum-core`'s parser nor its AST need any changes yet — that's Task 2.
30
+
31
+ - [ ] **Step 1: Uncomment `$.closure` in `expression`'s choice list**
32
+
33
+ Find (in `grammar.js`):
34
+
35
+ ```js
36
+ expression: ($) =>
37
+ choice(
38
+ $.comparison_operator,
39
+ $.not_operator,
40
+ $.boolean_operator,
41
+ // $.closure,
42
+ $.primary_expression,
43
+ $.ternary_expression,
44
+ ),
45
+ ```
46
+
47
+ Change to:
48
+
49
+ ```js
50
+ expression: ($) =>
51
+ choice(
52
+ $.comparison_operator,
53
+ $.not_operator,
54
+ $.boolean_operator,
55
+ $.closure,
56
+ $.primary_expression,
57
+ $.ternary_expression,
58
+ ),
59
+ ```
60
+
61
+ - [ ] **Step 2: Add the `fn_value_type` rule and wire it into `param`**
62
+
63
+ Find:
64
+
65
+ ```js
66
+ param: ($) =>
67
+ seq(
68
+ field("name", $.var_identifier),
69
+ ":",
70
+ field("type", choice($.type, $.variadic_type)),
71
+ optional(seq("=", field("value", $.expression))),
72
+ ),
73
+ ```
74
+
75
+ Change to:
76
+
77
+ ```js
78
+ param: ($) =>
79
+ seq(
80
+ field("name", $.var_identifier),
81
+ ":",
82
+ field("type", choice($.type, $.variadic_type, $.fn_value_type)),
83
+ optional(seq("=", field("value", $.expression))),
84
+ ),
85
+
86
+ fn_value_type: ($) =>
87
+ seq(
88
+ "fn",
89
+ "(",
90
+ field("params", optional(commaSep1($.type))),
91
+ ")",
92
+ optional(seq("->", field("returns", $.type))),
93
+ ),
94
+ ```
95
+
96
+ (Place `fn_value_type` near `variadic_type`'s definition, a few lines above `param`. The explicit `"params"`/`"returns"` field names — the same idiom `fn`'s own `field("returns", ...)` already uses — let `parser.rs` (Task 2) distinguish the return type from the param types unambiguously by field, rather than by counting/positional-guessing among same-kind `"type"` children.)
97
+
98
+ - [ ] **Step 3: Regenerate and run the existing corpus suite**
99
+
100
+ ```bash
101
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate && npx --yes tree-sitter-cli test
102
+ ```
103
+
104
+ Expected: generation succeeds with no unresolved-conflict errors, and all pre-existing corpus cases still pass. (The `closure` rule's own body — `"|" params "|" body` — already exists unchanged; only its reachability and the new `fn_value_type` rule are new. If `generate` reports a conflict — e.g. between `|` as the closure delimiter and `|` as the bitwise-or binary operator in expression position — read the reported example carefully; a `conflicts: [[$.closure, ...]]` entry or precedence adjustment may be needed. Don't guess a fix blindly; the existing `conflicts` array in `grammar.js` (already used for the `fn_call`/`class_call` ambiguity from prior work) is the established idiom for this.)
105
+
106
+ - [ ] **Step 4: Add corpus cases**
107
+
108
+ Append to `tooling/tree-sitter-plum/test/corpus/function.txt` (input halves — the next step fills in expected trees):
109
+
110
+ ```
111
+ ================================================================================
112
+ function - closure literal in expression position
113
+ ================================================================================
114
+
115
+ useClosure() -> Bool =
116
+ cb = |v|
117
+ True
118
+ cb(5)
119
+
120
+ --------------------------------------------------------------------------------
121
+ ================================================================================
122
+ function - closure literal with no params
123
+ ================================================================================
124
+
125
+ useClosure() -> Bool =
126
+ cb = ||
127
+ True
128
+ cb()
129
+
130
+ --------------------------------------------------------------------------------
131
+ ================================================================================
132
+ function - function-value type param annotation
133
+ ================================================================================
134
+
135
+ each(cb: fn(Int)) -> Bool =
136
+ True
137
+
138
+ --------------------------------------------------------------------------------
139
+ ================================================================================
140
+ function - function-value type param annotation with generic types and return
141
+ ================================================================================
142
+
143
+ each(cb: fn(a) -> b) -> Bool =
144
+ True
145
+
146
+ --------------------------------------------------------------------------------
147
+ ```
148
+
149
+ - [ ] **Step 5: Generate expected trees and verify**
150
+
151
+ ```bash
152
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test -u -f "closure literal" && npx --yes tree-sitter-cli test -u -f "function-value type"
153
+ ```
154
+
155
+ Open `test/corpus/function.txt` and confirm each new case's generated tree has no `ERROR`/`MISSING` node, and that the closure literal appears as a single `closure` node (not split), and `fn_value_type` appears as a single node containing its param types and optional return type.
156
+
157
+ - [ ] **Step 6: Run the full corpus suite and the Rust workspace suite**
158
+
159
+ ```bash
160
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
161
+ cargo test --workspace
162
+ ```
163
+
164
+ Expected: both green. (The workspace suite should be unaffected — no Rust code changes yet — but confirm nothing regresses via the parser's generic wrapper-node handling, the same way past grammar-only changes this session were verified.)
165
+
166
+ - [ ] **Step 7: Commit**
167
+
168
+ ```bash
169
+ git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/test/corpus/function.txt
170
+ git add tooling/tree-sitter-plum/src # generated parser.c etc, only if tracked — check git status first
171
+ git commit -m "feat(tree-sitter-plum): wire closure literals into expression position; add fn(...) type syntax"
172
+ ```
173
+
174
+ ---
175
+
176
+ ### Task 2: AST + Parser — `Expr::Closure`, `ParamType::Fn`
177
+
178
+ **Files:**
179
+ - Modify: `plum-core/src/ast.rs`
180
+ - Modify: `plum-core/src/parser.rs`
181
+ - Test: `plum-core/tests/` (a new or extended integration test parsing a closure and a `fn(...)` param — check existing test file conventions, e.g. `formatter_test.rs`, for how this crate structures its own tests; there is no dedicated parser test file today, so add one, `plum-core/tests/parser_test.rs`, following the same `AstParser::new(src)` + `tree_sitter::Parser` pattern already used in `plum-checker`/`plum-wasm-codegen`'s own test helpers)
182
+
183
+ **Interfaces:**
184
+ - Consumes: the `closure`/`fn_value_type` grammar nodes from Task 1.
185
+ - Produces:
186
+ ```rust
187
+ pub struct Closure { pub params: Vec<String>, pub body: Block }
188
+ // added to Expr:
189
+ Closure(Box<Closure>),
190
+ // added to ParamType:
191
+ Fn(Vec<Type>, Option<Box<Type>>),
192
+ ```
193
+ Task 3 (checker) and Task 5 (codegen) both match on these.
194
+
195
+ - [ ] **Step 1: Write failing tests**
196
+
197
+ Create `plum-core/tests/parser_test.rs`:
198
+
199
+ ```rust
200
+ use plum_core::ast::*;
201
+ use plum_core::AstParser;
202
+
203
+ fn parse(src: &str) -> Source {
204
+ let mut parser = tree_sitter::Parser::new();
205
+ parser.set_language(&tree_sitter_plum::LANGUAGE.into()).unwrap();
206
+ let tree = parser.parse(src, None).unwrap();
207
+ assert!(!tree.root_node().has_error(), "parse error:\n{}", tree.root_node().to_sexp());
208
+ let ap = AstParser::new(src);
209
+ ap.parse_source(tree.root_node())
210
+ }
211
+
212
+ fn only_fn(source: &Source) -> &Fn {
213
+ source.items.iter().find_map(|i| match i { Item::Fn(f) => Some(f), _ => None }).expect("expected a Fn item")
214
+ }
215
+
216
+ #[test]
217
+ fn closure_literal_parses_with_params_and_body() {
218
+ let src = "\
219
+ useClosure() -> Bool =
220
+ cb = |v|
221
+ True
222
+ cb(5)
223
+ ";
224
+ let source = parse(src);
225
+ let f = only_fn(&source);
226
+ let FnBody::Block(block) = &f.body else { panic!("expected a block body") };
227
+ let Stmt::Assign(assign) = &block.stmts[0] else { panic!("expected an assign statement") };
228
+ let Expr::Closure(closure) = &assign.values[0] else { panic!("expected a closure expression, got {:?}", assign.values[0]) };
229
+ assert_eq!(closure.params, vec!["v".to_string()]);
230
+ assert_eq!(closure.body.stmts.len(), 1);
231
+ }
232
+
233
+ #[test]
234
+ fn closure_literal_parses_with_no_params() {
235
+ let src = "\
236
+ useClosure() -> Bool =
237
+ cb = ||
238
+ True
239
+ cb()
240
+ ";
241
+ let source = parse(src);
242
+ let f = only_fn(&source);
243
+ let FnBody::Block(block) = &f.body else { panic!("expected a block body") };
244
+ let Stmt::Assign(assign) = &block.stmts[0] else { panic!("expected an assign statement") };
245
+ let Expr::Closure(closure) = &assign.values[0] else { panic!("expected a closure expression") };
246
+ assert!(closure.params.is_empty());
247
+ }
248
+
249
+ #[test]
250
+ fn fn_value_type_param_parses_with_positional_types_and_return() {
251
+ let src = "each(cb: fn(Int) -> Bool) -> Bool =\n True\n";
252
+ let source = parse(src);
253
+ let f = only_fn(&source);
254
+ let ParamType::Fn(param_types, ret) = &f.params[0].ty else { panic!("expected ParamType::Fn, got {:?}", f.params[0].ty) };
255
+ assert_eq!(param_types.len(), 1);
256
+ assert_eq!(param_types[0].name, "Int");
257
+ assert_eq!(ret.as_ref().map(|t| t.name.clone()), Some("Bool".to_string()));
258
+ }
259
+
260
+ #[test]
261
+ fn fn_value_type_param_parses_with_no_return() {
262
+ let src = "each(cb: fn(Int)) -> Bool =\n True\n";
263
+ let source = parse(src);
264
+ let f = only_fn(&source);
265
+ let ParamType::Fn(param_types, ret) = &f.params[0].ty else { panic!("expected ParamType::Fn") };
266
+ assert_eq!(param_types.len(), 1);
267
+ assert!(ret.is_none());
268
+ }
269
+ ```
270
+
271
+ - [ ] **Step 2: Run to see them fail**
272
+
273
+ Run: `cargo test -p plum-core --test parser_test`
274
+ Expected: fails to compile — `Expr::Closure`/`ParamType::Fn` don't exist yet.
275
+
276
+ - [ ] **Step 3: Add the AST nodes**
277
+
278
+ In `plum-core/src/ast.rs`, find the `Expr` enum (it currently ends with variants like `Var(String)`, `TypeName(String)`) and add a new variant:
279
+
280
+ ```rust
281
+ /// `|params| body`
282
+ Closure(Box<Closure>),
283
+ ```
284
+
285
+ Add the `Closure` struct near `Block`'s definition:
286
+
287
+ ```rust
288
+ #[derive(Debug, Clone, PartialEq)]
289
+ pub struct Closure {
290
+ pub params: Vec<String>,
291
+ pub body: Block,
292
+ }
293
+ ```
294
+
295
+ Find the `ParamType` enum (`Type(Type)`, `Variadic(Type)`) and add:
296
+
297
+ ```rust
298
+ /// `fn(Int, Str) -> Bool` — a function-value type annotation. Positional types
299
+ /// only, no param names (types don't need names).
300
+ Fn(Vec<Type>, Option<Box<Type>>),
301
+ ```
302
+
303
+ - [ ] **Step 4: Parse `closure` and `fn_value_type` nodes**
304
+
305
+ In `plum-core/src/parser.rs`, find `parse_primary_expression`'s match (it currently has arms like `"binary_operator" => ...`, `"fn_call" => ...`) — but `closure` is NOT under `primary_expression` in the grammar, it's a direct alternative of `expression` itself, so it needs handling in `parse_expression` instead. Find:
306
+
307
+ ```rust
308
+ pub fn parse_expression(&self, node: Node) -> Expr {
309
+ let node = self.unwrap_expr_node(node);
310
+ match node.kind() {
311
+ "comparison_operator" => self.parse_compare(node),
312
+ "not_operator" => {
313
+ let arg = node.named_child(0)
314
+ .map(|n| { let u = self.unwrap_expr_node(n); self.parse_expression(u) })
315
+ .unwrap_or(Expr::Int(0));
316
+ Expr::Not(Box::new(arg))
317
+ }
318
+ "boolean_operator" => self.parse_bool_op(node),
319
+ "ternary_expression" => self.parse_ternary(node),
320
+ _ => self.parse_primary_expression(node),
321
+ }
322
+ }
323
+ ```
324
+
325
+ Change to:
326
+
327
+ ```rust
328
+ pub fn parse_expression(&self, node: Node) -> Expr {
329
+ let node = self.unwrap_expr_node(node);
330
+ match node.kind() {
331
+ "comparison_operator" => self.parse_compare(node),
332
+ "not_operator" => {
333
+ let arg = node.named_child(0)
334
+ .map(|n| { let u = self.unwrap_expr_node(n); self.parse_expression(u) })
335
+ .unwrap_or(Expr::Int(0));
336
+ Expr::Not(Box::new(arg))
337
+ }
338
+ "boolean_operator" => self.parse_bool_op(node),
339
+ "ternary_expression" => self.parse_ternary(node),
340
+ "closure" => Expr::Closure(Box::new(self.parse_closure(node))),
341
+ _ => self.parse_primary_expression(node),
342
+ }
343
+ }
344
+
345
+ fn parse_closure(&self, node: Node) -> Closure {
346
+ // closure: "|" var_identifier,* "|" body
347
+ let params: Vec<String> = self.children_of_kind(node, "var_identifier")
348
+ .into_iter()
349
+ .map(|n| self.text(n))
350
+ .collect();
351
+ let body = self.children_of_kind(node, "body")
352
+ .into_iter()
353
+ .next()
354
+ .map(|n| self.parse_block(n))
355
+ .unwrap_or(Block { stmts: vec![] });
356
+ Closure { params, body }
357
+ }
358
+ ```
359
+
360
+ Find `parse_param` (it currently matches `n.kind() == "variadic_type"` vs. the `else` branch treating everything else as a plain `type`):
361
+
362
+ ```rust
363
+ fn parse_param(&self, node: Node) -> Param {
364
+ // param: var_identifier ":" (type | variadic_type) ("=" expression)?
365
+ let name = node.named_child(0).map(|n| self.text(n)).unwrap_or_default();
366
+ let ty = node.named_child(1).map(|n| {
367
+ if n.kind() == "variadic_type" {
368
+ let inner = n.named_child(0)
369
+ .map(|t| self.parse_type(t))
370
+ .unwrap_or(Type { name: String::new(), generics: vec![] });
371
+ ParamType::Variadic(inner)
372
+ } else {
373
+ ParamType::Type(self.parse_type(n))
374
+ }
375
+ }).unwrap_or(ParamType::Type(Type { name: String::new(), generics: vec![] }));
376
+ let default = node.named_child(2).map(|n| {
377
+ let unwrapped = self.unwrap_expr_node(n);
378
+ self.parse_expression(unwrapped)
379
+ });
380
+ Param { name, ty, default }
381
+ }
382
+ ```
383
+
384
+ Change the type-dispatch to also handle `fn_value_type`:
385
+
386
+ ```rust
387
+ fn parse_param(&self, node: Node) -> Param {
388
+ // param: var_identifier ":" (type | variadic_type | fn_value_type) ("=" expression)?
389
+ let name = node.named_child(0).map(|n| self.text(n)).unwrap_or_default();
390
+ let ty = node.named_child(1).map(|n| match n.kind() {
391
+ "variadic_type" => {
392
+ let inner = n.named_child(0)
393
+ .map(|t| self.parse_type(t))
394
+ .unwrap_or(Type { name: String::new(), generics: vec![] });
395
+ ParamType::Variadic(inner)
396
+ }
397
+ "fn_value_type" => self.parse_fn_value_type(n),
398
+ _ => ParamType::Type(self.parse_type(n)),
399
+ }).unwrap_or(ParamType::Type(Type { name: String::new(), generics: vec![] }));
400
+ let default = node.named_child(2).map(|n| {
401
+ let unwrapped = self.unwrap_expr_node(n);
402
+ self.parse_expression(unwrapped)
403
+ });
404
+ Param { name, ty, default }
405
+ }
406
+
407
+ fn parse_fn_value_type(&self, node: Node) -> ParamType {
408
+ // fn_value_type: "fn" "(" field("params", type,*) ")" ("->" field("returns", type))?
409
+ // The "returns" field (if present) is a distinct field from "params", so the
410
+ // two are disambiguated unambiguously by field name, not by counting/position
411
+ // among same-kind "type" children — the same idiom `fn`'s own `returns` field
412
+ // already uses.
413
+ let returns_node = node.child_by_field_name("returns");
414
+ let param_types: Vec<Type> = self.children_of_kind(node, "type")
415
+ .into_iter()
416
+ .filter(|n| Some(*n) != returns_node)
417
+ .map(|n| self.parse_type(n))
418
+ .collect();
419
+ let ret = returns_node.map(|n| Box::new(self.parse_type(n)));
420
+ ParamType::Fn(param_types, ret)
421
+ }
422
+ ```
423
+
424
+ - [ ] **Step 5: Run parser tests**
425
+
426
+ Run: `cargo test -p plum-core --test parser_test`
427
+ Expected: all 4 tests pass. If `parse_fn_value_type`'s field extraction needed a grammar tweak (per the note above), go back and apply it, re-running Task 1's corpus tests too.
428
+
429
+ - [ ] **Step 6: Run the full workspace suite**
430
+
431
+ Run: `cargo test --workspace`
432
+ Expected: green (new AST variants are additive; nothing existing matches on `Expr`/`ParamType` exhaustively in a way that would fail to compile — verify this by checking for compile errors from non-exhaustive `match` arms in `plum-checker`/`plum-wasm-codegen`, and add a minimal `_ => ...` fallback arm or explicit handling wherever the compiler flags one).
433
+
434
+ - [ ] **Step 7: Commit**
435
+
436
+ ```bash
437
+ git add plum-core/src/ast.rs plum-core/src/parser.rs plum-core/tests/parser_test.rs
438
+ git commit -m "feat(plum-core): parse closure literals and fn(...) type annotations"
439
+ ```
440
+
441
+ ---
442
+
443
+ ### Task 3: Checker — infer a closure's type; confirm closure calls type-check
444
+
445
+ **Files:**
446
+ - Modify: `plum-checker/src/lib.rs`
447
+ - Test: `plum-checker/tests/checker_tests.rs`
448
+
449
+ **Interfaces:**
450
+ - Consumes: `ast::Expr::Closure`, `ast::ParamType::Fn` (Task 2).
451
+ - Produces: `infer_expr` handles `Expr::Closure`, returning `PlumType::TFun`. No other function signatures change — `check_fn`'s existing `ParamType` match (used to bind each param's type into the local env) needs one new arm for `ParamType::Fn` too, converting it to `PlumType::TFun`.
452
+
453
+ - [ ] **Step 1: Write failing tests**
454
+
455
+ Append to `plum-checker/tests/checker_tests.rs`:
456
+
457
+ ```rust
458
+ #[test]
459
+ fn closure_literal_infers_as_a_function_type() {
460
+ let src = "\
461
+ useClosure() -> Bool =
462
+ cb = |v|
463
+ True
464
+ cb(5)
465
+ ";
466
+ let source = parse(src);
467
+ let result = check_source(&source);
468
+ assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
469
+ }
470
+
471
+ #[test]
472
+ fn fn_value_typed_param_can_be_called() {
473
+ let src = "\
474
+ each(cb: fn(Int) -> Bool) -> Bool =
475
+ cb(5)
476
+ ";
477
+ let source = parse(src);
478
+ let result = check_source(&source);
479
+ assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
480
+ }
481
+
482
+ #[test]
483
+ fn closure_passed_to_fn_value_typed_param_type_checks() {
484
+ let src = "\
485
+ each(cb: fn(Int) -> Bool) -> Bool =
486
+ cb(5)
487
+
488
+ use() -> Bool =
489
+ each(|v|
490
+ True)
491
+ ";
492
+ let source = parse(src);
493
+ let result = check_source(&source);
494
+ assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
495
+ }
496
+ ```
497
+
498
+ - [ ] **Step 2: Run to see them fail**
499
+
500
+ Run: `cargo test -p plum-checker --test checker_tests closure`
501
+ Expected: fails to compile initially (`infer_expr` doesn't exhaustively handle `Expr::Closure` — a non-exhaustive match compile error) or, once that's stubbed minimally, fails at runtime because `ParamType::Fn` isn't converted to a `PlumType` anywhere yet.
502
+
503
+ - [ ] **Step 3: Add `infer_expr`'s `Closure` arm**
504
+
505
+ In `plum-checker/src/lib.rs`, find `infer_expr`'s match (it has arms for `Expr::Int`, `Expr::Var`, etc., ending around the `Expr::Attribute` arm). Add:
506
+
507
+ ```rust
508
+ ast::Expr::Closure(cl) => {
509
+ let mut closure_env = env.clone();
510
+ let param_types: Vec<PlumType> = cl.params.iter().map(|p| {
511
+ let t = PlumType::TVar(format!("_closure_{}", p));
512
+ closure_env.insert(p.clone(), TypeScheme::mono(t.clone()));
513
+ t
514
+ }).collect();
515
+ let body_ty = match &cl.body.stmts.last() {
516
+ Some(ast::Stmt::Expr(e)) => infer_expr(e, &closure_env, ctx)?,
517
+ Some(ast::Stmt::Return(Some(e))) => infer_expr(e, &closure_env, ctx)?,
518
+ _ => PlumType::TUnit,
519
+ };
520
+ Ok(PlumType::TFun(param_types, Box::new(body_ty)))
521
+ }
522
+ ```
523
+
524
+ - [ ] **Step 4: Convert `ParamType::Fn` to a `PlumType` wherever `plum_type_from_ast`-style conversion happens for params**
525
+
526
+ Find every place in `plum-checker/src/lib.rs` that matches on `ast::ParamType::Type(t) => ...` / `ast::ParamType::Variadic(t) => ...` together (e.g. inside `build_global_tables`, `check_fn`) — there are a few such call sites. For each, add a third arm:
527
+
528
+ ```rust
529
+ ast::ParamType::Fn(param_types, ret) => PlumType::TFun(
530
+ param_types.iter().map(plum_type_from_ast).collect(),
531
+ Box::new(ret.as_ref().map(|r| plum_type_from_ast(r)).unwrap_or(PlumType::TUnit)),
532
+ ),
533
+ ```
534
+
535
+ (Match the exact surrounding style at each call site — some are inline closures passed to `.map()`, some are `match &p.ty { ... }` blocks. Search for `ast::ParamType::Variadic` to find every site that needs the parallel `Fn` arm — the compiler will also flag any non-exhaustive match as a hard error, which is the authoritative list.)
536
+
537
+ - [ ] **Step 5: Run checker tests**
538
+
539
+ Run: `cargo test -p plum-checker --test checker_tests closure`
540
+ Expected: all 3 pass.
541
+
542
+ - [ ] **Step 6: Run the full checker crate suite**
543
+
544
+ Run: `cargo test -p plum-checker`
545
+ Expected: green.
546
+
547
+ - [ ] **Step 7: Commit**
548
+
549
+ ```bash
550
+ git add plum-checker/src/lib.rs plum-checker/tests/checker_tests.rs
551
+ git commit -m "feat(plum-checker): infer closure literal types; type-check fn(...)-typed params"
552
+ ```
553
+
554
+ ---
555
+
556
+ ### Task 4: Codegen infrastructure — wasm function table + element section support
557
+
558
+ **Files:**
559
+ - Modify: `plum-wasm-codegen/src/lib.rs`
560
+ - Test: `plum-wasm-codegen/tests/codegen_tests.rs`
561
+
562
+ **Interfaces:**
563
+ - Consumes: nothing from other tasks — this is pure `WasmModule` infrastructure, independently testable without any closure-compiling logic existing yet.
564
+ - Produces: `WasmModule::add_table_element(&mut self, func_idx: u32) -> u32`, appending `func_idx` to a single funcref table and returning its table index. `WasmModule::finish()` now emits a `Table` section (only if any elements were added) between the existing Function and Memory sections, and an `Element` section between the existing Export and Code sections — both are the correct binary positions per the wasm module section order (Type, Import, Function, **Table**, Memory, Global, Export, **Element**, Code, Data).
565
+
566
+ - [ ] **Step 1: Write a failing test proving a table + element section round-trips through a real module**
567
+
568
+ Append to `plum-wasm-codegen/tests/codegen_tests.rs`:
569
+
570
+ ```rust
571
+ #[test]
572
+ fn wasm_module_with_a_table_element_validates_and_call_indirect_works() {
573
+ // Exercises WasmModule's new table/element support directly, independent of any
574
+ // closure-compiling logic (which doesn't exist yet) — builds a tiny module by
575
+ // hand: one function that returns 42, registered as table element 0, called via
576
+ // `call_indirect` from `main` using a runtime-computed (not compile-time-constant)
577
+ // table index, to prove the table/element wiring is real, not coincidentally
578
+ // skipped by validation.
579
+ let mut module = plum_wasm_codegen::WasmModule::new();
580
+ let ret42_type = module.add_type(&[], &[wasm_encoder::ValType::I64]);
581
+ let ret42_idx = module.add_function(ret42_type, &{
582
+ let mut body = Vec::new();
583
+ wasm_encoder::Instruction::I64Const(42).encode(&mut body);
584
+ wasm_encoder::Instruction::End.encode(&mut body);
585
+ body
586
+ });
587
+ let table_idx = module.add_table_element(ret42_idx);
588
+ assert_eq!(table_idx, 0);
589
+
590
+ let main_type = module.add_type(&[], &[wasm_encoder::ValType::I64]);
591
+ let main_idx = module.add_function(main_type, &{
592
+ let mut body = Vec::new();
593
+ wasm_encoder::Instruction::I32Const(0).encode(&mut body); // table index operand
594
+ wasm_encoder::Instruction::CallIndirect { type_index: ret42_type, table_index: 0 }.encode(&mut body);
595
+ wasm_encoder::Instruction::End.encode(&mut body);
596
+ body
597
+ });
598
+ module.add_export("main", wasm_encoder::ExportKind::Func, main_idx);
599
+
600
+ let bytes = module.finish();
601
+ let result = wasmparser::validate(&bytes);
602
+ assert!(result.is_ok(), "wasm validation failed: {:?}", result.err());
603
+
604
+ let engine = wasmtime::Engine::default();
605
+ let wasm_module = wasmtime::Module::new(&engine, &bytes).expect("module should be loadable");
606
+ let mut store = wasmtime::Store::new(&engine, ());
607
+ let instance = wasmtime::Instance::new(&mut store, &wasm_module, &[]).expect("module should instantiate");
608
+ let main = instance.get_typed_func::<(), i64>(&mut store, "main").expect("main should have signature () -> i64");
609
+ assert_eq!(main.call(&mut store, ()).expect("main should not trap"), 42);
610
+ }
611
+ ```
612
+
613
+ - [ ] **Step 2: Run to see it fail**
614
+
615
+ Run: `cargo test -p plum-wasm-codegen --test codegen_tests wasm_module_with_a_table`
616
+ Expected: fails to compile — `add_table_element` doesn't exist yet.
617
+
618
+ - [ ] **Step 3: Add table/element support to `WasmModule`**
619
+
620
+ In `plum-wasm-codegen/src/lib.rs`, find the `WasmModule` struct:
621
+
622
+ ```rust
623
+ pub struct WasmModule {
624
+ types: Vec<FuncType>,
625
+ imports: Vec<(String, String, u32)>,
626
+ functions: Vec<(u32, Vec<u8>)>,
627
+ exports: Vec<(String, ExportKind, u32)>,
628
+ memories: Vec<MemoryType>,
629
+ globals: Vec<(ValType, bool, Vec<u8>)>,
630
+ data_segments: Vec<(u32, Vec<u8>)>,
631
+ pub func_import_count: u32,
632
+ pub func_count: u32,
633
+ global_count: u32,
634
+ }
635
+ ```
636
+
637
+ Add a new field:
638
+
639
+ ```rust
640
+ pub struct WasmModule {
641
+ types: Vec<FuncType>,
642
+ imports: Vec<(String, String, u32)>,
643
+ functions: Vec<(u32, Vec<u8>)>,
644
+ exports: Vec<(String, ExportKind, u32)>,
645
+ memories: Vec<MemoryType>,
646
+ globals: Vec<(ValType, bool, Vec<u8>)>,
647
+ data_segments: Vec<(u32, Vec<u8>)>,
648
+ /// Function indices, in table order — the single funcref table used for
649
+ /// closure `call_indirect` dispatch. Index into this vec IS the table index.
650
+ table_elements: Vec<u32>,
651
+ pub func_import_count: u32,
652
+ pub func_count: u32,
653
+ global_count: u32,
654
+ }
655
+ ```
656
+
657
+ Update `WasmModule::new()`'s struct literal to add `table_elements: Vec::new(),`.
658
+
659
+ Add a new method, near `add_memory`:
660
+
661
+ ```rust
662
+ /// Registers `func_idx` as the next slot in the single funcref table used for
663
+ /// closure `call_indirect` dispatch, returning its table index.
664
+ pub fn add_table_element(&mut self, func_idx: u32) -> u32 {
665
+ let table_idx = self.table_elements.len() as u32;
666
+ self.table_elements.push(func_idx);
667
+ table_idx
668
+ }
669
+ ```
670
+
671
+ In `finish()`, find the Function section block, ending with `module.section(&funcs); }`, and insert a Table section right after it (before the Memory section block):
672
+
673
+ ```rust
674
+ // Table section
675
+ if !self.table_elements.is_empty() {
676
+ let mut tables = TableSection::new();
677
+ tables.table(TableType {
678
+ element_type: RefType::FUNCREF,
679
+ minimum: self.table_elements.len() as u64,
680
+ maximum: Some(self.table_elements.len() as u64),
681
+ table64: false,
682
+ shared: false,
683
+ });
684
+ module.section(&tables);
685
+ }
686
+ ```
687
+
688
+ Find the Export section block, ending with `module.section(&exports); }`, and insert an Element section right after it (before the Code section block):
689
+
690
+ ```rust
691
+ // Element section
692
+ if !self.table_elements.is_empty() {
693
+ let mut elements = ElementSection::new();
694
+ let offset = ConstExpr::i32_const(0);
695
+ elements.active(Some(0), &offset, Elements::Functions(std::borrow::Cow::Borrowed(&self.table_elements)));
696
+ module.section(&elements);
697
+ }
698
+ ```
699
+
700
+ - [ ] **Step 4: Run the new test**
701
+
702
+ Run: `cargo test -p plum-wasm-codegen --test codegen_tests wasm_module_with_a_table`
703
+ Expected: passes.
704
+
705
+ - [ ] **Step 5: Run the full workspace suite**
706
+
707
+ Run: `cargo test --workspace`
708
+ Expected: green — confirm no existing test that builds a `WasmModule` and checks its exact byte output (if any) is affected by the new always-present-but-conditionally-emitted table field (it should be a pure no-op when `table_elements` is empty, since both new sections are gated on `!self.table_elements.is_empty()`).
709
+
710
+ - [ ] **Step 6: Commit**
711
+
712
+ ```bash
713
+ git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs
714
+ git commit -m "feat(plum-wasm-codegen): add wasm function table + element section support"
715
+ ```
716
+
717
+ ---
718
+
719
+ ### Task 5: Codegen — compile closure literals and closure calls
720
+
721
+ **Files:**
722
+ - Modify: `plum-wasm-codegen/src/lib.rs`
723
+ - Test: `plum-wasm-codegen/tests/codegen_tests.rs`
724
+
725
+ **This is the largest, most judgment-requiring task in this plan** — genuinely new algorithmic code (free-variable analysis, a program-wide discovery pre-pass, synthetic function generation), not a modification of existing tested logic. Treat the design below as your starting architecture, not verified-correct code to transcribe — validate every piece via the tests as you go, and stop and report BLOCKED/NEEDS_CONTEXT if a specific piece doesn't hold up under testing, per this plan's Global Constraints.
726
+
727
+ **Interfaces:**
728
+ - Consumes: `ast::Expr::Closure`, `ast::ParamType::Fn` (Task 2); `WasmModule::add_table_element` (Task 4); `PlumType::TFun` (already exists; Task 3 makes the checker produce it for closures).
729
+ - Produces: `compile_source` compiles every closure literal in the program to its own wasm function and table entry; `compile_expr` handles `Expr::Closure` (constructing the runtime closure value) and calling a closure-typed binding (`cb(x)` where `cb`'s inferred type is `TFun`, via `call_indirect` instead of a direct `Call`).
730
+
731
+ **Design:**
732
+
733
+ 1. **Runtime representation.** A closure value is a single `i32` pointer to a heap-allocated pair `{table_index: i32, env_pointer: i32}` (8-byte stride per field, matching every other class-like struct already in this codegen — `table_index` at offset 0, `env_pointer` at offset 8). The captured-environment struct itself is a separate heap allocation: one 8-byte slot per captured variable, in a stable (e.g. alphabetical, or first-appearance) order.
734
+
735
+ 2. **Discovery pre-pass.** Before compiling any function body, walk every `ast::Item::Fn`'s body (a new walker, since `Collector` runs per-function *after* registration and doesn't cross function boundaries) to find every `ast::Expr::Closure` node. For each one found as an argument to a call whose corresponding declared param type is `ast::ParamType::Fn(param_types, ret)` (already-monomorphized concrete types, since this pre-pass runs on the output of `monomorphize_source`), record:
736
+ - a synthetic mangled name (e.g. `format!("closure${}", n)` with a simple incrementing counter);
737
+ - the concrete wasm param/return `ValType`s (from `param_types`/`ret` via the existing `ast_type_to_wasm`);
738
+ - its free variables — every `Var(name)` referenced in the closure's body that is NOT one of the closure's own params — resolved against the *enclosing* function's locals (both real params/assigned locals AND, if the closure is itself nested inside another closure, that outer closure's own free variables/params) — with each free variable's `PlumType` (via `infer_local_type` against the enclosing scope's type env, exactly as `Collector`/`compile_expr` already do elsewhere in this file).
739
+
740
+ Store the result in a new `HashMap<usize, ClosureInfo>` (keyed by the closure `Expr`'s pointer identity, the same keying convention `classcall_scratch`/`match_scratch_index` already use), where:
741
+ ```rust
742
+ struct ClosureInfo {
743
+ mangled_name: String,
744
+ func_idx: u32,
745
+ table_idx: u32,
746
+ param_vts: Vec<ValType>,
747
+ ret_vt: Option<ValType>,
748
+ free_vars: Vec<(String, PlumType)>, // stable order
749
+ }
750
+ ```
751
+ Add this map to `CompileCtx` as `pub closures: HashMap<usize, ClosureInfo>`.
752
+
753
+ 3. **Registering each closure as a real function.** For each discovered closure, `module.add_type(...)` for `(env_ptr: I32, ...param_vts) -> ret_vt`, then reserve a function slot via `module.add_function(type_idx, &[])` (placeholder body, patched later — exactly how `compile_source` already pre-registers every ordinary `Fn`'s slot before compiling bodies) to get `func_idx`, then `module.add_table_element(func_idx)` to get `table_idx`.
754
+
755
+ 4. **Compiling each closure's own body.** Build a `LocalCtx` for the closure much like `compile_fn_body` already does for an ordinary `Fn`, except: local 0 is the (unused-by-name) env pointer param; each free variable gets its own local slot, loaded from the env pointer at function entry (`LocalGet(env_ptr_local); {I64,F64,I32}Load(offset); LocalSet(free_var_local)`, offsets assigned in the same stable order used when constructing the env struct at the call site); each real closure param gets ordinary param-local treatment, offset by 1 (to account for the implicit env-pointer param at index 0) plus however many free-var locals precede it in your chosen local-numbering scheme. Compile the closure's `Block` body via the existing `compile_block_as_fn_body`/value-position machinery, exactly as an ordinary function's block body already is.
756
+
757
+ 5. **Compiling a closure literal's construction site** (new `Expr::Closure` arm in `compile_expr`): reserve two scratch locals (extend the existing `classcall_scratch`-style pool, or add a parallel `closure_scratch: HashMap<usize, u32>` pool reserving 2 consecutive slots per closure literal — mirroring how `Collector`/`LocalCtx` already reserve scratch slots for `ClassCall`). Bump-allocate the env struct (size `8 * free_vars.len()`), store each free variable's *current* value (loaded via the existing `Expr::Var` local-lookup path in the *enclosing* function) into it; bump-allocate the 2-word closure struct, store `table_idx` (a compile-time `i32.const`) at offset 0 and the env struct's pointer at offset 8; leave the closure struct's pointer as the result.
758
+
759
+ 6. **Compiling a closure call.** In `compile_expr`'s existing `ast::Expr::FnCall(call) => { ... }` arm, before the existing `ctx.func_ids.get(&call.name)` lookup: check whether `call.name` is a *local* (`ctx.locals.contains_key(&call.name)`) whose inferred type (`infer_local_type` on `ast::Expr::Var(call.name.clone())`) is `PlumType::TFun(..)`. If so, compile as a closure call instead of a direct `Call`: `LocalGet` the closure pointer, `I32Load` its `env_pointer` (offset 8) into a scratch, push that env pointer, then compile+push each real argument, then `LocalGet` the closure pointer again and `I32Load` its `table_index` (offset 0), then `Instruction::CallIndirect { type_index, table_index: 0 }` — `type_index` resolved from the call site's own already-known concrete arg/return types (build/reuse a `module.add_type` call keyed by that signature, or thread the specific `ClosureInfo`/type index through if the call site's closure binding can be traced back to a specific `ClosureInfo` — use your judgment on the cleanest way to get a consistent type index here, since multiple closures of the same signature can share one).
760
+
761
+ - [ ] **Step 1: Write failing tests**
762
+
763
+ Append to `plum-wasm-codegen/tests/codegen_tests.rs`:
764
+
765
+ ```rust
766
+ #[test]
767
+ fn non_capturing_closure_passed_and_called_runs_correctly() {
768
+ let src = "\
769
+ each(cb: fn(Int) -> Int) -> Int =
770
+ cb(5)
771
+
772
+ main() -> Int =
773
+ each(|v|
774
+ v)
775
+ ";
776
+ let source = parse(src);
777
+ let bytes = compile_source(&source).expect("compile failed");
778
+ assert_eq!(run_main(&bytes), 5);
779
+ }
780
+
781
+ #[test]
782
+ fn capturing_closure_snapshots_value_at_creation_time_runs_correctly() {
783
+ let src = "\
784
+ each(cb: fn(Int) -> Int) -> Int =
785
+ cb(0)
786
+
787
+ useClosure() -> Int =
788
+ x = 10
789
+ cb = |v|
790
+ x + v
791
+ x = 999
792
+ each(cb)
793
+
794
+ main() -> Int =
795
+ useClosure()
796
+ ";
797
+ let source = parse(src);
798
+ let bytes = compile_source(&source).expect("compile failed");
799
+ // The closure must see x==10 (its value when the closure was created), not 999
800
+ // (its value when `each(cb)` is actually called) — proving snapshot-by-value
801
+ // capture, not a live/shared reference.
802
+ assert_eq!(run_main(&bytes), 10);
803
+ }
804
+
805
+ #[test]
806
+ fn closure_passed_through_already_generic_higher_order_function_runs_correctly() {
807
+ let src = "\
808
+ identity(value: a) -> a =
809
+ value
810
+
811
+ each(cb: fn(Int) -> Int) -> Int =
812
+ cb(identity(7))
813
+
814
+ main() -> Int =
815
+ each(|v|
816
+ v * 2)
817
+ ";
818
+ let source = parse(src);
819
+ let bytes = compile_source(&source).expect("compile failed");
820
+ assert_eq!(run_main(&bytes), 14);
821
+ }
822
+ ```
823
+
824
+ - [ ] **Step 2: Run to see them fail**
825
+
826
+ Run: `cargo test -p plum-wasm-codegen --test codegen_tests capturing_closure non_capturing_closure closure_passed_through`
827
+ Expected: all fail — none of the compiling logic exists yet.
828
+
829
+ - [ ] **Step 3: Implement the design above**
830
+
831
+ Follow the 6-part design. Iterate test-by-test — get `non_capturing_closure_passed_and_called_runs_correctly` passing first (no free-variable analysis needed for that one, simplifying the first pass), then tackle capture, then the generics-interop test.
832
+
833
+ - [ ] **Step 4: Run all three new tests, then the full codegen suite**
834
+
835
+ ```bash
836
+ cargo test -p plum-wasm-codegen --test codegen_tests
837
+ ```
838
+
839
+ Expected: all pass, including every pre-existing test (confirming the new discovery pre-pass and `FnCall` arm change don't regress ordinary function calls).
840
+
841
+ - [ ] **Step 5: Run the full workspace and tree-sitter suites**
842
+
843
+ ```bash
844
+ cargo test --workspace
845
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
846
+ ```
847
+
848
+ Expected: fully green.
849
+
850
+ - [ ] **Step 6: Commit**
851
+
852
+ ```bash
853
+ git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs
854
+ git commit -m "feat(plum-wasm-codegen): compile closure literals and closure calls via a function table"
855
+ ```
856
+
857
+ ---
858
+
859
+ ### Task 6: Examples and docs
860
+
861
+ **Files:**
862
+ - Modify or create: `examples/closures.plum` (new file, following this repo's existing `examples/*.plum` convention — one file per feature area, exercised by both crates' `examples_test.rs`)
863
+ - Modify: `plum-checker/tests/examples_test.rs`, `plum-wasm-codegen/tests/examples_test.rs`
864
+ - Modify: `README.md`
865
+
866
+ **Interfaces:**
867
+ - Consumes: everything from Tasks 1-5.
868
+ - Produces: nothing further downstream — final integration/documentation task.
869
+
870
+ - [ ] **Step 1: Add `examples/closures.plum`**
871
+
872
+ ```plum
873
+ each(cb: fn(Int) -> Int) -> Int =
874
+ cb(5)
875
+
876
+ double(v: Int) -> Int =
877
+ v * 2
878
+
879
+ useNamedFunctionAsValue() -> Int =
880
+ each(double)
881
+
882
+ useCapturingClosure() -> Int =
883
+ offset = 100
884
+ each(|v|
885
+ v + offset)
886
+
887
+ main() -> Int =
888
+ each(|v|
889
+ v * 3)
890
+ ```
891
+
892
+ (If `each(double)` — passing a top-level *named function* wherever a `fn(...)`-typed param is expected, not just a closure literal — isn't yet supported by Task 5's design (it may need a small addition: wrapping a plain function reference in the same `{table_index, env_pointer}` shape with an empty/null env), either extend Task 5 minimally to support it, or drop `useNamedFunctionAsValue`/adjust this example to closures only and note the named-function-as-value gap in README's Known Gaps instead. Verify which via a quick test before deciding.)
893
+
894
+ - [ ] **Step 2: Extend both crates' `examples_test.rs`**
895
+
896
+ In `plum-checker/tests/examples_test.rs`, confirm `examples/closures.plum` is picked up automatically (it likely already iterates every `.plum` file in the directory — check `example_files()`'s implementation; if so, no change needed beyond adding the file).
897
+
898
+ In `plum-wasm-codegen/tests/examples_test.rs`, add:
899
+
900
+ ```rust
901
+ #[test]
902
+ fn closures_example_compiles_and_runs_correctly() {
903
+ let bytes = assert_compiles("closures.plum");
904
+ let engine = wasmtime::Engine::default();
905
+ let module = wasmtime::Module::new(&engine, &bytes).expect("module should be loadable");
906
+ let mut store = wasmtime::Store::new(&engine, ());
907
+ let instance = wasmtime::Instance::new(&mut store, &module, &[]).expect("module should instantiate");
908
+ let main = instance.get_typed_func::<(), i64>(&mut store, "main").expect("main should have signature () -> i64");
909
+ assert_eq!(main.call(&mut store, ()).expect("main should not trap"), 15);
910
+ }
911
+ ```
912
+
913
+ (Adjust the expected result if Step 1's `main` body ends up different from what's shown above.)
914
+
915
+ - [ ] **Step 3: Run both examples suites**
916
+
917
+ ```bash
918
+ cargo test -p plum-checker --test examples_test
919
+ cargo test -p plum-wasm-codegen --test examples_test
920
+ ```
921
+
922
+ Expected: both green.
923
+
924
+ - [ ] **Step 4: Update README**
925
+
926
+ Add a new "Closures" section (find a sensible place — likely after the existing "Generics" section, before "`self`, field access, and methods"), documenting: the `|params| body` syntax, the `fn(...)` / `fn(...) -> T` type annotation syntax (positional types only), snapshot-by-value capture semantics, and a link to `examples/closures.plum`. Update the "Known gaps" list: remove the `closure` bullet (`` `closure` (`|params| body`) exists in `grammar.js` but isn't wired into any reachable rule yet, so it doesn't actually parse in context. ``) if it's still present verbatim — check the file's current end for this exact sentence — and add any residual gap actually discovered during Task 5 (e.g. named-function-as-closure-value, if that turned out to be unsupported per Task 6 Step 1's note).
927
+
928
+ - [ ] **Step 5: Run the full workspace and tree-sitter suites one final time**
929
+
930
+ ```bash
931
+ cargo test --workspace
932
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
933
+ ```
934
+
935
+ Expected: fully green, zero known failures.
936
+
937
+ - [ ] **Step 6: Commit**
938
+
939
+ ```bash
940
+ git add examples/closures.plum plum-checker/tests/examples_test.rs plum-wasm-codegen/tests/examples_test.rs README.md
941
+ git commit -m "docs+test: closures complete; add example and update README"
942
+ ```