plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-24-nested-method-declarations-design.md
| c664d4f | 1 | # Nested Method Declarations — Design Spec |
| c664d4f | 2 | |
| c664d4f | 3 | ## Goal |
| c664d4f | 4 | |
| c664d4f | 5 | Let methods be declared indented directly inside a `type`/`enum` body, |
| c664d4f | 6 | implicitly bound to that type as their receiver, instead of always requiring |
| c664d4f | 7 | a separate top-level declaration with an explicit `<Receiver>` annotation: |
| c664d4f | 8 | |
| c664d4f | 9 | ``` |
| c664d4f | 10 | enum Step(n: Int) = |
| c664d4f | 11 | | READ_MIN_OCCURANCES(0) |
| c664d4f | 12 | | READ_MAX_OCCURANCES(1) |
| c664d4f | 13 | | READ_CHAR_TO_COUNT(2) |
| c664d4f | 14 | | COUNT_OCCURANCES(3) |
| c664d4f | 15 | |
| c664d4f | 16 | toNumber(self) = |
| c664d4f | 17 | match self |
| c664d4f | 18 | READ_MIN_OCCURANCES => 0 |
| c664d4f | 19 | READ_MAX_OCCURANCES => 1 |
| c664d4f | 20 | READ_CHAR_TO_COUNT => 2 |
| c664d4f | 21 | COUNT_OCCURANCES => 3 |
| c664d4f | 22 | ``` |
| c664d4f | 23 | |
| c664d4f | 24 | `toNumber(self) = ...` here is exactly equivalent to writing |
| c664d4f | 25 | `toNumber<Step>(self) = ...` at the top level today — nesting inside the |
| c664d4f | 26 | enum body is sugar for the receiver annotation, not a new binding mechanism. |
| c664d4f | 27 | |
| c664d4f | 28 | ## Non-goals |
| c664d4f | 29 | |
| c664d4f | 30 | - **Purely additive, not a replacement.** Today's top-level |
| c664d4f | 31 | `methodName<Receiver>(...) = ...` form keeps working unchanged, and can be |
| c664d4f | 32 | freely mixed with nested declarations for the same type (some methods |
| c664d4f | 33 | nested, some declared the old way) — this spec only adds a second surface |
| c664d4f | 34 | syntax for the same underlying `ast::Fn { type_param: Some(receiver), ... }` |
| c664d4f | 35 | shape; the checker and codegen need no awareness that this feature exists. |
| c664d4f | 36 | - **`trait` bodies are out of scope.** A trait's body holds method |
| c664d4f | 37 | *signatures* (`trait_field`, no body) — nesting a full method |
| c664d4f | 38 | *implementation* inside a trait would be a "default method" feature, |
| c664d4f | 39 | a materially different and separate concept from what's being asked for |
| c664d4f | 40 | here (which is about `enum`/`type`, per the motivating example). Not |
| c664d4f | 41 | addressed by this spec. |
| c664d4f | 42 | - No change to how the receiver annotation `<Receiver>` behaves when used |
| c664d4f | 43 | explicitly at the top level — untouched. |
| c664d4f | 44 | |
| c664d4f | 45 | ## Current state |
| c664d4f | 46 | |
| c664d4f | 47 | - `class`/`enum` grammar rules (`tooling/tree-sitter-plum/grammar.js`) only |
| c664d4f | 48 | allow `repeat(alias($.class_field, $.field))` / |
| c664d4f | 49 | `optional(repeat(alias($.enum_field, $.field)))` inside their body — no |
| c664d4f | 50 | path for a full `fn` declaration to appear nested inside either. |
| c664d4f | 51 | - `fn` (top-level today) already supports an optional receiver annotation: |
| c664d4f | 52 | `field("type", optional(alias($.fn_type, $.type)))`, parsed by |
| c664d4f | 53 | `plum-core/src/parser.rs`'s `parse_fn` into `ast::Fn.type_param: Option<String>`. |
| c664d4f | 54 | A method declared today (`toNumber<Step>(self) -> Int = ...`) already |
| c664d4f | 55 | produces exactly the AST shape this feature's nested form should also |
| c664d4f | 56 | produce — the only thing missing is a grammar/parser path that arrives at |
| c664d4f | 57 | that same shape without writing `<Step>` explicitly, by inferring it from |
| c664d4f | 58 | nesting position instead. |
| c664d4f | 59 | - `parse_source` (`plum-core/src/parser.rs`) builds `Source.items: Vec<Item>` |
| c664d4f | 60 | by matching each top-level child's `kind()` (`"class"`, `"trait"`, |
| c664d4f | 61 | `"enum"`, `"fn"`, `"const"`) to one `Item` each — there is currently no |
| c664d4f | 62 | concept of one top-level declaration producing MORE than one `Item`. |
| c664d4f | 63 | |
| c664d4f | 64 | ## Design |
| c664d4f | 65 | |
| c664d4f | 66 | ### Grammar |
| c664d4f | 67 | |
| c664d4f | 68 | `class` and `enum` each gain an optional trailing `repeat($.fn)` after their |
| c664d4f | 69 | existing fields, reusing the `fn` rule completely unmodified (a nested |
| c664d4f | 70 | method is byte-for-byte the same grammar as a top-level method, just |
| c664d4f | 71 | appearing at a different position in the tree — including still allowing |
| c664d4f | 72 | (but not requiring) its own explicit `<Receiver>` annotation, which would be |
| c664d4f | 73 | redundant but not a parse error, same as any other harmless redundancy the |
| c664d4f | 74 | grammar doesn't specifically forbid elsewhere): |
| c664d4f | 75 | |
| c664d4f | 76 | ```js |
| c664d4f | 77 | class: ($) => |
| c664d4f | 78 | seq( |
| c664d4f | 79 | "type", |
| c664d4f | 80 | field("name", $.type_identifier), |
| c664d4f | 81 | field("generics", optional($.generics)), |
| c664d4f | 82 | field("implements", optional(seq("(", commaSep1($.type_identifier), ")"))), |
| c664d4f | 83 | "=", |
| c664d4f | 84 | $._indent, |
| c664d4f | 85 | field("fields", optional(repeat(alias($.class_field, $.field)))), |
| c664d4f | 86 | field("methods", optional(repeat($.fn))), |
| c664d4f | 87 | $._dedent, |
| c664d4f | 88 | ), |
| c664d4f | 89 | |
| c664d4f | 90 | enum: ($) => |
| c664d4f | 91 | seq( |
| c664d4f | 92 | "enum", |
| c664d4f | 93 | field("name", $.type_identifier), |
| c664d4f | 94 | field("params", optional(seq("(", commaSep1($.enum_param), ")"))), |
| c664d4f | 95 | "=", |
| c664d4f | 96 | $._indent, |
| c664d4f | 97 | optional(repeat(alias($.enum_field, $.field))), |
| c664d4f | 98 | field("methods", optional(repeat($.fn))), |
| c664d4f | 99 | $._dedent, |
| c664d4f | 100 | ), |
| c664d4f | 101 | ``` |
| c664d4f | 102 | |
| c664d4f | 103 | Fields and methods are distinguished structurally without any new |
| c664d4f | 104 | lookahead/conflict: a `class_field`/`enum_field` always starts |
| c664d4f | 105 | `identifier ":" ...` (or `"|" identifier ...` for enum variants), while `fn` |
| c664d4f | 106 | always starts `identifier ("<" ... ">")? "(" ...` — the token immediately |
| c664d4f | 107 | after the leading identifier (`:` vs `<`/`(`) already disambiguates them, the |
| c664d4f | 108 | same way the grammar already disambiguates other same-position alternatives |
| c664d4f | 109 | elsewhere. |
| c664d4f | 110 | |
| c664d4f | 111 | ### Parser |
| c664d4f | 112 | |
| c664d4f | 113 | Rather than changing `parse_class`/`parse_enum`'s existing return types |
| c664d4f | 114 | (`Class`/`Enum`, unchanged — no other caller/test should need to know this |
| c664d4f | 115 | feature exists), add one new helper: |
| c664d4f | 116 | |
| c664d4f | 117 | ```rust |
| c664d4f | 118 | /// Collects any `fn` named children nested directly inside a class/enum/etc. |
| c664d4f | 119 | /// body and parses each as an ordinary top-level `Fn`, with `type_param` |
| c664d4f | 120 | /// forced to `owner` regardless of whatever the nested `fn` itself parsed |
| c664d4f | 121 | /// (a nested method's receiver is implicit from its enclosing declaration, |
| c664d4f | 122 | /// not from its own optional `<Receiver>` annotation — which, if written |
| c664d4f | 123 | /// explicitly and redundantly, is simply overridden, not treated as a |
| c664d4f | 124 | /// conflict/error). |
| c664d4f | 125 | fn collect_nested_fns(&self, node: Node, owner: &str) -> Vec<Fn> { |
| c664d4f | 126 | self.children_of_kind(node, "fn") |
| c664d4f | 127 | .into_iter() |
| c664d4f | 128 | .map(|n| { |
| c664d4f | 129 | let mut f = self.parse_fn(n); |
| c664d4f | 130 | f.type_param = Some(owner.to_string()); |
| c664d4f | 131 | f |
| c664d4f | 132 | }) |
| c664d4f | 133 | .collect() |
| c664d4f | 134 | } |
| c664d4f | 135 | ``` |
| c664d4f | 136 | |
| c664d4f | 137 | `parse_source` calls this alongside `parse_class`/`parse_enum` for each |
| c664d4f | 138 | `"class"`/`"enum"` top-level child, pushing the resulting `Fn`s as |
| c664d4f | 139 | additional `Item::Fn(...)` entries immediately after the owning |
| c664d4f | 140 | `Item::Class(...)`/`Item::Enum(...)` — i.e. one `class`/`enum` grammar node |
| c664d4f | 141 | can now expand to MULTIPLE `Item`s in `Source.items`, in declaration order |
| c664d4f | 142 | (class/enum first, then each of its nested methods in the order written). |
| c664d4f | 143 | This is the one structural change to `parse_source`'s loop; everything |
| c664d4f | 144 | downstream of `Source.items` (checker, codegen) already iterates |
| c664d4f | 145 | `Item::Fn` as a flat list and needs no changes — a nested method is |
| c664d4f | 146 | indistinguishable from a top-level one by the time it reaches `Item::Fn`. |
| c664d4f | 147 | |
| c664d4f | 148 | ### Checker / Codegen |
| c664d4f | 149 | |
| c664d4f | 150 | No changes. By construction, a nested method desugars to exactly the |
| c664d4f | 151 | `ast::Fn { type_param: Some(receiver), ... }` shape a top-level |
| c664d4f | 152 | `<Receiver>`-annotated method already produces — every downstream consumer |
| c664d4f | 153 | (dispatch resolution, monomorphization, codegen) is already correct for |
| c664d4f | 154 | that shape and has no way to observe which surface syntax produced it. |
| c664d4f | 155 | |
| c664d4f | 156 | ### Testing strategy |
| c664d4f | 157 | |
| c664d4f | 158 | - Tree-sitter corpus: an `enum`/`type` with fields followed by one or more |
| c664d4f | 159 | nested `fn` declarations, confirming the new `fn`-inside-`enum`/`class` |
| c664d4f | 160 | node shape parses; confirm a `class`/`enum` with NO nested methods |
| c664d4f | 161 | (today's shape) is unaffected; confirm a `trait` is unaffected (no `fn` |
| c664d4f | 162 | nesting added there). |
| c664d4f | 163 | - `plum-core` parser tests: parsing a nested method produces an `Item::Fn` |
| c664d4f | 164 | with `type_param == Some("EnclosingTypeName")`, appearing in `Source.items` |
| c664d4f | 165 | immediately after the owning `Item::Class`/`Item::Enum`, in the same |
| c664d4f | 166 | relative order as multiple nested methods were written. |
| c664d4f | 167 | - `plum-checker`/`plum-wasm-codegen` tests: a nested method type-checks, |
| c664d4f | 168 | dispatches, and runs identically to the same method written in today's |
| c664d4f | 169 | top-level `<Receiver>` form (e.g. compile the `Step`/`toNumber` example |
| c664d4f | 170 | from this spec's Goal section, or a smaller equivalent, and confirm it |
| c664d4f | 171 | runs correctly end-to-end) — proving the desugaring is truly |
| c664d4f | 172 | behavior-identical, not just parse-identical. |