plum

#treesitter#compiler#wasm

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

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


019d1f0 — Peter John 2026-11-01T18:19:00+05:30
docs(plans): union enums Phase B machine plan
Files changed (1) hide show
  1. docs/plans/2026-11-01-union-enums.md +239 -0
docs/plans/2026-11-01-union-enums.md ADDED
@@ -0,0 +1,239 @@
1
+ # Enums as unions of member types (Phase B)
2
+
3
+ **Review plan:** docs/plans/2026-11-01-struct-value-types.review.md
4
+ **Goal:** `enum Shape = Circle | Rectangle`. Members are types in scope (structs, records, `Float`, `Str`, `List[Json]`, ...) or bare tags. A member converts to its enum implicitly, a bare member name in a pattern narrows the subject, and methods every member shares auto-dispatch.
5
+ **Architecture:** lower to today's enums.
6
+ 1. A loader pass (`plum_core::unions::resolveMembers`) turns each member into a hidden positional variant `Shape__Circle(Circle)`.
7
+ 2. The checker accepts the union forms and records a `UnionPlan` (expression, pattern and case addresses) on the program it checks.
8
+ 3. `plum_core::union_lower::applyPlan` rewrites that program in place into plain variant constructions, nested patterns, renamed narrowed bindings and `match` dispatches. The checker then checks the result again and codegen compiles it. This is the same path `for`-loop lowering uses (`TypedInfo::lowered`).
9
+
10
+ Codegen is unchanged.
11
+ **Tech stack:** Rust workspace, tree-sitter.
12
+ **Spec:** docs/plans/2026-11-01-struct-value-types.review.md (Design + "Changed since revision 3" + "More design", Phase B steps 6-13)
13
+ **Detail:** contracts
14
+ **Target-min:** 40
15
+
16
+ ## Global constraints
17
+ - Syntax:
18
+ - one line: `enum Name[G] = A | B[X] | C`, with no body;
19
+ - multi-line: `enum Name[G] =` NEWLINE ` | A` / ` | B[X]` ... followed by optional consts and methods.
20
+
21
+ Ruling: only the multi-line form takes a body. Discriminant enums (`enum Step(n: Int) = | ReadMin(10)`) are unchanged.
22
+ - **Member resolution:** a payload-less, value-less variant name is a type member if it names an item in the merged source (any enum/struct/record/primitive) or one of `Int`, `Float`, `Byte`. Otherwise it is a tag. Generic args (`List[Json]`) make it a member, or else a resolve error: `unknown member type List`.
23
+ - Hidden variant name: `<Enum>__<Member base name>`, e.g. `Shape__Circle`, `Json__List`. Its field is the member type, positional, with no field name.
24
+ - Errors, verbatim:
25
+ - `enum Json has two List members; a pattern 'List' would be ambiguous`
26
+ - `inline variant payloads are removed: declare record Circle(r: Float) and list Circle in enum Shape` (raised for any non-discriminant variant with a payload)
27
+ - `enum User has a single variant named User — write record User(...)`
28
+ - `s is a copy of the Circle inside Shape; assign it to a local to change it`
29
+ - `Shape has no method 'area': member Red is a tag`
30
+ - `Shape has no method 'area': member Square doesn't define it`
31
+ - `Shape.area: members disagree: Circle.area is <t1>, Square.area is <t2>`
32
+ - `cannot join Circle and Oval: no single enum has both members` (for an if/match with branches of different member types)
33
+ - A type may be a member of several enums. Narrowing applies only when the subject is a local, a param or `self`. Auto-dispatch covers method calls and `"{e}"` interpolation (via `toStr`), not operators (ruling: operators on enums stay as they are today).
34
+ - Regenerate tree-sitter only with `--disable-optimizations`. Never run `plum format` on repo files. Read node fields with `child_by_field_name`. `touch` edited `.rs` files before trusting a build. Use multi-letter type names in tests (a single uppercase letter is a generic).
35
+ - Tests: `cargo test -p <crate>`; `cd plum-tooling/tree-sitter-plum && tree-sitter test`; `cargo build --release -p plum-cli && ./target/release/plum test plum-std plum-examples`.
36
+
37
+ ## Main diagram
38
+ ```mermaid
39
+ flowchart LR
40
+ P["parser: EnumVariant.member candidate"] --> R["unions::resolveMembers (loader)"]
41
+ R --> C2["checker pass: unions rules + recordWrap/Pattern/Narrow/Dispatch"]
42
+ C2 --> AP["union_lower::applyPlan (in place)"]
43
+ AP --> C3["checker re-check"]
44
+ C3 --> CG["codegen (unchanged)"]
45
+ ```
46
+
47
+ ## File structure
48
+ - grammar.js + generated src, corpus `union_enum.txt`, `parser.rs`, `ast.rs`: syntax and the `member` field.
49
+ - `plum-core/src/unions.rs`: resolution, hidden names, the UnionPlan recorder.
50
+ - `plum-core/src/union_lower.rs`: applyPlan.
51
+ - `plum-checker/src/unions.rs` plus hooks in `plum-checker/src/lib.rs`: typing rules.
52
+ - `plum-examples/unions.plum`: end-to-end tests.
53
+ - Migration across std, examples, tests and README.
54
+
55
+ ## Task graph
56
+ ```mermaid
57
+ flowchart LR
58
+ T1[grammar + parser]
59
+ T2[resolve + plan recorder]
60
+ T3[checker coercions]
61
+ T4[checker patterns + narrowing]
62
+ T5[checker auto-dispatch]
63
+ T6[applyPlan rewrite]
64
+ T7[pipeline wiring + e2e]
65
+ T8[migration + legacy errors]
66
+ T9[Json + README]
67
+ %% taskgraph
68
+ T1 --> T2
69
+ T2 --> T3
70
+ T3 --> T4
71
+ T4 --> T5
72
+ T2 --> T6
73
+ T5 --> T7
74
+ T6 --> T7
75
+ T7 --> T8
76
+ T8 --> T9
77
+ ```
78
+
79
+ ```mermaid
80
+ %% schedule
81
+ gantt
82
+ title Predicted schedule (mm:ss)
83
+ dateFormat X
84
+ axisFormat %M:%S
85
+ section lane 1
86
+ T1 grammar + parser :t1, 0, 173s
87
+ T2 resolve + plan recorder :t2, 173, 140s
88
+ T6 applyPlan rewrite :t6, 313, 123s
89
+ T5 checker auto-dispatch :t5, 541, 107s
90
+ T7 pipeline wiring + e2e :t7, 648, 179s
91
+ T8 migration + legacy errors :t8, 827, 202s
92
+ T9 Json + README :t9, 1029, 171s
93
+ section lane 2
94
+ T3 checker coercions :t3, 313, 113s
95
+ T4 checker patterns + narrowing :t4, 426, 115s
96
+ ```
97
+
98
+ critical path: T1 → T2 → T3 → T4 → T5 → T7 → T8 → T9 (20.0 min) · peak width 2 · predicted build 20.0 min · predicted total ≈ 23.5 min (target ✓)
99
+ Ruling: sequential - T3/T4/T5 all hook `plum-checker/src/lib.rs` (the same coercion/pattern/call sites), and T8/T9 need working unions to keep the suite green - cost: a long critical path, accepted.
100
+
101
+ ## Predicted schedule
102
+
103
+ ## Syntax
104
+
105
+ ### T1 grammar + parser · Complexity 4 · Est. context 45k
106
+ **Owns:** plum-tooling/tree-sitter-plum/grammar.js, plum-tooling/tree-sitter-plum/src/parser.c, plum-tooling/tree-sitter-plum/src/grammar.json, plum-tooling/tree-sitter-plum/src/node-types.json, plum-tooling/tree-sitter-plum/test/corpus/union_enum.txt, plum-tooling/tree-sitter-plum/queries/plum/format.scm, plum-core/src/ast.rs, plum-core/src/parser.rs, plum-core/tests/parser_test.rs, plum-core/tests/formatter_test.rs
107
+ **Components:** 2
108
+ **LOC owned:** 180
109
+ **Tests:** slow
110
+ **Model:** sonnet
111
+ **Est. time:** 4 min
112
+ **Produces:** `ast::EnumVariant.member: Option<ast::Type>`. The parser sets it to `Some(Type{name, generics})` for every variant with no payload and no values, and leaves it `None` otherwise. It also accepts generic args on a variant name: `| Node[T]`, `| List[Json]`.
113
+ e.g. parse("enum Shape = Circle | Rectangle") -> Enum{name:"Shape", variants:[{name:"Circle", member:Some(Circle), fields:[]}, {name:"Rectangle", member:Some(Rectangle)}]}
114
+ e.g. parse("enum Tree[T] =\n | Leaf\n | Node[T]\n") -> variants [{name:"Leaf", member:Some(Leaf)}, {name:"Node", member:Some(Node[T])}]
115
+ **Review Focus:** `one_line_then_fun`: `enum Sh = Ci | Re\nfun f() -> Int = 1` gives 2 items with no ERROR; `one_line_generic_member`: `enum Js = JsNull | List[Js] | Map[Str, Js]` has member generics [Js] and [Str, Js]; `discriminant_unchanged`: `enum Step(n: Int) =\n | ReadMin(10)` has values [10] and member None; `inline_payload_still_parses`: `| Circle(r: Float)` parses (T8 rejects it later)
116
+ **Test code (contracts):** corpus `union_enum.txt`: one-line; one-line with generics; multi-line members plus a method body; one-line followed by `fun`; a closure containing `|` in a const after an enum. Parser tests for the 4 Review Focus cases plus both e.g. lines. format.scm: one-line enum stays one line, with ` | ` between members (formatter test `formatOneLineEnum`, idempotent).
117
+ **Steps:** grammar, regenerate, corpus, parser plus the `member` field, format rule, tests.
118
+
119
+ ## Core
120
+
121
+ ### T2 resolve + plan recorder · Complexity 4 · Est. context 40k
122
+ **Owns:** plum-core/src/unions.rs, plum-core/src/lib.rs, plum-core/src/loader.rs, plum-core/tests/unions_test.rs
123
+ **Components:** 2
124
+ **LOC owned:** 200
125
+ **Tests:** fast
126
+ **Model:** sonnet
127
+ **Est. time:** 4 min
128
+ **Consumes:** `ast::EnumVariant.member: Option<ast::Type>` (T1)
129
+ **Produces:** `unions::hiddenVariant(enumName: &str, member: &str) -> String` · `unions::resolveMembers(source: &mut Source) -> Vec<String>` · `unions::UnionPlan { wraps: HashMap<usize, String>, patterns: HashMap<usize, String>, narrowings: HashMap<usize, (String, String, String)>, dispatches: HashMap<usize, Vec<String>> }` · `unions::beginPlan()` · `unions::recordWrap(exprAddr: usize, variant: String)` · `unions::recordPattern(patAddr: usize, variant: String)` · `unions::recordNarrow(caseAddr: usize, subject: String, variant: String)` · `unions::recordDispatch(callAddr: usize, variants: Vec<String>)` · `unions::takePlan() -> UnionPlan` · `unions::memberOf(source: &Source, enumName: &str, member: &str) -> Option<String>`
130
+ e.g. hiddenVariant("Shape", "Circle") -> "Shape__Circle"
131
+ e.g. resolveMembers on `struct Circle(r: Float)` + `enum Shape = Circle | Red` -> Shape.variants == [{name:"Shape__Circle", fields:[Circle], member:Some(Circle)}, {name:"Red", fields:[], member:None}], errors []
132
+ e.g. recordNarrow(0x10, "s", "Shape__Circle") then takePlan().narrowings[0x10] == ("s", "Shape__Circle", "s__n1") (the fresh name is `<subject>__n<k>`, with k counting up within one plan)
133
+ **Review Focus:** `dup_base_name`: `enum Js = List[Int] | List[Str]` gives the duplicate error verbatim; `member_in_two_enums`: Circle in Shape and Round gives the hidden variants Shape__Circle and Round__Circle; `idempotent`: running resolveMembers twice doesn't double-mangle
134
+ **Test code (contracts):** unions_test.rs: the e.g. lines, the 3 Review Focus cases, `memberOf(src, "Shape", "Circle") == Some("Shape__Circle")`, and `memberOf(src, "Shape", "Oval") == None`. loader.rs calls resolveMembers after merging and before forms; a non-empty error list becomes the loader's Err, one line each. Export the modules from lib.rs.
135
+ **Notes:** the plan recorder is a thread_local, like `forms::begin`. The three legacy errors (inline payload, single self-variant) are written here as `unions::legacyErrors(source: &Source) -> Vec<String>` but NOT called (T8 turns them on).
136
+
137
+ ### T6 applyPlan rewrite · Complexity 6 · Est. context 50k
138
+ **Owns:** plum-core/src/union_lower.rs, plum-core/tests/union_lower_test.rs
139
+ **Components:** 1
140
+ **LOC owned:** 260
141
+ **Tests:** fast
142
+ **Model:** sonnet
143
+ **Est. time:** 5 min
144
+ **Consumes:** `unions::UnionPlan` (T2)
145
+ **Produces:** `union_lower::applyPlan(source: &mut Source, plan: &UnionPlan)`. It works in place and post-order (children before parents), so every recorded address is still valid when it's visited.
146
+ e.g. a wrap on `Circle(r: 2.0)` with "Shape__Circle" -> the expr becomes the AST the parser builds for `Shape__Circle(Circle(r: 2.0))`
147
+ e.g. a pattern on `Circle(r)` with "Shape__Circle" -> `Shape__Circle(Circle(r))`; a pattern on bare `Circle` -> `Shape__Circle(_)`
148
+ e.g. a narrowing on case `Circle => s.r` with ("s","Shape__Circle","s__n1") -> case `Shape__Circle(s__n1) => s__n1.r` (rename in the guard and body only, not in shadowed inner scopes)
149
+ e.g. a dispatch on `s.area()` with ["Shape__Circle","Shape__Square"] -> `match s` / `Shape__Circle(d__1) => d__1.area()` / `Shape__Square(d__2) => d__2.area()`
150
+ **Review Focus:** `narrow_closure_capture`: a closure inside the arm that captures `s` is renamed too; `narrow_self`: subject `self` is renamed to `self__n1` in the arm; `narrow_in_pattern_if`: a Bind `Circle := s` in an `if` renames in the then-block only; `dispatch_in_interpolation`: an interpolated `{s}` with a dispatch on it becomes `{match s ... => d.toStr()}`; `nested_wrap_inside_dispatch_arg`: both rewrites apply
151
+ **Test code (contracts):** union_lower_test.rs builds a plan by hand from addresses taken out of a parsed Source (a helper finds an expr by its source text). Cover each e.g. line and each Review Focus case, and assert on the pretty-printed or matched AST shapes.
152
+
153
+ ## Checker
154
+
155
+ ### T3 checker coercions · Complexity 6 · Est. context 55k
156
+ **Owns:** plum-checker/src/unions.rs, plum-checker/src/lib.rs, plum-checker/tests/unions_coerce_test.rs
157
+ **Components:** 1
158
+ **LOC owned:** 180
159
+ **Tests:** fast
160
+ **Model:** sonnet
161
+ **Est. time:** 5 min
162
+ **Consumes:** `unions::recordWrap(exprAddr: usize, variant: String)` (T2) · `unions::memberOf(source: &Source, enumName: &str, member: &str) -> Option<String>` (T2) · `unions::resolveMembers(source: &mut Source) -> Vec<String>` (T2)
163
+ **Review Focus:** `literal_into_json`: `j: Json = 3.0` records a wrap with Json__Float; `wrap_at_arg_return_push`: a Shape param, a `-> Shape` return and `List[Shape].push(Circle(..))` each record one wrap; `join_if_branches`: `x := if c then Circle(..) else Square(..)` has type Shape plus 2 wraps; `join_ambiguous`: with Circle and Oval in no common enum, the join error appears verbatim; `no_wrap_same_type`: `c: Circle = Circle(..)` records none
164
+ **Test code (contracts):** unions_coerce_test.rs parses, runs resolveMembers, checks, then inspects `takePlan().wraps` (count and variant names) for each Review Focus case, plus `Circle(..)` alone having type Circle (not Shape). The hooks sit next to the 3 existing `recordErrCoercion` sites and the if/match branch unification.
165
+
166
+ ### T4 checker patterns + narrowing · Complexity 6 · Est. context 55k
167
+ **Owns:** plum-checker/src/unions.rs, plum-checker/src/lib.rs, plum-checker/tests/unions_pattern_test.rs
168
+ **Components:** 1
169
+ **LOC owned:** 200
170
+ **Tests:** fast
171
+ **Model:** sonnet
172
+ **Est. time:** 5 min
173
+ **Consumes:** `unions::recordPattern(patAddr: usize, variant: String)` (T2) · `unions::recordNarrow(caseAddr: usize, subject: String, variant: String)` (T2)
174
+ **Review Focus:** `destructure_member`: `match s` / `Circle(r) => r` types r as Float; `narrow_match`: `Rectangle => s.w` types s as Rectangle in the arm only (after the match, s is Shape); `narrow_struct_write_error`: `Circle => s.r = 1.0` gives the copy error verbatim; `narrow_record_write_ok`: the same with a record member gives no error; `exhaustive_members`: arms Circle and Rectangle cover Shape, and missing one reports the existing non-exhaustive error naming the member; `expr_subject_no_narrow`: `match f()` / `Circle => 1` records a pattern, not a narrowing; `pattern_if`: `if Circle := s` narrows in the then-block
175
+ **Test code (contracts):** unions_pattern_test.rs covers the Review Focus cases and asserts errors verbatim and `takePlan()` contents.
176
+
177
+ ### T5 checker auto-dispatch · Complexity 5 · Est. context 50k
178
+ **Owns:** plum-checker/src/unions.rs, plum-checker/src/lib.rs, plum-checker/tests/unions_dispatch_test.rs
179
+ **Components:** 1
180
+ **LOC owned:** 140
181
+ **Tests:** fast
182
+ **Model:** sonnet
183
+ **Est. time:** 4 min
184
+ **Consumes:** `unions::recordDispatch(callAddr: usize, variants: Vec<String>)` (T2)
185
+ **Review Focus:** `dispatch_ok`: `s.area()` has type Float and dispatches to [Shape__Circle, Shape__Rectangle]; `own_method_wins`: an enum body method `area` gives no dispatch; `tag_member_error`, `missing_method_error`, `signature_mismatch_error`: all verbatim; `interpolation_toStr`: `"{s}"` dispatches toStr when every member has toStr; `generic_member_dispatch`: `j.toStr()` on Json with List[Json]/Map members resolves through the instantiated method types
186
+ **Test code (contracts):** unions_dispatch_test.rs covers each Review Focus case.
187
+
188
+ ## Integration
189
+
190
+ ### T7 pipeline wiring + e2e · Complexity 5 · Est. context 50k
191
+ **Owns:** plum-checker/src/lib.rs, plum-examples/unions.plum, plum-wasm-codegen/tests/unions_e2e_test.rs
192
+ **Components:** 2
193
+ **LOC owned:** 220
194
+ **Tests:** slow
195
+ **Model:** sonnet
196
+ **Est. time:** 5 min
197
+ **Consumes:** `union_lower::applyPlan(source: &mut Source, plan: &UnionPlan)` (T6) · `unions::takePlan() -> UnionPlan` (T2) · `unions::beginPlan()` (T2)
198
+ **Review Focus:** `struct_member_copy`: a struct taken out of a Shape by destructure, then modified, leaves the Shape's copy unchanged; `list_of_shapes`: `List[Shape]` holding Circle and Rectangle sums `area()` correctly; `tree_generic`: `enum Tree[T] = Leaf | Node[T]` recursive sum; `json_like_primitives`: `enum Jv = JvNull | Bool | Float | Str | List[Jv]` builds and prints by matching; `two_enums_one_type`: Circle passes as both a Shape and a Round
199
+ **Test code (contracts):** in checkSourceFull and checkSourceOnce's caller: beginPlan, then the pass whose program codegen will compile; if takePlan() is non-empty, run applyPlan on that owned program (the forms-lowered copy, or a clone for a non-form source taken BEFORE that pass), check it again, and set `info.lowered`. `plum-examples/unions.plum` holds `test` blocks for each Review Focus case plus `if Circle := s` and auto-dispatch. unions_e2e_test.rs compiles and runs 3 of them through codegen.
200
+
201
+ ## Migration
202
+
203
+ ### T8 migration + legacy errors · Complexity 4 · Est. context 60k
204
+ **Owns:** plum-core/src/loader.rs, plum-std/Array.plum, plum-examples/types.plum, plum-examples/generics.plum, plum-examples/match.plum, plum-examples/functions.plum, plum-examples/oop_visitor.plum, plum-examples/values.plum, plum-examples/pattern_if.plum, plum-examples/constants.plum, plum-examples/chains.plum, plum-examples/forms.plum, plum-examples/methods.plum, plum-examples/operators.plum, plum-examples/optionals.plum, plum-examples/safe_navigation.plum, plum-examples/closures.plum, plum-checker/tests/checker_tests.rs, plum-checker/tests/error_union_tests.rs, plum-wasm-codegen/tests/codegen_tests.rs, plum-core/tests/parser_test.rs, plum-core/tests/loader_test.rs, plum-cli/tests/assert_once_test.rs, plum-lsp/src/main.rs, plum-tooling/tree-sitter-plum/test/corpus/enum.txt, plum-tooling/tree-sitter-plum/test/corpus/match.txt, plum-tooling/tree-sitter-plum/test/corpus/type.txt
205
+ **Components:** 2
206
+ **LOC owned:** 400
207
+ **Tests:** slow
208
+ **Model:** sonnet
209
+ **Est. time:** 6 min
210
+ **Consumes:** `unions::legacyErrors(source: &Source) -> Vec<String>` (T2)
211
+ **Review Focus:** `same_test_count`: record the count before; afterwards `plum test plum-std plum-examples` passes the same number of tests (or more); `legacy_error_on`: a test asserting the inline-payload error and the single-self-variant error verbatim
212
+ **Test code (contracts):** A scratchpad script (not committed):
213
+ - hoists each inline payload variant into `record <Name>(fields)` after its enum, naming positional payloads `v`, `v0`, `v1`...;
214
+ - lists the name as a member;
215
+ - rewrites the remaining self-named single-variant enums to `record`;
216
+ - handles `\n`-escaped Rust snippets and reports clashes.
217
+
218
+ Plum-std/Json.plum is left for T9. Excluding Json.plum from legacyErrors until T9 is not allowed: T8 must keep the suite green, so if Json breaks it, T8 converts Json's variants to records too (T9 then simplifies them to direct members). Then call `legacyErrors` in loader.rs.
219
+ **Notes:** Ruling: tests whose point was the old syntax are rewritten to assert the new error.
220
+
221
+ ### T9 Json + README · Complexity 3 · Est. context 45k
222
+ **Owns:** plum-std/Json.plum, README.md
223
+ **Components:** 2
224
+ **LOC owned:** 160
225
+ **Tests:** slow
226
+ **Model:** sonnet
227
+ **Est. time:** 4 min
228
+ **Review Focus:** `json_roundtrip`: Json.plum's existing tests pass after `enum Json = JsonNull | Bool | Float | Str | List[Json] | Map[Str, Json]` with narrowing (`Float => j.toStr()`); `readme_snippets_parse`: `cargo test -p plum-lsp` stays green
229
+ **Test code (contracts):**
230
+ - Rewrite Json per the review plan's Goal block.
231
+ - README: rewrite the Enums section (members, tags, implicit conversion, narrowing, auto-dispatch, one type in several enums) and the Pattern-if section (bare member narrows), and update the cheat-sheet enum row to `enum Shape = Circle | Rectangle`.
232
+ - Verify: `plum test plum-std/Json.plum`, `cargo test -p plum-lsp`.
233
+
234
+ ## Review Focus
235
+ - core and checker (compiler library): deep, state, encoding. Risks: stale addresses in the plan after an in-place rewrite; narrowing leaking past its arm; generic member substitution; double check passes changing error messages or positions. **Probe entry:** run=./target/release/plum test plum-examples/unions.plum
236
+ - migration (file generator): file, deep. **Probe entry:** run=./target/release/plum test plum-std plum-examples
237
+
238
+ ## Self-review
239
+ Every Phase B step (6-13) maps to T1-T9. The Consumes lines match the Produces lines of T2/T6. The checker tasks are chained because they share lib.rs. T6 runs parallel to T3-T5.