plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/plans/2026-07-20-tail-position-and-grammar-gaps.md
# Tail-Position Value Propagation and Grammar Gap Fixes Implementation Plan
> **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.
**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.
**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.
**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.
## Global Constraints
- 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.
- 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.
- 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`.
- 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.
---
### Task 1: Grammar — accept a full expression as a body's trailing statement
**Files:**
- Modify: `tooling/tree-sitter-plum/grammar.js:166-179` (`_statement` rule)
- Test: `tooling/tree-sitter-plum/test/corpus/` (new cases)
**Interfaces:**
- Consumes: nothing from other tasks.
- 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).
- [ ] **Step 1: Make the grammar change**
In `tooling/tree-sitter-plum/grammar.js`, change:
```js
_statement: ($) =>
choice(
$.assign,
$.break,
$.continue,
$.assert,
$.for,
$.while,
$.if,
$.match,
$.return,
$.todo,
$.primary_expression
),
```
to:
```js
_statement: ($) =>
choice(
$.assign,
$.break,
$.continue,
$.assert,
$.for,
$.while,
$.if,
$.match,
$.return,
$.todo,
$.expression
),
```
- [ ] **Step 2: Regenerate and run the existing corpus suite**
```bash
cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate && npx --yes tree-sitter-cli test
```
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).
- [ ] **Step 3: Add corpus cases for the newly-parseable statement forms**
Append to `tooling/tree-sitter-plum/test/corpus/function.txt` three new cases (input half only — the next step fills in the expected tree):
```
================================================================================
function - bare comparison as body's trailing statement
================================================================================
isNone(o: Option) -> Bool =
o == None
--------------------------------------------------------------------------------
================================================================================
function - bare boolean-operator as body's trailing statement
================================================================================
bothTrue(a: Bool, b: Bool) -> Bool =
a && b
--------------------------------------------------------------------------------
================================================================================
function - bare ternary as body's trailing statement
================================================================================
pick(cond: Bool, a: Int, b: Int) -> Int =
cond ? a : b
--------------------------------------------------------------------------------
```
- [ ] **Step 4: Generate the expected trees and verify them**
```bash
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"
```
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.
- [ ] **Step 5: Run the full corpus suite**
```bash
cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
```
Expected: all cases pass, old and new.
- [ ] **Step 6: Run the full Rust workspace suite**
```bash
cargo test --workspace
```
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).
- [ ] **Step 7: Commit**
```bash
git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/test/corpus/function.txt
git add tooling/tree-sitter-plum/src # generated parser.c etc, only if tracked — check `git status` first
git commit -m "fix(tree-sitter-plum): allow a full expression as a body's trailing statement"
```
---
### Task 2: Codegen — recursive value-position propagation for tail `if`/`match`
**Files:**
- 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)
- Test: `plum-wasm-codegen/tests/codegen_tests.rs`
**Interfaces:**
- 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).
- 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`.
- [ ] **Step 1: Write failing tests**
Append to `plum-wasm-codegen/tests/codegen_tests.rs`:
```rust
#[test]
fn tail_match_without_return_runs_correctly() {
let src = "\
bindExample(n: Int) -> Int =
match n
x =>
x
main() -> Int =
bindExample(5)
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 5);
}
#[test]
fn tail_if_without_return_runs_correctly() {
let src = "\
abs(n: Int) -> Int =
if n < 0
-n
else
n
main() -> Int =
abs(-7)
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 7);
}
#[test]
fn tail_if_nested_inside_match_arm_without_return_runs_correctly() {
let src = "\
classify(n: Int) -> Int =
match n
0 =>
1
x =>
if x < 0
-1
else
2
main() -> Int =
classify(-5)
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), -1);
}
#[test]
fn tail_match_mixing_return_and_bare_expr_arms_runs_correctly() {
let src = "\
describe(n: Int) -> Int =
match n
0 =>
return 100
x =>
x * 2
main() -> Int =
describe(21)
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 42);
}
#[test]
fn tail_enum_match_without_return_runs_correctly() {
let src = "\
enum Option =
| Some(Int)
| None
unwrapOr(o: Option, default: Int) -> Int =
match o
Some(v) =>
v
None =>
default
main() -> Int =
unwrapOr(Some(9), 0)
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 9);
}
#[test]
fn tail_if_without_else_is_a_clear_error() {
let src = "\
bad(n: Int) -> Int =
if n < 0
return 1
";
let source = parse(src);
let err = compile_source(&source).expect_err("if without else in value position must be a clear error, not invalid wasm");
assert!(err.contains("doesn't produce a return value"), "got: {}", err);
}
#[test]
fn tail_match_non_exhaustive_is_a_clear_error() {
let src = "\
bad(n: Int) -> Int =
match n
0 =>
1
";
let source = parse(src);
let err = compile_source(&source).expect_err("non-exhaustive match in value position must be a clear error, not invalid wasm");
assert!(err.contains("doesn't produce a return value"), "got: {}", err);
}
#[test]
fn tail_match_arm_ending_in_non_value_statement_is_a_clear_error() {
let src = "\
bad(n: Int) -> Int =
match n
x =>
y = x
";
let source = parse(src);
let err = compile_source(&source).expect_err("a match arm ending in a non-value statement must be a clear error, not invalid wasm");
assert!(err.contains("doesn't produce a return value"), "got: {}", err);
}
```
- [ ] **Step 2: Run to see them fail**
Run: `cargo test -p plum-wasm-codegen --test codegen_tests tail_`
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`.
- [ ] **Step 3: Read the current file to confirm line numbers, then make the edits**
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.
**3a. Add `block_type_for` near the other small helpers** (e.g. right after `plum_type_to_valtype`, around line 249-256):
```rust
fn block_type_for(result_vt: Option<ValType>) -> BlockType {
result_vt.map(BlockType::Result).unwrap_or(BlockType::Empty)
}
```
**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:
```rust
/// True if control can never fall through past this statement — every reachable path
/// ends in a `return`. Used to decide whether a tail-position If/Match needs a
/// trailing `unreachable` to satisfy wasm's per-block (not whole-function) validation
/// when the function declares a non-Unit return type.
fn stmt_always_diverges(stmt: &ast::Stmt) -> bool {
match stmt {
ast::Stmt::Return(_) | ast::Stmt::Todo => true,
ast::Stmt::If(if_) => {
if_.else_.is_some()
&& block_always_diverges(&if_.body)
&& if_.else_ifs.iter().all(|ei| block_always_diverges(&ei.body))
&& if_.else_.as_ref().is_some_and(block_always_diverges)
}
ast::Stmt::Match(m) => !m.cases.is_empty() && m.cases.iter().all(|c| block_always_diverges(&c.body)),
_ => false,
}
}
fn block_always_diverges(block: &ast::Block) -> bool {
block.stmts.last().map(stmt_always_diverges).unwrap_or(false)
}
```
**3c. Add the new value-position helpers**, right after `compile_block` (currently lines 635-640) and before where `stmt_always_diverges` used to be:
```rust
/// Compiles a case/branch body either as an ordinary statement block (`result_vt: None`)
/// or, when in value position, via `compile_block_in_value_position` so its own tail
/// statement propagates a value instead of being dropped.
fn compile_case_body(
block: &ast::Block,
result_vt: Option<ValType>,
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
match result_vt {
Some(vt) => compile_block_in_value_position(block, vt, body, ctx, state),
None => compile_block(block, body, ctx, state),
}
}
/// Compiles a block whose value must be produced when control reaches its end — every
/// statement except the last compiles normally; the last is compiled via
/// `compile_stmt_in_value_position`.
fn compile_block_in_value_position(
block: &ast::Block,
result_vt: ValType,
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
let (last, rest) = block.stmts.split_last().ok_or_else(|| {
"codegen: function has a control-flow path that doesn't produce a return value (empty branch)".to_string()
})?;
for stmt in rest {
compile_stmt(stmt, body, ctx, state)?;
}
compile_stmt_in_value_position(last, result_vt, body, ctx, state)
}
/// Compiles a single statement in value position: a bare expression is left on the stack
/// (not dropped); `return`/`todo` compile normally (both are stack-polymorphic in wasm —
/// control never falls through past them, so no value is needed on this path); `if`/`match`
/// recurse so every arm/branch resolves the same way. Any other statement kind can't
/// produce a value, so this returns a clear error instead of ever emitting wasm that
/// would fail validation.
fn compile_stmt_in_value_position(
stmt: &ast::Stmt,
result_vt: ValType,
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
match stmt {
ast::Stmt::Expr(e) => compile_expr(e, body, ctx, state),
ast::Stmt::Return(_) | ast::Stmt::Todo => compile_stmt(stmt, body, ctx, state),
ast::Stmt::If(if_) => compile_if(if_, Some(result_vt), body, ctx, state),
ast::Stmt::Match(m) => compile_match(m, body, ctx, state, Some(result_vt)),
_ => Err(
"codegen: function has a control-flow path that doesn't produce a return value".to_string(),
),
}
}
/// Compiles an `if`/`else if`/`else` chain. `result_vt` is `None` for an ordinary statement
/// (each branch is `BlockType::Empty`, nothing left on the stack) or `Some(vt)` when this
/// `if` is in value position — every branch must then leave a `vt` value on the stack, which
/// requires an `else` (a value can't be produced on a path that doesn't exist).
fn compile_if(
if_: &ast::If,
result_vt: Option<ValType>,
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
if result_vt.is_some() && if_.else_.is_none() {
return Err(
"codegen: function has a control-flow path that doesn't produce a return value (if without else)".to_string(),
);
}
let bt = block_type_for(result_vt);
compile_expr(&if_.condition, body, ctx, state)?;
Instruction::If(bt).encode(body);
compile_case_body(&if_.body, result_vt, body, ctx, state)?;
if !if_.else_ifs.is_empty() || if_.else_.is_some() {
Instruction::Else.encode(body);
for ei in &if_.else_ifs {
compile_expr(&ei.condition, body, ctx, state)?;
Instruction::If(bt).encode(body);
compile_case_body(&ei.body, result_vt, body, ctx, state)?;
Instruction::Else.encode(body);
}
if let Some(else_block) = &if_.else_ {
compile_case_body(else_block, result_vt, body, ctx, state)?;
}
for _ in &if_.else_ifs {
Instruction::End.encode(body);
}
}
Instruction::End.encode(body);
Ok(())
}
```
**3d. Simplify `compile_block_as_fn_body`** (currently lines 664-706) — replace the whole function:
```rust
/// Compiles a block that is the body of a function. If the function returns a value,
/// its tail statement is compiled in value position (see `compile_stmt_in_value_position`)
/// so a bare expression, or an `if`/`match` whose arms resolve to one, propagates that
/// value instead of being dropped.
fn compile_block_as_fn_body(
block: &ast::Block,
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
result_vt: Option<ValType>,
) -> Result<(), String> {
match result_vt {
Some(vt) => compile_block_in_value_position(block, vt, body, ctx, state),
None => compile_block(block, body, ctx, state),
}
}
```
**3e. Update `compile_fn_body`'s call site** (currently around lines 620 and 627):
Replace:
```rust
let has_return_value = f.returns.as_ref().map(|r| r.name != "Unit").unwrap_or(false);
match &f.body {
ast::FnBody::Expr(e) => {
compile_expr(e, &mut body, &local_ctx, state)?;
}
ast::FnBody::Block(block) => {
compile_block_as_fn_body(block, &mut body, &local_ctx, state, has_return_value)?;
}
}
```
with:
```rust
let result_vt = ret_type_to_wasm(f.returns.as_ref());
match &f.body {
ast::FnBody::Expr(e) => {
compile_expr(e, &mut body, &local_ctx, state)?;
}
ast::FnBody::Block(block) => {
compile_block_as_fn_body(block, &mut body, &local_ctx, state, result_vt)?;
}
}
```
**3f. Replace `compile_stmt`'s inline `If` arm** (currently lines 730-750) with a call to the new `compile_if`:
Replace:
```rust
ast::Stmt::If(if_) => {
compile_expr(&if_.condition, body, ctx, state)?;
Instruction::If(BlockType::Empty).encode(body);
compile_block(&if_.body, body, ctx, state)?;
if !if_.else_ifs.is_empty() || if_.else_.is_some() {
Instruction::Else.encode(body);
for ei in &if_.else_ifs {
compile_expr(&ei.condition, body, ctx, state)?;
Instruction::If(BlockType::Empty).encode(body);
compile_block(&ei.body, body, ctx, state)?;
Instruction::Else.encode(body);
}
if let Some(else_block) = &if_.else_ {
compile_block(else_block, body, ctx, state)?;
}
for _ in &if_.else_ifs {
Instruction::End.encode(body);
}
}
Instruction::End.encode(body);
}
```
with:
```rust
ast::Stmt::If(if_) => {
compile_if(if_, None, body, ctx, state)?;
}
```
**3g. Update `compile_stmt`'s `Match` arm** (currently `compile_match(m, body, ctx, state)?;`) to pass `None`:
```rust
ast::Stmt::Match(m) => {
compile_match(m, body, ctx, state, None)?;
}
```
**3h. Update `compile_match`'s signature and body** (currently lines 844-863):
Replace:
```rust
fn compile_match(m: &ast::Match, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut ModuleState) -> Result<(), String> {
if m.subjects.len() != 1 {
return Err("codegen: multi-subject match is not yet supported".to_string());
}
let subject = &m.subjects[0];
let subject_ty = infer_local_type(subject, ctx);
let subject_vt = plum_type_to_valtype(&subject_ty);
let key = m as *const ast::Match as usize;
let slot = *ctx
.match_scratch_index
.get(&key)
.ok_or_else(|| "internal codegen error: missing match scratch slot".to_string())?;
let scratch_local = ctx.match_scratch_base + slot;
compile_expr(subject, body, ctx, state)?;
Instruction::LocalSet(scratch_local).encode(body);
compile_match_arms(&m.cases, subject_vt, scratch_local, body, ctx, state)
}
```
with:
```rust
fn compile_match(
m: &ast::Match,
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
result_vt: Option<ValType>,
) -> Result<(), String> {
if m.subjects.len() != 1 {
return Err("codegen: multi-subject match is not yet supported".to_string());
}
let subject = &m.subjects[0];
let subject_ty = infer_local_type(subject, ctx);
let subject_vt = plum_type_to_valtype(&subject_ty);
let key = m as *const ast::Match as usize;
let slot = *ctx
.match_scratch_index
.get(&key)
.ok_or_else(|| "internal codegen error: missing match scratch slot".to_string())?;
let scratch_local = ctx.match_scratch_base + slot;
compile_expr(subject, body, ctx, state)?;
Instruction::LocalSet(scratch_local).encode(body);
compile_match_arms(&m.cases, subject_vt, scratch_local, result_vt, body, ctx, state)
}
```
**3i. Update `compile_match_arms`** (currently lines 865-921):
Replace the whole function:
```rust
fn compile_match_arms(
cases: &[ast::Case],
subject_vt: ValType,
scratch_local: u32,
result_vt: Option<ValType>,
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
let (case, rest) = match cases.split_first() {
None => {
return match result_vt {
Some(_) => Err(
"codegen: function has a control-flow path that doesn't produce a return value (non-exhaustive match)".to_string(),
),
None => Ok(()),
};
}
Some(pair) => pair,
};
let pat = case.patterns.first().ok_or_else(|| "codegen: match case has no pattern".to_string())?;
match pat {
ast::CasePattern::Wildcard => {
// Any cases after a wildcard are unreachable, matching real match semantics.
compile_case_body(&case.body, result_vt, body, ctx, state)
}
ast::CasePattern::Name(n) => {
let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
&& ctx.enum_variants.contains_key(n);
if is_variant {
compile_variant_eq_arm(n, subject_vt, scratch_local, result_vt, case, rest, body, ctx, state)
} else {
let idx = ctx
.locals
.get(n)
.copied()
.ok_or_else(|| format!("internal codegen error: missing binding local '{}'", n))?;
Instruction::LocalGet(scratch_local).encode(body);
Instruction::LocalSet(idx).encode(body);
ctx.type_env.borrow_mut().insert(n.clone(), TypeScheme::mono(plum_type_from_valtype_hint(subject_vt)));
compile_case_body(&case.body, result_vt, body, ctx, state)
// A binding arm always matches — any following cases are unreachable.
}
}
ast::CasePattern::Int(n) => {
if subject_vt != ValType::I64 {
return Err("codegen: integer match pattern against a non-Int subject".to_string());
}
Instruction::LocalGet(scratch_local).encode(body);
Instruction::I64Const(*n).encode(body);
Instruction::I64Eq.encode(body);
Instruction::If(block_type_for(result_vt)).encode(body);
compile_case_body(&case.body, result_vt, body, ctx, state)?;
Instruction::Else.encode(body);
compile_match_arms(rest, subject_vt, scratch_local, result_vt, body, ctx, state)?;
Instruction::End.encode(body);
Ok(())
}
ast::CasePattern::String(_) => Err("codegen: string match patterns are not yet supported".to_string()),
ast::CasePattern::Float(_) => Err("codegen: float match patterns are not yet supported".to_string()),
ast::CasePattern::Class { name, fields } => {
compile_variant_constructor_arm(name, fields, subject_vt, scratch_local, result_vt, case, rest, body, ctx, state)
}
}
}
```
**3j. Update `compile_variant_eq_arm`** (currently lines 923-951):
Replace the whole function:
```rust
#[allow(clippy::too_many_arguments)]
fn compile_variant_eq_arm(
name: &str,
subject_vt: ValType,
scratch_local: u32,
result_vt: Option<ValType>,
case: &ast::Case,
rest: &[ast::Case],
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
let info = ctx
.enum_variants
.get(name)
.ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
if subject_vt != ValType::I32 {
return Err(format!("codegen: enum tag pattern '{}' against a non-enum subject", name));
}
let tag = info.tag;
Instruction::LocalGet(scratch_local).encode(body);
Instruction::I32Const(tag).encode(body);
Instruction::I32Eq.encode(body);
Instruction::If(block_type_for(result_vt)).encode(body);
compile_case_body(&case.body, result_vt, body, ctx, state)?;
Instruction::Else.encode(body);
compile_match_arms(rest, subject_vt, scratch_local, result_vt, body, ctx, state)?;
Instruction::End.encode(body);
Ok(())
}
```
**3k. Update `compile_variant_constructor_arm`** (currently lines 953-1026):
Replace the whole function:
```rust
#[allow(clippy::too_many_arguments)]
fn compile_variant_constructor_arm(
name: &str,
fields: &[ast::CasePattern],
subject_vt: ValType,
scratch_local: u32,
result_vt: Option<ValType>,
case: &ast::Case,
rest: &[ast::Case],
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
let info = ctx
.enum_variants
.get(name)
.ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
if subject_vt != ValType::I32 {
return Err(format!("codegen: constructor pattern '{}' against a non-enum subject", name));
}
if fields.len() != info.field_types.len() {
return Err(format!(
"codegen: constructor pattern '{}' expects {} field(s), got {}",
name, info.field_types.len(), fields.len()
));
}
let tag = info.tag;
let field_types = info.field_types.clone();
// A constructor pattern can only match if the runtime subject is actually
// a heap pointer (payload variants are always >= HEAP_BASE); a
// payload-free sibling variant is a small int tag, and loading i32 from
// that address would read unrelated/zeroed memory instead of a real tag.
// Guard with a range check before doing the I32Load.
Instruction::LocalGet(scratch_local).encode(body);
Instruction::I32Const(HEAP_BASE as i32).encode(body);
Instruction::I32GeU.encode(body);
Instruction::If(BlockType::Result(ValType::I32)).encode(body);
Instruction::LocalGet(scratch_local).encode(body);
Instruction::I32Load(MemArg { offset: 0, align: 2, memory_index: 0 }).encode(body);
Instruction::I32Const(tag).encode(body);
Instruction::I32Eq.encode(body);
Instruction::Else.encode(body);
Instruction::I32Const(0).encode(body);
Instruction::End.encode(body);
Instruction::If(block_type_for(result_vt)).encode(body);
for (i, (pat, field_ty)) in fields.iter().zip(field_types.iter()).enumerate() {
let bind_name = match pat {
ast::CasePattern::Name(n) => Some(n.as_str()),
ast::CasePattern::Wildcard => None,
_ => return Err("codegen: only bare bindings or '_' are supported inside a constructor pattern".to_string()),
};
if let Some(n) = bind_name {
let idx = ctx
.locals
.get(n)
.copied()
.ok_or_else(|| format!("internal codegen error: missing binding local '{}'", n))?;
Instruction::LocalGet(scratch_local).encode(body);
let offset = ((i + 1) as u64) * 8;
match plum_type_to_valtype(field_ty) {
ValType::I64 => Instruction::I64Load(MemArg { offset, align: 3, memory_index: 0 }),
ValType::F64 => Instruction::F64Load(MemArg { offset, align: 3, memory_index: 0 }),
_ => Instruction::I32Load(MemArg { offset, align: 2, memory_index: 0 }),
}.encode(body);
Instruction::LocalSet(idx).encode(body);
ctx.type_env.borrow_mut().insert(n.to_string(), TypeScheme::mono(field_ty.clone()));
}
}
compile_case_body(&case.body, result_vt, body, ctx, state)?;
Instruction::Else.encode(body);
compile_match_arms(rest, subject_vt, scratch_local, result_vt, body, ctx, state)?;
Instruction::End.encode(body);
Ok(())
}
```
- [ ] **Step 4: Run the codegen test suite**
Run: `cargo test -p plum-wasm-codegen --test codegen_tests`
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).
- [ ] **Step 5: Run the full workspace and tree-sitter suites**
```bash
cargo test --workspace
cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
```
Expected: fully green.
- [ ] **Step 6: Commit**
```bash
git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs
git commit -m "fix(plum-wasm-codegen): propagate a value through tail-position match/if without explicit return"
```
---
### Task 3: Restore `examples/match.plum` to idiomatic bare-tail style, update docs
**Files:**
- Modify: `examples/match.plum`
- Modify: `README.md`
- Test: `plum-wasm-codegen/tests/examples_test.rs` (no changes expected, just re-run)
**Interfaces:**
- Consumes: Task 2's fix (a function whose tail statement is `match`/`if` with bare-expression arms now compiles and runs correctly).
- Produces: nothing further downstream — this is the final integration/documentation task.
- [ ] **Step 1: Revert `examples/match.plum`'s five functions to their natural bare-tail-expression form**
Replace the whole file `examples/match.plum` with:
```plum
enum Color =
| Red
| Green
| Blue
enum Option =
| Some(Int)
| None
describeNumber(n: Int) -> Str =
match n
0 =>
"zero"
1 =>
"one"
_ =>
"many"
describeBool(b: Bool) -> Int =
match b
True =>
1
False =>
0
bindExample(n: Int) -> Int =
match n
x =>
x
describeColor(c: Color) -> Str =
match c
Red =>
"red"
Green =>
"green"
Blue =>
"blue"
describeOption(opt: Option) -> Int =
match opt
Some(v) =>
v
None =>
0
main() -> Int =
describeOption(Some(5))
```
(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.)
- [ ] **Step 2: Run the examples test suite**
Run: `cargo test -p plum-wasm-codegen --test examples_test`
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`.
- [ ] **Step 3: Remove the now-fixed bullet from README's Known Gaps**
In `README.md`, remove this line (currently line 327):
```markdown
- 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)
```
so the Known Gaps list reads (only the remaining, still-true items):
```markdown
- string interpolation (plain, non-interpolated string literals do compile)
- multi-subject `match` (`match a, b`)
- 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
- nested constructor patterns inside `match` (`Some(Some(v))`) — a constructor pattern's own sub-patterns must be a bare binding or `_`
```
- [ ] **Step 4: Run the full workspace and tree-sitter suites one final time**
```bash
cargo test --workspace
cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
```
Expected: fully green, zero known failures.
- [ ] **Step 5: Commit**
```bash
git add examples/match.plum README.md
git commit -m "docs+test: restore examples/match.plum to idiomatic bare-tail style; tail-position gap fixed"
```