plum

#treesitter#compiler#wasm

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

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


cc77764Peter John 2026-09-04T20:55:50+05:30
docs: update README for Bool/Str import-gating and stdlib additions
Files changed (1) hide show
  1. README.md +55 -21
README.md CHANGED
@@ -17,7 +17,7 @@ clang >= 16.0.0
17
17
 
18
18
  This section documents the syntax currently implemented by the [tree-sitter grammar](tooling/tree-sitter-plum/grammar.js), [`plum-checker`](plum-checker), and [`plum-wasm-codegen`](plum-wasm-codegen). Every construct below has a runnable, verified example in [`examples/`](examples) — the two test suites [`plum-checker/tests/examples_test.rs`](plum-checker/tests/examples_test.rs) and [`plum-wasm-codegen/tests/examples_test.rs`](plum-wasm-codegen/tests/examples_test.rs) parse, type-check, and (where noted) compile-and-execute every file in that directory, so this documentation can't silently drift out of sync with what actually works.
19
19
 
20
- > The [docs website](website/src/content/docs) describes a considerably more elaborate *future* syntax (`fn`, `record`, arrow return types, `{}` blocks, generic `[T]` params). That syntax isn't implemented yet — everything below is what the compiler accepts **today**.
20
+ > The [docs website](website/src/content/docs) describes a considerably more elaborate *future* syntax (`fn`, `record`, arrow return types, `{}` blocks). That syntax isn't implemented yet — everything below is what the compiler accepts **today**. (Generic `[T]` class/trait/enum params are one piece of that future syntax that *has* landed already — see [Generics](#generics).)
21
21
 
22
22
  ### Table of contents
23
23
 
