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
32da118 1
# Generic enums: support more than one concrete instantiation per program
32da118 2
32da118 3
## Problem
32da118 4
32da118 5
Generic enum monomorphization (from the prior generics-monomorphization work) has a real,
32da118 6
documented limitation: a given generic enum may only be instantiated at **one** concrete type per
32da118 7
program. Instantiating the same generic enum at two different concrete types (e.g. both
32da118 8
`Option<Int>` and `Option<Str>` needed in the same file) is detected and rejected with a clear
32da118 9
`monomorphize:` error rather than silently corrupting anything — but it's still a real limitation
32da118 10
for exactly the kind of general-purpose `Option`/`Result` a real program would want.
32da118 11
32da118 12
Root cause: `plum_checker::build_global_tables` builds `EnumVariants` — the table matching a
32da118 13
`match` pattern's or a construction call's bare variant name (`"Some"`, `"None"`) to its owning
32da118 14
enum, tag, and field types — keyed by **bare variant name, globally across the whole program**.
32da118 15
Two specializations of the same generic enum (`Option$Int`, `Option$Str`) both declare a variant
32da118 16
literally named `Some`, so they'd collide in that flat table. The existing monomorphizer detects
32da118 17
this collision and rejects it rather than letting one specialization silently clobber the other's
32da118 18
registration — correct, but overly conservative.
32da118 19
32da118 20
## Fix
32da118 21
32da118 22
Mangle variant names too, using the same suffix as their enum's own mangled name — `Some` →
32da118 23
`Some$Int` / `Some$Str`, `None` → `None$Int` / `None$Str`. This is necessary even for
32da118 24
payload-free variants like `None`: although their runtime representation (a small tag, no
32da118 25
payload) is identical regardless of the concrete type argument, their *static type* differs per
32da118 26
instantiation (a bare `None` used as a value must type as `TNamed("Option$Int")` or
32da118 27
`TNamed("Option$Str")`, not an ambiguous, instantiation-independent `Option`), so leaving them
32da118 28
unmangled would still create real inference ambiguity even though it wouldn't corrupt anything
32da118 29
at the runtime-representation level.
32da118 30
32da118 31
This touches three points, all inside the already-built `plum_checker::monomorphize` pass — no
32da118 32
new pipeline integration is needed, since `monomorphize_source` already runs ahead of both
32da118 33
`check_source` and `compile_source`:
32da118 34
32da118 35
1. **`specialize_enum`**: extend it to rename each variant using the same mangled suffix as the
32da118 36
   enum's own name, not just the enum itself.
32da118 37
2. **Construction call-site rewriting** (`resolve_enum_instantiation`): currently deliberately
32da118 38
   does *not* rewrite `call.name` for a variant constructor like `Some(5)`, on the theory that
32da118 39
   variant names stay bare. That has to change now: once the enum's own instantiation is
32da118 40
   resolved, also rewrite the construction call's name from `"Some"` to its mangled form.
32da118 41
3. **Match-pattern rewriting** (new): `rewrite_stmt`'s existing `Match` handling infers the
32da118 42
   subject's type (already needed for binding a non-variant pattern name) but never touches the
32da118 43
   patterns themselves. It needs to: check whether the inferred subject type is a specialized
32da118 44
   generic enum, and if so, rewrite every `Some`/`None`-shaped pattern (`ast::CasePattern::Name`
32da118 45
   for a payload-free tag, `ast::CasePattern::Class` for a constructor pattern) in that match's
32da118 46
   cases to the corresponding mangled variant name, using a per-enum variant-mangling table
32da118 47
   recorded at the moment that enum specialization was produced.
32da118 48
32da118 49
With variant names uniquely mangled per specialization, the existing collision-detection
32da118 50
machinery (the `enum_variant_owner` ownership table, and the error path it guards) becomes
32da118 51
unnecessary and should be removed — there is no more collision to detect, since every
32da118 52
specialization's variants live under their own unique mangled names.
32da118 53
84e946f 54
## Addendum: ordinary functions with a bare generic-typed param must be specialized too
84e946f 55
84e946f 56
Implementation surfaced a real, blocking gap the above design didn't account for, discovered
84e946f 57
because it broke the **pre-existing** single-instantiation codegen test: an *ordinary* function
84e946f 58
(one with no lowercase-letter generic params) that takes a bare generic-enum-typed parameter — the
84e946f 59
completely normal way to write this, e.g.
84e946f 60
84e946f 61
```plum
84e946f 62
unwrapOr(o: Option, default: Int) -> Int =
84e946f 63
  match o
84e946f 64
    Some(v) => v
84e946f 65
    None => default
84e946f 66
```
84e946f 67
84e946f 68
— never has its own param type resolved to a concrete specialization at all. `o`'s declared type
84e946f 69
stays the literal, unmangled `Option`, so inside `unwrapOr`'s body, `match o` infers a subject
84e946f 70
type of `TNamed("Option")` — a key that can never match the mangling table (keyed by the mangled
84e946f 71
name, `"Option$Int"`). This isn't an ordering bug (verified directly, including by reordering
84e946f 72
source); it's that ordinary functions are never treated as needing specialization at all today,
84e946f 73
even though a bare generic-enum-typed param makes them behave exactly like a specialization
84e946f 74
target. The same root cause almost certainly affects generic **classes** too, for the identical
84e946f 75
shape (`f(b: Box) -> Int = ...`) — untested before now only because no existing test happened to
84e946f 76
exercise it, but the failure mode is analogous (post-monomorphization, the bare generic class
84e946f 77
`Box` no longer exists in the output at all, since only its mangled specializations survive).
84e946f 78
84e946f 79
**Fix, symmetric for both classes and enums:** treat an ordinary function whose param or return
84e946f 80
type bare-names a generic class or enum as needing its own per-call-site specialization, exactly
84e946f 81
mirroring how a truly-generic function (lowercase-letter params) is already specialized:
84e946f 82
84e946f 83
- **Return position** is half-solved already: `maybe_rewrite_return` already resolves a bare
84e946f 84
  generic-*class* return (e.g. `-> Box`) from the body's inferred tail type, via the existing
