plum

#treesitter#compiler#wasm

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.