plum

#treesitter#compiler#wasm

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

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


c664d4fPeter John 2026-07-24T12:22:08+05:30
docs: add design specs for enum discriminant values and nested methods
docs/superpowers/specs/2026-07-24-enum-discriminant-values-design.md ADDED
@@ -0,0 +1,214 @@
1
+ # Enum Discriminant Values — Design Spec
2
+
3
+ ## Goal
4
+
5
+ Let an enum declare one or more shared, uniformly-typed fields that every
6
+ variant supplies a concrete value for, readable via ordinary field access
7
+ (`self.n`) regardless of which variant a given instance actually is:
8
+
9
+ ```
10
+ enum Step(n: Int) =
11
+ | READ_MIN_OCCURANCES(0)
12
+ | READ_MAX_OCCURANCES(1)
13
+ | READ_CHAR_TO_COUNT(2)
14
+ | COUNT_OCCURANCES(3)
15
+ ```
16
+
17
+ Here every `Step` value carries an `Int` field `n`; `READ_MIN_OCCURANCES.n`
18
+ (or `self.n` inside a method) reads `0` without needing a `match`. This is
19
+ different from today's per-variant payload (`Some(a)`/`Ok(a)`), where each
20
+ variant's payload has a different shape and can only be read by destructuring
21
+ via `match`.
22
+
23
+ ## Non-goals
24
+
25
+ - No mixing of enum-level discriminant params with today's per-variant
26
+ generic-type payload (`| Some[T]`) in the same enum — an enum is either
27
+ a "discriminant enum" (this feature) or an ordinary/generic enum
28
+ (today's feature), not both, for v1. If a real use case for combining them
29
+ shows up later, that's a follow-up.
30
+ - No change to how ordinary enums (`Option`, `Result`, `Bool`, `Color`, ...)
31
+ are declared, checked, or compiled — this is purely additive.
32
+ - No automatic derivation of a discriminant from declaration order (that's
33
+ `plum-checker`'s existing internal *tag* numbering, which already exists
34
+ and is unrelated — see Current State). This feature is about a
35
+ user-declared, user-typed, user-readable field, not the internal tag.
36
+
37
+ ## Current state (relevant constraints found during design)
38
+
39
+ This is the key architectural fact the design has to work around:
40
+
41
+ - **Payload-free variants are bare integers today, not heap values.** A
42
+ nullary variant like `None`/`True`/`Red` compiles to a plain `i32.const`
43
+ with **no heap allocation** (`plum-wasm-codegen`'s
44
+ `compile_variant_construction`: `if field_types.is_empty() { return
45
+ I32Const(tag) }`). This is a real optimization the checker/codegen rely on
46
+ elsewhere (pattern-match codegen explicitly branches on "is this variant a
47
+ small int or a heap pointer").
48
+ - **Payload variants are heap-allocated as `[tag: i32][field0][field1]...`**,
49
+ each slot at an 8-byte stride, sized independently per variant (no
50
+ "widest variant" padding).
51
+ - **Field access (`self.field`) is class-only today.** It requires the
52
+ receiver's type to resolve to a `Class` in `ClassEnv`, looks up the
53
+ field's positional index, and loads at `field_idx * 8` from the class's
54
+ heap pointer. Enums have no entry in `ClassEnv` and are never consulted
55
+ by this code path today.
56
+ - **A bare variant reference used as a value** (`None`, `True`, `Red` with
57
+ no explicit constructor call) always compiles to just the constant tag
58
+ integer — there is no existing mechanism for a bare reference to also
59
+ carry an accompanying value.
60
+
61
+ The consequence: a variant that carries a discriminant field **cannot** use
62
+ today's bare-int optimization — if `READ_MIN_OCCURANCES` compiled to a plain
63
+ `i32.const 0` tag, there'd be nowhere to also store the field value `0`
64
+ itself (and no way to tell "tag" from "field value" if they happened to
65
+ collide numerically for a different variant). So a discriminant enum's
66
+ variants must **always heap-allocate**, exactly like today's payload
67
+ variants — this feature reuses that existing scheme rather than inventing a
68
+ new one.
69
+
70
+ ## Design
71
+
72
+ ### Grammar
73
+
74
+ ```js
75
+ enum: ($) =>
76
+ seq(
77
+ "enum",
78
+ field("name", $.type_identifier),
79
+ field("params", optional(seq("(", commaSep1($.enum_param), ")"))),
80
+ "=",
81
+ $._indent,
82
+ optional(repeat(alias($.enum_field, $.field))),
83
+ $._dedent,
84
+ ),
85
+
86
+ enum_param: ($) =>
87
+ seq(field("name", $.var_identifier), ":", field("type", $.type)),
88
+
89
+ enum_field: ($) =>
90
+ seq(
91
+ "|",
92
+ field("name", $.type_identifier),
93
+ field(
94
+ "parameters",
95
+ optional(choice(
96
+ seq("[", commaSep1(choice($.type_identifier, $.generic)), "]"), // existing: generic type payload
97
+ seq("(", commaSep1($.expression), ")"), // new: discriminant value literals
98
+ )),
99
+ ),
100
+ ),
101
+ ```
102
+
103
+ The bracket form (`[...]`, existing) and the paren form (`(...)`, new) are
104
+ distinguished by delimiter, matching the rest of the language's convention
105
+ post-migration: brackets for type-level content, parens for value-level
106
+ content — a discriminant value list is values, not types, so it naturally
107
+ takes parens, the same delimiter a constructor call already uses for its
108
+ arguments.
109
+
110
+ ### AST
111
+
112
+ ```rust
113
+ pub struct Enum {
114
+ pub name: String,
115
+ pub params: Vec<EnumParam>, // new; empty for every enum declared without "(...)"
116
+ pub variants: Vec<EnumVariant>,
117
+ }
118
+
119
+ pub struct EnumParam { // new
120
+ pub name: String,
121
+ pub ty: Type,
122
+ }
123
+
124
+ pub struct EnumVariant {
125
+ pub name: String,
126
+ pub fields: Vec<String>, // existing: generic/type payload names, unchanged meaning
127
+ pub values: Vec<Expr>, // new: discriminant literal values, empty for ordinary variants
128
+ }
129
+ ```
130
+
131
+ A variant has either a non-empty `fields` (today's generic payload) or a
132
+ non-empty `values` (this feature), never both — enforced by the checker
133
+ (see below), not the grammar (the grammar can't tell a bare type name from a
134
+ literal expression apart at the same position without this kind of
135
+ cross-check, so it accepts either shape structurally and the checker
136
+ rejects the mixed case with a clear error).
137
+
138
+ ### Checker
139
+
140
+ - **Declaration-time validation:** for an enum with non-empty `params`,
141
+ every variant must supply exactly `params.len()` values, and each value's
142
+ inferred type must unify with the corresponding param's declared type
143
+ (e.g. the literal `0` against `Int`). A variant supplying the wrong count,
144
+ or a value of the wrong type, is a checker error. An enum with EMPTY
145
+ `params` must have every variant's `values` also empty (this is what "an
146
+ enum is either a discriminant enum or an ordinary enum" means in
147
+ practice) — a variant with non-empty `values` on a param-less enum is
148
+ also a checker error (most likely to happen by a typo like accidentally
149
+ writing `Some(5)` instead of `Some[Int]` for an ordinary generic enum;
150
+ the error should say so plainly).
151
+ - **Field access (`self.n`, `obj.n`):** extend the existing field-access
152
+ check (today: receiver type must resolve to a `Class` in `ClassEnv`) with
153
+ a second lookup path: if the receiver's type resolves to an `Enum` name
154
+ that has non-empty `params` (tracked in a new `EnumParams` map alongside
155
+ the existing `EnumVariants`/`ClassEnv` context maps), resolve the field
156
+ by name against that enum's declared params exactly as a class field
157
+ would be resolved — same "field not found" error shape, just checked
158
+ against `EnumParams` instead of `ClassEnv` when the name isn't a class.
159
+ Field access on an enum with EMPTY params still falls through to today's
160
+ behavior (class-only, or the permissive "unmodeled type" escape hatch)
161
+ — unchanged.
162
+
163
+ ### Codegen
164
+
165
+ - **Construction of a bare discriminant-variant reference**
166
+ (`READ_MIN_OCCURANCES` used as a value, no explicit call): today this
167
+ compiles `Expr::TypeName(n)` to a bare `I32Const(tag)` when the variant
168
+ is payload-free. For a variant belonging to a discriminant enum (non-empty
169
+ declared `values`), codegen instead reuses the EXISTING payload-variant
170
+ construction path (`compile_variant_construction`) directly, using the
171
+ variant's own declared literal `values` (compiled as constants) as the
172
+ "field values" to store — structurally as if the user had written
173
+ `READ_MIN_OCCURANCES(0)` explicitly at every use site, except the `0`
174
+ comes from the variant's declaration, not the call site (there is no call
175
+ site; syntactically it's still a bare reference). This means these
176
+ variants always heap-allocate, matching the Current State constraint
177
+ above — the bare-int optimization is simply not applied to them.
178
+ - **Field access codegen (`self.n`):** mirrors the existing class field-load
179
+ codegen exactly (`I32Load`/`I64Load`/`F64Load` at `(field_idx + 1) * 8`
180
+ from the heap pointer — `+1` because slot 0 is always the tag, matching
181
+ today's payload-variant layout) — the only change is resolving
182
+ `field_idx`/width from the enum's declared params (via the new
183
+ `EnumParams` lookup) instead of `ClassEnv`, when the receiver's static
184
+ type is a discriminant enum rather than a class.
185
+ - **Pattern matching** (`match` against a discriminant-enum value by variant
186
+ name, e.g. `match step \n READ_MIN_OCCURANCES => ...`) is unaffected —
187
+ since these variants are now always heap pointers (never bare ints), the
188
+ existing payload-variant tag-load-and-compare path in match codegen
189
+ applies uniformly, with no need for the "is this a small int or a
190
+ pointer" branch discriminant enums currently force ordinary enums to
191
+ have (every variant of a discriminant enum is a pointer, so that branch
192
+ is simply never taken for this kind of enum).
193
+
194
+ ### Testing strategy
195
+
196
+ - Tree-sitter corpus: a discriminant-enum declaration (`enum Step(n: Int) =
197
+ | READ_MIN_OCCURANCES(0) | ...`), confirming the new `enum_param` and the
198
+ paren-form `enum_field` parameters parse into the expected tree; confirm
199
+ today's bracket-form (`| Some[T]`) and truly nullary (`| Red`) variants
200
+ are unaffected.
201
+ - `plum-checker` tests: correct arity/type-checked discriminant values pass;
202
+ wrong arity, wrong value type, and mixing discriminant values into a
203
+ param-less enum's variant each produce a clear error; `self.n` field
204
+ access on a discriminant-enum method type-checks and resolves to the
205
+ right type; `self.n` on an ordinary enum (no declared params) still
206
+ errors exactly as it does today.
207
+ - `plum-wasm-codegen` tests: a method that reads `self.n` off a
208
+ discriminant-enum value returns the declared literal for each variant
209
+ (covering at least two different variants, to prove the field is
210
+ correctly positioned per-instance, not hardcoded to one variant's
211
+ value); confirm a discriminant-enum value used in `match` (destructuring
212
+ by variant name, not by field) still resolves and runs correctly,
213
+ exercising the "always a pointer, never a bare int" codegen path noted
214
+ above.
docs/superpowers/specs/2026-07-24-nested-method-declarations-design.md ADDED
@@ -0,0 +1,172 @@
1
+ # Nested Method Declarations — Design Spec
2
+
3
+ ## Goal
4
+
5
+ Let methods be declared indented directly inside a `type`/`enum` body,
6
+ implicitly bound to that type as their receiver, instead of always requiring
7
+ a separate top-level declaration with an explicit `<Receiver>` annotation:
8
+
9
+ ```
10
+ enum Step(n: Int) =
11
+ | READ_MIN_OCCURANCES(0)
12
+ | READ_MAX_OCCURANCES(1)
13
+ | READ_CHAR_TO_COUNT(2)
14
+ | COUNT_OCCURANCES(3)
15
+
16
+ toNumber(self) =
17
+ match self
18
+ READ_MIN_OCCURANCES => 0
19
+ READ_MAX_OCCURANCES => 1
20
+ READ_CHAR_TO_COUNT => 2
21
+ COUNT_OCCURANCES => 3
22
+ ```
23
+
24
+ `toNumber(self) = ...` here is exactly equivalent to writing
25
+ `toNumber<Step>(self) = ...` at the top level today — nesting inside the
26
+ enum body is sugar for the receiver annotation, not a new binding mechanism.
27
+
28
+ ## Non-goals
29
+
30
+ - **Purely additive, not a replacement.** Today's top-level
31
+ `methodName<Receiver>(...) = ...` form keeps working unchanged, and can be
32
+ freely mixed with nested declarations for the same type (some methods
33
+ nested, some declared the old way) — this spec only adds a second surface
34
+ syntax for the same underlying `ast::Fn { type_param: Some(receiver), ... }`
35
+ shape; the checker and codegen need no awareness that this feature exists.
36
+ - **`trait` bodies are out of scope.** A trait's body holds method
37
+ *signatures* (`trait_field`, no body) — nesting a full method
38
+ *implementation* inside a trait would be a "default method" feature,
39
+ a materially different and separate concept from what's being asked for
40
+ here (which is about `enum`/`type`, per the motivating example). Not
41
+ addressed by this spec.
42
+ - No change to how the receiver annotation `<Receiver>` behaves when used
43
+ explicitly at the top level — untouched.
44
+
45
+ ## Current state
46
+
47
+ - `class`/`enum` grammar rules (`tooling/tree-sitter-plum/grammar.js`) only
48
+ allow `repeat(alias($.class_field, $.field))` /
49
+ `optional(repeat(alias($.enum_field, $.field)))` inside their body — no
50
+ path for a full `fn` declaration to appear nested inside either.
51
+ - `fn` (top-level today) already supports an optional receiver annotation:
52
+ `field("type", optional(alias($.fn_type, $.type)))`, parsed by
53
+ `plum-core/src/parser.rs`'s `parse_fn` into `ast::Fn.type_param: Option<String>`.
54
+ A method declared today (`toNumber<Step>(self) -> Int = ...`) already
55
+ produces exactly the AST shape this feature's nested form should also
56
+ produce — the only thing missing is a grammar/parser path that arrives at
57
+ that same shape without writing `<Step>` explicitly, by inferring it from
58
+ nesting position instead.
59
+ - `parse_source` (`plum-core/src/parser.rs`) builds `Source.items: Vec<Item>`
60
+ by matching each top-level child's `kind()` (`"class"`, `"trait"`,
61
+ `"enum"`, `"fn"`, `"const"`) to one `Item` each — there is currently no
62
+ concept of one top-level declaration producing MORE than one `Item`.
63
+
64
+ ## Design
65
+
66
+ ### Grammar
67
+
68
+ `class` and `enum` each gain an optional trailing `repeat($.fn)` after their
69
+ existing fields, reusing the `fn` rule completely unmodified (a nested
70
+ method is byte-for-byte the same grammar as a top-level method, just
71
+ appearing at a different position in the tree — including still allowing
72
+ (but not requiring) its own explicit `<Receiver>` annotation, which would be
73
+ redundant but not a parse error, same as any other harmless redundancy the
74
+ grammar doesn't specifically forbid elsewhere):
75
+
76
+ ```js
77
+ class: ($) =>
78
+ seq(
79
+ "type",
80
+ field("name", $.type_identifier),
81
+ field("generics", optional($.generics)),
82
+ field("implements", optional(seq("(", commaSep1($.type_identifier), ")"))),
83
+ "=",
84
+ $._indent,
85
+ field("fields", optional(repeat(alias($.class_field, $.field)))),
86
+ field("methods", optional(repeat($.fn))),
87
+ $._dedent,
88
+ ),
89
+
90
+ enum: ($) =>
91
+ seq(
92
+ "enum",
93
+ field("name", $.type_identifier),
94
+ field("params", optional(seq("(", commaSep1($.enum_param), ")"))),
95
+ "=",
96
+ $._indent,
97
+ optional(repeat(alias($.enum_field, $.field))),
98
+ field("methods", optional(repeat($.fn))),
99
+ $._dedent,
100
+ ),
101
+ ```
102
+
103
+ Fields and methods are distinguished structurally without any new
104
+ lookahead/conflict: a `class_field`/`enum_field` always starts
105
+ `identifier ":" ...` (or `"|" identifier ...` for enum variants), while `fn`
106
+ always starts `identifier ("<" ... ">")? "(" ...` — the token immediately
107
+ after the leading identifier (`:` vs `<`/`(`) already disambiguates them, the
108
+ same way the grammar already disambiguates other same-position alternatives
109
+ elsewhere.
110
+
111
+ ### Parser
112
+
113
+ Rather than changing `parse_class`/`parse_enum`'s existing return types
114
+ (`Class`/`Enum`, unchanged — no other caller/test should need to know this
115
+ feature exists), add one new helper:
116
+
117
+ ```rust
118
+ /// Collects any `fn` named children nested directly inside a class/enum/etc.
119
+ /// body and parses each as an ordinary top-level `Fn`, with `type_param`
120
+ /// forced to `owner` regardless of whatever the nested `fn` itself parsed
121
+ /// (a nested method's receiver is implicit from its enclosing declaration,
122
+ /// not from its own optional `<Receiver>` annotation — which, if written
123
+ /// explicitly and redundantly, is simply overridden, not treated as a
124
+ /// conflict/error).
125
+ fn collect_nested_fns(&self, node: Node, owner: &str) -> Vec<Fn> {
126
+ self.children_of_kind(node, "fn")
127
+ .into_iter()
128
+ .map(|n| {
129
+ let mut f = self.parse_fn(n);
130
+ f.type_param = Some(owner.to_string());
131
+ f
132
+ })
133
+ .collect()
134
+ }
135
+ ```
136
+
137
+ `parse_source` calls this alongside `parse_class`/`parse_enum` for each
138
+ `"class"`/`"enum"` top-level child, pushing the resulting `Fn`s as
139
+ additional `Item::Fn(...)` entries immediately after the owning
140
+ `Item::Class(...)`/`Item::Enum(...)` — i.e. one `class`/`enum` grammar node
141
+ can now expand to MULTIPLE `Item`s in `Source.items`, in declaration order
142
+ (class/enum first, then each of its nested methods in the order written).
143
+ This is the one structural change to `parse_source`'s loop; everything
144
+ downstream of `Source.items` (checker, codegen) already iterates
145
+ `Item::Fn` as a flat list and needs no changes — a nested method is
146
+ indistinguishable from a top-level one by the time it reaches `Item::Fn`.
147
+
148
+ ### Checker / Codegen
149
+
150
+ No changes. By construction, a nested method desugars to exactly the
151
+ `ast::Fn { type_param: Some(receiver), ... }` shape a top-level
152
+ `<Receiver>`-annotated method already produces — every downstream consumer
153
+ (dispatch resolution, monomorphization, codegen) is already correct for
154
+ that shape and has no way to observe which surface syntax produced it.
155
+
156
+ ### Testing strategy
157
+
158
+ - Tree-sitter corpus: an `enum`/`type` with fields followed by one or more
159
+ nested `fn` declarations, confirming the new `fn`-inside-`enum`/`class`
160
+ node shape parses; confirm a `class`/`enum` with NO nested methods
161
+ (today's shape) is unaffected; confirm a `trait` is unaffected (no `fn`
162
+ nesting added there).
163
+ - `plum-core` parser tests: parsing a nested method produces an `Item::Fn`
164
+ with `type_param == Some("EnclosingTypeName")`, appearing in `Source.items`
165
+ immediately after the owning `Item::Class`/`Item::Enum`, in the same
166
+ relative order as multiple nested methods were written.
167
+ - `plum-checker`/`plum-wasm-codegen` tests: a nested method type-checks,
168
+ dispatches, and runs identically to the same method written in today's
169
+ top-level `<Receiver>` form (e.g. compile the `Step`/`toNumber` example
170
+ from this spec's Goal section, or a smaller equivalent, and confirm it
171
+ runs correctly end-to-end) — proving the desugaring is truly
172
+ behavior-identical, not just parse-identical.