plum
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
|
+
}
|