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
# Guard-Clause Match Arms — Design Spec
## Goal
Add a second form of `match` expression: one with no subject expression, where
each arm is a boolean guard condition instead of a value pattern, e.g.:
```
compare(x: Int, y: Int) -> Ordering =
match
| x > y => GT
| x == y => EQ
| _ => LT
```
This lets a function express a cascade of independent boolean conditions
without threading a placeholder subject through `match` or writing an
`if`/`else if`/`else` chain by hand. It is purely additive: the existing
subject-ful `match` form (`match expr \n pattern => body`) is completely
unchanged.
## Non-goals
- No new stdlib types (no `Ordering` enum) — `GT`/`EQ`/`LT` in the example
above are illustrative; a caller brings their own type.
- No exhaustiveness *proof* for guard matches — guard conditions are
arbitrary runtime booleans, not statically enumerable like an enum's
variants, so there is no compile-time exhaustiveness check for this form
(mirrors how today's pattern-match exhaustiveness check is itself only a
codegen-time affordance for enums with no wildcard, not a hard error).
- No change to the `fn` keyword, parameter syntax, or case-body arrow
(`=>`) — this spec is scoped to guard-clause arms only, reusing every
other piece of existing syntax as-is.
## Current state
- `match`'s grammar (`tooling/tree-sitter-plum/grammar.js`, `match` rule) requires
one or more subject expressions (`commaSep1(field("subject", $.expression))`,
no `optional()`); there is no subject-less form today.
- `case`'s patterns (`case_pattern` rule) are literals, constructor patterns,
dotted names, bare names, or `_` — never a full boolean expression like `x > y`.
- `plum-core/src/ast.rs`'s `CasePattern` enum has no variant for an arbitrary
expression.
- `plum-checker`'s `check_match` type-checks each case's patterns against the
subject types 1:1, erroring if the counts don't match; there is no code path
for zero subjects today.
- `plum-wasm-codegen`'s `compile_match` always evaluates and scratch-stores
every subject, then dispatches per-pattern-kind (int/string/float/class/name/
wildcard) with constructor-fallthrough logic — considerably more machinery
than a guard match needs.
## Design
### Grammar
`match` gains a second alternative via `choice`, disambiguated by whether the
line has a subject expression before the indent:
```js
match: ($) =>
prec.left(
choice(
seq(
"match",
commaSep1(field("subject", $.expression)),
$._indent,
repeat(field("case", $.case)),
$._dedent,
),
seq(
"match",
$._indent,
repeat(field("case", $.guard_case)),
$._dedent,
),
),
),
guard_case: ($) =>
seq(
"|",
field("guard", choice($.expression, "_")),
"=>",
field("body", choice($.expression, $.body)),
),
```
The existing `case`/`case_pattern`/`class_pattern` rules are untouched. Guard
arms are a structurally distinct rule (`guard_case`), not a new
`case_pattern` alternative — a guard is a full expression (`x > y`, `x == y`),
never a literal/constructor/name pattern, so keeping the two arm kinds as
separate grammar rules avoids any ambiguity between "is this a pattern or an
expression" at parse time.
### AST
One new variant on the existing `CasePattern` enum
(`plum-core/src/ast.rs`):
```rust
pub enum CasePattern {
Class { name: String, fields: Vec<CasePattern> },
String(String),
Int(i64),
Float(f64),
Name(String),
Wildcard,
Guard(Expr),
}
```
No new top-level struct. A subject-less `match` parses to the existing
`Match { subjects: vec![], cases }` shape, where each `Case` has exactly one
pattern: either `CasePattern::Guard(expr)` for a boolean condition, or
`CasePattern::Wildcard` for a bare `_` guard arm (the parser recognizes the
literal `_` token in guard position and emits the existing `Wildcard`
variant — not a `Guard` wrapping a trivial "true" expression — so every other
part of the pipeline that already knows how to handle `Wildcard` needs no new
knowledge of "guard-flavored wildcards").
`plum-core/src/parser.rs` gains a `guard_case`-parsing branch (parallel to
today's `case` parsing) that builds a `Case` with one `CasePattern::Guard` or
`CasePattern::Wildcard` pattern from each `guard_case` node, and `parse_match`
is extended so an empty subject list is valid (today's subject-collection
logic already just slices "named children before the first case node," so an
empty slice naturally falls out — no structural change needed there, only the
grammar's `optional`-equivalent `choice` unlocks it).
### Checker
`check_match` (`plum-checker/src/lib.rs`) branches on `m.subjects.is_empty()`:
- **Subject-ful (today's path, unchanged):** infer each subject's type, zip
patterns against subject types 1:1, `check_pattern` as today.
- **Guard (new path):** for each case (exactly one pattern per case, enforced
by the grammar), if the pattern is `CasePattern::Guard(expr)`, infer `expr`'s
type via `infer_expr` and unify it against `PlumType::TBool`, producing a
type error if a guard isn't boolean (e.g. `| x + 1 => ...`). If the pattern
is `CasePattern::Wildcard`, no type-check is needed (matches everything, same
as today's wildcard-pattern meaning). Every arm's body is still
`check_block`-ed against `declared_ret` exactly as today — this already
enforces that all arms return a consistent type, no new "unify across arms"
step is needed.
### Codegen
`plum-wasm-codegen` compiles a guard match as an if/else-if chain, reusing
whatever codegen the `if`/`else if`/`else` statement already uses (not routed
through `compile_match_arms_multi`'s subject-scratch-slot machinery, which
this form has no use for — no subject to evaluate/store, no destructuring, no
per-pattern-kind branching). Each `CasePattern::Guard(expr)` arm compiles its
guard as the `if`/`else if` condition and its body as that branch's body; a
`CasePattern::Wildcard` arm (if present) becomes the chain's trailing `else`.
If no `Wildcard` arm is present and every guard evaluates false at runtime,
the chain falls through to `Unreachable` (a trap) — consistent with today's
non-exhaustive pattern-match behavior, just determined at runtime instead of
compile time, since guard conditions aren't statically enumerable.
### Testing strategy
- Tree-sitter corpus (`tooling/tree-sitter-plum/test/corpus/match.txt`): add
cases for a subject-less guard match (with and without a trailing `_`
arm), verifying the new `guard_case` node shape.
- `plum-core` parser tests: add coverage for the new `guard_case` parsing
path (note: the survey found no dedicated existing `match` test in
`parser_test.rs` today — this is a good opportunity to add the first one,
scoped to this feature).
- `plum-checker` tests: a guard match with all-boolean guards type-checks;
a non-boolean guard (e.g. `| x + 1 => ...`) produces a type error; a guard
match with inconsistent arm-body return types produces the same kind of
error today's subject-ful match already produces for that case.
- `plum-wasm-codegen` tests: a guard match with a `_` fallback runs and
returns the expected arm's value for several inputs; a guard match with NO
`_` fallback traps at runtime when no guard is true (mirrors the existing
non-exhaustive-match trap test pattern already used for enum matches,
per the survey's note on `codegen_tests.rs`'s existing non-exhaustive-match
coverage).