plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-24-guard-clause-match-design.md
| c3f68dd | 1 | # Guard-Clause Match Arms — Design Spec |
| c3f68dd | 2 | |
| c3f68dd | 3 | ## Goal |
| c3f68dd | 4 | |
| c3f68dd | 5 | Add a second form of `match` expression: one with no subject expression, where |
| c3f68dd | 6 | each arm is a boolean guard condition instead of a value pattern, e.g.: |
| c3f68dd | 7 | |
| c3f68dd | 8 | ``` |
| c3f68dd | 9 | compare(x: Int, y: Int) -> Ordering = |
| c3f68dd | 10 | match |
| c3f68dd | 11 | | x > y => GT |
| c3f68dd | 12 | | x == y => EQ |
| c3f68dd | 13 | | _ => LT |
| c3f68dd | 14 | ``` |
| c3f68dd | 15 | |
| c3f68dd | 16 | This lets a function express a cascade of independent boolean conditions |
| c3f68dd | 17 | without threading a placeholder subject through `match` or writing an |
| c3f68dd | 18 | `if`/`else if`/`else` chain by hand. It is purely additive: the existing |
| c3f68dd | 19 | subject-ful `match` form (`match expr \n pattern => body`) is completely |
| c3f68dd | 20 | unchanged. |
| c3f68dd | 21 | |
| c3f68dd | 22 | ## Non-goals |
| c3f68dd | 23 | |
| c3f68dd | 24 | - No new stdlib types (no `Ordering` enum) — `GT`/`EQ`/`LT` in the example |
| c3f68dd | 25 | above are illustrative; a caller brings their own type. |
| c3f68dd | 26 | - No exhaustiveness *proof* for guard matches — guard conditions are |
| c3f68dd | 27 | arbitrary runtime booleans, not statically enumerable like an enum's |
| c3f68dd | 28 | variants, so there is no compile-time exhaustiveness check for this form |
| c3f68dd | 29 | (mirrors how today's pattern-match exhaustiveness check is itself only a |
| c3f68dd | 30 | codegen-time affordance for enums with no wildcard, not a hard error). |
| c3f68dd | 31 | - No change to the `fn` keyword, parameter syntax, or case-body arrow |
| c3f68dd | 32 | (`=>`) — this spec is scoped to guard-clause arms only, reusing every |
| c3f68dd | 33 | other piece of existing syntax as-is. |
| c3f68dd | 34 | |
| c3f68dd | 35 | ## Current state |
| c3f68dd | 36 | |
| c3f68dd | 37 | - `match`'s grammar (`tooling/tree-sitter-plum/grammar.js`, `match` rule) requires |
| c3f68dd | 38 | one or more subject expressions (`commaSep1(field("subject", $.expression))`, |
| c3f68dd | 39 | no `optional()`); there is no subject-less form today. |
| c3f68dd | 40 | - `case`'s patterns (`case_pattern` rule) are literals, constructor patterns, |
| c3f68dd | 41 | dotted names, bare names, or `_` — never a full boolean expression like `x > y`. |
| c3f68dd | 42 | - `plum-core/src/ast.rs`'s `CasePattern` enum has no variant for an arbitrary |
| c3f68dd | 43 | expression. |
| c3f68dd | 44 | - `plum-checker`'s `check_match` type-checks each case's patterns against the |
| c3f68dd | 45 | subject types 1:1, erroring if the counts don't match; there is no code path |
| c3f68dd | 46 | for zero subjects today. |
| c3f68dd | 47 | - `plum-wasm-codegen`'s `compile_match` always evaluates and scratch-stores |
| c3f68dd | 48 | every subject, then dispatches per-pattern-kind (int/string/float/class/name/ |
| c3f68dd | 49 | wildcard) with constructor-fallthrough logic — considerably more machinery |
| c3f68dd | 50 | than a guard match needs. |
| c3f68dd | 51 | |
| c3f68dd | 52 | ## Design |
| c3f68dd | 53 | |
| c3f68dd | 54 | ### Grammar |
| c3f68dd | 55 | |
| c3f68dd | 56 | `match` gains a second alternative via `choice`, disambiguated by whether the |
| c3f68dd | 57 | line has a subject expression before the indent: |
| c3f68dd | 58 | |
| c3f68dd | 59 | ```js |
| c3f68dd | 60 | match: ($) => |
| c3f68dd | 61 | prec.left( |
| c3f68dd | 62 | choice( |
| c3f68dd | 63 | seq( |
| c3f68dd | 64 | "match", |
| c3f68dd | 65 | commaSep1(field("subject", $.expression)), |
| c3f68dd | 66 | $._indent, |
| c3f68dd | 67 | repeat(field("case", $.case)), |
| c3f68dd | 68 | $._dedent, |
| c3f68dd | 69 | ), |
| c3f68dd | 70 | seq( |
| c3f68dd | 71 | "match", |
| c3f68dd | 72 | $._indent, |
| c3f68dd | 73 | repeat(field("case", $.guard_case)), |
| c3f68dd | 74 | $._dedent, |
| c3f68dd | 75 | ), |
| c3f68dd | 76 | ), |
| c3f68dd | 77 | ), |
| c3f68dd | 78 | |
| c3f68dd | 79 | guard_case: ($) => |
| c3f68dd | 80 | seq( |
| c3f68dd | 81 | "|", |
| c3f68dd | 82 | field("guard", choice($.expression, "_")), |
| c3f68dd | 83 | "=>", |
| c3f68dd | 84 | field("body", choice($.expression, $.body)), |
| c3f68dd | 85 | ), |
| c3f68dd | 86 | ``` |
| c3f68dd | 87 | |
| c3f68dd | 88 | The existing `case`/`case_pattern`/`class_pattern` rules are untouched. Guard |
| c3f68dd | 89 | arms are a structurally distinct rule (`guard_case`), not a new |
| c3f68dd | 90 | `case_pattern` alternative — a guard is a full expression (`x > y`, `x == y`), |
| c3f68dd | 91 | never a literal/constructor/name pattern, so keeping the two arm kinds as |
| c3f68dd | 92 | separate grammar rules avoids any ambiguity between "is this a pattern or an |
| c3f68dd | 93 | expression" at parse time. |
| c3f68dd | 94 | |
| c3f68dd | 95 | ### AST |
| c3f68dd | 96 | |
| c3f68dd | 97 | One new variant on the existing `CasePattern` enum |
| c3f68dd | 98 | (`plum-core/src/ast.rs`): |
| c3f68dd | 99 | |
| c3f68dd | 100 | ```rust |
| c3f68dd | 101 | pub enum CasePattern { |
| c3f68dd | 102 | Class { name: String, fields: Vec<CasePattern> }, |
| c3f68dd | 103 | String(String), |
| c3f68dd | 104 | Int(i64), |
| c3f68dd | 105 | Float(f64), |
| c3f68dd | 106 | Name(String), |
| c3f68dd | 107 | Wildcard, |
| c3f68dd | 108 | Guard(Expr), |
| c3f68dd | 109 | } |
| c3f68dd | 110 | ``` |
| c3f68dd | 111 | |
| c3f68dd | 112 | No new top-level struct. A subject-less `match` parses to the existing |
| c3f68dd | 113 | `Match { subjects: vec![], cases }` shape, where each `Case` has exactly one |
| c3f68dd | 114 | pattern: either `CasePattern::Guard(expr)` for a boolean condition, or |
| c3f68dd | 115 | `CasePattern::Wildcard` for a bare `_` guard arm (the parser recognizes the |
| c3f68dd | 116 | literal `_` token in guard position and emits the existing `Wildcard` |
| c3f68dd | 117 | variant — not a `Guard` wrapping a trivial "true" expression — so every other |
| c3f68dd | 118 | part of the pipeline that already knows how to handle `Wildcard` needs no new |
| c3f68dd | 119 | knowledge of "guard-flavored wildcards"). |
| c3f68dd | 120 | |
| c3f68dd | 121 | `plum-core/src/parser.rs` gains a `guard_case`-parsing branch (parallel to |
| c3f68dd | 122 | today's `case` parsing) that builds a `Case` with one `CasePattern::Guard` or |
| c3f68dd | 123 | `CasePattern::Wildcard` pattern from each `guard_case` node, and `parse_match` |
| c3f68dd | 124 | is extended so an empty subject list is valid (today's subject-collection |
| c3f68dd | 125 | logic already just slices "named children before the first case node," so an |
| c3f68dd | 126 | empty slice naturally falls out — no structural change needed there, only the |
| c3f68dd | 127 | grammar's `optional`-equivalent `choice` unlocks it). |
| c3f68dd | 128 | |
| c3f68dd | 129 | ### Checker |
| c3f68dd | 130 | |
| c3f68dd | 131 | `check_match` (`plum-checker/src/lib.rs`) branches on `m.subjects.is_empty()`: |
| c3f68dd | 132 | |
| c3f68dd | 133 | - **Subject-ful (today's path, unchanged):** infer each subject's type, zip |
| c3f68dd | 134 | patterns against subject types 1:1, `check_pattern` as today. |
| c3f68dd | 135 | - **Guard (new path):** for each case (exactly one pattern per case, enforced |
| c3f68dd | 136 | by the grammar), if the pattern is `CasePattern::Guard(expr)`, infer `expr`'s |
| c3f68dd | 137 | type via `infer_expr` and unify it against `PlumType::TBool`, producing a |
| c3f68dd | 138 | type error if a guard isn't boolean (e.g. `| x + 1 => ...`). If the pattern |
| c3f68dd | 139 | is `CasePattern::Wildcard`, no type-check is needed (matches everything, same |
| c3f68dd | 140 | as today's wildcard-pattern meaning). Every arm's body is still |
| c3f68dd | 141 | `check_block`-ed against `declared_ret` exactly as today — this already |
| c3f68dd | 142 | enforces that all arms return a consistent type, no new "unify across arms" |
| c3f68dd | 143 | step is needed. |
| c3f68dd | 144 | |
| c3f68dd | 145 | ### Codegen |
| c3f68dd | 146 | |
| c3f68dd | 147 | `plum-wasm-codegen` compiles a guard match as an if/else-if chain, reusing |
| c3f68dd | 148 | whatever codegen the `if`/`else if`/`else` statement already uses (not routed |
| c3f68dd | 149 | through `compile_match_arms_multi`'s subject-scratch-slot machinery, which |
| c3f68dd | 150 | this form has no use for — no subject to evaluate/store, no destructuring, no |
| c3f68dd | 151 | per-pattern-kind branching). Each `CasePattern::Guard(expr)` arm compiles its |
| c3f68dd | 152 | guard as the `if`/`else if` condition and its body as that branch's body; a |
| c3f68dd | 153 | `CasePattern::Wildcard` arm (if present) becomes the chain's trailing `else`. |
| c3f68dd | 154 | If no `Wildcard` arm is present and every guard evaluates false at runtime, |
| c3f68dd | 155 | the chain falls through to `Unreachable` (a trap) — consistent with today's |
| c3f68dd | 156 | non-exhaustive pattern-match behavior, just determined at runtime instead of |
| c3f68dd | 157 | compile time, since guard conditions aren't statically enumerable. |
| c3f68dd | 158 | |
| c3f68dd | 159 | ### Testing strategy |
| c3f68dd | 160 | |
| c3f68dd | 161 | - Tree-sitter corpus (`tooling/tree-sitter-plum/test/corpus/match.txt`): add |
| c3f68dd | 162 | cases for a subject-less guard match (with and without a trailing `_` |
| c3f68dd | 163 | arm), verifying the new `guard_case` node shape. |
| c3f68dd | 164 | - `plum-core` parser tests: add coverage for the new `guard_case` parsing |
| c3f68dd | 165 | path (note: the survey found no dedicated existing `match` test in |
| c3f68dd | 166 | `parser_test.rs` today — this is a good opportunity to add the first one, |
| c3f68dd | 167 | scoped to this feature). |
| c3f68dd | 168 | - `plum-checker` tests: a guard match with all-boolean guards type-checks; |
| c3f68dd | 169 | a non-boolean guard (e.g. `| x + 1 => ...`) produces a type error; a guard |
| c3f68dd | 170 | match with inconsistent arm-body return types produces the same kind of |
| c3f68dd | 171 | error today's subject-ful match already produces for that case. |
| c3f68dd | 172 | - `plum-wasm-codegen` tests: a guard match with a `_` fallback runs and |
| c3f68dd | 173 | returns the expected arm's value for several inputs; a guard match with NO |
| c3f68dd | 174 | `_` fallback traps at runtime when no guard is true (mirrors the existing |
| c3f68dd | 175 | non-exhaustive-match trap test pattern already used for enum matches, |
| c3f68dd | 176 | per the survey's note on `codegen_tests.rs`'s existing non-exhaustive-match |
| c3f68dd | 177 | coverage). |