plum

#treesitter#compiler#wasm

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

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


docs/superpowers/plans/2026-07-24-enum-discriminant-values.md
# Enum Discriminant Values Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let an enum declare shared, uniformly-typed value fields (`enum Step(n: Int) = | READ_MIN_OCCURANCES(0) | ...`), readable via ordinary field access (`self.n`) on any instance regardless of variant, per `docs/superpowers/specs/2026-07-24-enum-discriminant-values-design.md`.

**Architecture:** Grammar (new `enum_param` rule + paren-form variant values, alongside today's unchanged bracket-form generic payload) → AST (`Enum.params`, `EnumVariant.values`) → parser → checker (arity/type validation against declared params; discriminant enums also get an entry in `ClassEnv`-shaped bookkeeping so field access can reuse the SAME offset-load mechanism a class field already uses, adjusted by a constant +1 slot for the leading tag — not literally inserted into `ClassEnv` itself, since that would misalign offsets by one slot; a small dedicated lookup path instead) → codegen (bare variant references belonging to a discriminant enum reuse the EXISTING payload-variant construction path via a synthetically-built `ast::FnCall` carrying the variant's declared literal values as its "arguments" — no new heap-layout code needed at all, since `[tag][field0][field1]...` is already exactly what payload variants use).

**Tech Stack:** tree-sitter (JS grammar), Rust (`plum-core`, `plum-checker`, `plum-wasm-codegen`).

## Global Constraints

