plum
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. |