plum

#treesitter#compiler#wasm

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

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


ed7f16c — Peter John 2026-11-02T19:46:13+05:30
T5: field access through a trait
plum-checker/src/unions.rs CHANGED
@@ -554,9 +554,38 @@ pub fn restorePlan(p: &plan::UnionPlan) {
554
554
  for (a, vs) in &p.dispatches {
555
555
  plan::recordDispatch(*a, vs.clone());
556
556
  }
557
+ for (a, vs) in &p.fieldReads {
558
+ plan::recordFieldRead(*a, vs.clone());
559
+ }
560
+ for (a, vs) in &p.fieldWrites {
561
+ plan::recordFieldWrite(*a, vs.clone());
562
+ }
563
+ }
564
+
565
+ /// Names of the fields every member of the union has an accessor for.
566
+ fn commonFields(ms: &[(String, PlumType)], ctx: &CheckCtx) -> Option<Vec<String>> {
567
+ let mut common: Option<Vec<String>> = None;
568
+ for (_, mt) in ms {
569
+ let recv = crate::methodReceiverName(mt)?;
570
+ let mut mine: Vec<String> = ctx
571
+ .methods
572
+ .keys()
573
+ .filter(|(r, n)| *r == recv && n.starts_with("__get_"))
574
+ .map(|(_, n)| n["__get_".len()..].to_string())
575
+ .collect();
576
+ mine.sort();
577
+ common = Some(match common {
578
+ None => mine,
579
+ Some(c) => c.into_iter().filter(|f| mine.contains(f)).collect(),
580
+ });
581
+ }
582
+ common.filter(|c| !c.is_empty())
557
583
  }
558
584
 
