plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


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