plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


ea186e4Peter John 2026-07-20T14:59:56+05:30
docs: add design spec for generics monomorphization
docs/superpowers/specs/2026-07-20-generics-monomorphization-design.md ADDED
@@ -0,0 +1,115 @@
1
+ # Generics monomorphization
2
+
3
+ ## Problem
4
+
5
+ Generic type parameters (single lowercase letters: `a`, `b`, `c`, `d`) are supported today only
6
+ at the syntax and permissive-type-check level: `type Box(a) = value: a`, `trait Comparable(a: Ord)
7
+ = compareTo(other: a) -> Int`, and generic function params (`wrap(value: a) -> Bool`, `pair(first:
8
+ a, second: b) -> Bool`) all parse and type-check (`plum-checker` treats an unresolved generic
9
+ param permissively, the same way it treats any unmodeled/unknown type). `plum-wasm-codegen` has no
10
+ representation for a generic parameter at all — any attempt to compile a generic class/function
11
+ hits its permissive-fallback path (`ValType::I32` treated as "pointer to unmodeled type") without
12
+ ever resolving what concrete type is actually present at a given call site, so field
13
+ loads/stores and arithmetic on generic-typed values compile with the wrong width or fail outright.
14
+
15
+ This is the single largest remaining gap blocking `libs/std`'s actual `Option`/`Result`/`List`/
16
+ `Map` from compiling — **but fixing generics alone does not get there**: `libs/std/list.plum` and
17
+ `map.plum` also use syntax that doesn't exist in the grammar at all today (closures `|v| ...`,
18
+ `Nil`, `?.` optional chaining, `:=`, decorators like `@HasTrait`, colon-arrow return types). This
19
+ work targets **only** the currently-documented generic syntax (the shapes already exercised in
20
+ `examples/types.plum` and `examples/functions.plum`), not those std lib files.
21
+
22
+ ## Approach: monomorphization by specialization
23
+
24
+ Chosen over a type-erasure/boxed-uniform-representation approach because the rest of the compiler
25
+ already assumes every value has a statically-known concrete wasm type (`I32`/`I64`/`F64`) at
26
+ codegen time — monomorphization preserves that invariant everywhere, at the cost of compiling one
27
+ specialized copy per concrete instantiation actually used (standard Rust/C++-template-style
28
+ trade-off), rather than requiring a new runtime representation (boxed/tagged values, out-of-band
29
+ type metadata) that the rest of the codegen doesn't have anywhere else.
30
+
31
+ ### 1. Instantiation-site collection
32
+
33
+ Walk the whole program to determine every concrete `(generic item, concrete type argument list)`
34
+ combination actually needed:
35
+
36
+ - Explicit concrete type annotations naming a generic item with concrete arguments (`Box[Int]`,
37
+ `Box(Int)` — both bracket and paren syntax are already accepted per the grammar).
38
+ - Constructor calls where a generic class's field types resolve the class's generic parameters via
39
+ unification against the supplied argument types (`Box(value: 5)` → `a = Int`).
40
+ - Generic function/method calls where argument types resolve the function's generic parameters via
41
+ unification (`wrap(5)` → `a = Int`; `pair(1, "x")` → `a = Int, b = Str`).
42
+
43
+ This reuses `plum-checker`'s existing unification (`unify`, `infer_expr`) — extended so a generic
44
+ parameter name occurring in a declared type position is treated as a fresh unification variable
45
+ bound per call site, instead of being ignored/treated as an opaque unmodeled type name.
46
+
47
+ ### 2. Specialization mechanism
48
+
49
+ For each unique `(generic item, concrete type args)` pair, produce a substituted copy of the
50
+ `Class`/`Fn` AST item: every occurrence of a generic parameter name in a **declared type
51
+ position** (field types, param types, return types) is replaced by its concrete resolved
52
+ `ast::Type`, under an internal mangled name (e.g. `Box$Int`, `wrap$Int`, `pair$Int$Str` — `$`
53
+ joins the base name and each concrete type argument in declaration order). The substituted copy is
54
+ then run through the **existing, unmodified** non-generic checker (`build_global_tables`,
55
+ `check_fn`) and codegen (`compile_fn_body`, class field layout) pipeline — neither of those needs
56
+ to know or care that its input started out generic.
57
+
58
+ Mangled names are purely internal (`ClassEnv`/`MethodEnv`/`func_ids` keys, wasm export names where
59
+ relevant) — user source keeps calling `Box(...)`/`wrap(...)` normally; the compiler resolves which
60
+ specialization a given call site needs from its own already-inferred concrete argument types and
61
+ rewrites the call/construction internally to reference the matching mangled specialization.
62
+
63
+ A generic **method** (`get<List>(i: Int) -> a`) specializes alongside its receiver class: once
64
+ `List` is specialized as `List$Int`, every method declared with receiver `List` gets a
65
+ correspondingly substituted, separately-specialized copy keyed `List$Int::get`, following the
66
+ existing `MethodEnv` key shape (`(receiver type name, method name)`) with the receiver name being
67
+ the mangled one.
68
+
69
+ ### 3. Transitive/recursive generics — worklist to a fixed point
70
+
71
+ A freshly-specialized body can itself call another still-generic function/method — now with
72
+ concrete types known throughout that specialized body, so this is a new, fully-resolved
73
+ instantiation site. Processed as a worklist: seed it from every concrete instantiation site
74
+ reachable from non-generic code, specialize each, scan the newly-produced specialized bodies for
75
+ further instantiation sites they introduce, and repeat until no new specializations are added.
76
+
77
+ A recursion-depth guard bounds this (e.g. a hard cap on total specializations per compile, or on
78
+ nesting depth of a type argument list) so a pathological unbounded-expanding generic (a generic
79
+ type that embeds a larger instantiation of itself, with no concrete base case) fails with a clear
80
+ compile error rather than hanging or exhausting memory.
81
+
82
+ ## Out of scope
83
+
84
+ - Trait-bound enforcement (`trait Comparable(a: Ord)`'s `Ord` bound) — stays exactly as permissive
85
+ as it is today. Monomorphization only needs to substitute concrete types through, not verify
86
+ bounds are satisfied.
87
+ - Generic code with zero reachable concrete instantiation anywhere in the program is simply never
88
+ specialized or compiled — this tree-shaking falls out of the worklist approach for free, it
89
+ isn't a separate feature.
90
+ - `libs/std/list.plum` and `map.plum` compiling as-is — as established above, they need several
91
+ other unimplemented language features first (closures, `Nil`/optional chaining, decorators,
92
+ colon-arrow return syntax) that are unrelated to generics and out of scope here.
93
+ - A general, unbounded-generic-recursion *detector* beyond a simple depth/count cap — this is a
94
+ defensive backstop against a degenerate program, not a soundness analysis.
95
+
96
+ ## Testing plan
97
+
98
+ - **Checker tests** (`plum-checker/tests/checker_tests.rs`): a generic class instantiated at two
99
+ different concrete types in one program (`Box(Int)` and `Box(Str)`) resolves each usage's field
100
+ access to the correct concrete type; a generic function called with different concrete argument
101
+ types at different call sites infers correctly per site; a multi-param generic (`Pair`/`Map`-
102
+ shaped, two independent type params) resolves both params independently per call site.
103
+ - **Codegen tests** (`plum-wasm-codegen/tests/codegen_tests.rs`, compiled and executed via
104
+ `wasmtime` matching existing style): a generic class specialized at `Int` and at `Str` in the
105
+ same program, confirming the two specializations don't alias (distinct compiled bodies, correct,
106
+ independent field widths/offsets); a generic function called at multiple concrete types in one
107
+ program; a generic method dispatched on a specialized generic class; a transitively-generic call
108
+ chain (a specialized generic function itself calling another still-generic function) proving the
109
+ worklist reaches a correct fixed point; a deliberately-pathological unbounded-recursive generic
110
+ type, asserting `compile_source` returns a clear error rather than hanging or panicking.
111
+ - **Examples**: extend `examples/types.plum`/`examples/functions.plum` (currently declarations
112
+ only) with actual instantiations and a `main` that runs them end-to-end. Update README's Known
113
+ Gaps to drop the "user-defined generics aren't monomorphized" bullet, while adding/keeping a note
114
+ that this alone doesn't unblock `libs/std`'s actual `List`/`Map`/`Option`/`Result` (separate,
115
+ unrelated gaps remain there).