plum

#treesitter#compiler#wasm

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

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


docs/superpowers/plans/2026-07-25-wasm-gc-migration.md
5ef4579 1
# Wasm-GC Migration Implementation Plan
5ef4579 2
5ef4579 3
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Work task-by-task, in order — later tasks assume earlier ones landed.
5ef4579 4
5ef4579 5
**Goal:** Replace `plum-wasm-codegen`'s hand-rolled bump-allocator memory model with wasm-gc (`struct`/`array` types, `ref`/`ref null`, `struct.new`, `ref.test`/`br_on_cast`), per `docs/superpowers/specs/2026-07-25-wasm-gc-migration-design.md`. Retire the bump allocator, `MemoryType`, and `bump_global` entirely. Unblocks `libs/std/list.plum`'s `todo` methods (`add`/`set`/`removeAt`/`remove`/`clear`/`reverse`), which is the concrete deliverable.
5ef4579 6
5ef4579 7
**Architecture:** One wasm-gc `struct` type per concrete (post-monomorphization) class/struct. Per enum, one abstract supertype `struct` plus one concrete subtype `struct` per variant (wasm-gc declared subtyping), matched via `ref.test`/`br_on_cast` — **including `Bool`**, per explicit user decision below. `Str` becomes `array<i8>`. The linear memory, `MemoryType`, and `bump_global` are deleted entirely once nothing depends on them — static string data moves to a **passive** data segment consumed via `array.new_data` (no active memory needed for it at all).
5ef4579 8
5ef4579 9
**Decisions made during planning that the design spec left open** (read these before starting — they resolve real ambiguities, not stylistic preferences):
5ef4579 10
5ef4579 11
1. **Bool is a full wasm-gc struct, no special-casing** (explicit user decision — the alternative, keeping `Bool` as a raw `i32` for cheap control flow, was rejected in favor of literal spec uniformity). Consequence: every `if`/`while` condition, `&&`/`||` operand, and `assert` — anywhere a `Bool` value drives wasm's native control flow (which requires a raw `i32`, not a `ref`) — needs a `ref.test (ref $True)` inserted to convert the `Bool` ref into an `i32` immediately before it's consumed by `Instruction::If`/`BrIf`. Comparison operators (`==`, `!=`, `<`, etc.) go the other direction: the native predicate produces an `i32` 0/1, which then selects between the pre-allocated `$True`/`$False` singletons (see next point) via a small `if/else` yielding a `ref`.
5ef4579 12
2. **Payload-free variants are pre-allocated once as globals, not constructed per-use.** `True`, `False`, `None`, and any other zero-field variant of any enum are immutable and interchangeable, so each gets exactly one `struct.new` at a module-initialization point, stored in a `global`; every subsequent bare reference (`True`, `None`, a comparison result, a discriminant-free `enum` variant) is a `global.get` of that singleton, not a fresh allocation. This avoids needless GC churn for the single highest-frequency value shape in the language (every boolean expression).
5ef4579 13
3. **Closure env pointers are `anyref` in the shared `call_indirect` signature**, not a per-closure concrete type. Every closure literal has a structurally different captured-variable shape, but all closures of the same plum `fn(...)` type must share one wasm function-type for `call_indirect` to dispatch across them uniformly — exactly the role `i32` (an untyped address) plays today. Each closure's own compiled body does `ref.cast (ref $ItsOwnEnvType)` on the incoming `anyref` as its first instruction, recovering its concrete, statically-typed env struct before reading captured fields. The closure value itself (`{table_index, env_pointer}`) becomes a two-field `struct` (`i32`, `anyref`) instead of a bump-allocated pair.
5ef4579 14
4. **String data uses a passive data segment + `array.new_data`**, not an active one — this needs no `memory` section at all, which is what lets the memory section disappear completely rather than sticking around just for string constants.
5ef4579 15
5ef4579 16
**Tech Stack:** `wasm-encoder` 0.220 (already supports `CoreTypeEncoder::struct_`/`array`/`subtype`/`rec`, `Instruction::StructNew`/`StructGet`/`StructSet`/`ArrayNewData`/`RefTestNonNull`/`BrOnCast`, `RefType`/`HeapType::Concrete` — verified directly against the vendored source, no upgrade needed), `wasmtime` 28 (`Config::wasm_gc`/`wasm_function_references` exist and are reachable — the `"gc"`/`"gc-drc"`/`"gc-null"` cargo features are already in wasmtime's `default` feature set, so no `Cargo.toml` change is needed, just calling the `Config` methods in test setup). **Caveat directly from wasmtime's own doc comment on `Config::wasm_gc`: "Wasmtime's implementation of the GC proposal is still in progress and generally not ready for primetime."** Budget time in Task 1 to discover real limitations empirically, not just from docs.
5ef4579 17
5ef4579 18
## Global Constraints
5ef4579 19
5ef4579 20
- Spec: `docs/superpowers/specs/2026-07-25-wasm-gc-migration-design.md`. This plan's four numbered decisions above fill gaps that spec left unresolved (Bool, closures, singleton variants, string data segment kind) — treat them as binding, not as this plan's own invention to second-guess mid-implementation.
5ef4579 21
- This is fundamentally **not** an incrementally-toggleable migration: the moment `Str` (or anything) becomes a `ref`, every class/struct/enum that can hold one transitively can no longer live in linear memory (wasm-gc references cannot be stored in linear memory at all — that's the point of the proposal's separate reference universe). Task 2 is therefore one large, multi-step "big bang" task that switches construction, field/variant access, match dispatch, strings, and closures over together — expect it to be the bulk of this plan's work, and expect the codebase to be non-compiling partway through its steps until the whole task lands. Do not attempt to keep every individual step's diff independently green; the task-level `cargo test --workspace` at the end of Task 2 is the real gate.
5ef4579 22
- `plum-checker`'s type-checking/monomorphization needs **no changes** — it already resolves every generic instantiation to a concrete type before codegen runs (confirmed by reading `plum-checker/src/monomorphize.rs`'s existing role in `compileSource`). If you find this assumption wrong, stop and report BLOCKED rather than silently expanding scope into `plum-checker`.
5ef4579 23
- Run `cargo build --workspace --all-targets` after every step that touches `plum-wasm-codegen/src/lib.rs` (compilation alone is a fast, cheap checkpoint even mid-task before behavior is testable), and `cargo test --workspace` at the end of every task.
5ef4579 24
- `[T]` array literals/indexing and wasm exception-handling remain explicitly out of scope (per the spec) — do not add them opportunistically because GC types make them easier.
5ef4579 25
5ef4579 26
---
5ef4579 27
5ef4579 28
### Task 1: Wasmtime GC config verification + wasm-gc type-section emitter foundation
5ef4579 29
5ef4579 30
**Files:**
5ef4579 31
- Modify: `plum-wasm-codegen/Cargo.toml` (only if the empirical check in Step 1 finds the default features insufficient)
5ef4579 32
- Modify: `plum-wasm-codegen/tests/codegen_tests.rs`, `plum-wasm-codegen/tests/examples_test.rs`
5ef4579 33
- Modify: `plum-wasm-codegen/src/lib.rs`
5ef4579 34
5ef4579 35
**Interfaces:**
5ef4579 36
- Consumes: nothing (bottom of this plan's dependency chain).
5ef4579 37
- Produces: every `wasmtime::Engine`/`Config` used in tests has the GC proposal enabled; a new type-section emitter that, given the checker's monomorphized `Source`, emits one wasm-gc `struct` per concrete class, one abstract-supertype-plus-per-variant-subtype set per enum (Bool included, built in exactly like the existing hardcoded `True`/`False` `EnumVariantInfo` registration), and one `array<i8>` type for `Str` — **wired up but not yet consumed by any real codegen path**, so all EXISTING tests (still running through the untouched bump-allocator codegen) stay green. This task is purely additive.
5ef4579 38
0e39618 39
- [x] **Step 1: Confirm wasmtime's GC `Config` actually works empirically, first**
5ef4579 40
5ef4579 41
Before writing any type-emitter code, write a throwaway test (in `codegen_tests.rs`, can be deleted/replaced by Task 2) that: builds a `wasmtime::Config`, calls `.wasm_gc(true)` (and `.wasm_function_references(true)` — check by reading `wasmtime-28.0.1`'s `config.rs`, vendored at `~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasmtime-28.0.1/src/config.rs`, whether GC has an undeclared dependency on function-references that isn't auto-enabled), constructs an `Engine` from it, and hand-encodes (via `wasm_encoder` directly, no plum involved) the smallest possible module with one GC struct type, one function that does `struct.new_default` + returns it typed as `(ref null $T)`, and confirms it validates AND instantiates+runs under that `Engine`. If this fails or exhibits a real engine limitation, that finding drives every subsequent step — report it before proceeding, don't route around it silently.
5ef4579 42
0e39618 43
- [x] **Step 2: Update the test harness's `Engine`/`Config` construction**
5ef4579 44
5ef4579 45
Read the current `Engine::default()` (or equivalent) call sites in `codegen_tests.rs` and `examples_test.rs` (search `wasmtime::Engine`) and replace with the `Config` confirmed working in Step 1. Every test in both files must still pass unchanged (they're still exercising the untouched bump-allocator codegen at this point) — this step is purely "can a GC-enabled engine still run today's non-GC output," which it should, since enabling a feature doesn't retroactively require using it.
5ef4579 46
0e39618 47
- [x] **Step 3: Read the current type-registration surface before extending it**
5ef4579 48
5ef4579 49
Read `plum-checker`'s `ClassEnv`/`EnumVariants`/`EnumVariantInfo` definitions (`plum-checker/src/lib.rs`) and `plum-wasm-codegen`'s `CompileCtx`/`LocalCtx` (`plum-wasm-codegen/src/lib.rs`) exact current fields — this plan's earlier research read them as of the enum-discriminant-values plan's completion, but re-verify, since intervening work may have touched them.
5ef4579 50
0e39618 51
- [x] **Step 4: Add a GC type registry, populated but unconsumed**
5ef4579 52
5ef4579 53
Add a new struct (e.g. `GcTypeRegistry`) that, given the monomorphized `ast::Source` plus the existing `ClassEnv`/`EnumVariants` tables, assigns and records a wasm type-section index for: every concrete class name, every enum's abstract supertype, every enum variant's concrete subtype, and one shared `array<i8>` index for `Str`. Emit these into a real `TypeSection` via `CoreTypeEncoder::struct_`/`array`/`subtype` (structs need `SubType { is_final: false, supertype_idx: None, ... }` for a class since it needs no subtyping, but an enum's supertype needs `is_final: false` so variants CAN subtype it, and each variant subtype needs `SubType { supertype_idx: Some(super_idx), ... }`) — group the whole enum's supertype+variants in one `rec` group (`CoreTypeEncoder::rec`) since wasm-gc subtyping requires supertype and subtype to be declared in the same recursive group or supertype declared earlier in an earlier group; read `wasm-encoder`'s `rec`/`subtype` doc comments (`~/.cargo/registry/.../wasm-encoder-0.220.1/src/core/types.rs`) to get the ordering constraint exactly right. Thread the resulting index tables through `CompileCtx` as new fields (e.g. `class_type_idx: HashMap<String, u32>`, `enum_super_type_idx: HashMap<String, u32>`, `variant_type_idx: HashMap<String, u32>`, `str_type_idx: u32`), populated in `compileSource` but not read by anything else yet.
5ef4579 54
0e39618 55
- [x] **Step 5: Add a corresponding Rust test proving the type section is well-formed**
5ef4579 56
5ef4579 57
Add a test that compiles a small source (one class, one enum with a payload variant and a payload-free variant) through `compileSource`, and asserts the resulting bytes validate via `wasmparser::validate` under the GC-enabled config from Step 1 — this proves the new type section alone (still unused by real codegen) is well-formed, before Task 2 starts depending on it.
5ef4579 58
0e39618 59
- [x] **Step 6: Run full verification**
5ef4579 60
5ef4579 61
`cargo build --workspace --all-targets` then `cargo test --workspace`. Expected: 100% pass, identical results to before this task — nothing behavioral changed yet.
5ef4579 62
0e39618 63
- [x] **Step 7: Commit**
5ef4579 64
5ef4579 65
```bash
5ef4579 66
git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs plum-wasm-codegen/tests/examples_test.rs plum-wasm-codegen/Cargo.toml
5ef4579 67
git commit -m "feat(plum-wasm-codegen): add wasm-gc type-section emitter (unconsumed) and GC-enabled test Engine"
5ef4579 68
```
5ef4579 69
5ef4579 70
---
5ef4579 71
5ef4579 72
### Task 2: The big switchover — construction, field/variant access, match dispatch, strings, and closures onto wasm-gc
5ef4579 73
5ef4579 74
**Files:** `plum-wasm-codegen/src/lib.rs` (the overwhelming majority of this task), `plum-wasm-codegen/tests/codegen_tests.rs`, `plum-wasm-codegen/tests/examples_test.rs`
5ef4579 75
5ef4579 76
**Interfaces:**
5ef4579 77
- Consumes: the type registry from Task 1.
5ef4579 78
- Produces: every codegen path that currently reads/writes `ctx.bump_global` instead does `struct.new`/`struct.get`/`struct.set`/`array.*`; `match` dispatch uses `ref.test`/`br_on_cast`; the memory section, `bump_global`, `HEAP_BASE`, `STRING_BASE` are deleted with nothing left depending on them.
5ef4579 79
5ef4579 80
This task is large enough that its steps are grouped by concern. Within each concern, **read the current exact code first** — this plan describes shapes and gives file:line references from the research pass that wrote it, but by the time you reach a given step, earlier steps in this same task will have already changed surrounding code. Re-verify against the live file, not this plan's prose.
5ef4579 81
5ef4579 82
#### 2a. Bool/enum singletons and control-flow conversion (do this first — nearly everything else depends on being able to produce/consume a Bool)
5ef4579 83
0e39618 84
- [x] **Step 1: Read `compileSource`'s current initialization sequence, `compileStmt`'s `If`/`While` arms, `boolean_operator`/comparison codegen, and `assert` codegen**
5ef4579 85
5ef4579 86
These are all in `plum-wasm-codegen/src/lib.rs` — find them by searching `ast::Stmt::If`, `ast::Stmt::While`, `ast::Expr::Bool(`, `ast::Expr::Compare(`, `ast::Stmt::Assert`.
5ef4579 87
0e39618 88
- [x] **Step 2: Emit one global per payload-free variant across the whole program (including `True`/`False`)**
5ef4579 89
5ef4579 90
In `compileSource`, after the type registry (Task 1) is built, walk every enum's variants (plus the built-in `Bool`) and for each payload-free one, emit a `global` (mutable: false is ideal but wasm-gc struct globals may need an initializer expression capable of `struct.new` — read `wasm_encoder`'s `ConstExpr`/`GlobalSection` to confirm whether a GC `struct.new` is legal inside a global's constant-initializer expression under this wasm-gc revision; if not, fall back to a `start` function that runs once and stores each singleton into a mutable global before `main`/any export can run). Record `variant_name -> global_index` in a new `CompileCtx` field.
5ef4579 91
0e39618 92
- [x] **Step 3: Rewrite conditional control flow to convert Bool refs to i32 via `ref.test`**
5ef4579 93
5ef4579 94
Wherever a `Bool`-typed value currently feeds `Instruction::If`/`BrIf` unchanged (it's already an i32 today), insert `Instruction::RefTestNonNull(HeapType::Concrete(true_variant_type_idx))` immediately before it. This applies to at least: `if`/`else if` conditions, `while` conditions, `assert`, and short-circuit `&&`/`||` (which currently, per existing codegen, likely already lower to nested `If` blocks for short-circuiting — read the current `boolean_operator` codegen to confirm the exact shape before changing it).
5ef4579 95
0e39618 96
- [x] **Step 4: Rewrite comparison operators to produce a Bool ref from a native i32 predicate**
5ef4579 97
5ef4579 98
Currently a comparison (`==`, `<`, etc.) directly leaves an i32 on the stack as the Bool result. Change it to: compute the native i32 predicate as today, then `Instruction::If(BlockType::Result(bool_ref_type))` / push `GlobalGet(true_singleton)` / `Else` / `GlobalGet(false_singleton)` / `End`, so the result is a proper `Bool` ref matching every other Bool-producing expression.
5ef4579 99
0e39618 100
- [x] **Step 5: Update every OTHER payload-free bare-variant construction site to use its singleton global instead of allocating**
5ef4579 101
5ef4579 102
Search for the current handling of `ast::Expr::TypeName(n)` where `info.field_types.is_empty()` (bare tag construction) — this currently does `Instruction::I32Const(info.tag)`; change it to `Instruction::GlobalGet(singleton_idx)`.
5ef4579 103
0e39618 104
- [x] **Step 6: Build and run affected tests only, iterating**
5ef4579 105
5ef4579 106
`cargo build -p plum-wasm-codegen` first (fast compile-only check — expect many errors from downstream code still assuming i32 Bool/tags, which is fine, that's 2b/2c/2d's job). Do not attempt `cargo test` yet — the crate won't fully compile again until struct/enum construction (2b) and match dispatch (2d) also land, since they're mutually dependent on the same representation.
5ef4579 107
5ef4579 108
#### 2b. Struct/class construction and field access
5ef4579 109
0e39618 110
- [x] **Step 1: Read the current `ast::Expr::ClassCall` construction arm and the `Attribute`/`Field` read/write codegen**
5ef4579 111
5ef4579 112
Search `ast::Expr::ClassCall`, `ast::AttrKind::Field`, `ast::AssignTarget::Field`.
5ef4579 113
0e39618 114
- [x] **Step 2: Rewrite construction to `struct.new`**
5ef4579 115
5ef4579 116
For each `ClassCall`, look up the class's declared field order (not `call.fields`'s written order — construction must push values in DECLARATION order for `struct.new`). For each declared field, find the matching `call.fields` entry by name and `compileExpr` its value onto the stack; once every field's value is pushed, emit `Instruction::StructNew(class_type_idx)`. The `classcall_scratch` local is very likely no longer needed for this case (no bump-pointer bookkeeping requires holding an intermediate address — verify by reading whether anything else in the current code relies on that scratch slot for `ClassCall` specifically before deleting its allocation for this case).
5ef4579 117
0e39618 118
- [x] **Step 3: Rewrite field reads to `struct.get`**
5ef4579 119
5ef4579 120
Replace the current class-field-read arm's `emitLoad` at a byte offset with `compileExpr` (pushes the object ref) followed by `Instruction::StructGet { struct_type_index: class_type_idx, field_index }`.
5ef4579 121
0e39618 122
- [x] **Step 4: Rewrite field writes (`self.x = ...`, chained `a.b.c = ...`) to `struct.set`**
5ef4579 123
5ef4579 124
Same shape: compile the object ref, compile the new value, `Instruction::StructSet { struct_type_index, field_index }`. `StructSet`'s stack order — verify from `wasm-encoder`'s encode impl or the wasm-gc spec whether it expects `(struct_ref, value)` or `(value, struct_ref)` push order; get this from the actual instruction encoding, not assumption, since getting it backwards produces a type-valid-looking-in-isolation-but-wrong-at-runtime bug that validation may not always catch depending on other stack contents.
5ef4579 125
0e39618 126
- [x] **Step 5: Rewrite discriminant-enum field access (`self.n` on an `enum Foo(n: Int)`)** analogously, through `ctx.enum_params`, using `StructGet`/`StructSet` against the enum's own struct layout with the `field_idx + 1` tag-slot offset convention preserved conceptually (though the "+1 for tag" byte-offset trick is a linear-memory artifact — under wasm-gc each variant subtype simply has its own explicit field list starting at field index 0 for the first real field, no tag slot needed at all, since the tag IS the type itself now, discoverable via `ref.test`, not a stored value read at a fixed offset).
5ef4579 127
5ef4579 128
#### 2c. String migration to `array<i8>`
5ef4579 129
0e39618 130
- [x] **Step 1: Read the current string literal encoding, `registerStringConcatHelper`, `registerIntToStringHelper`, and string interpolation codegen**
5ef4579 131
5ef4579 132
Search `compile_static_string` (or equivalent), `registerStringConcatHelper`, `registerIntToStringHelper`.
5ef4579 133
0e39618 134
- [x] **Step 2: Static string literals become passive-data-segment `array.new_data`**
5ef4579 135
5ef4579 136
Each string literal's bytes go into one passive `DataSection::passive(bytes)` segment (no active memory offset — confirm `wasm_encoder`'s `DataSection::passive` doesn't require a preceding memory section to exist at all, per this plan's Decision 4). At the literal's use site, emit `Instruction::ArrayNewData { array_type_index: str_type_idx, array_data_index }`.
5ef4579 137
0e39618 138
- [x] **Step 3: Rewrite `registerStringConcatHelper` against `array.new`/`array.copy`**
5ef4579 139
5ef4579 140
Read the exact current helper body first. The new version needs the combined length (`array.len` on both inputs, or track lengths already known from a length-prefix design if you keep one — decide whether `Str`'s `array<i8>` needs an explicit stored length or relies on `array.len`, and note that GC arrays DO track their own length natively, so a manual length prefix is no longer needed at all, simplifying this over the current length-prefixed-blob design), allocates a new array of that combined size (`array.new_default` sized dynamically — check whether `array.new_default` accepts a non-constant size operand; it does, per the wasm-gc spec, size is a runtime i32 operand), then two `Instruction::ArrayCopy { array_type_index_dst, array_type_index_src }` calls to copy each source's bytes into the destination at the right offset.
5ef4579 141
0e39618 142
- [x] **Step 4: Rewrite `registerIntToStringHelper` similarly** — read its current byte-writing loop and adapt each byte store to `array.set` (or build into a fixed-size scratch and `array.new_data`-style copy, whichever the current algorithm's shape most directly maps to).
5ef4579 143
0e39618 144
- [x] **Step 5: Update string interpolation codegen** to call the rewritten helpers; verify `Str`-typed class/enum fields (now `ref null $StrArray` instead of i32) round-trip correctly through 2b/2d's `struct.get`/`struct.set` and `ref.test`/variant-subtype field lists.
5ef4579 145
5ef4579 146
#### 2d. Match dispatch via `ref.test`/`br_on_cast`
5ef4579 147
0e39618 148
- [x] **Step 1: Read `compileMatch`, `compileMatchArmsMulti`, `compileCasePositions`, `compileVariantEqArm` (or its current name — it may have been renamed during the camelCase pass), `compileVariantConstructorArm`, and `compileFieldPatterns` in full before changing any of them** — this is the most intricate part of the existing codegen (recursive multi-subject, multi-position, nested-constructor-pattern dispatch) and the part most likely to have subtle behavioral requirements not obvious from a partial read.
5ef4579 149
0e39618 150
- [x] **Step 2: Replace the `HEAP_BASE` range-check + tag-load-and-compare pattern with `ref.test`**
5ef4579 151
5ef4579 152
Every place that currently does "range-check against `HEAP_BASE`, then conditionally load+compare a tag" (both the top-level bare-Name/Class-pattern arms and the nested `compileFieldPatterns` equivalents) becomes a single `Instruction::RefTestNonNull(HeapType::Concrete(variant_type_idx))` (or `RefTestNullable` if the value can legitimately be null in context — decide per call site) directly on the subject ref, producing the same "did this match" i32 the existing `If`/`Else` structure around it already expects — the surrounding control-flow shape (fall through to `compileMatchArmsMulti(rest, ...)` on mismatch) doesn't need to change, only how the boolean test itself is computed.
5ef4579 153
0e39618 154
- [x] **Step 3: Replace payload-field extraction with `ref.cast` + `struct.get`**
5ef4579 155
5ef4579 156
Where a matched constructor pattern currently loads a field via `emitLoad(field_vt, offset)` from the raw pointer, first `Instruction::RefCastNonNull(HeapType::Concrete(variant_type_idx))` (narrowing the statically-typed-as-supertype subject to its concrete variant subtype — the match arm's binding scope is exactly where this narrowing is valid) then `Instruction::StructGet { struct_type_index: variant_type_idx, field_index }`.
5ef4579 157
0e39618 158
- [x] **Step 4: Consider (but don't require) using `br_on_cast` for the top-level dispatch chain** — the spec's Components section suggests `ref.test`/`br_on_cast` as the replacement mechanism; a straightforward `ref.test` + `if/else` chain (mirroring today's control-flow shape exactly, per Step 2) is very likely simpler to get right than restructuring into `br_on_cast`'s branch-table-like shape, and is equally correct. Only reach for `br_on_cast` if the `if/else` chain form turns out to hit a real wasm-gc validation or structural limitation — don't restructure working control flow for its own sake.
5ef4579 159
5ef4579 160
#### 2e. Closures
5ef4579 161
0e39618 162
- [x] **Step 1: Read `compileClosureLiteral`, `compileClosureCall`, `compileClosureBody`, and the `Collector`'s closure-discovery pass in full.**
5ef4579 163
0e39618 164
- [x] **Step 2: Give every closure literal its own env `struct` type**, one field per captured variable in `free_vars` order, field types matching each captured variable's own type (which may itself now be a `ref` type post-2b/2c). Register these in the type registry alongside classes/enums.
5ef4579 165
0e39618 166
- [x] **Step 3: Rewrite `compileClosureLiteral`**: build the env via `struct.new` (pushing each captured local's current value, per Decision 3 — snapshot-by-value semantics are unchanged, only the allocation mechanism changes), then build the 2-field closure struct `{i32 table_index, anyref env}` via `struct.new`, casting/widening the concrete env ref to `anyref` for storage (a supertype widening — confirm whether `wasm-encoder`/wasm-gc requires an explicit instruction for this or whether it's implicit at the type-checking level when a subtype value is used where a supertype/`anyref` is expected; if implicit, no extra instruction is needed).
5ef4579 167
0e39618 168
- [x] **Step 4: Rewrite `compileClosureBody`**: its first instructions must `RefCastNonNull` (or the nullable variant, matching the declared param type) the incoming `anyref` env parameter down to `(ref $ThisClosuresEnvType)` before any captured-field reads, then use `struct.get` for each.
5ef4579 169
0e39618 170
- [x] **Step 5: Update the shared `call_indirect` function-type signatures** (`fnParamTypeToWasmSig`/`ClosureSigKey` and wherever `call_indirect` types are registered/deduped) so the env-pointer parameter position is `ValType::Ref(RefType::ANYREF)` instead of `ValType::I32`, everywhere this shape is constructed.
5ef4579 171
5ef4579 172
#### 2f. Retire the bump allocator entirely
5ef4579 173
0e39618 174
- [x] **Step 1: Remove `HEAP_BASE`, `STRING_BASE`, `bump_global` (the field, its threading through `CompileCtx`/`LocalCtx`, and its initialization in `compileSource`), and the `addMemory`/memory-section code in `WasmModule::finish`** once `cargo build -p plum-wasm-codegen` shows nothing references them anymore. If anything still does, that's a sign an earlier 2a–2e step was missed or incomplete — go back, don't leave a partial bump-allocator fallback path.
0e39618 175
- [x] **Step 2: Remove now-dead scratch-slot bookkeeping** (`classcall_scratch` etc., in `Collector` and `LocalCtx`) for any use case fully replaced by direct stack-order `struct.new` construction (per 2b Step 2) — but only what's actually provably dead; `match`'s subject-holding scratch locals are still needed (now typed as `ref null` instead of `i32`), don't remove those.
5ef4579 176
5ef4579 177
#### 2g. Full verification for Task 2
5ef4579 178
0e39618 179
- [x] **Step 1: `cargo build --workspace --all-targets`** — iterate until clean.
0e39618 180
- [x] **Step 2: Update every existing `codegen_tests.rs`/`examples_test.rs` test that encoded assumptions about the old representation** (e.g. anything asserting raw byte layout, or relying on `HEAP_BASE`-adjacent behavior) — re-verify each still asserts the right OBSERVABLE behavior (a program's computed result), not the old mechanism.
0e39618 181
- [x] **Step 3: `cargo test --workspace`** — expected: 100% pass. This is the real gate for this entire task; do not commit Task 2 before this is green.
0e39618 182
- [x] **Step 4: Commit**
5ef4579 183
5ef4579 184
```bash
5ef4579 185
git add plum-wasm-codegen/src plum-wasm-codegen/tests
5ef4579 186
git commit -m "feat(plum-wasm-codegen): migrate structs, enums, strings, and closures to wasm-gc"
5ef4579 187
```
5ef4579 188
5ef4579 189
---
5ef4579 190
5ef4579 191
### Task 3: Implement `List`'s `todo` methods against the new struct representation
5ef4579 192
5ef4579 193
**Files:** `libs/std/list.plum`, `plum-wasm-codegen/tests/codegen_tests.rs`
5ef4579 194
5ef4579 195
**Interfaces:**
5ef4579 196
- Consumes: `struct.new`/`struct.set`/`ref null` field semantics from Task 2 — specifically, that unlinking a `Node` (overwriting a `next`/`prev` field with `None`) makes it unreachable and GC-reclaimed with no explicit free.
5ef4579 197
- Produces: `add`, `set`, `removeAt`, `remove`, `clear`, `reverse` on `List[T]` (currently `todo`) have real implementations; `Map` (built on `List`) works transitively.
5ef4579 198
02b3582 199
- [x] **Step 1: Read `libs/std/list.plum`'s current `Node`/`List` field declarations and the already-implemented `get`/`length`/`each`/`map` methods** for the established style (`Option[Node]` traversal via `match current`) to match.
02b3582 200
- [x] **Step 2: Implement `add`** — append (or prepend, per the existing doc comment on `add` — re-read it; the comment currently says "adds the specified elements to the start of the list" — confirm this is the intended semantic, since "add" more commonly means append, and fix the comment or the implementation, whichever is actually wrong, rather than silently implementing whichever is more convenient) by splicing a new `Node` in via `self.tail`/`self.head` and updating `size`. **Was prepend in the doc comment, append in spirit (`makeList` builds in call order) — fixed the comment; implemented as append.**
02b3582 201
- [x] **Step 3: Implement `set`** — traverse to index `i` (mirroring `get`'s traversal), overwrite the found node's `value` field, return `Some(oldValue)` or `None` if out of bounds.
02b3582 202
- [x] **Step 4: Implement `removeAt`/`remove`** — traverse to the target node, splice it out by rewriting its neighbors' `prev`/`next` to point at each other (or to `None` at the list's ends, updating `self.head`/`self.tail` accordingly), decrement `size`. The removed node becomes unreachable once nothing points to it — no manual free needed. **Added a shared `unlink` helper for the splice logic, used by both.**
02b3582 203
- [x] **Step 5: Implement `clear`** — reset `self.head`/`self.tail` to `None` and `size` to `0`; every node becomes unreachable transitively.
02b3582 204
- [x] **Step 6: Implement `reverse`** — build (or in-place relink) a new list with `prev`/`next` swapped at every node. **The pre-existing declared signature was `reverse(self, v: fn(T) -> Bool) -> List` (a leftover, unrelated predicate param — looks copy-pasted from `sort`); fixed to `reverse(self) -> List`.**
02b3582 205
- [x] **Step 7: Add codegen tests** — with an important caveat discovered while writing them (see "Discovered pre-existing gaps" below): `libs/std/list.plum` itself **cannot currently compile** through `plum-wasm-codegen`, for reasons unrelated to Task 3's new method bodies — this was already true of the pre-existing `get`/`each`/`map` before this task touched the file. The tests therefore exercise a field-for-field/statement-for-statement port of the same six methods against a deliberately **non-generic** `Node`/`List`/`NodeLink` (swapping the generic `Option[Node]` for a monomorphic `NodeLink` enum) — this validates the actual wasm-gc mechanics (self-referential nullable-via-enum struct fields, `struct.set` mutation through an aliased reference, `ref.test` dispatch, GC reachability after `removeAt`/`clear`) without depending on the separate, deeper checker gap. Two tests added: `listAddSetRemoveAtRemoveClearReverseAllWorkCorrectly` and `removingEveryNodeInALoopLeavesAnEmptyCorrectlyFunctioningList` (the latter is this step's required unreachability proof).
02b3582 206
- [x] **Step 8: Run `cargo test --workspace`.** 100% pass (211 tests across the workspace), including the new tests.
02b3582 207
- [x] **Step 9: Commit**
02b3582 208
02b3582 209
#### Discovered pre-existing gaps (found while implementing this task, NOT fixed — out of scope for "implement List's methods")
02b3582 210
02b3582 211
While testing against the REAL generic `List[T]`/`Node[T]`/`Option[Node]` shape, compilation failed with errors like `unknown type 'Option' in GC type registry` and `type name 'None' is not yet supported as a value`. Root cause, confirmed by direct inspection of the monomorphized AST: **`plum_checker::plumTypeFromAst` drops generic type arguments entirely** (`Option[Int]` and bare `Option` both become `PlumType::TNamed("Option")`) — so a class or enum-variant field declared with a concrete instantiation of a generic type (`Node.next: Option[Node]`) keeps referencing the generic type's bare name. Once monomorphization actually specializes that generic type for a concrete argument (renaming it to e.g. `Option$Int` and REMOVING the unspecialized original — confirmed via direct inspection), the field's stored type annotation is left pointing at a name that no longer exists. This is a real, pre-existing gap in `plum-checker`'s generics/monomorphization support (not something Task 2's wasm-gc work introduced — it would misbehave identically under the old bump-allocator representation, just never crash as loudly), and it blocks `libs/std/list.plum`'s `get`/`each`/`map` from compiling too, not just this task's new methods. Properly fixing it needs `PlumType` to represent type applications (not just bare names) and the monomorphizer to rewrite field-type annotations consistently with however it renames the types they reference — a separate, sizable piece of work.
02b3582 212
02b3582 213
Two smaller, genuinely wasm-gc-migration-scoped bugs were found and fixed along the way (both covered by the existing/new test suite):
02b3582 214
- `Expr::Compare`'s codegen unconditionally emitted `i64.eq`/`i64.ne` for any non-`Float` comparison — correct when `Bool`/enums were `i32`, but wrong now that they're wasm-gc refs. Fixed to emit `ref.eq` for `==`/`!=` between reference-typed operands (`plum-wasm-codegen/src/lib.rs`).
02b3582 215
- Method calls (`self.method(...)`/`obj.method(...)`) never packed trailing arguments into a GC array for a method with a variadic (`...T`) parameter — only plain function calls did. `add(self, values: ...T)` was the first variadic *method* anywhere in the codebase, which is how this surfaced. Fixed by porting the same variadic-packing logic to the method-call codegen path.
02b3582 216
02b3582 217
Also found (not fixed, not wasm-gc-related): plum's grammar's `class_call` rule requires named (`field: value`) arguments — there is no positional class-construction syntax — so the pre-existing `makeList`'s `List(None, None, 0)` (positional) has never actually compiled either. Left as-is since fixing it also requires resolving a second, separate issue (passing an already-packed variadic array through to another variadic parameter in `List(...).add(values)`).
5ef4579 218
5ef4579 219
```bash
5ef4579 220
git add libs/std/list.plum plum-wasm-codegen/tests/codegen_tests.rs
5ef4579 221
git commit -m "feat(libs/std): implement List's add/set/removeAt/remove/clear/reverse on wasm-gc"
5ef4579 222
```
5ef4579 223
5ef4579 224
---
5ef4579 225
5ef4579 226
### Task 4: Final verification and README update
5ef4579 227
5ef4579 228
**Files:** `README.md` (Known Gaps section)
5ef4579 229
ecd2178 230
- [x] **Step 1: `cargo build --workspace --all-targets` and `cargo test --workspace`.** Expected: 100% pass.
ecd2178 231
- [x] **Step 2: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test`** — unaffected by this plan (no grammar changes), confirm it's still 100% as a sanity check that nothing unrelated regressed.
ecd2178 232
- [x] **Step 3: Run `scripts/test-examples.sh`** (added in an earlier session) to confirm every `examples/*.plum` file still compiles and runs correctly through the real CLI end to end, not just the unit test suite.
ecd2178 233
- [x] **Step 4: Update README's "Known gaps" section** — the current entry "`libs/std`'s actual `List`/`Map` still don't fully compile ... `List`'s methods beyond `get`/`length` ... are still `todo`" is resolved by Task 3; remove or rewrite that bullet to reflect reality. Check whether `Map`'s own gap (references `Buffer`/`Stringable` trait-bounded dispatch, which `plum-checker` still doesn't process) is still accurate and leave that part as-is if so — don't overclaim `Map` fully works if trait dispatch still doesn't exist.
ecd2178 234
- [x] **Step 5: Commit**
5ef4579 235
5ef4579 236
```bash
5ef4579 237
git add README.md
5ef4579 238
git commit -m "docs: update Known gaps for wasm-gc migration and List's new methods"
5ef4579 239
```