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
# 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.