plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/plans/2026-07-20-generic-enum-multi-instantiation.md
# Generic Enum Multi-Instantiation 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:** Remove the "a generic enum may only be instantiated at one concrete type per program" limitation, so `Option<Int>` and `Option<Str>` (or any two concrete instantiations of the same generic enum) can coexist in one program.
**Architecture:** Mangle variant names with the same suffix as their enum's own mangled name (`Some` → `Some$Int`/`Some$Str`, `None` → `None$Int`/`None$Str`), rewrite variant-construction call sites to reference the mangled variant name, and rewrite `match` pattern names against the correct specialization (resolved from the subject's inferred concrete type). All changes are contained inside the already-built `plum_checker::monomorphize` module — no new pipeline integration is needed. The existing collision-detection guard (which rejected a second instantiation rather than corrupting the first) becomes unnecessary and is removed, since variant names are now uniquely mangled per specialization.
**Important — current repository state:** the checker-side half of variant mangling (what was originally this plan's only task) is **already implemented, correct, and sitting uncommitted** in `plum-checker/src/monomorphize.rs`/`plum-checker/tests/checker_tests.rs`/`plum-wasm-codegen/tests/codegen_tests.rs` — do not revert or redo it. It was blocked from being committed by a real gap discovered during implementation (see Task 1 below), which must land first. Read the current state of `plum-checker/src/monomorphize.rs` before starting — it already contains `enum_variant_mangling`, the rewritten `resolve_enum_instantiation`, and `rewrite_stmt`'s `Match`-pattern rewriting.
**Tech Stack:** Rust (`plum-checker` crate only for this plan — `plum-core`/`plum-wasm-codegen` need no changes).
## Global Constraints
- **Narrower residual limitation, replacing the old one:** a payload-free variant (e.g. `None`) used as a *bare value outside of a `match` pattern* (i.e. parsed as `ast::Expr::TypeName`, not inside a case pattern) still can't be disambiguated between multiple concrete instantiations of its enum, since nothing at that expression alone pins down which instantiation it belongs to. This is out of scope to fix here — such usage will fail to resolve cleanly (an "unknown"/unmodeled-name error from the checker or codegen) rather than silently misbehaving, which is an acceptable, documented trade-off. Constructing via a payload-carrying sibling (`Some(5)`) and matching (`Some(v) => ...`, `None => ...`) — the overwhelmingly common usage pattern — is fully supported.
- **A method** (not a free function) introducing this same bare-generic-reference shape (a method on a non-generic class whose own param bare-names a generic class/enum) is out of scope for this plan, consistent with the existing "a method introducing its own additional generic parameter" limitation.
- A function that is simultaneously truly-generic (lowercase-letter params) *and* bare-references another generic type is out of scope (no current example needs it).
- Everything else the generics-monomorphization plan already scoped out (trait-bound enforcement; `libs/std` compiling as-is) is unchanged.
- Follow existing code style: terse one-line "why" comments only where non-obvious; error messages use the `"monomorphize: ..."` prefix.
- Must leave `cargo test --workspace` and `npx --yes tree-sitter-cli test` (from `tooling/tree-sitter-plum/`) green.
---
### Task 1: Specialize ordinary functions with a bare generic-class/enum-typed parameter
**Files:**
- Modify: `plum-checker/src/monomorphize.rs`
- Modify: `plum-checker/tests/checker_tests.rs`
- Modify: `plum-wasm-codegen/tests/codegen_tests.rs`
**Why this task exists:** implementing the (already-uncommitted) variant-mangling work surfaced a real, blocking gap: an *ordinary* function (no lowercase-letter generic params) that takes a bare generic-enum-typed parameter — the completely normal way to write this, e.g. `unwrapOr(o: Option, default: Int) -> Int`, the shape the **pre-existing** `generic_enum_specialized_and_matched_runs_correctly` codegen test already uses — never has that param's type resolved to a concrete specialization at all. `o`'s declared type stays the literal, unmangled `Option`; `match o` inside its body then infers a subject type of `TNamed("Option")`, a key that can never match the mangling table (keyed by `"Option$Int"`). This isn't an ordering bug — verified directly (including by reordering source) — the *key itself* never matches, no matter when discovery happens. The same root cause affects generic **classes** for the identical param shape (a bare `Box`-typed parameter), untested until now only because no prior test happened to exercise it.
**Interfaces:**
- Consumes: `enum_generic_params`, `class_generic_params`, `mangle`, `Substitution`, `specialize_fn` (all already exist, unchanged).
- Produces: two new `Monomorphizer` fields — `enums_generic_by_name: BTreeMap<String, &'a ast::Enum>` (keyed by the enum's own name, distinct from the existing `enums_generic_by_variant` keyed by variant name) and `fns_bare_generic: BTreeMap<String, &'a ast::Fn>` (free functions needing this new kind of specialization). A new method `Monomorphizer::fn_bare_generic_refs` and a new method `Monomorphizer::resolve_bare_generic_fn_instantiation`. Reuses the existing `PendingSpecialization::Fn` worklist variant unchanged — no new variant needed.
- [ ] **Step 1: Write failing tests**
Append to `plum-checker/tests/checker_tests.rs`:
```rust
#[test]
fn ordinary_function_with_bare_generic_enum_param_type_checks() {
// The shape that broke the pre-existing codegen test: an otherwise-ordinary
// function taking a bare generic-enum-typed parameter.
let src = "\
enum Option =
| Some(a)
| None
unwrapOr(o: Option, default: Int) -> Int =
match o
Some(v) =>
v
None =>
default
use() -> Int =
unwrapOr(Some(5), 0)
";
let source = parse(src);
let result = check_source(&source);
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
// Directly prove `unwrapOr` itself got specialized (not left bare/unresolved).
let mono = plum_checker::monomorphize::monomorphize_source(&source)
.expect("monomorphize should succeed");
let has_specialized_unwrap_or = mono.items.iter().any(|it| matches!(it, Item::Fn(f)
if f.name.starts_with("unwrapOr$") && f.type_param.is_none()));
assert!(has_specialized_unwrap_or, "expected a specialized `unwrapOr$...` function in the output");
}
#[test]
fn ordinary_function_with_bare_generic_class_param_type_checks() {
// The same shape, for a generic CLASS param instead of an enum — untested until
// now, but the identical root cause: `Box` is dropped from the monomorphized
// output, so a bare `Box`-typed param would otherwise reference nothing.
let src = "\
type Box(a) =
value: a
getBoxValue<Box>() -> a =
self.value
sumBox(b: Box) -> Int =
b.getBoxValue()
use() -> Int =
sumBox(Box(value: 5))
";
let source = parse(src);
let result = check_source(&source);
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
}
```
Read `plum-wasm-codegen/tests/codegen_tests.rs`'s existing `generic_enum_specialized_and_matched_runs_correctly` test (search for it) for its exact current shape — this task must not change that test, but should confirm (Step 4) that it now passes without modification. Then append a new test proving the class-side fix works end-to-end via wasm execution:
```rust
#[test]
fn ordinary_function_with_bare_generic_class_param_runs_correctly() {
let src = "\
type Box(a) =
value: a
getBoxValue<Box>() -> Int =
self.value
sumBox(b: Box) -> Int =
b.getBoxValue()
main() -> Int =
sumBox(Box(value: 11))
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 11);
}
```
- [ ] **Step 2: Run to see them fail**
Run: `cargo test -p plum-checker --test checker_tests ordinary_function_with_bare`
Expected: both fail — `check_source` returns an error (the bare `Option`/`Box` param never resolves).
Run: `cargo test -p plum-wasm-codegen --test codegen_tests generic_enum_specialized_and_matched_runs_correctly ordinary_function_with_bare_generic_class_param_runs_correctly`
Expected: both fail — this is the actual blocker (`generic_enum_specialized_and_matched_runs_correctly` is the pre-existing test, currently broken by the already-uncommitted variant-mangling changes; the new class-param test fails for the analogous reason).
- [ ] **Step 3: Read the current file, then make the edits**
Read `plum-checker/src/monomorphize.rs` in full first — it already contains the uncommitted variant-mangling work; verify each snippet below still matches before replacing it.
**3a. Add the two new `Monomorphizer` fields.** Find the struct definition (it currently has `enums_generic_by_variant` and `enum_variant_mangling` fields among others) and add, alongside them:
```rust
/// The enum's own bare name -> the generic `Enum` — used to detect a bare
/// generic-enum-typed function param (e.g. `o: Option`), distinct from
/// `enums_generic_by_variant` (keyed by VARIANT name, used for construction
/// sites like `Some(5)`).
enums_generic_by_name: BTreeMap<String, &'a ast::Enum>,
/// Free functions that are NOT generic by `fn_generic_params`'s lowercase-letter
/// convention, but whose param type(s) bare-name a generic class or enum (e.g.
/// `unwrapOr(o: Option, ...)`) — such a function still needs its own
/// per-call-site specialization, since its receiver generic class/enum is
/// dropped from the monomorphized output and the bare name would otherwise
/// resolve to nothing.
fns_bare_generic: BTreeMap<String, &'a ast::Fn>,
```
**3b. Add `fn_bare_generic_refs` and `resolve_bare_generic_fn_instantiation` methods** to `impl<'a> Monomorphizer<'a>`, anywhere alongside the other `resolve_*_instantiation` methods:
```rust
/// The bare names of any generic class or enum referenced directly (not via a
/// lowercase-letter generic parameter) in `f`'s param types — e.g. `"Option"` for
/// `unwrapOr(o: Option, default: Int) -> Int`. See `fns_bare_generic`'s doc
/// comment for why such a function needs its own specialization.
fn fn_bare_generic_refs(&self, f: &ast::Fn) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for p in &f.params {
let n = match &p.ty {
ast::ParamType::Type(t) => &t.name,
ast::ParamType::Variadic(t) => &t.name,
};
if (self.classes_generic.contains_key(n.as_str()) || self.enums_generic_by_name.contains_key(n.as_str()))
&& !names.iter().any(|x| x == n)
{
names.push(n.clone());
}
}
names
}
/// Resolves a call to an otherwise-ordinary function whose param type(s)
/// bare-name a generic class/enum, specializing it per call site exactly like a
/// truly-generic function — reusing the same `PendingSpecialization::Fn`
/// worklist entry and the unmodified `specialize_fn`, whose substitution
/// mechanism already replaces any type whose bare name matches a substitution
/// key (it doesn't care whether that key came from a lowercase-letter generic
/// parameter or a bare generic class/enum reference).
fn resolve_bare_generic_fn_instantiation(&mut self, call: &mut ast::FnCall, env: &TypeEnv) -> Result<(), String> {
let Some(f) = self.fns_bare_generic.get(call.name.as_str()).copied() else { return Ok(()) };
let refs = self.fn_bare_generic_refs(f);
let mut bindings: BTreeMap<String, PlumType> = BTreeMap::new();
for (param, arg) in f.params.iter().zip(call.args.iter()) {
let n = match ¶m.ty {
ast::ParamType::Type(t) => t.name.clone(),
ast::ParamType::Variadic(t) => t.name.clone(),
};
if refs.contains(&n) {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
bindings.entry(n).or_insert_with(|| self.infer(arg_expr, env));
}
}
if bindings.len() != refs.len() {
return Err(format!(
"monomorphize: could not resolve all generic parameters for '{}' at this call site",
call.name
));
}
let type_args: Vec<PlumType> = refs.iter().map(|p| bindings[p].clone()).collect();
let mangled = mangle(&call.name, &type_args);
if !self.specialized.contains(&mangled) && !self.enqueued.contains(&mangled) {
self.enqueued.insert(mangled.clone());
self.worklist.push(PendingSpecialization::Fn { base: f, subst: Substitution(bindings), mangled: mangled.clone(), new_receiver: None });
}
call.name = mangled;
Ok(())
}
```
**3c. Wire the new resolution into `rewrite_expr`'s `FnCall` arm.** Find:
```rust
self.resolve_enum_instantiation(call, env)?;
self.resolve_fn_instantiation(call, env)?;
```
and change to:
```rust
self.resolve_enum_instantiation(call, env)?;
self.resolve_fn_instantiation(call, env)?;
self.resolve_bare_generic_fn_instantiation(call, env)?;
```
**3d. Extend `maybe_rewrite_return` to also recognize a bare generic *enum* return** (it already recognizes a bare generic *class* return). Find:
```rust
Some(rt) => {
is_generic_param_name(&rt.name)
|| self.classes_generic.contains_key(&rt.name)
}
```
and change to:
```rust
Some(rt) => {
is_generic_param_name(&rt.name)
|| self.classes_generic.contains_key(&rt.name)
|| self.enums_generic_by_name.contains_key(&rt.name)
}
```
**3e. Populate `enums_generic_by_name` and classify `fns_bare_generic`** in `monomorphize_source`. Find the first classification loop (it currently populates `classes_generic` and, for each generic enum, loops over its variants to populate `enums_generic_by_variant`):
```rust
for item in &source.items {
match item {
ast::Item::Class(c) if !c.generics.is_empty() => { m.classes_generic.insert(c.name.clone(), c); }
ast::Item::Enum(e) if !enum_generic_params(e).is_empty() => {
for v in &e.variants {
m.enums_generic_by_variant.insert(v.name.clone(), e);
}
}
_ => {}
}
}
```
Change the `Enum` arm to also populate `enums_generic_by_name`:
```rust
for item in &source.items {
match item {
ast::Item::Class(c) if !c.generics.is_empty() => { m.classes_generic.insert(c.name.clone(), c); }
ast::Item::Enum(e) if !enum_generic_params(e).is_empty() => {
m.enums_generic_by_name.insert(e.name.clone(), e);
for v in &e.variants {
m.enums_generic_by_variant.insert(v.name.clone(), e);
}
}
_ => {}
}
}
```
Find the second loop (classifies `Fn` items into `methods_generic_on`/`fns_generic`):
```rust
for item in &source.items {
if let ast::Item::Fn(f) = item {
let receiver_is_generic = f.type_param.as_deref().map(|r| m.classes_generic.contains_key(r)).unwrap_or(false);
if receiver_is_generic {
m.methods_generic_on.entry(f.type_param.clone().unwrap()).or_default().push(f);
} else if f.type_param.is_none() && !fn_generic_params(f).is_empty() {
m.fns_generic.insert(f.name.clone(), f);
}
// A method whose receiver is NOT generic is left as a regular method below,
// even if its own params/return happen to use a bare lowercase-letter type
// name — that shape (a method introducing its own extra generic parameter)
// is out of scope for this pass; see the plan's Global Constraints.
}
}
```
Add a third `else if` branch:
```rust
for item in &source.items {
if let ast::Item::Fn(f) = item {
let receiver_is_generic = f.type_param.as_deref().map(|r| m.classes_generic.contains_key(r)).unwrap_or(false);
if receiver_is_generic {
m.methods_generic_on.entry(f.type_param.clone().unwrap()).or_default().push(f);
} else if f.type_param.is_none() && !fn_generic_params(f).is_empty() {
m.fns_generic.insert(f.name.clone(), f);
} else if f.type_param.is_none() && !m.fn_bare_generic_refs(f).is_empty() {
m.fns_bare_generic.insert(f.name.clone(), f);
}
// A method whose receiver is NOT generic is left as a regular method below,
// even if its own params/return happen to use a bare lowercase-letter type
// name, or bare-name a generic class/enum — those shapes are out of scope
// for this pass; see the plan's Global Constraints.
}
}
```
**3f. Exclude `fns_bare_generic` members from direct pass-through.** Find the third loop's `Fn` arm:
```rust
ast::Item::Fn(f) => {
let receiver_is_generic = f.type_param.as_deref().map(|r| m.classes_generic.contains_key(r)).unwrap_or(false);
let is_generic_fn = f.type_param.is_none() && !fn_generic_params(f).is_empty();
if !receiver_is_generic && !is_generic_fn {
let mut f2 = f.clone();
m.rewrite_fn_body(&mut f2, false)?;
m.produced.push(ast::Item::Fn(f2));
}
}
```
and change to:
```rust
ast::Item::Fn(f) => {
let receiver_is_generic = f.type_param.as_deref().map(|r| m.classes_generic.contains_key(r)).unwrap_or(false);
let is_generic_fn = f.type_param.is_none() && !fn_generic_params(f).is_empty();
let is_bare_generic_fn = f.type_param.is_none() && m.fns_bare_generic.contains_key(f.name.as_str());
if !receiver_is_generic && !is_generic_fn && !is_bare_generic_fn {
let mut f2 = f.clone();
m.rewrite_fn_body(&mut f2, false)?;
m.produced.push(ast::Item::Fn(f2));
}
}
```
**3g. Initialize the two new fields** in the `Monomorphizer` struct literal. Find:
```rust
enums_generic_by_variant: BTreeMap::new(),
enum_variant_mangling: BTreeMap::new(),
```
and change to:
```rust
enums_generic_by_variant: BTreeMap::new(),
enums_generic_by_name: BTreeMap::new(),
enum_variant_mangling: BTreeMap::new(),
fns_bare_generic: BTreeMap::new(),
```
- [ ] **Step 4: Run all the tests**
Run: `cargo test -p plum-checker --test checker_tests`
Expected: fully green, including the two new tests and the two pre-existing enum tests (`generic_enum_single_instantiation_type_checks`, `generic_enum_multiple_instantiations_coexist_and_type_check`) from the already-uncommitted work.
Run: `cargo test -p plum-wasm-codegen --test codegen_tests`
Expected: fully green, including — critically — the **pre-existing** `generic_enum_specialized_and_matched_runs_correctly` passing unchanged (proving this task's fix resolves the actual blocker), the new `ordinary_function_with_bare_generic_class_param_runs_correctly`, and the already-uncommitted `generic_enum_multiple_instantiations_coexist_and_run_correctly`.
- [ ] **Step 5: Update README (this was deferred by the blocked prior attempt)**
In `README.md`, replace the sentence (currently around line 243):
```markdown
One documented limitation: a generic *enum* may only be instantiated at one concrete type per program (instantiating the same generic enum at two different concrete types produces a clear `monomorphize:`-prefixed error, since the runtime's enum-variant table is keyed by bare variant name).
```
with:
```markdown
Generic enums support any number of concrete instantiations coexisting in one program (variant names are mangled per instantiation, e.g. `Some` -> `Some$Int`/`Some$Str`, internally — invisible to user code). One narrower residual limitation: a payload-free variant (e.g. `None`) used as a bare value *outside* of a `match` pattern can't be disambiguated between multiple concrete instantiations of its enum from that expression alone; constructing via a payload-carrying sibling (`Some(5)`) and matching (`Some(v) => ...`, `None => ...`) is fully supported and is the overwhelmingly common usage pattern.
```
- [ ] **Step 6: Run the full workspace and tree-sitter suites**
```bash
cargo test --workspace
cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
```
Expected: fully green (aside from the pre-existing, intentionally-`#[ignore]`d slow recursion-guard test, unaffected by this change).
- [ ] **Step 7: Commit**
This commit includes BOTH the already-uncommitted variant-mangling changes and this task's new bare-generic-ref specialization mechanism — they were never separately commit-able, since the mangling work only actually works once this task's fix lands.
```bash
git add plum-checker/src/monomorphize.rs plum-checker/tests/checker_tests.rs plum-wasm-codegen/tests/codegen_tests.rs README.md
git commit -m "feat(plum-checker): support multiple concrete instantiations of the same generic enum"
```