- Spec: `docs/superpowers/specs/2026-07-24-enum-discriminant-values-design.md`
- A variant has either non-empty `fields` (today's generic-payload feature, bracket-delimited `[...]`) or non-empty `values` (this feature, paren-delimited `(...)`), never both — checker-enforced, not grammar-enforced.
- An enum with declared `params` requires every variant to supply exactly `params.len()` values, type-unified against the params' declared types.
- Field access (`self.n`) on a discriminant-enum value must load from offset `(param_idx + 1) * 8`**not** `param_idx * 8` — because slot 0 is always the tag, exactly matching today's payload-variant heap layout (`[tag: i32][field0][field1]...`). Getting this offset wrong is the single highest-risk mistake in this plan; Task 3's steps call this out explicitly.
- Ordinary enums (`Option`, `Result`, `Bool`, `Color`, ...) are completely unaffected — this is purely additive, gated on `params`/`values` being non-empty.
- Run `cargo test --workspace` and (from `tooling/tree-sitter-plum/`) `npx --yes tree-sitter-cli test` after every task.

---

### Task 1: Grammar — enum params and paren-form variant values

**Files:**
- Modify: `tooling/tree-sitter-plum/grammar.js`
- Modify: `tooling/tree-sitter-plum/test/corpus/enum.txt`

**Interfaces:**
- Consumes: nothing.
- Produces: `enum` gains an optional `field("params", ...)` before `"="`; `enum_field`'s existing `parameters` field gains a second alternative — parens containing an expression list (discriminant values) alongside the existing bracket-delimited type list.

- [ ] **Step 1: Read the current `enum`/`enum_field` rules first**

Read `tooling/tree-sitter-plum/grammar.js`'s current `enum` and `enum_field` rules directly — this plan's earlier research excerpts may have drifted from the exact current text (e.g. after Task 1 of the bracket-generics migration, `enum_field`'s parameters use `"[" commaSep1(choice($.type_identifier, $.generic)) "]"`). Use the actual current text as your starting point for the edits below, not this plan's paraphrase.

- [ ] **Step 2: Add `enum_param` and the `params` field to `enum`**

Add a new rule (near `generic_type`/`param`):

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

Extend `enum`'s rule to add an optional `params` field between the name and `"="` (mirroring how `class`'s `generics` field is positioned):

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

- [ ] **Step 3: Add the paren-form alternative to `enum_field`'s parameters**

Extend the existing bracket-only choice to also allow a paren-delimited expression list:

```js
    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
          )),
        ),
      ),
```

- [ ] **Step 4: Regenerate the parser**

Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate`
Expected: succeeds with no conflicts. If tree-sitter reports one (e.g. between the two `enum_field` parameter alternatives — an integer literal vs. a `type_identifier` can both start with different tokens, so this SHOULD be conflict-free, but verify), stop and report BLOCKED.

- [ ] **Step 5: Add corpus tests**

In `tooling/tree-sitter-plum/test/corpus/enum.txt`, add a new test block (match the file's existing header/divider format exactly — read it first):

```
================================================================================
enum - discriminant values
================================================================================

enum Step(n: Int) =
  | READ_MIN_OCCURANCES(0)
  | READ_MAX_OCCURANCES(1)
```

Do not hand-guess the expected S-expression tree — run Step 6 first, capture the REAL parser output, and paste that into the corpus file (same practice as the bracket-generics-migration and guard-clause-match plans' corpus steps).

- [ ] **Step 6: Run the corpus tests, fix the expected tree from real output, iterate to green**

Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test 2>&1 | tail -100`, using `npx --yes tree-sitter-cli parse -` on the new source (or a temp file) to get ground truth for the expected tree. Iterate until 100% pass including the new test AND every pre-existing test (in particular, re-confirm the EXISTING "enum - generic variant fields" test, `| Some[T]`, still passes unchanged — this task must not regress the bracket-form path).

- [ ] **Step 7: Commit**

```bash
git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/src tooling/tree-sitter-plum/test/corpus/enum.txt
git commit -m "feat(grammar): add enum discriminant-value declarations"
```

---

### Task 2: `plum-core` — AST and parser support

**Files:**
- Modify: `plum-core/src/ast.rs`
- Modify: `plum-core/src/parser.rs`
- Modify: `plum-core/tests/parser_test.rs`

**Interfaces:**
- Consumes: the regenerated grammar from Task 1 (`enum` node with an optional `"params"`-field child list of `enum_param` nodes; `enum_field` node whose `"parameters"` field may now contain either `type_identifier`/`generic` children (existing) OR arbitrary expression children (new)).
- Produces: `Enum.params: Vec<EnumParam>` (new, empty for ordinary enums); `EnumVariant.values: Vec<Expr>` (new, empty for ordinary variants).

- [ ] **Step 1: Add `EnumParam` and extend `Enum`/`EnumVariant` in `ast.rs`**

Read the current `Enum`/`EnumVariant` structs first (they may have shifted slightly). Add:

```rust
#[derive(Debug, Clone, PartialEq)]
pub struct EnumParam {
    pub name: String,
    pub ty: Type,
}
```

Extend `Enum`:

```rust
#[derive(Debug, Clone, PartialEq)]
pub struct Enum {
    pub name: String,
    pub params: Vec<EnumParam>,   // new; empty for an enum declared without "(...)"
    pub variants: Vec<EnumVariant>,
}
```

Extend `EnumVariant`:

```rust
#[derive(Debug, Clone, PartialEq)]
pub struct EnumVariant {
    pub name: String,
    pub fields: Vec<String>,  // existing, unchanged meaning
    pub values: Vec<Expr>,    // new; empty for an ordinary/generic variant
}
```

Every existing call site that constructs an `Enum`/`EnumVariant` literal (in `plum-core/src/parser.rs`, and any test fixture that builds one directly rather than through parsing) needs its `params: vec![]` / `values: vec![]` added — grep for `Enum {` and `EnumVariant {` across the workspace and fix each one.

- [ ] **Step 2: Parse `enum`'s new `params` field and `enum_field`'s value-list alternative**

Read `parse_enum`/`parse_enum_variant` in `plum-core/src/parser.rs` (their exact current bodies — this plan's earlier research summarized them but may be slightly stale). Extend `parse_enum` to also collect `enum_param` children into `Enum.params` (a small new helper `parse_enum_param(&self, node: Node) -> EnumParam` mirroring `parse_field`'s two-child-read shape: name then type). Extend `parse_enum_variant` so that when a `parameters`-field child is an expression node (not `type_identifier`/`generic`), it's parsed via `parse_expression`/`unwrap_expr_node` into `EnumVariant.values` instead of `EnumVariant.fields` — the two are populated from disjoint child-kind sets (type-identifier/generic-kind children go to `fields` as today; everything else under the same field position is an expression, goes to `values`).

- [ ] **Step 3: Add a parser test**

In `plum-core/tests/parser_test.rs`, parse the `Step` example from Task 1 and assert: `Enum.params == vec![EnumParam { name: "n", ty: Type { name: "Int", generics: vec![] } }]`, and each variant's `values` is `vec![Expr::Int(0)]` / `vec![Expr::Int(1)]` respectively, with `fields` empty for both.

- [ ] **Step 4: Run `plum-core`'s tests**

Run: `cargo test -p plum-core 2>&1 | tail -60`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add plum-core/src/ast.rs plum-core/src/parser.rs plum-core/tests/parser_test.rs
git commit -m "feat(plum-core): parse enum discriminant-value declarations"
```

---

### Task 3: `plum-checker` — validate discriminant values and enable field access

**Files:**
- Modify: `plum-checker/src/lib.rs`
- Modify: `plum-checker/tests/checker_tests.rs`

**Interfaces:**
- Consumes: `Enum.params`/`EnumVariant.values` from Task 2.
- Produces: `EnumVariantInfo` gains a `values: Vec<ast::Expr>` field (populated only for discriminant variants, consumed by `plum-wasm-codegen` in Task 4); arity/type validation errors for malformed discriminant declarations; `self.n`-style field access resolves correctly on a discriminant-enum receiver.

This is the highest-risk task in this plan — the field-access offset (`(param_idx + 1) * 8`, not `param_idx * 8`) is easy to get wrong, since it's tempting to just reuse `ClassEnv`'s exact field-access code path (which assumes NO leading tag slot). **Do not** insert a discriminant enum's params directly into `ClassEnv` — that would misalign every field by one 8-byte slot (it would read/write the tag instead of the actual value). Read the "Field access" bullet in the design spec's Codegen section again before starting this task if anything below is unclear.

- [ ] **Step 1: Read `build_global_tables`, `CheckCtx`, `EnumVariantInfo` and the field-access check function first**

Read their current exact bodies in `plum-checker/src/lib.rs` (file:line references from the design survey, which may have drifted: `EnumVariantInfo`/`EnumVariants`/`ClassEnv`/`CheckCtx` type defs around lines 44-66; `build_global_tables` around lines 72-143; the field-access check inside whatever function checks `ast::AttrKind::Field` — search for `"cannot access field"` or similar).

- [ ] **Step 2: Extend `EnumVariantInfo` with a `values` field**

```rust
#[derive(Debug, Clone, PartialEq)]
pub struct EnumVariantInfo {
    pub enum_name: String,
    pub tag: i32,
    pub field_types: Vec<PlumType>,
    pub values: Vec<ast::Expr>,  // new; non-empty only for a discriminant variant
}
```

Every existing construction site of `EnumVariantInfo` (there are at least two in `build_global_tables` — one for the built-in `Bool` variants, one in the enum-registration loop) needs `values: vec![]` added for the ordinary case.

- [ ] **Step 3: Populate `field_types`/`values` correctly for discriminant enums in `build_global_tables`**

The current enum-registration loop (inside `build_global_tables`'s first pass) computes each variant's `field_types` from `v.fields` (type-name strings). For a discriminant enum (`e.params` non-empty), EVERY variant's `field_types` must instead come from `e.params` (the same list, shared across all variants of that enum — this is what makes `self.n` resolve to a consistent type regardless of variant), and `values` comes from that specific variant's own `v.values`. Something like:

```rust
ast::Item::Enum(e) => {
    let shared_field_types: Vec<PlumType> = e.params.iter()
        .map(|p| plum_type_from_ast(&p.ty))
        .collect();
    for (tag, v) in e.variants.iter().enumerate() {
        let field_types = if !e.params.is_empty() {
            shared_field_types.clone()
        } else {
            v.fields.iter()
                .map(|f| plum_type_from_ast(&ast::Type { name: f.clone(), generics: vec![] }))
                .collect()
        };
        enum_variants.insert(v.name.clone(), EnumVariantInfo {
            enum_name: e.name.clone(),
            tag: tag as i32,
            field_types,
            values: v.values.clone(),
        });
    }
}
```

- [ ] **Step 4: Add arity/type validation for discriminant declarations**

Somewhere in `check_source` (or a small new helper called from it, once per `Item::Enum`), for every enum with non-empty `params`: every variant must have `values.len() == params.len()` (error otherwise, naming the variant and the expected/actual counts), and each value's inferred type (`infer_expr`) must unify against the corresponding param's declared type. For an enum with EMPTY `params`, every variant's `values` must also be empty — a non-empty `values` there is an error too (most likely caused by accidentally writing e.g. `Some(5)` where `Some[Int]` was meant). Add this validation pass; follow this file's existing conventions for where per-item declaration-level checks like this live (e.g. is there already a similar pass for classes/traits you can mirror the placement of).

- [ ] **Step 5: Enable field access on a discriminant enum receiver**

Add a new lookup table alongside `ClassEnv`/`EnumVariants` — reuse the exact same underlying shape (`Vec<(String, PlumType)>` per enum name) so the code reads almost identically to today's class-field lookup, but keep it a SEPARATE map (do not merge into `ClassEnv` — see the offset-misalignment warning above):

```rust
/// Field names and types for every discriminant enum's shared params (`enum Foo(n: Int) = ...`),
/// keyed by the ENUM's name (not a variant name) — e.g. `"Step" -> [("n", TInt)]`. Field
/// access on a value of this type must load/store at offset `(field_idx + 1) * 8`, NOT
/// `field_idx * 8` like a class — slot 0 is always the variant's tag.
pub type EnumParams = BTreeMap<String, Vec<(String, PlumType)>>;
```

Populate it in `build_global_tables` (one entry per discriminant enum, empty/no entry for ordinary enums), thread it through `CheckCtx` as a new field (`pub enum_params: &'a EnumParams`), and update `build_global_tables`'s return type/signature and its ONE call site in `check_source` (search for other callers too — `plum-wasm-codegen` calls this same function, see Task 4).

In the field-access check function, add a fallback: if `obj_ty`'s name isn't found in `ctx.classes`, check `ctx.enum_params` before falling through to today's permissive "unmodeled type" escape hatch — resolve the field the same way a class field would be (find by name, unify against its declared type), just sourced from `enum_params` instead of `classes`.

- [ ] **Step 6: Add checker tests**

In `plum-checker/tests/checker_tests.rs`:
1. The `Step` example type-checks with no errors.
2. A variant with the wrong number of discriminant values produces an error.
3. A variant with a wrongly-typed discriminant value (e.g. a `Str` literal where `Int` is declared) produces an error.
4. A non-empty `values` on a variant of a param-less (ordinary) enum produces an error.
5. `self.n` (or `obj.n`) field access inside a method on a discriminant-enum receiver type-checks and resolves to the declared param's type; the same on an ORDINARY enum (no `params`) still fails exactly as it does today.

- [ ] **Step 7: Run `plum-checker`'s tests**

Run: `cargo test -p plum-checker 2>&1 | tail -100`
Expected: PASS, including your new tests, and every pre-existing enum/field-access test.

- [ ] **Step 8: Commit**

```bash
git add plum-checker/src/lib.rs plum-checker/tests/checker_tests.rs
git commit -m "feat(plum-checker): validate enum discriminant values, enable field access on them"
```

---

### Task 4: `plum-wasm-codegen` — compile discriminant-enum construction and field access

**Files:**
- Modify: `plum-wasm-codegen/src/lib.rs`
- Modify: `plum-wasm-codegen/tests/codegen_tests.rs`

**Interfaces:**
- Consumes: `EnumVariantInfo.values`/`field_types` (Task 3), `EnumParams` (Task 3) threaded into `LocalCtx`.
- Produces: a bare discriminant-variant reference (`READ_MIN_OCCURANCES`) heap-allocates and stores `[tag][value0][value1]...` using the variant's OWN declared literal values (not call-site args, since there is no call site); `self.n` field access loads from `(field_idx + 1) * 8` off a discriminant-enum receiver.

- [ ] **Step 1: Read the current `compile_expr`'s `ast::Expr::TypeName` arm, `compile_variant_construction`, the `Attribute`/`Field` codegen arm, `build_global_tables`'s one call site, and the `Collector`'s `walk_expr` first**

These are all in `plum-wasm-codegen/src/lib.rs`; use file:line references from the design survey as a starting point but READ the actual current code — this is the largest, most detail-sensitive file touched by this plan.

- [ ] **Step 2: Thread `EnumParams` into `LocalCtx` and its one construction site**

Add `enum_params: &'a plum_checker::EnumParams` to the `LocalCtx` struct (alongside the existing `classes: &'a ClassEnv` field), and update `build_global_tables`'s call site (now returning an extra value) plus wherever `LocalCtx` is constructed (there appear to be two near-duplicate codegen entry points in this file, given the earlier survey found `match_scratch_base`/`match_scratch_index` set up twice, around lines ~1843 and ~3387 — both need the new field threaded through if both actually construct a full `LocalCtx`; verify this by reading, don't assume).

