plum

#treesitter#compiler#wasm

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

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


a039616 — Peter John 2026-11-02T10:41:03+05:30
feat: a fun without self is a static; Type.f() and x.f() are checked
README.md CHANGED
@@ -507,7 +507,7 @@ enum Step(n: Int) =
507
507
 
508
508
  ### Methods, `self` and field access
509
509
 
510
- A `fun` declared indented directly inside an `enum`, `struct` or `record` body is a method; its first parameter is `self`:
510
+ A `fun` declared indented directly inside an `enum`, `struct` or `record` body whose first parameter is `self` is a method, called on a value (`c.getAge()`). One without `self` is a static function, called on the type (`Cat.named("Tom")`), and can't use `self`. Calling a static on a value, or a method on the type, is an error:
511
511
 
512
512
  ```plum
513
513
  record Cat(name: Str, age: Int) =
@@ -517,6 +517,9 @@ record Cat(name: Str, age: Int) =
517
517
  fun birthday(self) -> Int =
518
518
  self.age + 1
519
519
 
520
+ fun named(name: Str) -> Cat = # static: Cat.named("Tom")
521
+ Cat(name: name, age: 0)
522
+
520
523
  record Wrapper(inner: Cat, tag: Int)
521
524
 
522
525
  fun main() -> Int =
plum-checker/src/lib.rs CHANGED
@@ -943,6 +943,23 @@ pub fn buildPrimitiveGenerics(source: &ast::Source) -> PrimitiveGenerics {
943
943
  .collect()
944
944
  }
945
945
 
946
+ /// `(type, name)` of every function declared in a type body without `self`: a
947
+ /// static, called as `Type.name(args)`, never on a value.
948
+ pub type StaticMethods = BTreeSet<(String, String)>;
949
+
950
+ pub fn buildStaticMethods(source: &ast::Source) -> StaticMethods {
951
+ source
952
+ .items
953
+ .iter()
954
+ .filter_map(|item| match item {
955
+ ast::Item::Fn(f) if !f.has_self => {
956
+ f.type_param.as_ref().map(|t| (t.clone(), f.name.clone()))
957
+ }
958
+ _ => None,
959
+ })
960
+ .collect()
961
+ }
962
+
946
963
  pub fn buildMinRequiredArgs(source: &ast::Source) -> MinRequiredArgs {
947
964
  let mut result = MinRequiredArgs::new();
948
965
  for item in &source.items {
@@ -969,6 +986,9 @@ pub struct CheckCtx<'a> {
969
986
  pub enum_params: &'a EnumParams,
970
987
  pub min_required: &'a MinRequiredArgs,
971
988
  pub primitive_generics: &'a PrimitiveGenerics,
989
+ /// `None` skips the static-vs-method call check: codegen re-infers source
990
+ /// the checker already accepted.
991
+ pub statics: Option<&'a StaticMethods>,
972
992
  }
973
993
 
974
994
  /// Builds the global lookup tables (function/const signatures, class fields, method
@@ -1616,12 +1636,14 @@ fn checkSourceInner(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1616
1636
  let (mut global_env, methods, enum_variants, enum_params) = buildGlobalTables(source);
1617
1637
  let min_required = buildMinRequiredArgs(source);
1618
1638
  let primitive_generics = buildPrimitiveGenerics(source);
1639
+ let statics = buildStaticMethods(source);
1619
1640
  let ctx = CheckCtx {
1620
1641
  methods: &methods,
1621
1642
  enum_variants: &enum_variants,
1622
1643
  enum_params: &enum_params,
1623
1644
  min_required: &min_required,
1624
1645
  primitive_generics: &primitive_generics,
1646
+ statics: Some(&statics),
1625
1647
  };
1626
1648
  // Constants are inlined at their use sites, so their type is whatever their
1627
1649
  // initializer infers to (`FLAG = true`, `N = LIMIT * 2 + 1`, `T = List(1, 2)`).
@@ -2105,8 +2127,9 @@ fn checkFn(
2105
2127
  }
2106
2128
  }
2107
2129
 
2130
+ // Methods (a `fun` in a type body whose first parameter is `self`) get a
2108
- // Methods (`name<Receiver>(...)`) get an implicit `self: Receiver` binding.
2131
+ // `self: Receiver` binding; a `fun` without `self` is a static.
2109
- if let Some(recv) = &f.type_param {
2132
+ if let (Some(recv), true) = (&f.type_param, f.has_self) {
2110
2133
  let generics = enumGenericsOf(recv, ctx);
2111
2134
  let self_ty = if generics.is_empty() {
2112
2135
  plumTypeFromName(recv)
@@ -3905,7 +3928,9 @@ fn inferExprInner(
3905
3928
  Some(s) if !s.vars.is_empty() => Ok(instSig(&s.body, &BTreeMap::new())),
3906
3929
  _ => lookup(env, name),
3907
3930
  },
3908
- ast::Expr::Self_ => lookup(env, "self"),
3931
+ ast::Expr::Self_ => lookup(env, "self").map_err(|_| {
3932
+ "`self` is only available in a method, a `fun` in a type body whose first parameter is `self` (one without `self` is a static, called as `Type.f()`)".to_string()
3933
+ }),
3909
3934
  // A bare `NAME` lexes as a type_identifier whenever it starts with an
3910
3935
  // uppercase letter — which includes every SCREAMING_CASE top-level
3911
3936
  // const (`PI`, `MAX_VALUE`, ...), since there's no separate "constant"
@@ -4309,6 +4334,7 @@ fn inferExprInner(
4309
4334
  )),
4310
4335
  },
4311
4336
  ast::AttrKind::Method(call) => {
4337
+ checkStaticUse(&attr.object, &obj_ty, &call.name, ctx)?;
4312
4338
  // A member value calling a method only its enum defines.
4313
4339
  if let Some(r) = unions::memberFallback(&obj_ty, &call.name, ctx) {
4314
4340
  let (en, variant) = r?;
@@ -4322,6 +4348,38 @@ fn inferExprInner(
4322
4348
  }
4323
4349
  }
4324
4350
 
4351
+ /// `Type.f()` must name a static (a `fun` without `self`) and `x.f()` a
4352
+ /// method: a static has no value to run on, and a method needs one.
4353
+ fn checkStaticUse(
4354
+ object: &ast::Expr,
4355
+ obj_ty: &PlumType,
4356
+ name: &str,
4357
+ ctx: &CheckCtx,
4358
+ ) -> Result<(), String> {
4359
+ let (Some(statics), Some(class)) = (ctx.statics, methodReceiverName(obj_ty)) else {
4360
+ return Ok(());
4361
+ };
4362
+ let key = (class.clone(), name.to_string());
4363
+ if !ctx.methods.contains_key(&key) {
4364
+ return Ok(());
4365
+ }
4366
+ let on_type = matches!(object, ast::Expr::TypeName(n) if *n == class);
4367
+ let is_static = statics.contains(&key);
4368
+ if on_type && !is_static {
4369
+ return Err(format!(
4370
+ "'{}' is a method of {} (it takes `self`): call it on a value, `x.{}(...)`",
4371
+ name, class, name
4372
+ ));
4373
+ }
4374
+ if !on_type && is_static {
4375
+ return Err(format!(
4376
+ "'{}' is a static function of {} (no `self`): call it as `{}.{}(...)`",
4377
+ name, class, class, name
4378
+ ));
4379
+ }
4380
+ Ok(())
4381
+ }
4382
+
4325
4383
  /// Makes errors from expanded for-forms and from `fun*` definitions
4326
4384
  /// readable: the checker only sees the expanded code (hygienic locals named
4327
4385
  /// `__f<id>_x`) and ordinary-fn copies named `__star_<name>`.
plum-checker/tests/checker_tests.rs CHANGED
@@ -14,12 +14,14 @@ fn emptyCtx() -> CheckCtx<'static> {
14
14
  static ENUM_PARAMS: OnceLock<plum_checker::EnumParams> = OnceLock::new();
15
15
  static MIN_REQUIRED: OnceLock<plum_checker::MinRequiredArgs> = OnceLock::new();
16
16
  static PRIM_GENERICS: OnceLock<plum_checker::PrimitiveGenerics> = OnceLock::new();
17
+ static STATICS: OnceLock<plum_checker::StaticMethods> = OnceLock::new();
17
18
  CheckCtx {
18
19
  methods: METHODS.get_or_init(Default::default),
19
20
  enum_variants: ENUM_VARIANTS.get_or_init(Default::default),
20
21
  enum_params: ENUM_PARAMS.get_or_init(Default::default),
21
22
  min_required: MIN_REQUIRED.get_or_init(Default::default),
22
23
  primitive_generics: PRIM_GENERICS.get_or_init(Default::default),
24
+ statics: Some(STATICS.get_or_init(Default::default)),
23
25
  }
24
26
  }
25
27
 
@@ -463,7 +465,7 @@ fn rejects_basic_expressions_and_calls() {
463
465
  r("fun bad() -> Int =\n false\n", &["return type mismatch"]),
464
466
  r("fun bad() -> Int =\n x\n", &["undefined name 'x'"]),
465
467
  r("fun bad() -> Int =\n 1 + 2.0\n", &["binary op: type mismatch"]),
466
- r("fun bad() -> Int =\n self\n", &["undefined name 'self'"]),
468
+ r("fun bad() -> Int =\n self\n", &["`self` is only available in a method"]),
467
469
  r("fun f() -> Int =\n nope\n", &["undefined name 'nope'"]),
468
470
  r("fun f() -> Int =\n crash 5\n", &["Str"]),
469
471
  r("fun f(c: Bool) -> Int =\n if c then 1 else \"no\"\n", &["inline if branches"]),
@@ -478,13 +480,16 @@ fn rejects_basic_expressions_and_calls() {
478
480
  r("fun f(a: Int?) -> Bool =\n a == 3\n", &["cannot compare"]),
479
481
  r("fun f(a: error{Bad}!Int) -> Bool =\n a == 3\n", &["cannot compare"]),
480
482
  // enum/class fields and constructors
481
- r(&format!("{cat}fun getAge() -> Int =\n self.age\n"), &["undefined name 'self'"]),
483
+ r(&format!("{cat}fun getAge() -> Int =\n self.age\n"), &["`self` is only available in a method"]),
484
+ // a `fun` without `self` is a static: called on the type, never on a value
482
- r("enum Cat =\n | Cat(name: Str)\n\n fun getAge() -> Int =\n self.age\n", &["no field 'age' on type 'Cat'"]),
485
+ r("record Cat(age: Int) =\n fun getAge() -> Int =\n self.age\n", &["`self` is only available in a method"]),
486
+ r("record Cat(age: Int) =\n fun make() -> Cat =\n Cat(age: 1)\n\nfun f() -> Cat =\n Cat(age: 2).make()\n", &["'make' is a static function of Cat", "Cat.make(...)"]),
487
+ r("record Cat(age: Int) =\n fun getAge(self) -> Int =\n self.age\n\nfun f() -> Int =\n Cat.getAge()\n", &["'getAge' is a method of Cat", "call it on a value"]),
483
488
  r("enum Cat =\n | Cat(name: Str)\n\n fun getAge(self) -> Int =\n self.age\n", &["no field 'age' on type 'Cat'"]),
484
489
  r(&format!("{cat}fun makeCat() -> Cat =\n Cat(name: \"x\", age: \"y\")\n"), &["variant 'Cat' field 'age': type mismatch"]),
485
490
  r("enum Cat =\n | Cat(name: Str)\n\nfun makeCat() -> Cat =\n Cat(name: \"x\", age: 1)\n", &["unknown field 'age' on variant 'Cat'"]),
486
- r(&format!("{cat} fun breakCat() =\n self.age = \"oops\"\n"), &["assign 'self.age': type mismatch"]),
491
+ r(&format!("{cat} fun breakCat(self) =\n self.age = \"oops\"\n"), &["assign 'self.age': type mismatch"]),
487
- r(&format!("{cat} fun breakCat() =\n self.nope = 1\n"), &["no field 'nope' on type 'Cat'"]),
492
+ r(&format!("{cat} fun breakCat(self) =\n self.nope = 1\n"), &["no field 'nope' on type 'Cat'"]),
488
493
  r("enum Shape =\n | Square(x: Int, y: Int)\n\nfun bad() -> Shape =\n Square(x: 1)\n", &["variant 'Square' missing field 'y'"]),
489
494
  r("enum Shape =\n | Square(x: Int, y: Int)\n\nfun bad() -> Shape =\n Square(x: 1, y: 2, z: 3)\n", &["unknown field 'z' on variant 'Square'"]),
490
495
  r("enum Shape =\n | Circle(radius: Int)\n | Square(radius: Int)\n\nfun bad() -> Int =\n r := Circle(radius: 5)\n return r.radius\n", &["field 'radius' is ambiguous across multiple variants of enum 'Shape'"]),
@@ -546,7 +551,7 @@ fn rejects_bad_generics_and_duck_typed_calls() {
546
551
  r("fun wrong(x: T) -> Int =\n \"hello\"\n\nfun useIt() -> Str =\n wrong(\"s\")\n", &["in wrong[Str]"]),
547
552
  r("fun identity(value: T) -> T =\n value\n\nfun a() -> Str =\n identity(5)\n", &["expected Str, found Int"]),
548
553
  r("fun f(x: T) -> Int =\n x\n\nfun main() -> Int =\n f(\"s\")\n", &["in f[Str]"]),
549
- r("enum Box[T] =\n | Box(value: T)\n\n fun get() -> Int =\n self.value\n\nfun main() -> Int =\n Box(value: \"s\").get()\n", &["in Box.get[Str]"]),
554
+ r("enum Box[T] =\n | Box(value: T)\n\n fun get(self) -> Int =\n self.value\n\nfun main() -> Int =\n Box(value: \"s\").get()\n", &["in Box.get[Str]"]),
550
555
  r(&generic("fun f() -> List[Box[Str]] =\n List(items: Box(value: 1))\n"), &["variant 'Box' field 'value': type mismatch"]),
551
556
  r(&generic("fun f() -> List[Str] =\n List(items: 1).map(|x| x + 1)\n"), &["method 'List.map' arg 0: type mismatch"]),
552
557
  r(&generic("fun f(xs: List[Int]) -> List[Int] =\n xs.map(|n| \"{n}\")\n"), &["type mismatch"]),
plum-examples/structs_records.plum CHANGED
@@ -83,10 +83,10 @@ fun bump(v: Vec2) -> Float =
83
83
 
84
84
  record Point(x: Int, y: Int)
85
85
 
86
- # `self` is implicit inside a record body, or can be written out.
86
+ # A method takes `self` first; a `fun` without `self` is a static (`Vec2.zero()`).
87
87
  record Cat(name: Str, age: Int) =
88
88
 
89
- fun getAge() -> Int =
89
+ fun getAge(self) -> Int =
90
90
  self.age
91
91
 
92
92
  fun birthday(self) -> Int =
@@ -225,7 +225,7 @@ test "record construction is named or positional"
225
225
  assert Point(x: 3, y: 4).y == 4
226
226
  assert Point(3, 4).y == 4
227
227
 
228
- test "record methods, with and without an explicit self"
228
+ test "record methods"
229
229
  c := Cat(name: "x", age: 7)
230
230
  assert c.getAge() == 7
231
231
  assert c.birthday() == 8
plum-lsp/src/completion.rs CHANGED
@@ -326,6 +326,10 @@ pub fn memberItems(
326
326
  if !owners.contains(r) || (r != &recv_name && items.iter().any(|i| i.label == *name)) {
327
327
  continue;
328
328
  }
329
+ // A static (no `self`) is reached as `Type.f()`, not on a value.
330
+ if ctx.statics.is_some_and(|st| st.contains(&(r.clone(), name.clone()))) {
331
+ continue;
332
+ }
329
333
  let detail = match fn_ty {
330
334
  PlumType::TFun(params, ret) => Some(format!(
331
335
  "({}) -> {:?}",
plum-lsp/src/main.rs CHANGED
@@ -330,12 +330,14 @@ fn memberCompletionFor(
330
330
  plum_checker::buildGlobalTables(&merged);
331
331
  let min_required = plum_checker::buildMinRequiredArgs(&merged);
332
332
  let primitive_generics = plum_checker::buildPrimitiveGenerics(&merged);
333
+ let statics = plum_checker::buildStaticMethods(&merged);
333
334
  let ctx = plum_checker::CheckCtx {
334
335
  methods: &methods,
335
336
  enum_variants: &enum_variants,
336
337
  enum_params: &enum_params,
337
338
  min_required: &min_required,
338
339
  primitive_generics: &primitive_generics,
340
+ statics: Some(&statics),
339
341
  };
340
342
  let cursor_fn = completion::enclosingFn(&own_source, cursor_line);
341
343
  completion::memberItems(cursor_fn, receiver, &global_env, &ctx, &merged.stars)
plum-wasm-codegen/src/lib.rs CHANGED
@@ -1370,6 +1370,7 @@ fn checkCtxOf<'a>(
1370
1370
  enum_params: ctx_enum_params,
1371
1371
  min_required: ctx_min_required,
1372
1372
  primitive_generics: ctx_primitive_generics,
1373
+ statics: None,
1373
1374
  }
1374
1375
  }
1375
1376