plum

#treesitter#compiler#wasm

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

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


651acd8 — Peter John 2026-11-03T19:17:08+05:30
Closure specialisation; allocation-free actions; direct scene traversal
README.md CHANGED
@@ -962,6 +962,19 @@ fun main() -> Int =
962
962
 
963
963
  Calling the result of a call directly (`adder(1)(2)`) is not supported yet.
964
964
 
965
+ ### Automatic specialisation
966
+
967
+ A closure literal or a function name passed directly to a `fn` parameter that the callee only calls (never stores, returns, captures or passes on) is specialised automatically: the compiler emits a copy of the callee with the closure body inlined, so there is no closure allocation and no indirect call. Captured variables become extra parameters of the copy. Escaping closures behave as before, and a callee gets at most 8 specialised copies before further call sites fall back to ordinary closure calls.
968
+
969
+ ```plum
970
+ fun twice(f: fn(Int) -> Int, x: Int) -> Int =
971
+ f(f(x))
972
+
973
+ test "specialised"
974
+ k := 3
975
+ assert twice(|v| v + k, 1) == 7
976
+ ```
977
+
965
978
  ### Tail calls
966
979
 
967
980
  A call in tail position (the last thing a function does: the value of its body, of an `if`/`match` arm in tail position, or of a `return`) compiles to a real tail call, for direct calls, methods and closure calls, so tail-recursive loops run in constant stack and a 200000-step recursion is fine:
