plum

#treesitter#compiler#wasm

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

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


0c02603 — Peter John 2026-10-26T11:48:56+05:30
fix(checker): strict missing methods on builtin receivers; exact call sites
plum-checker/src/lib.rs CHANGED
@@ -21,8 +21,9 @@ thread_local! {
21
21
  static TSUBST: RefCell<Option<BTreeMap<String, PlumType>>> = const { RefCell::new(None) };
22
22
  /// True while checking an instantiated generic body (method lookups on declared types are strict).
23
23
  static INST_MODE: Cell<bool> = const { Cell::new(false) };
24
+ /// Position (line, column) of the innermost call expression being inferred, or of the
24
- /// Line of the declaration currently being checked (the "called at" line of recorded calls).
25
+ /// declaration being checked outside any call: the "called at" site of recorded calls.
25
- static CUR_LINE: Cell<usize> = const { Cell::new(0) };
26
+ static CUR_POS: Cell<(usize, usize)> = const { Cell::new((0, 0)) };
26
27
  /// Address of the expression `inferExprWith` is currently inferring (the innermost one).
27
28
  static CUR_EXPR: Cell<usize> = const { Cell::new(0) };
28
29
  /// While checking the last statement of a value-producing tail (an fn body's
@@ -40,6 +41,7 @@ struct CallSite {
40
41
  name: String,
41
42
  args: BTreeMap<String, PlumType>,
42
43
  line: usize,
44
+ column: usize,
43
45
  /// Address of the call expression (or, for an implicit call, of the expression it is made on).
44
46
  expr: usize,
45
47
  /// A call codegen makes on its own (string interpolation's `toStr`), not written in source.
@@ -60,13 +62,15 @@ fn recordCallAt(
60
62
  if args.is_empty() {
61
63
  return;
62
64
  }
65
+ let (line, column) = CUR_POS.with(|p| p.get());
63
66
  CALLS.with(|c| {
64
67
  if let Some(v) = c.borrow_mut().as_mut() {
65
68
  v.push(CallSite {
66
69
  recv: recv.map(|r| r.to_string()),
67
70
  name: name.to_string(),
68
71
  args,
69
- line: CUR_LINE.with(|l| l.get()),
72
+ line,
73
+ column,
70
74
  expr,
71
75
  implicit,
72
76
  });
@@ -112,8 +116,10 @@ pub struct Instantiation {
112
116
  pub type_args: Vec<PlumType>,
113
117
  /// Instantiation whose body first called this one (`None` = a root).
114
118
  pub parent: Option<usize>,
115
- /// Declaration line of the calling root/instantiation.
119
+ /// Position of the call expression that first reached this instantiation
120
+ /// (the declaration's own position for a call with none, e.g. an implicit one).
116
121
  pub called_at_line: usize,
122
+ pub called_at_column: usize,
117
123
  }
118
124
 
119
125
  /// Both operands of a binary/compare operator have the same type, so an
@@ -1391,7 +1397,7 @@ fn checkSourceInner(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1391
1397
  // name-resolved here (its body is type-checked per instantiation below).
1392
1398
  for item in &source.items {
1393
1399
  if let ast::Item::Fn(f) = item {
1394
- CUR_LINE.with(|l| l.set(f.line));
1400
+ CUR_POS.with(|l| l.set((f.line, f.column)));
1395
1401
  let mut local_errors = checkFn(f, &global_env, &ctx, &enums_by_name);
1396
1402
  if !fnGenericNames(f, &ctx).is_empty() {
1397
1403
  local_errors.retain(|e| isResolutionError(&e.message));
@@ -1405,7 +1411,7 @@ fn checkSourceInner(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1405
1411
 
1406
1412
  for item in &source.items {
1407
1413
  if let ast::Item::Test(t) = item {
1408
- CUR_LINE.with(|l| l.set(t.line));
1414
+ CUR_POS.with(|l| l.set((t.line, t.column)));
1409
1415
  let mut env = global_env.clone();
1410
1416
  let mut block_errors = checkBlock(
1411
1417
  &t.body,
@@ -1436,7 +1442,7 @@ fn checkSourceInner(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1436
1442
  .collect();
1437
1443
  TSUBST.with(|s| *s.borrow_mut() = Some(subst));
1438
1444
  INST_MODE.with(|m| m.set(true));
1439
- CUR_LINE.with(|l| l.set(f.line));
1445
+ CUR_POS.with(|l| l.set((f.line, f.column)));
1440
1446
  let saved = RECORD.with(|r| r.borrow_mut().replace(HashMap::new()));
1441
1447
  let errs = checkFn(f, &global_env, &ctx, &enums_by_name);
1442
1448
  let own = RECORD.with(|r| std::mem::replace(&mut *r.borrow_mut(), saved));
@@ -1464,7 +1470,7 @@ fn checkSourceInner(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1464
1470
 
1465
1471
  struct InstState {
1466
1472
  info: TypedInfo,
1467
- /// Per instantiation id: the "in X[T] (called at f:l): " chain prefix.
1473
+ /// Per instantiation id: the "in X[T] (called at f:l:c): " chain prefix.
1468
1474
  chains: Vec<String>,
1469
1475
  queue: std::collections::VecDeque<usize>,
1470
1476
  file: String,
@@ -1563,12 +1569,13 @@ fn registerCalls(
1563
1569
  let id = st.info.instantiations.len();
1564
1570
  let arg_str: Vec<String> = args.iter().map(|a| a.to_string()).collect();
1565
1571
  st.chains.push(format!(
1566
- "{}in {}[{}] (called at {}:{}): ",
1572
+ "{}in {}[{}] (called at {}:{}:{}): ",
1567
1573
  parent_chain,
1568
1574
  item,
1569
1575
  arg_str.join(", "),
1570
1576
  st.file,
1571
- cs.line
1577
+ cs.line,
1578
+ cs.column
1572
1579
  ));
1573
1580
  st.info.instantiations.push(Instantiation {
1574
1581
  id,
@@ -1579,6 +1586,7 @@ fn registerCalls(
1579
1586
  type_args: args,
1580
1587
  parent,
1581
1588
  called_at_line: cs.line,
1589
+ called_at_column: cs.column,
1582
1590
  });
1583
1591
  st.info.inst_index.insert(key, id);
1584
1592
  st.info.call_inst.insert((parent, cs.expr, cs.implicit), id);
@@ -2973,6 +2981,11 @@ fn inferMethodCall(
2973
2981
  ));
2974
2982
  }
2975
2983
  let Some(class_name) = methodReceiverName(obj_ty) else {
2984
+ // Inside an instantiation a concrete receiver with no method table at
2985
+ // all (unit, tag union, fn type) has no methods.
2986
+ if INST_MODE.with(|m| m.get()) && !hasFreeVars(obj_ty) && !matches!(obj_ty, PlumType::TVar(_)) {
2987
+ return Err(format!("{} has no method '{}'", obj_ty, name));
2988
+ }
2976
2989
  return Ok(PlumType::TVar("_".to_string()));
2977
2990
  };
2978
2991
  // Fallback: a primitive/class receiver's method might be defined once on
@@ -2999,12 +3012,12 @@ fn inferMethodCall(
2999
3012
  }
3000
3013
  // Inside an instantiated generic body the receiver is a concrete type:
3001
3014
  // a declared type lacking the method is a real (duck-typing) error.
3015
+ // Builtin/primitive receivers too: their methods are those declared in
3016
+ // plum-std plus the compiler intrinsics handled before this lookup.
3002
- if INST_MODE.with(|m| m.get())
3017
+ if INST_MODE.with(|m| m.get()) && !hasFreeVars(obj_ty) {
3003
- && ctx.enum_variants.values().any(|i| i.enum_name == class_name)
3004
- {
3005
3018
  return Err(format!("{} has no method '{}'", obj_ty, name));
3006
3019
  }
3007
- // Unmodeled method (e.g. builtin/std): allow, codegen will catch.
3020
+ // Outside an instantiation (erased pass): allow, the per-instance pass decides.
3008
3021
  return Ok(PlumType::TVar("_".to_string()));
3009
3022
  };
3010
3023
  let PlumType::TFun(..) = sig else {
@@ -3109,7 +3122,20 @@ pub fn inferExprWith(
3109
3122
  expected: Option<&PlumType>,
3110
3123
  ) -> Result<PlumType, String> {
3111
3124
  let prev = CUR_EXPR.with(|c| c.replace(expr as *const ast::Expr as usize));
3125
+ let call_pos = match expr {
3126
+ ast::Expr::FnCall(c) => Some((c.line, c.column)),
3127
+ ast::Expr::Attribute(a) => match &a.attr {
3128
+ ast::AttrKind::Method(c) => Some((c.line, c.column)),
3129
+ _ => None,
3130
+ },
3131
+ _ => None,
3132
+ };
3133
+ let prev_pos = CUR_POS.with(|p| p.get());
3134
+ if let Some(pos) = call_pos.filter(|p| p.0 != 0) {
3135
+ CUR_POS.with(|p| p.set(pos));
3136
+ }
3112
3137
  let r = inferExprInner(expr, env, ctx, expected);
3138
+ CUR_POS.with(|p| p.set(prev_pos));
3113
3139
  CUR_EXPR.with(|c| c.set(prev));
3114
3140
  if let Ok(t) = &r {
3115
3141
  recordType(expr, t);
plum-checker/tests/checker_tests.rs CHANGED
@@ -2353,3 +2353,31 @@ fn true_false_are_never_tags() {
2353
2353
  let r = checkSource(&parseClean(src));
2354
2354
  assert!(r.is_ok(), "got {:?}", r.err());
2355
2355
  }
2356
+
2357
+ #[test]
2358
+ fn builtin_missing_method_strict() {
2359
+ let src = "fun maxOf(a: T, b: T) -> T =\n a.lt(b) ? b : a\n\nfun main() -> Int =\n maxOf(1, 2)\n";
2360
+ let msg = errText(checkSource(&parseClean(src)));
2361
+ assert!(msg.contains("in maxOf[Int] (called at "), "{}", msg);
2362
+ assert!(msg.contains("Int has no method 'lt'"), "{}", msg);
2363
+ }
2364
+
2365
+ #[test]
2366
+ fn called_at_exact_line_col() {
2367
+ // `maxOf` is declared on line 4 but called on line 7, col 3.
2368
+ let src = "enum Plain =\n | Plain(v: Int)\n\nfun maxOf(a: T, b: T) -> T =\n a.lt(b) ? b : a\nfun main() -> Plain =\n maxOf(Plain(v: 1), Plain(v: 2))\n";
2369
+ let msg = errText(checkSource(&parseClean(&src)));
2370
+ assert!(msg.contains("in maxOf[Plain] (called at "), "{}", msg);
2371
+ assert!(msg.contains(":7:3): "), "{}", msg);
2372
+ assert!(!msg.contains(":4:"), "{}", msg);
2373
+ }
2374
+
2375
+ #[test]
2376
+ fn nested_chain_exact_sites() {
2377
+ let src = "enum Plain =\n | Plain(v: Int)\n\nfun inner(a: T) -> T =\n a.nope()\n\nfun outer(a: T) -> T =\n x := 1\n inner(a)\n\nfun main() -> Plain =\n outer(Plain(v: 1))\n";
2378
+ let msg = errText(checkSource(&parseClean(src)));
2379
+ assert!(msg.contains("in outer[Plain] (called at "), "{}", msg);
2380
+ assert!(msg.contains(":12:3): in inner[Plain] (called at "), "{}", msg);
2381
+ assert!(msg.contains(":9:3): "), "{}", msg);
2382
+ assert!(msg.contains("Plain has no method 'nope'"), "{}", msg);
2383
+ }
plum-core/src/ast.rs CHANGED
@@ -461,6 +461,10 @@ pub struct ElvisExpr {
461
461
  pub struct FnCall {
462
462
  pub name: String,
463
463
  pub args: Vec<Arg>,
464
+ /// 1-based position of the call expression (a method call: the whole
465
+ /// `obj.name(...)`), for diagnostics. `(0, 0)` for a synthetic call.
466
+ pub line: usize,
467
+ pub column: usize,
464
468
  }
465
469
 
466
470
  #[derive(Debug, Clone, PartialEq)]
plum-core/src/parser.rs CHANGED
@@ -1112,6 +1112,7 @@ impl<'a> AstParser<'a> {
1112
1112
  // attribute: primary_expression "." fn_identifier fn_argument_list?
1113
1113
  // The member name is always fn_identifier (a superset of var_identifier); an
1114
1114
  // optional trailing argument list distinguishes a method call from field access.
1115
+ let pos = node.start_position();
1115
1116
  let named: Vec<Node> = self.namedChildren(node);
1116
1117
  let object = named
1117
1118
  .first()
@@ -1128,7 +1129,12 @@ impl<'a> AstParser<'a> {
1128
1129
  .named_children(&mut acursor)
1129
1130
  .map(|n| self.parseArg(n))
1130
1131
  .collect();
1131
- AttrKind::Method(FnCall { name: member, args })
1132
+ AttrKind::Method(FnCall {
1133
+ name: member,
1134
+ args,
1135
+ line: pos.row + 1,
1136
+ column: pos.column + 1,
1137
+ })
1132
1138
  }
1133
1139
  None => AttrKind::Field(member),
1134
1140
  };
@@ -1152,6 +1158,8 @@ impl<'a> AstParser<'a> {
1152
1158
  attr: AttrKind::Method(FnCall {
1153
1159
  name: "mapErr".to_string(),
1154
1160
  args: vec![Arg::Positional(closure)],
1161
+ line: node.start_position().row + 1,
1162
+ column: node.start_position().column + 1,
1155
1163
  }),
1156
1164
  }))));
1157
1165
  }
@@ -1171,6 +1179,7 @@ impl<'a> AstParser<'a> {
1171
1179
  fn parseSafeAttribute(&self, node: Node) -> Expr {
1172
1180
  const SAFE_NAV_PARAM: &str = "__safe_nav";
1173
1181
 
1182
+ let pos = node.start_position();
1174
1183
  let named: Vec<Node> = self.namedChildren(node);
1175
1184
  let object = named
1176
1185
  .first()
@@ -1187,7 +1196,12 @@ impl<'a> AstParser<'a> {
1187
1196
  .named_children(&mut acursor)
1188
1197
  .map(|n| self.parseArg(n))
1189
1198
  .collect();
1190
- AttrKind::Method(FnCall { name: member, args })
1199
+ AttrKind::Method(FnCall {
1200
+ name: member,
1201
+ args,
1202
+ line: pos.row + 1,
1203
+ column: pos.column + 1,
1204
+ })
1191
1205
  }
1192
1206
  None => AttrKind::Field(member),
1193
1207
  };
@@ -1207,6 +1221,8 @@ impl<'a> AstParser<'a> {
1207
1221
  attr: AttrKind::Method(FnCall {
1208
1222
  name: "map".to_string(),
1209
1223
  args: vec![Arg::Positional(Expr::Closure(Box::new(closure)))],
1224
+ line: pos.row + 1,
1225
+ column: pos.column + 1,
1210
1226
  }),
1211
1227
  }))
1212
1228
  }
@@ -1228,7 +1244,13 @@ impl<'a> AstParser<'a> {
1228
1244
  .collect()
1229
1245
  })
1230
1246
  .unwrap_or_default();
1247
+ let pos = node.start_position();
1231
- FnCall { name, args }
1248
+ FnCall {
1249
+ name,
1250
+ args,
1251
+ line: pos.row + 1,
1252
+ column: pos.column + 1,
1253
+ }
1232
1254
  }
1233
1255
 
1234
1256
  fn parseArg(&self, node: Node) -> Arg {
plum-wasm-codegen/src/lib.rs CHANGED
@@ -6637,6 +6637,8 @@ fn compileExpr(
6637
6637
  let init_call = ast::FnCall {
6638
6638
  name: "init".to_string(),
6639
6639
  args: call.args.clone(),
6640
+ line: call.line,
6641
+ column: call.column,
6640
6642
  };
6641
6643
  compileMethodCall(
6642
6644
  &object,
@@ -6694,6 +6696,8 @@ fn compileExpr(
6694
6696
  .cloned()
6695
6697
  .map(ast::Arg::Positional)
6696
6698
  .collect(),
6699
+ line: 0,
6700
+ column: 0,
6697
6701
  };
6698
6702
  compileVariantConstruction(info, &synthetic_call, expr, body, ctx, state)?;
6699
6703
  }
@@ -6740,6 +6744,8 @@ fn compileExpr(
6740
6744
  let init_call = ast::FnCall {
6741
6745
  name: "init".to_string(),
6742
6746
  args: vec![],
6747
+ line: 0,
6748
+ column: 0,
6743
6749
  };
6744
6750
  compileMethodCall(
6745
6751
  &object,
@@ -7414,6 +7420,8 @@ fn compileInterpolatedString(
7414
7420
  let to_str = ast::FnCall {
7415
7421
  name: "toStr".to_string(),
7416
7422
  args: vec![],
7423
+ line: 0,
7424
+ column: 0,
7417
7425
  };
7418
7426
  let str_ty = PlumType::TStr;
7419
7427
  let is_dyn = match &other {