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
# Enum Discriminant Values — Design Spec

## Goal

Let an enum declare one or more shared, uniformly-typed fields that every
variant supplies a concrete value for, readable via ordinary field access
(`self.n`) regardless of which variant a given instance actually is:

```
enum Step(n: Int) =
  | READ_MIN_OCCURANCES(0)
  | READ_MAX_OCCURANCES(1)
  | READ_CHAR_TO_COUNT(2)
  | COUNT_OCCURANCES(3)
```

Here every `Step` value carries an `Int` field `n`; `READ_MIN_OCCURANCES.n`
(or `self.n` inside a method) reads `0` without needing a `match`. This is
different from today's per-variant payload (`Some(a)`/`Ok(a)`), where each
variant's payload has a different shape and can only be read by destructuring
via `match`.

## Non-goals

- No mixing of enum-level discriminant params with today's per-variant
  generic-type payload (`| Some[T]`) in the same enum — an enum is either
  a "discriminant enum" (this feature) or an ordinary/generic enum
  (today's feature), not both, for v1. If a real use case for combining them
  shows up later, that's a follow-up.
- No change to how ordinary enums (`Option`, `Result`, `Bool`, `Color`, ...)
  are declared, checked, or compiled — this is purely additive.
- No automatic derivation of a discriminant from declaration order (that's
  `plum-checker`'s existing internal *tag* numbering, which already exists
  and is unrelated — see Current State). This feature is about a
  user-declared, user-typed, user-readable field, not the internal tag.

## Current state (relevant constraints found during design)

This is the key architectural fact the design has to work around:

- **Payload-free variants are bare integers today, not heap values.** A
  nullary variant like `None`/`True`/`Red` compiles to a plain `i32.const`
  with **no heap allocation** (`plum-wasm-codegen`'s
  `compile_variant_construction`: `if field_types.is_empty() { return
  I32Const(tag) }`). This is a real optimization the checker/codegen rely on
  elsewhere (pattern-match codegen explicitly branches on "is this variant a
  small int or a heap pointer").
- **Payload variants are heap-allocated as `[tag: i32][field0][field1]...`**,
  each slot at an 8-byte stride, sized independently per variant (no
  "widest variant" padding).
- **Field access (`self.field`) is class-only today.** It requires the
  receiver's type to resolve to a `Class` in `ClassEnv`, looks up the
  field's positional index, and loads at `field_idx * 8` from the class's
  heap pointer. Enums have no entry in `ClassEnv` and are never consulted
  by this code path today.
- **A bare variant reference used as a value** (`None`, `True`, `Red` with
  no explicit constructor call) always compiles to just the constant tag
  integer — there is no existing mechanism for a bare reference to also
  carry an accompanying value.

The consequence: a variant that carries a discriminant field **cannot** use
today's bare-int optimization — if `READ_MIN_OCCURANCES` compiled to a plain
`i32.const 0` tag, there'd be nowhere to also store the field value `0`
itself (and no way to tell "tag" from "field value" if they happened to
collide numerically for a different variant). So a discriminant enum's
variants must **always heap-allocate**, exactly like today's payload
variants — this feature reuses that existing scheme rather than inventing a
new one.

## Design

### Grammar

```js
enum: ($) =>
  seq(
    "enum",
    field("name", $.type_identifier),
    field("params", optional(seq("(", commaSep1($.enum_param), ")"))),
    "=",
    $._indent,
    optional(repeat(alias($.enum_field, $.field))),
    $._dedent,
  ),

enum_param: ($) =>
  seq(field("name", $.var_identifier), ":", field("type", $.type)),

enum_field: ($) =>
  seq(
    "|",
    field("name", $.type_identifier),
    field(
      "parameters",
      optional(choice(
        seq("[", commaSep1(choice($.type_identifier, $.generic)), "]"), // existing: generic type payload
        seq("(", commaSep1($.expression), ")"),                        // new: discriminant value literals
      )),
    ),
  ),
```

The bracket form (`[...]`, existing) and the paren form (`(...)`, new) are
distinguished by delimiter, matching the rest of the language's convention
post-migration: brackets for type-level content, parens for value-level
content — a discriminant value list is values, not types, so it naturally
takes parens, the same delimiter a constructor call already uses for its
arguments.

### AST

```rust
pub struct Enum {
    pub name: String,
    pub params: Vec<EnumParam>,   // new; empty for every enum declared without "(...)"
    pub variants: Vec<EnumVariant>,
}

pub struct EnumParam {           // new
    pub name: String,
    pub ty: Type,
}

pub struct EnumVariant {
    pub name: String,
    pub fields: Vec<String>,     // existing: generic/type payload names, unchanged meaning
    pub values: Vec<Expr>,       // new: discriminant literal values, empty for ordinary variants
}
```

A variant has either a non-empty `fields` (today's generic payload) or a
non-empty `values` (this feature), never both — enforced by the checker
(see below), not the grammar (the grammar can't tell a bare type name from a
literal expression apart at the same position without this kind of
cross-check, so it accepts either shape structurally and the checker
rejects the mixed case with a clear error).

### Checker

- **Declaration-time validation:** for an enum with non-empty `params`,
  every variant must supply exactly `params.len()` values, and each value's
  inferred type must unify with the corresponding param's declared type
  (e.g. the literal `0` against `Int`). A variant supplying the wrong count,
  or a value of the wrong type, is a checker error. An enum with EMPTY
  `params` must have every variant's `values` also empty (this is what "an
  enum is either a discriminant enum or an ordinary enum" means in
  practice) — a variant with non-empty `values` on a param-less enum is
  also a checker error (most likely to happen by a typo like accidentally
  writing `Some(5)` instead of `Some[Int]` for an ordinary generic enum;
  the error should say so plainly).
- **Field access (`self.n`, `obj.n`):** extend the existing field-access
  check (today: receiver type must resolve to a `Class` in `ClassEnv`) with
  a second lookup path: if the receiver's type resolves to an `Enum` name
  that has non-empty `params` (tracked in a new `EnumParams` map alongside
  the existing `EnumVariants`/`ClassEnv` context maps), resolve the field
  by name against that enum's declared params exactly as a class field
  would be resolved — same "field not found" error shape, just checked
  against `EnumParams` instead of `ClassEnv` when the name isn't a class.
  Field access on an enum with EMPTY params still falls through to today's
  behavior (class-only, or the permissive "unmodeled type" escape hatch)
  — unchanged.

### Codegen

- **Construction of a bare discriminant-variant reference**
  (`READ_MIN_OCCURANCES` used as a value, no explicit call): today this
  compiles `Expr::TypeName(n)` to a bare `I32Const(tag)` when the variant
  is payload-free. For a variant belonging to a discriminant enum (non-empty
  declared `values`), codegen instead reuses the EXISTING payload-variant
  construction path (`compile_variant_construction`) directly, using the
  variant's own declared literal `values` (compiled as constants) as the
  "field values" to store — structurally as if the user had written
  `READ_MIN_OCCURANCES(0)` explicitly at every use site, except the `0`
  comes from the variant's declaration, not the call site (there is no call
  site; syntactically it's still a bare reference). This means these
  variants always heap-allocate, matching the Current State constraint
  above — the bare-int optimization is simply not applied to them.
- **Field access codegen (`self.n`):** mirrors the existing class field-load
  codegen exactly (`I32Load`/`I64Load`/`F64Load` at `(field_idx + 1) * 8`
  from the heap pointer — `+1` because slot 0 is always the tag, matching
  today's payload-variant layout) — the only change is resolving
  `field_idx`/width from the enum's declared params (via the new
  `EnumParams` lookup) instead of `ClassEnv`, when the receiver's static
  type is a discriminant enum rather than a class.
- **Pattern matching** (`match` against a discriminant-enum value by variant
  name, e.g. `match step \n READ_MIN_OCCURANCES => ...`) is unaffected —
  since these variants are now always heap pointers (never bare ints), the
  existing payload-variant tag-load-and-compare path in match codegen
  applies uniformly, with no need for the "is this a small int or a
  pointer" branch discriminant enums currently force ordinary enums to
  have (every variant of a discriminant enum is a pointer, so that branch
  is simply never taken for this kind of enum).

### Testing strategy

- Tree-sitter corpus: a discriminant-enum declaration (`enum Step(n: Int) =
  | READ_MIN_OCCURANCES(0) | ...`), confirming the new `enum_param` and the
  paren-form `enum_field` parameters parse into the expected tree; confirm
  today's bracket-form (`| Some[T]`) and truly nullary (`| Red`) variants
  are unaffected.
- `plum-checker` tests: correct arity/type-checked discriminant values pass;
  wrong arity, wrong value type, and mixing discriminant values into a
  param-less enum's variant each produce a clear error; `self.n` field
  access on a discriminant-enum method type-checks and resolves to the
  right type; `self.n` on an ordinary enum (no declared params) still
  errors exactly as it does today.
- `plum-wasm-codegen` tests: a method that reads `self.n` off a
  discriminant-enum value returns the declared literal for each variant
  (covering at least two different variants, to prove the field is
  correctly positioned per-instance, not hardcoded to one variant's
  value); confirm a discriminant-enum value used in `match` (destructuring
  by variant name, not by field) still resolves and runs correctly,
  exercising the "always a pointer, never a bare int" codegen path noted
  above.