plum

#treesitter#compiler#wasm

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

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


fe4959d — Peter John 2026-11-02T19:47:30+05:30
T4: checker trait rules
plum-checker/src/lib.rs CHANGED
@@ -2,6 +2,7 @@
2
2
  // naming convention), not Rust's idiomatic snake_case — silence the resulting lint.
3
3
  #![allow(non_snake_case)]
4
4
 
5
+ pub mod traits;
5
6
  pub mod types;
6
7
  pub mod unions;
7
8
 
@@ -1032,6 +1033,7 @@ pub struct CheckCtx<'a> {
1032
1033
  /// `plum-wasm-codegen`, which needs the same tables to resolve `self`, field access,
1033
1034
  /// and method dispatch during code generation.
1034
1035
  pub fn buildGlobalTables(source: &ast::Source) -> (TypeEnv, MethodEnv, EnumVariants, EnumParams) {
1036
+ traits::register(source);
1035
1037
  let conv = plumTypeFromAstG;
1036
1038
  let mut global_env: TypeEnv = TypeEnv::new();
1037
1039
  let mut methods: MethodEnv = BTreeMap::new();
@@ -1209,10 +1211,7 @@ fn checkUnions(
1209
1211
  plum_core::unions::beginPlan();
1210
1212
  let (result, info) = check(&copy);
1211
1213
  let plan = plum_core::unions::takePlan();
1212
- let empty = plan.wraps.is_empty()
1214
+ let empty = plan.isEmpty();
1213
- && plan.patterns.is_empty()
1214
- && plan.narrowings.is_empty()
1215
- && plan.dispatches.is_empty();
1216
1215
  let out = if result.is_err() {
1217
1216
  // Report errors against the original, whose addresses codegen never sees.
1218
1217
  (result, info)
@@ -2781,12 +2780,31 @@ fn checkStmtInner(
2781
2780
  ));
2782
2781
  continue;
2783
2782
  }
2783
+ let stmtAddr = stmt as *const ast::Stmt as usize;
2784
- let field_expected = inferExpr(object, env, ctx)
2784
+ let objTy = inferExpr(object, env, ctx).ok();
2785
+ if let Some(msg) = objTy
2785
- .ok()
2786
+ .as_ref()
2787
+ .and_then(|o| unions::nestedTraitWrite(object, o, ctx))
2788
+ {
2789
+ errors.push(CheckError::at(
2790
+ fn_pos.0,
2791
+ fn_pos.1,
2792
+ format!("fn '{}': assign '{}': {}", fn_name, label, msg),
2793
+ ));
2794
+ continue;
2795
+ }
2796
+ let traitTy = objTy
2797
+ .as_ref()
2798
+ .and_then(|o| unions::traitField(o, field_name, stmtAddr, true, ctx));
2799
+ let field_expected = match &traitTy {
2800
+ Some(r) => r.clone().ok(),
2801
+ None => objTy
2802
+ .as_ref()
2786
- .and_then(|o| fieldTypeOf(&o, field_name, ctx).ok());
2803
+ .and_then(|o| fieldTypeOf(o, field_name, ctx).ok()),
2804
+ };
2787
2805
  match (inferExpr(object, env, ctx), inferExprWith(value, env, ctx, field_expected.as_ref())) {
2788
2806
  (Ok(obj_ty @ (PlumType::TNamed(_) | PlumType::TApp(..))), Ok(value_ty)) => {
2789
- match fieldTypeOf(&obj_ty, field_name, ctx) {
2807
+ match traitTy.unwrap_or_else(|| fieldTypeOf(&obj_ty, field_name, ctx)) {
2790
2808
  Ok(field_ty) => {
2791
2809
  if let Err(msg) = unifyArg(&field_ty, &value_ty, value, ctx) {
2792
2810
  errors.push(CheckError::at(fn_pos.0, fn_pos.1, format!("fn '{}': assign '{}': {}", fn_name, label, msg)));
@@ -3255,6 +3273,31 @@ fn checkMatch(
3255
3273
  // Exhaustiveness: enum / Bool / error-name subjects must cover every
3256
3274
  // constructor (recursively through variant payloads) unless a catch-all
3257
3275
  // arm exists. Literal (Int/Str/Float) subjects are left alone.
3276
+ if let [PlumType::TNamed(tn)] = subject_types.as_slice() {
3277
+ if traits::isTrait(tn, ctx) {
3278
+ let hasWildcard = m.cases.iter().any(|c| {
3279
+ c.guard.is_none()
3280
+ && c.patterns.len() == 1
3281
+ && matches!(c.patterns[0], ast::CasePattern::Wildcard)
3282
+ });
3283
+ // Lowered dispatch matches name hidden variants (`Entity__House`) and stay exhaustive.
3284
+ let lowered = m.cases.iter().any(|c| {
3285
+ c.patterns.iter().any(|p| match p {
3286
+ ast::CasePattern::Class { name, .. } | ast::CasePattern::Name(name) => {
3287
+ name.contains("__")
3288
+ }
3289
+ _ => false,
3290
+ })
3291
+ });
3292
+ if !hasWildcard && !lowered {
3293
+ errors.push(CheckError::at(
3294
+ fn_pos.0,
3295
+ fn_pos.1,
3296
+ format!("fn '{}': match on trait {} needs a `_` case", fn_name, tn),
3297
+ ));
3298
+ }
3299
+ }
3300
+ }
3258
3301
  if let [subject_ty] = subject_types.as_slice() {
3259
3302
  let rows: Vec<Vec<&ast::CasePattern>> = m
3260
3303
  .cases
@@ -3262,19 +3305,23 @@ fn checkMatch(
3262
3305
  .filter(|c| c.guard.is_none() && c.patterns.len() == 1)
3263
3306
  .map(|c| vec![&c.patterns[0]])
3264
3307
  .collect();
3308
+ let isTraitSubject =
3309
+ matches!(subject_ty, PlumType::TNamed(tn) if traits::isTrait(tn, ctx));
3265
3310
  if let Err(CoverFail::Reported(label, missing)) =
3266
3311
  coverRows(rows, std::slice::from_ref(subject_ty), ctx)
3267
3312
  {
3313
+ if !isTraitSubject {
3268
- errors.push(CheckError::at(
3314
+ errors.push(CheckError::at(
3269
- fn_pos.0,
3315
+ fn_pos.0,
3270
- fn_pos.1,
3316
+ fn_pos.1,
3271
- format!(
3317
+ format!(
3272
- "fn '{}': non-exhaustive match on {}: missing {}",
3318
+ "fn '{}': non-exhaustive match on {}: missing {}",
3273
- fn_name,
3319
+ fn_name,
3274
- label,
3320
+ label,
3275
- missing.join(", ")
3321
+ missing.join(", ")
3276
- ),
3322
+ ),
3277
- ));
3323
+ ));
3324
+ }
3278
3325
  }
3279
3326
  }
3280
3327
 
@@ -4730,7 +4777,16 @@ fn inferExprInner(
4730
4777
  // `Str` is an ordinary class with a real field; a generic
4731
4778
  // class substitutes its receiver's type arguments.
4732
4779
  PlumType::TNamed(_) | PlumType::TApp(..) => {
4780
+ match unions::traitField(
4781
+ &obj_ty,
4782
+ field_name,
4783
+ expr as *const ast::Expr as usize,
4784
+ false,
4785
+ ctx,
4786
+ ) {
4787
+ Some(r) => r,
4733
- fieldTypeOf(&obj_ty, field_name, ctx)
4788
+ None => fieldTypeOf(&obj_ty, field_name, ctx),
4789
+ }
4734
4790
  }
4735
4791
  // An unresolved generic return reaches here as a plain `TVar`;
4736
4792
  // field access on it is unmodeled — allow, codegen will catch.
@@ -4742,6 +4798,9 @@ fn inferExprInner(
4742
4798
  },
4743
4799
  ast::AttrKind::Method(call) => {
4744
4800
  checkStaticUse(&attr.object, &obj_ty, &call.name, ctx)?;
4801
+ if let Some(msg) = traits::methodError(&obj_ty, &call.name, ctx) {
4802
+ return Err(msg);
4803
+ }
4745
4804
  // A member value calling a method only its enum defines.
4746
4805
  if let Some(r) = unions::memberFallback(&obj_ty, &call.name, ctx) {
4747
4806
  let (en, variant) = r?;
plum-checker/src/traits.rs ADDED
@@ -0,0 +1,58 @@
1
+ //! Trait rules in the checker. A trait is a union enum (`Enum.isTrait`) whose
2
+ //! members are the embedding records; the declared methods come from `Source.traits`.
3
+ use crate::CheckCtx;
4
+ use plum_core::ast;
5
+ use std::cell::RefCell;
6
+ use std::collections::BTreeMap;
7
+
8
+ thread_local! {
9
+ static TRAITS: RefCell<BTreeMap<String, Vec<String>>> = const { RefCell::new(BTreeMap::new()) };
10
+ }
11
+
12
+ /// Records the traits of `source` (called while the global tables are built).
13
+ pub fn register(source: &ast::Source) {
14
+ let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
15
+ for item in &source.items {
16
+ if let ast::Item::Enum(e) = item {
17
+ if e.isTrait {
18
+ out.insert(e.name.clone(), Vec::new());
19
+ }
20
+ }
21
+ }
22
+ for t in &source.traits {
23
+ let names = t
24
+ .required
25
+ .iter()
26
+ .chain(t.methods.iter())
27
+ .map(|f| f.name.clone())
28
+ .collect();
29
+ out.insert(t.name.clone(), names);
30
+ }
31
+ TRAITS.with(|c| *c.borrow_mut() = out);
32
+ }
33
+
34
+ pub fn isTrait(name: &str, _ctx: &CheckCtx) -> bool {
35
+ TRAITS.with(|c| c.borrow().contains_key(name))
36
+ }
37
+
38
+ /// The trait's declared methods: required first, then defaults, declaration order.
39
+ pub fn traitMethods(name: &str, _ctx: &CheckCtx) -> Vec<String> {
40
+ TRAITS.with(|c| c.borrow().get(name).cloned().unwrap_or_default())
41
+ }
42
+
43
+ /// The error for calling `method` on a value of trait type `ty`, if it is not declared.
44
+ pub fn methodError(ty: &crate::types::PlumType, method: &str, ctx: &CheckCtx) -> Option<String> {
45
+ let name = match ty {
46
+ crate::types::PlumType::TNamed(n) => n,
47
+ _ => return None,
48
+ };
49
+ if !isTrait(name, ctx)
50
+ || method.starts_with("__get_")
51
+ || method.starts_with("__set_")
52
+ || traitMethods(name, ctx).iter().any(|m| m == method)
53
+ || ctx.methods.contains_key(&(name.clone(), method.to_string()))
54
+ {
55
+ return None;
56
+ }
57
+ Some(format!("trait {name} has no method {method}"))
58
+ }
plum-checker/tests/trait_fields_tests.rs CHANGED
@@ -208,7 +208,6 @@ fn plain_union_unaffected() {
208
208
  /// Through the checker proper (needs the `traitField` call sites in lib.rs and
209
209
  /// `UnionPlan::isEmpty` in `checkUnions`).
210
210
  #[test]
211
- #[ignore = "needs T4 call sites"]
212
211
  fn typed_source_lowers_field_access() {
213
212
  let s = lowered(
214
213
  "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",
plum-checker/tests/traits_tests.rs ADDED
@@ -0,0 +1,115 @@
1
+ #![allow(non_snake_case)]
2
+ //! Checker rules for traits: only declared methods on a trait value, `_` required in a
3
+ //! match, conversion of embedding records, default methods calling the record's own.
4
+ use plum_checker::checkSource;
5
+ use plum_core::traits::lowerTraits;
6
+ use plum_core::unions::resolveMembers;
7
+ use plum_core::AstParser;
8
+
9
+ const DEFS: &str = "\
10
+ record Vec2(x: Float, y: Float)
11
+
12
+ trait Entity(pos: Vec2, z: Int) =
13
+ fun update(self, dt: Float)
14
+ fun draw(self) -> Int
15
+ fun moveBy(self, d: Vec2) =
16
+ _ := self.draw()
17
+ self.z = self.z + 1
18
+
19
+ record House(Entity, kind: Int) =
20
+ fun update(self, dt: Float) =
21
+ _ := self.draw()
22
+ fun draw(self) -> Int = 1
23
+ fun onClick(self) -> Int = 2
24
+
25
+ record Soldier(Entity) =
26
+ fun update(self, dt: Float) =
27
+ _ := self.draw()
28
+ fun draw(self) -> Int = 3
29
+ ";
30
+
31
+ fn run(defs: &str, body: &str) -> Result<(), String> {
32
+ let src = format!("{defs}\n{body}\n");
33
+ let mut parser = tree_sitter::Parser::new();
34
+ parser
35
+ .set_language(&tree_sitter_plum::LANGUAGE.into())
36
+ .unwrap();
37
+ let tree = parser.parse(&src, None).unwrap();
38
+ assert!(!tree.root_node().has_error(), "parse errors:\n{src}");
39
+ let mut source = AstParser::new(&src).parseSourceRaw(tree.root_node());
40
+ let errs = lowerTraits(&mut source);
41
+ assert!(errs.is_empty(), "{errs:?}");
42
+ let errs = resolveMembers(&mut source);
43
+ assert!(errs.is_empty(), "{errs:?}");
44
+ checkSource(&source).map_err(|e| e.into_iter().map(|x| x.message).collect::<Vec<_>>().join(" | "))
45
+ }
46
+
47
+ #[test]
48
+ fn only_trait_methods() {
49
+ let e = run(DEFS, "fun f(e: Entity) =\n e.onClick()").unwrap_err();
50
+ assert!(e.contains("trait Entity has no method onClick"), "{e}");
51
+ run(DEFS, "fun f(e: Entity) =\n _ := e.draw()").unwrap();
52
+ run(DEFS, "fun f(e: Entity) =\n e.moveBy(Vec2(x: 1.0, y: 1.0))").unwrap();
53
+ }
54
+
55
+ #[test]
56
+ fn match_trait_needs_wildcard() {
57
+ let e = run(DEFS, "fun f(e: Entity) -> Int =\n match e\n House(a, b, c) => 1").unwrap_err();
58
+ assert!(e.contains("match on trait Entity needs a `_` case"), "{e}");
59
+ }
60
+
61
+ #[test]
62
+ fn match_trait_wildcard_narrows() {
63
+ run(
64
+ DEFS,
65
+ "fun f(e: Entity) -> Int =\n match e\n House(p, z, k) => k\n _ => 0",
66
+ )
67
+ .unwrap();
68
+ }
69
+
70
+ #[test]
71
+ fn union_enum_keeps_exhaustive_and_shared_dispatch() {
72
+ let defs = "\
73
+ record Circle(r: Float) =
74
+ fun area(self) -> Float = self.r
75
+ record Square(s: Float) =
76
+ fun area(self) -> Float = self.s
77
+ enum Shape = Circle | Square
78
+ ";
79
+ run(defs, "fun f(s: Shape) -> Float =\n s.area()").unwrap();
80
+ run(
81
+ defs,
82
+ "fun f(s: Shape) -> Int =\n match s\n Circle(r) => 1\n Square(q) => 2",
83
+ )
84
+ .unwrap();
85
+ let e = run(defs, "fun f(s: Shape) -> Int =\n match s\n Circle(r) => 1").unwrap_err();
86
+ assert!(e.contains("non-exhaustive"), "{e}");
87
+ }
88
+
89
+ #[test]
90
+ fn record_in_trait_and_union_enum() {
91
+ let defs = format!("{DEFS}\nenum Thing = House | Soldier\n");
92
+ run(&defs, "fun f(t: Thing) -> Int =\n 1\n\nfun g() -> Int =\n f(Soldier(pos: Vec2(x: 1.0, y: 1.0), z: 0))").unwrap();
93
+ run(&defs, "fun f(e: Entity) -> Int =\n e.draw()\n\nfun g() -> Int =\n f(Soldier(pos: Vec2(x: 1.0, y: 1.0), z: 0))").unwrap();
94
+ }
95
+
96
+ #[test]
97
+ fn trait_param_accepts_record() {
98
+ run(
99
+ DEFS,
100
+ "fun f(e: Entity) -> Int =\n e.draw()\n\nfun g() -> Int =\n f(House(pos: Vec2(x: 1.0, y: 1.0), z: 0, kind: 1))",
101
+ )
102
+ .unwrap();
103
+ }
104
+
105
+ #[test]
106
+ fn default_self_call_checks() {
107
+ run(DEFS, "fun f(h: House) =\n h.moveBy(Vec2(x: 1.0, y: 1.0))").unwrap();
108
+ }
109
+
110
+ #[test]
111
+ fn trait_helpers() {
112
+ // traitMethods order is covered through the rule: declared methods pass, others fail.
113
+ let e = run(DEFS, "fun f(e: Entity) =\n e.nope()").unwrap_err();
114
+ assert!(e.contains("trait Entity has no method nope"), "{e}");
115
+ }