plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-25-wasm-gc-migration-design.md
| 22e190c | 1 | # Design: Migrate plum-wasm-codegen to wasm-gc |
| 22e190c | 2 | |
| 22e190c | 3 | ## Motivation |
| 22e190c | 4 | |
| 22e190c | 5 | `plum-wasm-codegen` currently hand-rolls its own memory model directly on top of `wasm_encoder`: a single linear memory, a `bump_global` pointer, and manually computed byte offsets for every struct, enum, and string (`plum-wasm-codegen/src/lib.rs:73,331,433`). This allocator only ever grows — it never resizes and never frees — which is why `libs/std`'s `List` and `Map` don't fully compile today: `List` is a doubly-linked list of `Node[T]` structs (`head`/`tail`/`prev`/`next: Option[Node]`), and mutating methods (`add`, `set`, `removeAt`, `remove`, `clear`, `reverse`) are all still `todo` in `libs/std/list.plum`. Splicing nodes in and out of a linked list needs a memory model that can represent nullable/self-referential struct fields and reclaim unlinked nodes — the bump allocator can do neither. |
| 22e190c | 6 | |
| 22e190c | 7 | wasmtime (already a dev-dependency at version 28) and `wasm_encoder` (0.220) both support the wasm-gc proposal (`struct`/`array` types, `ref`/`ref null` types, `struct.new`/`struct.get`, `ref.test`/`br_on_cast`). Migrating plum's struct/class/enum/string representation onto wasm-gc directly solves the growable/freeable memory gap: unlinking a `Node` just drops the last reference to it, and the engine's GC reclaims it automatically, with no manual free bookkeeping and no risk of the current unbounded bump-allocator growth. |
| 22e190c | 8 | |
| 22e190c | 9 | ## Scope |
| 22e190c | 10 | |
| 22e190c | 11 | In scope: |
| 22e190c | 12 | - Migrate structs and classes to one wasm-gc `struct` type per concrete (post-monomorphization) type. |
| 22e190c | 13 | - Migrate enums to per-variant subtyping: one abstract supertype `struct` plus one concrete subtype `struct` per variant, matched via `ref.test`/`br_on_cast`. `Option[T]` is generated the same way as any other enum (`Some`/`None` as ordinary subtypes) — no null-ref special-casing, for full uniformity with the rest of the type system. |
| 22e190c | 14 | - Migrate `Str` to a wasm-gc `array<i8>`, rewriting the string-concat and int-to-string helpers (`plum-wasm-codegen/src/lib.rs:648,757`) against `array.new`/`array.copy` instead of bump-memory writes. |
| 22e190c | 15 | - Retire the bump allocator, `MemoryType`, and `bump_global` entirely once nothing depends on them. |
| 22e190c | 16 | - Implement `libs/std/list.plum`'s currently-`todo` methods (`add`, `set`, `removeAt`, `remove`, `clear`, `reverse`) against the new struct representation — this is the concrete deliverable the migration unblocks. `Map` (built on `List`) follows automatically. |
| 22e190c | 17 | - Update the `wasmtime` test harness (`codegen_tests.rs`, `examples_test.rs`) to an `Engine`/`Config` that has the GC proposal enabled, and extend existing tests to cover the new encoding plus the newly-working `List`/`Map` methods. |
| 22e190c | 18 | |
| 22e190c | 19 | Out of scope (explicitly deferred to future projects): |
| 22e190c | 20 | - A `[T]` array literal/indexing language feature. Not needed for this project — `List`'s underlying representation is a linked list, not a contiguous array, and wasm-gc `array` types are fixed-size at creation (no native resize primitive), so they wouldn't simplify `List`'s growth story anyway. A first-class array type may be worth adding later for genuinely contiguous/indexed use cases, as its own spec. |
| 22e190c | 21 | - wasm exception-handling (`try_table`/`throw`) for error propagation. Plum's existing `Option`/`Result` enum-based error handling doesn't need this to keep working; exceptions get their own spec once GC types exist to carry payloads through throw/catch, if ever pursued. |
| 22e190c | 22 | - Any change to `plum-checker`'s type-checking or monomorphization logic — monomorphization already resolves every generic instantiation to a concrete type before codegen runs, so this migration needs no generic wasm-gc types; it emits one concrete GC type per concrete instantiation, the same granularity bump-layout codegen uses today. |
| 22e190c | 23 | |
| 22e190c | 24 | ## Architecture |
| 22e190c | 25 | |
| 22e190c | 26 | The type section of the emitted module declares: |
| 22e190c | 27 | - One wasm-gc `struct` type per concrete struct/class, with fields typed as `ref`/`ref null` (for struct/enum-typed fields), `i32` (`Int`/`Bool`), or `f64` (`Float`). |
| 22e190c | 28 | - One `array<i8>` type for `Str`. |
| 22e190c | 29 | - Per enum, one abstract supertype `struct` and one concrete subtype `struct` per variant, declared with wasm-gc's subtyping relation so a variant value can be used wherever the enum's supertype is expected. |
| 22e190c | 30 | |
| 22e190c | 31 | Construction becomes `struct.new`/`struct.new_default` in place of bump-pointer arithmetic. Field access becomes `struct.get`/`struct.set`. Recursive/nullable fields — e.g. `List`'s `Node.next: Option[Node]` — become plain `ref`/`ref null` fields: unlinking a node is just overwriting a field with `ref.null`, after which the old node is unreachable and reclaimed by the engine's GC with no explicit free. |
| 22e190c | 32 | |
| 22e190c | 33 | ## Components |
| 22e190c | 34 | |
| 22e190c | 35 | - **Type-section emitter** (new): walks the checker's monomorphized struct/class/enum declarations and emits the wasm-gc composite types described above, replacing today's manual offset/layout computation. |
| 22e190c | 36 | - **Construction codegen** (`ast::Expr::New` and enum-variant construction, currently around `plum-wasm-codegen/src/lib.rs:2888-2963`): rewritten to `struct.new`/`struct.new_default` against the declared type index, instead of `GlobalGet`/`GlobalSet` bump-pointer bookkeeping. |
| 22e190c | 37 | - **Field/variant access codegen**: struct field reads/writes become `struct.get`/`struct.set` on the known concrete type index. Enum payload access adds a `ref.cast` to the variant's subtype first, since the static type at a match arm is the narrowed subtype. |
| 22e190c | 38 | - **Match dispatch codegen** (currently integer tag comparisons): rewritten to `ref.test`/`br_on_cast` against each variant's subtype in declaration order, falling through to a trap for exhaustiveness — the same shape as today's "no matching tag" fallthrough. |
| 22e190c | 39 | - **String helpers** (`register_string_concat_helper`, `register_int_to_string_helper`): rewritten against `array.new`/`array.copy` instead of bump-memory writes. |
| 22e190c | 40 | - **`libs/std/list.plum` / `map.plum`**: no representation-driven source changes needed; `add`, `set`, `removeAt`, `remove`, `clear`, `reverse` get real implementations now that `struct.new`/null-ref splicing makes them straightforward. This is the concrete unblock this project delivers. |
| 22e190c | 41 | - **Test harness** (`codegen_tests.rs`, `examples_test.rs`): `wasmtime::Engine::default()` calls updated to an explicit `Config` with the GC proposal enabled — first confirming whether wasmtime 28's default already enables it, since every other test in the migration depends on this. |
| 22e190c | 42 | |
| 22e190c | 43 | ## Data Flow |
| 22e190c | 44 | |
| 22e190c | 45 | The overall pipeline (parse → check/monomorphize → codegen → wasm bytes) is unchanged. Within codegen, the walk over monomorphized types now emits wasm-gc type declarations up front and records each type's assigned GC type index, instead of computing byte offsets into a shared linear memory. Every construction, field access, and match site looks up the relevant type index through the existing `func_ids`/`classes`/`enum_variants` tables (`plum-wasm-codegen/src/lib.rs:251-259`), extended to also carry that index. A generic instantiation like `List[Int]` vs `List[Str]` still gets its own fully concrete type — now as a distinct GC struct type index rather than a distinct offset layout. |
| 22e190c | 46 | |
| 22e190c | 47 | ## Error Handling |
| 22e190c | 48 | |
| 22e190c | 49 | Type errors continue to be fully caught by `plum-checker` before codegen runs; codegen continues to assume a well-typed, monomorphized AST. Codegen-side "not yet supported" gaps (in the style of today's `Float`-interpolation error) continue to report clear errors during the migration rather than silently falling back to bump-allocator codegen for anything not yet converted. At runtime, `match` exhaustiveness failures keep trapping exactly as today, now via a fallthrough after the last `br_on_cast` instead of after the last tag comparison — no new panic/exception mechanism is introduced, consistent with exceptions being out of scope. |
| 22e190c | 50 | |
| 22e190c | 51 | ## Testing |
| 22e190c | 52 | |
| 22e190c | 53 | `codegen_tests.rs` and `examples_test.rs` already round-trip generated wasm through `wasmtime` and assert on execution results; these are extended rather than replaced: |
| 22e190c | 54 | - Every existing struct/enum/string test is re-verified against the new GC encoding. |
| 22e190c | 55 | - New tests cover the concrete unblock: `List.add`/`set`/`removeAt`/`remove`/`clear`/`reverse`, and `Map` built on top of `List`. |
| 22e190c | 56 | - New tests cover recursive nullable fields specifically (`Node.next: Option[Node]`), confirming an unlinked node becomes unreachable and is reclaimed rather than leaked. |
| 22e190c | 57 | - The `wasmtime::Engine`/`Config` used in tests is confirmed to support the GC proposal first, since it gates every other test in the migration. |