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-24-guard-clause-match.md
1bc8c44 1
# Guard-Clause Match Arms Implementation Plan
1bc8c44 2
1bc8c44 3
> **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.
1bc8c44 4
1bc8c44 5
**Goal:** Add a subject-less `match` form whose arms are boolean guard conditions (`| cond => body`) instead of value patterns, with `_` as a catch-all, per `docs/superpowers/specs/2026-07-24-guard-clause-match-design.md`.
1bc8c44 6
1bc8c44 7
**Architecture:** Bottom-up: grammar (new `guard_case` rule, `match` becomes a `choice` of subject-ful/subject-less) → AST (one new `CasePattern::Guard(Expr)` variant, no new top-level struct) → parser (new `guard_case`-parsing branch reusing `parse_match`'s existing subject-splitting logic, which already tolerates zero subjects) → checker (`check_match` branches on `m.subjects.is_empty()`) → codegen (new dedicated `compile_guard_match`, reusing `compile_if`'s if/else-if/else chain shape, bypassing the subject-scratch/pattern-fallthrough machinery entirely — the existing Collector pass needs no changes, since it already produces an empty scratch-type list and a no-op pattern-zip for a subject-less match, verified during design).
1bc8c44 8
1bc8c44 9
**Tech Stack:** tree-sitter (JS grammar), Rust (`plum-core`, `plum-checker`, `plum-wasm-codegen`).
1bc8c44 10
1bc8c44 11
## Global Constraints
1bc8c44 12
1bc8c44 13
- Spec: `docs/superpowers/specs/2026-07-24-guard-clause-match-design.md`
1bc8c44 14
- Guard arm syntax: `"|" (expression | "_") "=>" (expression | body)` — leading `|` required, `=>` unchanged, no new punctuation elsewhere.
1bc8c44 15
- `_` in guard position parses to the EXISTING `CasePattern::Wildcard` (not a new "always-true" node) — every downstream consumer that already understands `Wildcard` needs no new knowledge.
1bc8c44 16
- Subject-ful `match` (today's form) is completely unchanged — this is purely additive, disambiguated by presence/absence of a subject expression before the indent.
1bc8c44 17
- No stdlib `Ordering` type is added — out of scope.
1bc8c44 18
- Run `cargo test --workspace` and (from `tooling/tree-sitter-plum/`) `npx --yes tree-sitter-cli test` after every task — all pre-existing tests must keep passing throughout.
1bc8c44 19
1bc8c44 20
---
1bc8c44 21
1bc8c44 22
### Task 1: Grammar — new `guard_case` rule and subject-less `match`
1bc8c44 23
1bc8c44 24
**Files:**
1bc8c44 25
- Modify: `tooling/tree-sitter-plum/grammar.js`
1bc8c44 26
- Modify: `tooling/tree-sitter-plum/test/corpus/match.txt`
1bc8c44 27
1bc8c44 28
**Interfaces:**
1bc8c44 29
- Consumes: nothing (bottom of the pipeline).
1bc8c44 30
- Produces: a regenerated parser where `match` accepts EITHER today's subject-ful form OR a new subject-less form whose body is `repeat(field("case", $.guard_case))`; `guard_case` is a new node kind `"guard_case"` with fields `"guard"` (an `expression` or the literal `"_"`) and `"body"`.
1bc8c44 31
1bc8c44 32
- [ ] **Step 1: Edit the `match` rule to add the subject-less alternative**
1bc8c44 33
1bc8c44 34
In `tooling/tree-sitter-plum/grammar.js`, find the current `match` rule:
1bc8c44 35
1bc8c44 36
```js
1bc8c44 37
    match: ($) =>
1bc8c44 38
      prec.left(
1bc8c44 39
        seq(
1bc8c44 40
          "match",
1bc8c44 41
          commaSep1(field("subject", $.expression)), // remove comma use tuples (a, b) and match against tuples
1bc8c44 42
          $._indent,
1bc8c44 43
          repeat(field("case", $.case)),
1bc8c44 44
          $._dedent,
1bc8c44 45
        ),
1bc8c44 46
      ),
1bc8c44 47
```
1bc8c44 48
1bc8c44 49
Replace it with:
1bc8c44 50
1bc8c44 51
```js
1bc8c44 52
    match: ($) =>
1bc8c44 53
      prec.left(
1bc8c44 54
        choice(
1bc8c44 55
          seq(
1bc8c44 56
            "match",
1bc8c44 57
            commaSep1(field("subject", $.expression)), // remove comma use tuples (a, b) and match against tuples
1bc8c44 58
            $._indent,
1bc8c44 59
            repeat(field("case", $.case)),
1bc8c44 60
            $._dedent,
1bc8c44 61
          ),
1bc8c44 62
          seq(
1bc8c44 63
            "match",
1bc8c44 64
            $._indent,
1bc8c44 65
            repeat(field("case", $.guard_case)),
1bc8c44 66
            $._dedent,
1bc8c44 67
          ),
1bc8c44 68
        ),
1bc8c44 69
      ),
1bc8c44 70
```
1bc8c44 71
1bc8c44 72
- [ ] **Step 2: Add the `guard_case` rule**
1bc8c44 73
1bc8c44 74
Add a new rule near `case`/`case_pattern` (find the existing `case`/`case_pattern`/`class_pattern` rules and add `guard_case` right after `case_pattern`):
1bc8c44 75
1bc8c44 76
```js
1bc8c44 77
    guard_case: ($) =>
1bc8c44 78
      seq(
1bc8c44 79
        "|",
1bc8c44 80
        field("guard", choice($.expression, "_")),
1bc8c44 81
        "=>",
1bc8c44 82
        field("body", choice($.expression, $.body)),
1bc8c44 83
      ),
1bc8c44 84
```
1bc8c44 85
1bc8c44 86
- [ ] **Step 3: Regenerate the parser**
1bc8c44 87
1bc8c44 88
Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate`
1bc8c44 89
Expected: succeeds with no conflicts. If tree-sitter reports a conflict (e.g. between the two `match` alternatives, or between `guard_case`'s `"_"` and `case_pattern`'s `"_"`), stop and report BLOCKED — that would mean the two forms aren't as structurally distinguishable as this plan assumes.
1bc8c44 90
1bc8c44 91
- [ ] **Step 4: Add corpus tests**
1bc8c44 92
1bc8c44 93
In `tooling/tree-sitter-plum/test/corpus/match.txt`, add a new test block (matching the file's existing format — a `====` header, source, a `----` divider, then the expected S-expression tree). Read the existing file first to match its exact section-separator style, then add:
1bc8c44 94
1bc8c44 95
```
1bc8c44 96
================================================================================
1bc8c44 97
match - guard-clause arms, no subject
1bc8c44 98
================================================================================
1bc8c44 99
1bc8c44 100
compare(x: Int, y: Int) -> Int =
1bc8c44 101
  match
1bc8c44 102
    | x > y => 1
1bc8c44 103
    | x == y => 0
1bc8c44 104
    | _ => -1
1bc8c44 105
1bc8c44 106
--------------------------------------------------------------------------------
1bc8c44 107
1bc8c44 108
(source
1bc8c44 109
  (fn
1bc8c44 110
    (fn_identifier)
1bc8c44 111
    (param
1bc8c44 112
      (var_identifier)
1bc8c44 113
      (type
1bc8c44 114
        (type_identifier)))
1bc8c44 115
    (param
1bc8c44 116
      (var_identifier)
1bc8c44 117
      (type
1bc8c44 118
        (type_identifier)))
1bc8c44 119
    (type
1bc8c44 120
      (type_identifier))
1bc8c44 121
    (body
1bc8c44 122
      (match
1bc8c44 123
        (guard_case
1bc8c44 124
          (expression
1bc8c44 125
            (comparison_operator
1bc8c44 126
              (primary_expression
1bc8c44 127
                (var_identifier))
1bc8c44 128
              (primary_expression
1bc8c44 129
                (var_identifier))))
1bc8c44 130
          (expression
1bc8c44 131
            (primary_expression
1bc8c44 132
              (integer))))
1bc8c44 133
        (guard_case
1bc8c44 134
          (expression
1bc8c44 135
            (comparison_operator
1bc8c44 136
              (primary_expression
1bc8c44 137
                (var_identifier))
1bc8c44 138
              (primary_expression
1bc8c44 139
                (var_identifier))))
1bc8c44 140
          (expression
1bc8c44 141
            (primary_expression
1bc8c44 142
              (integer))))
1bc8c44 143
        (guard_case
1bc8c44 144
          (expression
1bc8c44 145
            (primary_expression
1bc8c44 146
              (unary_operator
1bc8c44 147
                (primary_expression
1bc8c44 148
                  (integer)))))))))))
