plum

#treesitter#compiler#wasm

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

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


6b983a7 — Peter John 2026-11-02T19:20:27+05:30
checker: infer field-assignment statements in closure bodies
docs/plans/2026-11-02-traits.review.md CHANGED
@@ -42,6 +42,7 @@ for e := range scene.entities
42
42
  | Code generation | Emits the field reads and writes, and checks the dispatch and conversion paths reused from union enums | M | a careful helper |
43
43
  | Formatter and editor | Formats traits idempotently; hover, completion and go-to-definition for embedded fields | M | a fast helper |
44
44
  | Docs and example | README section, an example program with tests, and a game-style example (Scene of Entity values) | S | a fast helper |
45
+ | Scene → `Actor` trait | Migrates `std/game/Scene` from closure-based nodes (`act: fn(Node, Float)`) to libGDX Scene2D-style subclassing: a `trait Actor(x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, children: List[Actor], …)` with default `act`/`draw`/`hit` methods that games override by embedding it (`record Unit(Actor, hp: Int)`); keeps z-index, `sortChildren`, culling and `Stage`; updates `plum-examples/scene.plum`, the stress fixtures and the README; benchmarks against the closure version | M | a careful helper |
45
46
 
46
47
  ## How it fits together
47
48
  ```mermaid
@@ -75,6 +76,7 @@ flowchart TB
75
76
  ED[Editor support]
76
77
  end
77
78
  D[Docs and examples]
79
+ SC["Scene -> Actor trait (after the closure field-assignment fix)"]
78
80
  G --> TL
79
81
  G --> F
80
82
  TL --> CK
@@ -83,6 +85,7 @@ flowchart TB
83
85
  FA --> CG
84
86
  CK --> ED
85
87
  CG --> D
88
+ CG --> SC
86
89
  ```
87
90
 
88
91
  ## Decisions you can change
@@ -101,6 +104,8 @@ flowchart TB
101
104
  - **Union rules leaking into traits.** Union enums are exhaustive and allow any shared method. Traits need the opposite on both counts. The checker keeps an explicit "this union came from a trait" mark, and tests cover both rules.
102
105
  - **Grammar ambiguity.** A bare name in a record's field list next to `name: Type` fields. We regenerate with optimizations disabled (a known landmine here) and run the full corpus.
103
106
  - **Name clashes after copying.** Copied default methods and fields could clash with the record's own. Lowering checks this first and reports the error at the record, not at a generated copy.
107
+ - **A trait whose field holds its own type.** `Actor` has `children: List[Actor]` (a Scene2D group is an actor with children). The lowering and the GC type registry must handle that recursion. A test covers a three-level tree of mixed game records.
108
+ - **Scene performance.** Each `add` boxes the record once, not once per frame. Draw and act dispatch on the record type instead of calling a closure. The Scene benchmark (5k and 20k with culling, in `plum-game/web-test/BENCHMARK.md`) is re-run against the closure version. If the trait version is slower by more than 10%, the box-free subtyping option above becomes part of this work.
104
109
  - **Same record in a trait and a union enum.** Both use hidden wrapping, which could produce two candidate conversions. Tests cover a record that is both an `Entity` and a member of an enum.
105
110
 
106
111
  ## Out of scope
@@ -117,6 +122,7 @@ flowchart TB
117
122
  - Each error listed under "What you will get" has a test asserting its message and position.
118
123
  - A `match` on a trait without `_` is rejected. With `_`, it narrows to the record and binds its fields.
119
124
  - `plum format` on the new example makes no changes when run twice.
125
+ - `std/game/Scene` is built on `trait Actor`. A game record that embeds `Actor` and overrides `act` and `draw`, e.g. `record Walker(Actor, speed: Float)`, works in a `Stage` exactly like a libGDX `Actor` subclass. Z-index, `sortChildren`, culling and `hit` keep their tests green. `plum-examples/scene.plum` uses it and passes the browser check. The Scene benchmark is recorded for both versions.
120
126
  - The full Plum and Rust test suites and the tree-sitter corpus pass.
121
127
 
122
128
  ## Rough time and cost
