plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-20-tail-position-and-grammar-gaps-design.md
| 1ccf9bd | 1 | # Fix two pre-existing gaps: grammar trailing-statement limitation, codegen tail-position value drop |
| 1ccf9bd | 2 | |
| 1ccf9bd | 3 | ## Problem |
| 1ccf9bd | 4 | |
| 1ccf9bd | 5 | Two real, pre-existing defects were discovered (and worked around, not fixed) while implementing |
| 1ccf9bd | 6 | general enum support: |
| 1ccf9bd | 7 | |
| 1ccf9bd | 8 | 1. **Grammar**: a multi-line indented function/method body's `_statement` rule |
| 1ccf9bd | 9 | (`tooling/tree-sitter-plum/grammar.js:166-179`) only accepts `$.primary_expression` as one of |
| 1ccf9bd | 10 | its alternatives, not the full `$.expression`. A bare comparison (`a == b`), boolean operator |
| 1ccf9bd | 11 | (`a && b`), or ternary (`a ? b : c`) used as a statement — most commonly the final line of an |
| 1ccf9bd | 12 | indented body — fails to parse: the operator is dropped and the parser emits an `ERROR` node, |
| 1ccf9bd | 13 | silently splitting what should be one expression into two separate statements. |
| 1ccf9bd | 14 | |
| 1ccf9bd | 15 | 2. **Codegen**: `plum-wasm-codegen`'s `compile_block_as_fn_body` (`plum-wasm-codegen/src/lib.rs`) |
| 1ccf9bd | 16 | only preserves a function's return value when the body's *literal* last statement is a bare |
| 1ccf9bd | 17 | `ast::Stmt::Expr`. A `Stmt::If` or `Stmt::Match` in that same tail position compiles each arm as |
| 1ccf9bd | 18 | an ordinary statement block (`BlockType::Empty`), so a bare tail expression inside an arm is |
| 1ccf9bd | 19 | `Drop`ped instead of left as the function's result. This produces wasm that fails |
| 1ccf9bd | 20 | `wasmparser::validate` — a loud failure in the test suite, but `plum-cli`'s `compile` subcommand |
| 1ccf9bd | 21 | (`plum-cli/src/main.rs`) writes `compile_source`'s output bytes to disk without validating them, |
| 1ccf9bd | 22 | so a real user hitting this today gets a corrupt, unusable `.wasm` file with no error message at |
| 1ccf9bd | 23 | all, violating the README's stated guarantee that codegen "reports a clear error rather than |
| 1ccf9bd | 24 | silently producing wrong code." |
| 1ccf9bd | 25 | |
| 1ccf9bd | 26 | Both were confirmed by direct reproduction during a prior session (parse-tree dump for gap 1; |
| 1ccf9bd | 27 | `wasmparser::validate` failure plus a fixed reproduction using explicit `return` for gap 2). |
| 1ccf9bd | 28 | |
| 1ccf9bd | 29 | ## Fix 1: grammar |
| 1ccf9bd | 30 | |
| 1ccf9bd | 31 | `$.expression` (`grammar.js:261-269`) is already a strict superset of `$.primary_expression` |
| 1ccf9bd | 32 | (`grammar.js:271-285`) — every existing alternative reachable through `primary_expression` remains |
| 1ccf9bd | 33 | reachable through `expression`, and `expression` is already used without incident elsewhere in the |
| 1ccf9bd | 34 | grammar (`assign`'s values, `assert`, `return`, `if`'s condition). The fix is a single-line change: |
| 1ccf9bd | 35 | |
| 1ccf9bd | 36 | ```js |
| 1ccf9bd | 37 | _statement: ($) => |
| 1ccf9bd | 38 | choice( |
| 1ccf9bd | 39 | $.assign, |
| 1ccf9bd | 40 | $.break, |
| 1ccf9bd | 41 | $.continue, |
| 1ccf9bd | 42 | $.assert, |
| 1ccf9bd | 43 | $.for, |
| 1ccf9bd | 44 | $.while, |
| 1ccf9bd | 45 | $.if, |
| 1ccf9bd | 46 | $.match, |
| 1ccf9bd | 47 | $.return, |
| 1ccf9bd | 48 | $.todo, |
| 1ccf9bd | 49 | $.expression, // was: $.primary_expression |
| 1ccf9bd | 50 | ), |
| 1ccf9bd | 51 | ``` |
| 1ccf9bd | 52 | |
| 1ccf9bd | 53 | Regenerate the parser and run the full existing corpus suite to confirm zero regressions (every |
| 1ccf9bd | 54 | prior `primary_expression`-shaped statement remains valid, since `expression` accepts it too), then |
| 1ccf9bd | 55 | add new corpus cases proving a bare comparison, boolean-operator, and ternary expression now parse |
| 1ccf9bd | 56 | as a single statement with no `ERROR` node when used as a body's trailing line. |
| 1ccf9bd | 57 | |
| 1ccf9bd | 58 | ## Fix 2: codegen tail-position value propagation |
| 1ccf9bd | 59 | |
| 1ccf9bd | 60 | Add a "value position" compile path that mirrors the existing `Stmt::Expr` special-case in |
| 1ccf9bd | 61 | `compile_block_as_fn_body`, but recursively for `Stmt::If` and `Stmt::Match`: |
| 1ccf9bd | 62 | |
| 1ccf9bd | 63 | - A `Stmt::If`/`Stmt::Match` in **value position** (the function body's literal last statement, or |
| 1ccf9bd | 64 | recursively the last statement of an `if`/`else if`/`else` branch or `match` arm that is itself in |
| 1ccf9bd | 65 | value position) compiles its condition/subject as today, but compiles each branch/arm using |
| 1ccf9bd | 66 | `BlockType::Result(result_vt)` instead of `BlockType::Empty`, and compiles that branch's own last |
| 1ccf9bd | 67 | statement through the same value-position path (recursing into further nested `If`/`Match`, or |
| 1ccf9bd | 68 | terminating at a bare `Stmt::Expr` — left on the stack, not dropped — or `Stmt::Return`/`Stmt::Todo` |
| 1ccf9bd | 69 | — already stack-polymorphic in wasm, since control never falls through past them). |
| 1ccf9bd | 70 | - Every branch of a value-position `If` must have an `else` (a value can't be produced on a |
| 1ccf9bd | 71 | path that doesn't exist); every `match` arm must resolve to one of the shapes above. If any |
| 1ccf9bd | 72 | branch/arm's tail statement is some other shape (loop, assignment, etc.) that cannot yield a |
| 1ccf9bd | 73 | value, `compile_source` returns a clear `Err` (e.g. `"codegen: function 'f' has a control-flow |
| 1ccf9bd | 74 | path that doesn't produce a return value"`) — never silently falls through to today's |
| 1ccf9bd | 75 | invalid-wasm behavior. |
| 1ccf9bd | 76 | - Existing callers of `compile_block_as_fn_body` are unaffected: the recursive value-position |
| 1ccf9bd | 77 | logic only activates when `has_return_value` is true and the function's tail statement actually |
| 1ccf9bd | 78 | is `If`/`Match` (today it already special-cases plain `Stmt::Expr`; this generalizes the same |
| 1ccf9bd | 79 | idea one level of control flow deeper). `If`/`Match` appearing anywhere *except* value position |
| 1ccf9bd | 80 | (e.g. as a non-tail statement, or a tail statement in a `Unit`-returning function) keep their |
| 1ccf9bd | 81 | existing, unchanged compilation path. |
| 1ccf9bd | 82 | |
| 1ccf9bd | 83 | ## Testing plan |
| 1ccf9bd | 84 | |
| 1ccf9bd | 85 | - **Grammar**: extend `tooling/tree-sitter-plum/test/corpus/` with cases for a bare comparison, |
| 1ccf9bd | 86 | boolean-operator, and ternary expression as a body's trailing statement; run the full corpus |
| 1ccf9bd | 87 | suite to confirm no regressions. |
| 1ccf9bd | 88 | - **Checker**: no changes are needed to `plum-checker` for either fix (both are purely |
| 1ccf9bd | 89 | parser/codegen concerns) — but re-run the full checker suite (including `examples_test.rs`) to |
| 1ccf9bd | 90 | confirm nothing regresses, since the grammar change affects what `plum-core`'s parser produces. |
| 1ccf9bd | 91 | - **Codegen**: add tests (`plum-wasm-codegen/tests/codegen_tests.rs`) executing (via `wasmtime`, |
| 1ccf9bd | 92 | not just validating) functions whose tail statement is: a `match` with bare-expression arms and |
| 1ccf9bd | 93 | no explicit `return` (the original `examples/match.plum` shape that motivated this work); an `if` |
| 1ccf9bd | 94 | with bare-expression branches and no explicit `return`; a *nested* `if` inside a `match` arm |
| 1ccf9bd | 95 | (recursion through both control-flow kinds); a mix of one arm using explicit `return` and |
| 1ccf9bd | 96 | another using a bare tail expression (validates the stack-polymorphism claim); and a |
| 1ccf9bd | 97 | deliberately malformed case (a `match` arm whose tail statement is e.g. a bare `Stmt::Assign`) |
| 1ccf9bd | 98 | asserting `compile_source` returns the new clear `Err` rather than ever producing bytes. |
| 1ccf9bd | 99 | - **Examples**: revert the `return`-adding workaround applied to `examples/match.plum`'s five |
| 1ccf9bd | 100 | functions back to their natural bare-tail-expression form (proving the fix actually restores the |
| 1ccf9bd | 101 | originally-intended, more idiomatic style) and confirm `plum-wasm-codegen/tests/examples_test.rs` |
| 1ccf9bd | 102 | still passes. |
| 1ccf9bd | 103 | - **README**: remove the now-fixed "final statement being a match/if without explicit return" |
| 1ccf9bd | 104 | bullet from Known Gaps. |
| 1ccf9bd | 105 | |
| 1ccf9bd | 106 | ## Out of scope |
| 1ccf9bd | 107 | |
| 1ccf9bd | 108 | - General "does every code path return a value" checking beyond what's needed to make |
| 1ccf9bd | 109 | value-position `If`/`Match` either compile correctly or fail with a clear error — this is not a |
| 1ccf9bd | 110 | full control-flow/definite-assignment analysis in `plum-checker`, just a defensive check inside |
| 1ccf9bd | 111 | the new codegen path. |
| 1ccf9bd | 112 | - `plum-cli`'s `compile` command validating its own output before writing to disk — worth doing |
| 1ccf9bd | 113 | separately, but this fix makes the underlying codegen bug (the actual source of the corrupt |
| 1ccf9bd | 114 | output) go away rather than adding a downstream safety net. Not required for this fix to be |
| 1ccf9bd | 115 | complete, since the root cause is resolved. |
| 1ccf9bd | 116 | - Generics monomorphization — a separate, much larger effort, tracked independently. |