plum

#treesitter#compiler#wasm

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.