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-20-generic-enum-multi-instantiation-design.md
# Generic enums: support more than one concrete instantiation per program

## Problem

Generic enum monomorphization (from the prior generics-monomorphization work) has a real,
documented limitation: a given generic enum may only be instantiated at **one** concrete type per
program. Instantiating the same generic enum at two different concrete types (e.g. both
`Option<Int>` and `Option<Str>` needed in the same file) is detected and rejected with a clear
`monomorphize:` error rather than silently corrupting anything — but it's still a real limitation
for exactly the kind of general-purpose `Option`/`Result` a real program would want.

Root cause: `plum_checker::build_global_tables` builds `EnumVariants` — the table matching a
`match` pattern's or a construction call's bare variant name (`"Some"`, `"None"`) to its owning
enum, tag, and field types — keyed by **bare variant name, globally across the whole program**.
Two specializations of the same generic enum (`Option$Int`, `Option$Str`) both declare a variant
literally named `Some`, so they'd collide in that flat table. The existing monomorphizer detects
this collision and rejects it rather than letting one specialization silently clobber the other's
registration — correct, but overly conservative.

## Fix

Mangle variant names too, using the same suffix as their enum's own mangled name — `Some``Some$Int` / `Some$Str`, `None``None$Int` / `None$Str`. This is necessary even for
payload-free variants like `None`: although their runtime representation (a small tag, no
payload) is identical regardless of the concrete type argument, their *static type* differs per
instantiation (a bare `None` used as a value must type as `TNamed("Option$Int")` or
`TNamed("Option$Str")`, not an ambiguous, instantiation-independent `Option`), so leaving them
unmangled would still create real inference ambiguity even though it wouldn't corrupt anything
at the runtime-representation level.

This touches three points, all inside the already-built `plum_checker::monomorphize` pass — no
new pipeline integration is needed, since `monomorphize_source` already runs ahead of both
`check_source` and `compile_source`:

1. **`specialize_enum`**: extend it to rename each variant using the same mangled suffix as the
   enum's own name, not just the enum itself.
2. **Construction call-site rewriting** (`resolve_enum_instantiation`): currently deliberately
   does *not* rewrite `call.name` for a variant constructor like `Some(5)`, on the theory that
   variant names stay bare. That has to change now: once the enum's own instantiation is
   resolved, also rewrite the construction call's name from `"Some"` to its mangled form.
3. **Match-pattern rewriting** (new): `rewrite_stmt`'s existing `Match` handling infers the
   subject's type (already needed for binding a non-variant pattern name) but never touches the
   patterns themselves. It needs to: check whether the inferred subject type is a specialized
   generic enum, and if so, rewrite every `Some`/`None`-shaped pattern (`ast::CasePattern::Name`
   for a payload-free tag, `ast::CasePattern::Class` for a constructor pattern) in that match's
   cases to the corresponding mangled variant name, using a per-enum variant-mangling table
   recorded at the moment that enum specialization was produced.

With variant names uniquely mangled per specialization, the existing collision-detection
machinery (the `enum_variant_owner` ownership table, and the error path it guards) becomes
unnecessary and should be removed — there is no more collision to detect, since every
specialization's variants live under their own unique mangled names.

## Addendum: ordinary functions with a bare generic-typed param must be specialized too

Implementation surfaced a real, blocking gap the above design didn't account for, discovered
because it broke the **pre-existing** single-instantiation codegen test: an *ordinary* function
(one with no lowercase-letter generic params) that takes a bare generic-enum-typed parameter — the
completely normal way to write this, e.g.

```plum
unwrapOr(o: Option, default: Int) -> Int =
  match o
    Some(v) => v
    None => default
```

— never has its own param type resolved to a concrete specialization at all. `o`'s declared type
stays the literal, unmangled `Option`, so inside `unwrapOr`'s body, `match o` infers a subject
type of `TNamed("Option")` — a key that can never match the mangling table (keyed by the mangled
name, `"Option$Int"`). This isn't an ordering bug (verified directly, including by reordering
source); it's that ordinary functions are never treated as needing specialization at all today,
even though a bare generic-enum-typed param makes them behave exactly like a specialization
target. The same root cause almost certainly affects generic **classes** too, for the identical
shape (`f(b: Box) -> Int = ...`) — untested before now only because no existing test happened to
exercise it, but the failure mode is analogous (post-monomorphization, the bare generic class
`Box` no longer exists in the output at all, since only its mangled specializations survive).