84e946f 85
  `self.classes_generic.contains_key(&rt.name)` check — it just never checked the enum registry.
84e946f 86
  Add the parallel `self.enums_generic_by_name.contains_key(&rt.name)` check alongside it.
84e946f 87
- **Param position** (the actual blocker) is new: detect, for an otherwise-ordinary function,
84e946f 88
  every param whose declared type bare-names a generic class or enum (a new `fn_bare_generic_refs`
84e946f 89
  helper, parallel in spirit to `fn_generic_params` but checking against the classes/enums
84e946f 90
  registries instead of the lowercase-letter convention). Such a function is deferred (not passed
84e946f 91
  through directly) and resolved at each call site: infer the calling argument's already-rewritten
84e946f 92
  concrete type (e.g. the first argument to `unwrapOr(Some(13), 0)` is, after its own construction
84e946f 93
  site is rewritten, `TNamed("Option$Int")`), bind `{"Option": TNamed("Option$Int")}` into a
84e946f 94
  `Substitution`, mangle the function itself (`mangle("unwrapOr", &[TNamed("Option$Int")])` ->
84e946f 95
  `"unwrapOr$Option$Int"`), and specialize it via the **existing, unmodified** `specialize_fn` —
84e946f 96
  its substitution mechanism already walks and replaces any type whose bare name matches a
84e946f 97
  substitution key, so no changes to `specialize_fn` itself are needed, only to how the
84e946f 98
  substitution is discovered and populated for this new case.
84e946f 99
84e946f 100
Scoped to free functions only for this fix — a **method** introducing this same shape (a method
84e946f 101
on a non-generic class whose own param bare-names a generic class/enum) is a further edge case,
84e946f 102
consistent with the existing "a method introducing its own additional generic parameter" already
84e946f 103
being out of scope. A function that is simultaneously truly-generic (lowercase letters) *and*
84e946f 104
bare-references another generic type is also out of scope for now (no current example needs it).
84e946f 105
32da118 106
## Testing plan
32da118 107
32da118 108
- **Checker tests**: a generic `Option`-shaped enum instantiated at two different concrete types
32da118 109
  in the same program (both `Option<Int>` and `Option<Str>` constructed and matched) type-checks
32da118 110
  correctly, each `match`'s patterns resolving against the correct specialization.
32da118 111
- **Codegen tests**: the same two-instantiation scenario, compiled and executed via `wasmtime`,
32da118 112
  confirming both specializations produce correct, non-aliasing results (mirroring the existing
32da118 113
  "generic class specialized at two types" test's shape, now extended to enums).
32da118 114
- Re-verify (don't just assume unaffected): a single-instantiation generic enum still works
32da118 115
  exactly as before now that the collision-guard code path is gone — the existing
32da118 116
  single-instantiation tests from the prior plan must still pass unchanged.
32da118 117
- Remove or repurpose the now-obsolete "multi-instantiation collision is a clear error" test from
32da118 118
  the prior plan, since that behavior is being replaced by "multi-instantiation now works
32da118 119
  correctly" — replace it with the new coexistence test above.
84e946f 120
- The exact regression that surfaced the addendum's gap: an *ordinary* function taking a bare
84e946f 121
  generic-enum-typed parameter (`unwrapOr(o: Option, default: Int) -> Int`), compiled and run via
84e946f 122
  `wasmtime`, at both a single instantiation (proving no regression to the already-working case)
84e946f 123
  and multiple coexisting instantiations. Add the analogous test for a generic **class** (an
84e946f 124
  ordinary function taking a bare generic-class-typed parameter, e.g. `sumBox(b: Box) -> Int`),
84e946f 125
  since the addendum's fix is symmetric and this shape was never tested before.
32da118 126
32da118 127
## Out of scope
32da118 128
32da118 129
- Everything else the generics-monomorphization plan already scoped out (a method introducing
32da118 130
  its own additional generic parameter; trait-bound enforcement; `libs/std` compiling as-is) —
32da118 131
  unchanged, unaffected by this fix.
32da118 132
- Closures and the `list.plum`/`map.plum` rewrite — separate, subsequent follow-ups.