1bc8c44 149
```
1bc8c44 150
1bc8c44 151
Note: the exact tree shape for the `_` arm's body (`-1`, a unary-minus integer) and the precise nesting of `comparison_operator`/`expression` wrappers may not match this sketch exactly — **you must run Step 5 first, look at the ACTUAL generated tree via `tree-sitter parse`, and paste the real output into the corpus file rather than trusting this sketch verbatim.** This is standard practice for corpus tests (they encode ground truth from the real parser, not a hand-guess) and mirrors how earlier corpus updates in this codebase were done.
1bc8c44 152
1bc8c44 153
- [ ] **Step 5: Run the corpus tests, fix the expected tree from real output, iterate to green**
1bc8c44 154
1bc8c44 155
Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test 2>&1 | tail -100`
1bc8c44 156
1bc8c44 157
If the new test fails, run `echo '<the exact source from Step 4>' | npx --yes tree-sitter-cli parse -` (or write it to a temp `.plum` file and parse that) to see the actual tree, then correct the corpus file's expected S-expression to match reality. Repeat until `tree-sitter-cli test` reports 100% success including your new test.
1bc8c44 158
1bc8c44 159
- [ ] **Step 6: Commit**
1bc8c44 160
1bc8c44 161
```bash
1bc8c44 162
git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/src tooling/tree-sitter-plum/test/corpus/match.txt
1bc8c44 163
git commit -m "feat(grammar): add subject-less guard-clause match arms"
1bc8c44 164
```
1bc8c44 165
1bc8c44 166
---
1bc8c44 167
1bc8c44 168
### Task 2: `plum-core` — AST and parser support for guard arms
1bc8c44 169
1bc8c44 170
**Files:**
1bc8c44 171
- Modify: `plum-core/src/ast.rs`
1bc8c44 172
- Modify: `plum-core/src/parser.rs`
1bc8c44 173
- Modify: `plum-core/tests/parser_test.rs`
1bc8c44 174
1bc8c44 175
**Interfaces:**
1bc8c44 176
- Consumes: the regenerated grammar from Task 1 (node kind `"guard_case"` with fields `"guard"`/`"body"`; a `match` node with zero `"subject"` children and `"guard_case"`-kind case children instead of `"case"`-kind ones).
1bc8c44 177
- Produces: `CasePattern::Guard(ast::Expr)` (new variant); `Match { subjects: vec![], cases }` for a guard-form match, where each `Case.patterns` has exactly one element (`Guard(expr)` or `Wildcard`).
1bc8c44 178
1bc8c44 179
- [ ] **Step 1: Add the `Guard` variant to `CasePattern`**
1bc8c44 180
1bc8c44 181
In `plum-core/src/ast.rs`, find:
1bc8c44 182
1bc8c44 183
```rust
1bc8c44 184
#[derive(Debug, Clone, PartialEq)]
1bc8c44 185
pub enum CasePattern {
1bc8c44 186
    Class { name: String, fields: Vec<CasePattern> },
1bc8c44 187
    String(String),
1bc8c44 188
    Int(i64),
1bc8c44 189
    Float(f64),
1bc8c44 190
    Name(String),
1bc8c44 191
    Wildcard,
1bc8c44 192
}
1bc8c44 193
```
1bc8c44 194
1bc8c44 195
Add a `Guard` variant:
1bc8c44 196
1bc8c44 197
```rust
1bc8c44 198
#[derive(Debug, Clone, PartialEq)]
1bc8c44 199
pub enum CasePattern {
1bc8c44 200
    Class { name: String, fields: Vec<CasePattern> },
1bc8c44 201
    String(String),
1bc8c44 202
    Int(i64),
1bc8c44 203
    Float(f64),
1bc8c44 204
    Name(String),
1bc8c44 205
    Wildcard,
1bc8c44 206
    /// A boolean guard-clause arm in a subject-less `match` (`| x > y => ...`).
1bc8c44 207
    Guard(Expr),
1bc8c44 208
}
1bc8c44 209
```
1bc8c44 210
1bc8c44 211
- [ ] **Step 2: Parse `guard_case` nodes**
1bc8c44 212
1bc8c44 213
In `plum-core/src/parser.rs`, `parse_match` currently reads:
1bc8c44 214
1bc8c44 215
```rust
1bc8c44 216
    fn parse_match(&self, node: Node) -> Match {
1bc8c44 217
        // match: "match" commaSep1(expression) "is" case+
1bc8c44 218
        let mut cursor = node.walk();
1bc8c44 219
        let named: Vec<Node> = node.named_children(&mut cursor).collect();
1bc8c44 220
        let split = named.iter().position(|n| n.kind() == "case").unwrap_or(named.len());
1bc8c44 221
        let subjects = named[..split]
1bc8c44 222
            .iter()
1bc8c44 223
            .map(|n| { let u = self.unwrap_expr_node(*n); self.parse_expression(u) })
1bc8c44 224
            .collect();
1bc8c44 225
        let cases = named[split..]
1bc8c44 226
            .iter()
1bc8c44 227
            .filter(|n| n.kind() == "case")
1bc8c44 228
            .map(|n| self.parse_case(*n))
1bc8c44 229
            .collect();
1bc8c44 230
        Match { subjects, cases }
1bc8c44 231
    }
1bc8c44 232
```
1bc8c44 233
1bc8c44 234
Replace it so it also recognizes `guard_case` children (a guard-form match has NO `"case"`-kind children at all — only `"guard_case"`-kind ones — so `split` naturally lands at `named.len()` for a guard match, giving an empty `subjects` slice, which is exactly the desired `Match.subjects: vec![]`; you just need the `cases` collection to also parse `guard_case` nodes):
1bc8c44 235
1bc8c44 236
```rust
1bc8c44 237
    fn parse_match(&self, node: Node) -> Match {
1bc8c44 238
        // match: "match" commaSep1(expression) case+  |  "match" guard_case+
1bc8c44 239
        let mut cursor = node.walk();
1bc8c44 240
        let named: Vec<Node> = node.named_children(&mut cursor).collect();
1bc8c44 241
        let split = named.iter()
1bc8c44 242
            .position(|n| n.kind() == "case" || n.kind() == "guard_case")
1bc8c44 243
            .unwrap_or(named.len());
1bc8c44 244
        let subjects = named[..split]
1bc8c44 245
            .iter()
1bc8c44 246
            .map(|n| { let u = self.unwrap_expr_node(*n); self.parse_expression(u) })
1bc8c44 247
            .collect();
1bc8c44 248
        let cases = named[split..]
1bc8c44 249
            .iter()
1bc8c44 250
            .map(|n| match n.kind() {
1bc8c44 251
                "case" => self.parse_case(*n),
1bc8c44 252
                "guard_case" => self.parse_guard_case(*n),
1bc8c44 253
                _ => Case { patterns: vec![], body: Block { stmts: vec![] } },
1bc8c44 254
            })
1bc8c44 255
            .collect();
1bc8c44 256
        Match { subjects, cases }
1bc8c44 257
    }
1bc8c44 258
1bc8c44 259
    fn parse_guard_case(&self, node: Node) -> Case {
1bc8c44 260
        // guard_case: "|" field("guard", expression | "_") "=>" field("body", expression | body)
1bc8c44 261
        let guard_node = node.child_by_field_name("guard");
1bc8c44 262
        let pattern = match guard_node {
1bc8c44 263
            Some(n) if self.text(n) == "_" => CasePattern::Wildcard,
1bc8c44 264
            Some(n) => {
1bc8c44 265
                let unwrapped = self.unwrap_expr_node(n);
1bc8c44 266
                CasePattern::Guard(self.parse_expression(unwrapped))
1bc8c44 267
            }
1bc8c44 268
            None => CasePattern::Wildcard,
1bc8c44 269
        };
1bc8c44 270
        let body = node.child_by_field_name("body").map(|n| {
1bc8c44 271
            if n.kind() == "body" {
1bc8c44 272
                self.parse_block(n)
1bc8c44 273
            } else {
1bc8c44 274
                let unwrapped = self.unwrap_expr_node(n);
1bc8c44 275
                Block { stmts: vec![Stmt::Expr(self.parse_expression(unwrapped))] }
1bc8c44 276
            }
1bc8c44 277
        }).unwrap_or(Block { stmts: vec![] });
1bc8c44 278
        Case { patterns: vec![pattern], body }
1bc8c44 279
    }
1bc8c44 280
```
1bc8c44 281
1bc8c44 282
Read the current `parse_match`/`parse_case` in the actual file first — the surrounding code may have shifted slightly since this plan was written; match the existing style (e.g. whether `child_by_field_name` or positional `named_child` indexing is idiomatic here — `parse_case` uses positional filtering because `case`'s patterns and body aren't field-tagged the same way, but `guard_case`'s fields ARE explicitly named in the grammar, so `child_by_field_name` is the more direct and correct choice for it specifically).
1bc8c44 283
1bc8c44 284
- [ ] **Step 3: Add a parser test for guard-clause matches**
1bc8c44 285
1bc8c44 286
In `plum-core/tests/parser_test.rs`, add a test that parses a small guard-match function (e.g. the `compare` example from the spec) and asserts on the resulting `ast::Match`: `subjects` is empty, `cases.len() == 3`, the first two cases' single pattern is `CasePattern::Guard(_)` (match on the variant, don't need to assert the exact `Expr` shape), and the last case's pattern is `CasePattern::Wildcard`. Follow this file's existing test style/helpers (e.g. however it currently parses a source string into an `ast::Source` and drills into a specific `Fn`'s body).
1bc8c44 287
1bc8c44 288
- [ ] **Step 4: Run `plum-core`'s tests**
1bc8c44 289
1bc8c44 290
Run: `cargo test -p plum-core 2>&1 | tail -60`
1bc8c44 291
Expected: PASS, including your new test.
1bc8c44 292
1bc8c44 293
- [ ] **Step 5: Commit**
1bc8c44 294
1bc8c44 295
```bash
1bc8c44 296
git add plum-core/src/ast.rs plum-core/src/parser.rs plum-core/tests/parser_test.rs
1bc8c44 297
git commit -m "feat(plum-core): parse guard-clause match arms into CasePattern::Guard"
1bc8c44 298
```
1bc8c44 299
1bc8c44 300
---
1bc8c44 301
1bc8c44 302
### Task 3: `plum-checker` — type-check guard-clause matches
1bc8c44 303
1bc8c44 304
**Files:**
1bc8c44 305
- Modify: `plum-checker/src/lib.rs`
1bc8c44 306
- Modify: `plum-checker/tests/checker_tests.rs`
1bc8c44 307
1bc8c44 308
**Interfaces:**
1bc8c44 309
- Consumes: `Match.subjects: Vec<Expr>` possibly empty, `CasePattern::Guard(Expr)` from Task 2.
1bc8c44 310
- Produces: a guard's condition must type as `Bool`; a `Wildcard` guard arm needs no check; every arm's body still checks against `declared_ret` exactly as today.
1bc8c44 311
1bc8c44 312
- [ ] **Step 1: Branch `check_match` on empty subjects**
1bc8c44 313
1bc8c44 314
In `plum-checker/src/lib.rs`, `check_match` currently starts:
1bc8c44 315
1bc8c44 316
```rust
1bc8c44 317
fn check_match(m: &ast::Match, env: &TypeEnv, declared_ret: &PlumType, fn_name: &str, ctx: &CheckCtx) -> Vec<CheckError> {
1bc8c44 318
    let mut errors = Vec::new();
1bc8c44 319
1bc8c44 320
    let mut subject_types: Vec<PlumType> = Vec::new();
1bc8c44 321
    for s in &m.subjects {
1bc8c44 322
        match infer_expr(s, env, ctx) {
1bc8c44 323
            Ok(t) => subject_types.push(t),
1bc8c44 324
            Err(msg) => errors.push(CheckError { message: format!("fn '{}': match subject: {}", fn_name, msg) }),
1bc8c44 325
        }
1bc8c44 326
    }
1bc8c44 327
    if subject_types.len() != m.subjects.len() {
1bc8c44 328
        return errors; // a subject failed to type — cases can't be checked meaningfully
1bc8c44 329
    }
1bc8c44 330
1bc8c44 331
    for case in &m.cases {
1bc8c44 332
        // ... existing subject-ful pattern-zip logic ...
1bc8c44 333
    }
1bc8c44 334
    errors
1bc8c44 335
}
1bc8c44 336
```
1bc8c44 337
1bc8c44 338
Add a guard-clause branch at the very top, before any of the existing subject-inference logic:
1bc8c44 339
1bc8c44 340
```rust
1bc8c44 341
fn check_match(m: &ast::Match, env: &TypeEnv, declared_ret: &PlumType, fn_name: &str, ctx: &CheckCtx) -> Vec<CheckError> {
1bc8c44 342
    let mut errors = Vec::new();
1bc8c44 343
1bc8c44 344
    if m.subjects.is_empty() {
1bc8c44 345
        for case in &m.cases {
1bc8c44 346
            let mut case_env = env.clone();
1bc8c44 347
            match case.patterns.first() {
1bc8c44 348
                Some(ast::CasePattern::Guard(e)) => match infer_expr(e, &case_env, ctx) {
1bc8c44 349
                    Ok(ty) => {
1bc8c44 350
                        if let Err(msg) = unify(&ty, &PlumType::TBool) {
1bc8c44 351
                            errors.push(CheckError {
1bc8c44 352
                                message: format!("fn '{}': guard clause must be Bool: {}", fn_name, msg),
1bc8c44 353
                            });
1bc8c44 354
                        }
1bc8c44 355
                    }
1bc8c44 356
                    Err(msg) => errors.push(CheckError {
1bc8c44 357
                        message: format!("fn '{}': guard clause: {}", fn_name, msg),
1bc8c44 358
                    }),
1bc8c44 359
                },
1bc8c44 360
                Some(ast::CasePattern::Wildcard) => {}
1bc8c44 361
                _ => errors.push(CheckError {
1bc8c44 362
                    message: format!(
1bc8c44 363
                        "fn '{}': guard-clause match arm must be a boolean condition or '_'",
1bc8c44 364
                        fn_name
1bc8c44 365
                    ),
1bc8c44 366
                }),
1bc8c44 367
            }
1bc8c44 368
            errors.append(&mut check_block(&case.body, &mut case_env, declared_ret, fn_name, ctx));
1bc8c44 369
        }
1bc8c44 370
        return errors;
1bc8c44 371
    }
1bc8c44 372
1bc8c44 373
    let mut subject_types: Vec<PlumType> = Vec::new();
1bc8c44 374
    for s in &m.subjects {
1bc8c44 375
        match infer_expr(s, env, ctx) {
1bc8c44 376
            Ok(t) => subject_types.push(t),
1bc8c44 377
            Err(msg) => errors.push(CheckError { message: format!("fn '{}': match subject: {}", fn_name, msg) }),
1bc8c44 378
        }
1bc8c44 379
    }
1bc8c44 380
    if subject_types.len() != m.subjects.len() {
1bc8c44 381
        return errors; // a subject failed to type — cases can't be checked meaningfully
1bc8c44 382
    }
1bc8c44 383
1bc8c44 384
    for case in &m.cases {
1bc8c44 385
        // ... existing subject-ful pattern-zip logic, UNCHANGED ...
1bc8c44 386
    }
1bc8c44 387
    errors
1bc8c44 388
}
1bc8c44 389
```
1bc8c44 390
1bc8c44 391
Read the current file first to get the exact unchanged middle section right — copy it verbatim from the current source, don't retype it from this plan's earlier excerpt (this plan's excerpt may be stale relative to the actual file).
1bc8c44 392
1bc8c44 393
- [ ] **Step 2: `check_pattern` also needs a `Guard` arm (for completeness / future callers)**
1bc8c44 394
1bc8c44 395
`check_pattern` is a `match` over `CasePattern` with no `Guard` variant yet — since Rust match exhaustiveness will now fail to compile once `CasePattern::Guard` exists, add an arm (even though `check_match`'s new guard branch above doesn't call `check_pattern` for guard cases — some other code path might, and the compiler needs it regardless):
1bc8c44 396
1bc8c44 397
```rust
1bc8c44 398
        ast::CasePattern::Guard(e) => {
1bc8c44 399
            // Guard clauses are only meaningful inside a subject-less match, which
1bc8c44 400
            // check_match's dedicated branch already type-checks directly against
1bc8c44 401
            // Bool — check_pattern is only reached for subject-ful matches, where a
1bc8c44 402
            // bare Guard pattern shouldn't structurally occur. Treat it permissively
1bc8c44 403
            // (no subject to unify against) rather than panicking.
1bc8c44 404
            let _ = e;
1bc8c44 405
            Ok(())
1bc8c44 406
        }
1bc8c44 407
```
1bc8c44 408
1bc8c44 409
Add this arm to `check_pattern`'s `match pat { ... }` (exact insertion point: wherever the current arms are — read the file first).
1bc8c44 410
1bc8c44 411
- [ ] **Step 3: Add checker tests**
1bc8c44 412
1bc8c44 413
In `plum-checker/tests/checker_tests.rs`, add tests for:
1bc8c44 414
1. A guard match with all-`Bool` guards and a `_` fallback type-checks with no errors (e.g. the `compare` example).
1bc8c44 415
2. A guard match with a non-`Bool` guard (e.g. `| x + 1 => 0`) produces an error.
1bc8c44 416
3. A guard match with arms returning inconsistent types (e.g. one arm returns `Int`, another returns `Str`) produces an error, the same way today's subject-ful match already does for that case — find and mirror whatever existing test covers that for subject-ful match.
1bc8c44 417
1bc8c44 418
Follow this file's existing test conventions (however it currently asserts on `Vec<CheckError>` — count, message substring, etc.).
1bc8c44 419
1bc8c44 420
- [ ] **Step 4: Run `plum-checker`'s tests**
1bc8c44 421
1bc8c44 422
Run: `cargo test -p plum-checker 2>&1 | tail -100`
1bc8c44 423
Expected: PASS, including your new tests.
1bc8c44 424
1bc8c44 425
- [ ] **Step 5: Commit**
1bc8c44 426
1bc8c44 427
```bash
1bc8c44 428
git add plum-checker/src/lib.rs plum-checker/tests/checker_tests.rs
1bc8c44 429
git commit -m "feat(plum-checker): type-check guard-clause match arms"
1bc8c44 430
```
1bc8c44 431
1bc8c44 432
---
1bc8c44 433
1bc8c44 434
### Task 4: `plum-wasm-codegen` — compile guard-clause matches
1bc8c44 435
1bc8c44 436
**Files:**
1bc8c44 437
- Modify: `plum-wasm-codegen/src/lib.rs`
1bc8c44 438
- Modify: `plum-wasm-codegen/tests/codegen_tests.rs`
1bc8c44 439
1bc8c44 440
**Interfaces:**
1bc8c44 441
- Consumes: `Match.subjects.is_empty()` + `CasePattern::Guard(Expr)`/`Wildcard` cases from Task 2/3.
1bc8c44 442
- Produces: a guard match compiles as an if/else-if/else chain (mirroring `compile_if`), trapping (`Unreachable`) if no guard is true and no `Wildcard` arm is present.
1bc8c44 443
1bc8c44 444
- [ ] **Step 1: Add a dedicated `compile_guard_match` function**
1bc8c44 445
1bc8c44 446
In `plum-wasm-codegen/src/lib.rs`, `compile_match` currently starts by looking up a match-scratch slot and evaluating subjects — none of that applies to a guard match. Add a branch at the very top of `compile_match` (read the current function first to get its exact current signature/body verbatim, this plan's earlier excerpt may be stale):
1bc8c44 447
1bc8c44 448
```rust
1bc8c44 449
fn compile_match(
1bc8c44 450
    m: &ast::Match,
1bc8c44 451
    body: &mut Vec<u8>,
1bc8c44 452
    ctx: &LocalCtx,
1bc8c44 453
    state: &mut ModuleState,
1bc8c44 454
    result_vt: Option<ValType>,
1bc8c44 455
) -> Result<(), String> {
1bc8c44 456
    if m.subjects.is_empty() {
1bc8c44 457
        return compile_guard_match(m, result_vt, body, ctx, state);
1bc8c44 458
    }
1bc8c44 459
1bc8c44 460
    // ... existing subject-ful body, UNCHANGED ...
1bc8c44 461
}
1bc8c44 462
```
1bc8c44 463
1bc8c44 464
Add the new function near `compile_if` (they share the same if/else-if/else shape and `compile_case_body` helper):
1bc8c44 465
1bc8c44 466
```rust
1bc8c44 467
/// Compiles a subject-less guard-clause match (`match \n | cond => body ...`) as an
1bc8c44 468
/// if/else-if/else chain — structurally identical to `compile_if`, since a guard
1bc8c44 469
/// arm's condition is an ordinary boolean expression with no subject to evaluate or
1bc8c44 470
/// destructure. A `CasePattern::Wildcard` arm (bare `_`) becomes the chain's
1bc8c44 471
/// trailing `else`; if none is present and every guard is false at runtime, the
1bc8c44 472
/// chain falls through to `Unreachable` — the same "non-exhaustive match traps"
1bc8c44 473
/// behavior today's pattern-match codegen already has for enums, just checked at
1bc8c44 474
/// runtime instead of compile time (guard conditions aren't statically enumerable).
1bc8c44 475
fn compile_guard_match(
1bc8c44 476
    m: &ast::Match,
1bc8c44 477
    result_vt: Option<ValType>,
1bc8c44 478
    body: &mut Vec<u8>,
1bc8c44 479
    ctx: &LocalCtx,
1bc8c44 480
    state: &mut ModuleState,
1bc8c44 481
) -> Result<(), String> {
1bc8c44 482
    let bt = block_type_for(result_vt);
1bc8c44 483
    let mut open_blocks = 0usize;
1bc8c44 484
    let mut wildcard_case: Option<&ast::Case> = None;
1bc8c44 485
1bc8c44 486
    for case in &m.cases {
1bc8c44 487
        match case.patterns.first() {
1bc8c44 488
            Some(ast::CasePattern::Wildcard) => {
1bc8c44 489
                wildcard_case = Some(case);
1bc8c44 490
                break; // any arm after a `_` is unreachable; stop compiling arms here
1bc8c44 491
            }
1bc8c44 492
            Some(ast::CasePattern::Guard(cond)) => {
1bc8c44 493
                compile_expr(cond, body, ctx, state)?;
1bc8c44 494
                Instruction::If(bt).encode(body);
1bc8c44 495
                compile_case_body(&case.body, result_vt, body, ctx, state)?;
1bc8c44 496
                Instruction::Else.encode(body);
1bc8c44 497
                open_blocks += 1;
1bc8c44 498
            }
1bc8c44 499
            _ => return Err("codegen: guard-clause match arm must be a guard or '_'".to_string()),
1bc8c44 500
        }
1bc8c44 501
    }
1bc8c44 502
1bc8c44 503
    match wildcard_case {
1bc8c44 504
        Some(case) => compile_case_body(&case.body, result_vt, body, ctx, state)?,
1bc8c44 505
        None => {
1bc8c44 506
            if result_vt.is_some() {
1bc8c44 507
                Instruction::Unreachable.encode(body);
1bc8c44 508
            }
1bc8c44 509
            // A guard match with no `_` in a non-value (statement) position and no
1bc8c44 510
            // guard true at runtime simply falls through to nothing further to do —
1bc8c44 511
            // still needs the trap so execution doesn't silently continue with a
1bc8c44 512
            // dangling value-stack expectation when result_vt IS Some.
1bc8c44 513
        }
1bc8c44 514
    }
1bc8c44 515
1bc8c44 516
    for _ in 0..open_blocks {
1bc8c44 517
        Instruction::End.encode(body);
1bc8c44 518
    }
1bc8c44 519
    Ok(())
1bc8c44 520
}
1bc8c44 521
```
1bc8c44 522
1bc8c44 523
Read `compile_if`'s exact current body first and cross-check this sketch against it — in particular confirm the exact `Instruction::If`/`Else`/`End` encoding calls and `compile_case_body`'s exact signature match what's actually in the file (this plan's earlier research excerpt of `compile_if` may have drifted).
1bc8c44 524
1bc8c44 525
- [ ] **Step 2: Confirm the Collector pass needs no changes (verification step, not necessarily a code change)**
1bc8c44 526
1bc8c44 527
Read the `Collector` struct's `walk_stmt` handling of `ast::Stmt::Match(m)` (search for `match_scratch.insert` in `plum-wasm-codegen/src/lib.rs`). For a guard match, `m.subjects` is empty, so `subject_types` collects to `vec![]`, and the `case.patterns.iter().zip(subject_types.iter())` loop naturally produces zero iterations for every case (since `subject_types` is empty) — meaning `collect_pattern` is never called for `Guard`/`Wildcard` patterns, which is correct (they need no scratch-slot or env-binding registration). Confirm this by reading the actual code; if the Collector's `walk_stmt`/`walk_expr` for `Match` does anything ELSE that assumes `subjects.len() > 0` (e.g. panics on an empty subject list, or skips walking case bodies for empty-subject matches), that would be a real gap — fix it if found, and note the fix in your report. If it's exactly as described (a no-op for guard cases, but still walks `case.body` for nested match/expression discovery), no code change is needed here, just note that you verified it.
1bc8c44 528
1bc8c44 529
- [ ] **Step 3: Add codegen tests**
1bc8c44 530
1bc8c44 531
In `plum-wasm-codegen/tests/codegen_tests.rs`, add tests:
1bc8c44 532
1. A guard match with a `_` fallback (the `compare` example) compiles and runs correctly for at least 3 different input pairs (one hitting each arm), via `run_main`.
1bc8c44 533
2. A guard match with NO `_` fallback traps at runtime when no guard is true — mirror whatever existing test pattern this file already uses for "non-exhaustive match traps" (search for an existing trap/`Unreachable`-related test, e.g. around `todo_traps_at_runtime` or a non-exhaustive-enum-match test, and follow its assertion style for "the wasm call traps/errors").
1bc8c44 534
1bc8c44 535
- [ ] **Step 4: Run the codegen tests**
1bc8c44 536
1bc8c44 537
Run: `cargo test -p plum-wasm-codegen 2>&1 | tail -100`
1bc8c44 538
Expected: PASS, including your new tests.
1bc8c44 539
1bc8c44 540
- [ ] **Step 5: Run the full workspace suite**
1bc8c44 541
1bc8c44 542
Run: `cargo test --workspace 2>&1 | tail -100`
1bc8c44 543
Expected: all PASS.
1bc8c44 544
1bc8c44 545
- [ ] **Step 6: Commit**
1bc8c44 546
1bc8c44 547
```bash
1bc8c44 548
git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs
1bc8c44 549
git commit -m "feat(plum-wasm-codegen): compile guard-clause match arms as if/else-if chains"
1bc8c44 550
```
1bc8c44 551
1bc8c44 552
---
1bc8c44 553
1bc8c44 554
### Task 5: Final verification
1bc8c44 555
1bc8c44 556
**Files:** none (verification only).
1bc8c44 557
1bc8c44 558
- [ ] **Step 1: Full workspace test suite**
1bc8c44 559
1bc8c44 560
Run: `cargo test --workspace 2>&1 | tail -100`
1bc8c44 561
Expected: all PASS.
1bc8c44 562
1bc8c44 563
- [ ] **Step 2: Tree-sitter corpus suite**
1bc8c44 564
1bc8c44 565
Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test 2>&1 | tail -60`
1bc8c44 566
Expected: all PASS.
1bc8c44 567
1bc8c44 568
- [ ] **Step 3: No commit needed** — verification only.