plum

#treesitter#compiler#wasm

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

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


16cd722 — Peter John 2026-10-26T10:06:07+05:30
docs: how generics compile (erasure, boxing at edges, dyn traits); results
README.md CHANGED
@@ -244,7 +244,28 @@ fun wrap(value: T) -> Bool =
244
244
  True
245
245
  ```
246
246
 
247
- Generic types, methods, and free functions are monomorphized: each concrete instantiation used in the program gets its own specialized copy. `List[Int]` and `List(Int)` both parse as generic arguments.
247
+ Generic types, methods, and free functions are type-checked once and erased. `List[Int]` and `List(Int)` both parse as generic arguments. See [How generics compile](#how-generics-compile).
248
+
249
+ ### How generics compile
250
+
251
+ Generics work like TypeScript's: the checker verifies a generic body structurally against its bounds, then the type arguments are erased. There is exactly one compiled copy of every generic type, method, and function, however many instantiations the program uses. `List[Int]` and `List[Str]` run the same code.
252
+
253
+ - **Boxing only at generic edges.** Inside a generic, a `T` value is an `anyref`. Reference types (`Str`, classes, enums) pass through for free. A primitive (`Int`, `Float`, `Bool`, `Byte`) is boxed when it crosses into a `T` slot and unboxed when it comes back out, so `List[Int]` pays one small allocation per element. Non-generic code never boxes.
254
+ - **Bounds are required to call methods on `T`.** `fun show(x: T) -> Str = x.toStr()` is rejected; write `T: ToStr`. The bound is what tells the checker the call exists.
255
+ - **Trait names are types.** Every class and enum value carries a type id in its header, and a call through a trait type dispatches on it at runtime.
256
+
257
+ ```plum
258
+ trait Shape =
259
+ area() -> Float
260
+ name() -> Str
261
+
262
+ fun totalArea(shapes: List[Shape]) -> Float =
263
+ shapes.reduce(0.0, |acc, s| acc + s.area())
264
+ ```
265
+
266
+ `Circle` and `Square` both implement `Shape`, so a `List[Shape]` can hold either and `s.area()` picks the right method per element (see [`plum-examples/traits.plum`](plum-examples/traits.plum)).
267
+
268
+ - **Bare generic types need type arguments.** `p: Parcel` is an error when `Parcel` is generic; write `p: Parcel[Int]` (or `Parcel[T]` inside a generic).
248
269
 
249
270
  ## Closures
250
271
 
@@ -455,8 +476,8 @@ It's sugar for `obj.map(|v| v.field)` — nothing more than that; chain it as ma
455
476
  - **`Str`** (`import std/Str`) — a byte array with a codepoint-aware layer for Unicode correctness: `runeLength`, `runeAt`, `codePointAt`, `codePointToStr`
456
477
  - **`Byte`** / **`[]Byte`** (`import std/Byte`) — a scalar unsigned 8-bit value and a fixed-length mutable byte slice
457
478
  - **`Buffer`** (`import std/Buffer`) — a growable, mutable byte sequence for building up a `Str`, amortized O(n) writes
458
- - **`Array[T]`** (`import std/Array`) — a generic, growable array (reference types only)
479
+ - **`Array[T]`** (`import std/Array`) — the compiler-intrinsic generic array that `List` and `Map` are written on
459
480
  - **`Map[K: Hashable, V]`** (`import std/Map`) — a real bucketed hash table
460
- - **`List[T]`** (`import std/List`) — a singly-linked list with `get`/`add`/`set`/`removeAt`/`remove`/`each`/`map`/`reduce`/`sort`/`join`/`chunk`/`partition`
481
+ - **`List[T]`** (`import std/List`) — a growable list built on `Array`, with `get`/`add`/`set`/`removeAt`/`remove`/`each`/`map`/`reduce`/`sort`/`join`/`chunk`/`partition`
461
482
 
462
483
  See [`plum-examples/`](plum-examples) for a runnable file per feature above, and [`plum-std/`](plum-std) for the standard library source.
docs/plans/2026-10-25-erased-generics.md CHANGED
@@ -204,3 +204,25 @@ Checker: deep (nested TApp substitution, per-call method generics), state (bound
204
204
  ## Self-review
205
205
 
206
206
  Design items 1→T1, 2→T2, 3,5→T3, 4→T4, 6,7→T5, success criteria→T6. There are no placeholders. Each Consumes line matches a Produces line. Tasks are sequential by ruling, and each one leaves both suites green.
207
+
208
+ ## Results
209
+
210
+ Release build, best of 5, milliseconds.
211
+
212
+ | Measure | Before (monomorphizing) | After (erased) |
213
+ |---|---|---|
214
+ | Typical program, total | 380-500 | 20-25 (load ~9, check ~7, codegen ~5) |
215
+ | List.plum | 911 | typical range |
216
+ | Largest (oop_visitor, dop_visitor, Http, Os) | n/a | 32-35 |
217
+ | Bool.plum wasm size | 96.3 KB | 22.7 KB |
218
+ | `plum test` std + examples | ~89 s | ~21-28 s |
219
+
220
+ Targets (<=100 ms, <96 KB) met.
221
+
222
+ ### Open follow-ups
223
+
224
+ - A generic fn returning a closure is unparseable (`-> fn(Int) -> Int`).
225
+ - A closure capturing a fn-typed param fails with "unknown function 'cb'".
226
+ - A bare `-> Box` return inside Box's own methods leaves `T` unsubstituted.
227
+ - `List` is not its own intrinsic; it is Plum code on top of `Array`.
228
+ - A built-in `Bool` is planned next.
plum-checker/src/lib.rs CHANGED
@@ -210,7 +210,7 @@ pub fn enumGenericParams(e: &ast::Enum) -> Vec<String> {
210
210
  names
211
211
  }
212
212
 
213
- /// Structural type conversion for UN-monomorphized source: `List[Int]` ->
213
+ /// Structural type conversion for original (erased) source: `List[Int]` ->
214
214
  /// `TApp`, a single-uppercase-letter name (the grammar's generic parameter
215
215
  /// spelling) -> `TParam`.
216
216
  pub fn plumTypeFromAstG(ty: &ast::Type) -> PlumType {
@@ -681,7 +681,7 @@ pub struct EnumVariantInfo {
681
681
  pub field_names: Vec<String>,
682
682
  pub values: Vec<ast::Expr>,
683
683
  /// The owning enum's generic parameter names (`[T, E]` for `Try`) —
684
- /// empty for a non-generic enum and for monomorphized specializations.
684
+ /// empty for a non-generic enum and for non-generic uses.
685
685
  pub generics: Vec<String>,
686
686
  }
687
687
  /// Enum variant name -> its info, e.g. `"True" -> { enum_name: "Bool", tag: 1, field_types: [] }`.
@@ -786,10 +786,9 @@ pub fn buildGlobalTables(source: &ast::Source) -> (TypeEnv, MethodEnv, EnumVaria
786
786
  // This is what lets a bare `Int`/`Float` value be used
787
787
  // directly wherever `Number` is expected with no wrapper
788
788
  // syntax — see `unify`'s matching
789
- // primitive-vs-enum arm, and
789
+ // primitive-vs-enum arm; codegen
790
- // `monomorphize::wrapPrimitiveAgainstExpected`, which
791
- // does the actual AST rewrite into this variant's
790
+ // wraps into this variant's constructor at
792
- // constructor at specific expected-type usage sites.
791
+ // expected-type usage sites.
793
792
  (vec![plumTypeFromName(&v.name)], Vec::new())
794
793
  } else if v.fields.is_empty()
795
794
  && e.name != v.name
@@ -948,11 +947,9 @@ fn paramTypeCompareName(pt: &ast::ParamType) -> Option<&str> {
948
947
  /// anywhere) is silently SKIPPED rather than an error: those names are still
949
948
  /// purely aspirational today, and erroring on them would break every existing
950
949
  /// type that names one, for reasons unrelated to whether it actually implements
951
- /// its REAL trait claims. Runs on the pre-monomorphization source deliberately —
950
+ /// its REAL trait claims. Runs on the original source deliberately —
952
951
  /// a generic class's own methods (and a real trait's own declared signatures,
953
- /// which are never specialized at all) read naturally here, before mangling
952
+ /// which are never instantiated) read naturally here as written.
954
- /// folds a method's concrete return/param types into new, unrelated-looking
955
- /// names (`Link[JsonParseError]` -> `Link$JsonParseError`).
956
953
  fn checkTraitConformance(source: &ast::Source) -> Vec<CheckError> {
957
954
  let mut errors = Vec::new();
958
955
  let traits: BTreeMap<&str, &ast::Trait> = source
@@ -1134,7 +1131,7 @@ fn checkBareGenericTypes(source: &ast::Source) -> Vec<CheckError> {
1134
1131
 
1135
1132
  fn checkSourceInner(source: &ast::Source) -> CheckResult<()> {
1136
1133
  // Generic items are checked once, structurally (`TApp`/`TParam`), on the
1137
- // ORIGINAL source — no monomorphization.
1134
+ // ORIGINAL source.
1138
1135
  let mut errors: Vec<CheckError> = checkTraitConformance(source);
1139
1136
  errors.extend(checkBareGenericTypes(source));
1140
1137
  let (global_env, methods, enum_variants, enum_params) = buildGlobalTables(source);
@@ -2139,7 +2136,7 @@ fn inferClassCallRaw(
2139
2136
  /// A class construction call (`FantasyBook(title: ..., ...)`) whose name is ALSO a
2140
2137
  /// bare-type enum variant (`enum Book = | FantasyBook`, see `buildGlobalTables`)
2141
2138
  /// infers as the enum it wraps, not the bare class — mirroring the AST-level
2142
- /// desugaring `monomorphize::rewriteExpr` does before codegen (`FantasyBook(...)`
2139
+ /// desugaring codegen does (`FantasyBook(...)`
2143
2140
  /// there literally becomes `Book`'s `FantasyBook(FantasyBook(...))` constructor).
2144
2141
  fn bareVariantWrapType(type_name: &str, ctx: &CheckCtx) -> PlumType {
2145
2142
  match ctx.enum_variants.get(type_name) {
@@ -2398,7 +2395,7 @@ pub(crate) fn lookupFieldType(
2398
2395
  /// belonging to the same enum as `enum_name`, for the `?` operator (`Expr::Try`)
2399
2396
  /// — used identically by the checker (`inferExpr`) and codegen (`inferLocalType`
2400
2397
  /// callers), which is why this lives here rather than inline in either. Returns
2401
- /// `None` if `enum_name` isn't (a monomorphized specialization of) `Try`
2398
+ /// `None` if `enum_name` isn't (an instantiation of) `Try`
2402
2399
  /// — i.e. has no `Ok`/`Err` pair of variants.
2403
2400
  pub fn tryOperatorVariants<'a>(
2404
2401
  enum_name: &str,
plum-wasm-codegen/src/lib.rs CHANGED
@@ -194,7 +194,7 @@ impl WasmModule {
194
194
  // `call_indirect` against table 0 purely because its PARAMETER TYPE is
195
195
  // `fn(...)` (e.g. `List.each(self, cb: fn(T))`) — that method gets compiled
196
196
  // (and its `call_indirect` emitted) whenever its owning class is
197
- // monomorphized, regardless of whether THIS PARTICULAR program ever
197
+ // compiled, regardless of whether THIS PARTICULAR program ever
198
198
  // actually constructs a real closure literal anywhere to populate
199
199
  // `table_elements`. Without an (empty-but-present) table 0 declared, such
200
200
  // a module fails wasm validation ("unknown table 0") even though the
@@ -374,7 +374,7 @@ pub struct CompileCtx<'a> {
374
374
  pub closure_asts: HashMap<usize, &'a ast::Closure>,
375
375
  /// Closure arity -> the erased `call_indirect` function-type index.
376
376
  pub closure_call_types: HashMap<ClosureSigKey, u32>,
377
- /// Instantiated type of every expression of the (un-monomorphized) source,
377
+ /// Instantiated type of every expression of the original source,
378
378
  /// keyed by expression address — from `plum_checker::typedSource`.
379
379
  pub types: HashMap<usize, PlumType>,
380
380
  /// Top-level function name -> global index holding its zero-capture "trampoline"
@@ -944,7 +944,7 @@ fn plumTypeToValtype(t: &PlumType) -> ValType {
944
944
  }
945
945
  }
946
946
 
947
- /// Type-section indices for every wasm-gc composite type this program's monomorphized
947
+ /// Type-section indices for every wasm-gc composite type this program's
948
948
  /// classes/enums/`Str` need. Populated once in `compileSource` from the checker's
949
949
  /// global tables.
950
950
  #[derive(Clone)]
@@ -974,7 +974,7 @@ pub struct GcTypeRegistry {
974
974
  /// alongside `closure_type_idx`, from every `ParamType::Variadic` in the program.
975
975
  pub variadic_array_type_idx: HashMap<ValType, u32>,
976
976
  /// The single shared `array<anyref>` type index every `Array[T]`
977
- /// specialization (`Array$Int`, `Array$Str`, ...) uses regardless of its own
977
+ /// uses regardless of its own
978
978
  /// `T` — see `libs/std/array.plum`'s header comment. `0` (the `Str` slot,
979
979
  /// harmlessly never read as such) until/unless the program actually
980
980
  /// instantiates `Array` at least once.
@@ -1028,10 +1028,9 @@ fn typeIdOf(r: &GcTypeRegistry, name: &str) -> i32 {
1028
1028
  r.type_ids.get(name).copied().unwrap_or(0)
1029
1029
  }
1030
1030
 
1031
- /// Whether `name` is `Array` itself or one of its monomorphized specializations
1031
+ /// Whether `name` is the `Array` intrinsic — it is backed by one
1032
- /// (`Array$Int`, `Array$List$Pair$Str$Int`, ...) — every such class shares one
1033
1032
  /// physical wasm-gc array type (`GcTypeRegistry::array_type_idx`) instead of
1034
- /// getting its own struct type, and its `init`/`get`/`set`/`length` methods are
1033
+ /// a struct type, and its `init`/`get`/`set`/`length` methods are
1035
1034
  /// compiler intrinsics rather than compiled `todo` bodies (see
1036
1035
  /// `compileIntrinsicFnBody`).
1037
1036
  fn isArraySpecialization(name: &str) -> bool {
@@ -1082,8 +1081,8 @@ fn plumTypeToGcValtype(t: &PlumType, registry: &GcTypeRegistry) -> ValType {
1082
1081
  // A field typed as a generic enum (e.g. `Node.next: Link[Node]`)
1083
1082
  // resolves here to the BARE generic name — `plumTypeFromAst`
1084
1083
  // has no representation for type arguments, so it can't know this means
1085
- // `Link$Int` once monomorphization specializes (and removes the
1084
+ // `Link[Int]` rather than bare `Link` (erased generics keep one
1086
- // unspecialized) `Link` — same permissive `anyref` fallback as
1085
+ // compiled `Link`) — same permissive `anyref` fallback as
1087
1086
  // `plumTypeToValtype` above, for the same "genuinely unmodeled" reason:
1088
1087
  // `struct.get`/`ref.test`/`ref.cast` all work against `anyref` operands
1089
1088
  // fine, so a field merely being STORED as `anyref` instead of the exact
@@ -1121,7 +1120,7 @@ fn buildGcTypeRegistry(
1121
1120
  EnumSuper(String),
1122
1121
  Variant(String),
1123
1122
  /// The single shared `array<anyref>` type every `Array[T]`
1124
- /// specialization aliases — see `isArraySpecialization`.
1123
+ /// aliases — see `isArraySpecialization`.
1125
1124
  ArrayRef,
1126
1125
  Tag,
1127
1126
  BoxI64,
@@ -1164,8 +1163,7 @@ fn buildGcTypeRegistry(
1164
1163
  // are ALL represented by the one shared `array<anyref>` type regardless
1165
1164
  // of `T` (see `isArraySpecialization`'s own doc comment) — alias its
1166
1165
  // enum/variant/class names onto that single existing slot instead of
1167
- // giving it (and every OTHER `Array$...` specialization) its own real
1168
- // struct type.
1166
+ // giving it its own real struct type.
1169
1167
  if isArraySpecialization(enum_name) {
1170
1168
  let idx = *array_ref_slot.get_or_insert_with(|| {
1171
1169
  let idx = slots.len() as u32;
@@ -1815,7 +1813,7 @@ fn compileSourceInner(
1815
1813
  // set up right below) — imports must occupy the low end of the function
1816
1814
  // index space for every later `addFunction`'s index arithmetic to stay
1817
1815
  // correct. `plum-checker` has already confirmed every extern fn has no
1818
- // receiver and no body, so `f.name` alone (no `fnKey` receiver-mangling) is
1816
+ // receiver and no body, so `f.name` alone (no `fnKey` receiver-qualification) is
1819
1817
  // always its unique key.
1820
1818
  let mut func_ids: HashMap<String, u32> = HashMap::new();
1821
1819
  let mut func_sigs: HashMap<String, FuncSig> = HashMap::new();
@@ -1873,8 +1871,8 @@ fn compileSourceInner(
1873
1871
  if !info.field_types.is_empty() {
1874
1872
  continue;
1875
1873
  }
1876
- // `Array`/`Array$T`'s bare payload-free variant is aliased onto the
1874
+ // `Array`'s bare payload-free variant is aliased onto the
1877
- // ONE shared `array<anyref>` type every specialization reuses (see
1875
+ // ONE shared `array<anyref>` type (see
1878
1876
  // `buildGcTypeRegistry`'s `isArraySpecialization` handling), not a
1879
1877
  // real struct type — it's never actually instantiated this way
1880
1878
  // (construction goes through the `init`/`push` compiler intrinsics
@@ -1999,16 +1997,15 @@ fn compileSourceInner(
1999
1997
  .collect();
2000
1998
 
2001
1999
  // ---- Discovery pre-pass: find every closure literal in every function body. ----
2002
- // (Runs on the monomorphized source, so any generic types in a closure's context
2003
- // are already concrete.) Registers each closure as its own wasm function + table
2000
+ // (Runs on the original, erased source.) Registers each closure as its own wasm function + table
2004
2001
  // element and records the free variables it must capture.
2005
2002
  // Only genuine free functions (`type_param.is_none()`) — a bare `FnCall`
2006
2003
  // (`each(...)`) can only ever resolve to a free function, never a method
2007
2004
  // (those always dispatch through `Attribute`/`AttrKind::Method`, keyed by
2008
2005
  // `(receiver, name)` instead — see that arm's own comment below). Keying
2009
2006
  // this by bare name across free functions AND methods would let a
2010
- // monomorphized method silently SHADOW a same-named free function here
2007
+ // method silently SHADOW a same-named free function here
2011
- // (e.g. a user's own top-level `each` losing to `List$Str.each` once
2008
+ // (e.g. a user's own top-level `each` losing to `List.each` once
2012
2009
  // `List` happens to be in scope) — same-named methods on unrelated
2013
2010
  // classes are already ambiguous by bare name, so methods have no business
2014
2011
  // being in a bare-name lookup table at all.
@@ -3022,11 +3019,10 @@ fn unwrapStrToBytes(body: &mut Vec<u8>) {
3022
3019
  .encode(body);
3023
3020
  }
3024
3021
 
3025
- /// `Array[T]`'s `init`/`get`/`set`/`length` — every specialization (`Array$Int`,
3022
+ /// `Array[T]`'s `init`/`get`/`set`/`length` — the intrinsic is backed by ONE wasm-gc `array<anyref>` type
3026
- /// `Array$List$Pair$Str$Int`, ...) shares ONE wasm-gc `array<anyref>` type
3027
3023
  /// (`GcTypeRegistry::array_type_idx`, see `isArraySpecialization`), so these
3028
- /// compile identically regardless of which concrete `T` this specialization is
3024
+ /// compile identically regardless of the concrete `T`.
3029
- /// for. Every `type_param`-bearing `Fn` — methods AND non-`self` nested
3025
+ /// Every `type_param`-bearing `Fn` — methods AND non-`self` nested
3030
3026
  /// "static" constructors like `init` (see `fnWasmParamTypes`) — gets an
3031
3027
  /// implicit receiver as wasm param 0, so a declared plum param at position `k`
3032
3028
  /// is always wasm local `k + 1`. `get` is the one case that also needs a
plum-wasm-codegen/tests/codegen_tests.rs CHANGED
@@ -1413,7 +1413,7 @@ fun main() -> Int =
1413
1413
  // OPEN: a tag's payload sub-pattern may only be a name or `_` (codegen rejects
1414
1414
  // "unsupported pattern inside tag 'Wrap' payload"), and with a generic enum as the
1415
1415
  // payload (`Wrap(Try[..])`) it currently panics earlier ("'Ok' missing from the GC
1416
- // type registry") because monomorphize never specializes a tag-payload type.
1416
+ // type registry") (historical note: written when a monomorphizer existed; generics are now erased).
1417
1417
  #[test]
1418
1418
  #[ignore = "constructor sub-pattern inside a tag payload is unsupported (panics 'Ok' missing from the GC type registry)"]
1419
1419
  fn probe_three_level_nested_tag_pattern_generic_payload() {
@@ -1442,7 +1442,7 @@ fun main() -> Int =
1442
1442
  }
1443
1443
 
1444
1444
  #[test]
1445
- fn probe_nested_generic_mangled_name_roundtrip() {
1445
+ fn probe_nested_generic_name_roundtrip() {
1446
1446
  let got = runTryMain(
1447
1447
  "\
1448
1448
  enum Box[T] =