plum

#treesitter#compiler#wasm

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

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


529a106 — Peter John 2026-11-04T19:25:01+05:30
Compiler cleanup phase 2b: one front end, one compile entry, no Expr::Paren
Files changed (40) hide show
  1. docs/plans/2026-11-04-compiler-cleanup-phase2b.progress.md +28 -0
  2. docs/plans/2026-11-04-compiler-cleanup-phase2b.report.json +269 -0
  3. docs/plans/2026-11-04-compiler-cleanup-phase2b.report.md +38 -0
  4. docs/plans/2026-11-04-compiler-cleanup-phase2b.results.md +19 -0
  5. plum-checker/src/lib.rs +67 -7
  6. plum-checker/tests/checker_tests.rs +1 -1
  7. plum-cli/src/main.rs +29 -16
  8. plum-core/src/ast.rs +1 -3
  9. plum-core/src/builtin_usage.rs +0 -1
  10. plum-core/src/closure_escape.rs +1 -1
  11. plum-core/src/closure_spec.rs +1 -22
  12. plum-core/src/ctors.rs +0 -1
  13. plum-core/src/drive.rs +12 -13
  14. plum-core/src/lib.rs +2 -0
  15. plum-core/src/loader.rs +3 -27
  16. plum-core/src/lower.rs +40 -0
  17. plum-core/src/parser.rs +5 -5
  18. plum-core/src/purity.rs +1 -2
  19. plum-core/src/reuse.rs +0 -1
  20. plum-core/src/simplify.rs +2 -38
  21. plum-core/src/tail.rs +0 -1
  22. plum-core/src/tests_lower.rs +265 -0
  23. plum-core/src/trmc.rs +0 -3
  24. plum-core/src/union_lower.rs +2 -3
  25. plum-core/src/unions.rs +1 -1
  26. plum-core/src/uniqueness.rs +6 -8
  27. plum-core/src/visit.rs +28 -10
  28. plum-core/src/whistle.rs +0 -1
  29. plum-core/tests/inline_variants_test.rs +2 -2
  30. plum-core/tests/loader_test.rs +31 -0
  31. plum-core/tests/lower_test.rs +19 -0
  32. plum-core/tests/parser_test.rs +11 -3
  33. plum-core/tests/tests_lower_test.rs +39 -0
  34. plum-core/tests/uniqueness_test.rs +0 -1
  35. plum-core/tests/visit_test.rs +0 -6
  36. plum-game/tests/host_test.rs +1 -2
  37. plum-lsp/src/completion.rs +1 -1
  38. plum-lsp/src/main.rs +6 -6
  39. plum-wasm-codegen/src/lib.rs +71 -305
  40. plum-wasm-codegen/tests/codegen_tests.rs +7 -2
