plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
aa52629
— Peter 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.
|