- [ ] **Step 3: Make a bare discriminant-variant reference construct correctly**

The current `ast::Expr::TypeName(n)` arm in `compile_expr` (search `ast::Expr::TypeName(n) => match ctx.enum_variants.get(n)`) special-cases `info.field_types.is_empty()` (bare tag) vs. non-empty (today: an error, "carries a payload — construct it with '...(...)'").  Add a third case: `field_types` non-empty AND `info.values` non-empty (a discriminant variant) — construct it via the EXISTING `compile_variant_construction` function, unchanged, by building a synthetic `ast::FnCall` whose `args` are `info.values` wrapped as `ast::Arg::Positional`:

```rust
ast::Expr::TypeName(n) => match ctx.enum_variants.get(n) {
    Some(info) if info.field_types.is_empty() => {
        Instruction::I32Const(info.tag).encode(body);
    }
    Some(info) if !info.values.is_empty() => {
        let synthetic_call = ast::FnCall {
            name: n.clone(),
            args: info.values.iter().cloned().map(ast::Arg::Positional).collect(),
        };
        compile_variant_construction(info, &synthetic_call, expr, body, ctx, state)?;
    }
    Some(_) => return Err(format!("codegen: '{}' carries a payload — construct it with '{}(...)'", n, n)),
    None => return Err(format!("codegen: type name '{}' is not yet supported as a value", n)),
},
```