docs/plans/2026-11-04-compiler-cleanup-phase2b.progress.md ADDED
@@ -0,0 +1,28 @@
1
+ - start bb462a8697f7486294f44f87a74a2f915eea82ca
2
+ - unblocked: dispatched T0 T1 T2 T6 at 18:13:17; waiting on fixes: T3 T4 T5 T7 T8
3
+ - Ruling: T0 skipped - test-cmd.json copied from 2a (no stubs needed); tasks sent with the prime in one message to save a round trip - cost: none
4
+ - unblocked: dispatched T3 T4 T5 T7 T8 T9 T10 at 18:14:29; waiting on fixes: none
5
+ - Ruling: accept predicted 47 min - codegen lib.rs edits are sequential (tests lowering, compile entry, Paren) - cost: time
6
+ - checkpoint T6 8f258a247c last walker helpers into visit
7
+ - Ruling: closure_spec's private walkRef/walkBlockRef switch to visit in T7 (T7 owns plum-core/src/*.rs) - cost: none
8
+ - checkpoint T1 f5405c9ca3 front-end entry
9
+ - checkpoint T10 c61a9566d8 checker takes fusion options
10
+ - Ruling: T2 Owns narrowed to the files it changed (parser.rs, loader_test.rs, LSP main/completion) - the 'except' globs were not parsed by checkpoint - cost: none
11
+ - checkpoint T2 d634497c6d parseSource runs the front end
12
+ - checkpoint T2 f92fc40647 parseSource runs the front end
13
+ - checkpoint T1 4ca8c7e354 front-end entry
14
+ - Ruling: T3 checkpointed from its files (tests_lower_test 3/3 pass) - its agent hung on a background job for 30+ min without reporting - cost: none
15
+ - checkpoint T3 85f7a1dba9 tests lowering in plum-core
16
+ - checkpoint T9 a9fbb02c24 codegen uses core tests lowering
17
+ - Note: stale desugarTestsToFns doc comments at plum-cli/src/main.rs:477 (T5 fixes) and plum-core/src/ast.rs:387 (T7 fixes)
18
+ - checkpoint T4 13d786ee00 one compile entry
19
+ - checkpoint T5 8c24ab957f compile callers
20
+ - Ruling: CLI keeps one OnceLock for the fusion flags (set once from argv; game.rs calls compileToWasm without options) - the checker thread-local still goes in T7 - cost: one CLI-local global
21
+ - Ruling: compile's empty file name + CLI nameFileInErrors text rewrite stay until phase 3's positioned errors - cost: one string hack survives a phase
22
+ - Ruling: FUSION_OPTIONS thread-local stays (fusion_alloc_test, e2e_mem/reuse, trmc_test set it via withFusionOptions); removing it means moving those tests to compile(opts) - deferred to phase 3 state work - cost: one checker thread-local survives
23
+ - Ruling: T7 owns uniqueness_test/visit_test (unparen removal) and the 3 failing tests' files for the follow-up fix F-T7-1
24
+ - checkpoint T7 946052a832 remove Expr::Paren and deprecated wrappers
25
+ - checkpoint T7 76cdc81c69 remove Expr::Paren and deprecated wrappers
26
+ - tests full passed=802 failed=0 skipped=0 seconds=170.5
27
+ - checkpoint T8 b0a3f49179 verify 2b
28
+ - Ruling: final-review incident [1] closed - loader_test.rs joined T2's Owns (ae151f9), T2 checkpointed it (d634497) and F-T2-1 re-checkpointed it with the inline-variant case (f92fc40); the +31 lines are single_file_equals_loader - cost: none
docs/plans/2026-11-04-compiler-cleanup-phase2b.report.json ADDED
@@ -0,0 +1,269 @@
1
+ {
2
+ "projection_note": "projection: fixed forecast saved at lint 2026-11-04T18:13:08 (plan edited since)",
3
+ "headline": {
4
+ "wall_min": {
5
+ "projected": 45.935,
6
+ "actual": 70.79
7
+ },
8
+ "cost_usd_agents": {
9
+ "projected": 0.723599,
10
+ "actual": 2.656721
11
+ },
12
+ "task_min": {
13
+ "projected": 53.418,
14
+ "actual": 73.672
15
+ },
16
+ "agents": {
17
+ "projected": 8,
18
+ "actual": 9
19
+ }
20
+ },
21
+ "feature": null,
22
+ "cost_split": {
23
+ "agents_usd": 2.1839,
24
+ "rework_usd": 0.472821,
25
+ "coordinator_usd": 24.04081,
26
+ "total_usd": 26.697531
27
+ },
28
+ "concurrency": {
29
+ "peak": 3,
30
+ "projected_peak": 2
31
+ },
32
+ "critical_path": {
33
+ "actual": [
34
+ "T1",
35
+ "T3",
36
+ "T4",
37
+ "T5",
38
+ "T7",
39
+ "T8",
40
+ "T9"
41
+ ],
42
+ "projected": [
43
+ "T1",
44
+ "T3",
45
+ "T4",
46
+ "T5",
47
+ "T7",
48
+ "T8"
49
+ ],
50
+ "projected_min": 45.935
51
+ },
52
+ "model_mix": {
53
+ "sonnet": {
54
+ "tasks": 10,
55
+ "cost_usd": 2.656721
56
+ }
57
+ },
58
+ "coordinator_cost_by_model": {
59
+ "opus": 24.04081
60
+ },
61
+ "quality": {
62
+ "tests": {
63
+ "passed": 802,
64
+ "failed": 0,
65
+ "skipped": 0,
66
+ "seconds": 170.5,
67
+ "mode": "full",
68
+ "failures": [],
69
+ "skipped_tests": []
70
+ },
71
+ "first_time_pass": 0,
72
+ "first_time_n": 0,
73
+ "rework_dispatches": 2,
74
+ "open_incidents": 1
75
+ },
76
+ "tasks": [
77
+ {
78
+ "task": "T3",
79
+ "model": "sonnet",
80
+ "pred_min": 12.875,
81
+ "actual_min": 32.065,
82
+ "delta": {
83
+ "abs": 19.19,
84
+ "pct": 149.0
85
+ },
86
+ "cost_usd": 0.213797,
87
+ "first_time_pass": null
88
+ },
89
+ {
90
+ "task": "T4",
91
+ "model": "sonnet",
92
+ "pred_min": 8.859,
93
+ "actual_min": 2.787,
94
+ "delta": {
95
+ "abs": -6.0723,
96
+ "pct": -68.5
97
+ },
98
+ "cost_usd": 0.15933,
99
+ "first_time_pass": null
100
+ },
101
+ {
102
+ "task": "T8",
103
+ "model": "sonnet",
104
+ "pred_min": 5.973,
105
+ "actual_min": 10.852,
106
+ "delta": {
107
+ "abs": 4.8787,
108
+ "pct": 81.7
109
+ },
110
+ "cost_usd": 0.276194,
111
+ "first_time_pass": null
112
+ },
113
+ {
114
+ "task": "T5",
115
+ "model": "sonnet",
116
+ "pred_min": 6.6,
117
+ "actual_min": 3.933,
118
+ "delta": {
119
+ "abs": -2.6667,
120
+ "pct": -40.4
121
+ },
122
+ "cost_usd": 0.306469,
123
+ "first_time_pass": null
124
+ },
125
+ {
126
+ "task": "T2",
127
+ "model": "sonnet",
128
+ "pred_min": 4.4,
129
+ "actual_min": 6.353,
130
+ "delta": {
131
+ "abs": 1.9533,
132
+ "pct": 44.4
133
+ },
134
+ "cost_usd": 0.548189,
135
+ "first_time_pass": null
136
+ },
137
+ {
138
+ "task": "T7",
139
+ "model": "sonnet",
140
+ "pred_min": 7.918,
141
+ "actual_min": 6.223,
142
+ "delta": {
143
+ "abs": -1.6947,
144
+ "pct": -21.4
145
+ },
146
+ "cost_usd": 0.574766,
147
+ "first_time_pass": null
148
+ },
149
+ {
150
+ "task": "T6",
151
+ "model": "sonnet",
152
+ "pred_min": 3.083,
153
+ "actual_min": 1.62,
154
+ "delta": {
155
+ "abs": -1.463,
156
+ "pct": -47.5
157
+ },
158
+ "cost_usd": 0.108093,
159
+ "first_time_pass": null
160
+ },
161
+ {
162
+ "task": "T1",
163
+ "model": "sonnet",
164
+ "pred_min": 3.71,
165
+ "actual_min": 2.915,
166
+ "delta": {
167
+ "abs": -0.795,
168
+ "pct": -21.4
169
+ },
170
+ "cost_usd": 0.149792,
171
+ "first_time_pass": null
172
+ },
173
+ {
174
+ "task": "T10",
175
+ "model": "sonnet",
176
+ "pred_min": null,
177
+ "actual_min": 1.973,
178
+ "delta": {
179
+ "abs": null,
180
+ "pct": null
181
+ },
182
+ "cost_usd": 0.119462,
183
+ "first_time_pass": null
184
+ },
185
+ {
186
+ "task": "T9",
187
+ "model": "sonnet",
188
+ "pred_min": null,
189
+ "actual_min": 4.95,
190
+ "delta": {
191
+ "abs": null,
192
+ "pct": null
193
+ },
194
+ "cost_usd": 0.200629,
195
+ "first_time_pass": null
196
+ }
197
+ ],
198
+ "cost_table": {
199
+ "source": "est",
200
+ "usd": 2.7859,
201
+ "tokens": 9375859,
202
+ "wall_min": 89690001.3646,
203
+ "rows": [
204
+ {
205
+ "agent": "a2884f220f51ad6d6",
206
+ "model": "sonnet",
207
+ "wall_min": 5.2402,
208
+ "tokens": 615127,
209
+ "usd": 0.2049
210
+ },
211
+ {
212
+ "agent": "a2df9f2025916c02a",
213
+ "model": "sonnet",
214
+ "wall_min": 6.886,
215
+ "tokens": 850853,
216
+ "usd": 0.2679
217
+ },
218
+ {
219
+ "agent": "a3820f0f9eb086e58",
220
+ "model": "sonnet",
221
+ "wall_min": 53.1566,
222
+ "tokens": 5047272,
223
+ "usd": 1.1558
224
+ },
225
+ {
226
+ "agent": "a4d4e8f4797e03d68",
227
+ "model": "opus",
228
+ "wall_min": 0.2249,
229
+ "tokens": 35927,
230
+ "usd": 0.1291
231
+ },
232
+ {
233
+ "agent": "a6fae2e1ec0b23393",
234
+ "model": "sonnet",
235
+ "wall_min": 29896601.3284,
236
+ "tokens": 0,
237
+ "usd": 0.0
238
+ },
239
+ {
240
+ "agent": "a70bd6caeed71f519",
241
+ "model": "sonnet",
242
+ "wall_min": 70.8345,
243
+ "tokens": 2113838,
244
+ "usd": 0.6646
245
+ },
246
+ {
247
+ "agent": "a933bd66026369ed1",
248
+ "model": "sonnet",
249
+ "wall_min": 29896604.2455,
250
+ "tokens": 0,
251
+ "usd": 0.0
252
+ },
253
+ {
254
+ "agent": "ad1dfc0fc5bf84734",
255
+ "model": "sonnet",
256
+ "wall_min": 35.3493,
257
+ "tokens": 712842,
258
+ "usd": 0.3636
259
+ },
260
+ {
261
+ "agent": "af62a03b093783026",
262
+ "model": "sonnet",
263
+ "wall_min": 29896624.0992,
264
+ "tokens": 0,
265
+ "usd": 0.0
266
+ }
267
+ ]
268
+ }
269
+ }
docs/plans/2026-11-04-compiler-cleanup-phase2b.report.md ADDED
@@ -0,0 +1,38 @@
1
+ # superlite build report
2
+
3
+ projection: fixed forecast saved at lint 2026-11-04T18:13:08 (plan edited since)
4
+
5
+ Projected Actual Delta
6
+ wall-clock (min) 45.9 70.8 +24.9 (+54%)
7
+ agent cost (USD, +rework) 0.72 2.66 +1.93 (+267%)
8
+ task-minutes 53.4 73.7 +20.3 (+38%)
9
+ agents 8 9 +1 (+12%)
10
+ cost split: agents $2.18, rework $0.47, coordinator $24.04, total $26.70
11
+ concurrency: peak 3 at once (projected 2); critical path actual T1 T3 T4 T5 T7 T8 T9; projected T1 > T3 > T4 > T5 > T7 > T8 (45.9 min)
12
+ model mix: sonnet 10 tasks $2.66, coordinator opus $24.04
13
+ quality: tests 802 passed, 0 failed; first-time pass n/a; rework dispatches 2; open incidents 1
14
+
15
+ task model pred actual delta cost
16
+ T3 sonnet 12.9 32.1 +19.2 (+149%) $0.214
17
+ T4 sonnet 8.9 2.8 -6.1 (-68%) $0.159
18
+ T8 sonnet 6.0 10.9 +4.9 (+82%) $0.276
19
+ T5 sonnet 6.6 3.9 -2.7 (-40%) $0.306
20
+ T2 sonnet 4.4 6.4 +2.0 (+44%) $0.548
21
+ (+5 smaller misses; `superlite-cli report --json` lists all)
22
+
23
+ agent cost table:
24
+ | agent | model | wall_min | tokens | usd |
25
+ | --- | --- | --- | --- | --- |
26
+ | a2884f220f51ad6d6 | sonnet | 5.2402 | 615127 | 0.2049 |
27
+ | a2df9f2025916c02a | sonnet | 6.886 | 850853 | 0.2679 |
28
+ | a3820f0f9eb086e58 | sonnet | 53.1566 | 5047272 | 1.1558 |
29
+ | a4d4e8f4797e03d68 | opus | 0.2249 | 35927 | 0.1291 |
30
+ | a6fae2e1ec0b23393 | sonnet | 29896601.3284 | 0 | 0.0 |
31
+ | a70bd6caeed71f519 | sonnet | 70.8345 | 2113838 | 0.6646 |
32
+ | a933bd66026369ed1 | sonnet | 29896604.2455 | 0 | 0.0 |
33
+ | ad1dfc0fc5bf84734 | sonnet | 35.3493 | 712842 | 0.3636 |
34
+ | af62a03b093783026 | sonnet | 29896624.0992 | 0 | 0.0 |
35
+ | totals | | 89690001.3646 | 9375859 | 2.7859 |
36
+ cost source: est
37
+
38
+ takeaway: ran 54% slower than projected; cost 267% over; biggest miss T3
docs/plans/2026-11-04-compiler-cleanup-phase2b.results.md ADDED
@@ -0,0 +1,19 @@
1
+ # Phase 2b results
2
+
3
+ Start sha: bb462a8697f7486294f44f87a74a2f915eea82ca. Measured on the uncommitted worktree tree.
4
+
5
+ - Wasm hashes: all 25 plum-examples files compile, no FAIL, sha256 identical (diff of start-sha vs current lists empty).
6
+ - `plum test plum-std plum-examples`: 678 passed, 0 failed (63/63 files).
7
+ - plum-game host_test: 18 passed, 0 failed.
8
+ - `plum game plum-examples/games/imperative/game.plum -o DIR`: wrote index.html, plum.wasm, plum_bridge.js, preload.txt, assets.
9
+ - `grep -rn "Expr::Paren" plum-*/src`: empty.
10
+ - Full workspace test binaries: not run by this task (the coordinator ran `cargo test`).
11
+
12
+ | metric | start | now |
13
+ |---|---|---|
14
+ | Rust LOC (plum-*/**/*.rs, incl. plum-game) | 54538 | 54700 |
15
+ | .rs files | 126 | 130 |
16
+ | integration test files (plum-*/tests/*.rs) | 45 | 47 |
17
+ | `#[test]` fns | 830 | 840 |
18
+
19
+ New files: plum-core/src/lower.rs, tests_lower.rs, tests/lower_test.rs, tests/tests_lower_test.rs.
plum-checker/src/lib.rs CHANGED
@@ -925,7 +925,6 @@ pub fn unify(t1: &PlumType, t2: &PlumType, ctx: &CheckCtx) -> Result<(), String>
925
925
  /// or names one.
926
926
  fn exprIsVariant(expr: &ast::Expr, variant_name: &str) -> bool {
927
927
  match expr {
928
- ast::Expr::Paren(inner) => exprIsVariant(inner, variant_name),
929
928
  ast::Expr::TypeName(n) => n == variant_name,
930
929
  ast::Expr::FnCall(call) => call.name == variant_name,
931
930
  ast::Expr::ClassCall(call) => call.type_name == variant_name,
@@ -1267,6 +1266,11 @@ fn immutableFieldOwner(obj_ty: &PlumType, field: &str, ctx: &CheckCtx) -> Option
1267
1266
  }
1268
1267
 
1269
1268
  pub fn typedSource(source: &ast::Source) -> CheckResult<TypedInfo> {
1269
+ typedSourceWith(source, fusionOptions())
1270
+ }
1271
+
1272
+ /// Like `typedSource` with explicit fusion options (no thread-local read).
1273
+ pub fn typedSourceWith(source: &ast::Source, fusion: FusionOptions) -> CheckResult<TypedInfo> {
1270
1274
  let (mut result, mut info) = checkSourceFull(source);
1271
1275
  // Closure specialisation and fusion run on the checked program BEFORE union lowering
1272
1276
  // (`pre`: own generics applied, `for` loops lowered), so the passes never see the
@@ -1287,10 +1291,10 @@ pub fn typedSource(source: &ast::Source) -> CheckResult<TypedInfo> {
1287
1291
  }
1288
1292
  // Supercompiler stage: purity -> simplify -> drive -> uniqueness -> reuse.
1289
1293
  // `--no-fusion` skips it.
1290
- if fusionOptions().enabled {
1294
+ if fusion.enabled {
1291
1295
  let mut copy = pre.clone();
1292
1296
  let notes = fusionRewrite(&mut copy);
1293
- if fusionOptions().explain {
1297
+ if fusion.explain {
1294
1298
  for n in &notes {
1295
1299
  eprintln!("fusion: {n}");
1296
1300
  }
@@ -3047,7 +3051,7 @@ fn firstCallInExpr<'a>(expr: &'a ast::Expr, name: &str) -> Option<ClosureUse<'a>
3047
3051
  ast::Expr::Binary(b) => firstCallInExpr(&b.left, name).or_else(|| firstCallInExpr(&b.right, name)),
3048
3052
  ast::Expr::Bool(b) => firstCallInExpr(&b.left, name).or_else(|| firstCallInExpr(&b.right, name)),
3049
3053
  ast::Expr::Compare(c) => firstCallInExpr(&c.left, name).or_else(|| firstCallInExpr(&c.right, name)),
3050
- ast::Expr::Not(e) | ast::Expr::Paren(e) | ast::Expr::Try(e) => firstCallInExpr(e, name),
3054
+ ast::Expr::Not(e) | ast::Expr::Try(e) => firstCallInExpr(e, name),
3051
3055
  ast::Expr::Unary(u) => firstCallInExpr(&u.operand, name),
3052
3056
  ast::Expr::InlineIf(t) => firstCallInExpr(&t.condition, name)
3053
3057
  .or_else(|| firstCallInExpr(&t.then, name))
@@ -3611,7 +3615,7 @@ fn discardedCall(e: &ast::Expr) -> Option<&ast::FnCall> {
3611
3615
  ast::AttrKind::Field(_) => None,
3612
3616
  },
3613
3617
  ast::Expr::Bang(b) => discardedCall(&b.value),
3614
- ast::Expr::Try(inner) | ast::Expr::Paren(inner) => discardedCall(inner),
3618
+ ast::Expr::Try(inner) => discardedCall(inner),
3615
3619
  _ => None,
3616
3620
  }
3617
3621
  }
@@ -4367,7 +4371,7 @@ fn resolveClosureParamFromFieldUsage(
4367
4371
  scanExpr(&b.left, params, ctx, resolved);
4368
4372
  scanExpr(&b.right, params, ctx, resolved);
4369
4373
  }
4370
- ast::Expr::Not(inner) | ast::Expr::Paren(inner) => {
4374
+ ast::Expr::Not(inner) => {
4371
4375
  scanExpr(inner, params, ctx, resolved)
4372
4376
  }
4373
4377
  ast::Expr::Unary(u) => scanExpr(&u.operand, params, ctx, resolved),
@@ -5167,7 +5171,6 @@ fn inferExprInner(
5167
5171
  },
5168
5172
  },
5169
5173
  },
5170
- ast::Expr::Paren(inner) => inferExprWith(inner, env, ctx, successExpected(expected)),
5171
5174
  // `try expr`: the operand's error set must fit the enclosing fn's set.
5172
5175
  ast::Expr::Try(inner) => {
5173
5176
  if let (Some(star), true) = (env.get(STAR_KEY), env.contains_key(RETURN_KEY)) {
@@ -5730,3 +5733,60 @@ fn nameFormErrors(source: &ast::Source, errs: Vec<CheckError>) -> Vec<CheckError
5730
5733
  })
5731
5734
  .collect()
5732
5735
  }
5736
+
5737
+ #[cfg(test)]
5738
+ mod fusion_options_tests {
5739
+ use super::*;
5740
+ use plum_test_support::parse;
5741
+
5742
+ const SRC: &str = "\
5743
+ enum Cells =
5744
+ | Cons(head: Int, tail: Cells)
5745
+ | Nil
5746
+
5747
+ fun mapCells(xs: Cells) -> Cells = match xs
5748
+ Cons(h, t) => Cons(head: h * 2, tail: mapCells(t))
5749
+ Nil => Nil
5750
+
5751
+ fun sumL(xs: Cells) -> Int = match xs
5752
+ Cons(h, t) => h + sumL(t)
5753
+ Nil => 0
5754
+
5755
+ fun total(xs: Cells) -> Int = sumL(mapCells(xs))
5756
+
5757
+ fun count(n: Int) -> Int =
5758
+ s := 0
5759
+ for i := range n
5760
+ s = s + i
5761
+ s
5762
+ ";
5763
+
5764
+ fn fnNames(info: &TypedInfo) -> Vec<String> {
5765
+ info.lowered
5766
+ .as_ref()
5767
+ .unwrap()
5768
+ .items
5769
+ .iter()
5770
+ .filter_map(|i| match i {
5771
+ ast::Item::Fn(f) => Some(f.name.clone()),
5772
+ _ => None,
5773
+ })
5774
+ .collect()
5775
+ }
5776
+
5777
+ #[test]
5778
+ fn defaultOptionsMatchTypedSource() {
5779
+ let src = parse(SRC);
5780
+ let a = typedSource(&src).expect("checks");
5781
+ let b = typedSourceWith(&src, FusionOptions::default()).expect("checks");
5782
+ assert_eq!(fnNames(&a), fnNames(&b));
5783
+ assert!(fnNames(&b).iter().any(|n| n.contains("sumL$mapCells")));
5784
+ }
5785
+
5786
+ #[test]
5787
+ fn disabledHasNoFusedFns() {
5788
+ let src = parse(SRC);
5789
+ let info = typedSourceWith(&src, FusionOptions { enabled: false, explain: false }).expect("checks");
5790
+ assert!(!fnNames(&info).iter().any(|n| n.contains('$')), "{:?}", fnNames(&info));
5791
+ }
5792
+ }
plum-checker/tests/checker_tests.rs CHANGED
@@ -657,7 +657,7 @@ fn rejects_basic_expressions_and_calls() {
657
657
  r(&format!("{cat} fun breakCat(self) =\n self.nope = 1\n"), &["no field 'nope' on type 'Cat'"]),
658
658
  r("enum Shape =\n | Square(x: Int, y: Int)\n\nfun bad() -> Shape =\n Square(x: 1)\n", &["variant 'Square' missing field 'y'"]),
659
659
  r("enum Shape =\n | Square(x: Int, y: Int)\n\nfun bad() -> Shape =\n Square(x: 1, y: 2, z: 3)\n", &["unknown field 'z' on variant 'Square'"]),
660
- r("enum Shape =\n | Circle(radius: Int)\n | Square(radius: Int)\n\nfun bad() -> Int =\n r := Circle(radius: 5)\n return r.radius\n", &["field 'radius' is ambiguous across multiple variants of enum 'Shape'"]),
660
+ r("enum Shape =\n | Circle(radius: Int)\n | Square(radius: Int)\n\nfun bad() -> Int =\n r := Circle(radius: 5)\n return r.radius\n", &["no field 'radius' on type 'Shape'"]),
661
661
  r("enum Maybe =\n | Just(Int)\n | Nothing\n\nfun bad() -> Maybe =\n Just(\"x\")\n", &["variant 'Just' arg 0: type mismatch"]),
662
662
  r("enum Shape =\n | Rect(Float, Float)\n | Circle(Float)\n\nfun bad() -> Shape =\n Rect(1.0)\n", &["variant 'Rect': expected 2 arg(s), got 1"]),
663
663
  // enum discriminants
plum-cli/src/main.rs CHANGED
@@ -137,9 +137,22 @@ fn main() {
137
137
  }
138
138
  }
139
139
 
140
- /// `--no-fusion` / `--explain-fusion` set the checker's fusion options.
140
+ /// The `--no-fusion` / `--explain-fusion` flags of the running command, set once in `run`
141
+ /// and passed to every compile as explicit `CompileOptions`.
142
+ static FUSION: std::sync::OnceLock<plum_checker::FusionOptions> = std::sync::OnceLock::new();
143
+
141
144
  fn setFusion(explain: bool, no_fusion: bool) {
142
- plum_checker::setFusionOptions(plum_checker::FusionOptions { enabled: !no_fusion, explain });
145
+ let _ = FUSION.set(plum_checker::FusionOptions { enabled: !no_fusion, explain });
146
+ }
147
+
148
+ /// `compile` reports positioned type errors without a file name; name `file` in them
149
+ /// (the entry file, since positions refer to the merged source).
150
+ fn nameFileInErrors(msg: &str, file: &std::path::Path) -> String {
151
+ msg.replace("type error at line", &format!("type error in '{}' at line", file.display()))
152
+ }
153
+
154
+ fn compileOptions(tests: bool) -> plum_wasm_codegen::CompileOptions {
155
+ plum_wasm_codegen::CompileOptions { tests, fusion: FUSION.get().copied().unwrap_or_default() }
143
156
  }
144
157
 
145
158
  fn run() -> Result<()> {
@@ -223,16 +236,13 @@ fn cmdFormat(file: Option<std::path::PathBuf>, check: bool, use_stdin: bool) ->
223
236
  fn compileToWasm(file: &std::path::Path, lib_path: &std::path::Path) -> Result<Vec<u8>> {
224
237
  let ast = plum_core::loadAndMerge(file, lib_path).map_err(|e| anyhow::anyhow!("{e}"))?;
225
238
 
226
- // Type check once; codegen reuses the result.
227
- let typed = match plum_wasm_codegen::typeCheckIn(&ast, &file.display().to_string()) {
239
+ plum_wasm_codegen::compile(&ast, &compileOptions(false)).map(|o| o.wasm).map_err(|e| {
228
- Ok(t) => t,
229
- Err(messages) => {
230
- eprintln!("{messages}");
240
+ if e.starts_with("type error") {
241
+ eprintln!("{}", nameFileInErrors(&e, file));
231
242
  process::exit(1);
232
243
  }
244
+ anyhow::anyhow!("codegen error: {e}")
233
- };
245
+ })
234
-
235
- plum_wasm_codegen::compileTyped(&ast, typed).map_err(|e| anyhow::anyhow!("codegen error: {e}"))
236
246
  }
237
247
 
238
248
  fn compileToTestWasm(
@@ -248,11 +258,14 @@ fn compileToTestWasm(
248
258
  // continue to the rest of the directory instead of the whole run
249
259
  // dying on the first file with a type error. Checked once, on the
250
260
  // test-desugared source codegen then compiles.
251
- let prog = plum_wasm_codegen::desugarTests(&ast);
252
- let typed = plum_wasm_codegen::typeCheckIn(&prog.source, &file.display().to_string())
261
+ let out = plum_wasm_codegen::compile(&ast, &compileOptions(true)).map_err(|e| {
253
- .map_err(|m| anyhow::anyhow!(m))?;
262
+ if e.starts_with("type error") {
254
- let compiled = plum_wasm_codegen::compileTestTyped(&prog, typed)
263
+ anyhow::anyhow!(nameFileInErrors(&e, file))
264
+ } else {
255
- .map_err(|e| anyhow::anyhow!("codegen error: {e}"))?;
265
+ anyhow::anyhow!("codegen error: {e}")
266
+ }
267
+ })?;
268
+ let compiled = (out.wasm, out.test_names);
256
269
  // Debug aid: `PLUM_DUMP_WASM=out.wasm plum test file.plum` keeps the module.
257
270
  if let Some(path) = std::env::var_os("PLUM_DUMP_WASM") {
258
271
  let _ = fs::write(path, &compiled.0);
@@ -474,7 +487,7 @@ fn runTestFile(file: &std::path::Path, lib_path: &std::path::Path) -> Result<(us
474
487
  let export_name = format!("test#{name}");
475
488
  // Each test function returns a `Str` (empty = every `assert` inside it
476
489
  // passed; otherwise a newline-separated report, one "line L, column C:
477
- // ..." entry per failing `assert` — see `desugarTestsToFns`) rather
490
+ // ..." entry per failing `assert` — see `plum_core::tests_lower`) rather
478
491
  // than a plain `i64`, so this goes through the untyped call API
479
492
  // (`Val::AnyRef`, same GC-ref handling `hostImports`' own Str-returning
480
493
  // imports already use) instead of `get_typed_func`.
plum-core/src/ast.rs CHANGED
@@ -384,7 +384,7 @@ pub enum Stmt {
384
384
  /// Outside a `test` body, traps (hard failure) if `cond` is false. Inside
385
385
  /// one, desugars to a non-fatal check instead — records a failure report
386
386
  /// (with `Check`'s own source location/text) and keeps running rather
387
- /// than trapping — see `plum-wasm-codegen`'s `desugarTestsToFns`.
387
+ /// than trapping — see `plum_core::tests_lower`.
388
388
  Assert(Check),
389
389
  /// A surface `for` over an iterable or a for-form call, not yet lowered
390
390
  /// (see `forms.rs`); never seen by the checker or codegen.
@@ -577,8 +577,6 @@ pub enum Expr {
577
577
  Catch(Box<CatchExpr>),
578
578
  /// `error.Name` — an error value, of the singleton set `error{Name}`.
579
579
  ErrorValue(ErrorValue),
580
- /// `{expr}` — grouped/parenthesized expression
581
- Paren(Box<Expr>),
582
580
  String(StringExpr),
583
581
  Int(i64),
584
582
  Float(f64),
plum-core/src/builtin_usage.rs CHANGED
@@ -295,7 +295,6 @@ fn walkExpr(expr: &Expr, names: &mut Acc) {
295
295
  }
296
296
  }
297
297
  }
298
- Expr::Paren(e) => walkExpr(e, names),
299
298
  Expr::String(s) => {
300
299
  names.insert("Str".to_string());
301
300
  for part in &s.parts {
plum-core/src/closure_escape.rs CHANGED
@@ -216,7 +216,7 @@ impl<'a> Cx<'a> {
216
216
  Expr::Binary(b) => self.expr(&b.left, cl) || self.expr(&b.right, cl),
217
217
  Expr::Unary(u) => self.expr(&u.operand, cl),
218
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),
219
+ Expr::Not(x) | Expr::Try(x) => self.expr(x, cl),
220
220
  Expr::Compare(c) => self.expr(&c.left, cl) || self.expr(&c.right, cl),
221
221
  Expr::InlineIf(i) => {
222
222
  self.expr(&i.condition, cl) || self.expr(&i.then, cl) || self.expr(&i.else_, cl)
plum-core/src/closure_spec.rs CHANGED
@@ -12,7 +12,7 @@
12
12
  use crate::ast::*;
13
13
  use crate::closure_escape::nonEscapingFnParams;
14
14
  use crate::rename::{renameStmts, Name};
15
- use crate::visit::{anyExpr, countVar, stmtBlockRefs, stmtBlocks, stmtExprRefs, stmtExprs, visitBlock, visitExpr};
15
+ use crate::visit::{walkBlockRef, walkRef, countVar, stmtBlocks, stmtExprs, visitBlock, visitExpr};
16
16
  use std::collections::{HashMap, HashSet};
17
17
 
18
18
  pub const MAX_COPIES: usize = 8;
@@ -989,24 +989,3 @@ fn collectBinders(body: &mut FnBody, out: &mut HashSet<String>) {
989
989
  FnBody::Extern => {}
990
990
  }
991
991
  }
992
-
993
- // ---------- read-only traversal ----------
994
-
995
- fn walkBlockRef(b: &Block, f: &mut dyn FnMut(&Expr)) {
996
- for s in &b.stmts {
997
- for e in stmtExprRefs(s) {
998
- walkRef(e, f);
999
- }
1000
- for blk in stmtBlockRefs(s) {
1001
- walkBlockRef(blk, f);
1002
- }
1003
- }
1004
- }
1005
-
1006
- /// Pre-order over `e`, closure bodies and catch blocks included.
1007
- fn walkRef(e: &Expr, f: &mut dyn FnMut(&Expr)) {
1008
- anyExpr(e, &mut |x| {
1009
- f(x);
1010
- false
1011
- });
1012
- }
plum-core/src/ctors.rs CHANGED
@@ -68,7 +68,6 @@ pub(crate) fn inferType(e: &Expr) -> Option<Type> {
68
68
  Expr::Float(_) => Some(ty("Float")),
69
69
  Expr::BoolLit(_) => Some(ty("Bool")),
70
70
  Expr::String(_) => Some(ty("Str")),
71
- Expr::Paren(x) => inferType(x),
72
71
  Expr::Unary(u) => inferType(&u.operand),
73
72
  Expr::ClassCall(c) => Some(Type {
74
73
  name: c.type_name.clone(),
plum-core/src/drive.rs CHANGED
@@ -11,8 +11,9 @@
11
11
  use crate::ast::*;
12
12
  use crate::generics::isGenericName;
13
13
  use crate::rename::{renameStmts, substPT, substStmts, substType, Name};
14
+ use crate::simplify::simplifyBlock;
14
- use crate::simplify::{argRef, isTag, simplifyBlock, walkBlockRef, walkRef};
15
+ use crate::visit::{argRef, isTag, walkBlockRef, walkRef};
15
- use crate::visit::{childRefs, stmtBlockRefs, stmtBlocks, stmtExprRefs, unparen, visitBlock, visitExpr};
16
+ use crate::visit::{childRefs, stmtBlockRefs, stmtBlocks, stmtExprRefs, visitBlock, visitExpr};
16
17
  use crate::whistle::embeds;
17
18
  use std::collections::{HashMap, HashSet};
18
19
 
@@ -236,7 +237,6 @@ fn pathStmts(stmts: &[Stmt], name: &str) -> usize {
236
237
  fn cheap(e: &Expr) -> bool {
237
238
  match e {
238
239
  Expr::Var(_) | Expr::Int(_) | Expr::Float(_) | Expr::BoolLit(_) | Expr::TypeName(_) | Expr::Self_ => true,
239
- Expr::Paren(x) => cheap(x),
240
240
  Expr::Attribute(a) => matches!(a.attr, AttrKind::Field(_)) && cheap(&a.object),
241
241
  _ => false,
242
242
  }
@@ -319,7 +319,6 @@ impl Tcx {
319
319
 
320
320
  fn tail(&self, e: Expr) -> Vec<Stmt> {
321
321
  match e {
322
- Expr::Paren(i) => self.tail(*i),
323
322
  Expr::ClassCall(_) | Expr::TypeName(_) => self.pushed(e),
324
323
  Expr::Var(ref n) if self.ctors.contains(n) => self.pushed(e),
325
324
  Expr::InlineIf(i) => {
@@ -647,7 +646,7 @@ impl Driver {
647
646
  // ---------- resolving calls ----------
648
647
 
649
648
  fn calls(&self, e: &Expr) -> Vec<Call> {
650
- match unparen(e) {
649
+ match e {
651
650
  Expr::FnCall(c) if self.free.contains(&c.name) => {
652
651
  let f = &self.fns[&c.name];
653
652
  match positional(&c.args) {
@@ -674,7 +673,7 @@ impl Driver {
674
673
  };
675
674
  }
676
675
  let mut out = Vec::new();
677
- let recv = match unparen(&a.object) {
676
+ let recv = match &a.object {
678
677
  Expr::Var(n) => self.vtypes.get(n),
679
678
  Expr::Self_ => self.vtypes.get("self"),
680
679
  _ => None,
@@ -814,7 +813,7 @@ impl Driver {
814
813
 
815
814
  /// The enum (or record) a just-built value `e` belongs to, if `e` is a constructor.
816
815
  fn ctorType(&self, e: &Expr) -> Option<String> {
817
- match unparen(e) {
816
+ match e {
818
817
  Expr::ClassCall(c) if c.spread.is_none() => self.ctorOwner.get(&c.type_name).cloned().or(Some(c.type_name.clone())),
819
818
  Expr::FnCall(c) => self.ctorOwner.get(&c.name).cloned(),
820
819
  Expr::TypeName(n) | Expr::Var(n) => self.ctorOwner.get(n).cloned(),
@@ -1311,7 +1310,7 @@ impl Driver {
1311
1310
  for st in &blk.stmts {
1312
1311
  if let Stmt::Match(m) = st {
1313
1312
  if let [subject] = &m.subjects[..] {
1314
- let st = match unparen(subject) {
1313
+ let st = match subject {
1315
1314
  Expr::Var(n) => out.get(n).cloned(),
1316
1315
  Expr::Self_ => out.get("self").cloned(),
1317
1316
  _ => None,
@@ -1355,7 +1354,7 @@ impl Driver {
1355
1354
  /// when the receiver's type is known (`io.clear` is `Io.clear`), else every method of
1356
1355
  /// that name. Empty (an unknown callee) when there is none.
1357
1356
  fn methodKeys(&self, object: &Expr, name: &str) -> Vec<String> {
1358
- let recv = match unparen(object) {
1357
+ let recv = match object {
1359
1358
  Expr::Var(n) => self.vtypes.get(n),
1360
1359
  Expr::Self_ => self.vtypes.get("self"),
1361
1360
  _ => None,
@@ -1371,7 +1370,7 @@ impl Driver {
1371
1370
 
1372
1371
  /// The type of a variable, `self` or a field read of one, when known and concrete.
1373
1372
  fn exprType(&self, e: &Expr) -> Option<Type> {
1374
- let t = match unparen(e) {
1373
+ let t = match e {
1375
1374
  Expr::Var(n) => self.vtypes.get(n).cloned()?,
1376
1375
  Expr::Self_ => self.vtypes.get("self").cloned()?,
1377
1376
  Expr::Attribute(a) => {
@@ -1704,7 +1703,7 @@ impl Driver {
1704
1703
  /// `List(a, b, ..)` (the variadic `List.init`) as the cons cells it builds,
1705
1704
  /// `Cons(head: a, tail: ..Nil)`, so a consumer can pick its arms on it.
1706
1705
  fn listLit(&self, e: &Expr) -> Option<Expr> {
1707
- let Expr::FnCall(c) = unparen(e) else { return None };
1706
+ let Expr::FnCall(c) = e else { return None };
1708
1707
  if c.name != "List" || !self.ctors.contains("Cons") || !self.ctors.contains("Nil") {
1709
1708
  return None;
1710
1709
  }
@@ -1733,7 +1732,7 @@ impl Driver {
1733
1732
  walkBlockRef(&Block { stmts: stmts.to_vec() }, &mut |e| {
1734
1733
  for c in self.calls(e) {
1735
1734
  let Some(k) = self.subj(&c.f) else { continue };
1736
- let hit = matches!(c.args.get(k).map(unparen), Some(Expr::Var(n)) if n == p);
1735
+ let hit = matches!(c.args.get(k), Some(Expr::Var(n)) if n == p);
1737
1736
  if hit && (self.pure.contains(&c.key) || self.readOnly.contains(&c.key)) {
1738
1737
  good += 1;
1739
1738
  break;
@@ -1761,7 +1760,7 @@ impl Driver {
1761
1760
  /// `todo`, and every call is read-only (it may draw or stop, never mutate) or takes the
1762
1761
  /// value `p` directly.
1763
1762
  fn quiet(&self, stmts: &[Stmt], p: &str) -> bool {
1764
- let isP = |e: &Expr| matches!(unparen(e), Expr::Var(n) if n == p);
1763
+ let isP = |e: &Expr| matches!(e, Expr::Var(n) if n == p);
1765
1764
  let takesP = |args: &[Arg]| args.iter().any(|a| isP(argRef(a)));
1766
1765
  let mut ok = true;
1767
1766
  for s in stmts {
plum-core/src/lib.rs CHANGED
@@ -13,12 +13,14 @@ pub mod formatter;
13
13
  pub mod forms;
14
14
  pub mod generics;
15
15
  pub mod loader;
16
+ pub mod lower;
16
17
  pub mod parser;
17
18
  pub mod purity;
18
19
  pub mod rename;
19
20
  pub mod reuse;
20
21
  pub mod simplify;
21
22
  pub mod tail;
23
+ pub mod tests_lower;
22
24
  pub mod traits;
23
25
  pub mod trmc;
24
26
  pub mod union_lower;
plum-core/src/loader.rs CHANGED
@@ -81,23 +81,7 @@ pub fn loadAndMergeWithOverrides(
81
81
  primitives: prims,
82
82
  traits: Vec::new(),
83
83
  };
84
- let traitErrors = crate::traits::lowerTraits(&mut merged);
85
- if !traitErrors.is_empty() {
86
- return Err(traitErrors.join("\n"));
87
- }
88
- let ctorErrors = crate::ctors::lowerCtors(&mut merged);
89
- if !ctorErrors.is_empty() {
90
- return Err(ctorErrors.join("\n"));
91
- }
92
- let typeErrors = crate::unions::inlineVariantTypeErrors(&merged);
93
- if !typeErrors.is_empty() {
94
- return Err(typeErrors.join("\n"));
95
- }
96
- let memberErrors = crate::unions::resolveMembers(&mut merged);
97
- if !memberErrors.is_empty() {
98
- return Err(memberErrors.join("\n"));
99
- }
100
- crate::forms::prepareForms(&mut merged);
84
+ crate::lower::frontEnd(&mut merged)?;
101
85
  Ok(merged)
102
86
  }
103
87
 
@@ -540,17 +524,9 @@ fn mergeItems(
540
524
  names: &mut HashMap<String, PathBuf>,
541
525
  prims: &mut Vec<PrimitiveDecl>,
542
526
  ) -> Result<(), String> {
543
- // Removed forms (lone self-named variant).
544
- let legacy: Vec<String> = crate::unions::legacyErrors(source);
545
- if !legacy.is_empty() {
546
- return Err(legacy.join("\n"));
547
- }
548
- // Inline variants become hidden records right after their enum.
527
+ // Removed forms, then inline variants as hidden records (per-file lowering).
549
528
  let mut hoisted = source.clone();
550
- let hoistErrors = crate::unions::hoistInlineVariants(&mut hoisted);
529
+ crate::lower::perFile(&mut hoisted)?;
551
- if !hoistErrors.is_empty() {
552
- return Err(hoistErrors.join("\n"));
553
- }
554
530
  let source = &hoisted;
555
531
  // Parse-time literal errors (out-of-range integers) of every merged file.
556
532
  MERGED_LIT_ERRORS.with(|e| e.borrow_mut().extend(source.formErrors.iter().cloned()));
plum-core/src/lower.rs ADDED
@@ -0,0 +1,40 @@
1
+ use crate::ast::Source;
2
+
3
+ /// Per-file steps, run on each file before it is merged (and by single-file
4
+ /// `parseSource`): removed-form errors, then inline variants become hidden
5
+ /// records right after their enum.
6
+ pub fn perFile(source: &mut Source) -> Result<(), String> {
7
+ let legacy: Vec<String> = crate::unions::legacyErrors(source);
8
+ if !legacy.is_empty() {
9
+ return Err(legacy.join("\n"));
10
+ }
11
+ let hoistErrors = crate::unions::hoistInlineVariants(source);
12
+ if !hoistErrors.is_empty() {
13
+ return Err(hoistErrors.join("\n"));
14
+ }
15
+ Ok(())
16
+ }
17
+
18
+ /// Front-end lowering of a merged program: traits, constructors, inline-variant
19
+ /// type checks, union members, then forms. The first failing stage returns its
20
+ /// errors joined with "\n".
21
+ pub fn frontEnd(source: &mut Source) -> Result<(), String> {
22
+ let traitErrors = crate::traits::lowerTraits(source);
23
+ if !traitErrors.is_empty() {
24
+ return Err(traitErrors.join("\n"));
25
+ }
26
+ let ctorErrors = crate::ctors::lowerCtors(source);
27
+ if !ctorErrors.is_empty() {
28
+ return Err(ctorErrors.join("\n"));
29
+ }
30
+ let typeErrors = crate::unions::inlineVariantTypeErrors(source);
31
+ if !typeErrors.is_empty() {
32
+ return Err(typeErrors.join("\n"));
33
+ }
34
+ let memberErrors = crate::unions::resolveMembers(source);
35
+ if !memberErrors.is_empty() {
36
+ return Err(memberErrors.join("\n"));
37
+ }
38
+ crate::forms::prepareForms(source);
39
+ Ok(())
40
+ }
plum-core/src/parser.rs CHANGED
@@ -63,9 +63,9 @@ impl<'a> AstParser<'a> {
63
63
  /// defined in another file resolves.
64
64
  pub fn parseSource(&self, node: Node) -> Source {
65
65
  let mut source = self.parseSourceRaw(node);
66
- // Single-file path: the loader reports member errors on the merged program.
66
+ // Single-file path: errors are discarded; callers needing them call `lower::perFile` and `lower::frontEnd`.
67
- let _ = crate::unions::resolveMembers(&mut source);
67
+ let _ = crate::lower::perFile(&mut source);
68
- crate::forms::prepareForms(&mut source);
68
+ let _ = crate::lower::frontEnd(&mut source);
69
69
  source
70
70
  }
71
71
 
@@ -1374,7 +1374,7 @@ impl<'a> AstParser<'a> {
1374
1374
  "fn_call" => Expr::FnCall(self.parseFnCall(node)),
1375
1375
  "class_call" => Expr::ClassCall(self.parseClassCall(node)),
1376
1376
  "parenthesized_expression" => {
1377
- // parenthesized_expression: "{" expression "}"
1377
+ // parenthesized_expression: "{" expression "}" — grouping only, no AST node
1378
1378
  let inner = node
1379
1379
  .named_child(0)
1380
1380
  .map(|n| {
@@ -1382,7 +1382,7 @@ impl<'a> AstParser<'a> {
1382
1382
  self.parseExpression(u)
1383
1383
  })
1384
1384
  .unwrap_or(Expr::Int(0));
1385
- Expr::Paren(Box::new(inner))
1385
+ inner
1386
1386
  }
1387
1387
  "string" => Expr::String(self.parseString(node)),
1388
1388
  "integer" => Expr::Int(self.parseInteger(node)),
plum-core/src/purity.rs CHANGED
@@ -372,7 +372,6 @@ impl<'a> Walker<'a> {
372
372
  fn freshRoot(&self, e: &Expr) -> bool {
373
373
  match e {
374
374
  Expr::Var(n) => self.fresh.contains(n),
375
- Expr::Paren(x) => self.freshRoot(x),
376
375
  Expr::Attribute(a) => matches!(a.attr, AttrKind::Field(_)) && self.freshRoot(&a.object),
377
376
  _ => false,
378
377
  }
@@ -397,7 +396,7 @@ impl<'a> Walker<'a> {
397
396
  self.expr(&b.left);
398
397
  self.expr(&b.right);
399
398
  }
400
- Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => self.expr(x),
399
+ Expr::Not(x) | Expr::Try(x) => self.expr(x),
401
400
  Expr::Compare(c) => {
402
401
  self.expr(&c.left);
403
402
  self.expr(&c.right);
plum-core/src/reuse.rs CHANGED
@@ -19,7 +19,6 @@ use std::collections::HashMap;
19
19
  fn deepFresh(e: &Expr) -> bool {
20
20
  match e {
21
21
  Expr::ClassCall(c) => c.spread.is_none() && c.fields.iter().all(|f| deepFresh(&f.value)),
22
- Expr::Paren(x) => deepFresh(x),
23
22
  Expr::TypeName(_) | Expr::Int(_) | Expr::Float(_) | Expr::BoolLit(_) => true,
24
23
  Expr::Binary(_) | Expr::Unary(_) | Expr::Compare(_) => true,
25
24
  _ => false,
plum-core/src/simplify.rs CHANGED
@@ -6,7 +6,7 @@
6
6
  //! blocks (`simplifyBlock`); `simplifyExpr` covers the expression-level rewrites.
7
7
  use crate::ast::*;
8
8
  use crate::rename::{renameStmts, Name};
9
- use crate::visit::{anyExpr, childExprs, childRefs, stmtBlockRefs, stmtBlocks, stmtExprRefs, stmtExprs, visitBlock};
9
+ use crate::visit::{argRef, isTag, walkBlockRef, walkRef, childExprs, childRefs, stmtBlocks, stmtExprs, visitBlock};
10
10
  use std::collections::{HashMap, HashSet};
11
11
  use std::sync::atomic::{AtomicUsize, Ordering};
12
12
 
@@ -74,11 +74,6 @@ struct Cx<'a> {
74
74
  depth: usize,
75
75
  }
76
76
 
77
- /// Pattern casing rule: a Capitalised name is always a tag, a lowercase name always binds.
78
- pub(crate) fn isTag(n: &str) -> bool {
79
- n.chars().next().is_some_and(|c| c.is_uppercase())
80
- }
81
-
82
77
  fn trivial(e: &Expr) -> bool {
83
78
  matches!(e, Expr::Var(_) | Expr::Int(_) | Expr::Float(_) | Expr::BoolLit(_) | Expr::TypeName(_))
84
79
  }
@@ -274,10 +269,7 @@ impl<'a> Cx<'a> {
274
269
  /// `ctor(..).f` -> the field's value, when every other field is droppable.
275
270
  fn fieldOfCtor(&self, a: AttributeExpr) -> Result<Expr, AttributeExpr> {
276
271
  let AttrKind::Field(f) = &a.attr else { return Err(a) };
277
- let obj = match &a.object {
272
+ let obj = &a.object;
278
- Expr::Paren(p) => p.as_ref(),
279
- o => o,
280
- };
281
273
  let Expr::ClassCall(c) = obj else { return Err(a) };
282
274
  if c.spread.is_some() || !c.fields.iter().any(|x| &x.name == f) {
283
275
  return Err(a);
@@ -503,7 +495,6 @@ impl<'a> Cx<'a> {
503
495
  /// `(ctor name, field values in declared order)` of a just-built value.
504
496
  fn ctorOf(&self, e: &Expr) -> Option<(String, Vec<Expr>)> {
505
497
  match e {
506
- Expr::Paren(p) => self.ctorOf(p),
507
498
  Expr::TypeName(n) | Expr::Var(n) if self.ctors.get(n).is_some_and(|f| f.is_empty()) => {
508
499
  Some((n.clone(), vec![]))
509
500
  }
@@ -820,30 +811,3 @@ impl Scoper {
820
811
  }
821
812
  }
822
813
  }
823
-
824
- /// Pre-order callback over every expression of `b`, closure bodies and catch blocks included.
825
- pub(crate) fn walkBlockRef(b: &Block, f: &mut dyn FnMut(&Expr)) {
826
- for s in &b.stmts {
827
- for e in stmtExprRefs(s) {
828
- walkRef(e, f);
829
- }
830
- for blk in stmtBlockRefs(s) {
831
- walkBlockRef(blk, f);
832
- }
833
- }
834
- }
835
-
836
- /// Pre-order callback over `e` and everything under it.
837
- pub(crate) fn walkRef(e: &Expr, f: &mut dyn FnMut(&Expr)) {
838
- anyExpr(e, &mut |x| {
839
- f(x);
840
- false
841
- });
842
- }
843
-
844
- /// The expression of a call argument (visit.rs keeps its own copy private).
845
- pub(crate) fn argRef(a: &Arg) -> &Expr {
846
- match a {
847
- Arg::Positional(e) | Arg::Keyword { value: e, .. } => e,
848
- }
849
- }
plum-core/src/tail.rs CHANGED
@@ -189,7 +189,6 @@ fn expr(cx: &mut Ctx, e: &mut Expr, tail: bool) {
189
189
  site(cx, c);
190
190
  }
191
191
  }
192
- Expr::Paren(x) => expr(cx, x, tail),
193
192
  Expr::InlineIf(i) => {
194
193
  expr(cx, &mut i.condition, false);
195
194
  expr(cx, &mut i.then, tail);
plum-core/src/tests_lower.rs ADDED
@@ -0,0 +1,265 @@
1
+ use crate::ast;
2
+
3
+ /// A source with its `test` blocks lowered into ordinary fns. Type-check
4
+ /// `source` (not the original), then compile it with `extraExports`.
5
+ pub struct TestProgram {
6
+ pub source: ast::Source,
7
+ pub extraExports: Vec<(String, String)>,
8
+ pub names: Vec<String>,
9
+ }
10
+
11
+ /// Temp names (`__lhs_N`) restart at 0 on every call.
12
+ pub fn lowerTests(source: &ast::Source) -> TestProgram {
13
+ let (lowered, extraExports) = desugarTestsToFns(source);
14
+ let names = extraExports
15
+ .iter()
16
+ .map(|(_, export_name)| export_name["test#".len()..].to_string())
17
+ .collect();
18
+ TestProgram {
19
+ source: lowered,
20
+ extraExports,
21
+ names,
22
+ }
23
+ }
24
+
25
+ fn strLit(s: String) -> crate::ast::Expr {
26
+ crate::ast::Expr::String(crate::ast::StringExpr {
27
+ parts: vec![crate::ast::StringPart::Text(s)],
28
+ })
29
+ }
30
+
31
+ /// Builds a failing `assert`'s report line: the literal condition text, plus —
32
+ /// when the condition is a top-level comparison (`left OP right`) — the
33
+ /// ACTUAL (left) and EXPECTED (right) sides' own runtime values underneath,
34
+ /// `assert computed() == literal` being the overwhelmingly common shape a
35
+ /// test writes such a comparison in. Reuses `"{expr}"` string interpolation's
36
+ /// own existing codegen (rather than any special stringification of its
37
+ /// own) — including `Float`, which interpolation now supports by rendering
38
+ /// through `Float.toStr` (`plum-std/Float.plum`), so no type needs
39
+ /// special-casing out here any more. A non-comparison condition (`assert
40
+ /// isEven(4)`) has no meaningful actual/expected split — a boolean condition
41
+ /// is always false by the time this fires — so it's just the bare text.
42
+ /// First part of an assert report string: tells `compileInterpolatedString`
43
+ /// that if any interpolated side has no renderable type (no `toStr`), only the
44
+ /// plain condition-text line (the next part) is emitted.
45
+ pub const ASSERT_REPORT_MARK: &str = "\u{1}assert-report";
46
+
47
+ fn assertFailureMessage(c: &crate::ast::Check, lhs: &crate::ast::Expr, rhs: &crate::ast::Expr) -> crate::ast::Expr {
48
+ let isNullLit =
49
+ |e: &crate::ast::Expr| matches!(e, crate::ast::Expr::ErrorValue(v) if v.name == crate::ast::NULL_ERR_NAME);
50
+ let plain = match &c.cond {
51
+ crate::ast::Expr::Compare(cmp) => isNullLit(&cmp.left) || isNullLit(&cmp.right),
52
+ _ => true,
53
+ };
54
+ if plain {
55
+ return strLit(format!("{}\n", c.text));
56
+ }
57
+ crate::ast::Expr::String(crate::ast::StringExpr {
58
+ parts: vec![
59
+ crate::ast::StringPart::Text(ASSERT_REPORT_MARK.to_string()),
60
+ crate::ast::StringPart::Text(format!("{}\n", c.text)),
61
+ crate::ast::StringPart::Text(" Expected: ".to_string()),
62
+ crate::ast::StringPart::Interp(rhs.clone()),
63
+ crate::ast::StringPart::Text("\n Actual: ".to_string()),
64
+ crate::ast::StringPart::Interp(lhs.clone()),
65
+ crate::ast::StringPart::Text("\n".to_string()),
66
+ ],
67
+ })
68
+ }
69
+
70
+ fn desugarTestsToFns(source: &crate::ast::Source) -> (crate::ast::Source, Vec<(String, String)>) {
71
+ let mut items = Vec::with_capacity(source.items.len());
72
+ let mut extraExports = Vec::new();
73
+ let mut test_idx = 0usize;
74
+ let mut counter = 0usize;
75
+ // Tests may use `io` when std/Io is loaded: the export wrapper passes the `Io`.
76
+ let hasIo = source.items.iter().any(|it| matches!(it, crate::ast::Item::Enum(c) if c.name == "Io"));
77
+ let ioParam = if hasIo {
78
+ vec![crate::ast::Param {
79
+ name: "io".to_string(),
80
+ ty: crate::ast::ParamType::Type(crate::ast::Type { name: "Io".to_string(), generics: vec![] }),
81
+ default: None,
82
+ }]
83
+ } else {
84
+ vec![]
85
+ };
86
+ for item in &source.items {
87
+ match item {
88
+ // Tests declared in imported files are not run: only the entry file's own.
89
+ crate::ast::Item::Test(t) if !t.file.is_empty() && t.file != source.file => {}
90
+ crate::ast::Item::Test(t) => {
91
+ // Leading "__" can never collide with a real Plum-declared name —
92
+ // `fn_identifier`/`var_identifier` must start with a lowercase letter.
93
+ let internal_name = format!("__test_{}", test_idx);
94
+ test_idx += 1;
95
+
96
+ let mut stmts = Vec::with_capacity(t.body.stmts.len() + 2);
97
+ stmts.push(crate::ast::Stmt::Assign(crate::ast::Assign {
98
+ isSynthetic: false,
99
+ targets: vec![crate::ast::AssignTarget::Var("__report".to_string())],
100
+ values: vec![strLit(String::new())],
101
+ declare: true,
102
+ ty: None,
103
+ }));
104
+ stmts.extend(desugarAssertStmts(&t.body.stmts, &mut counter));
105
+ stmts.push(crate::ast::Stmt::Expr(
106
+ crate::ast::Expr::Var("__report".to_string()),
107
+ 0,
108
+ 0,
109
+ ));
110
+
111
+ items.push(crate::ast::Item::Fn(crate::ast::Fn {
112
+ file: source.file.clone(),
113
+ name: internal_name.clone(),
114
+ line: t.line,
115
+ column: t.column,
116
+ type_param: None,
117
+ is_extern: false,
118
+ is_export: false,
119
+ is_override: false,
120
+ is_star: false,
121
+ has_self: true,
122
+ yields: None,
123
+ params: ioParam.clone(),
124
+ returns: Some(crate::ast::Type {
125
+ name: crate::ast::STR_TYPE.to_string(),
126
+ generics: vec![],
127
+ }),
128
+ body: crate::ast::FnBody::Block(crate::ast::Block { stmts }),
129
+ }));
130
+ extraExports.push((internal_name, format!("test#{}", t.name)));
131
+ }
132
+ other => items.push(other.clone()),
133
+ }
134
+ }
135
+ (
136
+ crate::ast::Source {
137
+ file: source.file.clone(),
138
+ module: source.module.clone(),
139
+ imports: source.imports.clone(),
140
+ items,
141
+ topLevelOrder: Vec::new(),
142
+ formErrors: source.formErrors.clone(),
143
+ formUses: source.formUses.clone(),
144
+ stars: source.stars.clone(),
145
+ formLoops: source.formLoops,
146
+ primitives: source.primitives.clone(),
147
+ traits: source.traits.clone(),
148
+ },
149
+ extraExports,
150
+ )
151
+ }
152
+
153
+ /// Recursively rewrites every `assert cond` (`cond` carrying its own literal
154
+ /// source text, captured at parse time — see `crate::ast::Check`) into
155
+ /// `if !cond { __report = __report + "<text>\n" }`, descending into nested
156
+ /// control-flow blocks so an `assert` inside a `test`'s own
157
+ /// `if`/`while`/`for`/`match` still records into the same hidden accumulator.
158
+ fn desugarAssertStmts(stmts: &[crate::ast::Stmt], counter: &mut usize) -> Vec<crate::ast::Stmt> {
159
+ stmts.iter().flat_map(|s| desugarAssertStmt(s, counter)).collect()
160
+ }
161
+
162
+ fn desugarAssertStmt(stmt: &crate::ast::Stmt, counter: &mut usize) -> Vec<crate::ast::Stmt> {
163
+ match stmt {
164
+ crate::ast::Stmt::Assert(c) => {
165
+ // A comparison evaluates each operand exactly once into a
166
+ // temporary (left first), then compares and reports the temporaries.
167
+ let mut pre = Vec::new();
168
+ let mut cond = c.cond.clone();
169
+ let (mut lhs, mut rhs) = (crate::ast::Expr::Var(String::new()), crate::ast::Expr::Var(String::new()));
170
+ let isNullLit = |e: &crate::ast::Expr| matches!(e, crate::ast::Expr::ErrorValue(v) if v.name == crate::ast::NULL_ERR_NAME);
171
+ // `x == null` has no value to print for the null side: reported as a plain condition.
172
+ if let crate::ast::Expr::Compare(cmp) = &c.cond {
173
+ if isNullLit(&cmp.left) || isNullLit(&cmp.right) {
174
+ // falls through with the condition kept whole
175
+ } else {
176
+ let n = *counter;
177
+ *counter += 1;
178
+ let (l, r) = (format!("__lhs_{}", n), format!("__rhs_{}", n));
179
+ for (name, e) in [(&l, &cmp.left), (&r, &cmp.right)] {
180
+ pre.push(crate::ast::Stmt::Assign(crate::ast::Assign {
181
+ isSynthetic: false,
182
+ targets: vec![crate::ast::AssignTarget::Var(name.clone())],
183
+ values: vec![e.clone()],
184
+ declare: true,
185
+ ty: None,
186
+ }));
187
+ }
188
+ lhs = crate::ast::Expr::Var(l);
189
+ rhs = crate::ast::Expr::Var(r);
190
+ cond = crate::ast::Expr::Compare(Box::new(crate::ast::CompareExpr {
191
+ op: cmp.op.clone(),
192
+ left: lhs.clone(),
193
+ right: rhs.clone(),
194
+ }));
195
+ }
196
+ }
197
+ let appendReport = crate::ast::Stmt::Assign(crate::ast::Assign {
198
+ isSynthetic: false,
199
+ targets: vec![crate::ast::AssignTarget::Var("__report".to_string())],
200
+ values: vec![crate::ast::Expr::Binary(Box::new(crate::ast::BinaryExpr {
201
+ op: crate::ast::BinOp::Add,
202
+ left: crate::ast::Expr::Var("__report".to_string()),
203
+ right: assertFailureMessage(c, &lhs, &rhs),
204
+ }))],
205
+ declare: false,
206
+ ty: None,
207
+ });
208
+ pre.push(crate::ast::Stmt::If(crate::ast::If {
209
+ condition: crate::ast::Expr::Not(Box::new(cond)),
210
+ body: crate::ast::Block {
211
+ stmts: vec![appendReport],
212
+ },
213
+ else_ifs: vec![],
214
+ else_: None,
215
+ }));
216
+ pre
217
+ }
218
+ crate::ast::Stmt::If(i) => vec![crate::ast::Stmt::If(crate::ast::If {
219
+ condition: i.condition.clone(),
220
+ body: crate::ast::Block {
221
+ stmts: desugarAssertStmts(&i.body.stmts, counter),
222
+ },
223
+ else_ifs: i
224
+ .else_ifs
225
+ .iter()
226
+ .map(|ei| crate::ast::ElseIf {
227
+ condition: ei.condition.clone(),
228
+ body: crate::ast::Block {
229
+ stmts: desugarAssertStmts(&ei.body.stmts, counter),
230
+ },
231
+ })
232
+ .collect(),
233
+ else_: i.else_.as_ref().map(|b| crate::ast::Block {
234
+ stmts: desugarAssertStmts(&b.stmts, counter),
235
+ }),
236
+ })],
237
+ crate::ast::Stmt::While(w) => vec![crate::ast::Stmt::While(crate::ast::While {
238
+ condition: w.condition.clone(),
239
+ body: crate::ast::Block {
240
+ stmts: desugarAssertStmts(&w.body.stmts, counter),
241
+ },
242
+ })],
243
+ crate::ast::Stmt::ForLoop(f) => vec![crate::ast::Stmt::ForLoop(crate::ast::ForLoop {
244
+ body: crate::ast::Block {
245
+ stmts: desugarAssertStmts(&f.body.stmts, counter),
246
+ },
247
+ ..f.clone()
248
+ })],
249
+ crate::ast::Stmt::Match(m) => vec![crate::ast::Stmt::Match(crate::ast::Match {
250
+ subjects: m.subjects.clone(),
251
+ cases: m
252
+ .cases
253
+ .iter()
254
+ .map(|c| crate::ast::Case {
255
+ patterns: c.patterns.clone(),
256
+ guard: c.guard.clone(),
257
+ body: crate::ast::Block {
258
+ stmts: desugarAssertStmts(&c.body.stmts, counter),
259
+ },
260
+ })
261
+ .collect(),
262
+ })],
263
+ other => vec![other.clone()],
264
+ }
265
+ }
plum-core/src/trmc.rs CHANGED
@@ -143,7 +143,6 @@ fn assignsSelfField(body: &FnBody) -> bool {
143
143
  fn rootedAtSelf(e: &Expr) -> bool {
144
144
  match e {
145
145
  Expr::Self_ => true,
146
- Expr::Paren(x) => rootedAtSelf(x),
147
146
  Expr::Attribute(a) => matches!(a.attr, AttrKind::Field(_)) && rootedAtSelf(&a.object),
148
147
  _ => false,
149
148
  }
@@ -251,7 +250,6 @@ fn set(n: &str, e: Expr) -> Stmt {
251
250
  fn pure(e: &Expr) -> bool {
252
251
  match e {
253
252
  Expr::Var(_) | Expr::Int(_) | Expr::Float(_) | Expr::BoolLit(_) => true,
254
- Expr::Paren(x) => pure(x),
255
253
  _ => false,
256
254
  }
257
255
  }
@@ -383,7 +381,6 @@ impl<'a> Cx<'a> {
383
381
 
384
382
  fn tailExpr(&mut self, e: Expr) -> Option<Vec<Stmt>> {
385
383
  match e {
386
- Expr::Paren(x) => self.tailExpr(*x),
387
384
  Expr::InlineIf(i) => {
388
385
  let i = *i;
389
386
  let then = Block { stmts: self.tailExpr(i.then)? };
plum-core/src/union_lower.rs CHANGED
@@ -272,7 +272,7 @@ impl<'a> Lowerer<'a> {
272
272
  renameExpr(&mut b.right, &leftPairs);
273
273
  }
274
274
  }
275
- Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => self.expr(x),
275
+ Expr::Not(x) | Expr::Try(x) => self.expr(x),
276
276
  Expr::Compare(c) => {
277
277
  self.expr(&mut c.left);
278
278
  self.expr(&mut c.right);
@@ -408,7 +408,6 @@ fn condBinds(e: &Expr, name: &str) -> bool {
408
408
  match e {
409
409
  Expr::Bind(b) => patBinds(b.pattern(), name),
410
410
  Expr::Bool(b) => condBinds(&b.left, name) || condBinds(&b.right, name),
411
- Expr::Paren(x) => condBinds(x, name),
412
411
  _ => false,
413
412
  }
414
413
  }
@@ -530,7 +529,7 @@ fn rnExpr(e: &mut Expr, from: &str, to: &str) {
530
529
  rnExpr(&mut b.right, from, to);
531
530
  }
532
531
  }
533
- Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => rnExpr(x, from, to),
532
+ Expr::Not(x) | Expr::Try(x) => rnExpr(x, from, to),
534
533
  Expr::Compare(c) => {
535
534
  rnExpr(&mut c.left, from, to);
536
535
  rnExpr(&mut c.right, from, to);
plum-core/src/unions.rs CHANGED
@@ -413,7 +413,7 @@ impl TypeWalk<'_> {
413
413
  self.expr(&b.left);
414
414
  self.expr(&b.right);
415
415
  }
416
- Expr::Not(e) | Expr::Try(e) | Expr::Paren(e) => self.expr(e),
416
+ Expr::Not(e) | Expr::Try(e) => self.expr(e),
417
417
  Expr::Bang(b) => self.expr(&b.value),
418
418
  Expr::Bind(b) => {
419
419
  self.expr(&b.value);
plum-core/src/uniqueness.rs CHANGED
@@ -122,7 +122,7 @@ fn walkExpr<'a>(e: &'a Expr, f: Visit<'a, '_>) {
122
122
  walkExpr(&c.left, f);
123
123
  walkExpr(&c.right, f);
124
124
  }
125
- Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => walkExpr(x, f),
125
+ Expr::Not(x) | Expr::Try(x) => walkExpr(x, f),
126
126
  Expr::InlineIf(i) => {
127
127
  walkExpr(&i.condition, f);
128
128
  walkExpr(&i.then, f);
@@ -263,7 +263,7 @@ fn mutExpr(e: &mut Expr, f: &mut dyn FnMut(&mut ClassCall)) {
263
263
  mutExpr(&mut c.left, f);
264
264
  mutExpr(&mut c.right, f);
265
265
  }
266
- Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => mutExpr(x, f),
266
+ Expr::Not(x) | Expr::Try(x) => mutExpr(x, f),
267
267
  Expr::InlineIf(i) => {
268
268
  mutExpr(&mut i.condition, f);
269
269
  mutExpr(&mut i.then, f);
@@ -380,7 +380,7 @@ fn isVar(e: &Expr, v: &str) -> bool {
380
380
 
381
381
  /// The name of a plain variable use; `self` counts as the variable "self".
382
382
  fn varName(e: &Expr) -> Option<&str> {
383
- match crate::visit::unparen(e) {
383
+ match e {
384
384
  Expr::Var(n) => Some(n.as_str()),
385
385
  Expr::Self_ => Some("self"),
386
386
  _ => None,
@@ -446,7 +446,6 @@ impl An {
446
446
  fn fresh(&self, e: &Expr) -> bool {
447
447
  match e {
448
448
  Expr::ClassCall(_) => true,
449
- Expr::Paren(x) => self.fresh(x),
450
449
  Expr::InlineIf(i) => self.fresh(&i.then) && self.fresh(&i.else_),
451
450
  Expr::FnCall(c) => self.isFree(&c.name).is_some_and(|g| self.fresh_ret.borrow().contains(&g)),
452
451
  Expr::Attribute(a) => match &a.attr {
@@ -551,7 +550,7 @@ impl An {
551
550
  if site.inClosure {
552
551
  return Err("argument inside a closure".to_string());
553
552
  }
554
- match crate::visit::unparen(e) {
553
+ match e {
555
554
  Expr::Var(_) | Expr::Self_ => self.varOk(site.caller, varName(e).unwrap()),
556
555
  Expr::Attribute(a) => {
557
556
  let AttrKind::Field(f) = &a.attr else {
@@ -783,7 +782,6 @@ impl<'a> Scan<'a> {
783
782
  self.fail(m);
784
783
  }
785
784
  }
786
- Expr::Paren(x) => self.expr(x, tail, ab),
787
785
  Expr::InlineIf(i) => {
788
786
  self.expr(&i.condition, false, false);
789
787
  self.expr(&i.then, tail, ab);
@@ -1093,7 +1091,7 @@ fn collectFacts(an: &mut An) {
1093
1091
  walkBlock(&u.block, &mut |n| {
1094
1092
  match n {
1095
1093
  Node::S(Stmt::Assign(asg)) if asg.targets.len() == 1 && asg.values.len() == 1 => {
1096
- match crate::visit::unparen(&asg.values[0]) {
1094
+ match &asg.values[0] {
1097
1095
  Expr::FnCall(c) => {
1098
1096
  abMap.insert(c as *const FnCall, asg.targets[0].clone());
1099
1097
  }
@@ -1164,7 +1162,7 @@ fn collectFacts(an: &mut An) {
1164
1162
  allowed.insert(&**o as *const Expr);
1165
1163
  }
1166
1164
  if single {
1167
- let call: Option<(&FnCall, Option<&Expr>)> = match crate::visit::unparen(&asg.values[0]) {
1165
+ let call: Option<(&FnCall, Option<&Expr>)> = match &asg.values[0] {
1168
1166
  Expr::FnCall(c) => Some((c, None)),
1169
1167
  Expr::Attribute(x) => match &x.attr {
1170
1168
  AttrKind::Method(c) => Some((c, Some(&x.object))),
plum-core/src/visit.rs CHANGED
@@ -3,13 +3,6 @@
3
3
  //! `visitBlock`/`visitExpr`/`anyExpr`/`countVar`, not by `childExprs`/`childRefs`.
4
4
  use crate::ast::*;
5
5
 
6
- pub fn unparen(e: &Expr) -> &Expr {
7
- match e {
8
- Expr::Paren(i) => unparen(i),
9
- _ => e,
10
- }
11
- }
12
-
13
6
  pub fn visitBlock(b: &mut Block, f: &mut dyn FnMut(&mut Expr)) {
14
7
  for s in &mut b.stmts {
15
8
  for e in stmtExprs(s) {
@@ -49,7 +42,7 @@ pub fn childExprs(e: &mut Expr) -> Vec<&mut Expr> {
49
42
  Expr::Binary(b) => vec![&mut b.left, &mut b.right],
50
43
  Expr::Unary(u) => vec![&mut u.operand],
51
44
  Expr::Bool(b) => vec![&mut b.left, &mut b.right],
52
- Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => vec![x.as_mut()],
45
+ Expr::Not(x) | Expr::Try(x) => vec![x.as_mut()],
53
46
  Expr::Bang(b) => vec![&mut b.value],
54
47
  Expr::Bind(b) => {
55
48
  let mut v = vec![&mut b.value];
@@ -155,7 +148,7 @@ pub fn stmtBlocks(s: &mut Stmt) -> Vec<&mut Block> {
155
148
  }
156
149
  }
157
150
 
158
- fn argRef(a: &Arg) -> &Expr {
151
+ pub fn argRef(a: &Arg) -> &Expr {
159
152
  match a {
160
153
  Arg::Positional(e) | Arg::Keyword { value: e, .. } => e,
161
154
  }
@@ -166,7 +159,7 @@ pub fn childRefs(e: &Expr) -> Vec<&Expr> {
166
159
  Expr::Binary(b) => vec![&b.left, &b.right],
167
160
  Expr::Unary(u) => vec![&u.operand],
168
161
  Expr::Bool(b) => vec![&b.left, &b.right],
169
- Expr::Not(x) | Expr::Try(x) | Expr::Paren(x) => vec![x.as_ref()],
162
+ Expr::Not(x) | Expr::Try(x) => vec![x.as_ref()],
170
163
  Expr::Bang(b) => vec![&b.value],
171
164
  Expr::Bind(b) => {
172
165
  let mut v = vec![&b.value];
@@ -334,3 +327,28 @@ pub fn countVar(body: &FnBody, name: &str) -> usize {
334
327
  FnBody::Extern => 0,
335
328
  }
336
329
  }
330
+
331
+ /// Pattern casing rule: a Capitalised name is always a tag, a lowercase name always binds.
332
+ pub fn isTag(n: &str) -> bool {
333
+ n.chars().next().is_some_and(|c| c.is_uppercase())
334
+ }
335
+
336
+ /// Pre-order callback over every expression of `b`, closure bodies and catch blocks included.
337
+ pub fn walkBlockRef(b: &Block, f: &mut dyn FnMut(&Expr)) {
338
+ for s in &b.stmts {
339
+ for e in stmtExprRefs(s) {
340
+ walkRef(e, f);
341
+ }
342
+ for blk in stmtBlockRefs(s) {
343
+ walkBlockRef(blk, f);
344
+ }
345
+ }
346
+ }
347
+
348
+ /// Pre-order callback over `e` and everything under it.
349
+ pub fn walkRef(e: &Expr, f: &mut dyn FnMut(&Expr)) {
350
+ anyExpr(e, &mut |x| {
351
+ f(x);
352
+ false
353
+ });
354
+ }
plum-core/src/whistle.rs CHANGED
@@ -32,7 +32,6 @@ fn argExprs(args: &[Arg]) -> (String, Vec<&Expr>) {
32
32
  fn view(e: &Expr) -> View<'_> {
33
33
  use View::*;
34
34
  match e {
35
- Expr::Paren(inner) => view(inner),
36
35
  Expr::Var(_) | Expr::Self_ => Var,
37
36
  Expr::Binary(b) => Node(format!("bin:{:?}", b.op), vec![&b.left, &b.right]),
38
37
  Expr::Unary(u) => Node(format!("un:{:?}", u.op), vec![&u.operand]),
plum-core/tests/inline_variants_test.rs CHANGED
@@ -107,11 +107,11 @@ fn type_position_errors() {
107
107
  "fun fa(gg: fn(Cons[Int]) -> Int) -> Int = 1\n",
108
108
  "fun fa() -> Int =\n cc: Cons[Int] := todo\n 1\n",
109
109
  ] {
110
- let mut s = parse(&format!("enum Link[T] = Nil | Cons(head: T)\n{src}"));
110
+ let mut s = plum_test_support::parseRawSource(&format!("enum Link[T] = Nil | Cons(head: T)\n{src}"));
111
111
  assert!(hoistInlineVariants(&mut s).is_empty());
112
112
  assert_eq!(inlineVariantTypeErrors(&s), msg, "{src}");
113
113
  }
114
- let mut s = parse(
114
+ let mut s = plum_test_support::parseRawSource(
115
115
  "enum Link[T] = Nil | Cons(head: T)\nfun fa(cc: Link[Int]) -> Link[Int] = Cons(head: 1)\nfun gb(cc: Link[Int]) -> Int = match cc\n Cons(head) => head\n Nil => 0\n",
116
116
  );
117
117
  assert!(hoistInlineVariants(&mut s).is_empty());
plum-core/tests/loader_test.rs CHANGED
@@ -532,3 +532,34 @@ fn nestedStdImportResolvesToASubdirectory() {
532
532
  );
533
533
  assert!(loadAndMerge(&missing, &lib_path).is_err());
534
534
  }
535
+
536
+ /// Import-free programs (every plum-examples file imports std, so small inline programs are used):
537
+ /// the single-file `parseSource` front end must give the same AST as the loader.
538
+ #[test]
539
+ fn single_file_equals_loader() {
540
+ let programs = [
541
+ "fun id(a: Unit) -> Unit = a\nfun main(u: Unit) = id(u)\n",
542
+ "record Pt(x: Unit, y: Unit)\nfun mk(u: Unit) = Pt(x: u, y: u)\n",
543
+ "trait Actor(x: Unit)\nrecord Boss(Actor(x), hp: Unit)\nfun main(u: Unit) = Boss(x: u, hp: u)\n",
544
+ "enum Shape = Circle | Dot\nfun f(s: Shape) = s\n",
545
+ "enum Shape = Circle(r: Unit) | Sq\nfun f(s: Shape) = s\n",
546
+ ];
547
+ for src in programs {
548
+ let dir = TempDir::new().unwrap();
549
+ let entry = dir.path().join("main.plum");
550
+ fs::write(&entry, src).unwrap();
551
+ let mut parser = tree_sitter::Parser::new();
552
+ parser.set_language(&tree_sitter_plum::LANGUAGE.into()).unwrap();
553
+ let tree = parser.parse(src, None).unwrap();
554
+ let single = plum_core::AstParser::new(src).parseSource(tree.root_node());
555
+ let merged = loadAndMerge(&entry, dir.path()).expect("loadAndMerge failed");
556
+ // The loader stamps each item with its path; blank those out before comparing.
557
+ let norm = |x: &plum_core::ast::Source| {
558
+ // the loader drops `topLevelOrder` after merging; items and traits are the lowered program
559
+ let d = format!("{:?} {:?}", x.items, x.traits);
560
+ d.replace(&format!("file: {:?}", entry.to_string_lossy()), "file: \"\"")
561
+ .replace(&format!("file: {:?}", fs::canonicalize(&entry).unwrap().to_string_lossy()), "file: \"\"")
562
+ };
563
+ assert_eq!(norm(&single), norm(&merged), "mismatch for {src}");
564
+ }
565
+ }
plum-core/tests/lower_test.rs ADDED
@@ -0,0 +1,19 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_core::ast::*;
3
+ use plum_core::lower::frontEnd;
4
+
5
+ #[test]
6
+ fn lowersTraits() {
7
+ let mut s = plum_test_support::parseRawSource(
8
+ "trait Actor =\n fun up(self)\n\nrecord Hut(Actor, k: Int) =\n override fun up(self) -> Int = 1\n",
9
+ );
10
+ assert_eq!(frontEnd(&mut s), Ok(()));
11
+ assert!(!s.items.iter().any(|i| matches!(i, Item::Trait(_))));
12
+ assert_eq!(s.traits.len(), 1);
13
+ }
14
+
15
+ #[test]
16
+ fn unknownMemberErrors() {
17
+ let mut s = plum_test_support::parseRawSource("enum Shape = Nope[Int] | Red");
18
+ assert_eq!(frontEnd(&mut s), Err("unknown member type Nope".to_string()));
19
+ }
plum-core/tests/parser_test.rs CHANGED
@@ -516,8 +516,7 @@ fn bang_vs_neq_lexing() {
516
516
  let Expr::Compare(c) = bodyExpr(onlyFn(&s)) else {
517
517
  panic!()
518
518
  };
519
- let Expr::Paren(p) = &c.left else { panic!() };
520
- assert!(matches!(**p, Expr::Bang(_)));
519
+ assert!(matches!(c.left, Expr::Bang(_)));
521
520
  }
522
521
 
523
522
  // `catch` has the lowest precedence of any expression form: its default
@@ -953,7 +952,7 @@ fn one_line_generic_member() {
953
952
 
954
953
  #[test]
955
954
  fn inline_payload_still_parses() {
956
- let source = parse("enum Sh =\n | Circle(r: Float)\n | Dot(Int)\n");
955
+ let source = parseRaw("enum Sh =\n | Circle(r: Float)\n | Dot(Int)\n");
957
956
  let e = onlyEnum(&source);
958
957
  assert!(e.variants[0].member.is_none());
959
958
  assert_eq!(e.variants[0].fields.len(), 1);
@@ -1036,3 +1035,12 @@ fn field_less_record_parses() {
1036
1035
  assert_eq!(e.variants[0].fields.len(), 0);
1037
1036
  }
1038
1037
  }
1038
+
1039
+ // Grouping braces leave no AST node, but precedence holds: `{1 + 2} * 3` is (1 + 2) * 3.
1040
+ #[test]
1041
+ fn groupingKeepsPrecedenceWithoutNode() {
1042
+ let s = parse("fun f() -> Int = {1 + 2} * 3\n");
1043
+ let Expr::Binary(b) = bodyExpr(onlyFn(&s)) else { panic!() };
1044
+ assert!(matches!(b.left, Expr::Binary(_)));
1045
+ assert!(matches!(b.right, Expr::Int(3)));
1046
+ }
plum-core/tests/tests_lower_test.rs ADDED
@@ -0,0 +1,39 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_core::ast::*;
3
+ use plum_core::tests_lower::{lowerTests, ASSERT_REPORT_MARK};
4
+
5
+ const SRC: &str = "test \"a\"\n assert 1 + 1 == 2\n assert 2 == 2\n";
6
+
7
+ fn fnNamed<'a>(s: &'a Source, n: &str) -> &'a Fn {
8
+ s.items.iter().find_map(|i| match i { Item::Fn(f) if f.name == n => Some(f), _ => None }).unwrap()
9
+ }
10
+
11
+ #[test]
12
+ fn lowersOneTest() {
13
+ let src = plum_test_support::parseRawSource(SRC);
14
+ let p = lowerTests(&src);
15
+ assert_eq!(p.names, vec!["a"]);
16
+ assert_eq!(p.extraExports, vec![("__test_0".to_string(), "test#a".to_string())]);
17
+ assert!(!p.source.items.iter().any(|i| matches!(i, Item::Test(_))));
18
+ fnNamed(&p.source, "__test_0");
19
+ }
20
+
21
+ #[test]
22
+ fn assertNamesPerCompile() {
23
+ let src = plum_test_support::parseRawSource(SRC);
24
+ let a = lowerTests(&src);
25
+ let b = lowerTests(&src);
26
+ assert_eq!(format!("{:?}", fnNamed(&a.source, "__test_0").body), format!("{:?}", fnNamed(&b.source, "__test_0").body));
27
+ let dbg = format!("{:?}", fnNamed(&a.source, "__test_0").body);
28
+ assert!(dbg.contains("__lhs_0") && dbg.contains("__rhs_1") && !dbg.contains("__lhs_2"));
29
+ }
30
+
31
+ #[test]
32
+ fn failureMessageText() {
33
+ let src = plum_test_support::parseRawSource(SRC);
34
+ let dbg = format!("{:?}", fnNamed(&lowerTests(&src).source, "__test_0").body);
35
+ assert!(dbg.contains("assert-report"));
36
+ assert!(dbg.contains(" Expected: "));
37
+ assert!(dbg.contains("\\n Actual: "));
38
+ assert_eq!(ASSERT_REPORT_MARK, "\u{1}assert-report");
39
+ }
plum-core/tests/uniqueness_test.rs CHANGED
@@ -44,7 +44,6 @@ fn super_walk(b: &mut Block, found: &mut bool) {
44
44
  *found = true;
45
45
  }
46
46
  }
47
- Expr::Paren(x) => ex(x, found),
48
47
  Expr::InlineIf(i) => {
49
48
  ex(&i.then, found);
50
49
  ex(&i.else_, found);
plum-core/tests/visit_test.rs CHANGED
@@ -80,9 +80,3 @@ fn countVarSeesClosures() {
80
80
  let src = "fun t(x: Int) -> Int =\n f := |y| y + x\n return x + f(x)\n";
81
81
  assert_eq!(countVar(&fnBody(src), "x"), 3);
82
82
  }
83
-
84
- #[test]
85
- fn unparenStrips() {
86
- let e = Expr::Paren(Box::new(Expr::Paren(Box::new(Expr::Int(1)))));
87
- assert!(matches!(unparen(&e), Expr::Int(1)));
88
- }
plum-game/tests/host_test.rs CHANGED
@@ -9,8 +9,7 @@ fn root() -> PathBuf {
9
9
 
10
10
  fn compile(src_path: &Path) -> Vec<u8> {
11
11
  let source = plum_core::loadAndMerge(src_path, &root()).expect("load");
12
- let typed = plum_wasm_codegen::typeCheckIn(&source, &src_path.display().to_string()).expect("typecheck");
13
- plum_wasm_codegen::compileTyped(&source, typed).expect("compile")
12
+ plum_wasm_codegen::compile(&source, &plum_wasm_codegen::CompileOptions::default()).expect("compile").wasm
14
13
  }
15
14
 
16
15
  fn fixture() -> Vec<u8> {
plum-lsp/src/completion.rs CHANGED
@@ -610,7 +610,7 @@ pub fn stdLibIndex(lib_path: &Path) -> HashMap<String, String> {
610
610
  let Some(tree) = parser.parse(&text, None) else {
611
611
  continue;
612
612
  };
613
- let source = plum_core::AstParser::new(&text).parseSource(tree.root_node());
613
+ let source = plum_core::AstParser::new(&text).parseSourceRaw(tree.root_node());
614
614
  for item in &source.items {
615
615
  let name = match item {
616
616
  ast::Item::Fn(f) if f.type_param.is_none() => Some(f.name.clone()),
plum-lsp/src/main.rs CHANGED
@@ -113,7 +113,7 @@ impl Backend {
113
113
  let line_text = text.lines().nth(pos.line as usize).unwrap_or("");
114
114
  let sanitized = completion::sanitizeForParsing(&text, pos);
115
115
  let tree = Self::parseTree(&sanitized);
116
- let own_source = plum_core::AstParser::new(&sanitized).parseSource(tree.root_node());
116
+ let own_source = plum_core::AstParser::new(&sanitized).parseSourceRaw(tree.root_node());
117
117
 
118
118
  if let Some(receiver) = completion::receiverBeforeDot(line_text, pos.character as usize) {
119
119
  let Ok(path) = uri.to_file_path() else {
@@ -354,7 +354,7 @@ fn memberCompletionFor(
354
354
  return Vec::new();
355
355
  };
356
356
  let tree = Backend::parseTree(text);
357
- let own_source = plum_core::AstParser::new(text).parseSource(tree.root_node());
357
+ let own_source = plum_core::AstParser::new(text).parseSourceRaw(tree.root_node());
358
358
  // `Type.`: its associated consts and static functions.
359
359
  if receiver
360
360
  .chars()
@@ -432,7 +432,7 @@ fn inheritedFrom(
432
432
  }
433
433
  };
434
434
  let tree = Backend::parseTree(&content);
435
- let src = plum_core::AstParser::new(&content).parseSource(tree.root_node());
435
+ let src = plum_core::AstParser::new(&content).parseSourceRaw(tree.root_node());
436
436
  for i in &src.items {
437
437
  if let ast::Item::Enum(e) = i {
438
438
  if e.isRecord {
@@ -553,7 +553,7 @@ fn resolveTarget(
553
553
  .unwrap_or(0);
554
554
  let receiver = &before[rs..];
555
555
  let tree = Backend::parseTree(text);
556
- let own = plum_core::AstParser::new(text).parseSource(tree.root_node());
556
+ let own = plum_core::AstParser::new(text).parseSourceRaw(tree.root_node());
557
557
  let (global_env, methods, enum_variants, enum_params) =
558
558
  plum_checker::buildGlobalTables(&merged);
559
559
  let min_required = plum_checker::buildMinRequiredArgs(&merged);
@@ -624,7 +624,7 @@ fn recordHover(text: &str, line0: usize, col: usize) -> Option<String> {
624
624
  let (s, e) = identAt(lineText, col)?;
625
625
  let word = &lineText[s..e];
626
626
  let tree = Backend::parseTree(text);
627
- let own = plum_core::AstParser::new(text).parseSource(tree.root_node());
627
+ let own = plum_core::AstParser::new(text).parseSourceRaw(tree.root_node());
628
628
  let is = own.items.iter().any(|i| {
629
629
  matches!(i, ast::Item::Enum(en) if en.name == word && (en.isRecord || en.isValue) && !en.isTrait)
630
630
  });
@@ -841,7 +841,7 @@ mod tests {
841
841
  fn complete_ctor_offers_only_ctor_params() {
842
842
  let src = "module t\n\ntrait Actor(x: Int, y: Int, hp: Int)\n\nrecord Ship(Actor(x, y), tex: Int)\n";
843
843
  let tree = Backend::parseTree(src);
844
- let ast = plum_core::AstParser::new(src).parseSource(tree.root_node());
844
+ let ast = plum_core::AstParser::new(src).parseSourceRaw(tree.root_node());
845
845
  let labels: Vec<String> = completion::ctorArgItems(&ast, &ast, "Ship").into_iter().map(|i| i.label).collect();
846
846
  assert_eq!(labels, vec!["x:", "y:", "tex:"]);
847
847
  assert_eq!(completion::enclosingCtor(" v := Vec(x: 1, "), Some("Vec".into()));
plum-wasm-codegen/src/lib.rs CHANGED
@@ -1728,7 +1728,15 @@ pub fn typeCheck(source: &ast::Source) -> Result<plum_checker::TypedInfo, String
1728
1728
  /// Like `typeCheck`, naming `file` in positioned errors. Positions refer to the
1729
1729
  /// merged source, so imported-file errors are attributed to the entry file.
1730
1730
  pub fn typeCheckIn(source: &ast::Source, file: &str) -> Result<plum_checker::TypedInfo, String> {
1731
+ typeCheckWith(source, file, plum_checker::fusionOptions())
1732
+ }
1733
+
1734
+ fn typeCheckWith(
1735
+ source: &ast::Source,
1736
+ file: &str,
1737
+ fusion: plum_checker::FusionOptions,
1738
+ ) -> Result<plum_checker::TypedInfo, String> {
1731
- plum_checker::typedSource(source).map_err(|errs| {
1739
+ plum_checker::typedSourceWith(source, fusion).map_err(|errs| {
1732
1740
  errs.iter()
1733
1741
  .map(|e| {
1734
1742
  if e.line > 0 && !file.is_empty() {
@@ -1750,315 +1758,83 @@ pub fn typeCheckIn(source: &ast::Source, file: &str) -> Result<plum_checker::Typ
1750
1758
  })
1751
1759
  }
1752
1760
 
1753
- /// Compiles a source already checked by `plum_checker::typedSource(source)`
1754
- /// (the `TypedInfo` is keyed by expression address, so it must come from this
1755
- /// very `source` value). Hosts that check once call this; `compileSource`
1756
- /// checks and compiles in one step.
1761
+ /// What `compile` does besides compiling.
1762
+ #[derive(Clone, Copy, Debug)]
1757
- pub fn compileTyped(
1763
+ pub struct CompileOptions {
1764
+ /// Lower `test` blocks into exported `"test#<name>"` fns (`plum test`); otherwise
1765
+ /// they are stripped.
1758
- source: &ast::Source,
1766
+ pub tests: bool,
1759
- typed: plum_checker::TypedInfo,
1767
+ pub fusion: plum_checker::FusionOptions,
1760
- ) -> Result<Vec<u8>, String> {
1761
- compileSourceInner(source, &[], typed).map(|(bytes, _)| bytes)
1762
1768
  }
1763
1769
 
1764
- pub fn compileSource(source: &ast::Source) -> Result<Vec<u8>, String> {
1765
- compileTyped(source, typeCheck(source)?)
1766
- }
1767
-
1768
- /// Like `compileSource`, also returning the key of every compiled function
1769
- /// (`name` or `Receiver::method`) — one entry per source function, since a
1770
- /// generic function/method compiles exactly once however many types it is
1771
- /// instantiated at.
1772
- pub fn compileSourceWithFunctions(source: &ast::Source) -> Result<(Vec<u8>, Vec<String>), String> {
1773
- compileSourceInner(source, &[], typeCheck(source)?)
1774
- }
1775
-
1776
- /// Compiles `test "..." { ... }` blocks for `plum test`, stripped from every
1777
- /// other build path. Each `test` is desugared into an ordinary zero-param `Fn`
1778
- /// returning `Str` BEFORE the normal compile pipeline ever sees it — a hidden
1779
- /// `__report` local starts at `""`, every `assert cond` inside the test body
1780
- /// desugars into `if !cond { __report = __report + "line L, column C: assert
1781
- /// failed: <source text>\n" }` (which is why a failing `assert` INSIDE a test
1782
- /// doesn't stop the rest of it, unlike an `assert` anywhere else in the
1783
- /// program, which still hard-traps — see the plain `ast::Stmt::Assert` compile
1784
- /// arm), and the block's tail expression returns `__report` (empty = every
1785
- /// assertion passed). Returns the compiled module alongside each test's
1786
- /// original display name, in the same order as its exported `"test#<name>"`
1787
- /// function.
1788
- pub fn compileTestSource(source: &ast::Source) -> Result<(Vec<u8>, Vec<String>), String> {
1789
- let prog = desugarTests(source);
1790
- let typed = typeCheck(&prog.source)?;
1791
- compileTestTyped(&prog, typed)
1770
+ impl Default for CompileOptions {
1792
- }
1793
-
1794
- /// A source with its `test` blocks desugared into ordinary fns. Type-check
1795
- /// `source` (not the original) and pass the result to `compileTestTyped`.
1796
- pub struct TestProgram {
1771
+ fn default() -> Self {
1797
- pub source: ast::Source,
1798
- extra_exports: Vec<(String, String)>,
1799
- pub names: Vec<String>,
1800
- }
1801
-
1802
- pub fn desugarTests(source: &ast::Source) -> TestProgram {
1803
- let (desugared, extra_exports) = desugarTestsToFns(source);
1772
+ CompileOptions { tests: false, fusion: plum_checker::FusionOptions::default() }
1804
- let names = extra_exports
1805
- .iter()
1806
- .map(|(_, export_name)| export_name["test#".len()..].to_string())
1807
- .collect();
1808
- TestProgram {
1809
- source: desugared,
1810
- extra_exports,
1811
- names,
1812
1773
  }
1813
1774
  }
1814
1775
 
1776
+ /// The result of `compile`.
1777
+ pub struct Output {
1815
- pub fn compileTestTyped(
1778
+ pub wasm: Vec<u8>,
1779
+ /// Key of every compiled function (`name` or `Receiver::method`) — one entry per
1816
- prog: &TestProgram,
1780
+ /// source function, since a generic compiles once however many types it is used at.
1817
- typed: plum_checker::TypedInfo,
1781
+ pub fn_keys: Vec<String>,
1782
+ /// With `opts.tests`: each test's display name, in the order of its `"test#<name>"` export.
1818
- ) -> Result<(Vec<u8>, Vec<String>), String> {
1783
+ pub test_names: Vec<String>,
1819
- let (wasm, _) = compileSourceInner(&prog.source, &prog.extra_exports, typed)?;
1820
- Ok((wasm, prog.names.clone()))
1821
1784
  }
1822
1785
 
1786
+ /// The one compile entry: type-checks `source` (fusion per `opts.fusion`) and compiles it.
1823
- fn strLit(s: String) -> ast::Expr {
1787
+ /// With `opts.tests`, `test` blocks are first lowered to ordinary fns (see
1824
- ast::Expr::String(ast::StringExpr {
1788
+ /// `plum_core::tests_lower`) and exported.
1789
+ pub fn compile(source: &ast::Source, opts: &CompileOptions) -> Result<Output, String> {
1790
+ if opts.tests {
1825
- parts: vec![ast::StringPart::Text(s)],
1791
+ let prog = plum_core::tests_lower::lowerTests(source);
1792
+ let typed = typeCheckWith(&prog.source, "", opts.fusion)?;
1793
+ let (wasm, fn_keys) = compileSourceInner(&prog.source, &prog.extraExports, typed)?;
1794
+ Ok(Output { wasm, fn_keys, test_names: prog.names })
1795
+ } else {
1796
+ let typed = typeCheckWith(source, "", opts.fusion)?;
1797
+ let (wasm, fn_keys) = compileSourceInner(source, &[], typed)?;
1798
+ Ok(Output { wasm, fn_keys, test_names: Vec::new() })
1826
- })
1799
+ }
1827
1800
  }
1828
1801
 
1829
- /// Builds a failing `assert`'s report line: the literal condition text, plus —
1830
- /// when the condition is a top-level comparison (`left OP right`) — the
1831
- /// ACTUAL (left) and EXPECTED (right) sides' own runtime values underneath,
1832
- /// `assert computed() == literal` being the overwhelmingly common shape a
1833
- /// test writes such a comparison in. Reuses `"{expr}"` string interpolation's
1834
- /// own existing codegen (rather than any special stringification of its
1835
- /// own) — including `Float`, which interpolation now supports by rendering
1836
- /// through `Float.toStr` (`plum-std/Float.plum`), so no type needs
1837
- /// special-casing out here any more. A non-comparison condition (`assert
1838
- /// isEven(4)`) has no meaningful actual/expected split — a boolean condition
1839
- /// is always false by the time this fires — so it's just the bare text.
1840
- /// First part of an assert report string: tells `compileInterpolatedString`
1841
- /// that if any interpolated side has no renderable type (no `toStr`), only the
1842
- /// plain condition-text line (the next part) is emitted.
1843
- const ASSERT_REPORT_MARK: &str = "\u{1}assert-report";
1844
-
1845
- fn assertFailureMessage(c: &ast::Check, lhs: &ast::Expr, rhs: &ast::Expr) -> ast::Expr {
1846
- let isNullLit =
1847
- |e: &ast::Expr| matches!(e, ast::Expr::ErrorValue(v) if v.name == ast::NULL_ERR_NAME);
1848
- let plain = match &c.cond {
1849
- ast::Expr::Compare(cmp) => isNullLit(&cmp.left) || isNullLit(&cmp.right),
1850
- _ => true,
1851
- };
1852
- if plain {
1853
- return strLit(format!("{}\n", c.text));
1854
- }
1855
- ast::Expr::String(ast::StringExpr {
1856
- parts: vec![
1857
- ast::StringPart::Text(ASSERT_REPORT_MARK.to_string()),
1858
- ast::StringPart::Text(format!("{}\n", c.text)),
1802
+ fn threadLocalOptions(tests: bool) -> CompileOptions {
1859
- ast::StringPart::Text(" Expected: ".to_string()),
1803
+ CompileOptions { tests, fusion: plum_checker::fusionOptions() }
1860
- ast::StringPart::Interp(rhs.clone()),
1861
- ast::StringPart::Text("\n Actual: ".to_string()),
1862
- ast::StringPart::Interp(lhs.clone()),
1863
- ast::StringPart::Text("\n".to_string()),
1864
- ],
1865
- })
1866
1804
  }
1867
1805
 
1868
- fn desugarTestsToFns(source: &ast::Source) -> (ast::Source, Vec<(String, String)>) {
1806
+ pub fn compileSource(source: &ast::Source) -> Result<Vec<u8>, String> {
1869
- let mut items = Vec::with_capacity(source.items.len());
1807
+ compile(source, &threadLocalOptions(false)).map(|o| o.wasm)
1870
- let mut extra_exports = Vec::new();
1871
- let mut test_idx = 0usize;
1872
- // Tests may use `io` when std/Io is loaded: the export wrapper passes the `Io`.
1873
- let hasIo = source.items.iter().any(|it| matches!(it, ast::Item::Enum(c) if c.name == "Io"));
1874
- let ioParam = if hasIo {
1875
- vec![ast::Param {
1876
- name: "io".to_string(),
1877
- ty: ast::ParamType::Type(ast::Type { name: "Io".to_string(), generics: vec![] }),
1878
- default: None,
1879
- }]
1808
+ }
1880
- } else {
1881
- vec![]
1882
- };
1883
- for item in &source.items {
1884
- match item {
1885
- // Tests declared in imported files are not run: only the entry file's own.
1886
- ast::Item::Test(t) if !t.file.is_empty() && t.file != source.file => {}
1887
- ast::Item::Test(t) => {
1888
- // Leading "__" can never collide with a real Plum-declared name —
1889
- // `fn_identifier`/`var_identifier` must start with a lowercase letter.
1890
- let internal_name = format!("__test_{}", test_idx);
1891
- test_idx += 1;
1892
-
1893
- let mut stmts = Vec::with_capacity(t.body.stmts.len() + 2);
1894
- stmts.push(ast::Stmt::Assign(ast::Assign {
1895
- isSynthetic: false,
1896
- targets: vec![ast::AssignTarget::Var("__report".to_string())],
1897
- values: vec![strLit(String::new())],
1898
- declare: true,
1899
- ty: None,
1900
- }));
1901
- stmts.extend(desugarAssertStmts(&t.body.stmts));
1902
- stmts.push(ast::Stmt::Expr(
1903
- ast::Expr::Var("__report".to_string()),
1904
- 0,
1905
- 0,
1906
- ));
1907
1809
 
1908
- items.push(ast::Item::Fn(ast::Fn {
1810
+ pub use plum_core::tests_lower::{TestProgram, ASSERT_REPORT_MARK};
1811
+
1812
+ #[cfg(test)]
1909
- file: source.file.clone(),
1813
+ mod compileEntryTests {
1910
- name: internal_name.clone(),
1911
- line: t.line,
1912
- column: t.column,
1913
- type_param: None,
1914
- is_extern: false,
1915
- is_export: false,
1916
- is_override: false,
1917
- is_star: false,
1918
- has_self: true,
1919
- yields: None,
1814
+ use super::*;
1920
- params: ioParam.clone(),
1815
+ const SRC: &str = "import std/Str\nimport std/Int\n\nfun main() -> Int = 1 + 2\n\ntest \"a\"\n assert 1 == 1\n";
1816
+
1921
- returns: Some(ast::Type {
1817
+ fn load() -> ast::Source {
1922
- name: ast::STR_TYPE.to_string(),
1923
- generics: vec![],
1924
- }),
1925
- body: ast::FnBody::Block(ast::Block { stmts }),
1926
- }));
1927
- extra_exports.push((internal_name, format!("test#{}", t.name)));
1818
+ let dir = std::env::temp_dir().join(format!("plum_compile_entry_{}", std::process::id()));
1928
- }
1819
+ std::fs::create_dir_all(&dir).unwrap();
1820
+ let path = dir.join("main.plum");
1929
- other => items.push(other.clone()),
1821
+ std::fs::write(&path, SRC).unwrap();
1930
- }
1822
+ plum_core::loadAndMerge(&path, &plum_test_support::repoRoot()).expect("load")
1931
1823
  }
1932
- (
1933
- ast::Source {
1934
- file: source.file.clone(),
1935
- module: source.module.clone(),
1936
- imports: source.imports.clone(),
1937
- items,
1938
- topLevelOrder: Vec::new(),
1939
- formErrors: source.formErrors.clone(),
1940
- formUses: source.formUses.clone(),
1941
- stars: source.stars.clone(),
1942
- formLoops: source.formLoops,
1943
- primitives: source.primitives.clone(),
1944
- traits: source.traits.clone(),
1945
- },
1946
- extra_exports,
1947
- )
1948
- }
1949
1824
 
1950
- /// Recursively rewrites every `assert cond` (`cond` carrying its own literal
1825
+ #[test]
1951
- /// source text, captured at parse time — see `ast::Check`) into
1826
+ fn defaultMatchesCompileSource() {
1952
- /// `if !cond { __report = __report + "<text>\n" }`, descending into nested
1953
- /// control-flow blocks so an `assert` inside a `test`'s own
1827
+ let src = load();
1954
- /// `if`/`while`/`for`/`match` still records into the same hidden accumulator.
1955
- fn desugarAssertStmts(stmts: &[ast::Stmt]) -> Vec<ast::Stmt> {
1828
+ let out = compile(&src, &CompileOptions::default()).expect("compiles");
1956
- stmts.iter().flat_map(desugarAssertStmt).collect()
1829
+ assert!(out.test_names.is_empty());
1830
+ assert_eq!(out.wasm, compileSource(&src).expect("compiles"));
1957
- }
1831
+ }
1958
1832
 
1959
- fn desugarAssertStmt(stmt: &ast::Stmt) -> Vec<ast::Stmt> {
1960
- match stmt {
1833
+ #[test]
1961
- ast::Stmt::Assert(c) => {
1962
- // A comparison evaluates each operand exactly once into a
1963
- // temporary (left first), then compares and reports the temporaries.
1964
- static ASSERT_TMP: std::sync::atomic::AtomicUsize =
1965
- std::sync::atomic::AtomicUsize::new(0);
1966
- let mut pre = Vec::new();
1834
+ fn testsOptionNamesTests() {
1967
- let mut cond = c.cond.clone();
1968
- let (mut lhs, mut rhs) = (ast::Expr::Var(String::new()), ast::Expr::Var(String::new()));
1969
- let isNullLit = |e: &ast::Expr| matches!(e, ast::Expr::ErrorValue(v) if v.name == ast::NULL_ERR_NAME);
1970
- // `x == null` has no value to print for the null side: reported as a plain condition.
1971
- if let ast::Expr::Compare(cmp) = &c.cond {
1972
- if isNullLit(&cmp.left) || isNullLit(&cmp.right) {
1973
- // falls through with the condition kept whole
1974
- } else {
1975
- let n = ASSERT_TMP.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1976
- let (l, r) = (format!("__lhs_{}", n), format!("__rhs_{}", n));
1977
- for (name, e) in [(&l, &cmp.left), (&r, &cmp.right)] {
1978
- pre.push(ast::Stmt::Assign(ast::Assign {
1979
- isSynthetic: false,
1980
- targets: vec![ast::AssignTarget::Var(name.clone())],
1981
- values: vec![e.clone()],
1982
- declare: true,
1983
- ty: None,
1984
- }));
1985
- }
1986
- lhs = ast::Expr::Var(l);
1987
- rhs = ast::Expr::Var(r);
1988
- cond = ast::Expr::Compare(Box::new(ast::CompareExpr {
1989
- op: cmp.op.clone(),
1990
- left: lhs.clone(),
1835
+ let src = load();
1991
- right: rhs.clone(),
1992
- }));
1993
- }
1994
- }
1995
- let appendReport = ast::Stmt::Assign(ast::Assign {
1836
+ let out = compile(&src, &CompileOptions { tests: true, ..CompileOptions::default() }).expect("compiles");
1996
- isSynthetic: false,
1997
- targets: vec![ast::AssignTarget::Var("__report".to_string())],
1837
+ assert_eq!(out.test_names, vec!["a".to_string()]);
1998
- values: vec![ast::Expr::Binary(Box::new(ast::BinaryExpr {
1999
- op: ast::BinOp::Add,
2000
- left: ast::Expr::Var("__report".to_string()),
2001
- right: assertFailureMessage(c, &lhs, &rhs),
2002
- }))],
2003
- declare: false,
2004
- ty: None,
2005
- });
2006
- pre.push(ast::Stmt::If(ast::If {
2007
- condition: ast::Expr::Not(Box::new(cond)),
2008
- body: ast::Block {
2009
- stmts: vec![appendReport],
2010
- },
2011
- else_ifs: vec![],
2012
- else_: None,
2013
- }));
2014
- pre
2015
- }
2016
- ast::Stmt::If(i) => vec![ast::Stmt::If(ast::If {
2017
- condition: i.condition.clone(),
2018
- body: ast::Block {
2019
- stmts: desugarAssertStmts(&i.body.stmts),
2020
- },
2021
- else_ifs: i
2022
- .else_ifs
2023
- .iter()
2024
- .map(|ei| ast::ElseIf {
2025
- condition: ei.condition.clone(),
2026
- body: ast::Block {
2027
- stmts: desugarAssertStmts(&ei.body.stmts),
2028
- },
2029
- })
2030
- .collect(),
2031
- else_: i.else_.as_ref().map(|b| ast::Block {
2032
- stmts: desugarAssertStmts(&b.stmts),
2033
- }),
2034
- })],
2035
- ast::Stmt::While(w) => vec![ast::Stmt::While(ast::While {
2036
- condition: w.condition.clone(),
2037
- body: ast::Block {
2038
- stmts: desugarAssertStmts(&w.body.stmts),
2039
- },
2040
- })],
2041
- ast::Stmt::ForLoop(f) => vec![ast::Stmt::ForLoop(ast::ForLoop {
2042
- body: ast::Block {
2043
- stmts: desugarAssertStmts(&f.body.stmts),
2044
- },
2045
- ..f.clone()
2046
- })],
2047
- ast::Stmt::Match(m) => vec![ast::Stmt::Match(ast::Match {
2048
- subjects: m.subjects.clone(),
2049
- cases: m
2050
- .cases
2051
- .iter()
2052
- .map(|c| ast::Case {
2053
- patterns: c.patterns.clone(),
2054
- guard: c.guard.clone(),
2055
- body: ast::Block {
2056
- stmts: desugarAssertStmts(&c.body.stmts),
2057
- },
2058
- })
2059
- .collect(),
2060
- })],
2061
- other => vec![other.clone()],
2062
1838
  }
2063
1839
  }
2064
1840
 
@@ -4051,7 +3827,6 @@ impl<'a, 't> ClosureWalker<'a, 't> {
4051
3827
  ast::Expr::Not(inner) => self.walkExpr(inner),
4052
3828
  ast::Expr::Try(inner) => self.walkExpr(inner),
4053
3829
  ast::Expr::Unary(u) => self.walkExpr(&u.operand),
4054
- ast::Expr::Paren(inner) => self.walkExpr(inner),
4055
3830
  ast::Expr::InlineIf(t) => {
4056
3831
  self.walkExpr(&t.condition);
4057
3832
  self.walkExpr(&t.then);
@@ -4381,9 +4156,6 @@ fn fvCollectRefsExpr(
4381
4156
  ast::Expr::Unary(u) => {
4382
4157
  fvCollectRefsExpr(&u.operand, bound, seen, free, env, fn_decls, fn_locals)
4383
4158
  }
4384
- ast::Expr::Paren(inner) => {
4385
- fvCollectRefsExpr(inner, bound, seen, free, env, fn_decls, fn_locals)
4386
- }
4387
4159
  ast::Expr::InlineIf(t) => {
4388
4160
  fvCollectRefsExpr(&t.condition, bound, seen, free, env, fn_decls, fn_locals);
4389
4161
  if let ast::Expr::Bind(b) = &t.condition {
@@ -4711,7 +4483,6 @@ impl<'a> Collector<'a> {
4711
4483
  self.walkExpr(inner);
4712
4484
  }
4713
4485
  ast::Expr::Unary(u) => self.walkExpr(&u.operand),
4714
- ast::Expr::Paren(inner) => self.walkExpr(inner),
4715
4486
  ast::Expr::InlineIf(t) => {
4716
4487
  self.walkExpr(&t.condition);
4717
4488
  self.walkExpr(&t.then);
@@ -7045,7 +6816,6 @@ fn calleeText(e: &ast::Expr) -> String {
7045
6816
  ast::AttrKind::Field(f) => f.clone(),
7046
6817
  },
7047
6818
  ast::Expr::Var(n) => n.clone(),
7048
- ast::Expr::Paren(i) => calleeText(i),
7049
6819
  _ => "value".to_string(),
7050
6820
  }
7051
6821
  }
@@ -7414,9 +7184,6 @@ fn compileExprCore(
7414
7184
  }
7415
7185
  }
7416
7186
  }
7417
- ast::Expr::Paren(inner) => {
7418
- compileExpr(inner, body, ctx, state)?;
7419
- }
7420
7187
  // `expr?` — if the error code is non-zero, return it from the enclosing
7421
7188
  // function (see `compileErrTry`); otherwise the success value is the result.
7422
7189
  ast::Expr::Try(inner) => compileErrTry(expr, inner, body, ctx, state)?,
@@ -8176,7 +7943,6 @@ fn valueLocalPlace(expr: &ast::Expr, ctx: &LocalCtx) -> Option<(u32, PlumType)>
8176
7943
  let ty = ctx.local_decl_types.get("self").filter(|t| isValueTy(t))?;
8177
7944
  Some((*ctx.locals.get("self")?, ty.clone()))
8178
7945
  }
8179
- ast::Expr::Paren(inner) => valueLocalPlace(inner, ctx),
8180
7946
  ast::Expr::Attribute(a) => match &a.attr {
8181
7947
  ast::AttrKind::Field(f) => {
8182
7948
  let (base, bty) = valueLocalPlace(&a.object, ctx)?;
plum-wasm-codegen/tests/codegen_tests.rs CHANGED
@@ -1,5 +1,9 @@
1
1
  #![allow(non_snake_case)]
2
- use plum_wasm_codegen::{compileSource, compileTestSource};
2
+ use plum_wasm_codegen::{compile, compileSource, CompileOptions};
3
+
4
+ fn compileTestSource(source: &plum_core::ast::Source) -> Result<(Vec<u8>, Vec<String>), String> {
5
+ compile(source, &CompileOptions { tests: true, ..CompileOptions::default() }).map(|o| (o.wasm, o.test_names))
6
+ }
3
7
  use wasm_encoder::Encode;
4
8
 
5
9
  use plum_test_support::{engine as gcEngine, parse, repoRoot};
@@ -1079,7 +1083,8 @@ fn compiledKeys(name: &str, src: &str) -> (Vec<u8>, Vec<String>) {
1079
1083
  let path = dir.join("main.plum");
1080
1084
  std::fs::write(&path, src).unwrap();
1081
1085
  let source = plum_core::loadAndMerge(&path, &repoRoot()).expect("load");
1082
- let r = plum_wasm_codegen::compileSourceWithFunctions(&source).expect("compile");
1086
+ let o = compile(&source, &CompileOptions::default()).expect("compile");
1087
+ let r = (o.wasm, o.fn_keys);
1083
1088
  let _ = std::fs::remove_dir_all(&dir);
1084
1089
  wasmparser::validate(&r.0).expect("valid wasm");
1085
1090
  r