plum

#treesitter#compiler#wasm

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"
```