plum

#treesitter#compiler#wasm

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

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


e369b75 — Peter John 2026-11-03T14:34:52+05:30
docs(plans): inline variants + = match plans; enum/union split shelved
docs/plans/2026-11-03-enum-union-split.review.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Split enum and union: review plan
2
2
 
3
- **Status:** written, not approved or scheduled (parked at the user's request, 2026-11-03).
3
+ **Status:** shelved (2026-11-03). Inline record variants came back instead (docs/plans/2026-11-03-inline-variants-match-body.review.md): `enum` keeps doing both jobs and may list records.
4
4
 
5
5
  ## Summary
6
6
  Today one `enum` keyword does two jobs: named constants (`enum Color = Red | Green | Blue`) and a sum of existing types (`enum Json = | JsonNull | Bool | Float | Str | List[Json] | Map[Str, Json]`). This splits them. `enum` holds only constants, optionally with values. A new `union` keyword lists the types a value may be, but never a record or struct: records are grouped with a trait instead. Both keep exhaustive `match`; only a trait needs `_`. The compiler machinery stays: `union` is today's union enum, `enum` is today's tag enum, so codegen does not change.
docs/plans/2026-11-03-inline-variants-match-body.md ADDED
@@ -0,0 +1,135 @@
1
+ # Inline record variants and `= match` bodies
2
+
3
+ **Review plan:** docs/plans/2026-11-03-inline-variants-match-body.review.md
4
+ **Goal:** `enum Link[T] = Nil | Cons(head: T, tail: Link[T])` declares a variant with named fields inline: a hidden record plus a member, where `Cons` is a constructor and a pattern only and `Link[T]` is the only type. And `fun f(xs: Link[T]) -> Int = match xs` with indented arms is a valid body for functions and methods.
5
+ **Architecture:** T1: grammar `fn` body accepts `$.match` after `=`; the parser maps it to `FnBody::Block` with one `Stmt::Match` (same AST as the indented form); format.scm keeps it and fixes `when` spacing. T2 (plum-core, before `resolveMembers`): `unions::hoistInlineVariants` replaces the inline-payload legacy error: each enum variant with named fields becomes `record <Name>[gens](fields)` (gens = the enum generics its field types mention, in enum order) with `inlineOf: Some(<enum>)`, and the variant becomes a member `Name[gens]`; `unions::inlineVariantTypeErrors` rejects the name in any type position. T3 (checker): a construction of an inline record is typed as its enum (generics from the member binding, else the expected type) and recorded as a member wrap; `.field` on an inline record type is an error. Codegen unchanged.
6
+ **Tech stack:** Rust workspace, tree-sitter grammar (`--disable-optimizations`), Plum std/examples.
7
+ **Spec:** docs/plans/2026-11-03-inline-variants-match-body.review.md
8
+ **Detail:** contracts
9
+ **Target-min:** 20
10
+ **Budget-usd:** 50
11
+
12
+ Global constraints:
13
+ - Fast tests: `cargo test -p <crate> --test <file>`; Plum `cargo build --quiet && ./target/debug/plum test <path>` run from the repo root; `touch` edited .rs first; full suite via `superlite-cli run_tests --full`; `run_tests --fast=-p,<crate>,--test,<name>`.
14
+ - Grammar: regenerate with `cd plum-tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate --disable-optimizations` (the default optimizer silently breaks unrelated parses), then `npx --yes tree-sitter-cli test`.
15
+ - Gotchas: single uppercase letters lex as generics (multi-letter type names in tests); no `+=`; no `? :` (use `if c then a else b`); inline `if` cannot mix a value and `null`; a function cannot return `fn(..)` (wrap in a record); a generic fn called with a bare `Nil` needs a typed local; never `plum format` a real file (it rewrites in place; use a copy).
16
+ - Error strings (exact): "Cons is a variant of Link, not a type; use Link[...]"; "enum Link declares Cons inline, but Cons is already declared; list it as a member instead"; "variant Some(T) needs named fields: Some(value: T)" (positional types joined by ", "); "read Cons's fields with a pattern: Cons(...)" (field access on an inline variant value).
17
+ - Hand-written records listed as members (`record Circle` + `enum Shape = Circle | Rect`) are unchanged and remain standalone types. Discriminant variants (`| ReadMin(10)`) and tags are unchanged.
18
+
19
+ ```mermaid
20
+ flowchart LR
21
+ SRC["enum Link = Nil or Cons(head, tail); fun f = match x"] --> G["T1 grammar + parser + format.scm"]
22
+ G --> H["T2 hoistInlineVariants + type-position errors (plum-core loader)"]
23
+ H --> RM[resolveMembers] --> C["T3 checker: construction typed as enum; no .field"]
24
+ C --> CG[codegen unchanged]
25
+ C --> X["T4 functional.plum, README"]
26
+ ```
27
+
28
+ ## File structure
29
+ - plum-tooling/tree-sitter-plum/grammar.js, src/, test/corpus/function.txt: `= match` body.
30
+ - plum-tooling/tree-sitter-plum/queries/plum/format.scm: `= match` layout, `when` spacing, inline variant fields.
31
+ - plum-core/src/parser.rs: fn body from a `match` node; `Enum.inlineOf` in literals.
32
+ - plum-core/src/ast.rs: `Enum.inlineOf: Option<String>`.
33
+ - plum-core/src/unions.rs: `hoistInlineVariants`, `inlineVariantTypeErrors`; `legacyErrors` keeps only the self-named-variant error.
34
+ - plum-core/src/loader.rs: run hoisting per file in `mergeItems` (before the merge), type errors after the merge.
35
+ - plum-checker/src/lib.rs, plum-checker/src/unions.rs: construction typing, field-access error.
36
+ - plum-examples/functional.plum, README.md: example and docs.
37
+
38
+ ## Task graph
39
+ ```mermaid
40
+ flowchart LR
41
+ T1[match body grammar and formatter]
42
+ T2[inline variant hoisting]
43
+ T3[variant-only checker typing]
44
+ T4[example and docs]
45
+ %% taskgraph
46
+ T1 --> T2
47
+ T2 --> T3
48
+ T3 --> T4
49
+ T1 --> T4
50
+ ```
51
+
52
+ ## Package A: compiler
53
+
54
+ **Probe entry:** call=plum_core::unions::hoistInlineVariants
55
+
56
+ ### T1 `= match` body: grammar, parser, formatter · Complexity 4 · Est. context 40k
57
+ **Owns:** plum-tooling/tree-sitter-plum/grammar.js, plum-tooling/tree-sitter-plum/src/, plum-tooling/tree-sitter-plum/test/corpus/function.txt, plum-tooling/tree-sitter-plum/queries/plum/format.scm, plum-core/src/parser.rs, plum-core/tests/match_body_parse_test.rs, plum-core/tests/formatter_test.rs
58
+ **Components:** 2
59
+ **LOC owned:** 120
60
+ **Tests:** fast
61
+ **Kind:** design
62
+ **Model:** sonnet
63
+ **Est. time:** 5 min
64
+ **Consumes:** none
65
+ **Produces:** parser: `fun name(..) -> R = match <subjects>` + indented cases parses to `FnBody::Block(Block { stmts: [Stmt::Match(..)] })`, identical to the indented form
66
+ e.g. parse("fun ln(xs: Int) -> Int = match xs\n 0 => 1\n _ => 2\n") -> Fn ln, body Block with 1 stmt, Stmt::Match with 2 cases (equal to parsing the `=\n match xs\n ...` form)
67
+ e.g. parse("enum Shp = Ci | Re\n fun area(self) -> Int = match self\n Ci => 1\n Re => 2\n") -> method area with a Match body
68
+ e.g. parse("fun pair(a: Bool, b: Bool) -> Int = match a, b\n true, true => 1\n _, _ => 0\n") -> Match with 2 subjects
69
+ **Review Focus:** grammar: `= match` must not change `= expr`/`= <indented body>` parses (whole corpus + every examples file still parse with no ERROR); `when` guards in a `= match`; `match` with no subject (guard_case form) after `=`; a `= match` method followed by another method in the same body (dedent handling); format.scm: `fun f(x: Int) -> Int = match x` stays on one line with arms indented one level, `Circle(r) when r == 0.0 => 1` keeps spaces around `when`, `| Cons(head: T, tail: Link[T])` kept as written; formatter idempotent on plum-std and plum-examples (run on copies).
70
+ **Test code:** match_body_parse_test.rs: `match_body_equals_indented`, `match_body_method`, `match_body_two_subjects`, `match_body_with_when_guard`, `match_body_then_next_method`; corpus case `fun = match` in function.txt; formatter_test.rs: `format_match_body_one_line`, `format_when_guard_spacing`, `format_inline_variant_fields`, each asserting exact output and idempotence.
71
+ **Steps:** failing tests, grammar + regenerate, parser branch in `parseFn` (`"match"` -> Block), format.scm, PASS.
72
+
73
+ ### T2 inline variant hoisting in plum-core · Complexity 6 · Est. context 50k
74
+ **Owns:** plum-core/src/ast.rs, plum-core/src/unions.rs, plum-core/src/loader.rs, plum-core/src/traits.rs, plum-core/tests/inline_variants_test.rs, plum-core/tests/loader_test.rs, plum-core/tests/unions_test.rs
75
+ **Components:** 2
76
+ **LOC owned:** 220
77
+ **Tests:** fast
78
+ **Kind:** design
79
+ **Model:** sonnet
80
+ **Est. time:** 5.5 min
81
+ **Consumes:** none
82
+ **Produces:** plum_core::ast::Enum.inlineOf: Option<String> (Some(enum name) on a record generated from an inline variant, None everywhere else; every `Enum { .. }` literal, including parser.rs, sets it)
83
+ e.g. after hoisting `enum Link[T] = Nil | Cons(head: T, tail: Link[T])` the record item Cons has inlineOf Some("Link")
84
+ **Produces:** plum_core::unions::hoistInlineVariants(source: &mut Source) -> Vec<String>
85
+ e.g. "enum Link[T] = Nil | Cons(head: T, tail: Link[T])" -> [] ; items: enum Link with variants Nil (tag) and member Cons[T]; new `record Cons[T](head: T, tail: Link[T])` placed right after Link, isRecord true, inlineOf Some("Link")
86
+ e.g. "enum Tree[T] = Leaf(v: Int) | Node(l: Tree[T], r: Tree[T])" -> [] ; Leaf non-generic record, member `Leaf`; Node record generics [T], member `Node[T]`
87
+ e.g. "record Cons(a: Int)\nenum Link = Nil | Cons(head: Int)" -> ["enum Link declares Cons inline, but Cons is already declared; list it as a member instead"]
88
+ e.g. "enum Opt[T] = None | Some(T)" -> ["variant Some(T) needs named fields: Some(value: T)"]
89
+ **Produces:** plum_core::unions::inlineVariantTypeErrors(source: &Source) -> Vec<String>
90
+ e.g. a param `c: Cons[Int]` (Cons inline in Link) -> ["Cons is a variant of Link, not a type; use Link[...]"]; also return types, record/struct/trait field types, annotated locals, generic args (`List[Cons]`), fn-type params; construction `Cons(head: 1, tail: Nil)` and patterns are fine
91
+ **Review Focus:** state: idempotent (running hoist twice adds nothing); runs per file in `mergeItems` where `legacyErrors` ran (that keeps only the self-named-variant error), so a clash with a record in ANOTHER file is caught by the existing duplicate-name check; `inlineVariantTypeErrors` runs on the merged source before `resolveMembers` (find every call site of resolveMembers in loader.rs/parser.rs and keep order: hoist before it); deep: enum with both inline variants and listed members (`| Nil | Cons(..) | Int`), multi-line `|` form, enum with methods body, generic order (`enum Ab[K, V] = Ea(v: V) | Eb(k: K, v: V)` -> Ea[V], Eb[K, V]); fn-typed fields (`run: fn(T) -> T`) mention T; discriminant variants untouched; loader_test asserting the old removal error is updated to the new behaviour.
92
+ **Test code:** inline_variants_test.rs: one named test per e.g. above (`hoist_link`, `hoist_tree_generics`, `hoist_clash`, `hoist_positional_rejected`, `type_position_errors`, `hoist_idempotent`, `generic_order`), plus `loader_accepts_inline_variant` loading a temp file through the loader.
93
+ **Steps:** failing tests, ast field + literals (parser.rs/traits.rs literals are 1-line edits), hoist + type errors, loader wiring, PASS.
94
+
95
+ ### T3 variant-only typing in the checker · Complexity 6 · Est. context 55k
96
+ **Owns:** plum-checker/src/lib.rs, plum-checker/src/unions.rs, plum-checker/tests/inline_variants_tests.rs, plum-wasm-codegen/tests/inline_variants_e2e_test.rs
97
+ **Components:** 1
98
+ **LOC owned:** 200
99
+ **Tests:** fast
100
+ **Kind:** design
101
+ **Model:** sonnet
102
+ **Est. time:** 5 min
103
+ **Consumes:** plum_core::unions::hoistInlineVariants(source: &mut Source) -> Vec<String> (T2) · plum_core::ast::Enum.inlineOf: Option<String> (T2)
104
+ **Produces:** checker: `Cons(head: 1, tail: Nil)` (Cons inline in Link[T]) infers `Link[Int]` and records a member wrap (`recordMemberWrap`), so codegen builds the hidden variant; `.head` on a value whose type is the inline record is "read Cons's fields with a pattern: Cons(...)"
105
+ e.g. `fun mk() -> Link[Int] = Cons(head: 1, tail: Nil)` checks; `x := Cons(head: 1, tail: Nil)` gives x: Link[Int]
106
+ e.g. `fun lf() -> Tree[Str] = Leaf(v: 3)` checks (T from the expected type); `x := Leaf(v: 3)` with Tree[T] generic and nothing fixing T -> error naming Tree and asking for an annotation
107
+ e.g. `if Cons := xs` then `xs.head` -> "read Cons's fields with a pattern: Cons(...)"
108
+ **Review Focus:** construction inside a generic fn (`fun cons(h: T, t: Link[T]) -> Link[T] = Cons(head: h, tail: t)`); nested construction (`Cons(head: 1, tail: Cons(head: 2, tail: Nil))`); explicit args `Cons[Int](head: 1, tail: Nil)`; construction as a call argument, return value, list element, `if`/`match` branch value; the union-lowering re-check pass (the checker runs again on the lowered program) must not trip the field-access error or double-wrap (look at how `applyPlan` output is re-checked); patterns `Cons(h, t)` and nested `Cons(a, Cons(b, _))` unchanged; messages never print the hidden record where the user wrote nothing (a mismatch reports `Link[Int]`); hand-written member records (`Circle`) keep their own type.
109
+ **Test code:** inline_variants_tests.rs (checker): `construct_types_as_enum`, `construct_generic_from_expected`, `construct_unpinned_generic_errors`, `field_access_rejected`, `explicit_type_args`, `member_record_unchanged`. inline_variants_e2e_test.rs (compile and run through the loader): cons list length/map/foldl with `= match` bodies returning 6 for [1,2,3] sum, tree insert/size, `Leaf(v: 3)` into Tree[Str].
110
+ **Steps:** failing tests, implement in inferClassCallRaw's caller / union coercion, PASS.
111
+
112
+ ## Package B: example and docs
113
+
114
+ **Probe entry:** run=./target/debug/plum test plum-examples/functional.plum
115
+
116
+ ### T4 functional.plum example and README · Complexity 3 · Est. context 40k
117
+ **Owns:** plum-examples/functional.plum, README.md
118
+ **Components:** 1
119
+ **LOC owned:** 140
120
+ **Tests:** fast
121
+ **Kind:** design
122
+ **Model:** sonnet
123
+ **Est. time:** 3.5 min
124
+ **Consumes:** checker typing of inline variants (T3) · `= match` bodies (T1)
125
+ **Produces:** plum-examples/functional.plum passing `plum test`
126
+ **Review Focus:** start from /private/tmp/claude-501/-Users-pyrossh-Code-plum/ea4a6dd3-c970-4882-b24c-3468d9fb6b79/scratchpad/functional.plum (11 passing tests): convert `Link`, `Tree`, `Shape`, `Node` (scene graph) and `UiKind`-style enums to inline variants where the record is only used as a variant, every match-only function to `= match`, and replace record field reads on variants with patterns; keep every test and assertion value; README: Enums section documents inline variants (variant-only, errors) and Functions section documents `= match`; README ```plum blocks must parse; `plum test plum-std plum-examples` count = before + 11 or more.
127
+ **Test code:** the 11 existing tests in the file plus `inline_variant_not_a_type_doc` style snippets only in README (no new Rust tests).
128
+ **Steps:** copy, convert, run, README, run full plum test.
129
+
130
+ ## Review Focus (probe plan)
131
+ - Package A (library / parser / checker): deep (nested enums and generics), state (idempotent passes, re-check after lowering), encoding of names (hidden record never leaks in messages). Risks: grammar conflicts; generic inference when a variant mentions no generic; `.field` rejection hitting compiler-generated code.
132
+ - Package B (examples/docs): file; README snippet parsing.
133
+
134
+ ## Self-review
135
+ Coverage: inline variants (T2, T3), not-a-type and clash/positional errors (T2), field access error (T3), `= match` (T1), formatter + `when` fix (T1), example + README (T4). The enum/union split plan was marked shelved in the review phase. Edges: T1->T2 (shared parser.rs), T2->T3 and T3->T4 run real code, T1->T4 the example uses `= match`.
docs/plans/2026-11-03-inline-variants-match-body.review.md ADDED
@@ -0,0 +1,100 @@
1
+ # Inline record variants and `= match` bodies: review plan
2
+
3
+ **Status:** written, waiting for approval (2026-11-03). Orchestrator: human.
4
+
5
+ ## Summary
6
+ Two readability changes so recursive data reads like Gleam or Haskell. First, an enum variant may declare its fields inline again: `enum Link[T] = Nil | Cons(head: T, tail: Link[T])`. As in Haskell and Gleam, `Link[T]` is the only type: `Cons` is a constructor and a pattern, never a type of its own. Under the hood the loader still generates a hidden record and lists it as a member, so code generation is unchanged; the checker only makes sure `Cons` never appears as a type and that building one gives a `Link`. Second, a function or method whose whole body is one `match` can start it right after the `=`, with the arms indented below, instead of indenting the `match` on its own line.
7
+
8
+ ```plum
9
+ enum Link[T] =
10
+ | Nil
11
+ | Cons(head: T, tail: Link[T])
12
+
13
+ enum Tree = Leaf | Branch(left: Tree, value: Int, right: Tree)
14
+
15
+ fun length(xs: Link[T]) -> Int = match xs
16
+ Nil => 0
17
+ Cons(_, t) => 1 + length(t)
18
+ ```
19
+
20
+ ## What you will get
21
+ - Inline record variants with named fields (`| Cons(head: T, tail: Link[T])`), on one line or in the `|` list form, in generic and non-generic enums, alongside tags (`Nil`) and listed members (`Int`, `Circle`, `List[Json]`).
22
+ - `Cons` is a constructor and a pattern only:
23
+ - `Cons(head: 1, tail: Nil)` has type `Link[Int]` (the enum's generics are inferred from the fields, or from the expected type: `xs: Link[Int] := Nil`).
24
+ - Its fields are read by matching: `match xs` with `Cons(h, t) =>`, or `if Cons(h, t) := xs`. There is no `xs.head` on a `Link` (a `Nil` has no head).
25
+ - `Cons` has no methods of its own; methods go on the enum (`enum Link[T] = ...` with a `fun` body using `match self`).
26
+ - The old "inline variant payloads are removed" error is gone. New clear errors:
27
+ - `Cons` used as a type (`c: Cons[Int]`, `-> Cons`, `List[Cons]`): "Cons is a variant of Link, not a type; use Link[...]"
28
+ - a variant whose name is already a type: "enum Link declares Cons inline, but Cons is already declared; list it as a member instead"
29
+ - positional fields: "variant Some(T) needs named fields: Some(value: T)"
30
+ - Hand-written records listed as members (`record Circle(r: Float)` in `enum Shape = Circle | Rect`) keep working as today, including as standalone types.
31
+ - `fun name(...) -> R = match subject` followed by indented arms, for free functions, methods (enum, record, struct, trait bodies) and `match a, b`. `when` guards work as in any `match`. The indented form stays valid.
32
+ - The formatter keeps both new forms as written (one-line `= match`, inline variant fields), and its `when` bug (`Circle(r) when r == 0.0` printed as `Circle(r)whenr == 0.0`) is fixed, since these examples use guards.
33
+ - `plum-examples/functional.plum`: the cons list, persistent tree, pattern matching and pure scene graph example from today, rewritten with both forms and run by `plum test`.
34
+ - README: Enums and Functions sections updated; tree-sitter highlighting unchanged (no new keywords).
35
+ - The parked enum/union split plan is marked shelved (done with this plan, per your answer).
36
+
37
+ ## What will be built
38
+ | Part | What it does | Size | Built by |
39
+ |---|---|---|---|
40
+ | Inline variant lowering | In the loader, turns each inline variant into a hidden generated record plus a member (marked as inline); replaces the removal error with the clash and positional-field errors | M | a careful helper |
41
+ | Variant-only typing | In the checker, a construction of an inline variant is typed as its enum (generics inferred from fields or the expected type), and an inline variant name in any type position is the "not a type" error | M | a careful helper |
42
+ | `= match` grammar | Lets a function body be a `match` right after `=`; regenerates the parser (`--disable-optimizations`) and maps it to the same body the indented form produces | S | a careful helper |
43
+ | Formatter | Keeps `= match` on one line and inline variant fields; fixes the `when` spacing bug; idempotent over std and examples | S | a careful helper |
44
+ | Example, docs, tests | `functional.plum` in examples, README sections, checker and parser tests for both forms and both errors | S | a fast helper |
45
+
46
+ ## How it fits together
47
+ ```mermaid
48
+ flowchart LR
49
+ SRC["source: enum with Cons(head, tail) / fun f = match x"] --> TS["tree-sitter grammar"]
50
+ TS --> P["parser: EnumVariant with named fields; fn body = match block"]
51
+ P --> H["loader: inline variant lowering (new)"]
52
+ H --> U["unions: resolveMembers (unchanged)"]
53
+ U --> C["checker: inline variant builds type as its enum; never a type (new)"]
54
+ C --> G["codegen (unchanged)"]
55
+ TS --> F["formatter (format.scm)"]
56
+ ```
57
+
58
+ ## How it gets built
59
+ ```mermaid
60
+ flowchart TB
61
+ subgraph W1["Wave 1, in parallel"]
62
+ A["Inline variant lowering"]
63
+ B["= match grammar"]
64
+ end
65
+ A --> V["Variant-only typing"]
66
+ V --> D["Example, docs, tests"]
67
+ B --> D
68
+ B --> F["Formatter"]
69
+ F --> D
70
+ ```
71
+
72
+ ## Decisions you can change
73
+ - Variant fields after narrowing: read only through patterns (default) | also `xs.head` inside `if Cons := xs` / a `Cons =>` arm.
74
+ - Hidden record kind: `record` (default) | `struct` when every field is a plain value type.
75
+ - Positional fields (`Some(T)`): rejected with a hint (default, as you chose) | allowed with generated names `v0`, `v1`.
76
+ - `=` followed directly by a block form: only `match` (default) | also `if` / `for` blocks.
77
+ - Existing examples that declare `record X` and list `X` separately: left as they are (default; both forms stay valid) | rewritten to the inline form.
78
+
79
+ ## Risks
80
+ - Name clashes: a generated record named like another type or a variant in another enum. Caught by the clash error above; the merged-name table in the loader already rejects duplicates across files.
81
+ - Grammar conflicts: `= match` next to `= expression` could shift other parses. The parser is regenerated with `--disable-optimizations` and the full corpus plus every example must still parse unchanged.
82
+ - Generic inference: `Cons(head: 1, tail: Nil)` must come out as `Link[Int]` with no type args, and a variant that mentions no generic (`Nil`-like, or `Leaf(v: Int)` in `Tree[T]`) must take `T` from the expected type. Covered by named tests; where nothing fixes `T`, the error asks for an annotation.
83
+ - Error messages from deeper passes might still say the hidden record's name; the checker maps it back to `Link[...]` in messages, with a test.
84
+ - Discriminant variants (`| ReadMin(10)`) and value enums must not be mistaken for inline records: they are told apart by values vs named fields, as the parser already does.
85
+
86
+ ## Out of scope
87
+ - Positional variant fields, and the record constructor taking positional arguments.
88
+ - Tail calls (`return_call`), a std `Link[T]`/`Tree[T]` library, functions returning functions (`-> fn(A) -> B`), inline `if` mixing a value and `null`, and the `isEmpty(Nil)` codegen gap found today (each noted for a later plan).
89
+ - Exhaustiveness changes, and the scene graph engine itself.
90
+
91
+ ## How we will know it works
92
+ - `enum Link[T] = Nil | Cons(head: T, tail: Link[T])` with `length`/`map`/`foldl` written as `= match` passes `plum test`.
93
+ - `c := Cons(head: 1, tail: Nil)` has type `Link[Int]`; `if Cons(h, _) := c` gives `h == 1`.
94
+ - `c: Cons[Int] := ...` gives "Cons is a variant of Link, not a type; use Link[...]".
95
+ - `| Some(T)` and an inline `Cons` next to an existing `record Cons` give the two error messages above.
96
+ - `plum test plum-std plum-examples` passes with at least today's count plus the new tests; `cargo test` workspace green.
97
+ - `plum format` on a copy of `functional.plum` changes nothing (idempotent) and prints `when` guards correctly.
98
+
99
+ ## Rough time and cost
100
+ Rough, until the machine plan is linted: 5 tasks, about 20-30 minutes of build time, around $30-50.