plum
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 | ``` |