plum
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 +24 -3
- docs/plans/2026-10-25-erased-generics.md +22 -0
- plum-checker/src/lib.rs +10 -13
- plum-wasm-codegen/src/lib.rs +19 -23
- plum-wasm-codegen/tests/codegen_tests.rs +2 -2
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
|
|
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`) —
|
|
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
|
|
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
|
|
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
|
|
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
|
|
789
|
+
// primitive-vs-enum arm; codegen
|
|
790
|
-
// `monomorphize::wrapPrimitiveAgainstExpected`, which
|
|
791
|
-
//
|
|
790
|
+
// wraps into this variant's constructor at
|
|
792
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
///
|
|
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`
|
|
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
|
-
///
|
|
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
|
|
1084
|
+
// `Link[Int]` rather than bare `Link` (erased generics keep one
|
|
1086
|
-
//
|
|
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
|
-
///
|
|
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-
|
|
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`
|
|
1874
|
+
// `Array`'s bare payload-free variant is aliased onto the
|
|
1877
|
-
// ONE shared `array<anyref>` type
|
|
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
|
-
//
|
|
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
|
-
//
|
|
2007
|
+
// method silently SHADOW a same-named free function here
|
|
2011
|
-
// (e.g. a user's own top-level `each` losing to `List
|
|
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` —
|
|
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
|
|
3024
|
+
/// compile identically regardless of the concrete `T`.
|
|
3029
|
-
///
|
|
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")
|
|
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
|
|
1445
|
+
fn probe_nested_generic_name_roundtrip() {
|
|
1446
1446
|
let got = runTryMain(
|
|
1447
1447
|
"\
|
|
1448
1448
|
enum Box[T] =
|