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
# Design: field/attribute assignment target
## Problem
`Stmt::Assign` (`x = expr`) only ever binds a plain local variable — the grammar's
`assign` rule accepts `commaSep1(var_identifier)` on the left of `=`, and codegen
always treats a target as a `local.set`. There's no way to write `self.head = value`,
so any method that needs to mutate a struct field (e.g. `libs/std/list.plum`'s
`add`/`removeAt`/`set`) can't compile. This is the first of the two remaining
"Known gaps" entries in the README.
## Scope
Support `<object-expr>.<field> = <value>` as an assignment target, where
`<object-expr>` is any expression that evaluates to a class instance (including a
chain, so `self.head.value = x` falls out for free once `self.head` resolves to a
class instance). Multiple comma-separated targets (`a, b.c = 1, 2`) continue to work,
mixing plain-variable and field targets freely, since that's how the grammar already
treats the LHS list.
Out of scope (tracked separately): variadic parameters, `List`'s other `todo`
methods, index/array assignment (`self.items[i] = v`), enum-payload mutation.
## Grammar
Add a dedicated `field_target` rule instead of reusing `attribute` directly, so the
assignment LHS can't accidentally parse a method call as a target:
```js
field_target: ($) =>
seq(
field("object", $.primary_expression),
".",
field("member", $.fn_identifier),
),
assign: ($) =>
seq(
commaSep1(choice($.var_identifier, $.field_target)),
"=",
commaSep1($.expression),
),
```
`primary_expression` already includes `$.attribute`, which recurses on its own
`object` field — so `self.head.value` parses as
`field_target(object: attribute(self, head), member: value)` with no extra grammar
work, the same way chained reads already work.
## AST
```rust
pub enum AssignTarget {
Var(String),
Field(Box<Expr>, String), // object, field name
}
pub struct Assign {
pub targets: Vec<AssignTarget>,
pub values: Vec<Expr>,
}
```
`parser.rs`'s `parse_assign` builds `AssignTarget::Var` from a `var_identifier` node
and `AssignTarget::Field` from a `field_target` node (parsing its `object` child as an
expression via the existing `parse_expression`/`unwrap_expr_node` path, and taking
`member`'s text directly).
## Checker (`plum-checker`)
In `check_stmt`'s `Stmt::Assign` arm: for `AssignTarget::Var(name)`, keep today's
behavior (infer the value's type, bind `name` in the environment). For
`AssignTarget::Field(object, field_name)`: infer the object's type, require it to be
a `PlumType::TNamed(class)`, look up `field_name` in `ctx.classes[class]`, infer the
value's type, and `unify` the two — a mismatch or unknown field is a `CheckError` with
a message following the existing `"fn '{}': assign '{}': {}"` shape (target rendered
as `"{object}.{field}"` for the message, not used for env lookup).
## Codegen (`plum-wasm-codegen`)
`Stmt::Assign` is touched in six places today: the closure free-variable walker, the
local-name collector (for allocating wasm locals), the match/scratch-local counters,
and the final emission pass. Each needs an `AssignTarget::Var`/`AssignTarget::Field`
match arm:
- **Closure walker / local collector / scratch counters**: `Var(name)` keeps current
behavior (registers a local). `Field(object, _)` walks/visits `object` as an
expression (so any variables *it* references are tracked) but registers no new
local — a field target never introduces a binding.
- **Emission**: `Var(name)` keeps today's `local.set`. `Field(object, field_name)`
mirrors the existing field-*read* codegen at `Expr::Attribute`/`AttrKind::Field`
(`plum-wasm-codegen/src/lib.rs:2772-2796`) and the field-*init* codegen in
class-literal construction (`:2760-2768`): resolve `object`'s `PlumType::TNamed`,
look up the field's index/type in `ctx.classes`, `compile_expr(object)` to push the
instance pointer, `compile_expr(value)` to push the new value, then `emit_store`
at `field_idx * 8` with the field's `ValType`-appropriate store op — exactly the
same offset arithmetic already used for reads, no new addressing logic.
## Testing
New `plum-wasm-codegen` tests (in `tests/codegen_tests.rs`), each a small class with a
mutable field:
- assigning a field then reading it back returns the new value
- assigning a field of one type to a mismatched-type expression is a clear checker
error (not a codegen panic)
- a chained target (`self.head.value = x` where `head: Node`) compiles and the
written value round-trips through a subsequent read
- a mixed multi-assign (`a, self.field = 1, 2`) compiles and both targets take effect
## README
Once this lands, update the "Known gaps" bullet about `self.head = ...` to remove the
"mutating a field or attribute isn't a supported assignment target" clause, leaving
only the cross-file import resolution gap (that one is untouched by this work) until
the follow-up variadic-params work lands.