plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


9b6a86aPeter John 2026-07-24T12:31:23+05:30
docs: add implementation plan for nested method declarations
docs/superpowers/plans/2026-07-24-nested-method-declarations.md ADDED
@@ -0,0 +1,280 @@
1
+ # Nested Method Declarations Implementation Plan
2
+
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.
4
+
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`.
6
+
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.
8
+
9
+ **Tech Stack:** tree-sitter (JS grammar), Rust (`plum-core`).
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: `docs/superpowers/specs/2026-07-24-nested-method-declarations-design.md`
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.
15
+ - `trait` bodies are explicitly out of scope — no `fn` nesting added there.
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.
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.
18
+ - Run `cargo test --workspace` and (from `tooling/tree-sitter-plum/`) `npx --yes tree-sitter-cli test` after every task.
19
+
20
+ ---
21
+
22
+ ### Task 1: Grammar — allow `fn` nested inside `class`/`enum` bodies
23
+
24
+ **Files:**
25
+ - Modify: `tooling/tree-sitter-plum/grammar.js`
26
+ - Modify: `tooling/tree-sitter-plum/test/corpus/type.txt`
27
+ - Modify: `tooling/tree-sitter-plum/test/corpus/enum.txt`
28
+
29
+ **Interfaces:**
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.
31
+ - Produces: `class` and `enum` each gain an optional trailing `field("methods", optional(repeat($.fn)))` after their existing fields, reusing the `fn` rule unmodified.
32
+
33
+ - [ ] **Step 1: Read the current `class` and `enum` rules first**
34
+
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).
36
+
37
+ - [ ] **Step 2: Add `field("methods", optional(repeat($.fn)))` to `class`**
38
+
39
+ Current shape (verify against the actual file):
40
+
41
+ ```js
42
+ class: ($) =>
43
+ seq(
44
+ "type",
45
+ field("name", $.type_identifier),
46
+ field("generics", optional($.generics)),
47
+ field("implements", optional(seq("(", commaSep1($.type_identifier), ")"))),
48
+ "=",
49
+ $._indent,
50
+ field("fields", optional(repeat(alias($.class_field, $.field)))),
51
+ $._dedent,
52
+ ),
53
+ ```
54
+
55
+ Add the new field right before `$._dedent`:
56
+
57
+ ```js
58
+ class: ($) =>
59
+ seq(
60
+ "type",
61
+ field("name", $.type_identifier),
62
+ field("generics", optional($.generics)),
63
+ field("implements", optional(seq("(", commaSep1($.type_identifier), ")"))),
64
+ "=",
65
+ $._indent,
66
+ field("fields", optional(repeat(alias($.class_field, $.field)))),
67
+ field("methods", optional(repeat($.fn))),
68
+ $._dedent,
69
+ ),
70
+ ```
71
+
72
+ - [ ] **Step 3: Add the same field to `enum`**
73
+
74
+ Current shape (verify against the actual file — this may already include a `params` field if the enum-discriminant-values plan landed first):
75
+
76
+ ```js
77
+ enum: ($) =>
78
+ seq(
79
+ "enum",
80
+ field("name", $.type_identifier),
81
+ "=",
82
+ $._indent,
83
+ optional(repeat(alias($.enum_field, $.field))),
84
+ $._dedent,
85
+ ),
86
+ ```
87
+
88
+ Add the new field before `$._dedent`, preserving whatever else is already there:
89
+
90
+ ```js
91
+ enum: ($) =>
92
+ seq(
93
+ "enum",
94
+ field("name", $.type_identifier),
95
+ "=",
96
+ $._indent,
97
+ optional(repeat(alias($.enum_field, $.field))),
98
+ field("methods", optional(repeat($.fn))),
99
+ $._dedent,
100
+ ),
101
+ ```
102
+
103
+ - [ ] **Step 4: Regenerate the parser**
104
+
105
+ Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate`
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.
107
+
108
+ - [ ] **Step 5: Add corpus tests**
109
+
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.
111
+
112
+ - [ ] **Step 6: Run the corpus tests, fix expected trees from real output, iterate to green**
113
+
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).
115
+
116
+ - [ ] **Step 7: Commit**
117
+
118
+ ```bash
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
120
+ git commit -m "feat(grammar): allow methods nested inside type/enum bodies"
121
+ ```
122
+
123
+ ---
124
+
125
+ ### Task 2: `plum-core` — lift nested methods into top-level `Item::Fn`s
126
+
127
+ **Files:**
128
+ - Modify: `plum-core/src/parser.rs`
129
+ - Modify: `plum-core/tests/parser_test.rs`
130
+
131
+ **Interfaces:**
132
+ - Consumes: the regenerated grammar from Task 1 (`class`/`enum` nodes may now have `"fn"`-kind named children after their fields).
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.
134
+
135
+ - [ ] **Step 1: Read the current `parse_source`, `parse_class`, `parse_enum` first**
136
+
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:
138
+
139
+ ```rust
140
+ pub fn parse_source(&self, node: Node) -> Source {
141
+ assert_eq!(node.kind(), "source");
142
+ let mut module = None;
143
+ let mut imports = Vec::new();
144
+ let mut items = Vec::new();
145
+ let mut cursor = node.walk();
146
+ for child in node.named_children(&mut cursor) {
147
+ match child.kind() {
148
+ "module" => module = Some(self.parse_module(child)),
149
+ "import" => imports.push(self.parse_import(child)),
150
+ "class" => items.push(Item::Class(self.parse_class(child))),
151
+ "trait" => items.push(Item::Trait(self.parse_trait(child))),
152
+ "enum" => items.push(Item::Enum(self.parse_enum(child))),
153
+ "fn" => items.push(Item::Fn(self.parse_fn(child))),
154
+ "const" => items.push(Item::Const(self.parse_const(child))),
155
+ _ => {}
156
+ }
157
+ }
158
+ Source { module, imports, items }
159
+ }
160
+ ```
161
+
162
+ - [ ] **Step 2: Add `collect_nested_fns` and wire it into `parse_source`**
163
+
164
+ Add a new helper method (place it near `parse_class`/`parse_enum`):
165
+
166
+ ```rust
167
+ /// Collects any `fn` named children nested directly inside a class/enum body and
168
+ /// parses each as an ordinary top-level `Fn`, with `type_param` forced to `owner`
169
+ /// regardless of whatever the nested `fn` itself parsed (a nested method's receiver
170
+ /// is implicit from its enclosing declaration; if it also carries its own explicit,
171
+ /// redundant `<Receiver>` annotation, that's simply overridden, not treated as a
172
+ /// conflict/error).
173
+ fn collect_nested_fns(&self, node: Node, owner: &str) -> Vec<Fn> {
174
+ self.children_of_kind(node, "fn")
175
+ .into_iter()
176
+ .map(|n| {
177
+ let mut f = self.parse_fn(n);
178
+ f.type_param = Some(owner.to_string());
179
+ f
180
+ })
181
+ .collect()
182
+ }
183
+ ```
184
+
185
+ Update `parse_source`'s loop so `"class"` and `"enum"` children also push their nested methods immediately after the owning item:
186
+
187
+ ```rust
188
+ "class" => {
189
+ let c = self.parse_class(child);
190
+ let nested = self.collect_nested_fns(child, &c.name);
191
+ items.push(Item::Class(c));
192
+ items.extend(nested.into_iter().map(Item::Fn));
193
+ }
194
+ "enum" => {
195
+ let e = self.parse_enum(child);
196
+ let nested = self.collect_nested_fns(child, &e.name);
197
+ items.push(Item::Enum(e));
198
+ items.extend(nested.into_iter().map(Item::Fn));
199
+ }
200
+ ```
201
+
202
+ (`"trait"` stays exactly as it is today, untouched — no nesting added there.)
203
+
204
+ - [ ] **Step 3: Confirm `parse_class`/`parse_enum` themselves need no changes**
205
+
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.
207
+
208
+ - [ ] **Step 4: Add a parser test**
209
+
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:
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.
212
+ 2. Each nested method's `Fn.type_param == Some("<EnclosingTypeName>")`.
213
+ 3. A type/enum with NO nested methods still parses with no extra `Item::Fn`s (regression coverage for the common/existing case).
214
+
215
+ - [ ] **Step 5: Run `plum-core`'s tests**
216
+
217
+ Run: `cargo test -p plum-core 2>&1 | tail -60`
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).
219
+
220
+ - [ ] **Step 6: Commit**
221
+
222
+ ```bash
223
+ git add plum-core/src/parser.rs plum-core/tests/parser_test.rs
224
+ git commit -m "feat(plum-core): lift nested type/enum methods into top-level Fn items"
225
+ ```
226
+
227
+ ---
228
+
229
+ ### Task 3: End-to-end verification that nested methods behave identically to top-level ones
230
+
231
+ **Files:**
232
+ - Modify: `plum-checker/tests/checker_tests.rs`
233
+ - Modify: `plum-wasm-codegen/tests/codegen_tests.rs`
234
+
235
+ **Interfaces:**
236
+ - Consumes: `Item::Fn` entries produced by Task 2's desugaring.
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.
238
+
239
+ - [ ] **Step 1: Add a checker test**
240
+
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).
242
+
243
+ - [ ] **Step 2: Add a codegen test**
244
+
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.
246
+
247
+ - [ ] **Step 3: Run both crates' test suites**
248
+
249
+ Run: `cargo test -p plum-checker 2>&1 | tail -60` and `cargo test -p plum-wasm-codegen 2>&1 | tail -100`
250
+ Expected: both PASS, including your new tests.
251
+
252
+ - [ ] **Step 4: Run the full workspace suite**
253
+
254
+ Run: `cargo test --workspace 2>&1 | tail -100`
255
+ Expected: all PASS.
256
+
257
+ - [ ] **Step 5: Commit**
258
+
259
+ ```bash
260
+ git add plum-checker/tests/checker_tests.rs plum-wasm-codegen/tests/codegen_tests.rs
261
+ git commit -m "test: confirm nested type/enum methods behave identically to top-level ones"
262
+ ```
263
+
264
+ ---
265
+
266
+ ### Task 4: Final verification
267
+
268
+ **Files:** none (verification only).
269
+
270
+ - [ ] **Step 1: Full workspace test suite**
271
+
272
+ Run: `cargo test --workspace 2>&1 | tail -100`
273
+ Expected: all PASS.
274
+
275
+ - [ ] **Step 2: Tree-sitter corpus suite**
276
+
277
+ Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test 2>&1 | tail -60`
278
+ Expected: all PASS.
279
+
280
+ - [ ] **Step 3: No commit needed** — verification only.