plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-24-list-methods-design.md
| 0ec0f63 | 1 | # Design: wire up List's remaining methods |
| 0ec0f63 | 2 | |
| 0ec0f63 | 3 | ## Problem |
| 0ec0f63 | 4 | |
| 0ec0f63 | 5 | `libs/std/list.plum`'s `List` (a generic, mutable doubly-linked list backed by |
| 0ec0f63 | 6 | `Node(a)` with `head`/`tail`/`size` fields) has `get`, `length`, `each`, `map`, |
| 0ec0f63 | 7 | `first`, and `last` already implemented and working. `add`, `set`, `removeAt`, |
| 0ec0f63 | 8 | `remove`, `clear`, and `reverse` are still `todo` stubs. This was blocked on |
| 0ec0f63 | 9 | two prerequisites that are now both done: field/attribute assignment |
| 0ec0f63 | 10 | (`self.head = ...`, needed to mutate the linked structure) and variadic |
| 0ec0f63 | 11 | parameters (`values: ...a`, needed by `add`'s signature). `join` is not one of |
| 0ec0f63 | 12 | the six originally-scoped methods, but it separately calls `Buffer()` (an |
| 0ec0f63 | 13 | undeclared type) and `v.toStr()` on a generic-typed value (which would need |
| 0ec0f63 | 14 | trait-bounded dispatch — `plum-checker` doesn't process trait declarations at |
| 0ec0f63 | 15 | all) — both unrelated to this cycle's actual blockers, so it's included here |
| 0ec0f63 | 16 | with a simpler fix instead of being deferred again. |
| 0ec0f63 | 17 | |
| 0ec0f63 | 18 | ## Scope |
| 0ec0f63 | 19 | |
| 0ec0f63 | 20 | In scope: `add`, `set`, `removeAt`, `remove`, `clear`, `reverse`, `join`. |
| 0ec0f63 | 21 | |
| 0ec0f63 | 22 | Out of scope (untouched, still `todo`, not part of README's original gap and |
| 0ec0f63 | 23 | not addressed here): `sort`, `find`, `contains`, `flatMap`, `retain`, `reject`, |
| 0ec0f63 | 24 | `any`, `every`, `reduce`, `sublist`, `take`, `skip`, `drop`, `sample`, |
| 0ec0f63 | 25 | `shuffle`, `partition`, `chunk`, `groupBy`. `Map`'s own methods (a separate |
| 0ec0f63 | 26 | class in `libs/std/map.plum`) are untouched. |
| 0ec0f63 | 27 | |
| 0ec0f63 | 28 | Two corrections to the existing (buggy/inconsistent) doc comments and one |
| 0ec0f63 | 29 | signature change, confirmed with the project owner: |
| 0ec0f63 | 30 | - `add`'s doc comment currently says "to the start" but `init<List>` calls |
| 0ec0f63 | 31 | `List().add(values)` expecting insertion order to be preserved, which only |
| 0ec0f63 | 32 | works if `add` appends to the *tail*. The comment is stale; `add` appends. |
| 0ec0f63 | 33 | - `reverse`'s current signature, `reverse<List>(self, v: fn(a) -> Bool) -> |
| 0ec0f63 | 34 | List`, has a predicate parameter that doesn't match its own doc comment |
| 0ec0f63 | 35 | ("returns a new list with the elements in reverse order") — a copy-paste |
| 0ec0f63 | 36 | artifact from a filter-style method. Signature becomes `reverse<List>(self) |
| 0ec0f63 | 37 | -> List`, no predicate. |
| 0ec0f63 | 38 | - The mutable, doubly-linked `Node`/`head`/`tail`/`size` design is kept as-is |
| 0ec0f63 | 39 | (not rewritten as an immutable `Cons`/`Empty` enum) — it's already |
| 0ec0f63 | 40 | half-built and tested (`get`/`length`/`each`/`map`/`first`/`last` all depend |
| 0ec0f63 | 41 | on it), and it gives O(1) append/length, which a cons-list can't for `add`'s |
| 0ec0f63 | 42 | agreed append-in-order semantics. |
| 0ec0f63 | 43 | |
| 0ec0f63 | 44 | ## Method designs |
| 0ec0f63 | 45 | |
| 0ec0f63 | 46 | All methods below operate on the existing `type Node(a) = value: a, prev: |
| 0ec0f63 | 47 | Option[Node], next: Option[Node]` and `type List(Stringable)(a: Stringable) = |
| 0ec0f63 | 48 | head: Option[Node], tail: Option[Node], size: Int` — no type changes. |
| 0ec0f63 | 49 | |
| 0ec0f63 | 50 | **`add<List>(self, values: ...a)`**: for each value (in call order, via `for v |
| 0ec0f63 | 51 | in values`), construct `newNode = Node(value: v, prev: self.tail, next: None)`, |
| 0ec0f63 | 52 | link it after the current tail (`self.tail`'s node gets `.next = Some(newNode)` |
| 0ec0f63 | 53 | if a tail exists, otherwise this is the first node so `self.head = |
| 0ec0f63 | 54 | Some(newNode)` too), then `self.tail = Some(newNode)` and `self.size = self.size |
| 0ec0f63 | 55 | + 1`. |
| 0ec0f63 | 56 | |
| 0ec0f63 | 57 | **`set<List>(self, i: Int, v: a) -> Option(a)`**: traverse from `self.head` |
| 0ec0f63 | 58 | exactly like `get` does; at the matching index, capture the node's current |
| 0ec0f63 | 59 | `value`, assign `node.value = v`, and return `Some(oldValue)`; if the traversal |
| 0ec0f63 | 60 | runs off the end without finding index `i`, return `None`. |
| 0ec0f63 | 61 | |
| 0ec0f63 | 62 | **`removeAt<List>(self, i: Int)`** / **`remove<List>(self, v: a)`**: both |
| 0ec0f63 | 63 | locate a target node (`removeAt` by traversing to index `i`; `remove` by |
| 0ec0f63 | 64 | traversing and comparing `node.value == v`) and, if found, unlink it: its |
| 0ec0f63 | 65 | `prev` node's `next` becomes the target's `next` (or, if the target had no |
| 0ec0f63 | 66 | `prev`, `self.head` becomes the target's `next` — the target was the head); |
| 0ec0f63 | 67 | symmetrically for `next`/`self.tail`. Both decrement `self.size`. Neither |
| 0ec0f63 | 68 | returns a value (matching their current `todo` signatures, which declare no |
| 0ec0f63 | 69 | `-> T`). If the target isn't found, both are a no-op (no error) — this |
| 0ec0f63 | 70 | matches `get`'s existing behavior of returning `None` rather than erroring on |
| 0ec0f63 | 71 | an out-of-range index, so `removeAt`/`remove` follow the same "graceful |
| 0ec0f63 | 72 | no-match" convention already established in this file. |
| 0ec0f63 | 73 | |
| 0ec0f63 | 74 | **`clear<List>(self)`**: `self.head = None`, `self.tail = None`, `self.size = |
| 0ec0f63 | 75 | 0`. |
| 0ec0f63 | 76 | |
| 0ec0f63 | 77 | **`reverse<List>(self) -> List`**: `nl = List()`, then walk `self` from |
| 0ec0f63 | 78 | `self.tail` backward via each node's `.prev` (this naturally visits values in |
| 0ec0f63 | 79 | reverse order), calling `nl.add(value)` for each — reuses `add` rather than |
| 0ec0f63 | 80 | reimplementing low-level linking for the new list. |
| 0ec0f63 | 81 | |
| 0ec0f63 | 82 | **`join<List>(self, sep: Str = ",") -> Str`**: rewritten to avoid `Buffer`, |
| 0ec0f63 | 83 | `.toStr()`, and any trait dispatch — builds the result via string |
| 0ec0f63 | 84 | interpolation, which already compiles for `Str`/`Int`/`Bool` element types |
| 0ec0f63 | 85 | (interpolating a `Float` element remains a separate, pre-existing, unrelated |
| 0ec0f63 | 86 | gap — same as everywhere else in the language): |
| 0ec0f63 | 87 | |
| 0ec0f63 | 88 | ``` |
| 0ec0f63 | 89 | join<List>(self, sep: Str = ",") -> Str = |
| 0ec0f63 | 90 | result = "" |
| 0ec0f63 | 91 | current = self.head |
| 0ec0f63 | 92 | while current != None |
| 0ec0f63 | 93 | match current |
| 0ec0f63 | 94 | Some(node) => |
| 0ec0f63 | 95 | result = "{result}{node.value}{sep}" |
| 0ec0f63 | 96 | current = node.next |
| 0ec0f63 | 97 | None => |
| 0ec0f63 | 98 | break |
| 0ec0f63 | 99 | result |
| 0ec0f63 | 100 | ``` |
| 0ec0f63 | 101 | |
| 0ec0f63 | 102 | (This preserves the original `Buffer`-based version's existing quirk of |
| 0ec0f63 | 103 | always appending a trailing separator after the last element — not a |
| 0ec0f63 | 104 | regression, matching prior behavior.) |
| 0ec0f63 | 105 | |
| abb8959 | 106 | ## Additional fix: `List()` must always give explicit fields |
| abb8959 | 107 | |
| abb8959 | 108 | `plum-checker`'s `ClassCall` type-checking only validates whatever fields ARE |
| abb8959 | 109 | given, not that all of a class's fields are supplied — so `List()` (zero |
| abb8959 | 110 | fields) type-checks today. Codegen never initializes an unmentioned field, so |
| abb8959 | 111 | it's left as whatever the WASM linear memory already held there — which, for |
| abb8959 | 112 | memory a bump allocator has never written to before, is zero (WASM memory |
| abb8959 | 113 | starts zero-filled). For `size: Int` that's correctly `0`, but `head`/`tail: |
| abb8959 | 114 | Option[Node]` are enum-tagged, and `Option`'s declaration order (`Some` listed |
| abb8959 | 115 | before `None`) means `None`'s tag is very likely not `0` — so a zero-filled |
| abb8959 | 116 | `head`/`tail` probably decodes as a garbage `Some(...)`, not `None`. This is a |
| abb8959 | 117 | latent bug in the *existing* `init<List>`/`map<List>` methods (both call bare |
| abb8959 | 118 | `List()`), not something newly introduced. Fix: every fresh-empty-list |
| abb8959 | 119 | construction in this cycle — including `init`/`map`'s pre-existing bare |
| abb8959 | 120 | `List()` calls — becomes explicit: `List(head: None, tail: None, size: 0)`. |
| abb8959 | 121 | |
| 0ec0f63 | 122 | ## Risk: generic class construction inside a generic method of another generic class |
| 0ec0f63 | 123 | |
| 0ec0f63 | 124 | `add`'s `Node(value: v, prev: ..., next: ...)` construction is a class |
| 0ec0f63 | 125 | literal for one generic class (`Node(a)`) built inside a method of a |
| 0ec0f63 | 126 | *different* generic class (`List`'s `add<List>`), with `List`'s own type |
| 0ec0f63 | 127 | parameter `a` needing to flow into `Node`'s specialization. Existing |
| 0ec0f63 | 128 | generic-codegen tests cover a generic class's method reading its own field, |
| 0ec0f63 | 129 | and a generic *function* call chain — neither is this exact shape. It should |
| 0ec0f63 | 130 | work given the current monomorphization machinery (both classes are already |
| 0ec0f63 | 131 | generic and specialized elsewhere independently), but it's genuinely |
| 0ec0f63 | 132 | untested. `add` is deliberately the first implementation task so if this |
| 0ec0f63 | 133 | reveals a real compiler gap, it surfaces immediately rather than after |
| 0ec0f63 | 134 | building `set`/`removeAt`/`remove`/`reverse` on top of an unverified |
| 0ec0f63 | 135 | assumption. |
| 0ec0f63 | 136 | |
| 0ec0f63 | 137 | ## Testing |
| 0ec0f63 | 138 | |
| 0ec0f63 | 139 | Each method gets `plum-checker` (where relevant — e.g. `set`'s `Option(a)` |
| 0ec0f63 | 140 | return) and `plum-wasm-codegen` tests exercising it via `run_main`, using |
| 0ec0f63 | 141 | small `Node`/`List`/`Option` fixtures inline in the test source (matching this |
| 0ec0f63 | 142 | project's established testing convention — see e.g. |
| 0ec0f63 | 143 | `generic_method_on_generic_class_runs_correctly` in `codegen_tests.rs`) rather |
| 0ec0f63 | 144 | than depending on cross-file loading of the real `libs/std` files (a separate |
| 0ec0f63 | 145 | concern from this spec). Specifically: |
| 0ec0f63 | 146 | - `add`: append 3 values, confirm `length()` is 3 and traversal order matches |
| 0ec0f63 | 147 | insertion order (via `get(0)`/`get(1)`/`get(2)`). |
| 0ec0f63 | 148 | - `set`: set an in-range index, confirm the returned `Option` holds the old |
| 0ec0f63 | 149 | value and `get` reflects the new one; set an out-of-range index, confirm |
| 0ec0f63 | 150 | `None`. |
| 0ec0f63 | 151 | - `removeAt`/`remove`: remove the head, the tail, and a middle element in |
| 0ec0f63 | 152 | separate cases; confirm `length()` decrements and neighboring links are |
| 0ec0f63 | 153 | correct (traverse the remaining list end-to-end). |
| 0ec0f63 | 154 | - `clear`: add elements, clear, confirm `length()` is 0 and `get(0)` is |
| 0ec0f63 | 155 | `None`. |
| 0ec0f63 | 156 | - `reverse`: build a list, reverse it, confirm element order via `get`. |
| 0ec0f63 | 157 | - `join`: build a list of `Int`s, confirm the joined `Str` matches the |
| 0ec0f63 | 158 | expected (including the trailing-separator quirk). |
| 0ec0f63 | 159 | |
| 0ec0f63 | 160 | ## README |
| 0ec0f63 | 161 | |
| 0ec0f63 | 162 | Once this lands, remove the `List`-methods-still-`todo` clause from README's |
| 0ec0f63 | 163 | "Known gaps" bullet entirely (all six originally-listed methods, plus `join`, |
| 0ec0f63 | 164 | are done) — the much longer tail of still-`todo` extras (`sort`, `find`, |
| 0ec0f63 | 165 | `flatMap`, etc.) was never part of that bullet's scope and stays unmentioned, |
| 0ec0f63 | 166 | consistent with how the bullet was already scoped before this cycle. |