**Fix, symmetric for both classes and enums:** treat an ordinary function whose param or return
type bare-names a generic class or enum as needing its own per-call-site specialization, exactly
mirroring how a truly-generic function (lowercase-letter params) is already specialized:

- **Return position** is half-solved already: `maybe_rewrite_return` already resolves a bare
  generic-*class* return (e.g. `-> Box`) from the body's inferred tail type, via the existing
  `self.classes_generic.contains_key(&rt.name)` check — it just never checked the enum registry.
  Add the parallel `self.enums_generic_by_name.contains_key(&rt.name)` check alongside it.
- **Param position** (the actual blocker) is new: detect, for an otherwise-ordinary function,
  every param whose declared type bare-names a generic class or enum (a new `fn_bare_generic_refs`
  helper, parallel in spirit to `fn_generic_params` but checking against the classes/enums
  registries instead of the lowercase-letter convention). Such a function is deferred (not passed
  through directly) and resolved at each call site: infer the calling argument's already-rewritten
  concrete type (e.g. the first argument to `unwrapOr(Some(13), 0)` is, after its own construction
  site is rewritten, `TNamed("Option$Int")`), bind `{"Option": TNamed("Option$Int")}` into a
  `Substitution`, mangle the function itself (`mangle("unwrapOr", &[TNamed("Option$Int")])` ->
  `"unwrapOr$Option$Int"`), and specialize it via the **existing, unmodified** `specialize_fn` —
  its substitution mechanism already walks and replaces any type whose bare name matches a
  substitution key, so no changes to `specialize_fn` itself are needed, only to how the
  substitution is discovered and populated for this new case.

Scoped to free functions only for this fix — a **method** introducing this same shape (a method
on a non-generic class whose own param bare-names a generic class/enum) is a further edge case,
consistent with the existing "a method introducing its own additional generic parameter" already
being out of scope. A function that is simultaneously truly-generic (lowercase letters) *and*
bare-references another generic type is also out of scope for now (no current example needs it).

## Testing plan

- **Checker tests**: a generic `Option`-shaped enum instantiated at two different concrete types
  in the same program (both `Option<Int>` and `Option<Str>` constructed and matched) type-checks
  correctly, each `match`'s patterns resolving against the correct specialization.
- **Codegen tests**: the same two-instantiation scenario, compiled and executed via `wasmtime`,
  confirming both specializations produce correct, non-aliasing results (mirroring the existing
  "generic class specialized at two types" test's shape, now extended to enums).
- Re-verify (don't just assume unaffected): a single-instantiation generic enum still works
  exactly as before now that the collision-guard code path is gone — the existing
  single-instantiation tests from the prior plan must still pass unchanged.
- Remove or repurpose the now-obsolete "multi-instantiation collision is a clear error" test from
  the prior plan, since that behavior is being replaced by "multi-instantiation now works
  correctly" — replace it with the new coexistence test above.
- The exact regression that surfaced the addendum's gap: an *ordinary* function taking a bare
  generic-enum-typed parameter (`unwrapOr(o: Option, default: Int) -> Int`), compiled and run via
  `wasmtime`, at both a single instantiation (proving no regression to the already-working case)
  and multiple coexisting instantiations. Add the analogous test for a generic **class** (an
  ordinary function taking a bare generic-class-typed parameter, e.g. `sumBox(b: Box) -> Int`),
  since the addendum's fix is symmetric and this shape was never tested before.

## Out of scope

- Everything else the generics-monomorphization plan already scoped out (a method introducing
  its own additional generic parameter; trait-bound enforcement; `libs/std` compiling as-is) —
  unchanged, unaffected by this fix.
- Closures and the `list.plum`/`map.plum` rewrite — separate, subsequent follow-ups.