plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/plans/2026-07-19-general-enum-support.md
# General Enum Support 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:** Make arbitrary concrete (non-generic) user-declared `enum`s — both payload-free (`Color = Red | Green | Blue`) and payload-carrying (`Option = Some(Int) | None`, multi-field `Rect(Float, Float)`) — type-check *precisely* and compile to working wasm, for both construction and `match`, generalizing the Bool-only special-casing that exists today.
**Architecture:** Every enum value is a single `i32`: a payload-free variant is its small integer tag directly; a payload variant is a bump-heap pointer to `[tag: i32][field0][field1]...` (same bump-alloc/store/load mechanism already used for class instances). `plum-checker`'s `EnumVariants` table gains a tag number and field types per variant so both the checker and `plum-wasm-codegen` can validate/construct/destructure without re-deriving that data from the AST. A small grammar fix is needed first: today's grammar only lets a capitalized callee use *named*-field syntax (`Cat(name: "x")`), so positional variant calls like `Some(v)` don't actually parse as an expression yet.
**Tech Stack:** Rust (workspace: `plum-core`, `plum-checker`, `plum-wasm-codegen`), tree-sitter grammar (`tooling/tree-sitter-plum`, JS), `wasm-encoder`/`wasmparser`/`wasmtime` for codegen tests.
## Global Constraints
- Out of scope: generics monomorphization, multi-subject `match`, any pattern kind beyond bare tag / constructor (these remain the documented "Known gaps"). Do not attempt them here.
- Enum variant sub-patterns inside a constructor pattern (`Some(v)`, `Pair(a, b)`) are flat bindings or `_` wildcards only — no nested constructor patterns. This matches the README's existing description ("binding its argument") and current `libs/std`/`examples` usage.
- Follow existing code style exactly: this codebase has no doc-comment scaffolding beyond one-line "why" comments: mirror the terse style already in `plum-checker/src/lib.rs` and `plum-wasm-codegen/src/lib.rs`.
- Every task must leave `cargo test --workspace` and (where grammar changed) `npx --yes tree-sitter-cli test` green before moving to the next task.
---
### Task 1: Grammar — positional variant-construction calls (`Some(v)`, `Pair(a, b)`)
**Files:**
- Modify: `tooling/tree-sitter-plum/grammar.js:55` (conflicts array), `tooling/tree-sitter-plum/grammar.js:387-394` (`fn_call` rule)
- Test: `tooling/tree-sitter-plum/test/corpus/function.txt` (append a new case)
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces: `fn_call` nodes whose `function` field can be a `type_identifier` (not just `var_identifier`). `plum-core`'s `parser.rs::parse_fn_call` (unchanged — it already reads the callee via `self.text(n)` regardless of node kind) will keep parsing these into `ast::FnCall { name, args }` exactly like any other call.
Today, `class_call`'s `class_argument_list` only accepts `name: value` pairs (see `type.txt` corpus: `Cat(name: name, age: 0)`), so a positional capitalized call like `Some(v)` or `Ok(x)` does not currently parse as an expression at all (it only exists as a *pattern*, via `class_pattern`). This task adds that missing expression-level syntax by letting `fn_call`'s callee be either a `var_identifier` or a `type_identifier` — the argument-list grammars already disambiguate cleanly (`class_argument_list` requires `name:`, `fn_argument_list` never does), so this doesn't change parsing of any existing `Cat(name: "x")`-style call.
- [ ] **Step 1: Edit `fn_call` to accept a capitalized callee**
In `tooling/tree-sitter-plum/grammar.js`, change:
```js
fn_call: ($) =>
prec(PREC.call, seq(
field("function", $.var_identifier),
field(
"arguments",
$.fn_argument_list,
),
)),
```
to:
```js
fn_call: ($) =>
prec(PREC.call, seq(
field("function", choice($.var_identifier, $.type_identifier)),
field(
"arguments",
$.fn_argument_list,
),
)),
```
- [ ] **Step 2: Declare the `fn_call`/`class_call` conflict and regenerate**
In the same file, change line 55 from:
```js
conflicts: ($) => [],
```
to:
```js
conflicts: ($) => [[$.fn_call, $.class_call]],
```
Run:
```bash
cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate
```
Expected: completes with no "Unresolved conflict" errors. (If it reports one anyway, that means the two rules are ambiguous on some input neither of us anticipated — read the reported example carefully before changing anything further; don't just silence it.)
- [ ] **Step 3: Add the input half of a new corpus case**
Append to `tooling/tree-sitter-plum/test/corpus/function.txt`:
```
================================================================================
function - variant construction call (positional args on a capitalized name)
================================================================================
makeSome(v: Int) -> Option =
Some(v)
--------------------------------------------------------------------------------
```
(Leave the expected-tree section empty for now — the next step generates it.)
- [ ] **Step 4: Generate the expected tree and verify it**
Run:
```bash
cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test -u -f "variant construction call"
```
Expected: the tool fills in the tree under the separator. Open `test/corpus/function.txt` and confirm the generated tree is `(source (fn (fn_identifier) (param (var_identifier) (type (type_identifier))) (return_type (type_identifier)) (body (primary_expression (fn_call (type_identifier) (fn_argument_list (expression (primary_expression (var_identifier)))))))))` (formatted one-node-per-line as the rest of the corpus already is) — i.e. the callee shows up as `(type_identifier)` inside `fn_call`, not `class_call`, and there is no `ERROR`/`MISSING` node anywhere.
- [ ] **Step 5: Run the full corpus suite**
Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test`
Expected: all cases pass, including every pre-existing `class_call` case in `type.txt` (proving the grammar change didn't regress named-field construction).
- [ ] **Step 6: Commit**
```bash
git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/test/corpus/function.txt
git add tooling/tree-sitter-plum/src # generated parser.c etc, if tracked
git commit -m "feat(tree-sitter-plum): allow positional variant-construction calls"
```
(If `tooling/tree-sitter-plum/src/parser.c` and friends are `.gitignore`d, drop that second `git add` — check `git status` first.)
---
### Task 2: Checker — per-variant tag + field types, and a real construction/pattern type check
**Files:**
- Modify: `plum-checker/src/lib.rs:37-492`
- Test: `plum-checker/tests/checker_tests.rs`
**Interfaces:**
- Consumes: nothing from Task 1 at the type level (checker doesn't care about *how* an expression parsed, only its `ast::Expr` shape) — but Task 1 is what makes `Some(v)`-as-`FnCall` reach the checker at all.
- Produces:
```rust
pub struct EnumVariantInfo {
pub enum_name: String,
pub tag: i32,
pub field_types: Vec<PlumType>,
}
pub type EnumVariants = BTreeMap<String, EnumVariantInfo>;
```
used by Tasks 3 and 4 (`plum-wasm-codegen`) via `ctx.enum_variants.get(name)`.
- [ ] **Step 1: Write failing checker tests for the new behavior**
Append to `plum-checker/tests/checker_tests.rs`:
```rust
#[test]
fn bare_enum_tag_unifies_with_owning_enum_type() {
// Regression: a bare non-Bool tag like `None` used to type as `TNamed("None")`
// (itself, not its enum), so comparing it against an `Option` value would wrongly
// fail with a type mismatch.
let src = "\
enum Option =
| Some(Int)
| None
isNone(o: Option) -> Bool =
o == None
";
let source = parse(src);
let result = check_source(&source);
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
}
#[test]
fn variant_construction_checks_arg_count_and_types() {
let src = "\
enum Option =
| Some(Int)
| None
makeSome(v: Int) -> Option =
Some(v)
";
let source = parse(src);
let result = check_source(&source);
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
}
#[test]
fn variant_construction_wrong_arg_type_is_error() {
let src = "\
enum Option =
| Some(Int)
| None
bad() -> Option =
Some(\"x\")
";
let source = parse(src);
let result = check_source(&source);
assert!(result.is_err());
}
#[test]
fn variant_construction_wrong_arg_count_is_error() {
let src = "\
enum Shape =
| Rect(Float, Float)
| Circle(Float)
bad() -> Shape =
Rect(1.0)
";
let source = parse(src);
let result = check_source(&source);
assert!(result.is_err());
}
#[test]
fn constructor_pattern_binds_fields_to_declared_types() {
let src = "\
enum Shape =
| Rect(Float, Float)
| Circle(Float)
area(s: Shape) -> Float =
match s
Rect(w, h) =>
w * h
Circle(r) =>
r * r
";
let source = parse(src);
let result = check_source(&source);
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
}
#[test]
fn constructor_pattern_wrong_field_count_is_error() {
let src = "\
enum Shape =
| Rect(Float, Float)
| Circle(Float)
bad(s: Shape) -> Float =
match s
Rect(w) =>
w
_ =>
0.0
";
let source = parse(src);
let result = check_source(&source);
assert!(result.is_err());
}
```
- [ ] **Step 2: Run the tests to see them fail**
Run: `cargo test -p plum-checker --test checker_tests`
Expected: `bare_enum_tag_unifies_with_owning_enum_type` fails (type mismatch: `Option` vs `None`); `variant_construction_wrong_arg_count_is_error` and `constructor_pattern_wrong_field_count_is_error` fail (currently these type-check permissively as `Ok`, since arg/field counts aren't validated yet); the other three currently happen to pass already (permissive fallback) — that's fine, they'll keep passing once implemented properly.
- [ ] **Step 3: Add `EnumVariantInfo` and rebuild `EnumVariants`**
In `plum-checker/src/lib.rs`, replace line 47-48:
```rust
/// Enum variant name -> owning enum name, e.g. `"True" -> "Bool"`.
pub type EnumVariants = BTreeMap<String, String>;
```
with:
```rust
/// Info about one `enum` variant: which enum it belongs to, its 0-based runtime tag
/// (numbering is shared across all of that enum's variants), and its payload field
/// types (empty for a payload-free variant like `Red` or `None`).
#[derive(Debug, Clone, PartialEq)]
pub struct EnumVariantInfo {
pub enum_name: String,
pub tag: i32,
pub field_types: Vec<PlumType>,
}
/// Enum variant name -> its info, e.g. `"True" -> { enum_name: "Bool", tag: 1, field_types: [] }`.
pub type EnumVariants = BTreeMap<String, EnumVariantInfo>;
```
Replace lines 66-70:
```rust
let mut enum_variants: EnumVariants = BTreeMap::new();
// `Bool`'s variants are built in (see `infer_expr`'s TypeName handling) rather
// than requiring every source file to redeclare `enum Bool = | True | False`.
enum_variants.insert("True".to_string(), "Bool".to_string());
enum_variants.insert("False".to_string(), "Bool".to_string());
```
with:
```rust
let mut enum_variants: EnumVariants = BTreeMap::new();
// `Bool`'s variants are built in (see `infer_expr`'s TypeName handling) rather
// than requiring every source file to redeclare `enum Bool = | True | False`.
enum_variants.insert("True".to_string(), EnumVariantInfo { enum_name: "Bool".to_string(), tag: 1, field_types: vec![] });
enum_variants.insert("False".to_string(), EnumVariantInfo { enum_name: "Bool".to_string(), tag: 0, field_types: vec![] });
```
Replace lines 82-86:
```rust
ast::Item::Enum(e) => {
for v in &e.variants {
enum_variants.insert(v.name.clone(), e.name.clone());
}
}
```
with:
```rust
ast::Item::Enum(e) => {
for (tag, v) in e.variants.iter().enumerate() {
let field_types = 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,
});
}
}
```
- [ ] **Step 4: Fix `TypeName` to type as the owning enum, not itself**
Replace lines 368-371:
```rust
ast::Expr::TypeName(n) => match n.as_str() {
"True" | "False" => Ok(PlumType::TBool),
other => Ok(PlumType::TNamed(other.to_string())),
},
```
with:
```rust
ast::Expr::TypeName(n) => match n.as_str() {
"True" | "False" => Ok(PlumType::TBool),
_ => match ctx.enum_variants.get(n) {
Some(info) => Ok(PlumType::TNamed(info.enum_name.clone())),
// Unmodeled/builtin type name: allow, codegen will catch.
None => Ok(PlumType::TNamed(n.to_string())),
},
},
```
- [ ] **Step 5: Type-check variant-construction `FnCall`s properly**
Replace lines 409-429:
```rust
ast::Expr::FnCall(call) => {
match lookup(env, &call.name) {
Ok(PlumType::TFun(param_types, ret)) => {
if call.args.len() != param_types.len() {
return Err(format!("call '{}': expected {} args, got {}", call.name, param_types.len(), call.args.len()));
}
for (i, (arg, expected)) in call.args.iter().zip(param_types.iter()).enumerate() {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
let actual = infer_expr(arg_expr, env, ctx)?;
unify(expected, &actual).map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
}
Ok(*ret)
}
Ok(_) => Err(format!("'{}' is not a function", call.name)),
Err(_) => Ok(PlumType::TVar("_".to_string())), // unknown fn: allow, codegen will catch
}
}
```
with:
```rust
ast::Expr::FnCall(call) => {
if let Some(info) = ctx.enum_variants.get(&call.name) {
if call.args.len() != info.field_types.len() {
return Err(format!(
"variant '{}': expected {} arg(s), got {}",
call.name, info.field_types.len(), call.args.len()
));
}
for (i, (arg, expected)) in call.args.iter().zip(info.field_types.iter()).enumerate() {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
let actual = infer_expr(arg_expr, env, ctx)?;
unify(expected, &actual).map_err(|e| format!("variant '{}' arg {}: {}", call.name, i, e))?;
}
return Ok(PlumType::TNamed(info.enum_name.clone()));
}
match lookup(env, &call.name) {
Ok(PlumType::TFun(param_types, ret)) => {
if call.args.len() != param_types.len() {
return Err(format!("call '{}': expected {} args, got {}", call.name, param_types.len(), call.args.len()));
}
for (i, (arg, expected)) in call.args.iter().zip(param_types.iter()).enumerate() {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
let actual = infer_expr(arg_expr, env, ctx)?;
unify(expected, &actual).map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
}
Ok(*ret)
}
Ok(_) => Err(format!("'{}' is not a function", call.name)),
Err(_) => Ok(PlumType::TVar("_".to_string())), // unknown fn: allow, codegen will catch
}
}
```
- [ ] **Step 6: Type-check constructor patterns against real field types**
Replace the doc comment and body at lines 333-359:
```rust
/// Checks a single case pattern against the type of the subject it matches, binding any
/// new names it introduces into `env`. Constructor-payload sub-patterns (`Some(x)`) bind
/// against an unconstrained type since enum variants don't carry per-field type info (v1.5).
fn check_pattern(pat: &ast::CasePattern, subject_ty: &PlumType, env: &mut TypeEnv, ctx: &CheckCtx) -> Result<(), String> {
match pat {
ast::CasePattern::Wildcard => Ok(()),
ast::CasePattern::Int(_) => unify(subject_ty, &PlumType::TInt),
ast::CasePattern::Float(_) => unify(subject_ty, &PlumType::TFloat),
ast::CasePattern::String(_) => unify(subject_ty, &PlumType::TStr),
ast::CasePattern::Name(n) => {
let is_known_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
&& ctx.enum_variants.contains_key(n);
if is_known_variant {
Ok(()) // equality check against a known enum tag, e.g. `True`
} else {
env.insert(n.clone(), TypeScheme::mono(subject_ty.clone()));
Ok(())
}
}
ast::CasePattern::Class { name: _, fields } => {
for f in fields {
check_pattern(f, &PlumType::TVar("_".to_string()), env, ctx)?;
}
Ok(())
}
}
}
```
with:
```rust
/// Checks a single case pattern against the type of the subject it matches, binding any
/// new names it introduces into `env`. Constructor-payload sub-patterns (`Some(x)`) bind
/// against that variant's declared field types (see `EnumVariantInfo::field_types`).
fn check_pattern(pat: &ast::CasePattern, subject_ty: &PlumType, env: &mut TypeEnv, ctx: &CheckCtx) -> Result<(), String> {
match pat {
ast::CasePattern::Wildcard => Ok(()),
ast::CasePattern::Int(_) => unify(subject_ty, &PlumType::TInt),
ast::CasePattern::Float(_) => unify(subject_ty, &PlumType::TFloat),
ast::CasePattern::String(_) => unify(subject_ty, &PlumType::TStr),
ast::CasePattern::Name(n) => {
let is_known_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
&& ctx.enum_variants.contains_key(n);
if is_known_variant {
Ok(()) // equality check against a known enum tag, e.g. `True`
} else {
env.insert(n.clone(), TypeScheme::mono(subject_ty.clone()));
Ok(())
}
}
ast::CasePattern::Class { name, fields } => match ctx.enum_variants.get(name) {
Some(info) => {
if fields.len() != info.field_types.len() {
return Err(format!(
"constructor pattern '{}' expects {} field(s), got {}",
name, info.field_types.len(), fields.len()
));
}
for (f, fty) in fields.iter().zip(info.field_types.iter()) {
check_pattern(f, fty, env, ctx)?;
}
Ok(())
}
// Unmodeled/builtin variant: allow, codegen will catch.
None => {
for f in fields {
check_pattern(f, &PlumType::TVar("_".to_string()), env, ctx)?;
}
Ok(())
}
},
}
}
```
- [ ] **Step 7: Run checker tests**
Run: `cargo test -p plum-checker --test checker_tests`
Expected: all pass, including the six new tests.
- [ ] **Step 8: Run the full checker crate + examples test**
Run: `cargo test -p plum-checker`
Expected: all pass (this includes `examples_test.rs`, which type-checks every file under `examples/` — `types.plum` and `match.plum` both already declare a concrete `Option`/`Color` enum, so this proves the fix doesn't regress them).
- [ ] **Step 9: Commit**
```bash
git add plum-checker/src/lib.rs plum-checker/tests/checker_tests.rs
git commit -m "feat(plum-checker): general enum variant tags, field types, and construction checks"
```
---
### Task 3: Codegen — variant construction (`Some(v)`, `Pair(a, b)` compile to a tagged heap struct)
**Files:**
- Modify: `plum-wasm-codegen/src/lib.rs:1-10` (import), `:450-500` (`Collector::walk_expr`), `:1040-1054` (`compile_expr`'s `FnCall` arm)
- Test: `plum-wasm-codegen/tests/codegen_tests.rs`
**Interfaces:**
- Consumes: `plum_checker::EnumVariantInfo` (Task 2) via `ctx.enum_variants.get(&call.name)` — `EnumVariantInfo { enum_name: String, tag: i32, field_types: Vec<PlumType> }`.
- Produces: a payload-free variant call/bare value compiles to `i32.const <tag>`; a payload variant call compiles to a bump-allocated `[tag][field0][field1]...` struct (8-byte stride per slot, same convention as class fields) with its base pointer left on the stack. Task 4 (match lowering) reads this same layout back out.
- [ ] **Step 1: Write failing codegen tests**
Append to `plum-wasm-codegen/tests/codegen_tests.rs`:
```rust
#[test]
fn payload_free_variant_construction_compiles() {
let src = "\
enum Color =
| Red
| Green
| Blue
main() -> Int =\n x = Green\n 0\n";
assert_valid(src);
}
#[test]
fn payload_variant_construction_compiles_and_runs() {
let src = "\
enum Option =
| Some(Int)
| None
unwrapOr(o: Option, default: Int) -> Int =
match o
Some(v) =>
return v
None =>
return default
main() -> Int =
unwrapOr(Some(7), 0)
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 7);
}
#[test]
fn multi_field_variant_construction_compiles_and_runs() {
let src = "\
enum Shape =
| Rect(Int, Int)
| Circle(Int)
area(s: Shape) -> Int =
match s
Rect(w, h) =>
return w * h
Circle(r) =>
return r * r
main() -> Int =
area(Rect(3, 4))
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 12);
}
```
(These also exercise Task 4's match lowering — that's expected; construction and destructuring are tested together since one is useless to test without the other. Task 4 will make the `Some`/`None`/`Rect`/`Circle` match arms actually compile.)
- [ ] **Step 2: Run to see them fail**
Run: `cargo test -p plum-wasm-codegen --test codegen_tests`
Expected: `payload_free_variant_construction_compiles` fails with `codegen: type name 'Green' is not yet supported as a value` (bare non-Bool `TypeName` isn't handled yet — that's fixed in Step 4 below); the other two fail with `codegen: enum variant pattern '...' is not yet supported (only True/False)` (Task 4's job) — confirm both failure modes appear, then proceed.
- [ ] **Step 3: Import `EnumVariantInfo`**
In `plum-wasm-codegen/src/lib.rs`, change line 6:
```rust
use plum_checker::{ClassEnv, MethodEnv, EnumVariants};
```
to:
```rust
use plum_checker::{ClassEnv, MethodEnv, EnumVariants, EnumVariantInfo};
```
- [ ] **Step 4: Make bare payload-free variants compile as their tag**
Replace lines 1063-1067:
```rust
ast::Expr::TypeName(n) => match n.as_str() {
"True" => Instruction::I32Const(1).encode(body),
"False" => Instruction::I32Const(0).encode(body),
other => return Err(format!("codegen: type name '{}' is not yet supported as a value", other)),
},
```
with:
```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(_) => 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)),
},
```
- [ ] **Step 5: Allocate a scratch slot for payload-variant construction in `Collector`**
In `Collector::walk_expr`, replace lines 480-484:
```rust
ast::Expr::FnCall(call) => {
for arg in &call.args {
self.walk_arg(arg);
}
}
```
with:
```rust
ast::Expr::FnCall(call) => {
let carries_payload = self.cctx.enum_variants.get(&call.name)
.map(|info| !info.field_types.is_empty())
.unwrap_or(false);
if carries_payload {
let idx = self.next_classcall_slot;
self.next_classcall_slot += 1;
self.classcall_scratch.insert(expr as *const ast::Expr as usize, idx);
}
for arg in &call.args {
self.walk_arg(arg);
}
}
```
(`classcall_scratch`/`next_classcall_slot` are the same pool `ClassCall` already uses — a scratch local slot per allocation-then-store expression. Both kinds of expression have distinct pointer identities, so sharing the pool is safe: no key collisions.)
- [ ] **Step 6: Compile payload-variant construction in `compile_expr`**
Replace lines 1040-1054:
```rust
ast::Expr::FnCall(call) => {
for arg in &call.args {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
compile_expr(arg_expr, body, ctx, state)?;
}
let func_idx = ctx
.func_ids
.get(&call.name)
.ok_or_else(|| format!("unknown function '{}'", call.name))?;
Instruction::Call(*func_idx).encode(body);
}
```
with:
```rust
ast::Expr::FnCall(call) => {
if let Some(info) = ctx.enum_variants.get(&call.name) {
compile_variant_construction(info, call, expr, body, ctx, state)?;
} else {
for arg in &call.args {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
compile_expr(arg_expr, body, ctx, state)?;
}
let func_idx = ctx
.func_ids
.get(&call.name)
.ok_or_else(|| format!("unknown function '{}'", call.name))?;
Instruction::Call(*func_idx).encode(body);
}
}
```
Then add this new function right after `compile_expr`'s closing brace (after line ~1178, before `plum_type_from_valtype_hint` or anywhere else at module scope):
```rust
/// Compiles a variant-construction call. A payload-free variant (`None`, called as
/// `None()` rather than used bare) is just its tag. A payload variant bump-allocates
/// `[tag: i32][field0][field1]...` (8-byte stride per slot, matching class field
/// layout) and leaves the base pointer on the stack.
fn compile_variant_construction(
info: &EnumVariantInfo,
call: &ast::FnCall,
expr: &ast::Expr,
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
if call.args.len() != info.field_types.len() {
return Err(format!(
"codegen: variant '{}' expects {} arg(s), got {}",
call.name, info.field_types.len(), call.args.len()
));
}
if info.field_types.is_empty() {
Instruction::I32Const(info.tag).encode(body);
return Ok(());
}
let size = (1 + info.field_types.len() as i32) * 8;
let scratch_key = expr as *const ast::Expr as usize;
let scratch_idx = *ctx
.classcall_scratch
.get(&scratch_key)
.ok_or_else(|| "internal codegen error: missing variant-call scratch slot".to_string())?;
let scratch_local = ctx.classcall_scratch_base + scratch_idx;
Instruction::GlobalGet(ctx.bump_global).encode(body);
Instruction::LocalSet(scratch_local).encode(body);
Instruction::GlobalGet(ctx.bump_global).encode(body);
Instruction::I32Const(size).encode(body);
Instruction::I32Add.encode(body);
Instruction::GlobalSet(ctx.bump_global).encode(body);
Instruction::LocalGet(scratch_local).encode(body);
Instruction::I32Const(info.tag).encode(body);
Instruction::I32Store(MemArg { offset: 0, align: 2, memory_index: 0 }).encode(body);
for (i, (arg, field_ty)) in call.args.iter().zip(info.field_types.iter()).enumerate() {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
Instruction::LocalGet(scratch_local).encode(body);
compile_expr(arg_expr, body, ctx, state)?;
let offset = ((i + 1) as u64) * 8;
match plum_type_to_valtype(field_ty) {
ValType::I64 => Instruction::I64Store(MemArg { offset, align: 3, memory_index: 0 }).encode(body),
ValType::F64 => Instruction::F64Store(MemArg { offset, align: 3, memory_index: 0 }).encode(body),
_ => Instruction::I32Store(MemArg { offset, align: 2, memory_index: 0 }).encode(body),
};
}
Instruction::LocalGet(scratch_local).encode(body);
Ok(())
}
```
- [ ] **Step 7: Run codegen tests**
Run: `cargo test -p plum-wasm-codegen --test codegen_tests`
Expected: `payload_free_variant_construction_compiles` now passes. The other two new tests still fail (match lowering isn't done yet — that's Task 4); confirm they now fail specifically inside `match`, not during construction (temporarily comment out their `match` bodies and replace with a literal return if you want to isolate-verify construction alone; then restore).
- [ ] **Step 8: 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 general enum variant construction"
```
---
### Task 4: Codegen — match lowering for general enum tags and constructor patterns
**Files:**
- Modify: `plum-wasm-codegen/src/lib.rs:420-445` (`Collector::walk_stmt`'s `Match` arm), `:844-929` (`compile_match_arms` / `compile_variant_eq_arm`)
- Test: `plum-wasm-codegen/tests/codegen_tests.rs` (Task 3's `payload_variant_construction_compiles_and_runs` and `multi_field_variant_construction_compiles_and_runs` will pass once this lands)
**Interfaces:**
- Consumes: `EnumVariantInfo` (Task 2), the bump-heap `[tag][field...]` layout (Task 3).
- Produces: nothing further downstream — this is the last piece of the feature.
- [ ] **Step 1: Confirm Task 3's two match-dependent tests still fail, for the right reason**
Run: `cargo test -p plum-wasm-codegen --test codegen_tests payload_variant_construction_compiles_and_runs multi_field_variant_construction_compiles_and_runs`
Expected: both fail with `codegen: enum variant pattern '...' is not yet supported (only True/False)` or `codegen: constructor match patterns are not yet supported`.
- [ ] **Step 2: Bind constructor-pattern fields to their real types in `Collector`**
Replace lines 431-444:
```rust
for case in &m.cases {
let saved = self.env.clone();
if m.subjects.len() == 1 {
if let Some(ast::CasePattern::Name(n)) = case.patterns.first() {
let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
&& self.cctx.enum_variants.contains_key(n);
if !is_variant {
self.bind(n, subject_ty.clone());
}
}
}
self.walk_block(&case.body);
self.env = saved;
}
```
with:
```rust
for case in &m.cases {
let saved = self.env.clone();
if m.subjects.len() == 1 {
match case.patterns.first() {
Some(ast::CasePattern::Name(n)) => {
let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
&& self.cctx.enum_variants.contains_key(n);
if !is_variant {
self.bind(n, subject_ty.clone());
}
}
Some(ast::CasePattern::Class { name, fields }) => {
if let Some(info) = self.cctx.enum_variants.get(name) {
let field_types = info.field_types.clone();
for (f, fty) in fields.iter().zip(field_types.iter()) {
if let ast::CasePattern::Name(n) = f {
self.bind(n, fty.clone());
}
}
}
}
_ => {}
}
}
self.walk_block(&case.body);
self.env = saved;
}
```
- [ ] **Step 3: Generalize the bare-tag match arm beyond True/False**
Replace lines 900-929 (`compile_variant_eq_arm`):
```rust
#[allow(clippy::too_many_arguments)]
fn compile_variant_eq_arm(
name: &str,
subject_vt: ValType,
scratch_local: u32,
case: &ast::Case,
rest: &[ast::Case],
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
// Only Bool's own variants have a concrete runtime representation in v1.5.
let tag = match name {
"True" => 1i32,
"False" => 0i32,
other => return Err(format!("codegen: enum variant pattern '{}' is not yet supported (only True/False)", other)),
};
if subject_vt != ValType::I32 {
return Err("codegen: Bool match pattern against a non-Bool subject".to_string());
}
Instruction::LocalGet(scratch_local).encode(body);
Instruction::I32Const(tag).encode(body);
Instruction::I32Eq.encode(body);
Instruction::If(BlockType::Empty).encode(body);
compile_block(&case.body, body, ctx, state)?;
Instruction::Else.encode(body);
compile_match_arms(rest, subject_vt, scratch_local, body, ctx, state)?;
Instruction::End.encode(body);
Ok(())
}
```
with:
```rust
#[allow(clippy::too_many_arguments)]
fn compile_variant_eq_arm(
name: &str,
subject_vt: ValType,
scratch_local: u32,
case: &ast::Case,
rest: &[ast::Case],
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
let info = ctx
.enum_variants
.get(name)
.ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
if subject_vt != ValType::I32 {
return Err(format!("codegen: enum tag pattern '{}' against a non-enum subject", name));
}
let tag = info.tag;
Instruction::LocalGet(scratch_local).encode(body);
Instruction::I32Const(tag).encode(body);
Instruction::I32Eq.encode(body);
Instruction::If(BlockType::Empty).encode(body);
compile_block(&case.body, body, ctx, state)?;
Instruction::Else.encode(body);
compile_match_arms(rest, subject_vt, scratch_local, body, ctx, state)?;
Instruction::End.encode(body);
Ok(())
}
```
- [ ] **Step 4: Implement the constructor-pattern match arm**
In `compile_match_arms`, replace line 896:
```rust
ast::CasePattern::Class { .. } => Err("codegen: constructor match patterns are not yet supported".to_string()),
```
with:
```rust
ast::CasePattern::Class { name, fields } => {
compile_variant_constructor_arm(name, fields, subject_vt, scratch_local, case, rest, body, ctx, state)
}
```
Then add this new function right after `compile_variant_eq_arm`:
```rust
#[allow(clippy::too_many_arguments)]
fn compile_variant_constructor_arm(
name: &str,
fields: &[ast::CasePattern],
subject_vt: ValType,
scratch_local: u32,
case: &ast::Case,
rest: &[ast::Case],
body: &mut Vec<u8>,
ctx: &LocalCtx,
state: &mut ModuleState,
) -> Result<(), String> {
let info = ctx
.enum_variants
.get(name)
.ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
if subject_vt != ValType::I32 {
return Err(format!("codegen: constructor pattern '{}' against a non-enum subject", name));
}
if fields.len() != info.field_types.len() {
return Err(format!(
"codegen: constructor pattern '{}' expects {} field(s), got {}",
name, info.field_types.len(), fields.len()
));
}
let tag = info.tag;
let field_types = info.field_types.clone();
Instruction::LocalGet(scratch_local).encode(body);
Instruction::I32Load(MemArg { offset: 0, align: 2, memory_index: 0 }).encode(body);
Instruction::I32Const(tag).encode(body);
Instruction::I32Eq.encode(body);
Instruction::If(BlockType::Empty).encode(body);
for (i, (pat, field_ty)) in fields.iter().zip(field_types.iter()).enumerate() {
let bind_name = match pat {
ast::CasePattern::Name(n) => Some(n.as_str()),
ast::CasePattern::Wildcard => None,
_ => return Err("codegen: only bare bindings or '_' are supported inside a constructor pattern".to_string()),
};
if let Some(n) = bind_name {
let idx = ctx
.locals
.get(n)
.copied()
.ok_or_else(|| format!("internal codegen error: missing binding local '{}'", n))?;
Instruction::LocalGet(scratch_local).encode(body);
let offset = ((i + 1) as u64) * 8;
match plum_type_to_valtype(field_ty) {
ValType::I64 => Instruction::I64Load(MemArg { offset, align: 3, memory_index: 0 }),
ValType::F64 => Instruction::F64Load(MemArg { offset, align: 3, memory_index: 0 }),
_ => Instruction::I32Load(MemArg { offset, align: 2, memory_index: 0 }),
}.encode(body);
Instruction::LocalSet(idx).encode(body);
ctx.type_env.borrow_mut().insert(n.to_string(), TypeScheme::mono(field_ty.clone()));
}
}
compile_block(&case.body, body, ctx, state)?;
Instruction::Else.encode(body);
compile_match_arms(rest, subject_vt, scratch_local, body, ctx, state)?;
Instruction::End.encode(body);
Ok(())
}
```
- [ ] **Step 5: Run codegen tests**
Run: `cargo test -p plum-wasm-codegen --test codegen_tests`
Expected: all pass, including Task 3's `payload_variant_construction_compiles_and_runs` (returns 7) and `multi_field_variant_construction_compiles_and_runs` (returns 12).
- [ ] **Step 6: Add a dedicated test for a payload-free variant tag pattern beyond Bool, and one for `_`-wildcard inside a constructor pattern**
Append to `plum-wasm-codegen/tests/codegen_tests.rs`:
```rust
#[test]
fn non_bool_bare_tag_pattern_runs_correctly() {
let src = "\
enum Color =
| Red
| Green
| Blue
code(c: Color) -> Int =
match c
Red =>
return 1
Green =>
return 2
Blue =>
return 3
main() -> Int =
code(Green)
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 2);
}
#[test]
fn constructor_pattern_wildcard_field_runs_correctly() {
let src = "\
enum Option =
| Some(Int)
| None
isSome(o: Option) -> Int =
match o
Some(_) =>
return 1
None =>
return 0
main() -> Int =
isSome(Some(99))
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 1);
}
```
Run: `cargo test -p plum-wasm-codegen --test codegen_tests`
Expected: both pass.
- [ ] **Step 7: Run the full workspace test suite**
Run: `cargo test --workspace`
Expected: all green, including `plum-wasm-codegen`'s `examples_test.rs` (see Task 5 — its `match_example_reports_clear_unsupported_pattern_errors` test will now fail because `match.plum` compiles successfully; that's expected and fixed in the next task, not this one).
- [ ] **Step 8: 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 general enum match patterns (tags + constructors)"
```
---
### Task 5: Examples, outdated test expectations, and docs
**Files:**
- Modify: `plum-wasm-codegen/tests/examples_test.rs:69-78`
- Modify: `examples/match.plum` (add a `main` exercising construction, so the example is executed, not just compiled)
- Modify: `README.md` (Known gaps section)
- Test: same files above
**Interfaces:**
- Consumes: everything from Tasks 1-4.
- Produces: nothing further — this is the final integration/documentation task.
- [ ] **Step 1: Fix the now-outdated "expect error" example test**
`match.plum` no longer fails to compile — the `match_example_reports_clear_unsupported_pattern_errors` test in `plum-wasm-codegen/tests/examples_test.rs` currently asserts it does. In `plum-wasm-codegen/tests/examples_test.rs`, replace lines 69-78:
```rust
/// match.plum and strings.plum intentionally exercise syntax beyond what codegen
/// currently lowers (non-Bool enum-tag/constructor match patterns, string
/// interpolation) — they must fail loudly with a clear message, not silently
/// produce wrong wasm.
#[test]
fn match_example_reports_clear_unsupported_pattern_errors() {
let source = parse_file("match.plum");
let err = compile_source(&source).expect_err("non-Bool enum-tag patterns are not yet supported");
assert!(err.contains("enum variant pattern"), "got: {}", err);
}
```
with:
```rust
/// match.plum now exercises fully-supported syntax (general enum tag and
/// constructor patterns) and must compile and run correctly end to end.
#[test]
fn match_example_compiles_and_runs_correctly() {
let bytes = assert_compiles("match.plum");
let engine = wasmtime::Engine::default();
let module = wasmtime::Module::new(&engine, &bytes).expect("module should be loadable");
let mut store = wasmtime::Store::new(&engine, ());
let instance = wasmtime::Instance::new(&mut store, &module, &[]).expect("module should instantiate");
let main = instance
.get_typed_func::<(), i64>(&mut store, "main")
.expect("main should have signature () -> i64");
let result = main.call(&mut store, ()).expect("main should not trap");
// describeOption(Some(5)) = 5
assert_eq!(result, 5);
}
/// strings.plum still exercises string interpolation, which remains unimplemented.
#[test]
fn strings_example_reports_clear_interpolation_error() {
let source = parse_file("strings.plum");
let err = compile_source(&source).expect_err("string interpolation is not yet supported");
assert!(err.contains("interpolation"), "got: {}", err);
}
```
Then delete the now-duplicate copy of the same test that already exists lower in the file (originally at lines 80-85, now shifted down by the edit above) — find and remove this exact block, leaving only the one just added above:
```rust
#[test]
fn strings_example_reports_clear_interpolation_error() {
let source = parse_file("strings.plum");
let err = compile_source(&source).expect_err("string interpolation is not yet supported");
assert!(err.contains("interpolation"), "got: {}", err);
}
```
(There must be exactly one `strings_example_reports_clear_interpolation_error` function left in the file — `cargo test` will fail to compile with a "duplicate definition" error if both remain.)
- [ ] **Step 2: Add a `main` to `examples/match.plum` so it's actually executed, not just compiled**
Append to `examples/match.plum`:
```plum
main() -> Int =
describeOption(Some(5))
```
- [ ] **Step 3: Run codegen tests**
Run: `cargo test -p plum-wasm-codegen --test examples_test`
Expected: `match_example_compiles_and_runs_correctly` passes (returns 5), `strings_example_reports_clear_interpolation_error` still passes.
- [ ] **Step 4: Update the README's Known Gaps section**
In `README.md`, find (around line 319-327):
```markdown
### Known gaps
Some things parse and type-check but don't compile to wasm yet — `plum-wasm-codegen` reports a clear error rather than silently producing wrong code:
- string interpolation (plain, non-interpolated string literals do compile)
- `match` patterns other than integer literals, bindings, wildcard, and `True`/`False`; non-Bool enum-tag and constructor (`Some(v)`) patterns aren't lowered yet
- multi-subject `match` (`match a, b`)
- user-defined generics (they type-check but aren't monomorphized)
```
Replace with:
```markdown
### Known gaps
Some things parse and type-check but don't compile to wasm yet — `plum-wasm-codegen` reports a clear error rather than silently producing wrong code:
- string interpolation (plain, non-interpolated string literals do compile)
- multi-subject `match` (`match a, b`)
- user-defined generics (they type-check but aren't monomorphized) — this also blocks `libs/std`'s actual `Option`/`Result`/`List`/`Map`, which are declared generically
- nested constructor patterns inside `match` (`Some(Some(v))`) — a constructor pattern's own sub-patterns must be a bare binding or `_`
```
Also update the `match` section's prose just above it (around line 304, "Multiple comma-separated subjects/patterns are accepted by the grammar but not yet lowered by codegen.") — check whether it still needs the caveat about non-Bool enum tags; if that sentence mentions the now-fixed gap, trim it to talk only about multi-subject match remaining unsupported.
- [ ] **Step 5: Run the full test suite one more time**
Run:
```bash
cargo test --workspace
cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
```
Expected: everything green.
- [ ] **Step 6: Commit**
```bash
git add plum-wasm-codegen/tests/examples_test.rs examples/match.plum README.md
git commit -m "docs+test: general enum support is complete; update known gaps and example"
```