docs/plans/2026-11-03-zero-cost-closures-scene-perf.benchmark.md ADDED
@@ -0,0 +1,22 @@
1
+ # Benchmark: zero-cost closures and pure scene graph
2
+
3
+ ## Scene
4
+
5
+ Headed Chromium WebGPU, M3 Pro, `--uncapped --measure 10`, same session (ms per frame).
6
+
7
+ | Variant | avg | p95 | update | draw |
8
+ |---|---|---|---|---|
9
+ | Mutable Scene | 1.830 / 1.843 | 2.0 / 2.1 | 0.048 | 1.31 |
10
+ | Pure scene, after this plan | 1.777 / 1.778 | 2.0 | 0.126 | 1.24 |
11
+ | Probe without action stepping | 1.717 | - | 0.068 | - |
12
+ | Pure scene, before this plan (earlier session) | 1.895 | 2.3 | 0.320 | - |
13
+
14
+ Bar (avg within +10%, p95 within +15% of mutable): MET (avg -3%, p95 equal).
15
+
16
+ What changed: in-place allocation-free Actions on own records; traversal matches Group directly instead of calling `kids()` per node.
17
+
18
+ ## Closures
19
+
20
+ Specialisation works: no `call_ref` in specialised copies, captures become params, recursion stays in the copy. But the 1M List map/filter/fold timing barely moved (~632 -> ~625 ms), because the cost was never the closure calls. Wasmtime's copying GC copies the whole live set on each heap growth (sample: `CopyingHeap::forward`/`scan_field`). With a presized GC heap: build 1M 4.8 ms, map 8.0 ms. Slice for-chain 19 ms. In the browser V8's GC applies, so games are unaffected.
21
+
22
+ Open decision: host GC config (`gc_heap_initial_size`).
docs/plans/2026-11-03-zero-cost-closures-scene-perf.md ADDED
@@ -0,0 +1,121 @@
1
+ # Zero-cost closures and pure-scene-graph speed
2
+
3
+ **Review plan:** docs/plans/2026-11-03-zero-cost-closures-scene-perf.review.md
4
+ **Goal:** automatic specialisation of closures passed directly to non-escaping fn params (callee copy with the closure body inlined); profile and speed up the pure scene graph's update path (allocation-free actions, direct traversal); benchmark.
5
+ **Architecture:** an AST pass in plum-core (like trmc) run in plum-checker typedSource on a re-checked clone: find direct closure / fn-name args to non-escaping fn-typed params, emit a specialised copy of the callee per (callee, closure) with calls to the param replaced by the closure body (captures become extra params), cap 8 copies per callee. Scene work is in std/game Plum.
6
+ **Tech stack:** Rust (plum-core, plum-checker), Plum std/game, plum-game web-test benchmarks.
7
+ **Spec:** docs/plans/2026-11-03-zero-cost-closures-scene-perf.review.md
8
+ **Detail:** contracts
9
+ **Target-min:** 50
10
+
11
+ ## Global constraints
12
+ - Work only in /Users/pyrossh/Code/plum-pure-scene-values (branch pure-scene-values, base e04834f). Never touch /Users/pyrossh/Code/plum.
13
+ - Plum: `touch plum-cli/src/main.rs && cargo build --quiet -p plum-cli && ./target/debug/plum test <files>`; sweep `./target/debug/plum test plum-std plum-examples` (629 green). Rust: `cargo test --manifest-path <crate>/Cargo.toml`. Known failing: plum-checker generic_funstar_wired.
14
+ - User decisions: specialisation is automatic (no `inline` keyword); no for-chains on List; Array unchanged; cap 8 specialised copies per callee, then fall back to call_ref.
15
+ - Escape rule (conservative): a fn-typed param escapes if the callee stores it (field, list, local that escapes), returns it, captures it in another closure, or passes it to a param that escapes. Unknown => escapes.
16
+ - Never run `plum format` on real files. Headed benchmarks are run by the coordinator only.
17
+
18
+ ```mermaid
19
+ flowchart LR
20
+ E["escape analysis"] --> S["specialise + inline"] --> W["typedSource wiring"]
21
+ P["scene profile probes"] --> A["in-place Actions"] --> T["direct traversal"] --> B["benchmark + docs"]
22
+ S --> B
23
+ ```
24
+
25
+ File structure: each task's Owns. Spec coverage: What you will get (T1-T6), What will be built (T1-T6; blocks-on-List dropped by user decision), How it fits together (diagram), Decisions you can change (constraints), Risks (R1 T1, R2 T2, R3 T3, R4 T4), Out of scope (not built), How we will know it works (T1-T6), Rough time and cost (schedule).
26
+
27
+ ## Task graph
28
+ ```mermaid
29
+ flowchart LR
30
+ T1[escape analysis]
31
+ T2[specialise closures]
32
+ T3[scene profile probes]
33
+ T4[in-place actions]
34
+ T5[direct traversal]
35
+ T6[benchmark and docs]
36
+ %% taskgraph
37
+ T1 --> T2
38
+ T4 --> T5
39
+ T2 --> T6
40
+ T5 --> T6
41
+ ```
42
+
43
+ ## Closures
44
+
45
+ ### T1 escape analysis for fn-typed params · Complexity 4 · Est. context 45k
46
+ **Owns:** plum-core/src/closure_escape.rs, plum-core/src/lib.rs, plum-core/tests/closure_escape_test.rs
47
+ **Components:** 1
48
+ **LOC owned:** 160
49
+ **Tests:** fast
50
+ **Model:** sonnet
51
+ **Consumes:** none
52
+ **Produces:** closure_escape::nonEscapingFnParams(src: &ast::Source) -> HashMap<String, Vec<usize>>
53
+ e.g. `nonEscapingFnParams` on std List -> `{"List.map": [0], "List.filter": [0], "List.fold": [1], "mapCells": [1]}` (indices of fn-typed params, self excluded)
54
+ e.g. a fn storing its param `cb` in a record field -> not in the map
55
+ **Review Focus:** state R1: transitive (passing f to another fn's param that escapes => escapes; recursion with the same f is fine); methods keyed "Type.m"; generics; conservative on anything unknown.
56
+ **Test code:** closure_escape_test.rs: `direct_call_param_non_escaping`, `recursive_same_param_non_escaping` (mapCells), `stored_in_field_escapes`, `returned_escapes`, `captured_by_closure_escapes`, `passed_to_escaping_param_escapes`, `passed_to_non_escaping_param_ok`, `method_keys`, `std_list_combinators_non_escaping` (load std List).
57
+
58
+ ### T2 specialise closure arguments · Complexity 6 · Est. context 65k
59
+ **Owns:** plum-core/src/closure_spec.rs, plum-core/tests/closure_spec_test.rs, plum-checker/src/lib.rs
60
+ **Components:** 1
61
+ **LOC owned:** 190
62
+ **Tests:** fast
63
+ **Model:** sonnet
64
+ **Consumes:** closure_escape::nonEscapingFnParams(src: &ast::Source) -> HashMap<String, Vec<usize>>
65
+ **Produces:** closure_spec::specialise(src: &mut ast::Source) -> Vec<String>
66
+ e.g. `specialise` on `xs.map(|x| x * 2)` with std List -> `["List.map$c0", "mapCells$c0"]` (names of emitted copies)
67
+ **Review Focus:** state R2/R3: a call site passing a closure literal or a fn name directly to a non-escaping param gets a copy of the callee where calls of that param become the closure body inlined (fresh locals per call, captures added as extra params in order), recursive calls inside the copy target the copy; dedup by (callee, closure source span / fn name); cap 8 copies per callee then leave the call alone; run in typedSource on a re-checked clone (like trmc, before trmc and markTailCalls so TRMC/tail calls apply to copies), keep the original program if the re-check fails.
68
+ **Test code:** closure_spec_test.rs (compile+run and wasmparser): `map_literal_no_call_ref` (specialised List.map has no call_ref), `capture_becomes_param` (closure capturing a local), `recursive_copy_calls_itself`, `fn_name_argument`, `escaping_closure_untouched`, `dedup_same_closure_twice`, `cap_eight_copies`, `results_equal_original` (several pipelines), `list_pipeline_million_timed` (map/filter/fold 1M on List; report ms; target within 3-5x of the Slice for-chain 19 ms).
69
+
70
+ ## Pure scene graph
71
+
72
+ ### T3 scene profile probes · Complexity 2 · Est. context 30k
73
+ **Owns:** plum-game/web-test/fixtures/probe_scene_no_actions.plum, plum-game/web-test/fixtures/probe_scene_no_dispatch.plum, plum-game/web-test/fixtures/probe_scene_keep_lists.plum
74
+ **Components:** 1
75
+ **LOC owned:** 120
76
+ **Tests:** fast
77
+ **Model:** sonnet
78
+ **Consumes:** none
79
+ **Produces:** none
80
+ **Review Focus:** numeric: each is a copy of stress_pure_scene.plum (std PureScene/SceneTree/SceneInput on own) with ONE cost removed: (a) no action stepping, (b) update without trait dispatch for act/kids (direct field traversal written in the fixture), (c) action lists not rebuilt (finished actions kept). Same actor count and draw path.
81
+ **Test code:** each fixture `plum test` passes and `plum compile` succeeds; draw output count equals stress_pure_scene (non-headed `--frames-wait 6 --json`).
82
+
83
+ ### T4 in-place Actions · Complexity 4 · Est. context 45k
84
+ **Owns:** plum-std/game/Actions.plum, plum-std/game/SceneTree.plum
85
+ **Components:** 1
86
+ **LOC owned:** 160
87
+ **Tests:** fast
88
+ **Model:** sonnet
89
+ **Consumes:** none
90
+ **Produces:** none
91
+ **Review Focus:** state R4: stepAll/stepAction allocate nothing per frame for a running action: results as a struct (pose, spare, done), actions stepped in place on own records, finished actions dropped by compacting in place (Slice takeAt/putAt or a write index), no Stepped/SteppedAll record per actor; ownership checker accepts it with no checker change.
92
+ **Test code:** Actions.plum and SceneTree.plum tests keep names and asserted values (11 and 13); add `step_all_no_alloc_steady_state` (wasmparser or a counter: no struct.new in the steady-state step path for MoveBy/RotateBy).
93
+
94
+ ### T5 direct traversal · Complexity 3 · Est. context 40k
95
+ **Owns:** plum-std/game/PureScene.plum, plum-std/game/SceneInput.plum, plum-examples/game_pure_scene.plum, plum-game/web-test/fixtures/stress_pure_scene.plum
96
+ **Components:** 2
97
+ **LOC owned:** 120
98
+ **Tests:** fast
99
+ **Model:** sonnet
100
+ **Consumes:** none
101
+ **Produces:** none
102
+ **Review Focus:** state: the update/draw traversal reaches children by a direct `Group` check and field read, not `kids() -> Slice[Actor]?` per node; behaviour identical; example and fixture follow any internal API change.
103
+ **Test code:** PureScene 5, SceneInput 9, game_pure_scene 21 keep names and values; `cargo test --manifest-path plum-game/Cargo.toml --test host_test pureScene`; `cargo test --manifest-path plum-game/web-test/Cargo.toml --test web_check pure_scene_renders_and_animates`.
104
+
105
+ ## Wrap-up
106
+
107
+ ### T6 benchmark and docs · Complexity 2 · Est. context 30k
108
+ **Owns:** README.md, docs/plans/2026-11-03-zero-cost-closures-scene-perf.benchmark.md
109
+ **Components:** 1
110
+ **LOC owned:** 60
111
+ **Tests:** fast
112
+ **Model:** sonnet
113
+ **Consumes:** none
114
+ **Produces:** none
115
+ **Review Focus:** numeric: closure table (List pipeline before 581 ms, after; Slice for-chain 19 ms) from scratch probes; scene table filled in by the coordinator's headed runs (leave the rows, coordinator fills numbers); README sentence on automatic closure specialisation and its limits (escaping closures, cap 8).
116
+ **Test code:** README example compiles (scratch); benchmark doc present.
117
+
118
+ ## Review Focus
119
+ - compiler: deep, state. Risks: wrong escape classification; capture order; recursion in copies; code growth.
120
+ - std/game: state, numeric. Risks: behaviour drift in actions; in-place compaction order.
121
+ **Probe entry:** run=./target/debug/plum test
docs/plans/2026-11-03-zero-cost-closures-scene-perf.progress.md ADDED
@@ -0,0 +1,31 @@
1
+ # Zero-cost closures + scene perf run
2
+ - start e04834f
3
+ - Ruling: [scope] blocks/for-chains on List dropped - user: List keeps closure methods, made fast by specialisation - cost: closure pipelines still allocate per stage
4
+ - Ruling: skip T0 Wave 0 - no stub generation for Plum/Rust - cost: none
5
+ - unblocked: dispatched T1, T4+T3 batch at 18:40:44; waiting on fixes: T2 (SIZE) T6
6
+ - checkpoint T1 6a629e5ab5 escape analysis for fn-typed params
7
+ - T1 checkpointed 6a629e5 (nonEscapingFnParams, greatest fixpoint, name-resolved methods - conservative)
8
+ - unblocked: dispatched T2 at 18:44:02
9
+ - checkpoint T3 030cd64325 scene profile probes
10
+ - checkpoint T4 3998a4206f in-place Actions
11
+ - T3 checkpointed (3 probe fixtures; no_dispatch partial: no downcast), T4 checkpointed (in-place Actions, no struct.new in step path; step_does_not_change_input -> step_moves_the_action_in_place)
12
+ - unblocked: dispatched T5 at 18:47:02; headed benchmarks deferred until builds quiet
13
+ - checkpoint T5 dc68ad92c6 direct traversal
14
+ - T5 checkpointed (drawTree matches Group directly). Ruling: T5 extends to plum-std/game/SceneTree.plum (T4 done) to drop kids() on the update path - cost: none
15
+ - checkpoint T4 2aa86ac4b9 in-place Actions
16
+ - SceneTree kids() sites replaced by Group match (T4 file re-checkpointed via T5 agent)
17
+ - tests fast passed=1 failed=0 skipped=0 seconds=163.5
18
+ - T2 DONE_WITH_CONCERNS: specialisation works (10 tests) but no gain: List.map ~470ms is TRMC mapCells, not closure calls (filter/fold ~30ms). trmc_test name regression -> T2 agent. F-T2-1 slow TRMC map -> fixer-opus
19
+ - checkpoint T2 6d7cb25999 specialise closure arguments
20
+ - T2 checkpointed (+trmc_test fix committed); F-T2-1 dispatched to fixer-opus at 19:04:02
21
+ - Scene benchmark (headed): mutable 1.830/1.843 p95 2.0/2.1 upd 0.048; pure 1.777/1.778 p95 2.0 upd 0.126 (was 0.32) -> BAR MET; no_actions probe 1.717
22
+ - F-T2-1 NOT_FIXED by design: slow map = wasmtime copying GC heap growth; host gc_heap_initial_size is a user decision (follow-up)
23
+ - Ruling: drop the 3 probe fixtures (pre-T4 diagnostics; no_dispatch no longer compiles); T6 deletes them - cost: none
24
+ - checkpoint T6 572b884d72 benchmark and docs
25
+ - Final review CHANGES_REQUESTED (test gaps: closure deep/cap, escape state, Actions golden values, compaction order) -> F-FR-2 fixer-sonnet-high
26
+ - tests full passed=80 failed=1 skipped=0 seconds=46.7
27
+ - checkpoint T1 b6a2f96274 escape analysis for fn-typed params
28
+ - checkpoint T2 a49cb785ad specialise closure arguments
29
+ - checkpoint T4 9730da76f3 in-place Actions
30
+ - tests full passed=80 failed=1 skipped=0 seconds=17.6
31
+ - F-FR-2 FIXED (6 probe tests, no defects); full suite: only known generic_funstar_wired. Final review findings addressed.
docs/plans/2026-11-03-zero-cost-closures-scene-perf.report.json ADDED
@@ -0,0 +1,246 @@
1
+ {
2
+ "projection_note": "projection: fixed forecast saved at lint 2026-11-03T18:40:15 (plan edited since)",
3
+ "headline": {
4
+ "wall_min": {
5
+ "projected": 18.647,
6
+ "actual": 30.202
7
+ },
8
+ "cost_usd_agents": {
9
+ "projected": 0.733869,
10
+ "actual": 3.975266
11
+ },
12
+ "task_min": {
13
+ "projected": 35.475,
14
+ "actual": 31.458
15
+ },
16
+ "agents": {
17
+ "projected": 6,
18
+ "actual": 15
19
+ }
20
+ },
21
+ "feature": null,
22
+ "cost_split": {
23
+ "agents_usd": 3.080342,
24
+ "rework_usd": 0.894924,
25
+ "coordinator_usd": 50.066135,
26
+ "total_usd": 54.041401
27
+ },
28
+ "concurrency": {
29
+ "peak": 2,
30
+ "projected_peak": 3
31
+ },
32
+ "critical_path": {
33
+ "actual": [
34
+ "T1",
35
+ "T2",
36
+ "T6"
37
+ ],
38
+ "projected": [
39
+ "T1",
40
+ "T2",
41
+ "T6"
42
+ ],
43
+ "projected_min": 18.647
44
+ },
45
+ "model_mix": {
46
+ "sonnet": {
47
+ "tasks": 5,
48
+ "cost_usd": 3.9752660000000004
49
+ }
50
+ },
51
+ "coordinator_cost_by_model": {
52
+ "opus": 50.066135
53
+ },
54
+ "quality": {
55
+ "tests": {
56
+ "passed": 80,
57
+ "failed": 1,
58
+ "skipped": 0,
59
+ "seconds": 17.6,
60
+ "mode": "full",
61
+ "failures": [
62
+ "-::generic_funstar_wired: failed"
63
+ ],
64
+ "skipped_tests": []
65
+ },
66
+ "first_time_pass": 0,
67
+ "first_time_n": 0,
68
+ "rework_dispatches": 1,
69
+ "open_incidents": 2
70
+ },
71
+ "tasks": [
72
+ {
73
+ "task": "T2",
74
+ "model": "sonnet",
75
+ "pred_min": 9.316,
76
+ "actual_min": 19.803,
77
+ "delta": {
78
+ "abs": 10.4873,
79
+ "pct": 112.6
80
+ },
81
+ "cost_usd": 2.670396,
82
+ "first_time_pass": null
83
+ },
84
+ {
85
+ "task": "T1",
86
+ "model": "sonnet",
87
+ "pred_min": 6.454,
88
+ "actual_min": 2.937,
89
+ "delta": {
90
+ "abs": -3.5173,
91
+ "pct": -54.5
92
+ },
93
+ "cost_usd": 0.42209,
94
+ "first_time_pass": null
95
+ },
96
+ {
97
+ "task": "T5",
98
+ "model": "sonnet",
99
+ "pred_min": 5.351,
100
+ "actual_min": 2.17,
101
+ "delta": {
102
+ "abs": -3.181,
103
+ "pct": -59.4
104
+ },
105
+ "cost_usd": 0.161398,
106
+ "first_time_pass": null
107
+ },
108
+ {
109
+ "task": "T6",
110
+ "model": "sonnet",
111
+ "pred_min": 2.877,
112
+ "actual_min": 0.882,
113
+ "delta": {
114
+ "abs": -1.9953,
115
+ "pct": -69.4
116
+ },
117
+ "cost_usd": 0.122338,
118
+ "first_time_pass": null
119
+ },
120
+ {
121
+ "task": "T4",
122
+ "model": "sonnet",
123
+ "pred_min": 6.454,
124
+ "actual_min": 5.667,
125
+ "delta": {
126
+ "abs": -0.7873,
127
+ "pct": -12.2
128
+ },
129
+ "cost_usd": 0.599044,
130
+ "first_time_pass": null
131
+ }
132
+ ],
133
+ "cost_table": {
134
+ "source": "est",
135
+ "usd": 4.6578,
136
+ "tokens": 11810387,
137
+ "wall_min": 209266448.2716,
138
+ "rows": [
139
+ {
140
+ "agent": "a28e70b6c5466655c",
141
+ "model": "sonnet",
142
+ "wall_min": 19.8119,
143
+ "tokens": 5477333,
144
+ "usd": 1.7755
145
+ },
146
+ {
147
+ "agent": "a2b7551e7401c0456",
148
+ "model": "opus",
149
+ "wall_min": 3.3515,
150
+ "tokens": 1249546,
151
+ "usd": 0.8949
152
+ },
153
+ {
154
+ "agent": "a2e8f511b95e838f0",
155
+ "model": "sonnet",
156
+ "wall_min": 29895213.7276,
157
+ "tokens": 0,
158
+ "usd": 0.0
159
+ },
160
+ {
161
+ "agent": "a320c4ae073438df6",
162
+ "model": "sonnet",
163
+ "wall_min": 29895199.3553,
164
+ "tokens": 0,
165
+ "usd": 0.0
166
+ },
167
+ {
168
+ "agent": "a32ee8f042610711a",
169
+ "model": "sonnet",
170
+ "wall_min": 4.6229,
171
+ "tokens": 1500746,
172
+ "usd": 0.5738
173
+ },
174
+ {
175
+ "agent": "a3de6206b1938a855",
176
+ "model": "sonnet",
177
+ "wall_min": 29895191.2474,
178
+ "tokens": 0,
179
+ "usd": 0.0
180
+ },
181
+ {
182
+ "agent": "a5aadd250ae9d099e",
183
+ "model": "sonnet",
184
+ "wall_min": 29895214.1158,
185
+ "tokens": 0,
186
+ "usd": 0.0
187
+ },
188
+ {
189
+ "agent": "a6114011bcd91ade6",
190
+ "model": "sonnet",
191
+ "wall_min": 29895194.2183,
192
+ "tokens": 0,
193
+ "usd": 0.0
194
+ },
195
+ {
196
+ "agent": "a7c51f47306b0e4dc",
197
+ "model": "sonnet",
198
+ "wall_min": 2.9432,
199
+ "tokens": 919141,
200
+ "usd": 0.4221
201
+ },
202
+ {
203
+ "agent": "a8b053cb024a339f1",
204
+ "model": "sonnet",
205
+ "wall_min": 5.7614,
206
+ "tokens": 2034497,
207
+ "usd": 0.599
208
+ },
209
+ {
210
+ "agent": "ab13c7ca907e649de",
211
+ "model": "sonnet",
212
+ "wall_min": 29895198.5619,
213
+ "tokens": 0,
214
+ "usd": 0.0
215
+ },
216
+ {
217
+ "agent": "ac2dd171e416a6d86",
218
+ "model": "sonnet",
219
+ "wall_min": 29895197.2252,
220
+ "tokens": 0,
221
+ "usd": 0.0
222
+ },
223
+ {
224
+ "agent": "acc89408dbc5a17c2",
225
+ "model": "sonnet",
226
+ "wall_min": 0.8919,
227
+ "tokens": 229216,
228
+ "usd": 0.1223
229
+ },
230
+ {
231
+ "agent": "ad1f1f069e873e799",
232
+ "model": "opus",
233
+ "wall_min": 0.2504,
234
+ "tokens": 29894,
235
+ "usd": 0.1088
236
+ },
237
+ {
238
+ "agent": "af1dfd427ea4862a9",
239
+ "model": "sonnet",
240
+ "wall_min": 2.1869,
241
+ "tokens": 370014,
242
+ "usd": 0.1614
243
+ }
244
+ ]
245
+ }
246
+ }
docs/plans/2026-11-03-zero-cost-closures-scene-perf.report.md ADDED
@@ -0,0 +1,43 @@
1
+ # superlite build report
2
+
3
+ projection: fixed forecast saved at lint 2026-11-03T18:40:15 (plan edited since)
4
+
5
+ Projected Actual Delta
6
+ wall-clock (min) 18.6 30.2 +11.6 (+62%)
7
+ agent cost (USD, +rework) 0.73 3.98 +3.24 (+442%)
8
+ task-minutes 35.5 31.5 -4.0 (-11%)
9
+ agents 6 15 +9 (+150%)
10
+ cost split: agents $3.08, rework $0.89, coordinator $50.07, total $54.04
11
+ concurrency: peak 2 at once (projected 3); critical path actual T1 T2 T6; projected T1 > T2 > T6 (18.6 min)
12
+ model mix: sonnet 5 tasks $3.98, coordinator opus $50.07
13
+ quality: tests 80 passed, 1 failed; first-time pass n/a; rework dispatches 1; open incidents 2
14
+
15
+ task model pred actual delta cost
16
+ T2 sonnet 9.3 19.8 +10.5 (+113%) $2.670
17
+ T1 sonnet 6.5 2.9 -3.5 (-54%) $0.422
18
+ T5 sonnet 5.4 2.2 -3.2 (-59%) $0.161
19
+ T6 sonnet 2.9 0.9 -2.0 (-69%) $0.122
20
+ T4 sonnet 6.5 5.7 -0.8 (-12%) $0.599
21
+
22
+ agent cost table:
23
+ | agent | model | wall_min | tokens | usd |
24
+ | --- | --- | --- | --- | --- |
25
+ | a28e70b6c5466655c | sonnet | 19.8119 | 5477333 | 1.7755 |
26
+ | a2b7551e7401c0456 | opus | 3.3515 | 1249546 | 0.8949 |
27
+ | a2e8f511b95e838f0 | sonnet | 29895213.7276 | 0 | 0.0 |
28
+ | a320c4ae073438df6 | sonnet | 29895199.3553 | 0 | 0.0 |
29
+ | a32ee8f042610711a | sonnet | 4.6229 | 1500746 | 0.5738 |
30
+ | a3de6206b1938a855 | sonnet | 29895191.2474 | 0 | 0.0 |
31
+ | a5aadd250ae9d099e | sonnet | 29895214.1158 | 0 | 0.0 |
32
+ | a6114011bcd91ade6 | sonnet | 29895194.2183 | 0 | 0.0 |
33
+ | a7c51f47306b0e4dc | sonnet | 2.9432 | 919141 | 0.4221 |
34
+ | a8b053cb024a339f1 | sonnet | 5.7614 | 2034497 | 0.599 |
35
+ | ab13c7ca907e649de | sonnet | 29895198.5619 | 0 | 0.0 |
36
+ | ac2dd171e416a6d86 | sonnet | 29895197.2252 | 0 | 0.0 |
37
+ | acc89408dbc5a17c2 | sonnet | 0.8919 | 229216 | 0.1223 |
38
+ | ad1f1f069e873e799 | opus | 0.2504 | 29894 | 0.1088 |
39
+ | af1dfd427ea4862a9 | sonnet | 2.1869 | 370014 | 0.1614 |
40
+ | totals | | 209266448.2716 | 11810387 | 4.6578 |
41
+ cost source: est
42
+
43
+ takeaway: ran 62% slower than projected; cost 442% over; biggest miss T2
docs/plans/2026-11-03-zero-cost-closures-scene-perf.review.md ADDED
@@ -0,0 +1,90 @@
1
+ # Zero-cost closures, blocks on List, and pure-scene-graph speed: review plan
2
+
3
+ **Builds on:** branch `pure-scene-values` at e04834f: unboxed variants, std cons `List`, `Slice`, tail calls, opt-level 3 wasmtime in dev.
4
+
5
+ ## Summary
6
+ Measured 2026-11-03, 1M elements, `map(×2)` → `filter` → `fold(+)`, dev build:
7
+
8
+ | style | time |
9
+ |---|---|
10
+ | `Slice`, fused `for`-chains (blocks) | 19 ms |
11
+ | `Slice` / `List`, plain `for` loop | 2–3 ms |
12
+ | `List`, closure methods `xs.map(\|x\| x * 2)` | 581 ms |
13
+
14
+ Closure methods are slow for two reasons. Every closure call is an indirect `call_ref` that wasm can't inline, and every stage allocates a whole new list. The pure scene graph is about +12% average against the mutable scene, and almost all of that is in update (0.32 ms per frame vs 0.04).
15
+
16
+ This plan has two parts (user decision, 2026-11-03: no for-chains on List; List keeps its closure methods, made fast by specialisation):
17
+ 1. Rust-style zero-cost closures: a closure passed straight to a function gets specialised in.
18
+ 2. A profile-first speed-up of the pure scene graph's update path.
19
+
20
+ ## What you will get
21
+ - **`for`-chains work on `List`.** `evens := for x := range xs.filter()` with the predicate in the body, `for acc, x := range xs.fold(0)`, plus `map`, `take`, `skip`, `count`, `any`, `all` and `find`. Anything that declares `fun* items` gets the Seq adapters and terminals (std `List` does), and results are `List`. Expected speed is in line with `Slice` for-chains.
22
+ - **Zero-cost closures.** When a closure literal (or a named function) is passed directly to a `fn`-typed parameter that doesn't escape (isn't stored, returned or captured), the compiler emits a copy of the callee specialised to that closure, with the closure body inlined at each call. This includes recursive callees (`List.map` calls its own specialised copy) and methods on `List` and `Slice`. Copies are deduplicated by (callee, closure identity), and escaping closures stay as today. The target is `xs.map(|x| x * 2).filter(...).fold(...)` on `List` running within 3x of the for-chain version; the intermediate lists remain.
23
+ - **Pure scene graph update, measured, then fixed.**
24
+ 1. **Profile.** Variants of the stress fixture each remove one cost (no action stepping; no trait dispatch for `act`/`kids`; action lists not rebuilt), so each cost is measured in isolation.
25
+ 2. **Allocation-free `Actions.stepAll`.** It currently builds `Stepped`/`SteppedAll` records and new action lists for every actor every frame. It becomes in-place on `own` actions, returning a struct (pose and spare time) and dropping finished actions without rebuilding the list.
26
+ 3. **Less dispatch.** `kids()` returning `List[Actor]?` becomes a direct `Group` check and field read on the traversal path.
27
+ 4. **Benchmark** against the mutable scene in the same session. The bar is the same as before: average within 10% and p95 within 15%.
28
+
29
+ ## What will be built
30
+ | Part | What it does | Size |
31
+ |---|---|---|
32
+ | Seq over items | Seq adapters and terminals accept any type with `fun* items`; List results stay List | S–M |
33
+ | Closure specialisation: analysis | Find direct closure / fn-name arguments to non-escaping fn params; escape check | M |
34
+ | Closure specialisation: codegen | Emit specialised callee copies (dedup, recursion, methods, generics) and inline the closure body | M–L |
35
+ | Scene profile probes | Stress-fixture variants plus a headed benchmark table | S |
36
+ | Allocation-free actions | Actions.stepAll / stepAction in place on own actions; struct results | M |
37
+ | Scene traversal | SceneTree update/draw without `kids()` optional plus dispatch | S–M |
38
+ | Benchmark and docs | Five-row scene table, closure table, README notes | S |
39
+
40
+ ## How it fits together
41
+ ```mermaid
42
+ flowchart LR
43
+ subgraph C["closures"]
44
+ A1["call f(|x| ...)"] --> A2["non-escaping fn param?"] --> A3["specialised copy, body inlined"]
45
+ end
46
+ subgraph B["blocks"]
47
+ B1["for x := range xs.filter()"] --> B2["Seq over fun* items"]
48
+ end
49
+ subgraph S["scene"]
50
+ S1["profile variants"] --> S2["in-place Actions on own"] --> S3["direct Group traversal"] --> S4["benchmark"]
51
+ end
52
+ ```
53
+
54
+ ## Decisions you can change
55
+ - **Specialisation trigger.**
56
+ - Default: automatic for closure literals and fn names passed directly to non-escaping params.
57
+ - Alternative: an explicit `inline fun` marker on the callee, as in Kotlin.
58
+ - **Code size cap.**
59
+ - Default: at most 8 specialised copies per callee; beyond that, fall back to `call_ref`.
60
+ - Alternative: no cap.
61
+ - **Scene API changes.**
62
+ - Default: `Actions` and the scene modules may change their internal signatures, and `game_pure_scene` follows.
63
+ - Alternative: keep every public signature unchanged.
64
+
65
+ ## Risks
66
+ - **R1: escape analysis.** A closure stored in a field or list, returned, or captured by another closure must not be specialised. The check is conservative: anything unclear is treated as escaping.
67
+ - **R2: code growth.** Mitigated by the per-callee cap and deduplication.
68
+ - **R3: profile may point elsewhere.** If the measured cost is not stepping or dispatch, the scene part reports what is costing time and stops, as with phase B's R4.
69
+ - **R4: ownership.** In-place actions rely on the `own` rules. The checker must accept the new shapes without special cases.
70
+
71
+ ## Out of scope
72
+ - Fusing `map(f).filter(g)` into one pass without intermediate lists (supercompiler/deforestation, the later plan).
73
+ - Changing `Slice`'s for-chains, apart from accepting `items` types.
74
+
75
+ ## How we will know it works
76
+ - **Closures:**
77
+ - wasmparser shows no `call_ref` for a specialised call site;
78
+ - escaping-closure tests unchanged;
79
+ - recursion and generic-method tests;
80
+ - the 1M closure-style `List` pipeline within 3x of the for-chain version (timed).
81
+ - **Blocks:** `filter`, `map`, `fold`, `take` and `count` for-chains on `List` with exact results, at 1M in time similar to `Slice`.
82
+ - **Scene:**
83
+ - a profile table showing each cost's share;
84
+ - after the changes, update time and average/p95 against the mutable scene in the same session, judged against the bar;
85
+ - `game_pure_scene` tests unchanged in names and values;
86
+ - host parity and browser checks green.
87
+ - **Every existing suite stays green** (629+ Plum, Rust crates).
88
+
89
+ ## Rough time and cost
90
+ About 7 parts in three tracks; the closure codegen part is the largest. Rough agent time is 80–120 minutes, plus two headed benchmark runs.
plum-checker/src/lib.rs CHANGED
@@ -1180,6 +1180,27 @@ pub fn checkSource(source: &ast::Source) -> CheckResult<()> {
1180
1180
  /// set of generic instantiations (see `TypedInfo`) for codegen.
1181
1181
  pub fn typedSource(source: &ast::Source) -> CheckResult<TypedInfo> {
1182
1182
  let (mut result, mut info) = checkSourceFull(source);
1183
+ // Closure specialisation: copies of callees with the closure argument inlined. Same
1184
+ // re-check discipline as the TRMC rewrite below; it runs first so TRMC and tail calls
1185
+ // apply to the copies. On a failed re-check the original program stays.
1186
+ if result.is_ok() {
1187
+ if let Some(lowered) = info.lowered.as_ref() {
1188
+ let hints = closureHints(lowered, &info);
1189
+ let mut copy = (**lowered).clone();
1190
+ if !plum_core::closure_spec::specialiseWith(&mut copy, &hints).is_empty() {
1191
+ let saved = plum_core::unions::takePlan();
1192
+ plum_core::unions::beginPlan();
1193
+ let (r2, mut i2) = checkSourceOnce(&copy);
1194
+ plum_core::unions::takePlan();
1195
+ unions::restorePlan(&saved);
1196
+ if r2.is_ok() {
1197
+ i2.lowered = Some(std::rc::Rc::new(copy));
1198
+ result = r2;
1199
+ info = i2;
1200
+ }
1201
+ }
1202
+ }
1203
+ }
1183
1204
  // Tail recursion modulo cons: rewrite the lowered program, then check the
1184
1205
  // rewrite again (types are recorded per expression address). If the rewrite
1185
1206
  // does not check, keep the original program.
@@ -1208,6 +1229,56 @@ pub fn typedSource(source: &ast::Source) -> CheckResult<TypedInfo> {
1208
1229
  result.map(|()| info)
1209
1230
  }
1210
1231
 
1232
+ /// Receiver type names of the method calls of `lowered`, for `closure_spec` (a call site
1233
+ /// whose receiver has no single known type gets no hint).
1234
+ fn closureHints(lowered: &ast::Source, info: &TypedInfo) -> plum_core::closure_spec::Hints {
1235
+ fn head(t: &PlumType) -> Option<String> {
1236
+ match t {
1237
+ PlumType::TNamed(n) | PlumType::TApp(n, _) => Some(n.clone()),
1238
+ PlumType::TInt => Some("Int".to_string()),
1239
+ PlumType::TFloat => Some("Float".to_string()),
1240
+ PlumType::TBool => Some("Bool".to_string()),
1241
+ PlumType::TByte => Some("Byte".to_string()),
1242
+ _ => None,
1243
+ }
1244
+ }
1245
+ let mut inst: HashMap<usize, Vec<&PlumType>> = HashMap::new();
1246
+ for ((_, addr), t) in &info.inst_expr_types {
1247
+ inst.entry(*addr).or_default().push(t);
1248
+ }
1249
+ let mut hints = plum_core::closure_spec::Hints::new();
1250
+ let mut bad: std::collections::HashSet<(String, usize, usize, String)> = std::collections::HashSet::new();
1251
+ for (key, addr) in plum_core::closure_spec::methodSites(lowered) {
1252
+ let addr = addr as usize;
1253
+ let mut names: std::collections::HashSet<Option<String>> = std::collections::HashSet::new();
1254
+ if let Some(t) = info.expr_types.get(&addr) {
1255
+ names.insert(head(t));
1256
+ }
1257
+ for t in inst.get(&addr).into_iter().flatten() {
1258
+ names.insert(head(t));
1259
+ }
1260
+ let name = match (names.len(), names.into_iter().next()) {
1261
+ (1, Some(Some(n))) => n,
1262
+ _ => {
1263
+ bad.insert(key);
1264
+ continue;
1265
+ }
1266
+ };
1267
+ match hints.get(&key) {
1268
+ Some(prev) if *prev != name => {
1269
+ bad.insert(key);
1270
+ }
1271
+ _ => {
1272
+ hints.insert(key, name);
1273
+ }
1274
+ }
1275
+ }
1276
+ for k in bad {
1277
+ hints.remove(&k);
1278
+ }
1279
+ hints
1280
+ }
1281
+
1211
1282
  fn checkSourceFull(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1212
1283
  if let Some(applied) = applyOwnGenerics(source) {
1213
1284
  // Check (and let codegen compile) the copy with bare own-generic names applied.
plum-core/src/closure_escape.rs ADDED
@@ -0,0 +1,256 @@
1
+ //! Escape analysis for fn-typed parameters. A fn-typed param does not escape when
2
+ //! the callee only calls it directly (`f(x)`) or hands it, as a bare argument, to
3
+ //! a fn-typed param that itself does not escape (itself, recursively, included).
4
+ //! Anything else -- storing, returning, aliasing, assigning, capturing in a closure,
5
+ //! passing to an unknown or escaping callee -- makes it escape.
6
+ //!
7
+ //! Pure AST analysis, conservative: calls are resolved by name only. A free fn call
8
+ //! resolves to the free fn of that name; a method call `x.m(..)` (receiver type unknown)
9
+ //! resolves to every method named `m` that takes that many args (`Type.m(..)` to that
10
+ //! type's), and the argument is safe only if it is safe in all of them. No candidate
11
+ //! (extern, closure value, constructor) means escape. Solved as a greatest fixpoint, so
12
+ //! recursion with the same param stays non-escaping.
13
+ use crate::ast::*;
14
+ use std::collections::{HashMap, HashSet};
15
+
16
+ /// For each fn that has fn-typed params that do not escape: key = free fn name or
17
+ /// `Type.method`, value = the indices (self excluded) of those params, ascending.
18
+ pub fn nonEscapingFnParams(src: &Source) -> HashMap<String, Vec<usize>> {
19
+ let mut fns: Vec<(String, &Fn)> = Vec::new();
20
+ for item in &src.items {
21
+ if let Item::Fn(f) = item {
22
+ if f.is_extern || f.is_star || f.name.starts_with("__test_") {
23
+ continue;
24
+ }
25
+ let key = match &f.type_param {
26
+ Some(t) => format!("{}.{}", t, f.name),
27
+ None => f.name.clone(),
28
+ };
29
+ fns.push((key, f));
30
+ }
31
+ }
32
+ let mut free: HashMap<&str, usize> = HashMap::new();
33
+ let mut methods: HashMap<&str, Vec<usize>> = HashMap::new();
34
+ for (i, (_, f)) in fns.iter().enumerate() {
35
+ match f.type_param {
36
+ None => {
37
+ free.insert(f.name.as_str(), i);
38
+ }
39
+ Some(_) => methods.entry(f.name.as_str()).or_default().push(i),
40
+ }
41
+ }
42
+ let mut ok: HashSet<(usize, usize)> = HashSet::new();
43
+ for (i, (_, f)) in fns.iter().enumerate() {
44
+ for (j, p) in f.params.iter().enumerate() {
45
+ if isFnParam(p) {
46
+ ok.insert((i, j));
47
+ }
48
+ }
49
+ }
50
+ loop {
51
+ let mut drop = Vec::new();
52
+ for &(i, j) in &ok {
53
+ let f = fns[i].1;
54
+ let cx = Cx { fns: &fns, free: &free, methods: &methods, ok: &ok, p: &f.params[j].name };
55
+ if cx.fnEscapes(f) {
56
+ drop.push((i, j));
57
+ }
58
+ }
59
+ if drop.is_empty() {
60
+ break;
61
+ }
62
+ for d in drop {
63
+ ok.remove(&d);
64
+ }
65
+ }
66
+ let mut out: HashMap<String, Vec<usize>> = HashMap::new();
67
+ for (i, j) in ok {
68
+ out.entry(fns[i].0.clone()).or_default().push(j);
69
+ }
70
+ for v in out.values_mut() {
71
+ v.sort();
72
+ }
73
+ out
74
+ }
75
+
76
+ fn isFnParam(p: &Param) -> bool {
77
+ match &p.ty {
78
+ ParamType::Fn(..) => !p.isOwn,
79
+ ParamType::Type(t) => t.asFn().is_some() && !p.isOwn,
80
+ _ => false,
81
+ }
82
+ }
83
+
84
+ struct Cx<'a> {
85
+ fns: &'a [(String, &'a Fn)],
86
+ free: &'a HashMap<&'a str, usize>,
87
+ methods: &'a HashMap<&'a str, Vec<usize>>,
88
+ ok: &'a HashSet<(usize, usize)>,
89
+ /// The param under test.
90
+ p: &'a str,
91
+ }
92
+
93
+ impl<'a> Cx<'a> {
94
+ fn fnEscapes(&self, f: &Fn) -> bool {
95
+ match &f.body {
96
+ FnBody::Expr(e) => self.expr(e, false),
97
+ FnBody::Block(b) => self.block(b, false),
98
+ FnBody::Extern => true,
99
+ }
100
+ }
101
+
102
+ fn block(&self, b: &Block, cl: bool) -> bool {
103
+ b.stmts.iter().any(|s| self.stmt(s, cl))
104
+ }
105
+
106
+ fn stmt(&self, s: &Stmt, cl: bool) -> bool {
107
+ match s {
108
+ Stmt::Assign(a) => {
109
+ a.targets.iter().any(|t| match t {
110
+ AssignTarget::Var(n) => n == self.p,
111
+ AssignTarget::Field(o, _) => self.expr(o, cl),
112
+ }) || a.values.iter().any(|v| self.expr(v, cl))
113
+ }
114
+ Stmt::Break | Stmt::Continue | Stmt::Todo => false,
115
+ Stmt::Assert(c) => self.expr(&c.cond, cl),
116
+ Stmt::ForLoop(l) => {
117
+ self.expr(&l.source, cl)
118
+ || l.clauses.iter().any(|c| match c {
119
+ ForClause::If(e) | ForClause::Bind(_, e) => self.expr(e, cl),
120
+ })
121
+ || self.block(&l.body, cl)
122
+ }
123
+ Stmt::While(w) => self.expr(&w.condition, cl) || self.block(&w.body, cl),
124
+ Stmt::If(i) => {
125
+ self.expr(&i.condition, cl)
126
+ || self.block(&i.body, cl)
127
+ || i.else_ifs.iter().any(|e| self.expr(&e.condition, cl) || self.block(&e.body, cl))
128
+ || i.else_.as_ref().is_some_and(|b| self.block(b, cl))
129
+ }
130
+ Stmt::Match(m) => {
131
+ m.subjects.iter().any(|e| self.expr(e, cl)) || m.cases.iter().any(|c| self.case(c, cl))
132
+ }
133
+ Stmt::Return(e) => e.as_ref().is_some_and(|e| self.expr(e, cl)),
134
+ Stmt::Crash(e) => self.expr(e, cl),
135
+ Stmt::Expr(e, _, _) => self.expr(e, cl),
136
+ }
137
+ }
138
+
139
+ fn case(&self, c: &Case, cl: bool) -> bool {
140
+ c.guard.as_ref().is_some_and(|g| self.expr(g, cl)) || self.block(&c.body, cl)
141
+ }
142
+
143
+ fn isParam(&self, e: &Expr) -> bool {
144
+ matches!(e, Expr::Var(n) if n == self.p)
145
+ }
146
+
147
+ /// Whether the param escapes through this call's arguments. `callees` are the
148
+ /// candidate fns; an empty list means the callee is unknown.
149
+ fn args(&self, args: &[Arg], callees: &[usize], cl: bool) -> bool {
150
+ for (pos, a) in args.iter().enumerate() {
151
+ let (v, name) = match a {
152
+ Arg::Positional(v) => (v, None),
153
+ Arg::Keyword { name, value } => (value, Some(name)),
154
+ };
155
+ if self.isParam(v) {
156
+ if cl || callees.is_empty() {
157
+ return true;
158
+ }
159
+ for &c in callees {
160
+ let params = &self.fns[c].1.params;
161
+ let idx = match name {
162
+ None => Some(pos),
163
+ Some(n) => params.iter().position(|p| &p.name == n),
164
+ };
165
+ match idx {
166
+ Some(j) if self.ok.contains(&(c, j)) => {}
167
+ _ => return true,
168
+ }
169
+ }
170
+ } else if self.expr(v, cl) {
171
+ return true;
172
+ }
173
+ }
174
+ false
175
+ }
176
+
177
+ fn call(&self, c: &FnCall, cl: bool) -> bool {
178
+ if c.name == self.p {
179
+ // Direct call of the param; inside a closure it is a capture.
180
+ return cl || self.args(&c.args, &[], cl);
181
+ }
182
+ let callees: Vec<usize> = self.free.get(c.name.as_str()).copied().into_iter().collect();
183
+ self.args(&c.args, &callees, cl)
184
+ }
185
+
186
+ fn method(&self, object: &Expr, c: &FnCall, cl: bool) -> bool {
187
+ if self.expr(object, cl) {
188
+ return true;
189
+ }
190
+ let want = if let Expr::TypeName(t) = object { Some(t.as_str()) } else { None };
191
+ let callees: Vec<usize> = self
192
+ .methods
193
+ .get(c.name.as_str())
194
+ .map(|v| {
195
+ v.iter()
196
+ .copied()
197
+ .filter(|&i| {
198
+ let f = self.fns[i].1;
199
+ let ownerOk = match want {
200
+ Some(t) => f.type_param.as_deref() == Some(t),
201
+ None => f.has_self,
202
+ };
203
+ ownerOk
204
+ && (f.params.iter().any(|p| matches!(p.ty, ParamType::Variadic(_)))
205
+ || f.params.len() >= c.args.len())
206
+ })
207
+ .collect()
208
+ })
209
+ .unwrap_or_default();
210
+ self.args(&c.args, &callees, cl)
211
+ }
212
+
213
+ fn expr(&self, e: &Expr, cl: bool) -> bool {
214
+ match e {
215
+ Expr::Var(n) => n == self.p,
216
+ Expr::Binary(b) => self.expr(&b.left, cl) || self.expr(&b.right, cl),
217
+ Expr::Unary(u) => self.expr(&u.operand, cl),
218
+ Expr::Bool(b) => self.expr(&b.left, cl) || self.expr(&b.right, cl),
219
+ Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => self.expr(x, cl),
220
+ Expr::Compare(c) => self.expr(&c.left, cl) || self.expr(&c.right, cl),
221
+ Expr::InlineIf(i) => {
222
+ self.expr(&i.condition, cl) || self.expr(&i.then, cl) || self.expr(&i.else_, cl)
223
+ }
224
+ Expr::FnCall(c) => self.call(c, cl),
225
+ Expr::Yield(xs) => xs.iter().any(|x| self.expr(x, cl)),
226
+ Expr::ClassCall(c) => {
227
+ c.fields.iter().any(|f| self.expr(&f.value, cl))
228
+ || c.spread.as_ref().is_some_and(|s| self.expr(s, cl))
229
+ }
230
+ Expr::Attribute(a) => match &a.attr {
231
+ AttrKind::Field(_) => self.expr(&a.object, cl),
232
+ AttrKind::Method(c) => self.method(&a.object, c, cl),
233
+ },
234
+ Expr::Bind(b) => self.expr(&b.value, cl) || self.case(&b.case, cl),
235
+ Expr::Bang(b) => self.expr(&b.value, cl),
236
+ Expr::Catch(c) => {
237
+ self.expr(&c.value, cl)
238
+ || match &c.handler {
239
+ CatchHandler::Default(d) => self.expr(d, cl),
240
+ CatchHandler::Block { body, .. } => self.block(body, cl),
241
+ }
242
+ }
243
+ Expr::String(s) => s.parts.iter().any(|p| match p {
244
+ StringPart::Interp(x) => self.expr(x, cl),
245
+ StringPart::Text(_) => false,
246
+ }),
247
+ Expr::Closure(c) => self.block(&c.body, true),
248
+ Expr::ErrorValue(_)
249
+ | Expr::Int(_)
250
+ | Expr::Float(_)
251
+ | Expr::BoolLit(_)
252
+ | Expr::Self_
253
+ | Expr::TypeName(_) => false,
254
+ }
255
+ }
256
+ }
plum-core/src/closure_spec.rs ADDED
@@ -0,0 +1,1373 @@
1
+ //! Closure specialisation. A call that passes a closure literal (or a free fn name)
2
+ //! directly to a fn-typed parameter that does not escape (see `closure_escape`) gets a
3
+ //! copy of the callee, named `name$c0`, `name$c1`, ..., in which every call of that
4
+ //! parameter is replaced by the closure body (params bound to the call's arguments,
5
+ //! binders renamed fresh). Variables the closure captures become extra trailing params of
6
+ //! the copy (generic, named `__cap<spec>_<i>`) that the call site passes after the
7
+ //! original arguments. The fn-typed parameter itself stays in the copy's signature (so
8
+ //! generics keep being inferred from the argument) but is unused apart from being passed
9
+ //! on to the copies of callees it was handed to. Recursive calls passing the parameter
10
+ //! along go to the copy. At most `MAX_COPIES` copies per callee; past that, or when the
11
+ //! closure cannot be inlined, the call is left alone.
12
+ use crate::ast::*;
13
+ use crate::closure_escape::nonEscapingFnParams;
14
+ use std::collections::{HashMap, HashSet};
15
+
16
+ pub const MAX_COPIES: usize = 8;
17
+
18
+ /// Receiver type of a method call site: (declaring fn's file, call line, call column,
19
+ /// method name) -> type name. Supplied by the checker, which knows the types.
20
+ pub type Hints = HashMap<(String, usize, usize, String), String>;
21
+
22
+ pub fn specialise(src: &mut Source) -> Vec<String> {
23
+ specialiseWith(src, &Hints::new())
24
+ }
25
+
26
+ /// The method call sites of `src` (hint key, address of the receiver expression), so the
27
+ /// checker can look the receivers' types up.
28
+ pub fn methodSites(src: &Source) -> Vec<((String, usize, usize, String), *const Expr)> {
29
+ let mut out = Vec::new();
30
+ for item in &src.items {
31
+ if let Item::Fn(f) = item {
32
+ let file = f.file.clone();
33
+ let mut cb = |e: &Expr| {
34
+ if let Expr::Attribute(a) = e {
35
+ if let AttrKind::Method(c) = &a.attr {
36
+ let key = (file.clone(), c.line, c.column, baseName(&c.name).to_string());
37
+ out.push((key, &a.object as *const Expr));
38
+ }
39
+ }
40
+ };
41
+ match &f.body {
42
+ FnBody::Expr(e) => walkRef(e, &mut cb),
43
+ FnBody::Block(b) => walkBlockRef(b, &mut cb),
44
+ FnBody::Extern => {}
45
+ }
46
+ }
47
+ }
48
+ out
49
+ }
50
+
51
+ fn baseName(n: &str) -> &str {
52
+ n.split('$').next().unwrap_or(n)
53
+ }
54
+
55
+ pub fn specialiseWith(src: &mut Source, hints: &Hints) -> Vec<String> {
56
+ let ne = nonEscapingFnParams(src);
57
+ if ne.is_empty() {
58
+ return Vec::new();
59
+ }
60
+ let mut cx = Ctx {
61
+ hints,
62
+ ne,
63
+ fns: HashMap::new(),
64
+ methodsByName: HashMap::new(),
65
+ specs: Vec::new(),
66
+ copies: HashMap::new(),
67
+ perCallee: HashMap::new(),
68
+ out: Vec::new(),
69
+ outKeys: Vec::new(),
70
+ fresh: 0,
71
+ };
72
+ for item in &src.items {
73
+ if let Item::Fn(f) = item {
74
+ if f.is_extern || f.is_star || f.name.starts_with("__test_") {
75
+ continue;
76
+ }
77
+ let key = fnKey(f);
78
+ if f.type_param.is_some() && f.has_self {
79
+ cx.methodsByName.entry(f.name.clone()).or_default().push(key.clone());
80
+ }
81
+ cx.fns.insert(key, f.clone());
82
+ }
83
+ }
84
+ for i in 0..src.items.len() {
85
+ let mut f = match &src.items[i] {
86
+ Item::Fn(f) if !f.is_extern && !f.is_star && !f.name.starts_with("__test_") => f.clone(),
87
+ _ => continue,
88
+ };
89
+ if cx.processFn(&mut f) {
90
+ src.items[i] = Item::Fn(f);
91
+ }
92
+ }
93
+ let mut done = 0;
94
+ while done < cx.out.len() {
95
+ if let Some(mut f) = cx.out[done].clone() {
96
+ if cx.processFn(&mut f) {
97
+ cx.out[done] = Some(f);
98
+ }
99
+ }
100
+ done += 1;
101
+ }
102
+ let mut names = Vec::new();
103
+ for (i, f) in cx.out.into_iter().enumerate() {
104
+ if let Some(f) = f {
105
+ names.push(cx.outKeys[i].clone());
106
+ src.items.push(Item::Fn(f));
107
+ }
108
+ }
109
+ names
110
+ }
111
+
112
+ fn fnKey(f: &Fn) -> String {
113
+ match &f.type_param {
114
+ Some(t) => format!("{}.{}", t, f.name),
115
+ None => f.name.clone(),
116
+ }
117
+ }
118
+
119
+ fn capName(spec: usize, i: usize) -> String {
120
+ format!("__cap{}_{}", spec, i)
121
+ }
122
+
123
+ #[derive(Clone, PartialEq)]
124
+ enum Kind {
125
+ Lit(Closure),
126
+ Name(String),
127
+ }
128
+
129
+ #[derive(Clone, PartialEq)]
130
+ struct Spec {
131
+ kind: Kind,
132
+ /// The closure (or fn name) without source positions: the dedup identity.
133
+ norm: String,
134
+ /// Captured variables at the originating call site (`"self"` for the receiver).
135
+ caps: Vec<String>,
136
+ }
137
+
138
+ #[derive(Clone)]
139
+ struct Enc {
140
+ file: String,
141
+ ty: Option<String>,
142
+ }
143
+
144
+ struct Site {
145
+ enc: Enc,
146
+ locals: HashSet<String>,
147
+ }
148
+
149
+ struct Tx {
150
+ p: String,
151
+ spec: usize,
152
+ enc: Enc,
153
+ handled: usize,
154
+ err: bool,
155
+ }
156
+
157
+ struct Ctx<'h> {
158
+ hints: &'h Hints,
159
+ ne: HashMap<String, Vec<usize>>,
160
+ fns: HashMap<String, Fn>,
161
+ methodsByName: HashMap<String, Vec<String>>,
162
+ specs: Vec<Spec>,
163
+ /// (callee key, param index, spec) -> copy fn name; `None` when it cannot be made.
164
+ copies: HashMap<(String, usize, usize), Option<String>>,
165
+ perCallee: HashMap<String, usize>,
166
+ out: Vec<Option<Fn>>,
167
+ outKeys: Vec<String>,
168
+ fresh: usize,
169
+ }
170
+
171
+ impl<'h> Ctx<'h> {
172
+ // ---------- call sites ----------
173
+
174
+ fn processFn(&mut self, f: &mut Fn) -> bool {
175
+ let mut locals: HashSet<String> = f.params.iter().map(|p| p.name.clone()).collect();
176
+ collectBinders(&mut f.body, &mut locals);
177
+ let site = Site {
178
+ enc: Enc {
179
+ file: f.file.clone(),
180
+ ty: f.type_param.clone(),
181
+ },
182
+ locals,
183
+ };
184
+ let mut changed = false;
185
+ let mut cb = |e: &mut Expr| {
186
+ if self.siteExpr(&site, e) {
187
+ changed = true;
188
+ }
189
+ };
190
+ match &mut f.body {
191
+ FnBody::Expr(e) => visitExpr(e, &mut cb),
192
+ FnBody::Block(b) => visitBlock(b, &mut cb),
193
+ FnBody::Extern => {}
194
+ }
195
+ changed
196
+ }
197
+
198
+ fn siteExpr(&mut self, site: &Site, e: &mut Expr) -> bool {
199
+ match e {
200
+ Expr::FnCall(c) => {
201
+ if site.locals.contains(&c.name) {
202
+ return false;
203
+ }
204
+ match self.resolve(&site.enc, None, c) {
205
+ Some(key) => self.rewriteCall(site, key, c),
206
+ None => false,
207
+ }
208
+ }
209
+ Expr::Attribute(a) => {
210
+ let AttributeExpr { object, attr } = &mut **a;
211
+ if let AttrKind::Method(c) = attr {
212
+ match self.resolve(&site.enc, Some(object), c) {
213
+ Some(key) => self.rewriteCall(site, key, c),
214
+ None => false,
215
+ }
216
+ } else {
217
+ false
218
+ }
219
+ }
220
+ _ => false,
221
+ }
222
+ }
223
+
224
+ fn resolve(&self, enc: &Enc, object: Option<&Expr>, c: &FnCall) -> Option<String> {
225
+ match object {
226
+ None => {
227
+ let f = self.fns.get(&c.name)?;
228
+ if f.type_param.is_none() {
229
+ Some(c.name.clone())
230
+ } else {
231
+ None
232
+ }
233
+ }
234
+ Some(Expr::TypeName(t)) => {
235
+ let key = format!("{}.{}", t, c.name);
236
+ self.fns.contains_key(&key).then_some(key)
237
+ }
238
+ Some(obj) => {
239
+ let ty = if matches!(obj, Expr::Self_) {
240
+ enc.ty.clone()
241
+ } else {
242
+ let k = (enc.file.clone(), c.line, c.column, baseName(&c.name).to_string());
243
+ self.hints.get(&k).cloned()
244
+ };
245
+ match ty {
246
+ Some(t) => {
247
+ let key = format!("{}.{}", t, c.name);
248
+ self.fns.contains_key(&key).then_some(key)
249
+ }
250
+ None => {
251
+ let v = self.methodsByName.get(&c.name)?;
252
+ if v.len() == 1 {
253
+ Some(v[0].clone())
254
+ } else {
255
+ None
256
+ }
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ fn rewriteCall(&mut self, site: &Site, mut key: String, c: &mut FnCall) -> bool {
264
+ let mut changed = false;
265
+ for _ in 0..8 {
266
+ let Some(f) = self.fns.get(&key) else { break };
267
+ if c.args.len() != f.params.len()
268
+ || c.args.iter().any(|a| matches!(a, Arg::Keyword { .. }))
269
+ || f.params.iter().any(|p| !matches!(p.ty, ParamType::Type(_) | ParamType::Fn(..)))
270
+ {
271
+ break;
272
+ }
273
+ let idxs = self.ne.get(&key).cloned().unwrap_or_default();
274
+ let mut done = false;
275
+ for j in idxs {
276
+ let Arg::Positional(arg) = &c.args[j] else { continue };
277
+ let Some(spec) = self.mkSpec(arg, site) else { continue };
278
+ let sid = self.specId(spec);
279
+ let Some(name) = self.getCopy(&key, j, sid) else { continue };
280
+ for (i, cap) in self.specs[sid].caps.iter().enumerate() {
281
+ let _ = i;
282
+ c.args.push(Arg::Positional(if cap == "self" {
283
+ Expr::Self_
284
+ } else {
285
+ Expr::Var(cap.clone())
286
+ }));
287
+ }
288
+ let f = &self.fns[&key];
289
+ let newKey = match &f.type_param {
290
+ Some(t) => format!("{}.{}", t, name),
291
+ None => name.clone(),
292
+ };
293
+ c.name = name;
294
+ key = newKey;
295
+ changed = true;
296
+ done = true;
297
+ break;
298
+ }
299
+ if !done {
300
+ break;
301
+ }
302
+ }
303
+ changed
304
+ }
305
+
306
+ fn specId(&mut self, spec: Spec) -> usize {
307
+ if let Some(i) = self.specs.iter().position(|s| s.norm == spec.norm && s.caps == spec.caps) {
308
+ return i;
309
+ }
310
+ self.specs.push(spec);
311
+ self.specs.len() - 1
312
+ }
313
+
314
+ fn mkSpec(&self, arg: &Expr, site: &Site) -> Option<Spec> {
315
+ match arg {
316
+ Expr::Closure(cl) => {
317
+ let caps = self.analyze(cl, &site.locals)?;
318
+ let mut z = (**cl).clone();
319
+ zeroBlock(&mut z.body);
320
+ Some(Spec {
321
+ kind: Kind::Lit((**cl).clone()),
322
+ norm: format!("{:?}", z),
323
+ caps,
324
+ })
325
+ }
326
+ Expr::Var(n) if !site.locals.contains(n) => {
327
+ let f = self.fns.get(n)?;
328
+ if f.type_param.is_none() && !f.is_export {
329
+ Some(Spec {
330
+ kind: Kind::Name(n.clone()),
331
+ norm: format!("fn {}", n),
332
+ caps: Vec::new(),
333
+ })
334
+ } else {
335
+ None
336
+ }
337
+ }
338
+ _ => None,
339
+ }
340
+ }
341
+
342
+ /// The captured variables of a closure, or `None` if it cannot be inlined.
343
+ fn analyze(&self, cl: &Closure, locals: &HashSet<String>) -> Option<Vec<String>> {
344
+ if anyReturn(&cl.body.stmts) {
345
+ return None;
346
+ }
347
+ let mut c = cl.clone();
348
+ let mut binders: HashSet<String> = cl.params.iter().cloned().collect();
349
+ let mut plain: Vec<String> = Vec::new();
350
+ let mut refs: Vec<String> = Vec::new();
351
+ renameStmts(&mut c.body.stmts, &mut |n, kind| match kind {
352
+ Name::Decl => {
353
+ binders.insert(n.clone());
354
+ }
355
+ Name::Plain => plain.push(n.clone()),
356
+ Name::Ref => refs.push(n.clone()),
357
+ });
358
+ // `x = v` must update a binder of the closure itself, never a captured variable.
359
+ if plain.iter().any(|n| !binders.contains(n)) {
360
+ return None;
361
+ }
362
+ let mut bad = false;
363
+ let mut usesSelf = false;
364
+ visitBlock(&mut c.body, &mut |e| match e {
365
+ Expr::Try(_) => bad = true,
366
+ Expr::Self_ => usesSelf = true,
367
+ Expr::FnCall(call) => {
368
+ if binders.contains(&call.name)
369
+ || (locals.contains(&call.name)
370
+ && !self.fns.get(&call.name).is_some_and(|f| f.type_param.is_none()))
371
+ {
372
+ bad = true;
373
+ }
374
+ }
375
+ Expr::Yield(_) => bad = true,
376
+ _ => {}
377
+ });
378
+ if bad {
379
+ return None;
380
+ }
381
+ let mut caps: Vec<String> = Vec::new();
382
+ for n in refs {
383
+ if !binders.contains(&n) && locals.contains(&n) && !caps.contains(&n) {
384
+ caps.push(n);
385
+ }
386
+ }
387
+ if usesSelf {
388
+ caps.push("self".to_string());
389
+ }
390
+ Some(caps)
391
+ }
392
+
393
+ // ---------- copies ----------
394
+
395
+ fn getCopy(&mut self, key: &str, j: usize, sid: usize) -> Option<String> {
396
+ let ck = (key.to_string(), j, sid);
397
+ if let Some(v) = self.copies.get(&ck) {
398
+ return v.clone();
399
+ }
400
+ let n = *self.perCallee.get(key).unwrap_or(&0);
401
+ if n >= MAX_COPIES {
402
+ return None;
403
+ }
404
+ let orig = self.fns.get(key)?.clone();
405
+ if orig.is_extern
406
+ || orig.is_star
407
+ || orig.is_export
408
+ || orig.params.iter().any(|p| !matches!(p.ty, ParamType::Type(_) | ParamType::Fn(..)))
409
+ {
410
+ return None;
411
+ }
412
+ let name = format!("{}$c{}", orig.name, n);
413
+ let newKey = match &orig.type_param {
414
+ Some(t) => format!("{}.{}", t, name),
415
+ None => name.clone(),
416
+ };
417
+ self.perCallee.insert(key.to_string(), n + 1);
418
+ self.copies.insert(ck.clone(), Some(name.clone()));
419
+ let idx = self.out.len();
420
+ self.out.push(None);
421
+ self.outKeys.push(newKey.clone());
422
+ // Everything made from here on belongs to this copy until it succeeds.
423
+ let copiesBefore: HashSet<(String, usize, usize)> = self.copies.keys().cloned().collect();
424
+ let perBefore = self.perCallee.clone();
425
+ match self.buildCopy(orig, name.clone(), j, sid) {
426
+ Some(f) => {
427
+ if f.type_param.is_some() && f.has_self {
428
+ self.methodsByName.entry(f.name.clone()).or_default().push(newKey.clone());
429
+ }
430
+ let mut rest = self.ne.get(key).cloned().unwrap_or_default();
431
+ rest.retain(|&x| x != j);
432
+ if !rest.is_empty() {
433
+ self.ne.insert(newKey.clone(), rest);
434
+ }
435
+ self.fns.insert(newKey, f.clone());
436
+ self.out[idx] = Some(f);
437
+ Some(name)
438
+ }
439
+ None => {
440
+ // Undo this copy and every one made while building it.
441
+ for k in &self.outKeys[idx..] {
442
+ self.fns.remove(k);
443
+ self.ne.remove(k);
444
+ for v in self.methodsByName.values_mut() {
445
+ v.retain(|x| x != k);
446
+ }
447
+ }
448
+ self.out.truncate(idx);
449
+ self.outKeys.truncate(idx);
450
+ let now: Vec<_> = self.copies.keys().cloned().collect();
451
+ for k in now {
452
+ if !copiesBefore.contains(&k) {
453
+ self.copies.remove(&k);
454
+ }
455
+ }
456
+ self.perCallee = perBefore;
457
+ self.perCallee.insert(key.to_string(), n);
458
+ self.copies.insert(ck, None);
459
+ None
460
+ }
461
+ }
462
+ }
463
+
464
+ fn buildCopy(&mut self, orig: Fn, name: String, j: usize, sid: usize) -> Option<Fn> {
465
+ let spec = self.specs[sid].clone();
466
+ let mut f = orig;
467
+ f.name = name;
468
+ let p = f.params[j].name.clone();
469
+ let mut binders = HashSet::new();
470
+ collectBinders(&mut f.body, &mut binders);
471
+ if binders.contains(&p) {
472
+ return None;
473
+ }
474
+ if let Kind::Name(g) = &spec.kind {
475
+ if binders.contains(g) || f.params.iter().any(|q| &q.name == g) {
476
+ return None;
477
+ }
478
+ }
479
+ // Extra params for the captures, each a fresh generic type.
480
+ if !spec.caps.is_empty() {
481
+ let mut used = HashSet::new();
482
+ for q in &f.params {
483
+ paramTypeNames(&q.ty, &mut used);
484
+ }
485
+ if let Some(r) = &f.returns {
486
+ typeNames(r, &mut used);
487
+ }
488
+ bodyTypeNames(&mut f.body, &mut used);
489
+ let mut letters = ('A'..='Z').rev().map(|c| c.to_string()).filter(|l| !used.contains(l));
490
+ for i in 0..spec.caps.len() {
491
+ let letter = letters.next()?;
492
+ f.params.push(Param {
493
+ name: capName(sid, i),
494
+ ty: ParamType::Type(Type {
495
+ name: letter,
496
+ generics: vec![],
497
+ }),
498
+ default: None,
499
+ isOwn: false,
500
+ });
501
+ }
502
+ }
503
+ let before = countVar(&mut f.body, &p);
504
+ let mut tx = Tx {
505
+ p: p.clone(),
506
+ spec: sid,
507
+ enc: Enc {
508
+ file: f.file.clone(),
509
+ ty: f.type_param.clone(),
510
+ },
511
+ handled: 0,
512
+ err: false,
513
+ };
514
+ let wasExpr = matches!(f.body, FnBody::Expr(_));
515
+ let mut block = match std::mem::replace(&mut f.body, FnBody::Extern) {
516
+ FnBody::Expr(e) => Block {
517
+ stmts: vec![Stmt::Expr(e, 0, 0)],
518
+ },
519
+ FnBody::Block(b) => b,
520
+ FnBody::Extern => return None,
521
+ };
522
+ self.txBlock(&mut tx, &mut block);
523
+ if tx.err || tx.handled != before {
524
+ return None;
525
+ }
526
+ f.body = if wasExpr && block.stmts.len() == 1 && matches!(block.stmts[0], Stmt::Expr(..)) {
527
+ match block.stmts.pop() {
528
+ Some(Stmt::Expr(e, ..)) => FnBody::Expr(e),
529
+ _ => return None,
530
+ }
531
+ } else {
532
+ FnBody::Block(block)
533
+ };
534
+ Some(f)
535
+ }
536
+
537
+ fn txBlock(&mut self, tx: &mut Tx, b: &mut Block) {
538
+ let old = std::mem::take(&mut b.stmts);
539
+ let mut out = Vec::new();
540
+ for mut s in old {
541
+ if tx.err {
542
+ out.push(s);
543
+ continue;
544
+ }
545
+ match self.spliceStmt(tx, &mut s) {
546
+ Some(v) => out.extend(v),
547
+ None => {
548
+ for e in stmtExprs(&mut s) {
549
+ visitExpr(e, &mut |x| self.txExpr(tx, x));
550
+ }
551
+ for blk in stmtBlocks(&mut s) {
552
+ self.txBlock(tx, blk);
553
+ }
554
+ out.push(s);
555
+ }
556
+ }
557
+ }
558
+ b.stmts = out;
559
+ }
560
+
561
+ /// A statement that is exactly a call of the parameter (as an expression
562
+ /// statement, the value of a single assignment, or a `return`).
563
+ fn spliceStmt(&mut self, tx: &mut Tx, s: &mut Stmt) -> Option<Vec<Stmt>> {
564
+ let cl = match &self.specs[tx.spec].kind {
565
+ Kind::Lit(c) => c.clone(),
566
+ Kind::Name(_) => return None,
567
+ };
568
+ let call = match &*s {
569
+ Stmt::Expr(Expr::FnCall(c), ..) | Stmt::Return(Some(Expr::FnCall(c))) if c.name == tx.p => c.clone(),
570
+ Stmt::Assign(a) if a.targets.len() == 1 && a.values.len() == 1 => match &a.values[0] {
571
+ Expr::FnCall(c) if c.name == tx.p => c.clone(),
572
+ _ => return None,
573
+ },
574
+ _ => return None,
575
+ };
576
+ let mut args = Vec::new();
577
+ for a in call.args {
578
+ match a {
579
+ Arg::Positional(mut e) => {
580
+ visitExpr(&mut e, &mut |x| self.txExpr(tx, x));
581
+ args.push(e);
582
+ }
583
+ Arg::Keyword { .. } => {
584
+ tx.err = true;
585
+ return Some(vec![]);
586
+ }
587
+ }
588
+ }
589
+ let Some((binds, mut body)) = self.instantiate(&cl, tx.spec, args, false) else {
590
+ tx.err = true;
591
+ return Some(vec![]);
592
+ };
593
+ let mut out = binds;
594
+ match s {
595
+ Stmt::Expr(..) => out.extend(body),
596
+ Stmt::Return(_) | Stmt::Assign(_) => {
597
+ match body.pop() {
598
+ Some(Stmt::Expr(e, ..)) => {
599
+ out.extend(body);
600
+ match s {
601
+ Stmt::Return(_) => out.push(Stmt::Return(Some(e))),
602
+ Stmt::Assign(a) => {
603
+ let mut a = a.clone();
604
+ a.values = vec![e];
605
+ out.push(Stmt::Assign(a));
606
+ }
607
+ _ => unreachable!(),
608
+ }
609
+ }
610
+ _ => {
611
+ tx.err = true;
612
+ return Some(vec![]);
613
+ }
614
+ }
615
+ }
616
+ _ => unreachable!(),
617
+ }
618
+ Some(out)
619
+ }
620
+
621
+ fn txExpr(&mut self, tx: &mut Tx, e: &mut Expr) {
622
+ if tx.err {
623
+ return;
624
+ }
625
+ match e {
626
+ Expr::FnCall(c) => {
627
+ if c.name == tx.p {
628
+ let call = c.clone();
629
+ match self.inlineExpr(tx, &call) {
630
+ Some(new) => *e = new,
631
+ None => tx.err = true,
632
+ }
633
+ } else if hasPArg(c, &tx.p) {
634
+ self.passThrough(tx, None, c);
635
+ }
636
+ }
637
+ Expr::Attribute(a) => {
638
+ let AttributeExpr { object, attr } = &mut **a;
639
+ if let AttrKind::Method(c) = attr {
640
+ if hasPArg(c, &tx.p) {
641
+ self.passThrough(tx, Some(object), c);
642
+ }
643
+ }
644
+ }
645
+ _ => {}
646
+ }
647
+ }
648
+
649
+ fn passThrough(&mut self, tx: &mut Tx, object: Option<&Expr>, c: &mut FnCall) {
650
+ let fail = |tx: &mut Tx| tx.err = true;
651
+ if c.args.iter().any(|a| matches!(a, Arg::Keyword { .. })) {
652
+ return fail(tx);
653
+ }
654
+ let Some(j) = c
655
+ .args
656
+ .iter()
657
+ .position(|a| matches!(a, Arg::Positional(Expr::Var(n)) if *n == tx.p))
658
+ else {
659
+ return fail(tx);
660
+ };
661
+ let Some(key) = self.resolve(&tx.enc, object, c) else {
662
+ return fail(tx);
663
+ };
664
+ let ok = self.fns.get(&key).is_some_and(|g| g.params.len() == c.args.len())
665
+ && self.ne.get(&key).is_some_and(|v| v.contains(&j));
666
+ if !ok {
667
+ return fail(tx);
668
+ }
669
+ let Some(name) = self.getCopy(&key, j, tx.spec) else {
670
+ return fail(tx);
671
+ };
672
+ c.name = name;
673
+ for i in 0..self.specs[tx.spec].caps.len() {
674
+ c.args.push(Arg::Positional(Expr::Var(capName(tx.spec, i))));
675
+ }
676
+ tx.handled += 1;
677
+ }
678
+
679
+ /// The expression a call `p(args)` becomes.
680
+ fn inlineExpr(&mut self, tx: &Tx, call: &FnCall) -> Option<Expr> {
681
+ let mut args = Vec::new();
682
+ for a in &call.args {
683
+ match a {
684
+ Arg::Positional(e) => args.push(e.clone()),
685
+ Arg::Keyword { .. } => return None,
686
+ }
687
+ }
688
+ let cl = match &self.specs[tx.spec].kind {
689
+ Kind::Name(g) => {
690
+ let mut c = call.clone();
691
+ c.name = g.clone();
692
+ return Some(Expr::FnCall(c));
693
+ }
694
+ Kind::Lit(c) => c.clone(),
695
+ };
696
+ let (binds, mut body) = self.instantiate(&cl, tx.spec, args, true)?;
697
+ if !binds.is_empty() || body.len() != 1 {
698
+ return None;
699
+ }
700
+ match body.pop() {
701
+ Some(Stmt::Expr(e, ..)) => Some(e),
702
+ _ => None,
703
+ }
704
+ }
705
+
706
+ /// The closure body with its binders renamed fresh, captures replaced by the copy's
707
+ /// capture params and trivial arguments substituted; the other arguments are bound to
708
+ /// fresh locals (the returned statements). In `exprMode` they cannot be bound, so each
709
+ /// must be used exactly once, unconditionally (and at most one is non-trivial).
710
+ fn instantiate(
711
+ &mut self,
712
+ cl: &Closure,
713
+ sid: usize,
714
+ args: Vec<Expr>,
715
+ exprMode: bool,
716
+ ) -> Option<(Vec<Stmt>, Vec<Stmt>)> {
717
+ if cl.params.len() != args.len() {
718
+ return None;
719
+ }
720
+ let caps = self.specs[sid].caps.clone();
721
+ let k = self.fresh;
722
+ self.fresh += 1;
723
+ let mut body = cl.body.clone();
724
+ let mut binders: HashSet<String> = cl.params.iter().cloned().collect();
725
+ let mut assigned: HashSet<String> = HashSet::new();
726
+ renameStmts(&mut body.stmts, &mut |n, kind| match kind {
727
+ Name::Decl => {
728
+ binders.insert(n.clone());
729
+ }
730
+ Name::Plain => {
731
+ assigned.insert(n.clone());
732
+ }
733
+ Name::Ref => {}
734
+ });
735
+ let mut map: HashMap<String, String> = HashMap::new();
736
+ for b in &binders {
737
+ map.insert(b.clone(), format!("__i{}_{}", k, b));
738
+ }
739
+ for (i, c) in caps.iter().enumerate() {
740
+ if c != "self" {
741
+ map.insert(c.clone(), capName(sid, i));
742
+ }
743
+ }
744
+ renameStmts(&mut body.stmts, &mut |n, _| {
745
+ if let Some(new) = map.get(n.as_str()) {
746
+ *n = new.clone();
747
+ }
748
+ });
749
+ if let Some(i) = caps.iter().position(|c| c == "self") {
750
+ let selfCap = capName(sid, i);
751
+ visitBlock(&mut body, &mut |e| {
752
+ if matches!(e, Expr::Self_) {
753
+ *e = Expr::Var(selfCap.clone());
754
+ }
755
+ });
756
+ }
757
+ let fresh: Vec<String> = cl.params.iter().map(|p| map[p].clone()).collect();
758
+ let mut subst: HashMap<String, Expr> = HashMap::new();
759
+ let mut binds = Vec::new();
760
+ let mut nontrivial = 0;
761
+ for (i, a) in args.into_iter().enumerate() {
762
+ if trivial(&a) && !assigned.contains(&cl.params[i]) && !assigned.contains(&fresh[i]) {
763
+ subst.insert(fresh[i].clone(), a);
764
+ } else if exprMode {
765
+ nontrivial += 1;
766
+ if nontrivial > 1 || body.stmts.len() != 1 {
767
+ return None;
768
+ }
769
+ let Stmt::Expr(be, ..) = &mut body.stmts[0] else { return None };
770
+ let (total, lazy) = usesOf(be, &fresh[i]);
771
+ if total != 1 || lazy != 0 {
772
+ return None;
773
+ }
774
+ subst.insert(fresh[i].clone(), a);
775
+ } else {
776
+ binds.push(Stmt::Assign(Assign {
777
+ targets: vec![AssignTarget::Var(fresh[i].clone())],
778
+ values: vec![a],
779
+ declare: true,
780
+ ty: cl.param_types.get(i).cloned().flatten(),
781
+ }));
782
+ }
783
+ }
784
+ if !subst.is_empty() {
785
+ visitBlock(&mut body, &mut |e| {
786
+ if let Expr::Var(n) = e {
787
+ if let Some(r) = subst.get(n.as_str()) {
788
+ *e = r.clone();
789
+ }
790
+ }
791
+ });
792
+ }
793
+ Some((binds, body.stmts))
794
+ }
795
+ }
796
+
797
+ /// Clears every source position inside `b`.
798
+ fn zeroBlock(b: &mut Block) {
799
+ for s in &mut b.stmts {
800
+ match s {
801
+ Stmt::Expr(_, l, c) => {
802
+ *l = 0;
803
+ *c = 0;
804
+ }
805
+ Stmt::Assert(c) => {
806
+ c.line = 0;
807
+ c.column = 0;
808
+ c.text.clear();
809
+ }
810
+ _ => {}
811
+ }
812
+ for e in stmtExprs(s) {
813
+ visitExpr(e, &mut |x| match x {
814
+ Expr::FnCall(c) => {
815
+ c.line = 0;
816
+ c.column = 0;
817
+ }
818
+ Expr::Attribute(a) => {
819
+ if let AttrKind::Method(c) = &mut a.attr {
820
+ c.line = 0;
821
+ c.column = 0;
822
+ }
823
+ }
824
+ Expr::Bang(b) => {
825
+ b.line = 0;
826
+ b.column = 0;
827
+ }
828
+ Expr::Catch(c) => {
829
+ c.line = 0;
830
+ c.column = 0;
831
+ }
832
+ Expr::ErrorValue(v) => {
833
+ v.line = 0;
834
+ v.column = 0;
835
+ }
836
+ Expr::Closure(c) => zeroBlock(&mut c.body),
837
+ _ => {}
838
+ });
839
+ }
840
+ for blk in stmtBlocks(s) {
841
+ zeroBlock(blk);
842
+ }
843
+ }
844
+ }
845
+
846
+ fn hasPArg(c: &FnCall, p: &str) -> bool {
847
+ c.args.iter().any(|a| match a {
848
+ Arg::Positional(Expr::Var(n)) | Arg::Keyword { value: Expr::Var(n), .. } => n == p,
849
+ _ => false,
850
+ })
851
+ }
852
+
853
+ fn trivial(e: &Expr) -> bool {
854
+ matches!(e, Expr::Var(_) | Expr::Int(_) | Expr::Float(_) | Expr::BoolLit(_) | Expr::Self_)
855
+ }
856
+
857
+ /// (all occurrences of `name`, those in a lazily evaluated position) in `e`.
858
+ fn usesOf(e: &Expr, name: &str) -> (usize, usize) {
859
+ let mut tmp = e.clone();
860
+ let mut total = 0;
861
+ let mut lazy = 0;
862
+ visitExpr(&mut tmp, &mut |x| {
863
+ if matches!(x, Expr::Var(n) if n == name) {
864
+ total += 1;
865
+ }
866
+ for l in lazyChildren(x) {
867
+ visitExpr(l, &mut |y| {
868
+ if matches!(y, Expr::Var(n) if n == name) {
869
+ lazy += 1;
870
+ }
871
+ });
872
+ }
873
+ match x {
874
+ Expr::Closure(c) => visitBlock(&mut c.body, &mut |y| {
875
+ if matches!(y, Expr::Var(n) if n == name) {
876
+ lazy += 1;
877
+ }
878
+ }),
879
+ Expr::Catch(c) => {
880
+ if let CatchHandler::Block { body, .. } = &mut c.handler {
881
+ visitBlock(body, &mut |y| {
882
+ if matches!(y, Expr::Var(n) if n == name) {
883
+ lazy += 1;
884
+ }
885
+ })
886
+ }
887
+ }
888
+ _ => {}
889
+ }
890
+ });
891
+ (total, lazy)
892
+ }
893
+
894
+ fn lazyChildren(e: &mut Expr) -> Vec<&mut Expr> {
895
+ match e {
896
+ Expr::Bool(b) => vec![&mut b.right],
897
+ Expr::InlineIf(i) => vec![&mut i.then, &mut i.else_],
898
+ Expr::Catch(c) => match &mut c.handler {
899
+ CatchHandler::Default(d) => vec![d],
900
+ CatchHandler::Block { .. } => vec![],
901
+ },
902
+ Expr::Bind(b) => b.case.guard.iter_mut().collect(),
903
+ _ => vec![],
904
+ }
905
+ }
906
+
907
+ fn countVar(body: &mut FnBody, name: &str) -> usize {
908
+ let mut n = 0;
909
+ let mut cb = |e: &mut Expr| {
910
+ if matches!(e, Expr::Var(v) if v == name) {
911
+ n += 1;
912
+ }
913
+ };
914
+ match body {
915
+ FnBody::Expr(e) => visitExpr(e, &mut cb),
916
+ FnBody::Block(b) => visitBlock(b, &mut cb),
917
+ FnBody::Extern => {}
918
+ }
919
+ n
920
+ }
921
+
922
+ fn anyReturn(stmts: &[Stmt]) -> bool {
923
+ stmts.iter().any(|s| match s {
924
+ Stmt::Return(_) => true,
925
+ Stmt::While(w) => anyReturn(&w.body.stmts),
926
+ Stmt::ForLoop(f) => anyReturn(&f.body.stmts),
927
+ Stmt::If(i) => {
928
+ anyReturn(&i.body.stmts)
929
+ || i.else_ifs.iter().any(|e| anyReturn(&e.body.stmts))
930
+ || i.else_.as_ref().is_some_and(|b| anyReturn(&b.stmts))
931
+ }
932
+ Stmt::Match(m) => m.cases.iter().any(|c| anyReturn(&c.body.stmts)),
933
+ _ => false,
934
+ })
935
+ }
936
+
937
+ fn typeNames(t: &Type, out: &mut HashSet<String>) {
938
+ out.insert(t.name.clone());
939
+ t.generics.iter().for_each(|g| typeNames(g, out));
940
+ }
941
+
942
+ fn paramTypeNames(t: &ParamType, out: &mut HashSet<String>) {
943
+ match t {
944
+ ParamType::Type(t) | ParamType::Variadic(t) | ParamType::Seq(t) => typeNames(t, out),
945
+ ParamType::Fn(ps, r) => {
946
+ ps.iter().for_each(|p| typeNames(p, out));
947
+ if let Some(r) = r {
948
+ typeNames(r, out);
949
+ }
950
+ }
951
+ }
952
+ }
953
+
954
+ /// Type names written inside a body (annotated locals, closure params, explicit generics).
955
+ fn bodyTypeNames(body: &mut FnBody, out: &mut HashSet<String>) {
956
+ let mut stmtCb = |e: &mut Expr| match e {
957
+ Expr::Closure(c) => c.param_types.iter().flatten().for_each(|t| typeNames(t, out)),
958
+ Expr::ClassCall(c) => c.generics.iter().for_each(|t| typeNames(t, out)),
959
+ _ => {}
960
+ };
961
+ match body {
962
+ FnBody::Expr(e) => visitExpr(e, &mut stmtCb),
963
+ FnBody::Block(b) => {
964
+ visitBlock(b, &mut stmtCb);
965
+ annotations(&b.stmts, out);
966
+ }
967
+ FnBody::Extern => {}
968
+ }
969
+ }
970
+
971
+ fn annotations(stmts: &[Stmt], out: &mut HashSet<String>) {
972
+ for s in stmts {
973
+ match s {
974
+ Stmt::Assign(a) => {
975
+ if let Some(t) = &a.ty {
976
+ typeNames(t, out);
977
+ }
978
+ }
979
+ Stmt::While(w) => annotations(&w.body.stmts, out),
980
+ Stmt::ForLoop(f) => annotations(&f.body.stmts, out),
981
+ Stmt::If(i) => {
982
+ annotations(&i.body.stmts, out);
983
+ i.else_ifs.iter().for_each(|e| annotations(&e.body.stmts, out));
984
+ if let Some(b) = &i.else_ {
985
+ annotations(&b.stmts, out);
986
+ }
987
+ }
988
+ Stmt::Match(m) => m.cases.iter().for_each(|c| annotations(&c.body.stmts, out)),
989
+ _ => {}
990
+ }
991
+ }
992
+ }
993
+
994
+ fn collectBinders(body: &mut FnBody, out: &mut HashSet<String>) {
995
+ let mut cb = |n: &mut String, k: Name| {
996
+ if matches!(k, Name::Decl) {
997
+ out.insert(n.clone());
998
+ }
999
+ };
1000
+ match body {
1001
+ FnBody::Block(b) => renameStmts(&mut b.stmts, &mut cb),
1002
+ FnBody::Expr(e) => renameExpr(e, &mut cb),
1003
+ FnBody::Extern => {}
1004
+ }
1005
+ }
1006
+
1007
+ // ---------- mutable traversal ----------
1008
+
1009
+ fn visitBlock(b: &mut Block, f: &mut dyn FnMut(&mut Expr)) {
1010
+ for s in &mut b.stmts {
1011
+ for e in stmtExprs(s) {
1012
+ visitExpr(e, f);
1013
+ }
1014
+ for blk in stmtBlocks(s) {
1015
+ visitBlock(blk, f);
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ /// Post-order over `e`, into closure bodies and catch blocks too.
1021
+ fn visitExpr(e: &mut Expr, f: &mut dyn FnMut(&mut Expr)) {
1022
+ for c in childExprs(e) {
1023
+ visitExpr(c, f);
1024
+ }
1025
+ match e {
1026
+ Expr::Closure(c) => visitBlock(&mut c.body, f),
1027
+ Expr::Catch(c) => {
1028
+ if let CatchHandler::Block { body, .. } = &mut c.handler {
1029
+ visitBlock(body, f);
1030
+ }
1031
+ }
1032
+ _ => {}
1033
+ }
1034
+ f(e);
1035
+ }
1036
+
1037
+ fn argExpr(a: &mut Arg) -> &mut Expr {
1038
+ match a {
1039
+ Arg::Positional(e) | Arg::Keyword { value: e, .. } => e,
1040
+ }
1041
+ }
1042
+
1043
+ fn childExprs(e: &mut Expr) -> Vec<&mut Expr> {
1044
+ match e {
1045
+ Expr::Binary(b) => vec![&mut b.left, &mut b.right],
1046
+ Expr::Unary(u) => vec![&mut u.operand],
1047
+ Expr::Bool(b) => vec![&mut b.left, &mut b.right],
1048
+ Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => vec![x.as_mut()],
1049
+ Expr::Bang(b) => vec![&mut b.value],
1050
+ Expr::Bind(b) => {
1051
+ let mut v = vec![&mut b.value];
1052
+ v.extend(b.case.guard.iter_mut());
1053
+ v
1054
+ }
1055
+ Expr::Catch(c) => {
1056
+ let CatchExpr { value, handler, .. } = &mut **c;
1057
+ match handler {
1058
+ CatchHandler::Default(d) => vec![value, d],
1059
+ CatchHandler::Block { .. } => vec![value],
1060
+ }
1061
+ }
1062
+ Expr::Compare(c) => vec![&mut c.left, &mut c.right],
1063
+ Expr::InlineIf(t) => vec![&mut t.condition, &mut t.then, &mut t.else_],
1064
+ Expr::FnCall(c) => c.args.iter_mut().map(argExpr).collect(),
1065
+ Expr::Yield(vs) => vs.iter_mut().collect(),
1066
+ Expr::ClassCall(c) => {
1067
+ let mut v: Vec<&mut Expr> = c.fields.iter_mut().map(|f| &mut f.value).collect();
1068
+ if let Some(s) = &mut c.spread {
1069
+ v.push(s.as_mut());
1070
+ }
1071
+ v
1072
+ }
1073
+ Expr::Attribute(a) => {
1074
+ let AttributeExpr { object, attr } = &mut **a;
1075
+ let mut v = vec![object];
1076
+ if let AttrKind::Method(c) = attr {
1077
+ v.extend(c.args.iter_mut().map(argExpr));
1078
+ }
1079
+ v
1080
+ }
1081
+ Expr::String(s) => s
1082
+ .parts
1083
+ .iter_mut()
1084
+ .filter_map(|p| match p {
1085
+ StringPart::Interp(e) => Some(e),
1086
+ StringPart::Text(_) => None,
1087
+ })
1088
+ .collect(),
1089
+ Expr::Closure(_)
1090
+ | Expr::Int(_)
1091
+ | Expr::Float(_)
1092
+ | Expr::BoolLit(_)
1093
+ | Expr::Self_
1094
+ | Expr::ErrorValue(_)
1095
+ | Expr::Var(_)
1096
+ | Expr::TypeName(_) => vec![],
1097
+ }
1098
+ }
1099
+
1100
+ fn stmtExprs(s: &mut Stmt) -> Vec<&mut Expr> {
1101
+ match s {
1102
+ Stmt::Assign(a) => {
1103
+ let mut v: Vec<&mut Expr> = Vec::new();
1104
+ for t in &mut a.targets {
1105
+ if let AssignTarget::Field(obj, _) = t {
1106
+ v.push(obj.as_mut());
1107
+ }
1108
+ }
1109
+ v.extend(a.values.iter_mut());
1110
+ v
1111
+ }
1112
+ Stmt::Assert(c) => vec![&mut c.cond],
1113
+ Stmt::Crash(e) | Stmt::Expr(e, ..) => vec![e],
1114
+ Stmt::Return(e) => e.iter_mut().collect(),
1115
+ Stmt::ForLoop(f) => {
1116
+ let mut v: Vec<&mut Expr> = vec![&mut f.source];
1117
+ for c in &mut f.clauses {
1118
+ match c {
1119
+ ForClause::If(e) | ForClause::Bind(_, e) => v.push(e),
1120
+ }
1121
+ }
1122
+ v
1123
+ }
1124
+ Stmt::While(w) => vec![&mut w.condition],
1125
+ Stmt::If(i) => {
1126
+ let mut v = vec![&mut i.condition];
1127
+ v.extend(i.else_ifs.iter_mut().map(|e| &mut e.condition));
1128
+ v
1129
+ }
1130
+ Stmt::Match(m) => {
1131
+ let mut v: Vec<&mut Expr> = m.subjects.iter_mut().collect();
1132
+ v.extend(m.cases.iter_mut().filter_map(|c| c.guard.as_mut()));
1133
+ v
1134
+ }
1135
+ Stmt::Break | Stmt::Continue | Stmt::Todo => vec![],
1136
+ }
1137
+ }
1138
+
1139
+ fn stmtBlocks(s: &mut Stmt) -> Vec<&mut Block> {
1140
+ match s {
1141
+ Stmt::ForLoop(f) => vec![&mut f.body],
1142
+ Stmt::While(w) => vec![&mut w.body],
1143
+ Stmt::If(i) => {
1144
+ let mut v = vec![&mut i.body];
1145
+ v.extend(i.else_ifs.iter_mut().map(|e| &mut e.body));
1146
+ v.extend(i.else_.iter_mut());
1147
+ v
1148
+ }
1149
+ Stmt::Match(m) => m.cases.iter_mut().map(|c| &mut c.body).collect(),
1150
+ _ => vec![],
1151
+ }
1152
+ }
1153
+
1154
+ #[derive(Clone, Copy)]
1155
+ enum Name {
1156
+ /// A declaration site: `x := v`, a loop binder, a pattern name, a closure param.
1157
+ Decl,
1158
+ /// The target of a plain `x = v`.
1159
+ Plain,
1160
+ /// A use.
1161
+ Ref,
1162
+ }
1163
+
1164
+ fn renameStmts(stmts: &mut [Stmt], rn: &mut dyn FnMut(&mut String, Name)) {
1165
+ for s in stmts {
1166
+ match s {
1167
+ Stmt::Assign(a) => {
1168
+ let kind = if a.declare { Name::Decl } else { Name::Plain };
1169
+ for t in &mut a.targets {
1170
+ if let AssignTarget::Var(n) = t {
1171
+ rn(n, kind);
1172
+ }
1173
+ }
1174
+ }
1175
+ Stmt::ForLoop(f) => {
1176
+ f.binders.iter_mut().for_each(|v| rn(v, Name::Decl));
1177
+ for c in &mut f.clauses {
1178
+ if let ForClause::Bind(n, _) = c {
1179
+ rn(n, Name::Decl);
1180
+ }
1181
+ }
1182
+ if let Some((n, _)) = &mut f.result {
1183
+ rn(n, Name::Decl);
1184
+ }
1185
+ }
1186
+ Stmt::Match(m) => {
1187
+ for c in &mut m.cases {
1188
+ for p in &mut c.patterns {
1189
+ renamePattern(p, rn);
1190
+ }
1191
+ }
1192
+ }
1193
+ _ => {}
1194
+ }
1195
+ for e in stmtExprs(s) {
1196
+ renameExpr(e, rn);
1197
+ }
1198
+ for b in stmtBlocks(s) {
1199
+ renameStmts(&mut b.stmts, rn);
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ fn renamePattern(p: &mut CasePattern, rn: &mut dyn FnMut(&mut String, Name)) {
1205
+ match p {
1206
+ CasePattern::Name(n) => rn(n, Name::Decl),
1207
+ CasePattern::Class { fields, .. } => fields.iter_mut().for_each(|f| renamePattern(f, rn)),
1208
+ _ => {}
1209
+ }
1210
+ }
1211
+
1212
+ fn renameExpr(e: &mut Expr, rn: &mut dyn FnMut(&mut String, Name)) {
1213
+ match e {
1214
+ Expr::Var(n) => rn(n, Name::Ref),
1215
+ Expr::Bind(b) => renamePattern(&mut b.case.patterns[0], rn),
1216
+ Expr::Closure(c) => {
1217
+ c.params.iter_mut().for_each(|p| rn(p, Name::Decl));
1218
+ renameStmts(&mut c.body.stmts, rn);
1219
+ }
1220
+ Expr::Catch(c) => {
1221
+ if let CatchHandler::Block { binder, body } = &mut c.handler {
1222
+ rn(binder, Name::Decl);
1223
+ renameStmts(&mut body.stmts, rn);
1224
+ }
1225
+ }
1226
+ _ => {}
1227
+ }
1228
+ for ch in childExprs(e) {
1229
+ renameExpr(ch, rn);
1230
+ }
1231
+ }
1232
+
1233
+ // ---------- shared traversal ----------
1234
+
1235
+ fn walkBlockRef(b: &Block, f: &mut dyn FnMut(&Expr)) {
1236
+ for s in &b.stmts {
1237
+ for e in stmtExprRefs(s) {
1238
+ walkRef(e, f);
1239
+ }
1240
+ for blk in stmtBlockRefs(s) {
1241
+ walkBlockRef(blk, f);
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ fn walkRef(e: &Expr, f: &mut dyn FnMut(&Expr)) {
1247
+ f(e);
1248
+ for c in childRefs(e) {
1249
+ walkRef(c, f);
1250
+ }
1251
+ match e {
1252
+ Expr::Closure(c) => walkBlockRef(&c.body, f),
1253
+ Expr::Catch(c) => {
1254
+ if let CatchHandler::Block { body, .. } = &c.handler {
1255
+ walkBlockRef(body, f);
1256
+ }
1257
+ }
1258
+ _ => {}
1259
+ }
1260
+ }
1261
+
1262
+ fn argRef(a: &Arg) -> &Expr {
1263
+ match a {
1264
+ Arg::Positional(e) | Arg::Keyword { value: e, .. } => e,
1265
+ }
1266
+ }
1267
+
1268
+ fn childRefs(e: &Expr) -> Vec<&Expr> {
1269
+ match e {
1270
+ Expr::Binary(b) => vec![&b.left, &b.right],
1271
+ Expr::Unary(u) => vec![&u.operand],
1272
+ Expr::Bool(b) => vec![&b.left, &b.right],
1273
+ Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => vec![x.as_ref()],
1274
+ Expr::Bang(b) => vec![&b.value],
1275
+ Expr::Bind(b) => {
1276
+ let mut v = vec![&b.value];
1277
+ v.extend(b.case.guard.iter());
1278
+ v
1279
+ }
1280
+ Expr::Catch(c) => match &c.handler {
1281
+ CatchHandler::Default(d) => vec![&c.value, d],
1282
+ CatchHandler::Block { .. } => vec![&c.value],
1283
+ },
1284
+ Expr::Compare(c) => vec![&c.left, &c.right],
1285
+ Expr::InlineIf(t) => vec![&t.condition, &t.then, &t.else_],
1286
+ Expr::FnCall(c) => c.args.iter().map(argRef).collect(),
1287
+ Expr::Yield(vs) => vs.iter().collect(),
1288
+ Expr::ClassCall(c) => {
1289
+ let mut v: Vec<&Expr> = c.fields.iter().map(|f| &f.value).collect();
1290
+ if let Some(s) = &c.spread {
1291
+ v.push(s.as_ref());
1292
+ }
1293
+ v
1294
+ }
1295
+ Expr::Attribute(a) => {
1296
+ let mut v = vec![&a.object];
1297
+ if let AttrKind::Method(c) = &a.attr {
1298
+ v.extend(c.args.iter().map(argRef));
1299
+ }
1300
+ v
1301
+ }
1302
+ Expr::String(s) => s
1303
+ .parts
1304
+ .iter()
1305
+ .filter_map(|p| match p {
1306
+ StringPart::Interp(e) => Some(e),
1307
+ StringPart::Text(_) => None,
1308
+ })
1309
+ .collect(),
1310
+ Expr::Closure(_)
1311
+ | Expr::Int(_)
1312
+ | Expr::Float(_)
1313
+ | Expr::BoolLit(_)
1314
+ | Expr::Self_
1315
+ | Expr::ErrorValue(_)
1316
+ | Expr::Var(_)
1317
+ | Expr::TypeName(_) => vec![],
1318
+ }
1319
+ }
1320
+
1321
+ fn stmtExprRefs(s: &Stmt) -> Vec<&Expr> {
1322
+ match s {
1323
+ Stmt::Assign(a) => {
1324
+ let mut v: Vec<&Expr> = Vec::new();
1325
+ for t in &a.targets {
1326
+ if let AssignTarget::Field(obj, _) = t {
1327
+ v.push(obj.as_ref());
1328
+ }
1329
+ }
1330
+ v.extend(a.values.iter());
1331
+ v
1332
+ }
1333
+ Stmt::Assert(c) => vec![&c.cond],
1334
+ Stmt::Crash(e) | Stmt::Expr(e, ..) => vec![e],
1335
+ Stmt::Return(e) => e.iter().collect(),
1336
+ Stmt::ForLoop(f) => {
1337
+ let mut v: Vec<&Expr> = vec![&f.source];
1338
+ for c in &f.clauses {
1339
+ match c {
1340
+ ForClause::If(e) | ForClause::Bind(_, e) => v.push(e),
1341
+ }
1342
+ }
1343
+ v
1344
+ }
1345
+ Stmt::While(w) => vec![&w.condition],
1346
+ Stmt::If(i) => {
1347
+ let mut v = vec![&i.condition];
1348
+ v.extend(i.else_ifs.iter().map(|e| &e.condition));
1349
+ v
1350
+ }
1351
+ Stmt::Match(m) => {
1352
+ let mut v: Vec<&Expr> = m.subjects.iter().collect();
1353
+ v.extend(m.cases.iter().filter_map(|c| c.guard.as_ref()));
1354
+ v
1355
+ }
1356
+ Stmt::Break | Stmt::Continue | Stmt::Todo => vec![],
1357
+ }
1358
+ }
1359
+
1360
+ fn stmtBlockRefs(s: &Stmt) -> Vec<&Block> {
1361
+ match s {
1362
+ Stmt::ForLoop(f) => vec![&f.body],
1363
+ Stmt::While(w) => vec![&w.body],
1364
+ Stmt::If(i) => {
1365
+ let mut v = vec![&i.body];
1366
+ v.extend(i.else_ifs.iter().map(|e| &e.body));
1367
+ v.extend(i.else_.iter());
1368
+ v
1369
+ }
1370
+ Stmt::Match(m) => m.cases.iter().map(|c| &c.body).collect(),
1371
+ _ => vec![],
1372
+ }
1373
+ }
plum-core/src/lib.rs CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  pub mod ast;
6
6
  pub mod builtin_usage;
7
+ pub mod closure_escape;
8
+ pub mod closure_spec;
7
9
  pub mod ctors;
8
10
  pub mod embeds;
9
11
  pub mod formatter;
plum-core/tests/closure_escape_test.rs ADDED
@@ -0,0 +1,134 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_core::closure_escape::nonEscapingFnParams;
3
+ use plum_core::AstParser;
4
+
5
+ fn parse(src: &str) -> plum_core::ast::Source {
6
+ let mut parser = tree_sitter::Parser::new();
7
+ parser.set_language(&tree_sitter_plum::LANGUAGE.into()).unwrap();
8
+ let tree = parser.parse(src, None).unwrap();
9
+ assert!(!tree.root_node().has_error(), "{}", tree.root_node().to_sexp());
10
+ AstParser::new(src).parseSource(tree.root_node())
11
+ }
12
+
13
+ fn get(src: &str, key: &str) -> Vec<usize> {
14
+ nonEscapingFnParams(&parse(src)).get(key).cloned().unwrap_or_default()
15
+ }
16
+
17
+ #[test]
18
+ fn direct_call_param_non_escaping() {
19
+ assert_eq!(get("fun apply(x: Int, f: fn(Int) -> Int) -> Int = f(x)\n", "apply"), vec![1]);
20
+ }
21
+
22
+ #[test]
23
+ fn recursive_same_param_non_escaping() {
24
+ let src = "\
25
+ enum Link[T] =
26
+ | Nil
27
+ | Cons(head: T, tail: Link[T])
28
+
29
+ fun mapCells(xs: Link[A], f: fn(A) -> B) -> Link[B] = match xs
30
+ Nil => Nil
31
+ Cons(h, t) => Cons(head: f(h), tail: mapCells(t, f))
32
+ ";
33
+ assert_eq!(get(src, "mapCells"), vec![1]);
34
+ }
35
+
36
+ #[test]
37
+ fn stored_in_field_escapes() {
38
+ let src = "\
39
+ record Box(cb: fn(Int) -> Int)
40
+
41
+ fun keep(f: fn(Int) -> Int) -> Box = Box(cb: f)
42
+ ";
43
+ assert!(get(src, "keep").is_empty());
44
+ }
45
+
46
+ #[test]
47
+ fn returned_escapes() {
48
+ assert!(get("fun id(f: fn(Int) -> Int) -> fn(Int) -> Int = f\n", "id").is_empty());
49
+ }
50
+
51
+ #[test]
52
+ fn captured_by_closure_escapes() {
53
+ let src = "fun wrap(f: fn(Int) -> Int) -> fn(Int) -> Int = |x| f(x)\n";
54
+ assert!(get(src, "wrap").is_empty());
55
+ }
56
+
57
+ #[test]
58
+ fn passed_to_escaping_param_escapes() {
59
+ let src = "\
60
+ record Box(cb: fn(Int) -> Int)
61
+
62
+ fun keep(f: fn(Int) -> Int) -> Box = Box(cb: f)
63
+
64
+ fun viaKeep(g: fn(Int) -> Int) -> Box = keep(g)
65
+ ";
66
+ assert!(get(src, "viaKeep").is_empty());
67
+ }
68
+
69
+ #[test]
70
+ fn passed_to_non_escaping_param_ok() {
71
+ let src = "\
72
+ fun apply(x: Int, f: fn(Int) -> Int) -> Int = f(x)
73
+
74
+ fun twice(g: fn(Int) -> Int) -> Int = apply(apply(1, g), g)
75
+ ";
76
+ assert_eq!(get(src, "twice"), vec![0]);
77
+ }
78
+
79
+ #[test]
80
+ fn method_keys() {
81
+ let src = "\
82
+ record Counter(n: Int) =
83
+
84
+ fun each(self, f: fn(Int) -> Int) -> Int = f(1)
85
+
86
+ fun make(f: fn(Int) -> Int) -> Int = f(2)
87
+ ";
88
+ assert_eq!(get(src, "Counter.each"), vec![0]);
89
+ assert_eq!(get(src, "Counter.make"), vec![0]);
90
+ }
91
+
92
+ #[test]
93
+ fn std_list_combinators_non_escaping() {
94
+ let dir = std::env::temp_dir().join(format!("plum_escape_{}", std::process::id()));
95
+ std::fs::create_dir_all(&dir).unwrap();
96
+ let path = dir.join("main.plum");
97
+ std::fs::write(&path, "import std/Int\nimport std/List\n\nfun main() =\n List(1)\n").unwrap();
98
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
99
+ let r = plum_core::loadAndMerge(&path, &root);
100
+ let _ = std::fs::remove_dir_all(&dir);
101
+ let m = nonEscapingFnParams(&r.unwrap_or_else(|e| panic!("load: {e}")));
102
+ assert_eq!(m.get("List.map"), Some(&vec![0]), "{m:?}");
103
+ assert_eq!(m.get("List.filter"), Some(&vec![0]));
104
+ assert_eq!(m.get("List.fold"), Some(&vec![1]));
105
+ assert_eq!(m.get("mapCells"), Some(&vec![1]));
106
+ assert_eq!(m.get("List.keepRev"), Some(&vec![0]));
107
+ }
108
+
109
+ #[test]
110
+ fn escapes_via_field_return_container() {
111
+ // One fn each: stores the param in a record field, returns it, puts it into a Slice (built from it, or pushed into one; parse-level only, the analysis does not type-check).
112
+ // `apply` is a non-escaping control so an empty answer cannot mean "nothing analysed".
113
+ let src = "\
114
+ record Box(cb: fn(Int) -> Int)
115
+
116
+ fun apply(x: Int, f: fn(Int) -> Int) -> Int = f(x)
117
+
118
+ fun inField(f: fn(Int) -> Int) -> Box = Box(cb: f)
119
+
120
+ fun inReturn(f: fn(Int) -> Int) -> fn(Int) -> Int = f
121
+
122
+ fun inSlice(f: fn(Int) -> Int) -> Int =
123
+ out := Slice(f)
124
+ out.length()
125
+
126
+ fun inPush(out: Slice[Box], f: fn(Int) -> Int) =
127
+ out.push(f)
128
+ ";
129
+ let m = nonEscapingFnParams(&parse(src));
130
+ assert_eq!(m.get("apply"), Some(&vec![1]), "{m:?}");
131
+ for name in ["inField", "inReturn", "inSlice", "inPush"] {
132
+ assert!(m.get(name).map_or(true, |v| v.is_empty()), "{name} escapes: {m:?}");
133
+ }
134
+ }
plum-core/tests/closure_spec_test.rs ADDED
@@ -0,0 +1,408 @@
1
+ #![allow(non_snake_case)]
2
+ //! Closure specialisation (`plum_core::closure_spec`): which calls get a copy of the
3
+ //! callee with the closure inlined, and that the program still computes the same thing
4
+ //! (compiled and run through the real pipeline, where the pass is wired in).
5
+ use plum_wasm_codegen::compileSource;
6
+
7
+ fn gcEngine() -> wasmtime::Engine {
8
+ let mut config = wasmtime::Config::new();
9
+ config.wasm_gc(true);
10
+ config.wasm_function_references(true);
11
+ config.wasm_tail_call(true);
12
+ wasmtime::Engine::new(&config).expect("engine with GC config should construct")
13
+ }
14
+
15
+ fn repoRoot() -> std::path::PathBuf {
16
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
17
+ }
18
+
19
+ fn load(name: &str, src: &str) -> plum_core::ast::Source {
20
+ let dir = std::env::temp_dir().join(format!("plum_cspec_{}_{}", name, std::process::id()));
21
+ std::fs::create_dir_all(&dir).unwrap();
22
+ let path = dir.join("main.plum");
23
+ std::fs::write(&path, src).unwrap();
24
+ let r = plum_core::loadAndMerge(&path, &repoRoot());
25
+ let _ = std::fs::remove_dir_all(&dir);
26
+ r.unwrap_or_else(|e| panic!("load: {e}"))
27
+ }
28
+
29
+ fn compile(name: &str, src: &str) -> Vec<u8> {
30
+ let source = load(name, src);
31
+ let bytes = compileSource(&source).unwrap_or_else(|e| panic!("compile failed: {e}"));
32
+ wasmparser::validate(&bytes).expect("valid wasm");
33
+ bytes
34
+ }
35
+
36
+ fn runIntBytes(bytes: &[u8]) -> i64 {
37
+ let engine = gcEngine();
38
+ let module = wasmtime::Module::new(&engine, bytes).unwrap();
39
+ let mut store = wasmtime::Store::new(&engine, ());
40
+ let mut linker = wasmtime::Linker::new(&engine);
41
+ linker.define_unknown_imports_as_default_values(&mut store, &module).unwrap();
42
+ let instance = linker.instantiate(&mut store, &module).unwrap();
43
+ let main = instance.get_typed_func::<(), i64>(&mut store, "main").unwrap();
44
+ main.call(&mut store, ()).unwrap()
45
+ }
46
+
47
+ fn runInt(name: &str, src: &str) -> i64 {
48
+ runIntBytes(&compile(name, src))
49
+ }
50
+
51
+ fn lowered(name: &str, src: &str) -> std::rc::Rc<plum_core::ast::Source> {
52
+ let source = load(name, src);
53
+ let info = plum_checker::typedSource(&source).unwrap_or_else(|e| panic!("check: {e:?}"));
54
+ info.lowered.expect("lowered program")
55
+ }
56
+
57
+ /// The copies (`name$cN`) the checker's lowered program carries.
58
+ fn copies(name: &str, src: &str) -> Vec<String> {
59
+ lowered(name, src)
60
+ .items
61
+ .iter()
62
+ .filter_map(|i| match i {
63
+ plum_core::ast::Item::Fn(f) if f.name.contains("$c") => Some(match &f.type_param {
64
+ Some(t) => format!("{}.{}", t, f.name),
65
+ None => f.name.clone(),
66
+ }),
67
+ _ => None,
68
+ })
69
+ .collect()
70
+ }
71
+
72
+ /// Number of closure calls (`call_ref` / `call_indirect`) in the module.
73
+ fn callRefs(bytes: &[u8]) -> usize {
74
+ let mut n = 0;
75
+ for payload in wasmparser::Parser::new(0).parse_all(bytes) {
76
+ if let Ok(wasmparser::Payload::CodeSectionEntry(body)) = payload {
77
+ let mut r = body.get_operators_reader().unwrap();
78
+ while !r.eof() {
79
+ if matches!(
80
+ r.read().unwrap(),
81
+ wasmparser::Operator::CallRef { .. }
82
+ | wasmparser::Operator::ReturnCallRef { .. }
83
+ | wasmparser::Operator::CallIndirect { .. }
84
+ | wasmparser::Operator::ReturnCallIndirect { .. }
85
+ ) {
86
+ n += 1;
87
+ }
88
+ }
89
+ }
90
+ }
91
+ n
92
+ }
93
+
94
+ /// Names of the functions containing a closure call (needs the name section).
95
+ fn callerNames(bytes: &[u8]) -> Vec<String> {
96
+ let mut names: std::collections::HashMap<u32, String> = Default::default();
97
+ let mut imports = 0u32;
98
+ let mut with: Vec<u32> = Vec::new();
99
+ let mut idx = 0u32;
100
+ for payload in wasmparser::Parser::new(0).parse_all(bytes) {
101
+ match payload.unwrap() {
102
+ wasmparser::Payload::ImportSection(r) => {
103
+ for i in r {
104
+ if matches!(i.unwrap().ty, wasmparser::TypeRef::Func(_)) {
105
+ imports += 1;
106
+ }
107
+ }
108
+ idx = imports;
109
+ }
110
+ wasmparser::Payload::CodeSectionEntry(body) => {
111
+ let mut r = body.get_operators_reader().unwrap();
112
+ while !r.eof() {
113
+ if matches!(
114
+ r.read().unwrap(),
115
+ wasmparser::Operator::CallRef { .. }
116
+ | wasmparser::Operator::ReturnCallRef { .. }
117
+ | wasmparser::Operator::CallIndirect { .. }
118
+ | wasmparser::Operator::ReturnCallIndirect { .. }
119
+ ) {
120
+ with.push(idx);
121
+ }
122
+ }
123
+ idx += 1;
124
+ }
125
+ wasmparser::Payload::CustomSection(c) => {
126
+ if let wasmparser::KnownCustom::Name(r) = c.as_known() {
127
+ for n in r {
128
+ if let Ok(wasmparser::Name::Function(m)) = n {
129
+ for e in m.into_iter().flatten() {
130
+ names.insert(e.index, e.name.to_string());
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ _ => {}
137
+ }
138
+ }
139
+ with.iter().map(|i| names.get(i).cloned().unwrap_or(format!("#{i}"))).collect()
140
+ }
141
+
142
+ /// No List function (`map`, `fold`, `mapCells`, ...) of the module makes a closure call.
143
+ fn noListCalls(bytes: &[u8]) {
144
+ let bad: Vec<String> = callerNames(bytes)
145
+ .into_iter()
146
+ .filter(|n| n.contains("List") || n.contains("mapCells") || n.contains("fold") || n.contains("map"))
147
+ .collect();
148
+ assert!(bad.is_empty(), "closure calls left in {bad:?}");
149
+ }
150
+
151
+ const HEAD: &str = "\
152
+ import std/List
153
+ import std/Str
154
+ import std/Int
155
+ import std/Bool
156
+
157
+ ";
158
+
159
+ fn withHead(body: &str) -> String {
160
+ format!("{HEAD}{body}")
161
+ }
162
+
163
+ #[test]
164
+ fn map_literal_no_call_ref() {
165
+ let src = withHead(
166
+ "fun main() -> Int =\n xs := List(1, 2, 3, 4)\n return xs.map(|x| x * 2).fold(0, |a, x| a + x)\n",
167
+ );
168
+ let names = copies("map_lit", &src);
169
+ assert!(names.contains(&"List.map$c0".to_string()), "{names:?}");
170
+ assert!(names.contains(&"mapCells$c0".to_string()), "{names:?}");
171
+ let bytes = compile("map_lit", &src);
172
+ noListCalls(&bytes);
173
+ assert_eq!(runIntBytes(&bytes), 20);
174
+ }
175
+
176
+ #[test]
177
+ fn capture_becomes_param() {
178
+ let src = withHead(
179
+ "fun main() -> Int =\n k := 10\n xs := List(1, 2, 3)\n return xs.map(|x| x + k).fold(0, |a, x| a + x)\n",
180
+ );
181
+ let names = copies("capture", &src);
182
+ assert!(names.contains(&"List.map$c0".to_string()), "{names:?}");
183
+ let bytes = compile("capture", &src);
184
+ noListCalls(&bytes);
185
+ assert_eq!(runIntBytes(&bytes), 36);
186
+ }
187
+
188
+ #[test]
189
+ fn recursive_copy_calls_itself() {
190
+ let src = withHead(
191
+ "fun main() -> Int =\n xs := List(1, 2, 3, 4, 5)\n return xs.fold(0, |a, x| a + x)\n",
192
+ );
193
+ let l = lowered("recursive", &src);
194
+ let f = l
195
+ .items
196
+ .iter()
197
+ .find_map(|i| match i {
198
+ plum_core::ast::Item::Fn(f) if f.name == "fold$c0" => Some(f),
199
+ _ => None,
200
+ })
201
+ .expect("fold$c0 emitted");
202
+ let text = format!("{:?}", f.body);
203
+ assert!(text.contains("fold$c0"), "recursion goes to the copy: {text}");
204
+ assert_eq!(runInt("recursive", &src), 15);
205
+ }
206
+
207
+ #[test]
208
+ fn fn_name_argument() {
209
+ let src = withHead(
210
+ "fun double(x: Int) -> Int = x * 2\n\nfun main() -> Int =\n xs := List(1, 2, 3)\n return xs.map(double).fold(0, |a, x| a + x)\n",
211
+ );
212
+ let names = copies("fnname", &src);
213
+ assert!(names.contains(&"List.map$c0".to_string()), "{names:?}");
214
+ let bytes = compile("fnname", &src);
215
+ noListCalls(&bytes);
216
+ assert_eq!(runIntBytes(&bytes), 12);
217
+ }
218
+
219
+ #[test]
220
+ fn escaping_closure_untouched() {
221
+ let src = withHead(
222
+ "record Box(cb: fn(Int) -> Int)\n\nfun keep(f: fn(Int) -> Int) -> Box = Box(cb: f)\n\nfun main() -> Int =\n b := keep(|x| x + 1)\n g := b.cb\n return g(41)\n",
223
+ );
224
+ assert!(copies("escaping", &src).is_empty());
225
+ assert_eq!(runInt("escaping", &src), 42);
226
+ }
227
+
228
+ #[test]
229
+ fn dedup_same_closure_twice() {
230
+ let src = withHead(
231
+ "fun main() -> Int =\n xs := List(1, 2, 3)\n a := xs.map(|x| x * 2).fold(0, |a, x| a + x)\n b := xs.map(|x| x * 2).fold(0, |a, x| a + x)\n return a + b\n",
232
+ );
233
+ let names = copies("dedup", &src);
234
+ assert_eq!(names.iter().filter(|n| n.starts_with("List.map$")).count(), 1, "{names:?}");
235
+ assert_eq!(runInt("dedup", &src), 24);
236
+ }
237
+
238
+ #[test]
239
+ fn cap_eight_copies() {
240
+ let mut body = String::from("fun main() -> Int =\n xs := List(1, 2, 3)\n total := 0\n");
241
+ for i in 0..10 {
242
+ body.push_str(&format!(" total = total + xs.map(|x| x + {}).fold(0, |a, x| a + x)\n", i));
243
+ }
244
+ body.push_str(" return total\n");
245
+ let src = withHead(&body);
246
+ let names = copies("cap8", &src);
247
+ assert_eq!(names.iter().filter(|n| n.starts_with("List.map$")).count(), 8, "{names:?}");
248
+ // sum over i of (6 + 3i) = 60 + 3 * 45
249
+ assert_eq!(runInt("cap8", &src), 195);
250
+ }
251
+
252
+ #[test]
253
+ fn results_equal_original() {
254
+ let src = withHead(
255
+ "fun main() -> Int =\n xs := List(1, 2, 3, 4, 5, 6, 7, 8)\n k := 3\n a := xs.map(|x| x * k).filter(|x| x % 2 == 0).fold(0, |a, x| a + x)\n b := xs.filter(|x| x > k).map(|x| x - 1).fold(100, |a, x| a - x)\n c := xs.fold(1, |a, x| if x > 4 then a + x else a)\n return a * 10000 + b * 100 + c\n",
256
+ );
257
+ // Same program through closures held in locals, which are not specialised.
258
+ let plain = withHead(
259
+ "fun main() -> Int =\n xs := List(1, 2, 3, 4, 5, 6, 7, 8)\n k := 3\n m1 := |x| x * k\n f1 := |x| x % 2 == 0\n s1 := |a, x| a + x\n a := xs.map(m1).filter(f1).fold(0, s1)\n f2 := |x| x > k\n m2 := |x| x - 1\n s2 := |a, x| a - x\n b := xs.filter(f2).map(m2).fold(100, s2)\n s3 := |a, x| if x > 4 then a + x else a\n c := xs.fold(1, s3)\n return a * 10000 + b * 100 + c\n",
260
+ );
261
+ assert!(!copies("equal", &src).is_empty());
262
+ assert_eq!(runInt("equal", &src), runInt("equal_plain", &plain));
263
+ }
264
+
265
+ #[test]
266
+ fn list_pipeline_million_timed() {
267
+ let src = withHead(
268
+ "fun build(n: Int) -> List[Int] =\n out: List[Int] := Nil\n i := n\n while i > 0\n out = Cons(head: i, tail: out)\n i = i - 1\n return out\n\nfun main() -> Int =\n xs := build(1000000)\n return xs.map(|x| x * 2).filter(|x| x % 4 == 0).fold(0, |a, x| a + x)\n",
269
+ );
270
+ let bytes = compile("million", &src);
271
+ let t = std::time::Instant::now();
272
+ let r = runIntBytes(&bytes);
273
+ eprintln!(
274
+ "list_pipeline_million_timed: {} ms (closure calls {:?})",
275
+ t.elapsed().as_millis(),
276
+ callerNames(&bytes)
277
+ );
278
+ assert_eq!(r, 500001000000);
279
+ // Reference: the same pipeline through closures held in locals (not specialised).
280
+ let plain = withHead(
281
+ "fun build(n: Int) -> List[Int] =\n out: List[Int] := Nil\n i := n\n while i > 0\n out = Cons(head: i, tail: out)\n i = i - 1\n return out\n\nfun main() -> Int =\n xs := build(1000000)\n m := |x| x * 2\n f := |x| x % 4 == 0\n s := |a, x| a + x\n return xs.map(m).filter(f).fold(0, s)\n",
282
+ );
283
+ let buildOnly = withHead(
284
+ "fun build(n: Int) -> List[Int] =\n out: List[Int] := Nil\n i := n\n while i > 0\n out = Cons(head: i, tail: out)\n i = i - 1\n return out\n\nfun main() -> Int =\n xs := build(1000000)\n return xs.length()\n",
285
+ );
286
+ let bb = compile("million_build", &buildOnly);
287
+ let t = std::time::Instant::now();
288
+ runIntBytes(&bb);
289
+ eprintln!("list build + length only: {} ms", t.elapsed().as_millis());
290
+ for (label, expr) in [
291
+ ("map only", "xs.map(|x| x * 2).length()"),
292
+ ("filter only", "xs.filter(|x| x % 4 == 0).length()"),
293
+ ("fold only", "xs.fold(0, |a, x| a + x)"),
294
+ ] {
295
+ let one = withHead(&format!(
296
+ "fun build(n: Int) -> List[Int] =\n out: List[Int] := Nil\n i := n\n while i > 0\n out = Cons(head: i, tail: out)\n i = i - 1\n return out\n\nfun main() -> Int =\n xs := build(1000000)\n return {expr}\n"
297
+ ));
298
+ let b = compile("million_stage", &one);
299
+ let t = std::time::Instant::now();
300
+ runIntBytes(&b);
301
+ eprintln!(" stage {label} (incl. build): {} ms", t.elapsed().as_millis());
302
+ }
303
+ let pb = compile("million_plain", &plain);
304
+ let t = std::time::Instant::now();
305
+ assert_eq!(runIntBytes(&pb), 500001000000);
306
+ eprintln!("list_pipeline_million_timed (call_ref version, {} call_ref): {} ms", callRefs(&pb), t.elapsed().as_millis());
307
+ }
308
+
309
+ #[test]
310
+ fn copies_get_trmc_and_tail_marks() {
311
+ let src = withHead(
312
+ "fun main() -> Int =\n xs := List(1, 2, 3)\n return xs.map(|x| x * 2).fold(0, |a, x| a + x)\n",
313
+ );
314
+ let l = lowered("trmc_copy", &src);
315
+ let text = |n: &str| {
316
+ l.items
317
+ .iter()
318
+ .find_map(|i| match i {
319
+ plum_core::ast::Item::Fn(f) if f.name == n => Some(format!("{:?}", f.body)),
320
+ _ => None,
321
+ })
322
+ .unwrap_or_else(|| panic!("{n} missing"))
323
+ };
324
+ assert!(text("mapCells$c0").contains("__tc_acc"), "TRMC applies to the copy");
325
+ assert!(text("fold$c0").contains("tail: Other"), "tail call marked in the copy");
326
+ }
327
+
328
+ /// Copies of one callee (`name$cN`), whatever its receiver type.
329
+ fn copiesOf(all: &[String], name: &str) -> usize {
330
+ all.iter().filter(|n| n.rsplit('.').next().unwrap_or(n).starts_with(&format!("{name}$c"))).count()
331
+ }
332
+
333
+ #[test]
334
+ fn deep_recursive_closure_callee() {
335
+ // `loop` recurses 100,000 deep through a tail call; the closure travels through two
336
+ // levels of non-escaping params (`outer` -> `mid` -> `loop`) and is also used by
337
+ // `mid` itself.
338
+ let src = withHead(
339
+ "fun loop(n: Int, acc: Int, f: fn(Int) -> Int) -> Int =\n if n == 0 then acc else loop(n - 1, f(acc), f)\n\nfun mid(n: Int, f: fn(Int) -> Int) -> Int = loop(n, f(0), f)\n\nfun outer(n: Int, g: fn(Int) -> Int) -> Int = mid(n, g)\n\nfun main() -> Int =\n k := 2\n return outer(100000, |x| x + 1) + outer(50000, |x| x + k)\n",
340
+ );
341
+ let names = copies("deep", &src);
342
+ for callee in ["loop", "mid", "outer"] {
343
+ let n = copiesOf(&names, callee);
344
+ assert!(n >= 1 && n <= 8, "{callee}: {n} copies in {names:?}");
345
+ }
346
+ let bytes = compile("deep", &src);
347
+ // Same program through closures held in locals (not specialised).
348
+ let plain = withHead(
349
+ "fun loop(n: Int, acc: Int, f: fn(Int) -> Int) -> Int =\n if n == 0 then acc else loop(n - 1, f(acc), f)\n\nfun mid(n: Int, f: fn(Int) -> Int) -> Int = loop(n, f(0), f)\n\nfun outer(n: Int, g: fn(Int) -> Int) -> Int = mid(n, g)\n\nfun main() -> Int =\n k := 2\n a := |x| x + 1\n b := |x| x + k\n return outer(100000, a) + outer(50000, b)\n",
350
+ );
351
+ assert!(copies("deep_plain", &plain).is_empty());
352
+ let r = runIntBytes(&bytes);
353
+ assert_eq!(r, runInt("deep_plain", &plain));
354
+ assert_eq!(r, 100001 + 100002);
355
+ }
356
+
357
+ #[test]
358
+ fn many_call_sites_capped() {
359
+ // 20 distinct closures to one callee: exactly MAX_COPIES (8) copies, the rest left as
360
+ // ordinary closure calls, every result right.
361
+ let mut body = String::from("fun apply(x: Int, f: fn(Int) -> Int) -> Int = f(x)\n\nfun main() -> Int =\n total := 0\n");
362
+ for i in 0..20 {
363
+ body.push_str(&format!(" total = total + apply(5, |x| x * {} + {})\n", i + 1, i));
364
+ }
365
+ body.push_str(" return total\n");
366
+ let src = withHead(&body);
367
+ let names = copies("many20", &src);
368
+ assert_eq!(copiesOf(&names, "apply"), 8, "{names:?}");
369
+ // sum over i of (5 * (i + 1) + i) = 5 * 210 + 190
370
+ let bytes = compile("many20", &src);
371
+ assert!(callRefs(&bytes) > 0, "the 12 call sites past the cap still use closures");
372
+ assert_eq!(runIntBytes(&bytes), 1240);
373
+ }
374
+
375
+ #[test]
376
+ fn capture_read_after_mutation() {
377
+ // The closure reads `k` when it runs; `k` was reassigned before the call. A plain
378
+ // closure held in a local (not specialised) created at the same point must agree
379
+ // with the specialised call, and the value is the reassigned one.
380
+ let spec = withHead(
381
+ "fun main() -> Int =\n xs := List(1, 2, 3)\n k := 1\n k = 10\n a := xs.map(|x| x + k).fold(0, |a, x| a + x)\n k = 100\n b := xs.map(|x| x + k).fold(0, |a, x| a + x)\n return a * 1000 + b\n",
382
+ );
383
+ let plain = withHead(
384
+ "fun main() -> Int =\n xs := List(1, 2, 3)\n k := 1\n k = 10\n m := |x| x + k\n s := |a, x| a + x\n a := xs.map(m).fold(0, s)\n k = 100\n m2 := |x| x + k\n b := xs.map(m2).fold(0, s)\n return a * 1000 + b\n",
385
+ );
386
+ assert!(!copies("capmut", &spec).is_empty());
387
+ assert!(copies("capmut_plain", &plain).is_empty());
388
+ let r = runInt("capmut", &spec);
389
+ assert_eq!(r, runInt("capmut_plain", &plain));
390
+ assert_eq!(r, 36 * 1000 + 306);
391
+ }
392
+
393
+ #[test]
394
+ fn capture_reassigned_between_closure_and_call() {
395
+ // A closure local created BEFORE the reassignment is not a direct argument, so it is
396
+ // never specialised. Record what plain closures do (value at creation vs at call) and
397
+ // check the direct-argument (specialised) form sees the reassigned value.
398
+ let held = withHead(
399
+ "fun main() -> Int =\n xs := List(1, 2, 3)\n k := 1\n m := |x| x + k\n k = 10\n return xs.map(m).fold(0, |a, x| a + x)\n",
400
+ );
401
+ let direct = withHead(
402
+ "fun main() -> Int =\n xs := List(1, 2, 3)\n k := 1\n k = 10\n return xs.map(|x| x + k).fold(0, |a, x| a + x)\n",
403
+ );
404
+ let heldResult = runInt("held", &held);
405
+ assert_eq!(runInt("direct", &direct), 36);
406
+ eprintln!("closure created before reassignment gives {heldResult} (9 = captured by value, 36 = by reference)");
407
+ assert!(heldResult == 9 || heldResult == 36, "held closure gave {heldResult}");
408
+ }
plum-std/game/Actions.plum CHANGED
@@ -3,26 +3,28 @@ import std/Slice
3
3
  import std/Int
4
4
  import std/Float
5
5
 
6
- # scene2d-style actions as plain data plus a pure step function. An action is a
6
+ # scene2d-style actions as plain data plus a step function. `Actions.stepAction`
7
+ # steps an action IN PLACE on its own record (a running action allocates nothing:
7
- # value; `Actions.stepAction(pose, action, dt)` returns a NEW pose, the action
8
+ # the result is a struct, flattened into multi-value returns) and reports the new
8
- # that is left (null when finished) and the part of `dt` it did not use. Nothing
9
+ # pose, the part of `dt` it did not use and whether it finished. `stepAll` drops
9
- # here mutates its inputs. Sequences hand the spare time to the next step, so
10
+ # finished actions by compacting the Slice in place. Sequences hand the spare time
10
- # chained and looping animations do not drift.
11
+ # to the next step, so chained and looping animations do not drift. A `Forever`
12
+ # restarts a lap from a fresh copy of its template (one allocation per lap).
11
13
 
12
14
  # The animated part of an actor's transform.
13
15
  struct Pose(x: Float, y: Float, rotation: Float, scaleX: Float, scaleY: Float)
14
16
 
15
- struct MoveBy(dx: Float, dy: Float, duration: Float, elapsed: Float)
17
+ record MoveBy(dx: Float, dy: Float, duration: Float, elapsed: Float)
16
18
 
17
- struct RotateBy(radians: Float, duration: Float, elapsed: Float)
19
+ record RotateBy(radians: Float, duration: Float, elapsed: Float)
18
20
 
19
- struct Delay(duration: Float, elapsed: Float)
21
+ record Delay(duration: Float, elapsed: Float)
20
22
 
21
23
  record Sequence(steps: Slice[Action])
22
24
 
23
25
  record Parallel(steps: Slice[Action])
24
26
 
25
- # `template` is the action restarted each lap, `current` the lap in progress.
27
+ # `template` is the pristine action restarted each lap, `current` the lap in progress.
26
28
  record Forever(template: Action, current: Action)
27
29
 
28
30
  enum Action = MoveBy | RotateBy | Delay | Sequence | Parallel | Forever
@@ -31,12 +33,9 @@ enum Action = MoveBy | RotateBy | Delay | Sequence | Parallel | Forever
31
33
  # once and applies all of itself (no 0/0).
32
34
  struct Tick(used: Float, frac: Float, done: Bool)
33
35
 
34
- # The result of stepping one action.
36
+ # The result of one step: the pose, the unused part of dt (0 while running) and
37
+ # whether the action(s) finished. For `stepAll`, `done` means none are left.
35
- record Stepped(pose: Pose, left: Action?, spare: Float)
38
+ struct Step(pose: Pose, spare: Float, done: Bool)
36
-
37
- # The result of stepping actions side by side: the unfinished ones, and the
38
- # smallest spare time among the finished ones.
39
- record SteppedAll(pose: Pose, kept: Slice[Action], spare: Float)
40
39
 
41
40
  primitive Actions =
42
41
  fun moveBy(dx: Float, dy: Float, secs: Float) -> Action =
@@ -55,7 +54,25 @@ primitive Actions =
55
54
  Parallel(steps: steps)
56
55
 
57
56
  fun forever(a: Action) -> Action =
58
- Forever(template: a, current: a)
57
+ Forever(template: Actions.fresh(a), current: a)
58
+
59
+ # An unstarted copy of `a` (elapsed 0, all steps present).
60
+ fun fresh(a: Action) -> Action =
61
+ match a
62
+ MoveBy => MoveBy(dx: a.dx, dy: a.dy, duration: a.duration, elapsed: 0.0)
63
+ RotateBy => RotateBy(radians: a.radians, duration: a.duration, elapsed: 0.0)
64
+ Delay => Delay(duration: a.duration, elapsed: 0.0)
65
+ Sequence =>
66
+ out := Slice[Action]()
67
+ for i := range a.steps.length()
68
+ out.push(Actions.fresh(a.steps.at(i)))
69
+ Sequence(steps: out)
70
+ Parallel =>
71
+ out := Slice[Action]()
72
+ for i := range a.steps.length()
73
+ out.push(Actions.fresh(a.steps.at(i)))
74
+ Parallel(steps: out)
75
+ Forever => Forever(template: a.template, current: Actions.fresh(a.template))
59
76
 
60
77
  fun tick(duration: Float, elapsed: Float, dt: Float) -> Tick =
61
78
  if duration <= 0.0
@@ -63,78 +80,67 @@ primitive Actions =
63
80
  used := dt.min(duration - elapsed)
64
81
  Tick(used: used, frac: used / duration, done: elapsed + used >= duration)
65
82
 
66
- fun stepAction(p: Pose, a: Action, dt: Float) -> Stepped =
83
+ fun stepAction(p: Pose, a: Action, dt: Float) -> Step =
67
84
  match a
68
85
  MoveBy =>
69
86
  k := Actions.tick(a.duration, a.elapsed, dt)
87
+ a.elapsed = a.elapsed + k.used
70
88
  q := Pose(x: p.x + a.dx * k.frac, y: p.y + a.dy * k.frac, rotation: p.rotation, scaleX: p.scaleX, scaleY: p.scaleY)
71
- if k.done
72
- return Stepped(pose: q, left: null, spare: dt - k.used)
89
+ Step(pose: q, spare: if k.done then dt - k.used else 0.0, done: k.done)
73
- next: Action := MoveBy(dx: a.dx, dy: a.dy, duration: a.duration, elapsed: a.elapsed + k.used)
74
- Stepped(pose: q, left: next, spare: 0.0)
75
90
  RotateBy =>
76
91
  k := Actions.tick(a.duration, a.elapsed, dt)
92
+ a.elapsed = a.elapsed + k.used
77
93
  q := Pose(x: p.x, y: p.y, rotation: p.rotation + a.radians * k.frac, scaleX: p.scaleX, scaleY: p.scaleY)
78
- if k.done
79
- return Stepped(pose: q, left: null, spare: dt - k.used)
94
+ Step(pose: q, spare: if k.done then dt - k.used else 0.0, done: k.done)
80
- next: Action := RotateBy(radians: a.radians, duration: a.duration, elapsed: a.elapsed + k.used)
81
- Stepped(pose: q, left: next, spare: 0.0)
82
95
  Delay =>
83
96
  k := Actions.tick(a.duration, a.elapsed, dt)
84
- if k.done
97
+ a.elapsed = a.elapsed + k.used
85
- return Stepped(pose: p, left: null, spare: dt - k.used)
98
+ Step(pose: p, spare: if k.done then dt - k.used else 0.0, done: k.done)
86
- next: Action := Delay(duration: a.duration, elapsed: a.elapsed + k.used)
87
- Stepped(pose: p, left: next, spare: 0.0)
88
99
  Sequence =>
89
100
  if a.steps.length() == 0
90
- return Stepped(pose: p, left: null, spare: dt)
101
+ return Step(pose: p, spare: dt, done: true)
91
102
  s := Actions.stepAction(p, a.steps.at(0), dt)
92
- rest := a.steps.drop(1)
93
- if cur := s.left
103
+ if !s.done
94
- kept := Slice[Action]()
95
- kept.push(cur)
96
- for i := range rest.length()
97
- kept.push(rest.at(i))
98
- seq: Action := Sequence(steps: kept)
99
- return Stepped(pose: s.pose, left: seq, spare: 0.0)
104
+ return Step(pose: s.pose, spare: 0.0, done: false)
100
105
  # The rest starts in this same frame with the time left over; the list
101
106
  # is one shorter on every call, so this terminates.
102
- tail: Action := Sequence(steps: rest)
107
+ a.steps.removeAt(0)
103
- Actions.stepAction(s.pose, tail, s.spare)
108
+ Actions.stepAction(s.pose, a, s.spare)
104
109
  Parallel =>
105
- all := Actions.stepAll(p, a.steps, dt)
110
+ Actions.stepAll(p, a.steps, dt)
106
- if all.kept.length() == 0
107
- return Stepped(pose: all.pose, left: null, spare: all.spare)
108
- par: Action := Parallel(steps: all.kept)
109
- Stepped(pose: all.pose, left: par, spare: 0.0)
110
111
  Forever =>
111
112
  # Restarts at most once per step, with the spare time, so a zero-length
112
113
  # template cannot spin forever inside one step.
113
114
  s := Actions.stepAction(p, a.current, dt)
114
- if cur := s.left
115
+ if !s.done
115
- again: Action := Forever(template: a.template, current: cur)
116
- return Stepped(pose: s.pose, left: again, spare: 0.0)
116
+ return Step(pose: s.pose, spare: 0.0, done: false)
117
+ a.current = Actions.fresh(a.template)
117
- r := Actions.stepAction(s.pose, a.template, s.spare)
118
+ r := Actions.stepAction(s.pose, a.current, s.spare)
118
- lap := a.template
119
- if nxt := r.left
119
+ if r.done
120
- lap = nxt
121
- again: Action := Forever(template: a.template, current: lap)
120
+ a.current = Actions.fresh(a.template)
122
- Stepped(pose: r.pose, left: again, spare: 0.0)
121
+ Step(pose: r.pose, spare: 0.0, done: false)
122
+
123
-
123
+ # Steps every action side by side in place and drops the finished ones by
124
+ # compacting `acts`. `spare` is the smallest spare time among the finished
125
+ # ones, 0 while any action is left.
124
- fun stepAll(p: Pose, acts: Slice[Action], dt: Float) -> SteppedAll =
126
+ fun stepAll(p: Pose, acts: Slice[Action], dt: Float) -> Step =
125
127
  pose := p
126
- kept := Slice[Action]()
127
128
  spare := dt
129
+ w := 0
128
130
  for i := range acts.length()
129
131
  s := Actions.stepAction(pose, acts.at(i), dt)
130
132
  pose = s.pose
133
+ if !s.done
131
- if cur := s.left
134
+ if w != i
132
- kept.push(cur)
135
+ acts.putAt(w, acts.takeAt(i))
136
+ w = w + 1
133
137
  else
134
138
  spare = spare.min(s.spare)
135
- if kept.length() > 0
139
+ while acts.length() > w
140
+ acts.removeAt(acts.length() - 1)
141
+ if w > 0
136
142
  spare = 0.0
137
- SteppedAll(pose: pose, kept: kept, spare: spare)
143
+ Step(pose: pose, spare: spare, done: w == 0)
138
144
 
139
145
  fun actNear(a: Float, b: Float) -> Bool =
140
146
  {a - b}.abs() < 0.000001
@@ -144,32 +150,173 @@ fun actSeqLen(a: Action) -> Int =
144
150
  Sequence => a.steps.length()
145
151
  _ => 0 - 1
146
152
 
153
+ fun actElapsed(a: Action) -> Float =
154
+ match a
155
+ MoveBy => a.elapsed
156
+ _ => 0.0 - 1.0
157
+
147
158
  fun actPose0() -> Pose =
148
159
  Pose(x: 0.0, y: 0.0, rotation: 0.0, scaleX: 1.0, scaleY: 1.0)
149
160
 
161
+ # Golden values for actions, taken from the previous (pure, allocating) implementation
162
+ # (`git show e04834f:plum-std/game/Actions.plum`) run on the same inputs: pose, spare
163
+ # time and done flag at fixed checkpoints, stepping a fresh action with dt 0.016 x N
164
+ # and some large dts that span several sub-actions.
165
+ record GoldRun(a: Action, p: Pose, spare: Float, done: Bool)
166
+
167
+ fun goldRun(a: Action) -> GoldRun =
168
+ GoldRun(a: a, p: actPose0(), spare: 0.0, done: false)
169
+
170
+ fun goldAdv(r: GoldRun, n: Int, dt: Float) =
171
+ for i := range n
172
+ if !r.done
173
+ s := Actions.stepAction(r.p, r.a, dt)
174
+ r.p = s.pose
175
+ r.spare = s.spare
176
+ r.done = s.done
177
+
178
+ fun goldNear(a: Float, b: Float) -> Bool =
179
+ {a - b}.abs() < 0.00001
180
+
181
+ fun goldWant(r: GoldRun, x: Float, y: Float, rotation: Float, spare: Float, done: Bool) =
182
+ assert goldNear(r.p.x, x)
183
+ assert goldNear(r.p.y, y)
184
+ assert goldNear(r.p.rotation, rotation)
185
+ assert goldNear(r.spare, spare)
186
+ assert r.done == done
187
+
188
+ fun goldMove() =
189
+ r := goldRun(Actions.moveBy(10.0, 5.0, 1.0))
190
+ goldAdv(r, 1, 0.016)
191
+ goldWant(r, 0.16, 0.08, 0.0, 0.0, false)
192
+ goldAdv(r, 29, 0.016)
193
+ goldWant(r, 4.8, 2.4, 0.0, 0.0, false)
194
+ goldAdv(r, 32, 0.016)
195
+ goldWant(r, 9.92, 4.96, 0.0, 0.0, false)
196
+ goldAdv(r, 1, 0.016)
197
+ goldWant(r, 10.0, 5.0, 0.0, 0.008, true)
198
+
199
+ fun goldRotate() =
200
+ r := goldRun(Actions.rotateBy(2.0, 0.5))
201
+ goldAdv(r, 10, 0.016)
202
+ goldWant(r, 0.0, 0.0, 0.64, 0.0, false)
203
+ goldAdv(r, 21, 0.016)
204
+ goldWant(r, 0.0, 0.0, 1.984, 0.0, false)
205
+ goldAdv(r, 1, 0.016)
206
+ goldWant(r, 0.0, 0.0, 2.0, 0.012, true)
207
+
208
+ fun goldDelay() =
209
+ r := goldRun(Actions.delay(0.1))
210
+ goldAdv(r, 6, 0.016)
211
+ goldWant(r, 0.0, 0.0, 0.0, 0.0, false)
212
+ goldAdv(r, 1, 0.016)
213
+ goldWant(r, 0.0, 0.0, 0.0, 0.012, true)
214
+
215
+ fun goldSequence() =
216
+ r := goldRun(Actions.sequence(Slice(Actions.moveBy(10.0, 0.0, 0.2), Actions.delay(0.1), Actions.rotateBy(1.0, 0.2))))
217
+ goldAdv(r, 12, 0.016)
218
+ goldWant(r, 9.6, 0.0, 0.0, 0.0, false)
219
+ goldAdv(r, 1, 0.016)
220
+ goldWant(r, 10.0, 0.0, 0.0, 0.0, false)
221
+ goldAdv(r, 6, 0.016)
222
+ goldWant(r, 10.0, 0.0, 0.02, 0.0, false)
223
+ goldAdv(r, 1, 0.016)
224
+ goldWant(r, 10.0, 0.0, 0.1, 0.0, false)
225
+ goldAdv(r, 5, 0.016)
226
+ goldWant(r, 10.0, 0.0, 0.5, 0.0, false)
227
+ goldAdv(r, 10, 0.016)
228
+ goldWant(r, 10.0, 0.0, 1.0, 0.012, true)
229
+ goldAdv(r, 5, 0.016)
230
+ goldWant(r, 10.0, 0.0, 1.0, 0.012, true)
231
+
232
+ fun goldParallel() =
233
+ r := goldRun(Actions.parallel(Slice(Actions.moveBy(10.0, 0.0, 0.3), Actions.rotateBy(1.0, 0.1), Actions.delay(0.2))))
234
+ goldAdv(r, 5, 0.016)
235
+ goldWant(r, 2.666667, 0.0, 0.8, 0.0, false)
236
+ goldAdv(r, 2, 0.016)
237
+ goldWant(r, 3.733333, 0.0, 1.0, 0.0, false)
238
+ goldAdv(r, 5, 0.016)
239
+ goldWant(r, 6.4, 0.0, 1.0, 0.0, false)
240
+ goldAdv(r, 7, 0.016)
241
+ goldWant(r, 10.0, 0.0, 1.0, 0.004, true)
242
+ goldAdv(r, 1, 0.016)
243
+ goldWant(r, 10.0, 0.0, 1.0, 0.004, true)
244
+
245
+ fun goldForever() =
246
+ r := goldRun(Actions.forever(Actions.sequence(Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.delay(0.05)))))
247
+ goldAdv(r, 9, 0.016)
248
+ goldWant(r, 1.0, 0.0, 0.0, 0.0, false)
249
+ goldAdv(r, 91, 0.016)
250
+ goldWant(r, 11.0, 0.0, 0.0, 0.0, false)
251
+ goldAdv(r, 900, 0.016)
252
+ goldWant(r, 107.0, 0.0, 0.0, 0.0, false)
253
+
254
+ fun goldNested() =
255
+ inner := Actions.parallel(Slice(Actions.moveBy(4.0, 0.0, 0.1), Actions.delay(0.2)))
256
+ loop := Actions.forever(Actions.parallel(Slice(Actions.rotateBy(1.0, 0.1), Actions.moveBy(0.0, 1.0, 0.3))))
257
+ r := goldRun(Actions.sequence(Slice(inner, loop)))
258
+ goldAdv(r, 6, 0.016)
259
+ goldWant(r, 3.84, 0.0, 0.0, 0.0, false)
260
+ goldAdv(r, 7, 0.016)
261
+ goldWant(r, 4.0, 0.026667, 0.08, 0.0, false)
262
+ goldAdv(r, 20, 0.016)
263
+ goldWant(r, 4.0, 1.093333, 1.28, 0.0, false)
264
+ goldAdv(r, 200, 0.016)
265
+ goldWant(r, 4.0, 11.76, 12.0, 0.0, false)
266
+
267
+ fun goldLargeDt() =
268
+ r := goldRun(Actions.sequence(Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.moveBy(0.0, 1.0, 0.1), Actions.rotateBy(1.0, 0.1), Actions.delay(0.1))))
269
+ goldAdv(r, 1, 0.25)
270
+ goldWant(r, 1.0, 1.0, 0.5, 0.0, false)
271
+ goldAdv(r, 1, 0.05)
272
+ goldWant(r, 1.0, 1.0, 1.0, 0.0, false)
273
+ goldAdv(r, 1, 0.5)
274
+ goldWant(r, 1.0, 1.0, 1.0, 0.4, true)
275
+
276
+ fun goldLargeDtParallel() =
277
+ r := goldRun(Actions.parallel(Slice(Actions.moveBy(2.0, 0.0, 0.1), Actions.sequence(Slice(Actions.delay(0.1), Actions.rotateBy(1.0, 0.1))), Actions.delay(0.3))))
278
+ goldAdv(r, 1, 0.15)
279
+ goldWant(r, 2.0, 0.0, 0.5, 0.0, false)
280
+ goldAdv(r, 1, 0.1)
281
+ goldWant(r, 2.0, 0.0, 1.0, 0.0, false)
282
+ goldAdv(r, 1, 0.4)
283
+ goldWant(r, 2.0, 0.0, 1.0, 0.35, true)
284
+
285
+ fun goldLargeDtForever() =
286
+ r := goldRun(Actions.forever(Actions.sequence(Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.rotateBy(1.0, 0.1)))))
287
+ goldAdv(r, 1, 0.25)
288
+ goldWant(r, 1.5, 0.0, 1.0, 0.0, false)
289
+ goldAdv(r, 1, 0.5)
290
+ goldWant(r, 3.0, 0.0, 3.0, 0.0, false)
291
+ goldAdv(r, 1, 0.016)
292
+ goldWant(r, 3.16, 0.0, 3.0, 0.0, false)
293
+
150
294
  test "stepAll moves partway and keeps the action"
151
- r := Actions.stepAll(actPose0(), Slice(Actions.moveBy(10.0, 0.0, 1.0)), 0.5)
295
+ acts := Slice(Actions.moveBy(10.0, 0.0, 1.0))
296
+ r := Actions.stepAll(actPose0(), acts, 0.5)
152
297
  assert actNear(r.pose.x, 5.0)
153
- assert r.kept.length() == 1
298
+ assert acts.length() == 1
154
299
  assert actNear(r.spare, 0.0)
155
300
 
156
301
  test "stepAll finishes every action and reports no spare"
157
- r := Actions.stepAll(actPose0(), Slice(Actions.moveBy(10.0, 0.0, 1.0), Actions.rotateBy(1.0, 0.5)), 1.0)
302
+ acts := Slice(Actions.moveBy(10.0, 0.0, 1.0), Actions.rotateBy(1.0, 0.5))
303
+ r := Actions.stepAll(actPose0(), acts, 1.0)
158
304
  assert actNear(r.pose.x, 10.0)
159
305
  assert actNear(r.pose.rotation, 1.0)
160
- assert r.kept.length() == 0
306
+ assert acts.length() == 0
161
307
  assert actNear(r.spare, 0.0)
162
308
 
163
309
  test "stepAll with no actions is a no-op"
310
+ acts := Slice[Action]()
164
- r := Actions.stepAll(actPose0(), Slice[Action](), 0.016)
311
+ r := Actions.stepAll(actPose0(), acts, 0.016)
165
- assert r.kept.length() == 0
312
+ assert acts.length() == 0
166
313
  assert actNear(r.spare, 0.016)
167
314
  assert actNear(r.pose.x, 0.0)
168
315
 
169
316
  test "zero_length_move_applies_at_once"
170
317
  r := Actions.stepAction(actPose0(), Actions.moveBy(10.0, 0.0, 0.0), 0.016)
171
318
  assert actNear(r.pose.x, 10.0)
172
- assert r.left == null
319
+ assert r.done
173
320
  assert actNear(r.spare, 0.016)
174
321
 
175
322
  test "forever_rotate_no_drift"
@@ -178,9 +325,7 @@ test "forever_rotate_no_drift"
178
325
  for i := range 625
179
326
  r := Actions.stepAction(p, a, 0.016)
180
327
  p = r.pose
181
- assert r.left != null
328
+ assert !r.done
182
- if nxt := r.left
183
- a = nxt
184
329
  assert actNear(p.rotation, 10.0)
185
330
 
186
331
  test "sequence_spills_spare_time"
@@ -189,24 +334,22 @@ test "sequence_spills_spare_time"
189
334
  assert actNear(r.pose.x, 10.0)
190
335
  assert actNear(r.pose.y, 5.0)
191
336
  assert actNear(r.spare, 0.0)
192
- if nxt := r.left
337
+ assert !r.done
193
- assert actSeqLen(nxt) == 1
338
+ assert actSeqLen(a) == 1
194
- else
195
- assert false
196
339
 
197
340
  test "empty_sequence_finishes_with_full_spare"
198
341
  r := Actions.stepAction(actPose0(), Actions.sequence(Slice[Action]()), 0.3)
199
- assert r.left == null
342
+ assert r.done
200
343
  assert actNear(r.spare, 0.3)
201
344
 
202
345
  test "parallel_spare_is_min_of_finished"
203
346
  r := Actions.stepAction(actPose0(), Actions.parallel(Slice(Actions.delay(0.2), Actions.delay(0.5))), 1.0)
204
- assert r.left == null
347
+ assert r.done
205
348
  assert actNear(r.spare, 0.5)
206
349
 
207
350
  test "forever_zero_length_template_terminates"
208
351
  r := Actions.stepAction(actPose0(), Actions.forever(Actions.delay(0.0)), 1.0)
209
- assert r.left != null
352
+ assert !r.done
210
353
 
211
354
  test "forever of a nested sequence keeps looping"
212
355
  a := Actions.forever(Actions.sequence(Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.delay(0.1))))
@@ -214,12 +357,39 @@ test "forever of a nested sequence keeps looping"
214
357
  for i := range 100
215
358
  r := Actions.stepAction(p, a, 0.05)
216
359
  p = r.pose
217
- if nxt := r.left
218
- a = nxt
219
360
  assert actNear(p.x, 25.0)
220
361
 
221
- test "step_does_not_change_input"
362
+ test "step_moves_the_action_in_place"
222
- m := MoveBy(dx: 10.0, dy: 0.0, duration: 1.0, elapsed: 0.0)
363
+ a := Actions.moveBy(10.0, 0.0, 1.0)
223
- r := Actions.stepAction(actPose0(), m, 0.5)
364
+ r := Actions.stepAction(actPose0(), a, 0.5)
224
- assert actNear(m.elapsed, 0.0)
225
365
  assert actNear(r.pose.x, 5.0)
366
+ assert actElapsed(a) == 0.5
367
+
368
+ # Steady state: the held MoveBy and RotateBy are the very records stepped (their
369
+ # elapsed moves, no replacement lists or actions), and the slice keeps its length.
370
+ # The wasm of this path has no struct.new (checked with wasm-tools print).
371
+ test "step_all_no_alloc_steady_state"
372
+ m := Actions.moveBy(100.0, 0.0, 100.0)
373
+ r := Actions.rotateBy(1.0, 100.0)
374
+ acts := Slice(m, r)
375
+ p := actPose0()
376
+ for i := range 10
377
+ s := Actions.stepAll(p, acts, 0.5)
378
+ p = s.pose
379
+ assert !s.done
380
+ assert acts.length() == 2
381
+ assert actNear(actElapsed(m), 5.0)
382
+ assert actNear(p.x, 5.0)
383
+ assert actNear(p.rotation, 0.05)
384
+
385
+ test "actions_golden_values"
386
+ goldMove()
387
+ goldRotate()
388
+ goldDelay()
389
+ goldSequence()
390
+ goldParallel()
391
+ goldForever()
392
+ goldNested()
393
+ goldLargeDt()
394
+ goldLargeDtParallel()
395
+ goldLargeDtForever()
plum-std/game/PureScene.plum CHANGED
@@ -173,9 +173,11 @@ primitive PureScene =
173
173
  m := parent * PureScene.localMatrix(a)
174
174
  t := pureMulColor(tint, a.color)
175
175
  a.draw(m, t)
176
+ match a
176
- if g := PureScene.childrenOf(a)
177
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, g) =>
177
- for i := range g.length()
178
+ for i := range g.length()
178
- PureScene.drawTree(g.at(i), m, t)
179
+ PureScene.drawTree(g.at(i), m, t)
180
+ _ => 0
179
181
 
180
182
  # The children of a group, or null for a leaf.
181
183
  fun childrenOf(a: Actor) -> Slice[Actor]? =
plum-std/game/SceneTree.plum CHANGED
@@ -27,9 +27,10 @@ primitive SceneTree =
27
27
  b.rotation = r.pose.rotation
28
28
  b.scaleX = r.pose.scaleX
29
29
  b.scaleY = r.pose.scaleY
30
- b.actions = r.kept
30
+ match b
31
- if ks := b.kids()
31
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
32
- _ := ks.mapInPlace(|k| SceneTree.update(k, dt))
32
+ _ := ks.mapInPlace(|k| SceneTree.update(k, dt))
33
+ _ => 0
33
34
  b
34
35
 
35
36
  # The path (child indexes from `a`) to the topmost actor under `p`, which is
@@ -44,13 +45,15 @@ primitive SceneTree =
44
45
  lp := Vec2(x: 0.0, y: 0.0)
45
46
  if q := inv
46
47
  lp = q.apply(p)
47
- if ks := a.kids()
48
+ match a
49
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
48
- i := ks.length() - 1
50
+ i := ks.length() - 1
49
- while i >= 0
51
+ while i >= 0
50
- sub := SceneTree.findHit(ks.at(i), m, p)
52
+ sub := SceneTree.findHit(ks.at(i), m, p)
51
- if s := sub
53
+ if s := sub
52
- return treePrepend(i, s)
54
+ return treePrepend(i, s)
53
- i = i - 1
55
+ i = i - 1
56
+ _ => 0
54
57
  if a.hit(lp)
55
58
  return Slice[Int]()
56
59
  null
@@ -59,21 +62,25 @@ primitive SceneTree =
59
62
  fun idAt(a: Actor, path: Slice[Int], depth: Int) -> Int =
60
63
  if depth >= path.length()
61
64
  return a.id
62
- if ks := a.kids()
65
+ match a
66
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
63
- i := path.at(depth)
67
+ i := path.at(depth)
64
- if i >= 0 && i < ks.length()
68
+ if i >= 0 && i < ks.length()
65
- return SceneTree.idAt(ks.at(i), path, depth + 1)
69
+ return SceneTree.idAt(ks.at(i), path, depth + 1)
70
+ _ => 0
66
71
  -1
67
72
 
68
73
  # The path to the first actor with `id`, depth first; null if none.
69
74
  fun findPath(a: Actor, id: Int) -> Slice[Int]? =
70
75
  if a.id == id
71
76
  return Slice[Int]()
72
- if ks := a.kids()
77
+ match a
78
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
73
- for i := range ks.length()
79
+ for i := range ks.length()
74
- sub := SceneTree.findPath(ks.at(i), id)
80
+ sub := SceneTree.findPath(ks.at(i), id)
75
- if s := sub
81
+ if s := sub
76
- return treePrepend(i, s)
82
+ return treePrepend(i, s)
83
+ _ => 0
77
84
  null
78
85
 
79
86
  # The tree where the first actor with `id` is replaced by `f` of it.
@@ -86,11 +93,13 @@ primitive SceneTree =
86
93
  fun modifyAt(own a: Actor, path: Slice[Int], depth: Int, f: fn(Actor) -> Actor) -> Actor =
87
94
  if depth >= path.length()
88
95
  return f(a)
89
- if ks := a.kids()
96
+ match a
97
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
90
- i := path.at(depth)
98
+ i := path.at(depth)
91
- if i >= 0 && i < ks.length()
99
+ if i >= 0 && i < ks.length()
92
- c := ks.takeAt(i)
100
+ c := ks.takeAt(i)
93
- ks.putAt(i, SceneTree.modifyAt(c, path, depth + 1, f))
101
+ ks.putAt(i, SceneTree.modifyAt(c, path, depth + 1, f))
102
+ _ => 0
94
103
  a
95
104
 
96
105
  # Delivers `e` (in the coordinates of `a`'s parent) along `path`: the child on
@@ -109,11 +118,13 @@ primitive SceneTree =
109
118
  lp = q.apply(lp)
110
119
  local := Pointer(kind: e.kind, x: lp.x, y: lp.y, over: e.over)
111
120
  if depth < path.length()
112
- if ks := a.kids()
121
+ match a
122
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
113
- i := path.at(depth)
123
+ i := path.at(depth)
114
- if i >= 0 && i < ks.length()
124
+ if i >= 0 && i < ks.length()
115
- c := ks.takeAt(i)
125
+ c := ks.takeAt(i)
116
- ks.putAt(i, SceneTree.routeInto(c, path, depth + 1, local, events))
126
+ ks.putAt(i, SceneTree.routeInto(c, path, depth + 1, local, events))
127
+ _ => 0
117
128
  h := a.onPointer(local)
118
129
  for j := range h.events.length()
119
130
  events.push(h.events.at(j))
@@ -161,15 +172,17 @@ fun ttMovingTree() -> Actor =
161
172
  me
162
173
 
163
174
  fun ttFirstKidX(a: Actor) -> Float =
164
- ks := a.kids()
165
- if k := ks
175
+ match a
176
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, k) =>
166
- return k.at(0).x
177
+ return k.at(0).x
178
+ _ => 0
167
179
  0.0 - 1.0
168
180
 
169
181
  fun ttFirstKidActions(a: Actor) -> Int =
170
- ks := a.kids()
171
- if k := ks
182
+ match a
183
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, k) =>
172
- return k.at(0).actions.length()
184
+ return k.at(0).actions.length()
185
+ _ => 0
173
186
  0 - 1
174
187
 
175
188
  fun ttSetTouch(own t: Actor, id: Int, tc: Touchable) -> Actor =
@@ -188,9 +201,10 @@ fun ttSetX9(own a: Actor) -> Actor =
188
201
  a
189
202
 
190
203
  fun ttKidX(a: Actor, i: Int) -> Float =
191
- ks := a.kids()
192
- if k := ks
204
+ match a
205
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, k) =>
193
- return k.at(i).x
206
+ return k.at(i).x
207
+ _ => 0
194
208
  0.0 - 1.0
195
209
 
196
210
  fun ttButtonRoot() -> Actor =
@@ -205,6 +219,33 @@ fun ttZero() -> Slice[Int] =
205
219
  out.push(0)
206
220
  out
207
221
 
222
+ # Five actions on one sprite: the 1st, 3rd and 5th finish in the first 0.1s step,
223
+ # the 2nd (MoveBy) and 4th (RotateBy) keep running.
224
+ fun ttCompactTree() -> Actor =
225
+ s := PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0)
226
+ s.actions = Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.moveBy(0.0, 10.0, 10.0), Actions.rotateBy(1.0, 0.1), Actions.rotateBy(2.0, 10.0), Actions.delay(0.1))
227
+ kids := Slice[Actor]()
228
+ kids.push(s)
229
+ root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
230
+ me: Actor := root
231
+ me
232
+
233
+ fun ttKid(a: Actor) -> Actor =
234
+ match a
235
+ Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, k) =>
236
+ return k.at(0)
237
+ _ => 0
238
+ none: Actor := PureScene.sprite(0, 0, 0.0, 0.0, 0.0, 0.0)
239
+ none
240
+
241
+ # 1 MoveBy, 2 RotateBy, 3 Delay, 0 anything else.
242
+ fun ttActKind(a: Action) -> Int =
243
+ match a
244
+ MoveBy => 1
245
+ RotateBy => 2
246
+ Delay => 3
247
+ _ => 0
248
+
208
249
  test "find_hit_nested"
209
250
  r := SceneTree.findHit(ttFixture(), Affine.Identity, ttAt(21.0, 1.0))
210
251
  assert ttPathEq(r, ttInts(1, 0))
@@ -290,3 +331,23 @@ test "stale_path_reaches_group_only"
290
331
  nine.push(9)
291
332
  h := SceneTree.route(ttButtonRoot(), nine, 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
292
333
  assert h.events.length() == 0
334
+
335
+ test "compaction_keeps_order"
336
+ n := SceneTree.update(ttCompactTree(), 0.1)
337
+ acts := ttKid(n).actions
338
+ assert acts.length() == 2
339
+ assert ttActKind(acts.at(0)) == 1
340
+ assert ttActKind(acts.at(1)) == 2
341
+ assert {ttKid(n).x - 1.0}.abs() < 1e-6
342
+ assert {ttKid(n).y - 0.1}.abs() < 1e-6
343
+ assert {ttKid(n).rotation - 1.02}.abs() < 1e-6
344
+ # The next step advances the right survivors: MoveBy 0.1 -> 0.6, RotateBy 0.1 -> 0.6.
345
+ m := SceneTree.update(n, 0.5)
346
+ acts2 := ttKid(m).actions
347
+ assert acts2.length() == 2
348
+ assert ttActKind(acts2.at(0)) == 1
349
+ assert ttActKind(acts2.at(1)) == 2
350
+ assert actElapsed(acts2.at(0)) == 0.6
351
+ assert {ttKid(m).y - 0.6}.abs() < 1e-6
352
+ assert {ttKid(m).rotation - 1.12}.abs() < 1e-6
353
+ assert {ttKid(m).x - 1.0}.abs() < 1e-6