plum

#treesitter#compiler#wasm

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

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


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