`compile_variant_construction` needs NO changes — it only reads `call.args`/`call.name` and `expr`'s pointer identity for scratch-slot lookup, all of which the synthetic call/the original `expr` already provide correctly.

- [ ] **Step 4: Register a scratch slot for bare discriminant-variant references in the `Collector`**

`compile_variant_construction`'s heap-allocation path requires a `classcall_scratch` entry keyed by the `expr` pointer (see `ctx.classcall_scratch.get(&scratch_key)`, which errors with "internal codegen error: missing variant-call scratch slot" if absent). Today, the `Collector`'s `walk_expr` only registers this slot for `ast::Expr::ClassCall` and payload-carrying `ast::Expr::FnCall` (search `classcall_scratch.insert` — there are two near-duplicate `Collector`-like structs in this file per the earlier survey; both need this fix, verify by reading). Add a new arm for `ast::Expr::TypeName(n)`:

```rust
ast::Expr::TypeName(n) => {
    let carries_baked_in_payload = self.cctx.enum_variants.get(n)
        .map(|info| !info.values.is_empty())
        .unwrap_or(false);
    if carries_baked_in_payload {
        let idx = self.next_classcall_slot;
        self.next_classcall_slot += 1;
        self.classcall_scratch.insert(expr as *const ast::Expr as usize, idx);
    }
}
```

