plum

#treesitter#compiler#wasm

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).