plum

#treesitter#compiler#wasm

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

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


950794d — Peter John 2026-11-06T11:06:24+05:30
plans: top-level functions with receivers, module namespaces
docs/backlog.md CHANGED
@@ -4,7 +4,7 @@ Open work, in the order the user queued it, plus smaller items found during buil
4
4
 
5
5
  ## Planned (in order)
6
6
 
7
- **Current order (user 2026-11-05):** explicit fn generics (done faa4d7e) → cleanup phase 3b (pass list, positioned errors; running) → Io primitive + extern methods → move Rust crates under `compiler/` → tooling + scripts moved under compiler/ (done) → cleanup phase 4 (done: node ids, typed codegen, `inferExpr` errors at spans; `docs/plans/2026-11-05-compiler-cleanup-phase4.results.md`) → phases 5 and 6 (done: file splits, type kinds, tests in Plum; `docs/plans/2026-11-05-compiler-cleanup-phase5-6.results.md`) → benchmark (incl. iterator unboxing / niche Option, run C game A/B) → FBIP (stop for user approval) → deterministic sim → kids' games. The items below keep their detail; this line wins on order.
7
+ **Current order (user 2026-11-05):** explicit fn generics (done faa4d7e) → cleanup phase 3b (pass list, positioned errors; running) → Io primitive + extern methods → move Rust crates under `compiler/` → tooling + scripts moved under compiler/ (done) → cleanup phase 4 (done: node ids, typed codegen, `inferExpr` errors at spans; `docs/plans/2026-11-05-compiler-cleanup-phase4.results.md`) → phases 5 and 6 (done: file splits, type kinds, tests in Plum; `docs/plans/2026-11-05-compiler-cleanup-phase5-6.results.md`) → game engine refactor (done) → FBIP (user approved 2026-11-06; running, stage 1 first) → top-level functions with receivers and module namespaces (`docs/plans/2026-11-06-receiver-functions.review.md`, needs the user's decisions; replaces the "no top-level functions" note) → benchmark (incl. iterator unboxing / niche Option, run C game A/B) → FBIP (stop for user approval) → deterministic sim → kids' games. The items below keep their detail; this line wins on order.
8
8
 
9
9
  1. **Compiler cleanup and architecture** — `docs/plans/2026-11-04-compiler-cleanup.review.md` (written in another session; meant to run after language-restrictions merged). Approved 2026-11-04; running on branch `superlite/compiler-cleanup`, phase 1 first.
10
10
  - **Explicit function generics** (user 2026-11-04: right after cleanup phase 2) — functions declare their generics (`fun map[B](...)`) instead of every single capital letter being a generic; allows longer generic names and one-letter concrete types/tags. Grammar change + about 140 signatures in std/examples. Phase 2's "generics by scope" makes this mostly a grammar + signature rewrite.
docs/plans/2026-11-06-receiver-functions.review.md ADDED
@@ -0,0 +1,108 @@
1
+ # Top-level functions with receivers: review plan
2
+
3
+ **Status:** planned, not approved. Runs after FBIP, because both rewrite the same files. Nothing is built until you say so.
4
+ **Your idea (2026-11-06):** all functions are top level, written `fun Color.toStr(self)`. `primitive` is restricted to the standard library. Functions that need grouping go in their own module.
5
+
6
+ ## Summary
7
+
8
+ Today a method is written inside its type's body, and a group of related functions goes in a `primitive`. This plan gives one way to define a function: always at the top level, with the type in its name when it belongs to one.
9
+
10
+ ```plum
11
+ enum Color =
12
+ | Red
13
+ | Green
14
+ | Blue
15
+
16
+ fun Color.toStr(self) -> Str = match self
17
+ Red => "Red"
18
+ Green => "Green"
19
+ Blue => "Blue"
20
+ ```
21
+
22
+ Calling code doesn't change: `c.toStr()` still works, and so does everything built on it, such as generics, operators, `for` and overrides. The compiler already lifts nested methods to top-level functions with a receiver type internally, so this is mostly a change to the grammar and the source files.
23
+
24
+ ## What you will get
25
+
26
+ - One way to write a function, and one less level of indentation everywhere.
27
+ - A type's declaration shows only its fields or variants, so it's easy to read at a glance.
28
+ - The definition looks like the call: `fun Vec2.add` and `Vec2.add` read the same way.
29
+ - User code has no `primitive` to learn.
30
+ - Modules do the grouping, like Gleam, which also gives Plum real namespaces.
31
+
32
+ ## How it looks
33
+
34
+ ```plum
35
+ struct Vec2(x: Float, y: Float)
36
+
37
+ fun Vec2.add(self, o: Vec2) -> Vec2 = # self: a method, called v.add(o)
38
+ Vec2(x: self.x + o.x, y: self.y + o.y)
39
+
40
+ fun Vec2.unit() -> Vec2 = # no self: a static, called Vec2.unit()
41
+ Vec2(x: 1.0, y: 1.0)
42
+
43
+ fun Box[T].map[B](self, f: fn(T) -> B) -> Box[B] = # generics
44
+ Box(value: f(self.value))
45
+
46
+ override fun Orbs.update(self, dt: Float) = ... # overrides
47
+
48
+ abstract fun Game.draw(self) # abstract methods
49
+
50
+ fun steer(ship: Ship, dt: Float) -> Ship = ... # a plain function
51
+ ```
52
+
53
+ ## The real work: modules need to become namespaces
54
+
55
+ **This is the main finding, and the biggest part of the plan.** Grouping functions in a module only works if a module has its own names. Today it doesn't: the loader merges every imported file's items into one program, so all names share one global namespace. Two files can't both define `clamp`, as that's a "duplicate declaration" error, and there's no `math.clamp(x)` form. `Math.sqrt` only works today because `Math` is a `primitive`.
56
+
57
+ So user code needs something like this:
58
+
59
+ ```plum
60
+ import app/rules
61
+
62
+ fun main(io: Io) =
63
+ n := rules.clamp(5, 0, 3)
64
+ ```
65
+
66
+ ## What will be built
67
+
68
+ | Part | What it does | Size |
69
+ |---|---|---|
70
+ | Module namespaces | `import a/b` gives the name `b`, with `b.f()`, `b.T` and `b.Const`. Two modules can define the same name. Type methods come with their type | L |
71
+ | Receiver syntax | `fun T.m(self)`, statics, generics, `override`, `abstract`, `extern` on `Io`, in the grammar, parser, formatter and LSP | M |
72
+ | Top-level constants | `Vec2.Zero = ...` for a type, and `MaxRetries = 3` for a module (193 constants today, all inside bodies) | S |
73
+ | Migration tool | A script rewrites nested methods to top level. About 1,050 functions: 741 in `std`, 252 in `examples`, 64 in `tests` | M |
74
+ | User primitives | `Config`, `Grid`, `Id`, `Layout`, `Rules`, `Shelf` in the examples become types or modules, and `primitive` outside `std` becomes an error | S |
75
+ | Remove the old form | A method inside a type body becomes a parse error with a pointer to the new form | S |
76
+ | Docs | Language guide and examples | S |
77
+
78
+ ## Decisions for you
79
+
80
+ 1. **What about std's namespaces?** `Math`, `Os`, `Http`, `Testing`, `Base64`, `Ui`, `Key`, `Mouse` and `Blend` are `primitive` namespaces in `std`. You said `primitive` stays allowed in `std`, so `Math.sqrt` can stay as is. **I'd convert the plain namespaces to modules too,** keeping `primitive` for what it's truly for, declaring built-ins (`Int`, `Str`, `Io`, `Array`). Then `primitive` means one thing, and users see one model. That changes the case: `import std/math`, then `math.sqrt(x)`. It's more churn, though, and could be a later step.
81
+
82
+ 2. **Must a method live in the same file as its type?** I'd say yes, like Go: there are no surprise methods from imports, and one file shows all of a type's behaviour. `std/Json` adds `toJson` to `Int`, `Str` and others, so std stays exempt, as it is today.
83
+
84
+ 3. **How are names imported?** I'd start with `import a/b` then `b.f()`, like Gleam, plus types usable unqualified where the import names them. Does `import std/game/Game` still give plain `Game`, `Key` and `Canvas`? **I'd keep types and enum variants unqualified by import,** since `Game` and `Some` everywhere would otherwise get noisy, and require the module name only for plain functions and constants. Worth a look at real code before settling.
85
+
86
+ 4. **Free functions in `main`'s file?** You earlier noted the opposite idea, that every function should belong to something. This plan replaces that note: plain functions stay, in modules. I'd also keep `steer(ship, dt)` style rules as plain functions.
87
+
88
+ ## Risks
89
+
90
+ - **Namespaces touch the loader, checker, codegen and LSP.** They're the risky part, since names are assumed global throughout. Internally, the simplest route is to mangle names per module when merging, so later passes stay unchanged.
91
+ - **A large diff.** About 1,050 functions move, so review has to rely on tooling. The check is that example wasm stays byte-identical across the syntax migration, since the syntax changes and the program doesn't.
92
+ - **Self-hosting and FBIP.** The FBIP `fip` keyword has to fit the new form, as `fip fun T.m(self)`, and stage 2's call-site rewrites should land first.
93
+ - **Scattering.** Methods can drift away from their type within a file. `plum format` could keep them together if this becomes annoying.
94
+
95
+ ## Out of scope
96
+
97
+ Package management, visibility (`pub`/private), and multi-file types.
98
+
99
+ ## How we will know it works
100
+
101
+ - Wasm is byte-identical before and after the syntax migration, on every example.
102
+ - Two modules can define the same function name and both work.
103
+ - All tests pass, and the docs show one form only.
104
+ - `primitive` outside `std` and a method inside a type body each give a clear error.
105
+
106
+ ## Rough time and cost
107
+
108
+ Two builds. Namespaces first, about 6 to 8 tasks, then receiver syntax and migration, about 5 to 6 tasks. Could land in either order, but namespaces first lets the migration move grouped functions straight into modules.