plum

#treesitter#compiler#wasm

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

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


f1e33d7Peter John 2026-07-20T13:40:09+05:30
docs: add implementation plan for tail-position and grammar gap fixes
docs/superpowers/plans/2026-07-20-tail-position-and-grammar-gaps.md ADDED
@@ -0,0 +1,909 @@
1
+ # Tail-Position Value Propagation and Grammar Gap Fixes 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:** Fix two pre-existing, unrelated-to-enums defects discovered during general enum support: (1) a tree-sitter-plum grammar limitation where a multi-line body's trailing statement can only be `$.primary_expression`, not a full `$.expression`; (2) a `plum-wasm-codegen` gap where a function's tail `match`/`if` (without explicit `return` in every arm) silently drops its value instead of returning it, producing wasm that fails validation.
6
+
7
+ **Architecture:** Fix 1 is a one-line grammar change (`$.primary_expression` → `$.expression` in `_statement`) plus corpus tests. Fix 2 threads an `Option<ValType>` "value position" parameter through the match/if compilation functions (`compile_match`, `compile_match_arms`, `compile_variant_eq_arm`, `compile_variant_constructor_arm`, and a new `compile_if`), with two new small recursive helpers (`compile_stmt_in_value_position`, `compile_block_in_value_position`) that decide, for a statement/block that must produce the function's return value, whether to recurse further (nested `if`/`match`), leave a bare expression's value on the stack, pass through a `return`/`todo` unchanged (both are stack-polymorphic in wasm), or emit a clear compile error for any other shape.
8
+
9
+ **Tech Stack:** Rust (workspace: `plum-core`, `plum-checker`, `plum-wasm-codegen`), tree-sitter grammar (`tooling/tree-sitter-plum`, JS), `wasm-encoder`/`wasmparser`/`wasmtime` for codegen tests.
10
+
11
+ ## Global Constraints
12
+
13
+ - Out of scope: generics monomorphization, multi-subject `match`, nested constructor patterns, a full "does every path return a value" static analysis in `plum-checker` — only make value-position `if`/`match` either compile correctly or fail with a clear `codegen:`-prefixed error, matching this file's existing error-message convention.
14
+ - No changes needed in `plum-checker` for either fix — re-run its full suite (including `examples_test.rs`) to confirm no regression, but don't touch its source.
15
+ - Follow existing code style: terse one-line "why" comments only where non-obvious; error messages use the existing `"codegen: ..."` prefix convention already used throughout `plum-wasm-codegen/src/lib.rs`.
16
+ - 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.
17
+
18
+ ---
19
+
20
+ ### Task 1: Grammar — accept a full expression as a body's trailing statement
21
+
22
+ **Files:**
23
+ - Modify: `tooling/tree-sitter-plum/grammar.js:166-179` (`_statement` rule)
24
+ - Test: `tooling/tree-sitter-plum/test/corpus/` (new cases)
25
+
26
+ **Interfaces:**
27
+ - Consumes: nothing from other tasks.
28
+ - Produces: `_statement` accepts any `$.expression` (comparison, boolean-op, ternary, or the existing `$.primary_expression` alternatives), not just `$.primary_expression`. `plum-core`'s parser needs no change — `parse_case`/block-statement parsing already dispatches on node kind, and every new node kind reachable through `expression` (`comparison_operator`, `boolean_operator`, `ternary_expression`) is already handled by `AstParser::parse_expression` (used for expression-context nodes elsewhere).
29
+
30
+ - [ ] **Step 1: Make the grammar change**
31
+
32
+ In `tooling/tree-sitter-plum/grammar.js`, change:
33
+
34
+ ```js
35
+ _statement: ($) =>
36
+ choice(
37
+ $.assign,
38
+ $.break,
39
+ $.continue,
40
+ $.assert,
41
+ $.for,
42
+ $.while,
43
+ $.if,
44
+ $.match,
45
+ $.return,
46
+ $.todo,
47
+ $.primary_expression
48
+ ),
49
+ ```
50
+
51
+ to:
52
+
53
+ ```js
54
+ _statement: ($) =>
55
+ choice(
56
+ $.assign,
57
+ $.break,
58
+ $.continue,
59
+ $.assert,
60
+ $.for,
61
+ $.while,
62
+ $.if,
63
+ $.match,
64
+ $.return,
65
+ $.todo,
66
+ $.expression
67
+ ),
68
+ ```
69
+
70
+ - [ ] **Step 2: Regenerate and run the existing corpus suite**
71
+
72
+ ```bash
73
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate && npx --yes tree-sitter-cli test
74
+ ```
75
+
76
+ Expected: generation succeeds with no unresolved-conflict errors, and all pre-existing corpus cases still pass (every statement previously reachable via `primary_expression` remains reachable, since `expression`'s own last alternative is `primary_expression` — see `grammar.js`'s `expression` rule).
77
+
78
+ - [ ] **Step 3: Add corpus cases for the newly-parseable statement forms**
79
+
80
+ Append to `tooling/tree-sitter-plum/test/corpus/function.txt` three new cases (input half only — the next step fills in the expected tree):
81
+
82
+ ```
83
+ ================================================================================
84
+ function - bare comparison as body's trailing statement
85
+ ================================================================================
86
+
87
+ isNone(o: Option) -> Bool =
88
+ o == None
89
+
90
+ --------------------------------------------------------------------------------
91
+ ================================================================================
92
+ function - bare boolean-operator as body's trailing statement
93
+ ================================================================================
94
+
95
+ bothTrue(a: Bool, b: Bool) -> Bool =
96
+ a && b
97
+
98
+ --------------------------------------------------------------------------------
99
+ ================================================================================
100
+ function - bare ternary as body's trailing statement
101
+ ================================================================================
102
+
103
+ pick(cond: Bool, a: Int, b: Int) -> Int =
104
+ cond ? a : b
105
+
106
+ --------------------------------------------------------------------------------
107
+ ```
108
+
109
+ - [ ] **Step 4: Generate the expected trees and verify them**
110
+
111
+ ```bash
112
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test -u -f "bare comparison as body" && npx --yes tree-sitter-cli test -u -f "bare boolean-operator as body" && npx --yes tree-sitter-cli test -u -f "bare ternary as body"
113
+ ```
114
+
115
+ Open `test/corpus/function.txt` and confirm each of the three new cases' generated tree has **no** `ERROR`/`MISSING` node — the comparison/boolean-op/ternary node must appear as a single, complete node directly inside the function's `body`, not split into two separate statements.
116
+
117
+ - [ ] **Step 5: Run the full corpus suite**
118
+
119
+ ```bash
120
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
121
+ ```
122
+
123
+ Expected: all cases pass, old and new.
124
+
125
+ - [ ] **Step 6: Run the full Rust workspace suite**
126
+
127
+ ```bash
128
+ cargo test --workspace
129
+ ```
130
+
131
+ Expected: green (the grammar change doesn't remove any previously-valid parse, so nothing downstream should regress; `plum-checker`/`plum-wasm-codegen` tests exercise the parser transitively via their own `parse()` helpers).
132
+
133
+ - [ ] **Step 7: Commit**
134
+
135
+ ```bash
136
+ git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/test/corpus/function.txt
137
+ git add tooling/tree-sitter-plum/src # generated parser.c etc, only if tracked — check `git status` first
138
+ git commit -m "fix(tree-sitter-plum): allow a full expression as a body's trailing statement"
139
+ ```
140
+
141
+ ---
142
+
143
+ ### Task 2: Codegen — recursive value-position propagation for tail `if`/`match`
144
+
145
+ **Files:**
146
+ - Modify: `plum-wasm-codegen/src/lib.rs` (see exact line ranges in each step below — line numbers assume Task 1 has already landed and did not touch this file, so they should still be accurate; verify by reading the current file before editing)
147
+ - Test: `plum-wasm-codegen/tests/codegen_tests.rs`
148
+
149
+ **Interfaces:**
150
+ - Consumes: nothing new from Task 1 (Task 1 only touched the grammar; this task's AST shapes — `ast::Stmt::If`, `ast::Stmt::Match`, `ast::Stmt::Expr`, `ast::Stmt::Return`, `ast::Stmt::Todo` — are unchanged).
151
+ - Produces: `compile_block_as_fn_body`'s signature changes from `(..., has_return_value: bool)` to `(..., result_vt: Option<ValType>)` — its one call site (in `compile_fn_body`) is part of this task. `compile_match`'s signature gains a trailing `result_vt: Option<ValType>` parameter; so do `compile_match_arms`, `compile_variant_eq_arm`, `compile_variant_constructor_arm`. Three new functions: `block_type_for(Option<ValType>) -> BlockType`, `compile_case_body(&ast::Block, Option<ValType>, ...) -> Result<(), String>`, `compile_if(&ast::If, Option<ValType>, ...) -> Result<(), String>`, `compile_block_in_value_position(&ast::Block, ValType, ...) -> Result<(), String>`, `compile_stmt_in_value_position(&ast::Stmt, ValType, ...) -> Result<(), String>`. Task 3 does not depend on any of these names directly — it only exercises the feature through `.plum` source and `compile_source`.
152
+
153
+ - [ ] **Step 1: Write failing tests**
154
+
155
+ Append to `plum-wasm-codegen/tests/codegen_tests.rs`:
156
+
157
+ ```rust
158
+ #[test]
159
+ fn tail_match_without_return_runs_correctly() {
160
+ let src = "\
161
+ bindExample(n: Int) -> Int =
162
+ match n
163
+ x =>
164
+ x
165
+
166
+ main() -> Int =
167
+ bindExample(5)
168
+ ";
169
+ let source = parse(src);
170
+ let bytes = compile_source(&source).expect("compile failed");
171
+ assert_eq!(run_main(&bytes), 5);
172
+ }
173
+
174
+ #[test]
175
+ fn tail_if_without_return_runs_correctly() {
176
+ let src = "\
177
+ abs(n: Int) -> Int =
178
+ if n < 0
179
+ -n
180
+ else
181
+ n
182
+
183
+ main() -> Int =
184
+ abs(-7)
185
+ ";
186
+ let source = parse(src);
187
+ let bytes = compile_source(&source).expect("compile failed");
188
+ assert_eq!(run_main(&bytes), 7);
189
+ }
190
+
191
+ #[test]
192
+ fn tail_if_nested_inside_match_arm_without_return_runs_correctly() {
193
+ let src = "\
194
+ classify(n: Int) -> Int =
195
+ match n
196
+ 0 =>
197
+ 1
198
+ x =>
199
+ if x < 0
200
+ -1
201
+ else
202
+ 2
203
+
204
+ main() -> Int =
205
+ classify(-5)
206
+ ";
207
+ let source = parse(src);
208
+ let bytes = compile_source(&source).expect("compile failed");
209
+ assert_eq!(run_main(&bytes), -1);
210
+ }
211
+
212
+ #[test]
213
+ fn tail_match_mixing_return_and_bare_expr_arms_runs_correctly() {
214
+ let src = "\
215
+ describe(n: Int) -> Int =
216
+ match n
217
+ 0 =>
218
+ return 100
219
+ x =>
220
+ x * 2
221
+
222
+ main() -> Int =
223
+ describe(21)
224
+ ";
225
+ let source = parse(src);
226
+ let bytes = compile_source(&source).expect("compile failed");
227
+ assert_eq!(run_main(&bytes), 42);
228
+ }
229
+
230
+ #[test]
231
+ fn tail_enum_match_without_return_runs_correctly() {
232
+ let src = "\
233
+ enum Option =
234
+ | Some(Int)
235
+ | None
236
+
237
+ unwrapOr(o: Option, default: Int) -> Int =
238
+ match o
239
+ Some(v) =>
240
+ v
241
+ None =>
242
+ default
243
+
244
+ main() -> Int =
245
+ unwrapOr(Some(9), 0)
246
+ ";
247
+ let source = parse(src);
248
+ let bytes = compile_source(&source).expect("compile failed");
249
+ assert_eq!(run_main(&bytes), 9);
250
+ }
251
+
252
+ #[test]
253
+ fn tail_if_without_else_is_a_clear_error() {
254
+ let src = "\
255
+ bad(n: Int) -> Int =
256
+ if n < 0
257
+ return 1
258
+ ";
259
+ let source = parse(src);
260
+ let err = compile_source(&source).expect_err("if without else in value position must be a clear error, not invalid wasm");
261
+ assert!(err.contains("doesn't produce a return value"), "got: {}", err);
262
+ }
263
+
264
+ #[test]
265
+ fn tail_match_non_exhaustive_is_a_clear_error() {
266
+ let src = "\
267
+ bad(n: Int) -> Int =
268
+ match n
269
+ 0 =>
270
+ 1
271
+ ";
272
+ let source = parse(src);
273
+ let err = compile_source(&source).expect_err("non-exhaustive match in value position must be a clear error, not invalid wasm");
274
+ assert!(err.contains("doesn't produce a return value"), "got: {}", err);
275
+ }
276
+
277
+ #[test]
278
+ fn tail_match_arm_ending_in_non_value_statement_is_a_clear_error() {
279
+ let src = "\
280
+ bad(n: Int) -> Int =
281
+ match n
282
+ x =>
283
+ y = x
284
+ ";
285
+ let source = parse(src);
286
+ let err = compile_source(&source).expect_err("a match arm ending in a non-value statement must be a clear error, not invalid wasm");
287
+ assert!(err.contains("doesn't produce a return value"), "got: {}", err);
288
+ }
289
+ ```
290
+
291
+ - [ ] **Step 2: Run to see them fail**
292
+
293
+ Run: `cargo test -p plum-wasm-codegen --test codegen_tests tail_`
294
+ Expected: the first five tests fail with `wasmparser::validate` errors surfacing as `compile failed` panics (the value is dropped, producing invalid wasm) or wrong runtime results; the last two "clear error" tests fail because `compile_source` currently returns `Ok` (or panics) instead of the expected `Err`.
295
+
296
+ - [ ] **Step 3: Read the current file to confirm line numbers, then make the edits**
297
+
298
+ Read `plum-wasm-codegen/src/lib.rs` around the ranges below before editing — Task 1 doesn't touch this file, so these should still be accurate, but verify.
299
+
300
+ **3a. Add `block_type_for` near the other small helpers** (e.g. right after `plum_type_to_valtype`, around line 249-256):
301
+
302
+ ```rust
303
+ fn block_type_for(result_vt: Option<ValType>) -> BlockType {
304
+ result_vt.map(BlockType::Result).unwrap_or(BlockType::Empty)
305
+ }
306
+ ```
307
+
308
+ **3b. Remove `stmt_always_diverges` and `block_always_diverges`** (currently lines 642-662) — they become dead code once `compile_block_as_fn_body` no longer needs them (Step 3d). Delete this whole block:
309
+
310
+ ```rust
311
+ /// True if control can never fall through past this statement — every reachable path
312
+ /// ends in a `return`. Used to decide whether a tail-position If/Match needs a
313
+ /// trailing `unreachable` to satisfy wasm's per-block (not whole-function) validation
314
+ /// when the function declares a non-Unit return type.
315
+ fn stmt_always_diverges(stmt: &ast::Stmt) -> bool {
316
+ match stmt {
317
+ ast::Stmt::Return(_) | ast::Stmt::Todo => true,
318
+ ast::Stmt::If(if_) => {
319
+ if_.else_.is_some()
320
+ && block_always_diverges(&if_.body)
321
+ && if_.else_ifs.iter().all(|ei| block_always_diverges(&ei.body))
322
+ && if_.else_.as_ref().is_some_and(block_always_diverges)
323
+ }
324
+ ast::Stmt::Match(m) => !m.cases.is_empty() && m.cases.iter().all(|c| block_always_diverges(&c.body)),
325
+ _ => false,
326
+ }
327
+ }
328
+
329
+ fn block_always_diverges(block: &ast::Block) -> bool {
330
+ block.stmts.last().map(stmt_always_diverges).unwrap_or(false)
331
+ }
332
+ ```
333
+
334
+ **3c. Add the new value-position helpers**, right after `compile_block` (currently lines 635-640) and before where `stmt_always_diverges` used to be:
335
+
336
+ ```rust
337
+ /// Compiles a case/branch body either as an ordinary statement block (`result_vt: None`)
338
+ /// or, when in value position, via `compile_block_in_value_position` so its own tail
339
+ /// statement propagates a value instead of being dropped.
340
+ fn compile_case_body(
341
+ block: &ast::Block,
342
+ result_vt: Option<ValType>,
343
+ body: &mut Vec<u8>,
344
+ ctx: &LocalCtx,
345
+ state: &mut ModuleState,
346
+ ) -> Result<(), String> {
347
+ match result_vt {
348
+ Some(vt) => compile_block_in_value_position(block, vt, body, ctx, state),
349
+ None => compile_block(block, body, ctx, state),
350
+ }
351
+ }
352
+
353
+ /// Compiles a block whose value must be produced when control reaches its end — every
354
+ /// statement except the last compiles normally; the last is compiled via
355
+ /// `compile_stmt_in_value_position`.
356
+ fn compile_block_in_value_position(
357
+ block: &ast::Block,
358
+ result_vt: ValType,
359
+ body: &mut Vec<u8>,
360
+ ctx: &LocalCtx,
361
+ state: &mut ModuleState,
362
+ ) -> Result<(), String> {
363
+ let (last, rest) = block.stmts.split_last().ok_or_else(|| {
364
+ "codegen: function has a control-flow path that doesn't produce a return value (empty branch)".to_string()
365
+ })?;
366
+ for stmt in rest {
367
+ compile_stmt(stmt, body, ctx, state)?;
368
+ }
369
+ compile_stmt_in_value_position(last, result_vt, body, ctx, state)
370
+ }
371
+
372
+ /// Compiles a single statement in value position: a bare expression is left on the stack
373
+ /// (not dropped); `return`/`todo` compile normally (both are stack-polymorphic in wasm —
374
+ /// control never falls through past them, so no value is needed on this path); `if`/`match`
375
+ /// recurse so every arm/branch resolves the same way. Any other statement kind can't
376
+ /// produce a value, so this returns a clear error instead of ever emitting wasm that
377
+ /// would fail validation.
378
+ fn compile_stmt_in_value_position(
379
+ stmt: &ast::Stmt,
380
+ result_vt: ValType,
381
+ body: &mut Vec<u8>,
382
+ ctx: &LocalCtx,
383
+ state: &mut ModuleState,
384
+ ) -> Result<(), String> {
385
+ match stmt {
386
+ ast::Stmt::Expr(e) => compile_expr(e, body, ctx, state),
387
+ ast::Stmt::Return(_) | ast::Stmt::Todo => compile_stmt(stmt, body, ctx, state),
388
+ ast::Stmt::If(if_) => compile_if(if_, Some(result_vt), body, ctx, state),
389
+ ast::Stmt::Match(m) => compile_match(m, body, ctx, state, Some(result_vt)),
390
+ _ => Err(
391
+ "codegen: function has a control-flow path that doesn't produce a return value".to_string(),
392
+ ),
393
+ }
394
+ }
395
+
396
+ /// Compiles an `if`/`else if`/`else` chain. `result_vt` is `None` for an ordinary statement
397
+ /// (each branch is `BlockType::Empty`, nothing left on the stack) or `Some(vt)` when this
398
+ /// `if` is in value position — every branch must then leave a `vt` value on the stack, which
399
+ /// requires an `else` (a value can't be produced on a path that doesn't exist).
400
+ fn compile_if(
401
+ if_: &ast::If,
402
+ result_vt: Option<ValType>,
403
+ body: &mut Vec<u8>,
404
+ ctx: &LocalCtx,
405
+ state: &mut ModuleState,
406
+ ) -> Result<(), String> {
407
+ if result_vt.is_some() && if_.else_.is_none() {
408
+ return Err(
409
+ "codegen: function has a control-flow path that doesn't produce a return value (if without else)".to_string(),
410
+ );
411
+ }
412
+ let bt = block_type_for(result_vt);
413
+ compile_expr(&if_.condition, body, ctx, state)?;
414
+ Instruction::If(bt).encode(body);
415
+ compile_case_body(&if_.body, result_vt, body, ctx, state)?;
416
+ if !if_.else_ifs.is_empty() || if_.else_.is_some() {
417
+ Instruction::Else.encode(body);
418
+ for ei in &if_.else_ifs {
419
+ compile_expr(&ei.condition, body, ctx, state)?;
420
+ Instruction::If(bt).encode(body);
421
+ compile_case_body(&ei.body, result_vt, body, ctx, state)?;
422
+ Instruction::Else.encode(body);
423
+ }
424
+ if let Some(else_block) = &if_.else_ {
425
+ compile_case_body(else_block, result_vt, body, ctx, state)?;
426
+ }
427
+ for _ in &if_.else_ifs {
428
+ Instruction::End.encode(body);
429
+ }
430
+ }
431
+ Instruction::End.encode(body);
432
+ Ok(())
433
+ }
434
+ ```
435
+
436
+ **3d. Simplify `compile_block_as_fn_body`** (currently lines 664-706) — replace the whole function:
437
+
438
+ ```rust
439
+ /// Compiles a block that is the body of a function. If the function returns a value,
440
+ /// its tail statement is compiled in value position (see `compile_stmt_in_value_position`)
441
+ /// so a bare expression, or an `if`/`match` whose arms resolve to one, propagates that
442
+ /// value instead of being dropped.
443
+ fn compile_block_as_fn_body(
444
+ block: &ast::Block,
445
+ body: &mut Vec<u8>,
446
+ ctx: &LocalCtx,
447
+ state: &mut ModuleState,
448
+ result_vt: Option<ValType>,
449
+ ) -> Result<(), String> {
450
+ match result_vt {
451
+ Some(vt) => compile_block_in_value_position(block, vt, body, ctx, state),
452
+ None => compile_block(block, body, ctx, state),
453
+ }
454
+ }
455
+ ```
456
+
457
+ **3e. Update `compile_fn_body`'s call site** (currently around lines 620 and 627):
458
+
459
+ Replace:
460
+
461
+ ```rust
462
+ let has_return_value = f.returns.as_ref().map(|r| r.name != "Unit").unwrap_or(false);
463
+
464
+ match &f.body {
465
+ ast::FnBody::Expr(e) => {
466
+ compile_expr(e, &mut body, &local_ctx, state)?;
467
+ }
468
+ ast::FnBody::Block(block) => {
469
+ compile_block_as_fn_body(block, &mut body, &local_ctx, state, has_return_value)?;
470
+ }
471
+ }
472
+ ```
473
+
474
+ with:
475
+
476
+ ```rust
477
+ let result_vt = ret_type_to_wasm(f.returns.as_ref());
478
+
479
+ match &f.body {
480
+ ast::FnBody::Expr(e) => {
481
+ compile_expr(e, &mut body, &local_ctx, state)?;
482
+ }
483
+ ast::FnBody::Block(block) => {
484
+ compile_block_as_fn_body(block, &mut body, &local_ctx, state, result_vt)?;
485
+ }
486
+ }
487
+ ```
488
+
489
+ **3f. Replace `compile_stmt`'s inline `If` arm** (currently lines 730-750) with a call to the new `compile_if`:
490
+
491
+ Replace:
492
+
493
+ ```rust
494
+ ast::Stmt::If(if_) => {
495
+ compile_expr(&if_.condition, body, ctx, state)?;
496
+ Instruction::If(BlockType::Empty).encode(body);
497
+ compile_block(&if_.body, body, ctx, state)?;
498
+ if !if_.else_ifs.is_empty() || if_.else_.is_some() {
499
+ Instruction::Else.encode(body);
500
+ for ei in &if_.else_ifs {
501
+ compile_expr(&ei.condition, body, ctx, state)?;
502
+ Instruction::If(BlockType::Empty).encode(body);
503
+ compile_block(&ei.body, body, ctx, state)?;
504
+ Instruction::Else.encode(body);
505
+ }
506
+ if let Some(else_block) = &if_.else_ {
507
+ compile_block(else_block, body, ctx, state)?;
508
+ }
509
+ for _ in &if_.else_ifs {
510
+ Instruction::End.encode(body);
511
+ }
512
+ }
513
+ Instruction::End.encode(body);
514
+ }
515
+ ```
516
+
517
+ with:
518
+
519
+ ```rust
520
+ ast::Stmt::If(if_) => {
521
+ compile_if(if_, None, body, ctx, state)?;
522
+ }
523
+ ```
524
+
525
+ **3g. Update `compile_stmt`'s `Match` arm** (currently `compile_match(m, body, ctx, state)?;`) to pass `None`:
526
+
527
+ ```rust
528
+ ast::Stmt::Match(m) => {
529
+ compile_match(m, body, ctx, state, None)?;
530
+ }
531
+ ```
532
+
533
+ **3h. Update `compile_match`'s signature and body** (currently lines 844-863):
534
+
535
+ Replace:
536
+
537
+ ```rust
538
+ fn compile_match(m: &ast::Match, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut ModuleState) -> Result<(), String> {
539
+ if m.subjects.len() != 1 {
540
+ return Err("codegen: multi-subject match is not yet supported".to_string());
541
+ }
542
+ let subject = &m.subjects[0];
543
+ let subject_ty = infer_local_type(subject, ctx);
544
+ let subject_vt = plum_type_to_valtype(&subject_ty);
545
+
546
+ let key = m as *const ast::Match as usize;
547
+ let slot = *ctx
548
+ .match_scratch_index
549
+ .get(&key)
550
+ .ok_or_else(|| "internal codegen error: missing match scratch slot".to_string())?;
551
+ let scratch_local = ctx.match_scratch_base + slot;
552
+
553
+ compile_expr(subject, body, ctx, state)?;
554
+ Instruction::LocalSet(scratch_local).encode(body);
555
+
556
+ compile_match_arms(&m.cases, subject_vt, scratch_local, body, ctx, state)
557
+ }
558
+ ```
559
+
560
+ with:
561
+
562
+ ```rust
563
+ fn compile_match(
564
+ m: &ast::Match,
565
+ body: &mut Vec<u8>,
566
+ ctx: &LocalCtx,
567
+ state: &mut ModuleState,
568
+ result_vt: Option<ValType>,
569
+ ) -> Result<(), String> {
570
+ if m.subjects.len() != 1 {
571
+ return Err("codegen: multi-subject match is not yet supported".to_string());
572
+ }
573
+ let subject = &m.subjects[0];
574
+ let subject_ty = infer_local_type(subject, ctx);
575
+ let subject_vt = plum_type_to_valtype(&subject_ty);
576
+
577
+ let key = m as *const ast::Match as usize;
578
+ let slot = *ctx
579
+ .match_scratch_index
580
+ .get(&key)
581
+ .ok_or_else(|| "internal codegen error: missing match scratch slot".to_string())?;
582
+ let scratch_local = ctx.match_scratch_base + slot;
583
+
584
+ compile_expr(subject, body, ctx, state)?;
585
+ Instruction::LocalSet(scratch_local).encode(body);
586
+
587
+ compile_match_arms(&m.cases, subject_vt, scratch_local, result_vt, body, ctx, state)
588
+ }
589
+ ```
590
+
591
+ **3i. Update `compile_match_arms`** (currently lines 865-921):
592
+
593
+ Replace the whole function:
594
+
595
+ ```rust
596
+ fn compile_match_arms(
597
+ cases: &[ast::Case],
598
+ subject_vt: ValType,
599
+ scratch_local: u32,
600
+ result_vt: Option<ValType>,
601
+ body: &mut Vec<u8>,
602
+ ctx: &LocalCtx,
603
+ state: &mut ModuleState,
604
+ ) -> Result<(), String> {
605
+ let (case, rest) = match cases.split_first() {
606
+ None => {
607
+ return match result_vt {
608
+ Some(_) => Err(
609
+ "codegen: function has a control-flow path that doesn't produce a return value (non-exhaustive match)".to_string(),
610
+ ),
611
+ None => Ok(()),
612
+ };
613
+ }
614
+ Some(pair) => pair,
615
+ };
616
+ let pat = case.patterns.first().ok_or_else(|| "codegen: match case has no pattern".to_string())?;
617
+ match pat {
618
+ ast::CasePattern::Wildcard => {
619
+ // Any cases after a wildcard are unreachable, matching real match semantics.
620
+ compile_case_body(&case.body, result_vt, body, ctx, state)
621
+ }
622
+ ast::CasePattern::Name(n) => {
623
+ let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
624
+ && ctx.enum_variants.contains_key(n);
625
+ if is_variant {
626
+ compile_variant_eq_arm(n, subject_vt, scratch_local, result_vt, case, rest, body, ctx, state)
627
+ } else {
628
+ let idx = ctx
629
+ .locals
630
+ .get(n)
631
+ .copied()
632
+ .ok_or_else(|| format!("internal codegen error: missing binding local '{}'", n))?;
633
+ Instruction::LocalGet(scratch_local).encode(body);
634
+ Instruction::LocalSet(idx).encode(body);
635
+ ctx.type_env.borrow_mut().insert(n.clone(), TypeScheme::mono(plum_type_from_valtype_hint(subject_vt)));
636
+ compile_case_body(&case.body, result_vt, body, ctx, state)
637
+ // A binding arm always matches — any following cases are unreachable.
638
+ }
639
+ }
640
+ ast::CasePattern::Int(n) => {
641
+ if subject_vt != ValType::I64 {
642
+ return Err("codegen: integer match pattern against a non-Int subject".to_string());
643
+ }
644
+ Instruction::LocalGet(scratch_local).encode(body);
645
+ Instruction::I64Const(*n).encode(body);
646
+ Instruction::I64Eq.encode(body);
647
+ Instruction::If(block_type_for(result_vt)).encode(body);
648
+ compile_case_body(&case.body, result_vt, body, ctx, state)?;
649
+ Instruction::Else.encode(body);
650
+ compile_match_arms(rest, subject_vt, scratch_local, result_vt, body, ctx, state)?;
651
+ Instruction::End.encode(body);
652
+ Ok(())
653
+ }
654
+ ast::CasePattern::String(_) => Err("codegen: string match patterns are not yet supported".to_string()),
655
+ ast::CasePattern::Float(_) => Err("codegen: float match patterns are not yet supported".to_string()),
656
+ ast::CasePattern::Class { name, fields } => {
657
+ compile_variant_constructor_arm(name, fields, subject_vt, scratch_local, result_vt, case, rest, body, ctx, state)
658
+ }
659
+ }
660
+ }
661
+ ```
662
+
663
+ **3j. Update `compile_variant_eq_arm`** (currently lines 923-951):
664
+
665
+ Replace the whole function:
666
+
667
+ ```rust
668
+ #[allow(clippy::too_many_arguments)]
669
+ fn compile_variant_eq_arm(
670
+ name: &str,
671
+ subject_vt: ValType,
672
+ scratch_local: u32,
673
+ result_vt: Option<ValType>,
674
+ case: &ast::Case,
675
+ rest: &[ast::Case],
676
+ body: &mut Vec<u8>,
677
+ ctx: &LocalCtx,
678
+ state: &mut ModuleState,
679
+ ) -> Result<(), String> {
680
+ let info = ctx
681
+ .enum_variants
682
+ .get(name)
683
+ .ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
684
+ if subject_vt != ValType::I32 {
685
+ return Err(format!("codegen: enum tag pattern '{}' against a non-enum subject", name));
686
+ }
687
+ let tag = info.tag;
688
+ Instruction::LocalGet(scratch_local).encode(body);
689
+ Instruction::I32Const(tag).encode(body);
690
+ Instruction::I32Eq.encode(body);
691
+ Instruction::If(block_type_for(result_vt)).encode(body);
692
+ compile_case_body(&case.body, result_vt, body, ctx, state)?;
693
+ Instruction::Else.encode(body);
694
+ compile_match_arms(rest, subject_vt, scratch_local, result_vt, body, ctx, state)?;
695
+ Instruction::End.encode(body);
696
+ Ok(())
697
+ }
698
+ ```
699
+
700
+ **3k. Update `compile_variant_constructor_arm`** (currently lines 953-1026):
701
+
702
+ Replace the whole function:
703
+
704
+ ```rust
705
+ #[allow(clippy::too_many_arguments)]
706
+ fn compile_variant_constructor_arm(
707
+ name: &str,
708
+ fields: &[ast::CasePattern],
709
+ subject_vt: ValType,
710
+ scratch_local: u32,
711
+ result_vt: Option<ValType>,
712
+ case: &ast::Case,
713
+ rest: &[ast::Case],
714
+ body: &mut Vec<u8>,
715
+ ctx: &LocalCtx,
716
+ state: &mut ModuleState,
717
+ ) -> Result<(), String> {
718
+ let info = ctx
719
+ .enum_variants
720
+ .get(name)
721
+ .ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
722
+ if subject_vt != ValType::I32 {
723
+ return Err(format!("codegen: constructor pattern '{}' against a non-enum subject", name));
724
+ }
725
+ if fields.len() != info.field_types.len() {
726
+ return Err(format!(
727
+ "codegen: constructor pattern '{}' expects {} field(s), got {}",
728
+ name, info.field_types.len(), fields.len()
729
+ ));
730
+ }
731
+ let tag = info.tag;
732
+ let field_types = info.field_types.clone();
733
+
734
+ // A constructor pattern can only match if the runtime subject is actually
735
+ // a heap pointer (payload variants are always >= HEAP_BASE); a
736
+ // payload-free sibling variant is a small int tag, and loading i32 from
737
+ // that address would read unrelated/zeroed memory instead of a real tag.
738
+ // Guard with a range check before doing the I32Load.
739
+ Instruction::LocalGet(scratch_local).encode(body);
740
+ Instruction::I32Const(HEAP_BASE as i32).encode(body);
741
+ Instruction::I32GeU.encode(body);
742
+ Instruction::If(BlockType::Result(ValType::I32)).encode(body);
743
+ Instruction::LocalGet(scratch_local).encode(body);
744
+ Instruction::I32Load(MemArg { offset: 0, align: 2, memory_index: 0 }).encode(body);
745
+ Instruction::I32Const(tag).encode(body);
746
+ Instruction::I32Eq.encode(body);
747
+ Instruction::Else.encode(body);
748
+ Instruction::I32Const(0).encode(body);
749
+ Instruction::End.encode(body);
750
+ Instruction::If(block_type_for(result_vt)).encode(body);
751
+ for (i, (pat, field_ty)) in fields.iter().zip(field_types.iter()).enumerate() {
752
+ let bind_name = match pat {
753
+ ast::CasePattern::Name(n) => Some(n.as_str()),
754
+ ast::CasePattern::Wildcard => None,
755
+ _ => return Err("codegen: only bare bindings or '_' are supported inside a constructor pattern".to_string()),
756
+ };
757
+ if let Some(n) = bind_name {
758
+ let idx = ctx
759
+ .locals
760
+ .get(n)
761
+ .copied()
762
+ .ok_or_else(|| format!("internal codegen error: missing binding local '{}'", n))?;
763
+ Instruction::LocalGet(scratch_local).encode(body);
764
+ let offset = ((i + 1) as u64) * 8;
765
+ match plum_type_to_valtype(field_ty) {
766
+ ValType::I64 => Instruction::I64Load(MemArg { offset, align: 3, memory_index: 0 }),
767
+ ValType::F64 => Instruction::F64Load(MemArg { offset, align: 3, memory_index: 0 }),
768
+ _ => Instruction::I32Load(MemArg { offset, align: 2, memory_index: 0 }),
769
+ }.encode(body);
770
+ Instruction::LocalSet(idx).encode(body);
771
+ ctx.type_env.borrow_mut().insert(n.to_string(), TypeScheme::mono(field_ty.clone()));
772
+ }
773
+ }
774
+ compile_case_body(&case.body, result_vt, body, ctx, state)?;
775
+ Instruction::Else.encode(body);
776
+ compile_match_arms(rest, subject_vt, scratch_local, result_vt, body, ctx, state)?;
777
+ Instruction::End.encode(body);
778
+ Ok(())
779
+ }
780
+ ```
781
+
782
+ - [ ] **Step 4: Run the codegen test suite**
783
+
784
+ Run: `cargo test -p plum-wasm-codegen --test codegen_tests`
785
+ Expected: every test passes, including all 8 new ones from Step 1 and every pre-existing test in the file (in particular, every test that already uses explicit `return` in match/if arms must still pass unchanged — `result_vt: None` for ordinary statement position and value-position `return` handling are both untouched by this refactor).
786
+
787
+ - [ ] **Step 5: Run the full workspace and tree-sitter suites**
788
+
789
+ ```bash
790
+ cargo test --workspace
791
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
792
+ ```
793
+
794
+ Expected: fully green.
795
+
796
+ - [ ] **Step 6: Commit**
797
+
798
+ ```bash
799
+ git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs
800
+ git commit -m "fix(plum-wasm-codegen): propagate a value through tail-position match/if without explicit return"
801
+ ```
802
+
803
+ ---
804
+
805
+ ### Task 3: Restore `examples/match.plum` to idiomatic bare-tail style, update docs
806
+
807
+ **Files:**
808
+ - Modify: `examples/match.plum`
809
+ - Modify: `README.md`
810
+ - Test: `plum-wasm-codegen/tests/examples_test.rs` (no changes expected, just re-run)
811
+
812
+ **Interfaces:**
813
+ - Consumes: Task 2's fix (a function whose tail statement is `match`/`if` with bare-expression arms now compiles and runs correctly).
814
+ - Produces: nothing further downstream — this is the final integration/documentation task.
815
+
816
+ - [ ] **Step 1: Revert `examples/match.plum`'s five functions to their natural bare-tail-expression form**
817
+
818
+ Replace the whole file `examples/match.plum` with:
819
+
820
+ ```plum
821
+ enum Color =
822
+ | Red
823
+ | Green
824
+ | Blue
825
+
826
+ enum Option =
827
+ | Some(Int)
828
+ | None
829
+
830
+ describeNumber(n: Int) -> Str =
831
+ match n
832
+ 0 =>
833
+ "zero"
834
+ 1 =>
835
+ "one"
836
+ _ =>
837
+ "many"
838
+
839
+ describeBool(b: Bool) -> Int =
840
+ match b
841
+ True =>
842
+ 1
843
+ False =>
844
+ 0
845
+
846
+ bindExample(n: Int) -> Int =
847
+ match n
848
+ x =>
849
+ x
850
+
851
+ describeColor(c: Color) -> Str =
852
+ match c
853
+ Red =>
854
+ "red"
855
+ Green =>
856
+ "green"
857
+ Blue =>
858
+ "blue"
859
+
860
+ describeOption(opt: Option) -> Int =
861
+ match opt
862
+ Some(v) =>
863
+ v
864
+ None =>
865
+ 0
866
+
867
+ main() -> Int =
868
+ describeOption(Some(5))
869
+ ```
870
+
871
+ (This is identical to the file's content before the `return`-adding workaround, restoring the originally-intended idiomatic style now that Task 2 makes it actually compile.)
872
+
873
+ - [ ] **Step 2: Run the examples test suite**
874
+
875
+ Run: `cargo test -p plum-wasm-codegen --test examples_test`
876
+ Expected: `match_example_compiles_and_runs_correctly` (already asserting `describeOption(Some(5)) == 5`) still passes — now genuinely exercising bare-tail-expression match arms instead of explicit `return`.
877
+
878
+ - [ ] **Step 3: Remove the now-fixed bullet from README's Known Gaps**
879
+
880
+ In `README.md`, remove this line (currently line 327):
881
+
882
+ ```markdown
883
+ - a function body's final statement being a `match`/`if` whose arms don't all use explicit `return` — the arm values are silently dropped instead of returned, producing invalid wasm rather than a clear error (workaround: always `return` from match/if arms in tail position)
884
+ ```
885
+
886
+ so the Known Gaps list reads (only the remaining, still-true items):
887
+
888
+ ```markdown
889
+ - string interpolation (plain, non-interpolated string literals do compile)
890
+ - multi-subject `match` (`match a, b`)
891
+ - user-defined generics (they type-check but aren't monomorphized) — this also blocks `libs/std`'s actual `Option`/`Result`/`List`/`Map`, which are declared generically
892
+ - nested constructor patterns inside `match` (`Some(Some(v))`) — a constructor pattern's own sub-patterns must be a bare binding or `_`
893
+ ```
894
+
895
+ - [ ] **Step 4: Run the full workspace and tree-sitter suites one final time**
896
+
897
+ ```bash
898
+ cargo test --workspace
899
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
900
+ ```
901
+
902
+ Expected: fully green, zero known failures.
903
+
904
+ - [ ] **Step 5: Commit**
905
+
906
+ ```bash
907
+ git add examples/match.plum README.md
908
+ git commit -m "docs+test: restore examples/match.plum to idiomatic bare-tail style; tail-position gap fixed"
909
+ ```