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
# Nested Method Declarations — Design Spec
## Goal
Let methods be declared indented directly inside a `type`/`enum` body,
implicitly bound to that type as their receiver, instead of always requiring
a separate top-level declaration with an explicit `<Receiver>` annotation:
```
enum Step(n: Int) =
| READ_MIN_OCCURANCES(0)
| READ_MAX_OCCURANCES(1)
| READ_CHAR_TO_COUNT(2)
| COUNT_OCCURANCES(3)
toNumber(self) =
match self
READ_MIN_OCCURANCES => 0
READ_MAX_OCCURANCES => 1
READ_CHAR_TO_COUNT => 2
COUNT_OCCURANCES => 3
```
`toNumber(self) = ...` here is exactly equivalent to writing
`toNumber<Step>(self) = ...` at the top level today — nesting inside the
enum body is sugar for the receiver annotation, not a new binding mechanism.
## Non-goals
- **Purely additive, not a replacement.** Today's top-level
`methodName<Receiver>(...) = ...` form keeps working unchanged, and can be
freely mixed with nested declarations for the same type (some methods
nested, some declared the old way) — this spec only adds a second surface
syntax for the same underlying `ast::Fn { type_param: Some(receiver), ... }`
shape; the checker and codegen need no awareness that this feature exists.
- **`trait` bodies are out of scope.** A trait's body holds method
*signatures* (`trait_field`, no body) — nesting a full method
*implementation* inside a trait would be a "default method" feature,
a materially different and separate concept from what's being asked for
here (which is about `enum`/`type`, per the motivating example). Not
addressed by this spec.
- No change to how the receiver annotation `<Receiver>` behaves when used
explicitly at the top level — untouched.
## Current state
- `class`/`enum` grammar rules (`tooling/tree-sitter-plum/grammar.js`) only
allow `repeat(alias($.class_field, $.field))` /
`optional(repeat(alias($.enum_field, $.field)))` inside their body — no
path for a full `fn` declaration to appear nested inside either.
- `fn` (top-level today) already supports an optional receiver annotation:
`field("type", optional(alias($.fn_type, $.type)))`, parsed by
`plum-core/src/parser.rs`'s `parse_fn` into `ast::Fn.type_param: Option<String>`.
A method declared today (`toNumber<Step>(self) -> Int = ...`) already
produces exactly the AST shape this feature's nested form should also
produce — the only thing missing is a grammar/parser path that arrives at
that same shape without writing `<Step>` explicitly, by inferring it from
nesting position instead.
- `parse_source` (`plum-core/src/parser.rs`) builds `Source.items: Vec<Item>`
by matching each top-level child's `kind()` (`"class"`, `"trait"`,
`"enum"`, `"fn"`, `"const"`) to one `Item` each — there is currently no
concept of one top-level declaration producing MORE than one `Item`.
## Design
### Grammar
`class` and `enum` each gain an optional trailing `repeat($.fn)` after their
existing fields, reusing the `fn` rule completely unmodified (a nested
method is byte-for-byte the same grammar as a top-level method, just
appearing at a different position in the tree — including still allowing
(but not requiring) its own explicit `<Receiver>` annotation, which would be
redundant but not a parse error, same as any other harmless redundancy the
grammar doesn't specifically forbid elsewhere):
```js
class: ($) =>
seq(
"type",
field("name", $.type_identifier),
field("generics", optional($.generics)),
field("implements", optional(seq("(", commaSep1($.type_identifier), ")"))),
"=",
$._indent,
field("fields", optional(repeat(alias($.class_field, $.field)))),
field("methods", optional(repeat($.fn))),
$._dedent,
),
enum: ($) =>
seq(
"enum",
field("name", $.type_identifier),
field("params", optional(seq("(", commaSep1($.enum_param), ")"))),
"=",
$._indent,
optional(repeat(alias($.enum_field, $.field))),
field("methods", optional(repeat($.fn))),
$._dedent,
),
```
Fields and methods are distinguished structurally without any new
lookahead/conflict: a `class_field`/`enum_field` always starts
`identifier ":" ...` (or `"|" identifier ...` for enum variants), while `fn`
always starts `identifier ("<" ... ">")? "(" ...` — the token immediately
after the leading identifier (`:` vs `<`/`(`) already disambiguates them, the
same way the grammar already disambiguates other same-position alternatives
elsewhere.
### Parser
Rather than changing `parse_class`/`parse_enum`'s existing return types
(`Class`/`Enum`, unchanged — no other caller/test should need to know this
feature exists), add one new helper:
```rust
/// Collects any `fn` named children nested directly inside a class/enum/etc.
/// body and parses each as an ordinary top-level `Fn`, with `type_param`
/// forced to `owner` regardless of whatever the nested `fn` itself parsed
/// (a nested method's receiver is implicit from its enclosing declaration,
/// not from its own optional `<Receiver>` annotation — which, if written
/// explicitly and redundantly, is simply overridden, not treated as a
/// conflict/error).
fn collect_nested_fns(&self, node: Node, owner: &str) -> Vec<Fn> {
self.children_of_kind(node, "fn")
.into_iter()
.map(|n| {
let mut f = self.parse_fn(n);
f.type_param = Some(owner.to_string());
f
})
.collect()
}
```
`parse_source` calls this alongside `parse_class`/`parse_enum` for each
`"class"`/`"enum"` top-level child, pushing the resulting `Fn`s as
additional `Item::Fn(...)` entries immediately after the owning
`Item::Class(...)`/`Item::Enum(...)` — i.e. one `class`/`enum` grammar node
can now expand to MULTIPLE `Item`s in `Source.items`, in declaration order
(class/enum first, then each of its nested methods in the order written).
This is the one structural change to `parse_source`'s loop; everything
downstream of `Source.items` (checker, codegen) already iterates
`Item::Fn` as a flat list and needs no changes — a nested method is
indistinguishable from a top-level one by the time it reaches `Item::Fn`.
### Checker / Codegen
No changes. By construction, a nested method desugars to exactly the
`ast::Fn { type_param: Some(receiver), ... }` shape a top-level
`<Receiver>`-annotated method already produces — every downstream consumer
(dispatch resolution, monomorphization, codegen) is already correct for
that shape and has no way to observe which surface syntax produced it.
### Testing strategy
- Tree-sitter corpus: an `enum`/`type` with fields followed by one or more
nested `fn` declarations, confirming the new `fn`-inside-`enum`/`class`
node shape parses; confirm a `class`/`enum` with NO nested methods
(today's shape) is unaffected; confirm a `trait` is unaffected (no `fn`
nesting added there).
- `plum-core` parser tests: parsing a nested method produces an `Item::Fn`
with `type_param == Some("EnclosingTypeName")`, appearing in `Source.items`
immediately after the owning `Item::Class`/`Item::Enum`, in the same
relative order as multiple nested methods were written.
- `plum-checker`/`plum-wasm-codegen` tests: a nested method type-checks,
dispatches, and runs identically to the same method written in today's
top-level `<Receiver>` form (e.g. compile the `Step`/`toNumber` example
from this spec's Goal section, or a smaller equivalent, and confirm it
runs correctly end-to-end) — proving the desugaring is truly
behavior-identical, not just parse-identical.