plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-23-field-assignment-design.md
| aa52629 | 1 | # Design: field/attribute assignment target |
| aa52629 | 2 | |
| aa52629 | 3 | ## Problem |
| aa52629 | 4 | |
| aa52629 | 5 | `Stmt::Assign` (`x = expr`) only ever binds a plain local variable — the grammar's |
| aa52629 | 6 | `assign` rule accepts `commaSep1(var_identifier)` on the left of `=`, and codegen |
| aa52629 | 7 | always treats a target as a `local.set`. There's no way to write `self.head = value`, |
| aa52629 | 8 | so any method that needs to mutate a struct field (e.g. `libs/std/list.plum`'s |
| aa52629 | 9 | `add`/`removeAt`/`set`) can't compile. This is the first of the two remaining |
| aa52629 | 10 | "Known gaps" entries in the README. |
| aa52629 | 11 | |
| aa52629 | 12 | ## Scope |
| aa52629 | 13 | |
| aa52629 | 14 | Support `<object-expr>.<field> = <value>` as an assignment target, where |
| aa52629 | 15 | `<object-expr>` is any expression that evaluates to a class instance (including a |
| aa52629 | 16 | chain, so `self.head.value = x` falls out for free once `self.head` resolves to a |
| aa52629 | 17 | class instance). Multiple comma-separated targets (`a, b.c = 1, 2`) continue to work, |
| aa52629 | 18 | mixing plain-variable and field targets freely, since that's how the grammar already |
| aa52629 | 19 | treats the LHS list. |
| aa52629 | 20 | |
| aa52629 | 21 | Out of scope (tracked separately): variadic parameters, `List`'s other `todo` |
| aa52629 | 22 | methods, index/array assignment (`self.items[i] = v`), enum-payload mutation. |
| aa52629 | 23 | |
| aa52629 | 24 | ## Grammar |
| aa52629 | 25 | |
| aa52629 | 26 | Add a dedicated `field_target` rule instead of reusing `attribute` directly, so the |
| aa52629 | 27 | assignment LHS can't accidentally parse a method call as a target: |
| aa52629 | 28 | |
| aa52629 | 29 | ```js |
| aa52629 | 30 | field_target: ($) => |
| aa52629 | 31 | seq( |
| aa52629 | 32 | field("object", $.primary_expression), |
| aa52629 | 33 | ".", |
| aa52629 | 34 | field("member", $.fn_identifier), |
| aa52629 | 35 | ), |
| aa52629 | 36 | |
| aa52629 | 37 | assign: ($) => |
| aa52629 | 38 | seq( |
| aa52629 | 39 | commaSep1(choice($.var_identifier, $.field_target)), |
| aa52629 | 40 | "=", |
| aa52629 | 41 | commaSep1($.expression), |
| aa52629 | 42 | ), |
| aa52629 | 43 | ``` |
| aa52629 | 44 | |
| aa52629 | 45 | `primary_expression` already includes `$.attribute`, which recurses on its own |
| aa52629 | 46 | `object` field — so `self.head.value` parses as |
| aa52629 | 47 | `field_target(object: attribute(self, head), member: value)` with no extra grammar |
| aa52629 | 48 | work, the same way chained reads already work. |
| aa52629 | 49 | |
| aa52629 | 50 | ## AST |
| aa52629 | 51 | |
| aa52629 | 52 | ```rust |
| aa52629 | 53 | pub enum AssignTarget { |
| aa52629 | 54 | Var(String), |
| aa52629 | 55 | Field(Box<Expr>, String), // object, field name |
| aa52629 | 56 | } |
| aa52629 | 57 | |
| aa52629 | 58 | pub struct Assign { |
| aa52629 | 59 | pub targets: Vec<AssignTarget>, |
| aa52629 | 60 | pub values: Vec<Expr>, |
| aa52629 | 61 | } |
| aa52629 | 62 | ``` |
| aa52629 | 63 | |
| aa52629 | 64 | `parser.rs`'s `parse_assign` builds `AssignTarget::Var` from a `var_identifier` node |
| aa52629 | 65 | and `AssignTarget::Field` from a `field_target` node (parsing its `object` child as an |
| aa52629 | 66 | expression via the existing `parse_expression`/`unwrap_expr_node` path, and taking |
| aa52629 | 67 | `member`'s text directly). |
| aa52629 | 68 | |
| aa52629 | 69 | ## Checker (`plum-checker`) |
| aa52629 | 70 | |
| aa52629 | 71 | In `check_stmt`'s `Stmt::Assign` arm: for `AssignTarget::Var(name)`, keep today's |
| aa52629 | 72 | behavior (infer the value's type, bind `name` in the environment). For |
| aa52629 | 73 | `AssignTarget::Field(object, field_name)`: infer the object's type, require it to be |
| aa52629 | 74 | a `PlumType::TNamed(class)`, look up `field_name` in `ctx.classes[class]`, infer the |
| aa52629 | 75 | value's type, and `unify` the two — a mismatch or unknown field is a `CheckError` with |
| aa52629 | 76 | a message following the existing `"fn '{}': assign '{}': {}"` shape (target rendered |
| aa52629 | 77 | as `"{object}.{field}"` for the message, not used for env lookup). |
| aa52629 | 78 | |
| aa52629 | 79 | ## Codegen (`plum-wasm-codegen`) |
| aa52629 | 80 | |
| aa52629 | 81 | `Stmt::Assign` is touched in six places today: the closure free-variable walker, the |
| aa52629 | 82 | local-name collector (for allocating wasm locals), the match/scratch-local counters, |
| aa52629 | 83 | and the final emission pass. Each needs an `AssignTarget::Var`/`AssignTarget::Field` |
| aa52629 | 84 | match arm: |
| aa52629 | 85 | |
| aa52629 | 86 | - **Closure walker / local collector / scratch counters**: `Var(name)` keeps current |
| aa52629 | 87 | behavior (registers a local). `Field(object, _)` walks/visits `object` as an |
| aa52629 | 88 | expression (so any variables *it* references are tracked) but registers no new |
| aa52629 | 89 | local — a field target never introduces a binding. |
| aa52629 | 90 | - **Emission**: `Var(name)` keeps today's `local.set`. `Field(object, field_name)` |
| aa52629 | 91 | mirrors the existing field-*read* codegen at `Expr::Attribute`/`AttrKind::Field` |
| aa52629 | 92 | (`plum-wasm-codegen/src/lib.rs:2772-2796`) and the field-*init* codegen in |
| aa52629 | 93 | class-literal construction (`:2760-2768`): resolve `object`'s `PlumType::TNamed`, |
| aa52629 | 94 | look up the field's index/type in `ctx.classes`, `compile_expr(object)` to push the |
| aa52629 | 95 | instance pointer, `compile_expr(value)` to push the new value, then `emit_store` |
| aa52629 | 96 | at `field_idx * 8` with the field's `ValType`-appropriate store op — exactly the |
| aa52629 | 97 | same offset arithmetic already used for reads, no new addressing logic. |
| aa52629 | 98 | |
| aa52629 | 99 | ## Testing |
| aa52629 | 100 | |
| aa52629 | 101 | New `plum-wasm-codegen` tests (in `tests/codegen_tests.rs`), each a small class with a |
| aa52629 | 102 | mutable field: |
| aa52629 | 103 | |
| aa52629 | 104 | - assigning a field then reading it back returns the new value |
| aa52629 | 105 | - assigning a field of one type to a mismatched-type expression is a clear checker |
| aa52629 | 106 | error (not a codegen panic) |
| aa52629 | 107 | - a chained target (`self.head.value = x` where `head: Node`) compiles and the |
| aa52629 | 108 | written value round-trips through a subsequent read |
| aa52629 | 109 | - a mixed multi-assign (`a, self.field = 1, 2`) compiles and both targets take effect |
| aa52629 | 110 | |
| aa52629 | 111 | ## README |
| aa52629 | 112 | |
| aa52629 | 113 | Once this lands, update the "Known gaps" bullet about `self.head = ...` to remove the |
| aa52629 | 114 | "mutating a field or attribute isn't a supported assignment target" clause, leaving |
| aa52629 | 115 | only the cross-file import resolution gap (that one is untouched by this work) until |
| aa52629 | 116 | the follow-up variadic-params work lands. |