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-nested-method-declarations.md
9b6a86a 1
# Nested Method Declarations Implementation Plan
9b6a86a 2
9b6a86a 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.
9b6a86a 4
9b6a86a 5
**Goal:** Let methods be declared indented directly inside a `type`/`enum` body, implicitly bound to that type as their receiver, per `docs/superpowers/specs/2026-07-24-nested-method-declarations-design.md`.
9b6a86a 6
9b6a86a 7
**Architecture:** This is a `plum-core`-only, parser-level desugaring — no grammar ambiguity risk (fields and `fn`s are already structurally distinguishable at the same position), and no `plum-checker`/`plum-wasm-codegen` changes at all, since a nested method desugars to EXACTLY the same `ast::Fn { type_param: Some(receiver), ... }` shape a top-level `<Receiver>`-annotated method already produces.
9b6a86a 8
9b6a86a 9
**Tech Stack:** tree-sitter (JS grammar), Rust (`plum-core`).
9b6a86a 10
9b6a86a 11
## Global Constraints
9b6a86a 12
9b6a86a 13
- Spec: `docs/superpowers/specs/2026-07-24-nested-method-declarations-design.md`
9b6a86a 14
- Purely additive: today's top-level `methodName<Receiver>(...) = ...` form is completely unchanged and may be freely mixed with nested declarations for the same type.
9b6a86a 15
- `trait` bodies are explicitly out of scope — no `fn` nesting added there.
9b6a86a 16
- A nested method's `type_param` is forced to the enclosing type's name, regardless of whatever its own (redundant, optional) `<Receiver>` annotation parsed to, if one was written.
9b6a86a 17
- `plum-checker`/`plum-wasm-codegen` need NO changes — verify this remains true as you implement; if it turns out not to be true, stop and report BLOCKED rather than silently expanding scope.
9b6a86a 18
- Run `cargo test --workspace` and (from `tooling/tree-sitter-plum/`) `npx --yes tree-sitter-cli test` after every task.
9b6a86a 19
9b6a86a 20
---
9b6a86a 21
9b6a86a 22
### Task 1: Grammar — allow `fn` nested inside `class`/`enum` bodies
9b6a86a 23
9b6a86a 24
**Files:**
9b6a86a 25
- Modify: `tooling/tree-sitter-plum/grammar.js`
9b6a86a 26
- Modify: `tooling/tree-sitter-plum/test/corpus/type.txt`
9b6a86a 27
- Modify: `tooling/tree-sitter-plum/test/corpus/enum.txt`
9b6a86a 28
9b6a86a 29
**Interfaces:**
9b6a86a 30
- Consumes: nothing (bottom of the pipeline). Note: if the enum-discriminant-values plan is implemented before this one, `enum`'s rule will already have gained a `params` field — read the CURRENT `enum`/`class` rules before editing, don't assume this plan's excerpt below is the exact current text.
9b6a86a 31
- Produces: `class` and `enum` each gain an optional trailing `field("methods", optional(repeat($.fn)))` after their existing fields, reusing the `fn` rule unmodified.
9b6a86a 32
9b6a86a 33
- [ ] **Step 1: Read the current `class` and `enum` rules first**
9b6a86a 34
9b6a86a 35
Read them directly in `tooling/tree-sitter-plum/grammar.js` — this plan's excerpts below reflect their state as of the bracket-generics-migration plan's completion, but may have since changed (e.g. if enum discriminant values landed first, `enum` will already have a `params` field).
9b6a86a 36
9b6a86a 37
- [ ] **Step 2: Add `field("methods", optional(repeat($.fn)))` to `class`**
9b6a86a 38
9b6a86a 39
Current shape (verify against the actual file):
9b6a86a 40
9b6a86a 41
```js
9b6a86a 42
    class: ($) =>
9b6a86a 43
      seq(
9b6a86a 44
        "type",
9b6a86a 45
        field("name", $.type_identifier),
9b6a86a 46
        field("generics", optional($.generics)),
9b6a86a 47
        field("implements", optional(seq("(", commaSep1($.type_identifier), ")"))),
9b6a86a 48
        "=",
9b6a86a 49
        $._indent,
9b6a86a 50
        field("fields", optional(repeat(alias($.class_field, $.field)))),
9b6a86a 51
        $._dedent,
9b6a86a 52
      ),
9b6a86a 53
```
9b6a86a 54
9b6a86a 55
Add the new field right before `$._dedent`:
9b6a86a 56
9b6a86a 57
```js
9b6a86a 58
    class: ($) =>
9b6a86a 59
      seq(
9b6a86a 60
        "type",
9b6a86a 61
        field("name", $.type_identifier),
9b6a86a 62
        field("generics", optional($.generics)),
9b6a86a 63
        field("implements", optional(seq("(", commaSep1($.type_identifier), ")"))),
9b6a86a 64
        "=",
9b6a86a 65
        $._indent,
9b6a86a 66
        field("fields", optional(repeat(alias($.class_field, $.field)))),
9b6a86a 67
        field("methods", optional(repeat($.fn))),
9b6a86a 68
        $._dedent,
9b6a86a 69
      ),
9b6a86a 70
```
9b6a86a 71
9b6a86a 72
- [ ] **Step 3: Add the same field to `enum`**
9b6a86a 73
9b6a86a 74
Current shape (verify against the actual file — this may already include a `params` field if the enum-discriminant-values plan landed first):
9b6a86a 75
9b6a86a 76
```js
9b6a86a 77
    enum: ($) =>
9b6a86a 78
      seq(
9b6a86a 79
        "enum",
9b6a86a 80
        field("name", $.type_identifier),
9b6a86a 81
        "=",
9b6a86a 82
        $._indent,
9b6a86a 83
        optional(repeat(alias($.enum_field, $.field))),
9b6a86a 84
        $._dedent,
9b6a86a 85
      ),
9b6a86a 86
```
9b6a86a 87
9b6a86a 88
Add the new field before `$._dedent`, preserving whatever else is already there:
9b6a86a 89
9b6a86a 90
```js
9b6a86a 91
    enum: ($) =>
9b6a86a 92
      seq(
9b6a86a 93
        "enum",
9b6a86a 94
        field("name", $.type_identifier),
9b6a86a 95
        "=",
9b6a86a 96
        $._indent,
9b6a86a 97
        optional(repeat(alias($.enum_field, $.field))),
9b6a86a 98
        field("methods", optional(repeat($.fn))),
9b6a86a 99
        $._dedent,
9b6a86a 100
      ),
9b6a86a 101
```
9b6a86a 102
9b6a86a 103
- [ ] **Step 4: Regenerate the parser**
9b6a86a 104
9b6a86a 105
Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate`
9b6a86a 106
Expected: succeeds with no conflicts. `class_field`/`enum_field` and `fn` start with different tokens after their leading identifier (`:` vs. `<`/`(`), so this should be conflict-free — if tree-sitter reports one anyway, stop and report BLOCKED.
9b6a86a 107
9b6a86a 108
- [ ] **Step 5: Add corpus tests**
9b6a86a 109
9b6a86a 110
Add a new test block to `tooling/tree-sitter-plum/test/corpus/type.txt` (a class with one nested method) and one to `enum.txt` (the `Step`/`toNumber` example from the spec's Goal section, or a smaller equivalent) — match each file's existing header/divider format exactly. Don't hand-guess the expected S-expression tree: run Step 6 first to get the real parser output, then paste that into the corpus files.
9b6a86a 111
9b6a86a 112
- [ ] **Step 6: Run the corpus tests, fix expected trees from real output, iterate to green**
9b6a86a 113
9b6a86a 114
Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test 2>&1 | tail -100`. Use `npx --yes tree-sitter-cli parse -` on the new sources to get ground truth. Iterate until 100% pass, including every pre-existing test (confirm a `class`/`enum` with NO nested methods, and a `trait`, are unaffected).
9b6a86a 115
9b6a86a 116
- [ ] **Step 7: Commit**
9b6a86a 117
9b6a86a 118
```bash
9b6a86a 119
git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/src tooling/tree-sitter-plum/test/corpus/type.txt tooling/tree-sitter-plum/test/corpus/enum.txt
9b6a86a 120
git commit -m "feat(grammar): allow methods nested inside type/enum bodies"
9b6a86a 121
```
9b6a86a 122
9b6a86a 123
---
9b6a86a 124
9b6a86a 125
### Task 2: `plum-core` — lift nested methods into top-level `Item::Fn`s
9b6a86a 126
9b6a86a 127
**Files:**
9b6a86a 128
- Modify: `plum-core/src/parser.rs`
9b6a86a 129
- Modify: `plum-core/tests/parser_test.rs`
9b6a86a 130
9b6a86a 131
**Interfaces:**
9b6a86a 132
- Consumes: the regenerated grammar from Task 1 (`class`/`enum` nodes may now have `"fn"`-kind named children after their fields).
9b6a86a 133
- Produces: each nested `fn` becomes an `Item::Fn` in `Source.items`, immediately after the owning `Item::Class`/`Item::Enum`, in the order written, with `type_param` forced to the enclosing type's name.
9b6a86a 134
9b6a86a 135
- [ ] **Step 1: Read the current `parse_source`, `parse_class`, `parse_enum` first**
9b6a86a 136
9b6a86a 137
Read their current exact bodies in `plum-core/src/parser.rs` — reproduced below as they stood when this plan was written, but re-verify, especially if the enum-discriminant-values plan already changed `parse_enum`'s signature/body:
9b6a86a 138
9b6a86a 139
```rust
9b6a86a 140
    pub fn parse_source(&self, node: Node) -> Source {
9b6a86a 141
        assert_eq!(node.kind(), "source");
9b6a86a 142
        let mut module = None;
9b6a86a 143
        let mut imports = Vec::new();
9b6a86a 144
        let mut items = Vec::new();
9b6a86a 145
        let mut cursor = node.walk();
9b6a86a 146
        for child in node.named_children(&mut cursor) {
9b6a86a 147
            match child.kind() {
9b6a86a 148
                "module" => module = Some(self.parse_module(child)),
9b6a86a 149
                "import" => imports.push(self.parse_import(child)),
9b6a86a 150
                "class" => items.push(Item::Class(self.parse_class(child))),
9b6a86a 151
                "trait" => items.push(Item::Trait(self.parse_trait(child))),
9b6a86a 152
                "enum" => items.push(Item::Enum(self.parse_enum(child))),
9b6a86a 153
                "fn" => items.push(Item::Fn(self.parse_fn(child))),
9b6a86a 154
                "const" => items.push(Item::Const(self.parse_const(child))),
9b6a86a 155
                _ => {}
9b6a86a 156
            }
9b6a86a 157
        }
9b6a86a 158
        Source { module, imports, items }
9b6a86a 159
    }
9b6a86a 160
```
9b6a86a 161
9b6a86a 162
- [ ] **Step 2: Add `collect_nested_fns` and wire it into `parse_source`**
9b6a86a 163
9b6a86a 164
Add a new helper method (place it near `parse_class`/`parse_enum`):
9b6a86a 165
9b6a86a 166
```rust
9b6a86a 167
    /// Collects any `fn` named children nested directly inside a class/enum body and
9b6a86a 168
    /// parses each as an ordinary top-level `Fn`, with `type_param` forced to `owner`
9b6a86a 169
    /// regardless of whatever the nested `fn` itself parsed (a nested method's receiver
9b6a86a 170
    /// is implicit from its enclosing declaration; if it also carries its own explicit,
9b6a86a 171
    /// redundant `<Receiver>` annotation, that's simply overridden, not treated as a
9b6a86a 172
    /// conflict/error).
9b6a86a 173
    fn collect_nested_fns(&self, node: Node, owner: &str) -> Vec<Fn> {
9b6a86a 174
        self.children_of_kind(node, "fn")
9b6a86a 175
            .into_iter()
9b6a86a 176
            .map(|n| {
9b6a86a 177
                let mut f = self.parse_fn(n);
9b6a86a 178
                f.type_param = Some(owner.to_string());
9b6a86a 179
                f
9b6a86a 180
            })
9b6a86a 181
            .collect()
9b6a86a 182
    }
9b6a86a 183
```
9b6a86a 184
9b6a86a 185
Update `parse_source`'s loop so `"class"` and `"enum"` children also push their nested methods immediately after the owning item:
9b6a86a 186
9b6a86a 187
```rust
9b6a86a 188
                "class" => {
9b6a86a 189
                    let c = self.parse_class(child);
9b6a86a 190
                    let nested = self.collect_nested_fns(child, &c.name);
9b6a86a 191
                    items.push(Item::Class(c));
9b6a86a 192
                    items.extend(nested.into_iter().map(Item::Fn));
9b6a86a 193
                }
9b6a86a 194
                "enum" => {
9b6a86a 195
                    let e = self.parse_enum(child);
9b6a86a 196
                    let nested = self.collect_nested_fns(child, &e.name);
9b6a86a 197
                    items.push(Item::Enum(e));
9b6a86a 198
                    items.extend(nested.into_iter().map(Item::Fn));
9b6a86a 199
                }
9b6a86a 200
```
9b6a86a 201
9b6a86a 202
(`"trait"` stays exactly as it is today, untouched — no nesting added there.)
9b6a86a 203
9b6a86a 204
- [ ] **Step 3: Confirm `parse_class`/`parse_enum` themselves need no changes**
9b6a86a 205
9b6a86a 206
`parse_class`'s field-collection (`named.iter().filter(|n| n.kind() == "field")`) and `parse_enum`'s variant-collection (via `self.children_of_kind(node, "field")`) already filter specifically for `"field"`-kind children — a nested `"fn"`-kind child is a different `kind()` and is simply skipped by these existing filters, requiring no change to either function. Verify this by reading their current bodies (Task 1 of this plan didn't touch `parse_class`/`parse_enum`, only `parse_source` and the new helper) — if you find either function DOES need a change (e.g. because it walks ALL named children rather than filtering by kind), report what you found and fix it, noting the deviation from this plan's assumption in your report.
9b6a86a 207
9b6a86a 208
- [ ] **Step 4: Add a parser test**
9b6a86a 209
9b6a86a 210
In `plum-core/tests/parser_test.rs`, parse a `type`/`enum` with one or more nested methods (e.g. the `Step`/`toNumber` example, or a smaller `type`-based equivalent) and assert:
9b6a86a 211
1. `Source.items` contains the owning `Item::Class`/`Item::Enum` immediately followed by one `Item::Fn` per nested method, in the order they were written.
9b6a86a 212
2. Each nested method's `Fn.type_param == Some("<EnclosingTypeName>")`.
9b6a86a 213
3. A type/enum with NO nested methods still parses with no extra `Item::Fn`s (regression coverage for the common/existing case).
9b6a86a 214
9b6a86a 215
- [ ] **Step 5: Run `plum-core`'s tests**
9b6a86a 216
9b6a86a 217
Run: `cargo test -p plum-core 2>&1 | tail -60`
9b6a86a 218
Expected: PASS, including your new tests and every pre-existing one (in particular, re-confirm the `Fn.type_param`/`TraitMethod.returns` regression tests added during the bracket-generics migration's Task 2 still pass — this task touches the same `parse_source`/`Fn` machinery).
9b6a86a 219
9b6a86a 220
- [ ] **Step 6: Commit**
9b6a86a 221
9b6a86a 222
```bash
9b6a86a 223
git add plum-core/src/parser.rs plum-core/tests/parser_test.rs
9b6a86a 224
git commit -m "feat(plum-core): lift nested type/enum methods into top-level Fn items"
9b6a86a 225
```
9b6a86a 226
9b6a86a 227
---
9b6a86a 228
9b6a86a 229
### Task 3: End-to-end verification that nested methods behave identically to top-level ones
9b6a86a 230
9b6a86a 231
**Files:**
9b6a86a 232
- Modify: `plum-checker/tests/checker_tests.rs`
9b6a86a 233
- Modify: `plum-wasm-codegen/tests/codegen_tests.rs`
9b6a86a 234
9b6a86a 235
**Interfaces:**
9b6a86a 236
- Consumes: `Item::Fn` entries produced by Task 2's desugaring.
9b6a86a 237
- Produces: proof (not just assertion) that `plum-checker`/`plum-wasm-codegen` need no changes — a nested method type-checks, dispatches, and runs identically to the same method written in today's top-level `<Receiver>` form.
9b6a86a 238
9b6a86a 239
- [ ] **Step 1: Add a checker test**
9b6a86a 240
9b6a86a 241
In `plum-checker/tests/checker_tests.rs`, add a test that type-checks a small `type`/`enum` with one nested method and confirms it produces the same result (no errors, or the same errors) as an equivalent top-level `<Receiver>`-annotated version — e.g. two near-identical test sources, one nested one not, both compiled through `check_source`, asserting both succeed (or both fail identically, if you also want a negative-case pair).
9b6a86a 242
9b6a86a 243
- [ ] **Step 2: Add a codegen test**
9b6a86a 244
9b6a86a 245
In `plum-wasm-codegen/tests/codegen_tests.rs`, add a test that compiles and runs (`run_main`) a program using a nested method (e.g. the `Step`/`toNumber` example, or a smaller equivalent using a `type` instead of the enum-discriminant feature if that plan hasn't landed yet — a nested method on an ordinary `type`/`enum` is sufficient to prove this feature works standalone) and confirms the expected result.
9b6a86a 246
9b6a86a 247
- [ ] **Step 3: Run both crates' test suites**
9b6a86a 248
9b6a86a 249
Run: `cargo test -p plum-checker 2>&1 | tail -60` and `cargo test -p plum-wasm-codegen 2>&1 | tail -100`
9b6a86a 250
Expected: both PASS, including your new tests.
9b6a86a 251
9b6a86a 252
- [ ] **Step 4: Run the full workspace suite**
9b6a86a 253
9b6a86a 254
Run: `cargo test --workspace 2>&1 | tail -100`
9b6a86a 255
Expected: all PASS.
9b6a86a 256
9b6a86a 257
- [ ] **Step 5: Commit**
9b6a86a 258
9b6a86a 259
```bash
9b6a86a 260
git add plum-checker/tests/checker_tests.rs plum-wasm-codegen/tests/codegen_tests.rs
9b6a86a 261
git commit -m "test: confirm nested type/enum methods behave identically to top-level ones"
9b6a86a 262
```
9b6a86a 263
9b6a86a 264
---
9b6a86a 265
9b6a86a 266
### Task 4: Final verification
9b6a86a 267
9b6a86a 268
**Files:** none (verification only).
9b6a86a 269
9b6a86a 270
- [ ] **Step 1: Full workspace test suite**
9b6a86a 271
9b6a86a 272
Run: `cargo test --workspace 2>&1 | tail -100`
9b6a86a 273
Expected: all PASS.
9b6a86a 274
9b6a86a 275
- [ ] **Step 2: Tree-sitter corpus suite**
9b6a86a 276
9b6a86a 277
Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test 2>&1 | tail -60`
9b6a86a 278
Expected: all PASS.
9b6a86a 279
9b6a86a 280
- [ ] **Step 3: No commit needed** — verification only.