Match this file's existing style for where `ast::Expr::TypeName` currently falls in this match (it may currently be in a catch-all arm doing nothing — check, and add this specific arm before/instead of that catch-all as appropriate).

- [ ] **Step 5: Field access on a discriminant-enum receiver**

In `compile_expr`'s `ast::AttrKind::Field(field_name)` arm (search `"cannot access field"`), add a fallback: if `obj_ty`'s name isn't in `ctx.classes`, check `ctx.enum_params` before erroring — resolve `field_idx`/`field_ty` the same way the class path does, but compute `offset = ((field_idx + 1) as u64) * 8` (the `+1` is the critical difference — slot 0 is always the tag). Compile `attr.object` (pushes the heap pointer, exactly as the class path does) then load at that offset with the width matching `field_ty` (`I64Load`/`F64Load`/`I32Load`, same pattern as the class path).

- [ ] **Step 6: Add codegen tests**

In `plum-wasm-codegen/tests/codegen_tests.rs`:
1. A method reading `self.n` off a `Step`-shaped discriminant enum returns the correct declared value for at least two different variants (proving per-instance correctness, not a hardcoded constant) — compile and `run_main` an end-to-end program using the `Step` example.
2. A discriminant-enum value used in `match` (destructuring by variant NAME, not by its field) still resolves and runs correctly — since these variants are now always heap pointers (matching payload-variant layout), this should already work via the existing pattern-match codegen path unchanged; write a test proving it.

- [ ] **Step 7: Run the codegen tests**

Run: `cargo test -p plum-wasm-codegen 2>&1 | tail -100`
Expected: PASS, including your new tests and every pre-existing enum/field-access/pattern-match test (in particular, the payload-free-variant and payload-variant tests noted in the design spec's survey — confirm neither regressed).

- [ ] **Step 8: Run the full workspace suite**

Run: `cargo test --workspace 2>&1 | tail -100`
Expected: all PASS.

- [ ] **Step 9: Commit**

```bash
git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs
git commit -m "feat(plum-wasm-codegen): compile enum discriminant-value construction and field access"
```

---

### Task 5: Final verification

**Files:** none (verification only).

- [ ] **Step 1: Full workspace test suite**

Run: `cargo test --workspace 2>&1 | tail -100`
Expected: all PASS.

- [ ] **Step 2: Tree-sitter corpus suite**

Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test 2>&1 | tail -60`
Expected: all PASS.

- [ ] **Step 3: No commit needed** — verification only.