559
- /// Field access through a trait value (T5 implements). `None`: not a trait field access.
585
+ /// Field access through a trait value: the field's type, with the read (or
586
+ /// write, when `write`) recorded for `union_lower` to turn into an accessor
587
+ /// dispatch. `None`: `obj` is not a trait. `exprAddr` is the address of the
588
+ /// `Attribute` expression for a read, of the assign statement for a write.
560
589
  pub fn traitField(
561
590
  obj: &PlumType,
562
591
  field: &str,
@@ -564,6 +593,50 @@ pub fn traitField(
564
593
  write: bool,
565
594
  ctx: &CheckCtx,
566
595
  ) -> Option<Result<PlumType, String>> {
596
+ let (en, args) = enumOf(obj)?;
597
+ let ms = members(en, args, ctx);
598
+ if ms.is_empty() {
599
+ return None;
600
+ }
601
+ let fields = commonFields(&ms, ctx)?;
602
+ if !fields.iter().any(|f| f == field) {
603
+ return Some(Err(format!("trait {en} has no field {field}")));
604
+ }
605
+ let acc = plum_core::traits::getter(field);
606
+ let ty = match memberSig(&ms[0].1, &acc, ctx) {
607
+ Some(PlumType::TFun(_, ret)) => *ret,
608
+ _ => return Some(Err(format!("trait {en} has no field {field}"))),
609
+ };
610
+ let variants: Vec<String> = ms.into_iter().map(|(v, _)| v).collect();
611
+ if write {
612
+ plan::recordFieldWrite(exprAddr, variants);
613
+ } else {
567
- let _ = (obj, field, exprAddr, write, ctx);
614
+ plan::recordFieldRead(exprAddr, variants);
615
+ }
616
+ Some(Ok(ty))
617
+ }
618
+
619
+ /// For a field-assignment target `object.x = ..` whose `object` (already
620
+ /// inferred, type `objTy`) is itself a trait field read of a struct type:
621
+ /// the error to report, since writing into a struct copy would be lost.
622
+ pub fn nestedTraitWrite(
623
+ object: &ast::Expr,
624
+ objTy: &PlumType,
625
+ ctx: &CheckCtx,
626
+ ) -> Option<String> {
627
+ if !plan::hasFieldRead(object as *const ast::Expr as usize) {
568
- None
628
+ return None;
629
+ }
630
+ let PlumType::TNamed(n) = objTy else { return None };
631
+ if !ctx.enum_variants.get(n).is_some_and(|i| i.is_value) {
632
+ return None;
633
+ }
634
+ let ast::Expr::Attribute(at) = object else { return None };
635
+ let ast::AttrKind::Field(f) = &at.attr else { return None };
636
+ let base = match &at.object {
637
+ ast::Expr::Var(v) => v.clone(),
638
+ ast::Expr::Self_ => "self".to_string(),
639
+ _ => "e".to_string(),
640
+ };
641
+ Some(format!("assign the whole field: {base}.{f} = ..."))
569
642
  }
plum-checker/tests/trait_fields_tests.rs ADDED
@@ -0,0 +1,223 @@
1
+ #![allow(non_snake_case)]
2
+ //! Trait field reads/writes: the checker's `traitField` plus `union_lower`'s
3
+ //! rewrite of the recorded reads and writes into `__get_f` / `__set_f` dispatch.
4
+ use plum_checker::types::PlumType;
5
+ use plum_checker::unions::{nestedTraitWrite, traitField};
6
+ use plum_checker::{
7
+ buildGlobalTables, buildMinRequiredArgs, buildPrimitiveGenerics, buildStaticMethods,
8
+ checkSource, CheckCtx,
9
+ };
10
+ use plum_core::ast::*;
11
+ use plum_core::traits::lowerTraits;
12
+ use plum_core::union_lower::applyPlan;
13
+ use plum_core::unions::{beginPlan, resolveMembers, takePlan};
14
+ use plum_core::AstParser;
15
+
16
+ const DEFS: &str = "\
17
+ struct Vec2(x: Float, y: Float)
18
+ trait Ent(pos: Vec2, z: Float) =
19
+ fun draw(self) -> Float
20
+
21
+ record Hut(Ent, k: Float) =
22
+ fun draw(self) -> Float = self.z
23
+
24
+ record Orc(Ent) =
25
+ fun draw(self) -> Float = self.z + 1.0
26
+
27
+ record Circle(r: Float)
28
+ record Square(s: Float)
29
+ enum Shape = Circle | Square
30
+ ";
31
+
32
+ fn lowered(body: &str) -> Source {
33
+ let src = format!("{DEFS}\n{body}\n");
34
+ let mut parser = tree_sitter::Parser::new();
35
+ parser
36
+ .set_language(&tree_sitter_plum::LANGUAGE.into())
37
+ .unwrap();
38
+ let tree = parser.parse(&src, None).unwrap();
39
+ assert!(!tree.root_node().has_error(), "{}", tree.root_node().to_sexp());
40
+ let mut s = AstParser::new(&src).parseSourceRaw(tree.root_node());
41
+ assert_eq!(lowerTraits(&mut s), Vec::<String>::new());
42
+ let errs = resolveMembers(&mut s);
43
+ assert!(errs.is_empty(), "{errs:?}");
44
+ s
45
+ }
46
+
47
+ fn withCtx<R>(s: &Source, f: impl FnOnce(&CheckCtx) -> R) -> R {
48
+ let (_, methods, enum_variants, enum_params) = buildGlobalTables(s);
49
+ let min_required = buildMinRequiredArgs(s);
50
+ let primitive_generics = buildPrimitiveGenerics(s);
51
+ let statics = buildStaticMethods(s);
52
+ let ctx = CheckCtx {
53
+ methods: &methods,
54
+ enum_variants: &enum_variants,
55
+ enum_params: &enum_params,
56
+ min_required: &min_required,
57
+ primitive_generics: &primitive_generics,
58
+ statics: Some(&statics),
59
+ };
60
+ f(&ctx)
61
+ }
62
+
63
+ fn named(n: &str) -> PlumType {
64
+ PlumType::TNamed(n.to_string())
65
+ }
66
+
67
+ fn fnBody<'a>(s: &'a Source, name: &str) -> &'a FnBody {
68
+ s.items
69
+ .iter()
70
+ .find_map(|i| match i {
71
+ Item::Fn(f) if f.name == name && f.type_param.is_none() => Some(&f.body),
72
+ _ => None,
73
+ })
74
+ .unwrap()
75
+ }
76
+
77
+ fn firstStmt<'a>(s: &'a Source, name: &str) -> &'a Stmt {
78
+ let FnBody::Block(b) = fnBody(s, name) else { panic!("block") };
79
+ &b.stmts[0]
80
+ }
81
+
82
+ fn hasMethod(s: &Source, recv: &str, name: &str) -> bool {
83
+ s.items.iter().any(
84
+ |i| matches!(i, Item::Fn(f) if f.type_param.as_deref() == Some(recv) && f.name == name),
85
+ )
86
+ }
87
+
88
+ #[test]
89
+ fn read_type_and_plan() {
90
+ let s = lowered("");
91
+ withCtx(&s, |ctx| {
92
+ beginPlan();
93
+ let r = traitField(&named("Ent"), "pos", 7, false, ctx);
94
+ assert_eq!(r, Some(Ok(named("Vec2"))));
95
+ let p = takePlan();
96
+ assert_eq!(p.fieldReads[&7], vec!["Ent__Hut", "Ent__Orc"]);
97
+ assert!(p.fieldWrites.is_empty());
98
+ beginPlan();
99
+ assert_eq!(traitField(&named("Ent"), "z", 9, true, ctx), Some(Ok(PlumType::TFloat)));
100
+ let p = takePlan();
101
+ assert_eq!(p.fieldWrites[&9], vec!["Ent__Hut", "Ent__Orc"]);
102
+ });
103
+ }
104
+
105
+ #[test]
106
+ fn unknown_field_and_non_trait() {
107
+ let s = lowered("");
108
+ withCtx(&s, |ctx| {
109
+ beginPlan();
110
+ assert_eq!(
111
+ traitField(&named("Ent"), "nope", 1, false, ctx),
112
+ Some(Err("trait Ent has no field nope".to_string()))
113
+ );
114
+ assert_eq!(traitField(&named("Shape"), "r", 1, false, ctx), None);
115
+ assert_eq!(traitField(&named("Hut"), "z", 1, false, ctx), None);
116
+ assert_eq!(traitField(&PlumType::TInt, "z", 1, false, ctx), None);
117
+ assert!(takePlan().fieldReads.is_empty());
118
+ });
119
+ }
120
+
121
+ #[test]
122
+ fn nested_write_rejected() {
123
+ let s = lowered("fun f(e: Ent) =\n e.pos.x = 1.0\n");
124
+ // The target is Field(Attribute(e, pos), x).
125
+ let Stmt::Assign(a) = firstStmt(&s, "f") else { panic!() };
126
+ let AssignTarget::Field(obj, _) = &a.targets[0] else { panic!() };
127
+ withCtx(&s, |ctx| {
128
+ beginPlan();
129
+ let objAddr = &**obj as *const Expr as usize;
130
+ assert_eq!(nestedTraitWrite(obj, &named("Vec2"), ctx), None); // nothing read yet
131
+ traitField(&named("Ent"), "pos", objAddr, false, ctx).unwrap().unwrap();
132
+ assert_eq!(
133
+ nestedTraitWrite(obj, &named("Vec2"), ctx),
134
+ Some("assign the whole field: e.pos = ...".to_string())
135
+ );
136
+ // A non-struct field type (a record) is shared, so writing into it is fine.
137
+ assert_eq!(nestedTraitWrite(obj, &named("Hut"), ctx), None);
138
+ takePlan();
139
+ });
140
+ }
141
+
142
+ /// `get` / `set` / `bump` lowered by hand-recorded plans (the checker call sites
143
+ /// live in lib.rs); the result still type-checks.
144
+ #[test]
145
+ fn lowering_rewrites_reads_and_writes() {
146
+ let mut s = lowered(
147
+ "fun get(e: Ent) -> Float =\n e.z\n\nfun set(e: Ent) =\n e.z = 5.0\n\nfun bump(e: Ent) =\n e.z = e.z + 1.0\n",
148
+ );
149
+ withCtx(&s, |ctx| {
150
+ beginPlan();
151
+ let getAddr = match fnBody(&s, "get") {
152
+ FnBody::Expr(e) => e as *const Expr as usize,
153
+ FnBody::Block(b) => match &b.stmts[0] {
154
+ Stmt::Return(Some(e)) | Stmt::Expr(e, ..) => e as *const Expr as usize,
155
+ _ => panic!(),
156
+ },
157
+ _ => panic!(),
158
+ };
159
+ traitField(&named("Ent"), "z", getAddr, false, ctx).unwrap().unwrap();
160
+ let setAddr = firstStmt(&s, "set") as *const Stmt as usize;
161
+ traitField(&named("Ent"), "z", setAddr, true, ctx).unwrap().unwrap();
162
+ // bump: read inside the value, write on the statement.
163
+ let bumpStmt = firstStmt(&s, "bump");
164
+ let Stmt::Assign(a) = bumpStmt else { panic!() };
165
+ let Expr::Binary(bin) = &a.values[0] else { panic!() };
166
+ traitField(&named("Ent"), "z", &bin.left as *const Expr as usize, false, ctx)
167
+ .unwrap()
168
+ .unwrap();
169
+ traitField(&named("Ent"), "z", bumpStmt as *const Stmt as usize, true, ctx)
170
+ .unwrap()
171
+ .unwrap();
172
+ });
173
+ let plan = takePlan();
174
+ applyPlan(&mut s, &plan);
175
+ // Accessor dispatch exists on the trait union.
176
+ assert!(hasMethod(&s, "Ent", "__get_z"));
177
+ assert!(hasMethod(&s, "Ent", "__set_z"));
178
+ // `set` is now a call `e.__set_z(5.0)`.
179
+ let Stmt::Expr(Expr::Attribute(at), ..) = firstStmt(&s, "set") else {
180
+ panic!("{:?}", firstStmt(&s, "set"))
181
+ };
182
+ let AttrKind::Method(c) = &at.attr else { panic!() };
183
+ assert_eq!(c.name, "__set_z");
184
+ // `bump` is `e.__set_z(e.__get_z() + 1.0)`: get then set.
185
+ let Stmt::Expr(Expr::Attribute(at), ..) = firstStmt(&s, "bump") else { panic!() };
186
+ let AttrKind::Method(c) = &at.attr else { panic!() };
187
+ assert_eq!(c.name, "__set_z");
188
+ let Arg::Positional(Expr::Binary(b)) = &c.args[0] else { panic!() };
189
+ let Expr::Attribute(g) = &b.left else { panic!() };
190
+ assert!(matches!(&g.attr, AttrKind::Method(m) if m.name == "__get_z"));
191
+ // The rewritten program is well typed.
192
+ checkSource(&s).unwrap_or_else(|e| panic!("{:?}", e.iter().map(|x| &x.message).collect::<Vec<_>>()));
193
+ }
194
+
195
+ #[test]
196
+ fn plain_union_unaffected() {
197
+ let mut s = lowered("fun f(x: Shape) -> Shape =\n x\n");
198
+ let before = s.clone();
199
+ applyPlan(&mut s, &takePlan());
200
+ assert_eq!(s, before);
201
+ withCtx(&s, |ctx| {
202
+ beginPlan();
203
+ assert_eq!(traitField(&named("Shape"), "r", 3, false, ctx), None);
204
+ assert!(takePlan().isEmpty());
205
+ });
206
+ }
207
+
208
+ /// Through the checker proper (needs the `traitField` call sites in lib.rs and
209
+ /// `UnionPlan::isEmpty` in `checkUnions`).
210
+ #[test]
211
+ #[ignore = "needs T4 call sites"]
212
+ fn typed_source_lowers_field_access() {
213
+ let s = lowered(
214
+ "fun bump(e: Ent) -> Float =\n e.z = e.z + 1.0\n e.z\n\nfun nest(e: Ent) =\n e.pos.x = 1.0\n",
215
+ );
216
+ let errs = plum_checker::typedSource(&s).err().expect("nested write must be rejected");
217
+ assert!(errs.iter().any(|e| e.message.contains("assign the whole field: e.pos = ...")), "{:?}", errs.iter().map(|e| &e.message).collect::<Vec<_>>());
218
+ let mut ok = s.clone();
219
+ ok.items.retain(|i| !matches!(i, Item::Fn(f) if f.name == "nest"));
220
+ let info = plum_checker::typedSource(&ok).unwrap_or_else(|e| panic!("{:?}", e.iter().map(|x| &x.message).collect::<Vec<_>>()));
221
+ let low = info.lowered.expect("lowered copy");
222
+ assert!(hasMethod(&low, "Ent", "__get_z") && hasMethod(&low, "Ent", "__set_z"));
223
+ }
plum-core/src/union_lower.rs CHANGED
@@ -64,6 +64,7 @@ impl<'a> Lowerer<'a> {
64
64
 
65
65
  fn stmt(&mut self, s: &mut Stmt) {
66
66
  let mark = self.pending.len();
67
+ let stmtAddr = addr(s);
67
68
  match s {
68
69
  Stmt::Assign(a) => {
69
70
  for t in &mut a.targets {
@@ -110,9 +111,46 @@ impl<'a> Lowerer<'a> {
110
111
  Stmt::Return(Some(e)) | Stmt::Crash(e) | Stmt::Expr(e, ..) => self.expr(e),
111
112
  Stmt::Return(None) | Stmt::Break | Stmt::Continue | Stmt::Todo => {}
112
113
  }
114
+ self.lowerFieldWrite(s, stmtAddr);
113
115
  self.pending.truncate(mark);
114
116
  }
115
117
 
118
+ /// `e.f = v` on a trait value becomes the dispatch call `e.__set_f(v)`.
119
+ fn lowerFieldWrite(&mut self, s: &mut Stmt, stmtAddr: usize) {
120
+ let Stmt::Assign(a) = s else { return };
121
+ let inner = addr(a);
122
+ let Some(variants) = self
123
+ .plan
124
+ .fieldWrites
125
+ .get(&stmtAddr)
126
+ .or_else(|| self.plan.fieldWrites.get(&inner))
127
+ .cloned()
128
+ else {
129
+ return;
130
+ };
131
+ if a.targets.len() != 1 || a.values.len() != 1 || a.declare {
132
+ return;
133
+ }
134
+ let AssignTarget::Field(_, f) = &a.targets[0] else { return };
135
+ let method = crate::traits::setter(f);
136
+ let AssignTarget::Field(obj, _) = a.targets.remove(0) else { return };
137
+ let value = a.values.remove(0);
138
+ self.needs.push((variants, method.clone()));
139
+ *s = Stmt::Expr(
140
+ Expr::Attribute(Box::new(AttributeExpr {
141
+ object: *obj,
142
+ attr: AttrKind::Method(FnCall {
143
+ name: method,
144
+ args: vec![Arg::Positional(value)],
145
+ line: 0,
146
+ column: 0,
147
+ }),
148
+ })),
149
+ 0,
150
+ 0,
151
+ );
152
+ }
153
+
116
154
  fn matchStmt(&mut self, m: &mut Match) {
117
155
  for s in &mut m.subjects {
118
156
  self.expr(s);
@@ -268,6 +306,20 @@ impl<'a> Lowerer<'a> {
268
306
  | Expr::Var(_)
269
307
  | Expr::TypeName(_) => {}
270
308
  }
309
+ if let Some(variants) = self.plan.fieldReads.get(&a) {
310
+ if let Expr::Attribute(at) = e {
311
+ if let AttrKind::Field(f) = &at.attr {
312
+ let method = crate::traits::getter(f);
313
+ self.needs.push((variants.clone(), method.clone()));
314
+ at.attr = AttrKind::Method(FnCall {
315
+ name: method,
316
+ args: vec![],
317
+ line: 0,
318
+ column: 0,
319
+ });
320
+ }
321
+ }
322
+ }
271
323
  if let Some(variants) = self.plan.dispatches.get(&a) {
272
324
  let method = match e {
273
325
  Expr::Attribute(at) => match &at.attr {
plum-core/src/unions.rs CHANGED
@@ -153,6 +153,22 @@ pub struct UnionPlan {
153
153
  pub narrowings: HashMap<usize, (String, String, String)>,
154
154
  /// call address -> hidden variants to dispatch over
155
155
  pub dispatches: HashMap<usize, Vec<String>>,
156
+ /// trait field read (`Attribute(e, f)`) address -> hidden variants to dispatch `__get_f` over
157
+ pub fieldReads: HashMap<usize, Vec<String>>,
158
+ /// trait field write (assign statement) address -> hidden variants to dispatch `__set_f` over
159
+ pub fieldWrites: HashMap<usize, Vec<String>>,
160
+ }
161
+
162
+ impl UnionPlan {
163
+ /// Nothing was recorded.
164
+ pub fn isEmpty(&self) -> bool {
165
+ self.wraps.is_empty()
166
+ && self.patterns.is_empty()
167
+ && self.narrowings.is_empty()
168
+ && self.dispatches.is_empty()
169
+ && self.fieldReads.is_empty()
170
+ && self.fieldWrites.is_empty()
171
+ }
156
172
  }
157
173
 
158
174
  thread_local! {
@@ -197,11 +213,16 @@ pub fn takePlan() -> UnionPlan {
197
213
  PLAN.with(|p| std::mem::take(&mut *p.borrow_mut()))
198
214
  }
199
215
 
200
- /// Records a trait field read at `exprAddr` (T5 implements).
216
+ /// Records a trait field read at `exprAddr`.
201
217
  pub fn recordFieldRead(exprAddr: usize, variants: Vec<String>) {
202
- let _ = (exprAddr, variants);
218
+ PLAN.with(|p| p.borrow_mut().fieldReads.insert(exprAddr, variants));
203
219
  }
204
- /// Records a trait field write at `stmtAddr` (T5 implements).
220
+ /// Records a trait field write at `stmtAddr` (the address of the assign statement
221
+ /// or of its `Assign`).
205
222
  pub fn recordFieldWrite(stmtAddr: usize, variants: Vec<String>) {
206
- let _ = (stmtAddr, variants);
223
+ PLAN.with(|p| p.borrow_mut().fieldWrites.insert(stmtAddr, variants));
224
+ }
225
+ /// Whether a trait field read is recorded at `exprAddr`.
226
+ pub fn hasFieldRead(exprAddr: usize) -> bool {
227
+ PLAN.with(|p| p.borrow().fieldReads.contains_key(&exprAddr))
207
228
  }