plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-24-enum-discriminant-values-design.md
| c664d4f | 1 | # Enum Discriminant Values — Design Spec |
| c664d4f | 2 | |
| c664d4f | 3 | ## Goal |
| c664d4f | 4 | |
| c664d4f | 5 | Let an enum declare one or more shared, uniformly-typed fields that every |
| c664d4f | 6 | variant supplies a concrete value for, readable via ordinary field access |
| c664d4f | 7 | (`self.n`) regardless of which variant a given instance actually is: |
| 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 | |
| c664d4f | 17 | Here every `Step` value carries an `Int` field `n`; `READ_MIN_OCCURANCES.n` |
| c664d4f | 18 | (or `self.n` inside a method) reads `0` without needing a `match`. This is |
| c664d4f | 19 | different from today's per-variant payload (`Some(a)`/`Ok(a)`), where each |
| c664d4f | 20 | variant's payload has a different shape and can only be read by destructuring |
| c664d4f | 21 | via `match`. |
| c664d4f | 22 | |
| c664d4f | 23 | ## Non-goals |
| c664d4f | 24 | |
| c664d4f | 25 | - No mixing of enum-level discriminant params with today's per-variant |
| c664d4f | 26 | generic-type payload (`| Some[T]`) in the same enum — an enum is either |
| c664d4f | 27 | a "discriminant enum" (this feature) or an ordinary/generic enum |
| c664d4f | 28 | (today's feature), not both, for v1. If a real use case for combining them |
| c664d4f | 29 | shows up later, that's a follow-up. |
| c664d4f | 30 | - No change to how ordinary enums (`Option`, `Result`, `Bool`, `Color`, ...) |
| c664d4f | 31 | are declared, checked, or compiled — this is purely additive. |
| c664d4f | 32 | - No automatic derivation of a discriminant from declaration order (that's |
| c664d4f | 33 | `plum-checker`'s existing internal *tag* numbering, which already exists |
| c664d4f | 34 | and is unrelated — see Current State). This feature is about a |
| c664d4f | 35 | user-declared, user-typed, user-readable field, not the internal tag. |
| c664d4f | 36 | |
| c664d4f | 37 | ## Current state (relevant constraints found during design) |
| c664d4f | 38 | |
| c664d4f | 39 | This is the key architectural fact the design has to work around: |
| c664d4f | 40 | |
| c664d4f | 41 | - **Payload-free variants are bare integers today, not heap values.** A |
| c664d4f | 42 | nullary variant like `None`/`True`/`Red` compiles to a plain `i32.const` |
| c664d4f | 43 | with **no heap allocation** (`plum-wasm-codegen`'s |
| c664d4f | 44 | `compile_variant_construction`: `if field_types.is_empty() { return |
| c664d4f | 45 | I32Const(tag) }`). This is a real optimization the checker/codegen rely on |
| c664d4f | 46 | elsewhere (pattern-match codegen explicitly branches on "is this variant a |
| c664d4f | 47 | small int or a heap pointer"). |
| c664d4f | 48 | - **Payload variants are heap-allocated as `[tag: i32][field0][field1]...`**, |
| c664d4f | 49 | each slot at an 8-byte stride, sized independently per variant (no |
| c664d4f | 50 | "widest variant" padding). |
| c664d4f | 51 | - **Field access (`self.field`) is class-only today.** It requires the |
| c664d4f | 52 | receiver's type to resolve to a `Class` in `ClassEnv`, looks up the |
| c664d4f | 53 | field's positional index, and loads at `field_idx * 8` from the class's |
| c664d4f | 54 | heap pointer. Enums have no entry in `ClassEnv` and are never consulted |
| c664d4f | 55 | by this code path today. |
| c664d4f | 56 | - **A bare variant reference used as a value** (`None`, `True`, `Red` with |
| c664d4f | 57 | no explicit constructor call) always compiles to just the constant tag |
| c664d4f | 58 | integer — there is no existing mechanism for a bare reference to also |
| c664d4f | 59 | carry an accompanying value. |
| c664d4f | 60 | |
| c664d4f | 61 | The consequence: a variant that carries a discriminant field **cannot** use |
| c664d4f | 62 | today's bare-int optimization — if `READ_MIN_OCCURANCES` compiled to a plain |
| c664d4f | 63 | `i32.const 0` tag, there'd be nowhere to also store the field value `0` |
| c664d4f | 64 | itself (and no way to tell "tag" from "field value" if they happened to |
| c664d4f | 65 | collide numerically for a different variant). So a discriminant enum's |
| c664d4f | 66 | variants must **always heap-allocate**, exactly like today's payload |
| c664d4f | 67 | variants — this feature reuses that existing scheme rather than inventing a |
| c664d4f | 68 | new one. |
| c664d4f | 69 | |
| c664d4f | 70 | ## Design |
| c664d4f | 71 | |
| c664d4f | 72 | ### Grammar |
| c664d4f | 73 | |
| c664d4f | 74 | ```js |
| c664d4f | 75 | enum: ($) => |
| c664d4f | 76 | seq( |
| c664d4f | 77 | "enum", |
| c664d4f | 78 | field("name", $.type_identifier), |
| c664d4f | 79 | field("params", optional(seq("(", commaSep1($.enum_param), ")"))), |
| c664d4f | 80 | "=", |
| c664d4f | 81 | $._indent, |
| c664d4f | 82 | optional(repeat(alias($.enum_field, $.field))), |
| c664d4f | 83 | $._dedent, |
| c664d4f | 84 | ), |
| c664d4f | 85 | |
| c664d4f | 86 | enum_param: ($) => |
| c664d4f | 87 | seq(field("name", $.var_identifier), ":", field("type", $.type)), |
| c664d4f | 88 | |
| c664d4f | 89 | enum_field: ($) => |
| c664d4f | 90 | seq( |
| c664d4f | 91 | "|", |
| c664d4f | 92 | field("name", $.type_identifier), |
| c664d4f | 93 | field( |
| c664d4f | 94 | "parameters", |
| c664d4f | 95 | optional(choice( |
| c664d4f | 96 | seq("[", commaSep1(choice($.type_identifier, $.generic)), "]"), // existing: generic type payload |
| c664d4f | 97 | seq("(", commaSep1($.expression), ")"), // new: discriminant value literals |
| c664d4f | 98 | )), |
| c664d4f | 99 | ), |
| c664d4f | 100 | ), |
| c664d4f | 101 | ``` |
| c664d4f | 102 | |
| c664d4f | 103 | The bracket form (`[...]`, existing) and the paren form (`(...)`, new) are |
| c664d4f | 104 | distinguished by delimiter, matching the rest of the language's convention |
| c664d4f | 105 | post-migration: brackets for type-level content, parens for value-level |
| c664d4f | 106 | content — a discriminant value list is values, not types, so it naturally |
| c664d4f | 107 | takes parens, the same delimiter a constructor call already uses for its |
| c664d4f | 108 | arguments. |
| c664d4f | 109 | |
| c664d4f | 110 | ### AST |
| c664d4f | 111 | |
| c664d4f | 112 | ```rust |
| c664d4f | 113 | pub struct Enum { |
| c664d4f | 114 | pub name: String, |
| c664d4f | 115 | pub params: Vec<EnumParam>, // new; empty for every enum declared without "(...)" |
| c664d4f | 116 | pub variants: Vec<EnumVariant>, |
| c664d4f | 117 | } |
| c664d4f | 118 | |
| c664d4f | 119 | pub struct EnumParam { // new |
| c664d4f | 120 | pub name: String, |
| c664d4f | 121 | pub ty: Type, |
| c664d4f | 122 | } |
| c664d4f | 123 | |
| c664d4f | 124 | pub struct EnumVariant { |
| c664d4f | 125 | pub name: String, |
| c664d4f | 126 | pub fields: Vec<String>, // existing: generic/type payload names, unchanged meaning |
| c664d4f | 127 | pub values: Vec<Expr>, // new: discriminant literal values, empty for ordinary variants |
| c664d4f | 128 | } |
| c664d4f | 129 | ``` |
| c664d4f | 130 | |
| c664d4f | 131 | A variant has either a non-empty `fields` (today's generic payload) or a |
| c664d4f | 132 | non-empty `values` (this feature), never both — enforced by the checker |
| c664d4f | 133 | (see below), not the grammar (the grammar can't tell a bare type name from a |
| c664d4f | 134 | literal expression apart at the same position without this kind of |
| c664d4f | 135 | cross-check, so it accepts either shape structurally and the checker |
| c664d4f | 136 | rejects the mixed case with a clear error). |
| c664d4f | 137 | |
| c664d4f | 138 | ### Checker |
| c664d4f | 139 | |
| c664d4f | 140 | - **Declaration-time validation:** for an enum with non-empty `params`, |
| c664d4f | 141 | every variant must supply exactly `params.len()` values, and each value's |
| c664d4f | 142 | inferred type must unify with the corresponding param's declared type |
| c664d4f | 143 | (e.g. the literal `0` against `Int`). A variant supplying the wrong count, |
| c664d4f | 144 | or a value of the wrong type, is a checker error. An enum with EMPTY |
| c664d4f | 145 | `params` must have every variant's `values` also empty (this is what "an |
| c664d4f | 146 | enum is either a discriminant enum or an ordinary enum" means in |
| c664d4f | 147 | practice) — a variant with non-empty `values` on a param-less enum is |
| c664d4f | 148 | also a checker error (most likely to happen by a typo like accidentally |
| c664d4f | 149 | writing `Some(5)` instead of `Some[Int]` for an ordinary generic enum; |
| c664d4f | 150 | the error should say so plainly). |
| c664d4f | 151 | - **Field access (`self.n`, `obj.n`):** extend the existing field-access |
| c664d4f | 152 | check (today: receiver type must resolve to a `Class` in `ClassEnv`) with |
| c664d4f | 153 | a second lookup path: if the receiver's type resolves to an `Enum` name |
| c664d4f | 154 | that has non-empty `params` (tracked in a new `EnumParams` map alongside |
| c664d4f | 155 | the existing `EnumVariants`/`ClassEnv` context maps), resolve the field |
| c664d4f | 156 | by name against that enum's declared params exactly as a class field |
| c664d4f | 157 | would be resolved — same "field not found" error shape, just checked |
| c664d4f | 158 | against `EnumParams` instead of `ClassEnv` when the name isn't a class. |
| c664d4f | 159 | Field access on an enum with EMPTY params still falls through to today's |
| c664d4f | 160 | behavior (class-only, or the permissive "unmodeled type" escape hatch) |
| c664d4f | 161 | — unchanged. |
| c664d4f | 162 | |
| c664d4f | 163 | ### Codegen |
| c664d4f | 164 | |
| c664d4f | 165 | - **Construction of a bare discriminant-variant reference** |
| c664d4f | 166 | (`READ_MIN_OCCURANCES` used as a value, no explicit call): today this |
| c664d4f | 167 | compiles `Expr::TypeName(n)` to a bare `I32Const(tag)` when the variant |
| c664d4f | 168 | is payload-free. For a variant belonging to a discriminant enum (non-empty |
| c664d4f | 169 | declared `values`), codegen instead reuses the EXISTING payload-variant |
| c664d4f | 170 | construction path (`compile_variant_construction`) directly, using the |
| c664d4f | 171 | variant's own declared literal `values` (compiled as constants) as the |
| c664d4f | 172 | "field values" to store — structurally as if the user had written |
| c664d4f | 173 | `READ_MIN_OCCURANCES(0)` explicitly at every use site, except the `0` |
| c664d4f | 174 | comes from the variant's declaration, not the call site (there is no call |
| c664d4f | 175 | site; syntactically it's still a bare reference). This means these |
| c664d4f | 176 | variants always heap-allocate, matching the Current State constraint |
| c664d4f | 177 | above — the bare-int optimization is simply not applied to them. |
| c664d4f | 178 | - **Field access codegen (`self.n`):** mirrors the existing class field-load |
| c664d4f | 179 | codegen exactly (`I32Load`/`I64Load`/`F64Load` at `(field_idx + 1) * 8` |
| c664d4f | 180 | from the heap pointer — `+1` because slot 0 is always the tag, matching |
| c664d4f | 181 | today's payload-variant layout) — the only change is resolving |
| c664d4f | 182 | `field_idx`/width from the enum's declared params (via the new |
| c664d4f | 183 | `EnumParams` lookup) instead of `ClassEnv`, when the receiver's static |
| c664d4f | 184 | type is a discriminant enum rather than a class. |
| c664d4f | 185 | - **Pattern matching** (`match` against a discriminant-enum value by variant |
| c664d4f | 186 | name, e.g. `match step \n READ_MIN_OCCURANCES => ...`) is unaffected — |
| c664d4f | 187 | since these variants are now always heap pointers (never bare ints), the |
| c664d4f | 188 | existing payload-variant tag-load-and-compare path in match codegen |
| c664d4f | 189 | applies uniformly, with no need for the "is this a small int or a |
| c664d4f | 190 | pointer" branch discriminant enums currently force ordinary enums to |
| c664d4f | 191 | have (every variant of a discriminant enum is a pointer, so that branch |
| c664d4f | 192 | is simply never taken for this kind of enum). |
| c664d4f | 193 | |
| c664d4f | 194 | ### Testing strategy |
| c664d4f | 195 | |
| c664d4f | 196 | - Tree-sitter corpus: a discriminant-enum declaration (`enum Step(n: Int) = |
| c664d4f | 197 | | READ_MIN_OCCURANCES(0) | ...`), confirming the new `enum_param` and the |
| c664d4f | 198 | paren-form `enum_field` parameters parse into the expected tree; confirm |
| c664d4f | 199 | today's bracket-form (`| Some[T]`) and truly nullary (`| Red`) variants |
| c664d4f | 200 | are unaffected. |
| c664d4f | 201 | - `plum-checker` tests: correct arity/type-checked discriminant values pass; |
| c664d4f | 202 | wrong arity, wrong value type, and mixing discriminant values into a |
| c664d4f | 203 | param-less enum's variant each produce a clear error; `self.n` field |
| c664d4f | 204 | access on a discriminant-enum method type-checks and resolves to the |
| c664d4f | 205 | right type; `self.n` on an ordinary enum (no declared params) still |
| c664d4f | 206 | errors exactly as it does today. |
| c664d4f | 207 | - `plum-wasm-codegen` tests: a method that reads `self.n` off a |
| c664d4f | 208 | discriminant-enum value returns the declared literal for each variant |
| c664d4f | 209 | (covering at least two different variants, to prove the field is |
| c664d4f | 210 | correctly positioned per-instance, not hardcoded to one variant's |
| c664d4f | 211 | value); confirm a discriminant-enum value used in `match` (destructuring |
| c664d4f | 212 | by variant name, not by field) still resolves and runs correctly, |
| c664d4f | 213 | exercising the "always a pointer, never a bare int" codegen path noted |
| c664d4f | 214 | above. |