plum-checker/src/lib.rs CHANGED
@@ -4441,7 +4441,9 @@ fn inferExprInner(
4441
4441
  // `Assign` may declare a name the final statement depends on.
4442
4442
  if let Some((_, earlier)) = cl.body.stmts.split_last() {
4443
4443
  for stmt in earlier {
4444
+ let declares_only = matches!(stmt, ast::Stmt::Assign(a)
4445
+ if a.targets.iter().all(|t| matches!(t, ast::AssignTarget::Var(_))));
4444
- if let ast::Stmt::Assign(a) = stmt {
4446
+ if let (true, ast::Stmt::Assign(a)) = (declares_only, stmt) {
4445
4447
  for (target, value) in a.targets.iter().zip(a.values.iter()) {
4446
4448
  if let ast::AssignTarget::Var(name) = target {
4447
4449
  let ann = a.ty.as_ref().map(plumTypeFromAstG);
@@ -4451,7 +4453,7 @@ fn inferExprInner(
4451
4453
  }
4452
4454
  } else {
4453
4455
  // Walk every other statement (`if`, loops, `match`, plain
4454
- // calls) so the types of the expressions inside are
4456
+ // calls, field assignments like `m.x = k`) so the types of the expressions inside are
4455
4457
  // recorded for codegen. Errors are deliberately dropped:
4456
4458
  // the closure's params may still be unresolved here.
4457
4459
  let _ = checkStmt(
plum-checker/tests/checker_tests.rs CHANGED
@@ -931,3 +931,43 @@ fn discardTargetTwiceInOneScopeIsFine() {
931
931
  .is_ok()
932
932
  );
933
933
  }
934
+
935
+ /// A field assignment before the last statement of a closure body (`m.x = k`)
936
+ /// records the types of its object and value, as every other statement does —
937
+ /// codegen types the closure's captures (`m`, `k`) from them.
938
+ #[test]
939
+ fn closure_earlier_field_assign_records_types() {
940
+ let src = "\
941
+ record Pair2(x: Int, y: Int)
942
+
943
+ fun main() -> Int =
944
+ m := Pair2(x: 0, y: 0)
945
+ k := 5
946
+ f := ||
947
+ m.x = k
948
+ m.y = k + 1
949
+ f()
950
+ m.y
951
+ ";
952
+ let source = parseClean(src);
953
+ let info = typedSource(&source).unwrap_or_else(|e| {
954
+ panic!("{:?}", e.iter().map(|x| x.message.clone()).collect::<Vec<_>>())
955
+ });
956
+ let checked: &Source = info.lowered.as_deref().unwrap_or(&source);
957
+ let main = checked
958
+ .items
959
+ .iter()
960
+ .find_map(|i| match i {
961
+ Item::Fn(f) if f.name == "main" => Some(f),
962
+ _ => None,
963
+ })
964
+ .expect("main");
965
+ let FnBody::Block(b) = &main.body else { panic!("main has a block body") };
966
+ let Stmt::Assign(fa) = &b.stmts[2] else { panic!("f := closure") };
967
+ let Expr::Closure(cl) = &fa.values[0] else { panic!("closure literal") };
968
+ let Stmt::Assign(first) = &cl.body.stmts[0] else { panic!("m.x = k") };
969
+ let AssignTarget::Field(object, _) = &first.targets[0] else { panic!("field target") };
970
+ let ty = |e: &Expr| info.expr_types.get(&(e as *const Expr as usize)).cloned();
971
+ assert_eq!(ty(object), Some(PlumType::TNamed("Pair2".to_string())));
972
+ assert_eq!(ty(&first.values[0]), Some(PlumType::TInt));
973
+ }
plum-wasm-codegen/tests/codegen_tests.rs CHANGED
@@ -3201,3 +3201,56 @@ fun main() -> Int =
3201
3201
  assert!(exportsMemory("mem_with", with));
3202
3202
  assert!(!exportsMemory("mem_without", without));
3203
3203
  }
3204
+
3205
+ // ---- closures assigning fields of captured values ----
3206
+
3207
+ const CAPTURED_FIELDS: &str = "\
3208
+ record Pair3(x: Int, y: Int, z: Int)
3209
+ record Inner(x: Int)
3210
+ record Outer(inner: Inner, n: Int)
3211
+ struct Pt(x: Int, y: Int)
3212
+ ";
3213
+
3214
+ fn runCapturedFields(name: &str, main_body: &str) -> i64 {
3215
+ let src = format!("{}{}\nfun main() -> Int =\n{}", STD_HEAD, CAPTURED_FIELDS, main_body);
3216
+ runMerged::<i64>(name, &src)
3217
+ }
3218
+
3219
+ #[test]
3220
+ fn closure_assigns_two_fields_of_captured_record() {
3221
+ let body = " m := Pair3(x: 0, y: 0, z: 0)\n f := ||\n m.x = 5\n m.y = 6\n f()\n m.x * 10 + m.y\n";
3222
+ assert_eq!(runCapturedFields("capfields2", body), 56);
3223
+ }
3224
+
3225
+ #[test]
3226
+ fn closure_assigns_three_fields_of_captured_record() {
3227
+ let body = " m := Pair3(x: 0, y: 0, z: 0)\n f := ||\n m.x = 1\n m.y = 2\n m.z = 3\n f()\n m.x * 100 + m.y * 10 + m.z\n";
3228
+ assert_eq!(runCapturedFields("capfields3", body), 123);
3229
+ }
3230
+
3231
+ #[test]
3232
+ fn closure_assigns_fields_from_captured_local() {
3233
+ let body = " m := Pair3(x: 0, y: 0, z: 0)\n k := 5\n f := ||\n m.x = k\n m.y = k + 1\n f()\n m.x * 10 + m.y\n";
3234
+ assert_eq!(runCapturedFields("capfieldsk", body), 56);
3235
+ }
3236
+
3237
+ #[test]
3238
+ fn closure_assigns_nested_record_field() {
3239
+ let body = " o := Outer(inner: Inner(x: 0), n: 0)\n f := ||\n o.inner.x = 1\n o.n = 2\n f()\n o.inner.x * 10 + o.n\n";
3240
+ assert_eq!(runCapturedFields("capfieldsnested", body), 12);
3241
+ }
3242
+
3243
+ #[test]
3244
+ fn closure_assigning_captured_record_fields_runs_twice() {
3245
+ let body = " m := Pair3(x: 0, y: 0, z: 0)\n f := ||\n m.x = m.x + 1\n m.y = m.y + 2\n f()\n f()\n m.x * 10 + m.y\n";
3246
+ assert_eq!(runCapturedFields("capfieldstwice", body), 24);
3247
+ }
3248
+
3249
+ /// A struct is a value: the closure captures a copy when it is created, and each
3250
+ /// call starts from that copy. Field writes inside the closure are seen by the
3251
+ /// rest of that call only — not by the next call, nor by the enclosing scope.
3252
+ #[test]
3253
+ fn closure_assigning_captured_struct_fields_writes_its_own_copy() {
3254
+ let body = " p := Pt(x: 0, y: 0)\n f := ||\n p.x = p.x + 1\n p.y = p.y + 2\n p.x * 10 + p.y\n a := f()\n b := f()\n a * 10000 + b * 100 + p.x * 10 + p.y\n";
3255
+ assert_eq!(runCapturedFields("capfieldsstruct", body), 121200);
3256
+ }