plum
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 +40 -14
- plum-checker/tests/checker_tests.rs +28 -0
- plum-core/src/ast.rs +4 -0
- plum-core/src/parser.rs +25 -3
- plum-wasm-codegen/src/lib.rs +8 -0
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
|
-
///
|
|
25
|
+
/// declaration being checked outside any call: the "called at" site of recorded calls.
|
|
25
|
-
static
|
|
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
|
|
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
|
-
///
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|