plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
8a00a46
— Peter John
2026-11-06T14:38:08+05:30
plans: a simpler language, approved, on its own branch
docs/plans/2026-11-06-simpler-language.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# A simpler language
|
|
2
|
+
|
|
3
|
+
**Review plan:** docs/plans/2026-11-06-simpler-language.review.md, approved by the user 2026-11-06 ("Yes do everything to simplify all the language syntax and do it in a separate branch so that we can compare later on").
|
|
4
|
+
**Goal:** remove inheritance, two scene styles, `return`, `while`, `break`, `continue` and local reassignment; add modules, `.=` and paths in spreads; make the in-place proof work by type for structs and enums; compare the old imperative game with the new functional one.
|
|
5
|
+
**Architecture:** compiler/core, compiler/checker, compiler/driver, compiler/wasm-codegen, compiler/cli, compiler/lsp, compiler/host, compiler/game, compiler/tooling/tree-sitter-plum, compiler/tooling/vscode-plum, std/, examples/, tests/, docs/.
|
|
6
|
+
**Tech stack:** Rust 2021, Plum, tree-sitter.
|
|
7
|
+
**Detail:** contracts
|
|
8
|
+
**Target-min:** 480
|
|
9
|
+
|
|
10
|
+
## Global constraints
|
|
11
|
+
- All work is on branch `simpler-language` in the main checkout (`/Users/pyrossh/Code/plum`), default shared `target/`. Never switch branch, never touch `master`, no worktrees, don't commit (the coordinator does). Exception: T1 only, see its notes. The user asked for this branch; it overrides the AGENTS.md rule for this run only.
|
|
12
|
+
- Never run `plum format` on real files. No `sed -i -E`. `../kam_plum` is out of scope.
|
|
13
|
+
- No `cargo test` (hook): `cargo build --workspace --tests`, then run every test executable, listed by `cargo test --no-run --workspace 2>&1 | grep Executable` (14 binaries; may shrink). Sweep `target/debug/plum test std examples tests`. `compiler/game` is a standalone crate with its own build.
|
|
14
|
+
- Tree-sitter regeneration needs `--disable-optimizations`.
|
|
15
|
+
- Tests of a removed feature are deleted in the task that removes it; add each to the task's reply so it reaches the results. Any other test that fails is a bug. Never weaken a check to make a test pass.
|
|
16
|
+
- Behaviour: a program that still compiles prints what it printed before the task. Compare against the T1 baseline tree where in doubt. Wasm bytes may change; behaviour may not.
|
|
17
|
+
- Differential check at each task's end: the sweep passes with the default, with `--no-fbip`, and with `--no-fusion`.
|
|
18
|
+
- Rewriting Plum sources by script is fine when the script is checked by the sweep. Put scripts in `.superlite/`. Hand-write anything subtle.
|
|
19
|
+
- User decisions (not to be reopened): methods stay indented under their type, no receiver syntax. `mut` fields stay. `=` stays only for `mut` fields. `?` and postfix `!` stay. `for` stays as side-effect sugar only. `Array` and `Slice` stay mutable and get no FBIP. No `consume`, `own`, `pure fun` or `|>`. No `pub`/private.
|
|
20
|
+
|
|
21
|
+
## Task graph
|
|
22
|
+
```mermaid
|
|
23
|
+
flowchart LR
|
|
24
|
+
T1[baseline on master]
|
|
25
|
+
T2[plain games, one scene]
|
|
26
|
+
T3[no inheritance]
|
|
27
|
+
T4[modules]
|
|
28
|
+
T5[std namespaces to modules]
|
|
29
|
+
T6[question rewrite and block if]
|
|
30
|
+
T7[update operator and spread paths]
|
|
31
|
+
T8[std off return and while]
|
|
32
|
+
T9[examples and tests off return and while]
|
|
33
|
+
T10[remove the old forms]
|
|
34
|
+
T11[proof by type]
|
|
35
|
+
T12[last-use moves]
|
|
36
|
+
T13[shared and unique callers]
|
|
37
|
+
T14[functional port and comparison]
|
|
38
|
+
T15[docs]
|
|
39
|
+
%% taskgraph
|
|
40
|
+
T1 --> T2
|
|
41
|
+
T2 --> T3
|
|
42
|
+
T3 --> T4
|
|
43
|
+
T4 --> T5
|
|
44
|
+
T5 --> T6
|
|
45
|
+
T6 --> T7
|
|
46
|
+
T7 --> T8
|
|
47
|
+
T8 --> T9
|
|
48
|
+
T9 --> T10
|
|
49
|
+
T10 --> T11
|
|
50
|
+
T11 --> T12
|
|
51
|
+
T12 --> T13
|
|
52
|
+
T13 --> T14
|
|
53
|
+
T14 --> T15
|
|
54
|
+
```
|
|
55
|
+
Ruling: chain - every task rewrites the same std and compiler passes, so parallel writers would collide - cost: a long sequential run.
|
|
56
|
+
|
|
57
|
+
## Package A: baseline and removal
|
|
58
|
+
**Probe entry:** run=plum test std examples tests
|
|
59
|
+
|
|
60
|
+
### T1 baseline on master · Complexity 3 · Est. context 40k
|
|
61
|
+
**Owns:** docs/plans/2026-11-06-simpler-language.benchmark.md, .superlite/baseline-master/**
|
|
62
|
+
**Components:** 1
|
|
63
|
+
**LOC owned:** 100
|
|
64
|
+
**Tests:** fast
|
|
65
|
+
**Produces:** `.superlite/baseline-master/` holds an exported, release-built copy of `master` with its `plum` and `plum-web-test`; the benchmark doc records its game and loop numbers
|
|
66
|
+
e.g. `.superlite/baseline-master/target/release/plum test std` runs today's language
|
|
67
|
+
**Notes:** The export is `git archive master | tar -x -C .superlite/baseline-master`, not a worktree. Build it in release, including `compiler/game/web-test`. Record, with load average and 3 runs each: `stress_scene`, `stress_world` and the other stress fixtures through `plum-web-test` as in docs/plans/2026-11-05-game-engine.results.md, split into update, draw and render; compile time and `.wasm` size for one game; lines of `compiler/` Rust and of `std/`; the sweep count; the five loop probes from docs/plans/2026-11-06-simpler-language.review.md. Say which fixtures use the imperative scene and which state-and-view, and whether they draw the same scene. Don't change anything outside the owned paths.
|
|
68
|
+
**Test code:** none; the numbers are the deliverable.
|
|
69
|
+
|
|
70
|
+
### T2 plain games, one scene · Complexity 6 · Est. context 80k
|
|
71
|
+
**Owns:** std/game/**, examples/games/**, compiler/game/**, compiler/cli/**, compiler/driver/**, compiler/checker/src/**, compiler/checker/tests/**, compiler/core/src/**, compiler/core/tests/**, compiler/wasm-codegen/**, tests/**, docs/plans/2026-11-06-simpler-language.benchmark.md
|
|
72
|
+
**Components:** 3
|
|
73
|
+
**LOC owned:** 800
|
|
74
|
+
**Tests:** fast
|
|
75
|
+
**Consumes:** `.superlite/baseline-master/` holds an exported, release-built copy of `master` with its `plum` and `plum-web-test`; the benchmark doc records its game and loop numbers (T1)
|
|
76
|
+
**Produces:** `main` returning any type with `update(self, io, dt) -> Self` and `draw(self, io)` runs as a game; `std/game/scene` is the only scene library
|
|
77
|
+
e.g. the `OrbRun` game in the review plan runs under `plum run`, with `import std/game/Scene` until T4 adds module names
|
|
78
|
+
**Notes:** The return type of `main` decides: such a type is a game; `Int`, `Result` or nothing is a CLI app. A type with only one of the two methods gets a clear error naming the missing one. Remove `Game`, `Engine.create` and the `self.canvas`, `self.input` wrapper fields: games call `io` functions. Move `std/game/functional/*` to `std/game/`, delete `std/game/imperative/` and `std/game/pure/`, and port their still useful examples to state-and-view. Keep `compiler/game/web-test/fixtures` covering the same scenes: keep the old imperative fixtures' intent by porting them, since T14 measures them against `.superlite/baseline-master`. Hot reload keeps working.
|
|
79
|
+
**Test code:** the cli `run`/`build` tests use a plain game; a test for the missing-method error; web-test still passes on a ported game.
|
|
80
|
+
|
|
81
|
+
### T3 no inheritance · Complexity 7 · Est. context 90k
|
|
82
|
+
**Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, compiler/lsp/**, compiler/tooling/**, compiler/game/**, std/**, examples/**, tests/**
|
|
83
|
+
**Components:** 3
|
|
84
|
+
**LOC owned:** 1500
|
|
85
|
+
**Tests:** fast
|
|
86
|
+
**Consumes:** `main` returning any type with `update(self, io, dt) -> Self` and `draw(self, io)` runs as a game; `std/game/scene` is the only scene library (T2)
|
|
87
|
+
**Produces:** `abstract`, `override` and embedded struct fields are parse errors; `embeds.rs`, subtyping and override dispatch are gone
|
|
88
|
+
e.g. `struct A(B(x))` gives a parse error, and `examples/subtyping.plum` is deleted
|
|
89
|
+
**Notes:** Delete, don't disable. Every method call has one target. Replace remaining uses in std and examples with enums or plain structs. Purity, simplify, drive, reuse, uniqueness lose their override handling: remove it rather than leave dead branches. Report lines removed.
|
|
90
|
+
**Test code:** a parse-error test for each of the three removed forms.
|
|
91
|
+
|
|
92
|
+
## Package B: modules
|
|
93
|
+
|
|
94
|
+
### T4 modules · Complexity 8 · Est. context 110k
|
|
95
|
+
**Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, compiler/driver/**, compiler/lsp/**, compiler/host/**, compiler/tooling/**, tests/modules/**
|
|
96
|
+
**Components:** 3
|
|
97
|
+
**LOC owned:** 900
|
|
98
|
+
**Tests:** fast
|
|
99
|
+
**Consumes:** `abstract`, `override` and embedded struct fields are parse errors; `embeds.rs`, subtyping and override dispatch are gone (T3)
|
|
100
|
+
**Produces:** `import a/b` gives `b.f()` and `b.Const`; types and enum variants stay unqualified; two modules may reuse a function or constant name; module-level constants; `extern fun` at module level with `io: Io` first; `primitive` is rejected outside std
|
|
101
|
+
e.g. two imported modules each with `fun clamp` both work as `a.clamp(..)` and `b.clamp(..)`, and `ArenaW = 800.0` is a constant
|
|
102
|
+
**Notes:** Mangle module functions and constants while merging, so later passes keep seeing global names; show the source name in errors and the LSP. Two modules defining the same type name stay an error. Old unqualified access keeps working until T5 finishes; T5 removes that fallback. A local may shadow a module name; the local wins. Module constants accept any pure expression, evaluated once at start, and fusion may inline literals.
|
|
103
|
+
**Test code:** `tests/modules/`: same function name in two modules; constants; shadowing by a local; import cycles keep their current error; `primitive` outside std rejected; `extern` without `io` first rejected.
|
|
104
|
+
|
|
105
|
+
### T5 std namespaces to modules · Complexity 7 · Est. context 110k
|
|
106
|
+
**Owns:** std/**, examples/**, tests/**, compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, compiler/game/**, compiler/cli/**, compiler/lsp/**
|
|
107
|
+
**Components:** 3
|
|
108
|
+
**LOC owned:** 1500
|
|
109
|
+
**Tests:** fast
|
|
110
|
+
**Consumes:** `import a/b` gives `b.f()` and `b.Const`; types and enum variants stay unqualified; two modules may reuse a function or constant name; module-level constants; `extern fun` at module level with `io: Io` first; `primitive` is rejected outside std (T4)
|
|
111
|
+
**Produces:** std's namespaces are modules (`math.sqrt`, `gfx.rect(io, ..)`, `input.axis(io, ..)`); `primitive` only declares built-ins such as `Int` and `Array`; the unqualified fallback is gone
|
|
112
|
+
e.g. `import std/math` then `math.sqrt(2.0)`; `Math.sqrt` is an error
|
|
113
|
+
**Notes:** Host functions that today are `Io` methods used for games move to `gfx`, `input`, `audio` module functions taking `io` first. `io.printLn` and other general `Io` methods keep working. Static-style calls such as `Time.now(io)` become module functions where the type has no natural receiver. Use a script for call sites, checked by the sweep.
|
|
114
|
+
**Test code:** existing tests ported; a test that the old namespace form is an error.
|
|
115
|
+
|
|
116
|
+
## Package C: syntax
|
|
117
|
+
|
|
118
|
+
### T6 question rewrite and block if · Complexity 7 · Est. context 90k
|
|
119
|
+
**Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, tests/syntax/**
|
|
120
|
+
**Components:** 2
|
|
121
|
+
**LOC owned:** 500
|
|
122
|
+
**Tests:** fast
|
|
123
|
+
**Consumes:** std's namespaces are modules (`math.sqrt`, `gfx.rect(io, ..)`, `input.axis(io, ..)`); `primitive` only declares built-ins such as `Int` and `Array`; the unqualified fallback is gone (T5)
|
|
124
|
+
**Produces:** a lowering turns `x := e?` plus the rest of the block into a `match` with no `Expr::Question` left for later passes; block `if` and `match` are expressions anywhere an expression goes; functions using `?` fuse
|
|
125
|
+
e.g. `v := if a\n 1\nelse\n 2` compiles, and `--explain-fusion` reports a function containing `?` as eligible
|
|
126
|
+
**Notes:** Rewrite: `x := e?` then `rest` becomes `match e` with `Ok(x) => rest`, `Err(e) => Err(e)`, and the same for `Some`/`None`. A `?` nested in an argument is lifted to a temporary first, keeping evaluation order. Allowed: `?` at block level, including inside tail `if` and `match` branches, which recurse. Rejected with a clear error: `?` in a non-tail `if` branch, inside a closure whose return type doesn't fit, or inside a `for` body, with the message saying to move it into a function. The error mapping stays as it works today (`Err(error:)`). Remove the `Question` refusals in `drive.rs` and `closure_spec.rs`. Keep crash lines right for `!`.
|
|
127
|
+
**Test code:** `tests/syntax/question.plum`: chains; nested in arguments, with evaluation order observed; both enums; every rejected form; same answers with `--no-fusion`.
|
|
128
|
+
|
|
129
|
+
### T7 update operator and spread paths · Complexity 5 · Est. context 60k
|
|
130
|
+
**Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/lsp/**, compiler/tooling/**, tests/syntax/**
|
|
131
|
+
**Components:** 2
|
|
132
|
+
**LOC owned:** 300
|
|
133
|
+
**Tests:** fast
|
|
134
|
+
**Consumes:** a lowering turns `x := e?` plus the rest of the block into a `match` with no `Expr::Question` left for later passes; block `if` and `match` are expressions anywhere an expression goes; functions using `?` fuse (T6)
|
|
135
|
+
**Produces:** `self.items .= push(x)` means `self.items = self.items.push(x)` on a `mut` field; `State(..s, ship.x: 1.0)` updates a nested field without a temporary
|
|
136
|
+
e.g. `State(..s, ship.x: 1.0)` is `State(..s, ship: Ship(..s.ship, x: 1.0))`
|
|
137
|
+
**Notes:** Both desugar at parse or lowering so later passes see plain forms. The path receiver in `.=` is evaluated once. Paths in spreads go through struct fields only; an enum or a generic parameter on the path is a type error. Two paths may share a prefix: `S(..s, a.x: 1, a.y: 2)` builds one `a`. Update tree-sitter, the formatter queries and vscode syntax.
|
|
138
|
+
**Test code:** `tests/syntax/`: shared prefixes, deep paths, a receiver with a side effect runs once, non-`mut` target rejected, path through an enum rejected.
|
|
139
|
+
|
|
140
|
+
### T8 std off return and while · Complexity 8 · Est. context 130k
|
|
141
|
+
**Owns:** std/**
|
|
142
|
+
**Components:** 2
|
|
143
|
+
**LOC owned:** 3000
|
|
144
|
+
**Tests:** fast
|
|
145
|
+
**Consumes:** `self.items .= push(x)` means `self.items = self.items.push(x)` on a `mut` field; `State(..s, ship.x: 1.0)` updates a nested field without a temporary (T7)
|
|
146
|
+
**Produces:** std uses no `return`, `while`, `break`, `continue` or local `=`; std provides `fold`, `each`, `find`, `repeatUntil` over `range` and collections
|
|
147
|
+
e.g. `grep -rE '\breturn\b|^\s*while ' std` finds nothing in code
|
|
148
|
+
**Notes:** State goes through arguments, since closures copy captures. Use local recursive helper functions for hot paths in `Str`, `Buffer`, `Map`, `Slice`. A `for` with a side-effect body writing through `Array.set` is fine. Keep the same public behaviour and complexity: check `Map` and `Buffer` haven't gained copies. Compare the std probes in the benchmark doc after each large file, and report any more than 20% slower. Split the work by file and keep the sweep green throughout.
|
|
149
|
+
**Test code:** existing std tests stay unchanged and pass.
|
|
150
|
+
|
|
151
|
+
### T9 examples and tests off return and while · Complexity 6 · Est. context 110k
|
|
152
|
+
**Owns:** examples/**, tests/**, compiler/core/tests/**, compiler/checker/tests/**, compiler/wasm-codegen/tests/**, compiler/cli/tests/**, compiler/game/**
|
|
153
|
+
**Components:** 2
|
|
154
|
+
**LOC owned:** 2500
|
|
155
|
+
**Tests:** fast
|
|
156
|
+
**Consumes:** std uses no `return`, `while`, `break`, `continue` or local `=`; std provides `fold`, `each`, `find`, `repeatUntil` over `range` and collections (T8)
|
|
157
|
+
**Produces:** no Plum source in examples, tests or Rust test strings uses the old forms, apart from tests of those forms themselves
|
|
158
|
+
e.g. `examples/control_flow.plum` teaches `if`, `match`, `for`, `fold` and recursion
|
|
159
|
+
**Notes:** Rust tests of `return`/`while` semantics that exist only for those forms are deleted in T10, not here. `tests/fbip` keeps checking the same aliasing cases in the new forms: never drop one. Kids' games use `for` and state structs and stay readable.
|
|
160
|
+
**Test code:** ported tests.
|
|
161
|
+
|
|
162
|
+
### T10 remove the old forms · Complexity 8 · Est. context 120k
|
|
163
|
+
**Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, compiler/driver/**, compiler/lsp/**, compiler/tooling/**, std/Array.plum, tests/**
|
|
164
|
+
**Components:** 3
|
|
165
|
+
**LOC owned:** 2000
|
|
166
|
+
**Tests:** fast
|
|
167
|
+
**Consumes:** no Plum source in examples, tests or Rust test strings uses the old forms, apart from tests of those forms themselves (T9)
|
|
168
|
+
**Produces:** `return`, `while`, `break`, `continue` and `x = v` on a local are parse or type errors; `for` lowers to `each`; `Array.with`, `withUnique` and the pilot `Vec` are gone
|
|
169
|
+
e.g. `x = 1` on a local reports "locals can't be reassigned; use a new name with :="
|
|
170
|
+
**Notes:** Delete the AST nodes, checker and codegen arms, and the pass branches, and simplify the passes they complicated: this is the payoff. A `for` body that assigns an outer local is an error. A pass may keep a private loop node only if TRMC truly needs it, not parseable; say so if it does. Keep `fip fun`, `--no-fbip`, the fuzzer. Update the fuzzer's generator to the new forms. Report lines removed.
|
|
171
|
+
**Test code:** an error test for each removed form; `for` over a range, a list and a `Slice`.
|
|
172
|
+
|
|
173
|
+
## Package D: the proof, for structs and enums
|
|
174
|
+
|
|
175
|
+
### T11 proof by type · Complexity 8 · Est. context 110k
|
|
176
|
+
**Owns:** compiler/core/src/uniqueness.rs, compiler/core/src/purity.rs, compiler/core/src/reuse.rs, compiler/core/tests/**, compiler/driver/**, compiler/checker/src/**, tests/fbip/**
|
|
177
|
+
**Components:** 2
|
|
178
|
+
**LOC owned:** 800
|
|
179
|
+
**Tests:** fast
|
|
180
|
+
**Consumes:** `return`, `while`, `break`, `continue` and `x = v` on a local are parse or type errors; `for` lowers to `each`; `Array.with`, `withUnique` and the pilot `Vec` are gone (T10)
|
|
181
|
+
**Produces:** the proof and purity key methods by the checker's receiver type, so `Ship.update` and `Orb.update` are separate; struct and enum spreads and reuse prove in the shapes the pilot failed on
|
|
182
|
+
e.g. a struct with a method named `push` no longer blocks another type's `push`
|
|
183
|
+
**Notes:** With no overrides, each call has one target. Run after checking, or have the checker record the receiver type. Soundness first: anything unclear stays a copy.
|
|
184
|
+
**Test code:** every stage 1 aliasing test stays; new hostile programs for same-named methods on different types; the fuzzer, 200 seeds, compared with `--no-fbip`.
|
|
185
|
+
|
|
186
|
+
### T12 last-use moves · Complexity 7 · Est. context 90k
|
|
187
|
+
**Owns:** compiler/core/src/uniqueness.rs, compiler/core/tests/**, tests/fbip/**
|
|
188
|
+
**Components:** 1
|
|
189
|
+
**LOC owned:** 400
|
|
190
|
+
**Tests:** fast
|
|
191
|
+
**Consumes:** the proof and purity key methods by the checker's receiver type, so `Ship.update` and `Orb.update` are separate; struct and enum spreads and reuse prove in the shapes the pilot failed on (T11)
|
|
192
|
+
**Produces:** a local moved into a field or returned counts as handed over, so `S(..s, ship: s.ship.update(dt))` reuses both when `s` is unique
|
|
193
|
+
e.g. `--explain-fusion` shows `S(..s, ship: s.ship.update(dt))` in place
|
|
194
|
+
**Notes:** Last use is flow-sensitive across `if` and `match` branches. A closure capture or a read after the move stays shared.
|
|
195
|
+
**Test code:** hostile programs: read after the move in one branch; move in a nested spread; capture before the move; the same value moved into two fields.
|
|
196
|
+
|
|
197
|
+
### T13 shared and unique callers · Complexity 7 · Est. context 90k
|
|
198
|
+
**Owns:** compiler/core/src/uniqueness.rs, compiler/core/src/ctors.rs, compiler/core/tests/**, compiler/driver/**, tests/fbip/**
|
|
199
|
+
**Components:** 1
|
|
200
|
+
**LOC owned:** 400
|
|
201
|
+
**Tests:** fast
|
|
202
|
+
**Consumes:** a local moved into a field or returned counts as handed over, so `S(..s, ship: s.ship.update(dt))` reuses both when `s` is unique (T12)
|
|
203
|
+
**Produces:** a function called with both unique and shared values gets two compiled versions, so one shared call doesn't make every call copy
|
|
204
|
+
e.g. `--explain-fusion` names `update$unique` and `update` for such a function
|
|
205
|
+
**Notes:** Only when the unique version differs. Bound code growth: skip recursion cycles that would multiply versions, and report them. Report `.wasm` growth on one game.
|
|
206
|
+
**Test code:** a function called both ways gives the copying answer for the shared caller and reuses for the unique one; the fuzzer, 200 seeds.
|
|
207
|
+
|
|
208
|
+
## Package E: the comparison and docs
|
|
209
|
+
|
|
210
|
+
### T14 functional port and comparison · Complexity 6 · Est. context 80k
|
|
211
|
+
**Owns:** compiler/game/web-test/fixtures/**, docs/plans/2026-11-06-simpler-language.benchmark.md, docs/imperative-vs-functional.md
|
|
212
|
+
**Components:** 2
|
|
213
|
+
**LOC owned:** 500
|
|
214
|
+
**Tests:** fast
|
|
215
|
+
**Consumes:** a function called with both unique and shared values gets two compiled versions, so one shared call doesn't make every call copy (T13)
|
|
216
|
+
**Produces:** the benchmark doc compares `master`'s imperative stress games with the branch's functional ports on the same scene, and `docs/imperative-vs-functional.md` gives the result in plain words
|
|
217
|
+
e.g. a table of update, draw and total ms for old imperative, new functional, new `--no-fbip`, new `--no-fusion`
|
|
218
|
+
**Notes:** Rebuild nothing in the baseline; run old and new back to back, in alternation, 3 or more runs each, and record load. The scenes must match in sprite count and motion: state how that was checked. Also: compile time, `.wasm` size, lines of compiler and std, the sweep count, the five loop probes. Report what `--explain-fusion` says stayed copying. If the functional game loses, say so plainly and name the cause; don't tune the fixture to win. Say which conclusions the noise doesn't support.
|
|
219
|
+
**Test code:** none beyond fixtures running in the web-test harness.
|
|
220
|
+
|
|
221
|
+
### T15 docs · Complexity 5 · Est. context 70k
|
|
222
|
+
**Owns:** README.md, docs/language.md, docs/std.md, docs/game.md, docs/game-scene-styles.md, docs/tools.md, docs/performance.md, docs/backlog.md, docs/plans/2026-11-06-simpler-language.results.md
|
|
223
|
+
**Components:** 1
|
|
224
|
+
**LOC owned:** 800
|
|
225
|
+
**Tests:** fast
|
|
226
|
+
**Consumes:** the benchmark doc compares `master`'s imperative stress games with the branch's functional ports on the same scene, and `docs/imperative-vs-functional.md` gives the result in plain words (T14)
|
|
227
|
+
**Produces:** docs describe only the new language, every code example in them compiles, and the results doc lists what was removed and every deleted test
|
|
228
|
+
e.g. README's sample is the `OrbRun` shape
|
|
229
|
+
**Notes:** Keep the plain, short style of the current docs. Delete `docs/game-scene-styles.md` if one style makes it pointless, and fix links. Say in the results doc that `master` keeps the old language. Don't decide the merge: that's the user's.
|
|
230
|
+
**Test code:** compile every doc example, as earlier doc passes did.
|
docs/plans/2026-11-06-simpler-language.review.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# A simpler language: review plan
|
|
2
2
|
|
|
3
|
-
**Status:**
|
|
3
|
+
**Status:** approved by the user 2026-11-06 ("Yes do everything to simplify all the language syntax and do it in a separate branch"). Build plan: docs/plans/2026-11-06-simpler-language.md.
|
|
4
|
+
**Branch:** `simpler-language`, so it can be compared with `master`. `master` keeps today's language.
|
|
4
|
-
**Evidence:**
|
|
5
|
+
**Evidence:** docs/plans/2026-11-06-fbip-stage1.results.md.
|
|
5
6
|
|
|
6
7
|
## Summary
|
|
7
8
|
|
|
8
|
-
Remove inheritance, two of the three scene styles,
|
|
9
|
-
|
|
10
|
-
The pilot showed that FBIP can't go ahead as planned: the in-place proof works by name across the whole program, so a collection with ordinary names like `push` and `data` is never updated in place. That makes the work here a prerequisite, not just a tidy-up.
|
|
9
|
+
Make Plum a small, functional language. Remove inheritance, two of the three scene styles, `return`, `while`, `break`, `continue` and reassigning locals. Give it real modules. Make games plain structs. Then make the optimiser reliable for structs and enums, and measure the old imperative game against the new functional one.
|
|
11
10
|
|
|
12
11
|
## What a game looks like
|
|
13
12
|
|
|
@@ -44,69 +43,68 @@ fun main(io: Io) -> OrbRun =
|
|
|
44
43
|
|
|
45
44
|
A game is any type with `update` and `draw`, found by duck typing, as with Ebiten.
|
|
46
45
|
|
|
46
|
+
## Decisions
|
|
47
|
+
|
|
48
|
+
| Question | Decision |
|
|
49
|
+
|---|---|
|
|
50
|
+
| Inheritance | Removed: `abstract`, `override`, struct embedding |
|
|
51
|
+
| Scene styles | One: state-and-view, as `std/game/scene`. Imperative and pure scenes removed |
|
|
52
|
+
| std namespaces | Modules: `Math.sqrt` becomes `math.sqrt`. `primitive` only declares std's built-ins |
|
|
53
|
+
| Imports | Types and enum variants unqualified; functions and constants use the module name |
|
|
54
|
+
| `return` | Removed. Block `if` is an expression |
|
|
55
|
+
| `?` | Stays. The compiler rewrites the rest of the block into a `match` arm |
|
|
56
|
+
| `while`, `break`, `continue` | Removed. Use `fold`, `find`, `repeatUntil` or a recursive function |
|
|
57
|
+
| `for` | Stays as side-effect sugar for `each`: no early exit, no updating outer variables |
|
|
58
|
+
| `=` on locals | Removed. `:=` is the only form. `=` stays only for `mut` fields |
|
|
59
|
+
| `mut` | Stays for now, for `Array`, `Slice` and other in-place shells |
|
|
60
|
+
| `.=` | Added: `self.items .= push(x)` |
|
|
61
|
+
| Paths in spreads | Added: `State(..s, ship.x: 1.0)` |
|
|
62
|
+
| FBIP for `Array`, `Slice` | Skipped: they stay mutable. `Array.with` and the pilot `Vec` go |
|
|
63
|
+
| FBIP for structs and enums | Built: proof by type, last-use moves, shared and unique callers |
|
|
64
|
+
| `consume` | Not added |
|
|
65
|
+
|
|
47
66
|
## What will be built
|
|
48
67
|
|
|
68
|
+
| Step | Part | Size |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| 1 | Record `master`'s benchmarks and keep a built copy for comparison | S |
|
|
71
|
+
| 2 | Plain games and one scene style | M |
|
|
72
|
+
| 3 | No inheritance | L, mostly deletion |
|
|
73
|
+
| 4 | Modules | L |
|
|
74
|
+
| 5 | std namespaces become modules | L |
|
|
75
|
+
| 6 | `?` as a rewrite, block `if` as an expression, `.=`, spread paths | M |
|
|
76
|
+
| 7 | Move std, examples and tests off `return`, `while` and local `=` | L |
|
|
77
|
+
| 8 | Remove those forms from the compiler, and `Array.with` | L, mostly deletion |
|
|
49
|
-
|
|
78
|
+
| 9 | Proof by type, last-use moves, shared and unique callers | L |
|
|
79
|
+
| 10 | Port the imperative stress game to a functional one, and compare | M |
|
|
80
|
+
| 11 | Rewrite the docs | M |
|
|
50
81
|
|
|
51
|
-
|
|
52
|
-
|---|---|---|---|
|
|
53
|
-
| 1 | Plain games | Any type with `update` and `draw` is a game, and `update` returns the next one. Ports every game and fixture | M |
|
|
54
|
-
| 1 | One scene style | Remove `std/game/imperative` and `std/game/pure` (about 2,250 lines) and their examples. State-and-view stays, as `std/game/scene` | M |
|
|
55
|
-
| 2 | No inheritance | Remove `abstract`, `override` and struct embedding: `embeds.rs`, subtyping, most of `ctors.rs`, override dispatch | L, mostly deletion |
|
|
56
|
-
| 3 | Modules | `import a/b` gives `b.f()` and `b.Const`. Two modules can reuse a name. Module-level constants. `extern` on module-level functions, with `io` first. `primitive` only for std's built-ins | L |
|
|
57
|
-
| 4 | Syntax | The changes in decision 3 below | M |
|
|
58
|
-
| 5 | Proof by type | The proof uses the checker's types, so `Vec.push` and `Slice.push` are separate. With no overrides, every call has exactly one target | L |
|
|
59
|
-
| 6 | Last-use moves | A local moved into a field or returned counts as handed over: `S(..s, f: s.f.push(x))` through a method, and a returned unique local | M |
|
|
60
|
-
| 7 | Shared and unique callers | One shared call no longer turns every call into a copy, by compiling a second version of the function | M |
|
|
61
|
-
| 8 | FBIP stage 2 | Run the pilot again. If it passes, port `Slice`, `Map`, `Buffer`, `Deque` and the rest | L |
|
|
82
|
+
## The comparison
|
|
62
83
|
|
|
63
|
-
|
|
84
|
+
The same scene in both languages, on the same machine, back to back:
|
|
64
85
|
|
|
86
|
+
- **Old:** `master`'s imperative scene: a tree of objects changed in place.
|
|
65
|
-
|
|
87
|
+
- **New:** the branch's functional game: state and view, with fusion and FBIP.
|
|
88
|
+
- **New, optimiser off:** the same game with `--no-fusion`, and with `--no-fbip`, to show what the optimiser earns.
|
|
66
89
|
|
|
67
|
-
|
|
68
|
-
|---|---|
|
|
69
|
-
| Today's `Slice` with `mut` | 25 ms |
|
|
70
|
-
| A persistent `List`, `l = l.map(..)` | 15 to 19 ms |
|
|
71
|
-
| A pilot `Vec` in a local, proved in place | 31 ms |
|
|
72
|
-
| A pilot `Vec` in a state struct field, copying because the proof fails | 22,000 ms |
|
|
73
|
-
|
|
74
|
-
Two things follow:
|
|
75
|
-
- **In-place works when it's proved,** close to `Slice`. When it isn't proved, it's about 900 times slower. That's the risk `fip fun` exists to catch.
|
|
76
|
-
- **A persistent `List` is already fast, even faster than `Slice`.** The state-and-view style holds lists, so games using it are safe now, before any of steps 5 to 8.
|
|
77
|
-
|
|
78
|
-
## Decisions for you
|
|
79
|
-
|
|
80
|
-
1. **Remove inheritance?** Recommended. It costs little in features (enums and structs of functions cover it), and it removes most of the difficulty in the proof.
|
|
81
|
-
2. **Remove the imperative scene?** Recommended. If you'd rather keep a tree of objects you can change, the alternative is to keep it, written with `mut` fields and enums.
|
|
82
|
-
3. **Which syntax changes?**
|
|
83
|
-
- **Block `if` as an expression, and no `return`.** Recommended, the most valuable and the most disruptive. Fusion skips any function with an early `return`, so this turns the optimiser on everywhere. `?` stays. Returning from inside a loop would need `fold` or a helper.
|
|
84
|
-
- **`self.items .= push(x)`** for `self.items = self.items.push(x)`. Recommended.
|
|
85
|
-
- **`State(..s, ship.x: 1.0)`**, paths in a spread. Recommended, because binding a field to a variable blocks the proof.
|
|
86
|
-
- `consume`, a move marker: only if steps 5 to 7 leave a real gap.
|
|
87
|
-
4. **Should std's namespaces become modules?** `Math.sqrt` becomes `math.sqrt`, and so on. Recommended, so `primitive` means one thing: declaring a built-in.
|
|
88
|
-
5. **How are names imported?** Recommended: types and enum variants are unqualified, and functions and constants use the module name: `Texture`, `Some`, but `gfx.rect`.
|
|
89
|
-
6. **Remove `mut`?** Later. It becomes possible after step 8, if the shells no longer need it. Decide then, with numbers.
|
|
90
|
+
The report also covers compile time, `.wasm` size, and the size of the compiler and std.
|
|
90
91
|
|
|
91
92
|
## Risks
|
|
92
93
|
|
|
93
|
-
- **
|
|
94
|
-
- **
|
|
95
|
-
- **Wrong proofs are silent bugs.** Each proof change gets
|
|
96
|
-
- **
|
|
97
|
-
- **
|
|
94
|
+
- **Slower loops.** 50 million simple iterations cost the same in every form today, but that wasn't boxed state or game code. A closure the compiler can't specialise stays an indirect call. The comparison will show it, and the report will say so if the functional game loses.
|
|
95
|
+
- **Silent copies.** A shape the proof misses is much slower, with no error. `--explain-fusion` shows it and `fip fun` makes it an error.
|
|
96
|
+
- **Wrong proofs are silent bugs.** Each proof change gets hostile programs and the fuzzer in `tests/fbip/fuzz`, compared against `--no-fbip`.
|
|
97
|
+
- **Harder for beginners.** Kids' games lose `while` and counters. `for` keeps the simple case readable.
|
|
98
|
+
- **Very large migration.** About 600 `return`s and 150 `while`s. It runs after each new form works, so tests stay green throughout.
|
|
99
|
+
- **Modules touch every pass,** since names are assumed global. Mangling names per module while merging keeps later passes unchanged.
|
|
98
100
|
|
|
99
101
|
## Out of scope
|
|
100
102
|
|
|
101
|
-
Package management, visibility (`pub`/private), multi-touch, and splitting `Io`.
|
|
103
|
+
Package management, visibility (`pub`/private), multi-touch, and splitting `Io`. `kam_plum` isn't ported.
|
|
102
104
|
|
|
103
105
|
## How we will know it works
|
|
104
106
|
|
|
105
|
-
- Each step keeps all tests green,
|
|
107
|
+
- Each step keeps all tests green. Tests of removed features are deleted, and listed in the results.
|
|
106
|
-
-
|
|
108
|
+
- Examples behave as they do on `master`.
|
|
107
|
-
-
|
|
109
|
+
- The comparison is written down, whichever way it comes out.
|
|
108
|
-
- The compiler
|
|
110
|
+
- The compiler and std are smaller at the end than on `master`.
|
|
109
|
-
|
|
110
|
-
## Rough time and cost
|
|
111
|
-
|
|
112
|
-
Large, but steps 1 and 2 are mostly deletion. Each step is its own build and can be stopped at. Steps 1 to 4 are worth having even if FBIP stops there.
|