plum

#treesitter#compiler#wasm

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

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


0750af7Peter John 2026-07-23T18:10:53+05:30
docs: add implementation plan for field/attribute assignment targets
docs/superpowers/plans/2026-07-23-field-assignment.md ADDED
@@ -0,0 +1,777 @@
1
+ # Field/Attribute Assignment Target Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `obj.field = value` a valid assignment target end-to-end (grammar → parser → checker → codegen), so methods like `libs/std/list.plum`'s can mutate `self`'s fields.
6
+
7
+ **Architecture:** Add a `field_target` grammar rule (an "attribute with no call args", used only on the assignment LHS) and a new `AssignTarget` AST enum (`Var(String)` / `Field(Box<Expr>, String)`). Thread the new variant through the checker's single `Stmt::Assign` arm and codegen's five `Stmt::Assign` match arms (a sixth site, `scan_stmt_for_param_types`, only reads `a.values` and needs no change). The field-write itself reuses the exact offset arithmetic already used for field reads (`Expr::Attribute`/`AttrKind::Field`) and class-literal field init in `plum-wasm-codegen/src/lib.rs`.
8
+
9
+ **Tech Stack:** Rust, tree-sitter (grammar.js + generated C parser), wasm-encoder/wasmparser, wasmtime (test execution).
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: `docs/superpowers/specs/2026-07-23-field-assignment-design.md`
14
+ - Scope is exactly: `<object-expr>.<field> = <value>` as an assignment target (including chains like `self.head.value = x`, which fall out for free). Comma-separated multi-assign continues to work, mixing var and field targets.
15
+ - Out of scope: variadic parameters, `List`'s other `todo` methods, index/array assignment, enum-payload mutation — do not touch these.
16
+ - Every new/changed error message must follow the existing message shape used in the touched function (e.g. checker: `"fn '{}': assign '{}': {}"`; codegen: `"codegen: ..."`).
17
+ - Run the full workspace test suite (`cargo test --workspace`) after every task that touches Rust code — all pre-existing tests must keep passing throughout, not just the new ones.
18
+
19
+ ---
20
+
21
+ ### Task 1: Grammar — `field_target` rule and updated `assign` rule
22
+
23
+ **Files:**
24
+ - Modify: `tooling/tree-sitter-plum/grammar.js` (the `assign` rule, ~line 202)
25
+ - Test: `tooling/tree-sitter-plum/test/corpus/assign.txt` (append new corpus cases)
26
+
27
+ **Interfaces:**
28
+ - Produces: a new named grammar node `field_target` with fields `object` (a `primary_expression`) and `member` (an `fn_identifier`), and an `assign` rule whose LHS is `commaSep1(choice($.var_identifier, $.field_target))`. Task 2's parser code matches on these two node kinds by name (`"var_identifier"` / `"field_target"`).
29
+
30
+ - [ ] **Step 1: Add the failing corpus test**
31
+
32
+ Append to `tooling/tree-sitter-plum/test/corpus/assign.txt`:
33
+
34
+ ```
35
+ ================================================================================
36
+ field assignment target
37
+ ================================================================================
38
+
39
+ main() =
40
+ self.head = value
41
+ self.head.value = x
42
+ a, self.field = 1, 2
43
+
44
+ --------------------------------------------------------------------------------
45
+
46
+ (source
47
+ (fn
48
+ (fn_identifier)
49
+ (body
50
+ (assign
51
+ (field_target
52
+ (primary_expression
53
+ (self))
54
+ (fn_identifier))
55
+ (expression
56
+ (primary_expression
57
+ (var_identifier))))
58
+ (assign
59
+ (field_target
60
+ (primary_expression
61
+ (attribute
62
+ (primary_expression
63
+ (self))
64
+ (fn_identifier)))
65
+ (fn_identifier))
66
+ (expression
67
+ (primary_expression
68
+ (var_identifier))))
69
+ (assign
70
+ (var_identifier)
71
+ (field_target
72
+ (primary_expression
73
+ (var_identifier))
74
+ (fn_identifier))
75
+ (expression
76
+ (primary_expression
77
+ (integer)))
78
+ (expression
79
+ (primary_expression
80
+ (integer)))))))
81
+ ```
82
+
83
+ - [ ] **Step 2: Run the corpus test to verify it fails**
84
+
85
+ Run: `cd tooling/tree-sitter-plum && make corpus_test`
86
+ Expected: FAIL — `assign` doesn't yet parse `field_target` (either a parse error on `self.head = value`, or a mismatched-tree failure against the expected output above).
87
+
88
+ - [ ] **Step 3: Add the grammar rule**
89
+
90
+ In `tooling/tree-sitter-plum/grammar.js`, replace the `assign` rule (~line 202) with:
91
+
92
+ ```js
93
+ field_target: ($) =>
94
+ seq(
95
+ field("object", $.primary_expression),
96
+ ".",
97
+ field("member", $.fn_identifier),
98
+ ),
99
+
100
+ assign: ($) =>
101
+ seq(
102
+ commaSep1(choice($.var_identifier, $.field_target)),
103
+ "=",
104
+ commaSep1($.expression),
105
+ ),
106
+ ```
107
+
108
+ - [ ] **Step 4: Regenerate and run the corpus test**
109
+
110
+ Run: `cd tooling/tree-sitter-plum && make corpus_test`
111
+ Expected: PASS. If the actual tree shape printed by the failure differs from Step 1's expected output (e.g. field ordering), update the corpus file's expected tree to match tree-sitter's actual canonical output rather than fighting the generator — the goal is a parse for `self.head.value = x` where the outer `field_target`'s `object` is an `attribute` node wrapping the inner `self.head`.
112
+
113
+ - [ ] **Step 5: Run the full existing corpus suite**
114
+
115
+ Run: `cd tooling/tree-sitter-plum && make corpus_test`
116
+ Expected: PASS — all pre-existing `.txt` corpus files (assert, const, enum, for, function, if, literals, match, trait, type, while) still pass unchanged.
117
+
118
+ - [ ] **Step 6: Commit**
119
+
120
+ ```bash
121
+ git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/test/corpus/assign.txt tooling/tree-sitter-plum/src tooling/tree-sitter-plum/bindings
122
+ git commit -m "feat(tree-sitter-plum): add field_target rule for obj.field = value assignment"
123
+ ```
124
+
125
+ (`tree-sitter generate` regenerates `src/parser.c`/`src/grammar.json`/`src/node-types.json` — stage whatever files it changed under `src/` and `bindings/`.)
126
+
127
+ ---
128
+
129
+ ### Task 2: AST — `AssignTarget` enum
130
+
131
+ **Files:**
132
+ - Modify: `plum-core/src/ast.rs` (the `Assign` struct, ~line 155)
133
+ - Modify: `plum-core/src/parser.rs` (`parse_assign`, ~line 351)
134
+ - Test: `plum-core` has no dedicated parser unit tests today — verification for this task is via the downstream checker/codegen tests in Tasks 3–4, which exercise `parse_assign` transitively. Do not add a `plum-core`-only test; go straight to compiling and running `cargo build --workspace` to confirm the new enum compiles and every existing match on `Assign.targets`/`AssignTarget` (there are none yet outside this crate) still type-checks after this task alone (it won't — Tasks 3/4 fix the call sites; that's expected and is why Task 2 ends with a build-only check, not a full test run).
135
+
136
+ **Interfaces:**
137
+ - Consumes: nothing new.
138
+ - Produces: `pub enum AssignTarget { Var(String), Field(Box<Expr>, String) }` and `pub struct Assign { pub targets: Vec<AssignTarget>, pub values: Vec<Expr> }` (replacing `pub targets: Vec<String>`). Every downstream task matches on `AssignTarget::Var(name)` / `AssignTarget::Field(object, field_name)`.
139
+
140
+ - [ ] **Step 1: Change the AST types**
141
+
142
+ In `plum-core/src/ast.rs`, replace:
143
+
144
+ ```rust
145
+ #[derive(Debug, Clone, PartialEq)]
146
+ pub struct Assign {
147
+ pub targets: Vec<String>,
148
+ pub values: Vec<Expr>,
149
+ }
150
+ ```
151
+
152
+ with:
153
+
154
+ ```rust
155
+ #[derive(Debug, Clone, PartialEq)]
156
+ pub enum AssignTarget {
157
+ Var(String),
158
+ /// `object.field = value` — `object`'s evaluated type must be a class; `field`
159
+ /// is that class's field name being written.
160
+ Field(Box<Expr>, String),
161
+ }
162
+
163
+ #[derive(Debug, Clone, PartialEq)]
164
+ pub struct Assign {
165
+ pub targets: Vec<AssignTarget>,
166
+ pub values: Vec<Expr>,
167
+ }
168
+ ```
169
+
170
+ - [ ] **Step 2: Update `parse_assign`**
171
+
172
+ In `plum-core/src/parser.rs`, replace `parse_assign` (~line 351):
173
+
174
+ ```rust
175
+ fn parse_assign(&self, node: Node) -> Assign {
176
+ // assign: commaSep1(choice(var_identifier, field_target)) "=" commaSep1(expression)
177
+ // Named children are all targets (var_identifier | field_target) then all
178
+ // expressions. We split at the first child that is neither.
179
+ let mut cursor = node.walk();
180
+ let named: Vec<Node> = node.named_children(&mut cursor).collect();
181
+ let split = named
182
+ .iter()
183
+ .position(|n| n.kind() != "var_identifier" && n.kind() != "field_target")
184
+ .unwrap_or(named.len());
185
+ let targets = named[..split]
186
+ .iter()
187
+ .map(|n| self.parse_assign_target(*n))
188
+ .collect();
189
+ let values = named[split..]
190
+ .iter()
191
+ .map(|n| { let u = self.unwrap_expr_node(*n); self.parse_expression(u) })
192
+ .collect();
193
+ Assign { targets, values }
194
+ }
195
+
196
+ fn parse_assign_target(&self, node: Node) -> AssignTarget {
197
+ match node.kind() {
198
+ "field_target" => {
199
+ // field_target: object: primary_expression "." member: fn_identifier
200
+ let object_node = node.child_by_field_name("object").expect("field_target has an object");
201
+ let member = node
202
+ .child_by_field_name("member")
203
+ .map(|n| self.text(n))
204
+ .unwrap_or_default();
205
+ let object = self.parse_primary_expression(self.unwrap_expr_node(object_node));
206
+ AssignTarget::Field(Box::new(object), member)
207
+ }
208
+ _ => AssignTarget::Var(self.text(node)),
209
+ }
210
+ }
211
+ ```
212
+
213
+ - [ ] **Step 3: Build the workspace**
214
+
215
+ Run: `cargo build --workspace 2>&1 | tail -60`
216
+ Expected: `plum-core` builds. `plum-checker` and `plum-wasm-codegen` fail to build with errors about `a.targets` no longer being `Vec<String>` (e.g. `expected String, found AssignTarget` / no method `.clone()` producing a `String`) — this is expected; Tasks 3 and 4 fix those crates. Confirm the *only* new errors are in `plum-checker/src/lib.rs`, `plum-checker/src/monomorphize.rs`, and `plum-wasm-codegen/src/lib.rs`.
217
+
218
+ - [ ] **Step 4: Commit**
219
+
220
+ ```bash
221
+ git add plum-core/src/ast.rs plum-core/src/parser.rs
222
+ git commit -m "feat(plum-core): parse obj.field assignment targets into AssignTarget::Field"
223
+ ```
224
+
225
+ ---
226
+
227
+ ### Task 3: Checker — type-check field assignment targets
228
+
229
+ **Files:**
230
+ - Modify: `plum-checker/src/lib.rs` (`check_stmt`'s `Stmt::Assign` arm, ~line 246)
231
+ - Modify: `plum-checker/src/monomorphize.rs` (the `Stmt::Assign` arm at ~line 380 — see Step 1 below for what it needs)
232
+ - Test: `plum-checker/tests/checker_tests.rs`
233
+
234
+ **Interfaces:**
235
+ - Consumes: `ast::AssignTarget::{Var, Field}` from Task 2; `plum_checker::{infer_expr, unify, CheckCtx, ClassEnv}` (already defined in `plum-checker/src/lib.rs`).
236
+ - Produces: `check_stmt` correctly type-checks both target kinds; no new public functions.
237
+
238
+ - [ ] **Step 1: Fix `monomorphize.rs`'s `Stmt::Assign` arm**
239
+
240
+ `plum-checker/src/monomorphize.rs`'s `rewrite_stmt` (~line 378-386) currently reads:
241
+
242
+ ```rust
243
+ ast::Stmt::Assign(a) => {
244
+ for (target, value) in a.targets.iter().zip(a.values.iter_mut()) {
245
+ self.rewrite_expr(value, env)?;
246
+ let ty = self.infer(value, env);
247
+ env.insert(target.clone(), TypeScheme::mono(ty));
248
+ }
249
+ }
250
+ ```
251
+
252
+ `rewrite_expr` mutably rewrites generic-call mangling (e.g. `List(Int)` specialization) inside an expression, and `target.clone()` is used as the new binding's env key — both assume `target: &String`. Replace with:
253
+
254
+ ```rust
255
+ ast::Stmt::Assign(a) => {
256
+ for (target, value) in a.targets.iter_mut().zip(a.values.iter_mut()) {
257
+ self.rewrite_expr(value, env)?;
258
+ let ty = self.infer(value, env);
259
+ match target {
260
+ ast::AssignTarget::Var(name) => {
261
+ env.insert(name.clone(), TypeScheme::mono(ty));
262
+ }
263
+ ast::AssignTarget::Field(object, _) => {
264
+ self.rewrite_expr(object, env)?;
265
+ }
266
+ }
267
+ }
268
+ }
269
+ ```
270
+
271
+ (`a.targets.iter_mut()` instead of `.iter()`, since `AssignTarget::Field`'s boxed object expression needs the same mutable generic-mangling rewrite as any other expression — a plain `Var` target has no expression to rewrite, so its arm ignores the `&mut` and just reads the name.)
272
+
273
+ - [ ] **Step 2: Write the failing checker tests**
274
+
275
+ Add to `plum-checker/tests/checker_tests.rs`:
276
+
277
+ ```rust
278
+ #[test]
279
+ fn field_assignment_target_with_matching_type_passes() {
280
+ let src = "\
281
+ type Cat =
282
+ name: Str
283
+ age: Int
284
+
285
+ haveBirthday<Cat>() =
286
+ self.age = self.age + 1
287
+ ";
288
+ let source = parse(src);
289
+ assert!(check_source(&source).is_ok(), "expected Ok");
290
+ }
291
+
292
+ #[test]
293
+ fn field_assignment_target_with_mismatched_type_is_error() {
294
+ let src = "\
295
+ type Cat =
296
+ name: Str
297
+ age: Int
298
+
299
+ breakCat<Cat>() =
300
+ self.age = \"oops\"
301
+ ";
302
+ let source = parse(src);
303
+ let result = check_source(&source);
304
+ assert!(result.is_err());
305
+ }
306
+
307
+ #[test]
308
+ fn field_assignment_target_unknown_field_is_error() {
309
+ let src = "\
310
+ type Cat =
311
+ name: Str
312
+ age: Int
313
+
314
+ breakCat<Cat>() =
315
+ self.nope = 1
316
+ ";
317
+ let source = parse(src);
318
+ let result = check_source(&source);
319
+ assert!(result.is_err());
320
+ }
321
+ ```
322
+
323
+ - [ ] **Step 3: Run the tests to verify they fail (or fail to compile)**
324
+
325
+ Run: `cargo test -p plum-checker field_assignment 2>&1 | tail -60`
326
+ Expected: compile error (the crate doesn't build yet from Task 2's fallout) or, once you provisionally stub `check_stmt`'s new arm just enough to compile, a test failure because field targets aren't actually validated yet.
327
+
328
+ - [ ] **Step 4: Fix `check_stmt`'s `Stmt::Assign` arm**
329
+
330
+ In `plum-checker/src/lib.rs`, replace the arm (~line 246):
331
+
332
+ ```rust
333
+ ast::Stmt::Assign(a) => {
334
+ for (target, value) in a.targets.iter().zip(a.values.iter()) {
335
+ match target {
336
+ ast::AssignTarget::Var(name) => {
337
+ match infer_expr(value, env, ctx) {
338
+ Ok(t) => { env.insert(name.clone(), TypeScheme::mono(t)); }
339
+ Err(msg) => errors.push(CheckError { message: format!("fn '{}': assign '{}': {}", fn_name, name, msg) }),
340
+ }
341
+ }
342
+ ast::AssignTarget::Field(object, field_name) => {
343
+ let label = format!("{}.{}", describe_target_object(object), field_name);
344
+ match (infer_expr(object, env, ctx), infer_expr(value, env, ctx)) {
345
+ (Ok(PlumType::TNamed(class_name)), Ok(value_ty)) => {
346
+ match ctx.classes.get(&class_name).and_then(|fields| {
347
+ fields.iter().find(|(n, _)| n == field_name).map(|(_, ty)| ty.clone())
348
+ }) {
349
+ Some(field_ty) => {
350
+ if let Err(msg) = unify(&field_ty, &value_ty) {
351
+ errors.push(CheckError { message: format!("fn '{}': assign '{}': {}", fn_name, label, msg) });
352
+ }
353
+ }
354
+ None => errors.push(CheckError { message: format!("fn '{}': assign '{}': no field '{}' on class '{}'", fn_name, label, field_name, class_name) }),
355
+ }
356
+ }
357
+ (Ok(other), Ok(_)) => errors.push(CheckError { message: format!("fn '{}': assign '{}': cannot access field on non-class type {}", fn_name, label, other) }),
358
+ (Err(msg), _) => errors.push(CheckError { message: format!("fn '{}': assign '{}': {}", fn_name, label, msg) }),
359
+ (_, Err(msg)) => errors.push(CheckError { message: format!("fn '{}': assign '{}': {}", fn_name, label, msg) }),
360
+ }
361
+ }
362
+ }
363
+ }
364
+ }
365
+ ```
366
+
367
+ Add this small helper near `check_stmt` (used only for the error-message label above — it does not need to handle every `Expr` variant, only the ones that can appear as a `field_target`'s object per the grammar: `self`, a variable, or a nested attribute):
368
+
369
+ ```rust
370
+ fn describe_target_object(expr: &ast::Expr) -> String {
371
+ match expr {
372
+ ast::Expr::Self_ => "self".to_string(),
373
+ ast::Expr::Var(n) => n.clone(),
374
+ ast::Expr::Attribute(a) => {
375
+ if let ast::AttrKind::Field(f) = &a.attr {
376
+ format!("{}.{}", describe_target_object(&a.object), f)
377
+ } else {
378
+ "<expr>".to_string()
379
+ }
380
+ }
381
+ _ => "<expr>".to_string(),
382
+ }
383
+ }
384
+ ```
385
+
386
+ - [ ] **Step 5: Run the new tests**
387
+
388
+ Run: `cargo test -p plum-checker field_assignment 2>&1 | tail -40`
389
+ Expected: PASS (3 tests).
390
+
391
+ - [ ] **Step 6: Run the full checker test suite**
392
+
393
+ Run: `cargo test -p plum-checker 2>&1 | tail -60`
394
+ Expected: all tests PASS (pre-existing tests unaffected).
395
+
396
+ - [ ] **Step 7: Commit**
397
+
398
+ ```bash
399
+ git add plum-checker/src/lib.rs plum-checker/src/monomorphize.rs plum-checker/tests/checker_tests.rs
400
+ git commit -m "feat(plum-checker): type-check obj.field assignment targets"
401
+ ```
402
+
403
+ ---
404
+
405
+ ### Task 4: Codegen — compile field assignment targets
406
+
407
+ **Files:**
408
+ - Modify: `plum-wasm-codegen/src/lib.rs` — five `Stmt::Assign` sites:
409
+ - `ClosureWalker::walk_stmt` (~line 940)
410
+ - `fv_collect_bound_block` (~line 1336)
411
+ - `fv_collect_refs_block` (~line 1392)
412
+ - `Collector::walk_stmt` (~line 1573)
413
+ - `compile_stmt` (~line 1982, the emission pass)
414
+ - Test: `plum-wasm-codegen/tests/codegen_tests.rs`
415
+
416
+ **Interfaces:**
417
+ - Consumes: `ast::AssignTarget::{Var, Field}`; the existing field-offset lookup pattern already used at `Expr::Attribute`/`AttrKind::Field` (~line 2772-2796) and class-literal field init (~line 2760-2768) — reuse it verbatim, don't invent new offset math.
418
+ - Produces: `compile_stmt` correctly emits a field store for `AssignTarget::Field`; no new public functions.
419
+
420
+ - [ ] **Step 1: Write the failing codegen tests**
421
+
422
+ Add to `plum-wasm-codegen/tests/codegen_tests.rs`:
423
+
424
+ ```rust
425
+ #[test]
426
+ fn field_assignment_target_runs_correctly() {
427
+ let src = "\
428
+ type Counter =
429
+ value: Int
430
+
431
+ bump<Counter>() =
432
+ self.value = self.value + 1
433
+
434
+ main() -> Int =
435
+ c = Counter(value: 41)
436
+ c.bump()
437
+ c.value
438
+ ";
439
+ let source = parse(src);
440
+ let bytes = compile_source(&source).expect("compile failed");
441
+ assert_eq!(run_main(&bytes), 42);
442
+ }
443
+
444
+ #[test]
445
+ fn chained_field_assignment_target_runs_correctly() {
446
+ let src = "\
447
+ type Inner =
448
+ value: Int
449
+
450
+ type Outer =
451
+ inner: Inner
452
+
453
+ bump<Outer>() =
454
+ self.inner.value = self.inner.value + 1
455
+
456
+ main() -> Int =
457
+ o = Outer(inner: Inner(value: 9))
458
+ o.bump()
459
+ o.inner.value
460
+ ";
461
+ let source = parse(src);
462
+ let bytes = compile_source(&source).expect("compile failed");
463
+ assert_eq!(run_main(&bytes), 10);
464
+ }
465
+
466
+ #[test]
467
+ fn mixed_multi_assign_with_field_target_runs_correctly() {
468
+ let src = "\
469
+ type Counter =
470
+ value: Int
471
+
472
+ main() -> Int =
473
+ c = Counter(value: 5)
474
+ a, c.value = 100, 7
475
+ a + c.value
476
+ ";
477
+ let source = parse(src);
478
+ let bytes = compile_source(&source).expect("compile failed");
479
+ assert_eq!(run_main(&bytes), 107);
480
+ }
481
+ ```
482
+
483
+ - [ ] **Step 2: Run the tests to verify they fail**
484
+
485
+ Run: `cargo test -p plum-wasm-codegen field_assignment chained_field mixed_multi_assign 2>&1 | tail -60`
486
+ Expected: compile error (crate doesn't build yet per Task 2's fallout).
487
+
488
+ - [ ] **Step 3: Fix `ClosureWalker::walk_stmt`**
489
+
490
+ In `plum-wasm-codegen/src/lib.rs` (~line 940), replace:
491
+
492
+ ```rust
493
+ ast::Stmt::Assign(a) => {
494
+ for (target, value) in a.targets.iter().zip(a.values.iter()) {
495
+ self.walk_expr(value, None);
496
+ let ty = plum_checker::infer_expr(value, &self.env, &self.cctx).unwrap_or(PlumType::TInt);
497
+ self.env.insert(target.clone(), TypeScheme::mono(ty));
498
+ self.locals.insert(target.clone());
499
+ }
500
+ }
501
+ ```
502
+
503
+ with:
504
+
505
+ ```rust
506
+ ast::Stmt::Assign(a) => {
507
+ for (target, value) in a.targets.iter().zip(a.values.iter()) {
508
+ self.walk_expr(value, None);
509
+ match target {
510
+ ast::AssignTarget::Var(name) => {
511
+ let ty = plum_checker::infer_expr(value, &self.env, &self.cctx).unwrap_or(PlumType::TInt);
512
+ self.env.insert(name.clone(), TypeScheme::mono(ty));
513
+ self.locals.insert(name.clone());
514
+ }
515
+ ast::AssignTarget::Field(object, _) => {
516
+ self.walk_expr(object, None);
517
+ }
518
+ }
519
+ }
520
+ }
521
+ ```
522
+
523
+ - [ ] **Step 4: Fix `fv_collect_bound_block`**
524
+
525
+ (~line 1336), replace:
526
+
527
+ ```rust
528
+ ast::Stmt::Assign(a) => {
529
+ for t in &a.targets {
530
+ bound.insert(t.clone());
531
+ }
532
+ }
533
+ ```
534
+
535
+ with:
536
+
537
+ ```rust
538
+ ast::Stmt::Assign(a) => {
539
+ for t in &a.targets {
540
+ if let ast::AssignTarget::Var(name) = t {
541
+ bound.insert(name.clone());
542
+ }
543
+ }
544
+ }
545
+ ```
546
+
547
+ (A `Field` target introduces no new bound name — the object expression's own variable references are handled by `fv_collect_refs_block` in Step 5, which runs as a separate pass over the same block.)
548
+
549
+ - [ ] **Step 5: Fix `fv_collect_refs_block`**
550
+
551
+ (~line 1392), replace:
552
+
553
+ ```rust
554
+ ast::Stmt::Assign(a) => {
555
+ for v in &a.values {
556
+ fv_collect_refs_expr(v, bound, seen, free, env, fn_decls);
557
+ }
558
+ }
559
+ ```
560
+
561
+ with:
562
+
563
+ ```rust
564
+ ast::Stmt::Assign(a) => {
565
+ for v in &a.values {
566
+ fv_collect_refs_expr(v, bound, seen, free, env, fn_decls);
567
+ }
568
+ for t in &a.targets {
569
+ if let ast::AssignTarget::Field(object, _) = t {
570
+ fv_collect_refs_expr(object, bound, seen, free, env, fn_decls);
571
+ }
572
+ }
573
+ }
574
+ ```
575
+
576
+ - [ ] **Step 6: Fix `Collector::walk_stmt`**
577
+
578
+ (~line 1573), replace:
579
+
580
+ ```rust
581
+ ast::Stmt::Assign(a) => {
582
+ for (target, value) in a.targets.iter().zip(a.values.iter()) {
583
+ self.walk_expr(value);
584
+ let ty = if matches!(value, ast::Expr::Closure(_)) {
585
+ // ... (existing comment) ...
586
+ PlumType::TFun(Vec::new(), Box::new(PlumType::TUnit))
587
+ } else {
588
+ plum_checker::infer_expr(value, &self.env, &self.cctx).unwrap_or(PlumType::TInt)
589
+ };
590
+ self.bind(target, ty);
591
+ }
592
+ }
593
+ ```
594
+
595
+ with:
596
+
597
+ ```rust
598
+ ast::Stmt::Assign(a) => {
599
+ for (target, value) in a.targets.iter().zip(a.values.iter()) {
600
+ self.walk_expr(value);
601
+ match target {
602
+ ast::AssignTarget::Var(name) => {
603
+ let ty = if matches!(value, ast::Expr::Closure(_)) {
604
+ // The checker's own closure inference (`infer_expr` on
605
+ // `Expr::Closure`) infers the return type by recursively
606
+ // inferring the body's tail expression with each param bound
607
+ // to a fresh, unconstrained `TVar` — e.g. a captured/param
608
+ // attribute access (`c.age`) on a `TVar`-typed object isn't a
609
+ // known class, so it errors out entirely, and this call site
610
+ // then silently defaults to `TInt` — the *wrong* wasm local
611
+ // width for what's actually always an `i32` pointer. All that
612
+ // actually matters here is the local's wasm width, and every
613
+ // closure value is an i32 pointer regardless of its
614
+ // parameter/return types, so skip inference entirely.
615
+ PlumType::TFun(Vec::new(), Box::new(PlumType::TUnit))
616
+ } else {
617
+ plum_checker::infer_expr(value, &self.env, &self.cctx).unwrap_or(PlumType::TInt)
618
+ };
619
+ self.bind(name, ty);
620
+ }
621
+ ast::AssignTarget::Field(object, _) => {
622
+ self.walk_expr(object);
623
+ }
624
+ }
625
+ }
626
+ }
627
+ ```
628
+
629
+ (Keep the existing explanatory comment verbatim inside the `Var` arm — it's shown abbreviated above only for brevity in this plan.)
630
+
631
+ - [ ] **Step 7: Fix `compile_stmt` (the emission pass)**
632
+
633
+ (~line 1982), replace:
634
+
635
+ ```rust
636
+ ast::Stmt::Assign(a) => {
637
+ for (target, value) in a.targets.iter().zip(a.values.iter()) {
638
+ // See the matching comment in `Collector::walk_stmt`: ...
639
+ let vty = if matches!(value, ast::Expr::Closure(_)) {
640
+ PlumType::TFun(Vec::new(), Box::new(PlumType::TUnit))
641
+ } else {
642
+ infer_local_type(value, ctx)
643
+ };
644
+ compile_expr(value, body, ctx, state)?;
645
+ let idx = ctx
646
+ .locals
647
+ .get(target)
648
+ .copied()
649
+ .ok_or_else(|| format!("undeclared local '{}'", target))?;
650
+ Instruction::LocalSet(idx).encode(body);
651
+ ctx.type_env.borrow_mut().insert(target.clone(), TypeScheme::mono(vty));
652
+ if let ast::Expr::Closure(cl) = value {
653
+ let key = cl.as_ref() as *const ast::Closure as usize;
654
+ if let Some(info) = ctx.closures.get(&key) {
655
+ let mut sig_params = vec![ValType::I32];
656
+ sig_params.extend(info.param_vts.iter().copied());
657
+ ctx.closure_local_sigs.borrow_mut().insert(target.clone(), (sig_params, info.ret_vt));
658
+ }
659
+ }
660
+ }
661
+ }
662
+ ```
663
+
664
+ with:
665
+
666
+ ```rust
667
+ ast::Stmt::Assign(a) => {
668
+ for (target, value) in a.targets.iter().zip(a.values.iter()) {
669
+ match target {
670
+ ast::AssignTarget::Var(name) => {
671
+ // See the matching comment in `Collector::walk_stmt`: the checker's
672
+ // closure inference is unreliable (can error out entirely depending
673
+ // on the body), but every closure value is an i32 pointer regardless
674
+ // of its real signature, so don't bother inferring it at all here.
675
+ let vty = if matches!(value, ast::Expr::Closure(_)) {
676
+ PlumType::TFun(Vec::new(), Box::new(PlumType::TUnit))
677
+ } else {
678
+ infer_local_type(value, ctx)
679
+ };
680
+ compile_expr(value, body, ctx, state)?;
681
+ let idx = ctx
682
+ .locals
683
+ .get(name)
684
+ .copied()
685
+ .ok_or_else(|| format!("undeclared local '{}'", name))?;
686
+ Instruction::LocalSet(idx).encode(body);
687
+ ctx.type_env.borrow_mut().insert(name.clone(), TypeScheme::mono(vty));
688
+ if let ast::Expr::Closure(cl) = value {
689
+ let key = cl.as_ref() as *const ast::Closure as usize;
690
+ if let Some(info) = ctx.closures.get(&key) {
691
+ let mut sig_params = vec![ValType::I32];
692
+ sig_params.extend(info.param_vts.iter().copied());
693
+ ctx.closure_local_sigs.borrow_mut().insert(name.clone(), (sig_params, info.ret_vt));
694
+ }
695
+ }
696
+ }
697
+ ast::AssignTarget::Field(object, field_name) => {
698
+ let obj_ty = infer_local_type(object, ctx);
699
+ let class_name = match &obj_ty {
700
+ PlumType::TNamed(n) => n.clone(),
701
+ other => return Err(format!("codegen: cannot assign field '{}' on non-class type {}", field_name, other)),
702
+ };
703
+ let fields = ctx
704
+ .classes
705
+ .get(&class_name)
706
+ .ok_or_else(|| format!("codegen: unknown class '{}'", class_name))?;
707
+ let (field_idx, field_ty) = fields
708
+ .iter()
709
+ .position(|(n, _)| n == field_name)
710
+ .map(|i| (i, fields[i].1.clone()))
711
+ .ok_or_else(|| format!("codegen: no field '{}' on class '{}'", field_name, class_name))?;
712
+ compile_expr(object, body, ctx, state)?;
713
+ compile_expr(value, body, ctx, state)?;
714
+ let offset = (field_idx as u64) * 8;
715
+ match plum_type_to_valtype(&field_ty) {
716
+ ValType::I64 => Instruction::I64Store(MemArg { offset, align: 3, memory_index: 0 }).encode(body),
717
+ ValType::F64 => Instruction::F64Store(MemArg { offset, align: 3, memory_index: 0 }).encode(body),
718
+ _ => Instruction::I32Store(MemArg { offset, align: 2, memory_index: 0 }).encode(body),
719
+ };
720
+ }
721
+ }
722
+ }
723
+ }
724
+ ```
725
+
726
+ - [ ] **Step 8: Build and run the new tests**
727
+
728
+ Run: `cargo test -p plum-wasm-codegen field_assignment chained_field mixed_multi_assign 2>&1 | tail -60`
729
+ Expected: PASS (3 tests). If `field_assignment_target_runs_correctly` traps or returns the wrong value, check the field store pushes the *object pointer* before the *value* (wasm stack order for a store is `[address, value]` — `I32Store`/`I64Store`/`F64Store` pop value then address, so `compile_expr(object)` must run first, matching the existing class-literal field-init code this was modeled on).
730
+
731
+ - [ ] **Step 9: Run the full workspace test suite**
732
+
733
+ Run: `cargo test --workspace 2>&1 | tail -100`
734
+ Expected: all tests PASS, including every pre-existing `plum-wasm-codegen`, `plum-checker`, and `tree-sitter-plum` test.
735
+
736
+ - [ ] **Step 10: Commit**
737
+
738
+ ```bash
739
+ git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs
740
+ git commit -m "feat(plum-wasm-codegen): compile obj.field = value assignment targets"
741
+ ```
742
+
743
+ ---
744
+
745
+ ### Task 5: README — close the gap
746
+
747
+ **Files:**
748
+ - Modify: `README.md` (the "Known gaps" section, ~line 345-350)
749
+
750
+ **Interfaces:**
751
+ - Consumes: nothing.
752
+ - Produces: nothing (docs only).
753
+
754
+ - [ ] **Step 1: Update the Known gaps bullet**
755
+
756
+ In `README.md`, the current bullet reads:
757
+
758
+ ```
759
+ - `libs/std`'s actual `List`/`Map` still don't fully compile — mutating a field or attribute (`self.head = ...`) isn't a supported assignment target yet (only a plain local variable is), and there's no cross-file import resolution yet either, so a file that references a type/enum declared in a different `libs/std` file won't type-check standalone
760
+ ```
761
+
762
+ Replace it with:
763
+
764
+ ```
765
+ - `libs/std`'s actual `List`/`Map` still don't fully compile — there's no cross-file import resolution yet, so a file that references a type/enum declared in a different `libs/std` file won't type-check standalone; separately, `List`'s methods beyond `get`/`length` are still `todo` pending variadic-parameter support (`values: ...a`), a distinct follow-up gap
766
+ ```
767
+
768
+ Also check whether any earlier section of the README (e.g. wherever assignment / `self.field` is first documented, likely near "Naming conventions" or a "Statements"/"Classes" section) currently says a field/attribute can't be an assignment target, and update it to state that `obj.field = value` is now supported. Search first:
769
+
770
+ Run: `grep -n "assignment target\|self\\.field\|field or attribute" README.md`
771
+
772
+ - [ ] **Step 2: Commit**
773
+
774
+ ```bash
775
+ git add README.md
776
+ git commit -m "docs: field/attribute assignment is no longer a known gap"
777
+ ```