@@ -33,11 +33,13 @@ This section documents the syntax currently implemented by the [tree-sitter gram
33
33
  - [Testing](#testing)
34
34
  - [Types: records, traits, enums](#types-records-traits-enums)
35
35
  - [Generics](#generics)
36
+ - [Closures](#closures)
36
37
  - [`self`, field access, and methods](#self-field-access-and-methods)
37
38
  - [Constructing values](#constructing-values)
38
39
  - [`match`](#match)
39
40
  - [String interpolation](#string-interpolation)
40
41
  - [`extern` functions and printing](#extern-functions-and-printing)
42
+ - [Standard library highlights](#standard-library-highlights)
41
43
  - [Known gaps](#known-gaps)
42
44
 
43
45
  ### Layout and comments
@@ -58,13 +60,14 @@ The lexer enforces case by construct — using the wrong case for a position is
58
60
 
59
61
  | Token | Case | Used for |
60
62
  |---|---|---|
61
- | `type_identifier` | `PascalCase` | type/trait/enum names, generic bounds |
63
+ | `type_identifier` | `PascalCase` | type/trait/enum names, trait bounds |
62
64
  | `fn_identifier` | `camelCase` | function/method names, `.field`/`.method()` member names |
63
65
  | `var_identifier` | `snake_case` | variables, params, fields (also accepts camelCase — see below) |
64
66
  | `const_identifier` | `SCREAMING_SNAKE_CASE` | top-level constants |
65
67
  | `mod_identifier` | `snake_case`, single word | `module` declarations only (no `/`) |
68
+ | `generic` | single uppercase letter (`T`, `K`, `V`, ...) | generic type parameters — see [Generics](#generics) |
66
69
 
67
- `var_identifier`'s regex is deliberately a superset of `fn_identifier`'s (it allows both `_` and mixed case) so that a bare function call like `factorial(x)` — an all-lowercase, no-underscore name — isn't ambiguous between "a variable" and "a function" at the lexer level.
70
+ `var_identifier`'s regex is deliberately a superset of `fn_identifier`'s (it allows both `_` and mixed case) so that a bare function call like `factorial(x)` — an all-lowercase, no-underscore name — isn't ambiguous between "a variable" and "a function" at the lexer level. A single uppercase letter is reserved for generics and is illegal as an ordinary `type_identifier` — you can't name a type `T`.
68
71
 
69
72
  ### Modules and imports
70
73
 
@@ -106,7 +109,7 @@ yes = True
106
109
  no = False
107
110
  ```
108
111
 
109
- `True`/`False` are built into the type checker/codegen as `Bool`'s two variants you don't need to declare `enum Bool` yourself to use them.
112
+ `True`/`False` are `Bool`'s two variants, declared as an ordinary `enum` in [`libs/std/Bool.plum`](libs/std/Bool.plum) `import std/Bool` to use them (see [Standard library highlights](#standard-library-highlights)); there's no implicit prelude that pulls `Bool` in for you.
110
113
 
111
114
  Full example: [`examples/basics.plum`](examples/basics.plum), [`examples/strings.plum`](examples/strings.plum).
112
115
 
@@ -128,9 +131,10 @@ From tightest to loosest binding:
128
131
  | | `!` | boolean not |
129
132
  | | `&&` | |
130
133
  | | `\|\|` | |
131
- | | `..` | range (e.g. `for i in 0..5`) |
132
134
  | lowest | `? :` | ternary |
133
135
 
136
+ There's no range operator — `for i := range n` (see [Control flow](#control-flow)) is the only way to iterate a numeric range, and it's a dedicated statement form, not an expression built from `..`.
137
+
134
138
  `!` binds **looser** than comparisons — `!a == b` means `!(a == b)`, not `(!a) == b`. Whenever you mix categories (especially with `!`), group explicitly rather than relying on memorized precedence:
135
139
 
136
140
  ```plum
@@ -147,11 +151,13 @@ Full example: [`examples/basics.plum`](examples/basics.plum).
147
151
  ### Variables and assignment
148
152
 
149
153
  ```plum
150
- x = 1
154
+ x := 1 # declares a new binding — errors if `x` is already in scope
151
- a, b = 1, 2 # multiple targets, positionally paired with multiple values
155
+ a, b := 1, 2 # multiple targets, positionally paired with multiple values
156
+
157
+ x = 2 # reassigns an existing binding — type-checked against x's declared type
152
158
  ```
153
159
 
154
- Assignment introduces a binding if `x` isn't already in scope, or updates it otherwise there's no separate `let`/`var` keyword.
160
+ `:=` (Go-style) always declares a fresh binding, and it's an error to `:=` a name that's already in scope. Bare `=` is kept for backward compatibility: on a name's first use it still declares (exactly like `:=`), but on every subsequent use it reassigns and is type-checked against the existing binding's type — it can no longer silently change a variable's type out from under it. New code should prefer `:=` for declarations and reserve `=` for reassignment, but both spellings work in either position on a first use.
155
161
 
156
162
  ### Control flow
157
163
 
@@ -163,10 +169,11 @@ else if n == 0
163
169
  else
164
170
  return "positive"
165
171
 
172
+ i := start
166
173
  while i > 0
167
174
  i = i - 1
168
175
 
169
- for i in 0..limit
176
+ for i := range limit
170
177
  if i == 3
171
178
  continue
172
179
  if i == 8
@@ -177,7 +184,7 @@ assert n > 0 # traps at runtime if false
177
184
  todo # traps at runtime — marks a body as not yet implemented
178
185
  ```
179
186
 
180
- `for x in <range>` only accepts a range expression (`a..b`) or another primary expression on the right there's no iterator protocol yet.
187
+ `for i := range n` is the only `for`-loop form — it iterates `i` from `0` up to (excluding) `n`, where `n` is an `Int`-valued expression, and binds `i` as `Int`. There's no `in`/`..` range syntax and no general iterator protocol; the one other thing `range` accepts is a variadic function parameter (`fun sumAll(nums: ...Int) = for n := range nums ...`), which iterates its actual arguments and binds `n` as the variadic's element type instead of `Int`.
181
188
 
182
189
  Full example: [`examples/control_flow.plum`](examples/control_flow.plum).
183
190
 
@@ -256,29 +263,37 @@ enum Option =
256
263
  enum Shape =
257
264
  | Circle(radius: Int) # named payload fields — Circle(radius: 5) or Circle(5)
258
265
  | Square(x: Int, y: Int) # Square(x: 1, y: 2) or Square(1, 2)
266
+
267
+ enum Step(n: Int) = # a shared param on the whole enum ...
268
+ | ReadMin(10) # ... each variant supplies its own discriminant value for it
269
+ | ReadMax(20)
259
270
  ```
260
271
 
272
+ An `enum(param: Type) = | Variant(value) ...` declares one shared field on every variant of the enum, with each variant giving its own literal value for that field instead of a payload — `match` still matches `ReadMin`/`ReadMax` by variant tag, but a method on the enum can read `self.n` back and get `10` or `20` depending which variant `self` is. See `Step`/`Step.toNumber` in [`examples/types.plum`](examples/types.plum).
273
+
261
274
  Full example: [`examples/types.plum`](examples/types.plum).
262
275
 
263
276
  `type X(TraitName, ...) = ...`'s trait claims are checked: `plum-checker` verifies `X` actually defines every method `TraitName` declares, with a matching param count/types and return type (a return/param type the trait itself can't parameterize, like `Err`'s `cause() -> Option`, matches any concrete instantiation an implementer declares, e.g. `Option[MyError]`). A trait name that isn't actually declared anywhere (`Comparable`/`Readable`/`Writable` are used this way in a few places in `libs/std` today) is silently unenforced rather than an error, since there's nothing real yet to check it against.
264
277
 
265
278
  ### Generics
266
279
 
267
- Generic type parameters are single letters only: `a`, `b`, `c`, `d`.
280
+ Generic type **parameters** are single uppercase letters (`T`, `K`, `V`, ...) — a single uppercase letter is a reserved token of its own (`generic`), distinct from an ordinary `PascalCase` `type_identifier`, so you can't name a real type `T`. A class/trait/enum declares its generic parameters with a bracketed `[Param, Param: Bound, ...]` list right after its name; an optional `: Bound` (or `: Bound1 + Bound2`) constrains a param to types implementing that trait.
268
281
 
269
282
  ```plum
270
- type Box(a) =
283
+ type Box[T] =
271
- value: a
284
+ value: T
272
285
 
273
- trait Comparable(a: Ord) = # bounded generic param
286
+ trait Comparable[T: Ord] = # bounded generic param
274
- compareTo(other: a) -> Int
287
+ compareTo(other: T) -> Int
275
288
 
276
- fun wrap(value: a) -> Bool = # generic param type
289
+ fun wrap(value: T) -> Bool = # a bare uppercase letter in a param/return type is enough —
277
- True
290
+ True # free functions have no `[T]` declaration of their own
278
291
  ```
279
292
 
280
293
  Generic **arguments** (instantiating a generic type) accept either bracket or paren syntax: `List[Int]` and `List(Int)` both parse. User-defined generics (classes, their methods, free functions, and enums) are monomorphized: each concrete-type-argument combination actually used in the program gets its own specialized, fully-concrete copy, which then type-checks and compiles to wasm through the normal, unmodified pipeline. See `useWrap`/`usePair` in [`examples/functions.plum`](examples/functions.plum) and `makeIntBox`/`makeStrBox` in [`examples/types.plum`](examples/types.plum) for real instantiation sites. Generic enums support any number of concrete instantiations coexisting in one program (variant names are mangled per instantiation, e.g. `Some` -> `Some$Int`/`Some$Str`, internally — invisible to user code). One narrower residual limitation: a payload-free variant (e.g. `None`) used as a bare value *outside* of a `match` pattern can't be disambiguated between multiple concrete instantiations of its enum from that expression alone; constructing via a payload-carrying sibling (`Some(5)`) and matching (`Some(v) => ...`, `None => ...`) is fully supported and is the overwhelmingly common usage pattern.
281
294
 
295
+ Constructing a generic class (`Foo[T](...)`) only ever accepts `name: value` keyword arguments or an empty argument list — there's no positional-argument form for a `class_call`, generic or not (see `Standard library highlights` below for how `Array[T]`'s API is shaped around this gap).
296
+
282
297
  Full example: [`examples/types.plum`](examples/types.plum), [`examples/functions.plum`](examples/functions.plum).
283
298
 
284
299
  ### Closures
@@ -362,9 +377,15 @@ match opt
362
377
  v
363
378
  None =>
364
379
  0
380
+
381
+ match book
382
+ FantasyBook(title, _, hasMythicalCreatures) when hasMythicalCreatures =>
383
+ "Fantasy book \"{title}\" features mythical creatures"
384
+ FantasyBook(title, _, _) =>
385
+ "Fantasy book \"{title}\" has no mythical creatures"
365
386
  ```
366
387
 
367
- A case body can be a single inline expression right after `=>`, or an indented block — pick whichever reads better for that arm. Patterns can be: integer/float/string literals, a bare identifier (binds a new local to the subject's value), a bare capitalized tag (`True`, `False`, or any declared `enum` variant with no payload — compared, not bound), a constructor pattern (`Some(v)`, binding its argument — sub-patterns can themselves be constructor patterns, e.g. `Wrap(Some(v))`), or `_` (wildcard). `match a, b, ...` against multiple comma-separated subjects is also supported — each case supplies one pattern per subject, and every position must match for that case to apply.
388
+ A case body can be a single inline expression right after `=>`, or an indented block — pick whichever reads better for that arm. Patterns can be: integer/float/string literals, a bare identifier (binds a new local to the subject's value), a bare capitalized tag (`True`, `False`, or any declared `enum` variant with no payload — compared, not bound), a constructor pattern (`Some(v)`, binding its argument — sub-patterns can themselves be constructor patterns, e.g. `Wrap(Some(v))`), or `_` (wildcard). `match a, b, ...` against multiple comma-separated subjects is also supported — each case supplies one pattern per subject, and every position must match for that case to apply. A case's pattern can also carry a `when <expr>` guard: the pattern must still match positionally for the guard to run at all, and a matching pattern whose guard evaluates `False` falls through to the *next* case (the same fallthrough an outright pattern mismatch gets), not to a different position within the same case. See [`examples/dop_visitor.plum`](examples/dop_visitor.plum) for a realistic use of guards.
368
389
 
369
390
  Full example: [`examples/match.plum`](examples/match.plum).
370
391
 
@@ -385,7 +406,7 @@ Full example: [`examples/strings.plum`](examples/strings.plum).
385
406
  extern fun printLn(s: Str)
386
407
  ```
387
408
 
388
- `extern fun` declares a function with no Plum body at all — no `=`, no `todo` — backed instead by a host-provided wasm import. wasm has no built-in notion of stdout, so this is how `printLn` exists: `plum-checker` requires an `extern` fn to have no body and no receiver (it can't be a method), and `plum-wasm-codegen` compiles it to a genuine wasm import (`plum::printLn`) instead of a normal function body. Both `plum run` (JIT-executes immediately under an embedded `wasmtime`) and `plum build` (AOT-compiles to wasm and links it into a standalone executable via the `plum-runtime` crate) supply this import; a bare `.wasm` file run through an unmodified `wasmtime` CLI will fail to instantiate for lack of it. `libs/std/os.plum` declares `printLn` for use via `import std/os`; [`examples/io.plum`](examples/io.plum) declares an equivalent local copy (avoiding the import, so it stays runnable by the single-file test harnesses in `plum-checker`/`plum-wasm-codegen`) and is the smallest example to run for a smoke test:
409
+ `extern fun` declares a function with no Plum body at all — no `=`, no `todo` — backed instead by a host-provided wasm import. wasm has no built-in notion of stdout, so this is how `printLn` exists: `plum-checker` requires an `extern` fn to have no body and no receiver (it can't be a method), and `plum-wasm-codegen` compiles it to a genuine wasm import (`plum::printLn`) instead of a normal function body. Both `plum run` (JIT-executes immediately under an embedded `wasmtime`) and `plum build` (AOT-compiles to wasm and links it into a standalone executable via the `plum-runtime` crate) supply this import; a bare `.wasm` file run through an unmodified `wasmtime` CLI will fail to instantiate for lack of it. `libs/std/os.plum` declares `printLn` for use via `import std/os`; [`examples/io.plum`](examples/io.plum) declares an equivalent local copy (avoiding pulling in every other extern `import std/os` would, so it stays the smallest possible example to run for a smoke test):
389
410
 
390
411
  ```
391
412
  $ plum run examples/io.plum
@@ -393,13 +414,26 @@ hello from plum
393
414
  hello, world!
394
415
  ```
395
416
 
417
+ ### Standard library highlights
418
+
419
+ None of the types below are compiler-hardcoded (the checker/codegen doesn't know their names) except where called out — they're ordinary `libs/std/*.plum` source, imported like anything else and readable end to end.
420
+
421
+ - **`Bool`** (`import std/Bool`) is an ordinary, import-gated `enum Bool = | True | False` — it used to be hardcoded into the checker/codegen (its variants and GC type seeded unconditionally, `True`/`False` special-cased in inference) but no longer is. A file that uses `True`/`False`/`Bool` must `import std/Bool` like any other type; there's no implicit prelude.
422
+ - **`Str`** (`import std/Str`) is likewise an ordinary `type Str = data: Buffer` — a ref-counted-free wasm-gc struct wrapping a `Buffer`, not a compiler primitive. `Str` is a byte array under the hood, not an array of Unicode codepoints, but has a codepoint-aware layer on top for correctness on non-ASCII text: `runeLength()` (codepoint count, not byte count), `runeAt(i)` (the `i`-th codepoint as its own `Str`), `codePointAt(i)` (byte index -> codepoint value), and the free function `codePointToStr(cp)`. `reverse()` and word-splitting are codepoint-aware too, rather than shredding multi-byte characters.
423
+ - **`Byte`** (`import std/Byte`) is a scalar unsigned 8-bit value (`Byte(x)` converts an `Int`, wrapping rather than trapping out of range — a compiler-recognized conversion like `Int(x)`/`Float(x)`). **`[]Byte`** is a fixed-length, mutable byte slice type (grammar: `seq("[", "]", type)`) — `Str`'s and `Buffer`'s own backing storage, and the only element type a slice type currently supports.
424
+ - **`Buffer`** (`import std/Buffer`) is a growable, mutable byte sequence for building up a `Str` piece by piece (modeled on Go's `bytes.Buffer`): a `[]Byte` capacity plus a logical length, doubling on overflow, so a sequence of `n` writes is amortized O(n) rather than O(n²).
425
+ - **`Array[T]`** (`import std/Array`) is a generic, growable array backed by a wasm-gc `array<anyref>`. `T` must be a reference type (a `type`/`enum`, `Str`, `List`, ...) — a primitive `T` (`Int`/`Float`/`Bool`/`Byte`) isn't supported, since every element is boxed as `anyref` and unboxed via `ref.cast`. `Array[T]()` is deliberately zero-arg (the [generic `class_call` positional-args gap](#generics) means there's no `Array[T](n)` constructor) — build a specific length by `push`ing onto an empty one.
426
+ - **`Map[K: Hashable, V]`** (`import std/Map`) is a real bucketed hash table (`hash(k) % BUCKET_COUNT`, `Array[List[Pair[K, V]]]` as the bucket storage), not an association list — `Str.hash` (FNV-1a) and `Int.hash` (identity) are the two `Hashable` implementations `libs/std` provides today.
427
+ - **`List[T]`** (`import std/List`) is a singly-linked list with `get`/`length`/`add`/`set`/`removeAt`/`remove`/`clear`/`reverse`/`each`/`map`/`reduce`/`sort`/`join`/`chunk`/`partition`, plus a `ToStr` implementation so `List[List[T]]` prints correctly too.
428
+
396
429
  ### Known gaps
397
430
 
398
431
  Some things parse and type-check but don't compile to wasm yet — `plum-wasm-codegen` reports a clear error rather than silently producing wrong code:
399
432
 
433
+ - `class_call` (constructing a `type` value, generic or not) only ever accepts `name: value` keyword arguments or an empty argument list — there's no positional-argument constructor syntax (`Foo(1, 2)` for a `type`, as opposed to an `enum` variant, isn't parseable). `Array[T]`'s API is deliberately shaped around this (see [Standard library highlights](#standard-library-highlights)).
400
434
  - interpolating a `Float` value in a string (`Str`/`Int`/`Bool` interpolation, and plain non-interpolated literals, all compile) — correct decimal formatting of a float is a substantial separate undertaking (something like Grisu/Ryu), scoped out for now
401
- - The generic-field-type gap that used to block `libs/std`'s real `List[T]`/`Node[T]` from compiling at all (a class/enum-variant field declared with a concrete instantiation of another generic type, e.g. `Node.next: Option[Node]`) is fixed — `List[T]`'s methods (`get`/`length`/`add`/`set`/`removeAt`/`remove`/`clear`/`reverse`/`each`/`map`/`reduce`/`sort`/`join`, including its `ToStr`-bounded dispatch) are exercised directly, at their real generic type, by the `test` blocks at the bottom of [`libs/std/list.plum`](libs/std/list.plum) itself (run via `plum test libs/std/list.plum`) — not a non-generic stand-in.
435
+ - The generic-field-type gap that used to block `libs/std`'s real `List[T]`/`Node[T]` from compiling at all (a class/enum-variant field declared with a concrete instantiation of another generic type, e.g. `Node.next: Option[Node]`) is fixed — `List[T]`'s methods (`get`/`length`/`add`/`set`/`removeAt`/`remove`/`clear`/`reverse`/`each`/`map`/`reduce`/`sort`/`join`, including its `ToStr`-bounded dispatch) are exercised directly, at their real generic type, by the `test` blocks at the bottom of [`libs/std/List.plum`](libs/std/List.plum) itself (run via `plum test libs/std/List.plum`) — not a non-generic stand-in.
402
436
  - `Map[K, V]` previously hit a *different* bug here: a top-level function whose own declared return type was a fully-concrete generic instantiation (e.g. `fun makeMap() -> Map[Str, Int] = ...`) caused monomorphization to eagerly (and incorrectly) attempt to specialize an unrelated generic type's method (`Option.filter`) at an unresolved type variable, producing a spurious `expected Option, found Option$V` type error even though `filter` was never called anywhere in the program. Root cause: `maybeRewriteReturn` (in `plum-checker/src/monomorphize.rs`) treated ANY return type merely NAMING a known generic class/enum as still-unresolved and in need of rewriting from the body's inferred tail type — even when that name already carried concrete type args (`List[Str]`, `Map[Str, Int]`), clobbering an already-correct declared return type with a stale, unmangled one inferred through tables monomorphization hadn't finished refreshing yet. Fixed by only treating a BARE reference (no `[...]` at all, e.g. `-> Box`) as needing that rewrite; a companion fix pre-resolves every ordinary (non-generic) fn/method's signature into the type tables before any body rewriting begins, so cross-file call order no longer matters. `Option.andThen`/`Result.map`/`Result.mapErr` (a single extra generic, possibly nested inside a closure's return type) are now implemented and working. `Map.map` (needing TWO new generic params nested inside a closure's return type, on a class rather than an enum) is now implemented too, once `specializeFn` was fixed to also substitute a method's own extra generics inside its BODY (not just its params/return) — an explicit bracketed construction like `Map[X, Y](items: ...)` written directly in a method's own body previously kept the literal letters "X"/"Y" forever, silently minting bogus specializations that corrupted unrelated template compilation elsewhere in the program. A second, narrower gap remains: a closure-literal argument whose body does field access on its own param (`|p| cb(p.key, p.val)`) still can't type-check once more than one specialization of the same generic class shares that field name (e.g. both `Pair$Str$Int` and `Pair$Str$Str` declare `key`/`val`) — the checker's field-usage heuristic for inferring a closure param's class becomes ambiguous and gives up; `Map.map`'s own body sidesteps this by using a manual linked-list walk (matching `keys`/`values`/`each`'s existing style) instead of `List.map` with a field-accessing closure
403
437
  - A generic class/enum method whose return type nests that SAME class/enum inside itself (`List[T].chunk(self) -> List[List[T]]`) used to stack-overflow the compiler for every program merely importing the class, whether or not anything called the method — fixed; `List.chunk`/`List.partition` now ship and work as a working example of the pattern (build any inner `List[T]` value via an EXISTING ordinary method, e.g. `self.sublist(...)`, never a second bare `List(...)` construction in the same body, which is ambiguous with the method's own return-type fallback)
404
438
  - A ternary as a closure's own inline body (`|n| cond ? a : b`) used to parse as an OUTER ternary wrapping the whole closure instead (`(|n| cond) ? a : b`) — a grammar shift/reduce ambiguity where `closure`'s default precedence (0) beat `ternary_expression`'s deliberately-low `PREC.conditional` (-1) at the point the parser decides whether to keep extending the closure's body or reduce it as already-complete. Fixed by giving `closure` its own even-lower precedence (`PREC.closure = -2`, `tooling/tree-sitter-plum/grammar.js`) so the parser now always prefers letting a ternary (or anything else valid there) keep extending the closure's inline body — see `examples/closures.plum`'s `"closure with an inline ternary body runs correctly"` test.
405
- - `libs/std/str.plum` is otherwise complete (including `endsWith`/`pad`/`truncate`, filled in alongside the `ToStr`-conformance work below), but three methods remain deliberately unimplemented `todo` stubs (they compile fine and trap at runtime if actually called, rather than failing to compile — a different flavor of gap than the rest of this section): `test`/`matchPattern`/`matchAll`/`replace`/`replaceAll`/`search` all need a real `Regex` type/engine, which doesn't exist anywhere in `libs/std` yet; `deburr` (diacritic stripping, `é` -> `e`) needs a full Unicode decomposition table; `template` (lodash's `_.template`) needs the ability to compile and run Plum source from a `Str` value at runtime — there's no `eval`/dynamic-codegen capability in the language to build it on.
439
+ - `libs/std/Str.plum` is otherwise complete (including `endsWith`/`pad`/`truncate`, filled in alongside the `ToStr`-conformance work below), but three methods remain deliberately unimplemented `todo` stubs (they compile fine and trap at runtime if actually called, rather than failing to compile — a different flavor of gap than the rest of this section): `test`/`matchPattern`/`matchAll`/`replace`/`replaceAll`/`search` all need a real `Regex` type/engine, which doesn't exist anywhere in `libs/std` yet; `deburr` (diacritic stripping, `é` -> `e`) needs a full Unicode decomposition table; `template` (lodash's `_.template`) needs the ability to compile and run Plum source from a `Str` value at runtime — there's no `eval`/dynamic-codegen capability in the language to build it on.