plum

#treesitter#compiler#wasm

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

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


a261b90 — Peter John 2026-10-26T11:15:58+05:30
feat(checker): duck-typed generics checked per instantiation
plum-checker/src/lib.rs CHANGED
@@ -15,13 +15,78 @@ thread_local! {
15
15
  /// When set, `inferExprWith` records every expression's type here (see `typedSource`).
16
16
  static RECORD_BINDS: RefCell<Option<Binds>> = const { RefCell::new(None) };
17
17
  static RECORD: RefCell<Option<HashMap<usize, PlumType>>> = const { RefCell::new(None) };
18
+ /// Generic-call sites seen while checking one body (see `CallSite`); `None` outside `checkSourceInner`.
19
+ static CALLS: RefCell<Option<Vec<CallSite>>> = const { RefCell::new(None) };
20
+ /// While checking a generic body for one instantiation: its generic parameter -> concrete type.
21
+ static TSUBST: RefCell<Option<BTreeMap<String, PlumType>>> = const { RefCell::new(None) };
22
+ /// True while checking an instantiated generic body (method lookups on declared types are strict).
23
+ static INST_MODE: Cell<bool> = const { Cell::new(false) };
24
+ /// Line of the declaration currently being checked (the "called at" line of recorded calls).
25
+ static CUR_LINE: Cell<usize> = const { Cell::new(0) };
26
+ }
27
+
28
+ /// A call to a generic fn/method seen in some body: callee identity plus the
29
+ /// (possibly still unresolved) type bound to each of its generic parameters.
30
+ #[derive(Debug, Clone)]
31
+ struct CallSite {
32
+ recv: Option<String>,
33
+ name: String,
34
+ args: BTreeMap<String, PlumType>,
35
+ line: usize,
36
+ }
37
+
38
+ fn recordCall(recv: Option<&str>, name: &str, args: BTreeMap<String, PlumType>) {
39
+ if args.is_empty() {
40
+ return;
41
+ }
42
+ CALLS.with(|c| {
43
+ if let Some(v) = c.borrow_mut().as_mut() {
44
+ v.push(CallSite {
45
+ recv: recv.map(|r| r.to_string()),
46
+ name: name.to_string(),
47
+ args,
48
+ line: CUR_LINE.with(|l| l.get()),
49
+ });
50
+ }
51
+ });
18
52
  }
19
53
 
20
54
  /// Per-expression instantiated types of a checked source, for codegen.
55
+ ///
56
+ /// Generic bodies are checked per concrete instantiation (Zig-style duck
57
+ /// typing). The data for codegen is:
58
+ /// * `expr_types`: types from the generic-erased (TParam) pass, as before;
59
+ /// * `instantiations`: every `(item, type args)` pair reached from a root
60
+ /// (non-generic fns, tests), in discovery order; `Instantiation::id` is the
61
+ /// index into this vector (so one wasm fn per entry can be emitted);
62
+ /// * `inst_expr_types`: `(instantiation id, expr address)` -> that expression's
63
+ /// type inside the instantiated body, generics substituted;
64
+ /// * `inst_index`: `(item path, type args)` -> instantiation id.
21
65
  #[derive(Debug, Clone, Default)]
22
66
  pub struct TypedInfo {
23
67
  /// Expression address (`&ast::Expr as *const _ as usize`) -> its type.
24
68
  pub expr_types: HashMap<usize, PlumType>,
69
+ pub instantiations: Vec<Instantiation>,
70
+ pub inst_expr_types: HashMap<(usize, usize), PlumType>,
71
+ pub inst_index: HashMap<(String, Vec<PlumType>), usize>,
72
+ }
73
+
74
+ /// One reached instantiation of a generic fn or method of a generic type.
75
+ #[derive(Debug, Clone, PartialEq)]
76
+ pub struct Instantiation {
77
+ pub id: usize,
78
+ /// Item path: `"max"` for a free fn, `"List.map"` for a method.
79
+ pub item: String,
80
+ pub receiver: Option<String>,
81
+ pub name: String,
82
+ /// Generic parameter names: the owner type's generics first, then the fn's own.
83
+ pub generics: Vec<String>,
84
+ /// Concrete type per entry of `generics`.
85
+ pub type_args: Vec<PlumType>,
86
+ /// Instantiation whose body first called this one (`None` = a root).
87
+ pub parent: Option<usize>,
88
+ /// Declaration line of the calling root/instantiation.
89
+ pub called_at_line: usize,
25
90
  }
26
91
 
27
92
  /// Both operands of a binary/compare operator have the same type, so an
@@ -120,6 +185,14 @@ fn freeTParams(ty: &PlumType, out: &mut Vec<String>) {
120
185
  /// Instantiates every free `TParam` in `sig`: those named in `owner` take the
121
186
  /// owner's (receiver's) type, all others get a fresh inference variable.
122
187
  fn instSig(sig: &PlumType, owner: &BTreeMap<String, PlumType>) -> PlumType {
188
+ instSigMap(sig, owner).0
189
+ }
190
+
191
+ /// `instSig`, also returning the generic-parameter -> type map it used.
192
+ fn instSigMap(
193
+ sig: &PlumType,
194
+ owner: &BTreeMap<String, PlumType>,
195
+ ) -> (PlumType, BTreeMap<String, PlumType>) {
123
196
  let mut names = Vec::new();
124
197
  freeTParams(sig, &mut names);
125
198
  let map: BTreeMap<String, PlumType> = names
@@ -129,7 +202,7 @@ fn instSig(sig: &PlumType, owner: &BTreeMap<String, PlumType>) -> PlumType {
129
202
  (n, t)
130
203
  })
131
204
  .collect();
132
- types::substitute(sig, &map)
205
+ (types::substitute(sig, &map), map)
133
206
  }
134
207
 
135
208
  /// One-way matching: binds the inference variables of `pattern` from `actual`.
@@ -221,6 +294,9 @@ pub fn plumTypeFromAstG(ty: &ast::Type) -> PlumType {
221
294
  );
222
295
  }
223
296
  if isGenericName(&ty.name) {
297
+ if let Some(t) = TSUBST.with(|s| s.borrow().as_ref().and_then(|m| m.get(&ty.name).cloned())) {
298
+ return t;
299
+ }
224
300
  return PlumType::TParam(ty.name.clone());
225
301
  }
226
302
  plumTypeFromName(&ty.name)
@@ -907,9 +983,59 @@ pub fn buildGlobalTables(source: &ast::Source) -> (TypeEnv, MethodEnv, EnumVaria
907
983
  }
908
984
  }
909
985
 
986
+ // `Bool.toStr` is a compiler builtin (see `builtinBoolToStr`); it is visible as a
987
+ // method (trait conformance, dyn dispatch) once `Str` is in the program.
988
+ if hasStr(source) {
989
+ methods
990
+ .entry(("Bool".to_string(), "toStr".to_string()))
991
+ .or_insert_with(|| PlumType::TFun(Vec::new(), Box::new(PlumType::TStr)));
992
+ }
910
993
  (global_env, methods, enum_variants, enum_params)
911
994
  }
912
995
 
996
+ fn hasStr(source: &ast::Source) -> bool {
997
+ source
998
+ .items
999
+ .iter()
1000
+ .any(|i| matches!(i, ast::Item::Enum(e) if e.name == "Str"))
1001
+ }
1002
+
1003
+ /// True if `source` should get the builtin `Bool.toStr` (it returns `Str`, and
1004
+ /// the user has not defined their own).
1005
+ pub fn needsBuiltinBoolToStr(source: &ast::Source) -> bool {
1006
+ hasStr(source)
1007
+ && !source.items.iter().any(|i| {
1008
+ matches!(i, ast::Item::Fn(f) if f.name == "toStr" && f.type_param.as_deref() == Some("Bool"))
1009
+ })
1010
+ }
1011
+
1012
+ /// The builtin `Bool.toStr` as an ordinary method AST (`self ? "True" : "False"`),
1013
+ /// for codegen to compile as one wasm function. Nothing is added to the source.
1014
+ pub fn builtinBoolToStr() -> ast::Fn {
1015
+ let lit = |t: &str| {
1016
+ ast::Expr::String(ast::StringExpr {
1017
+ parts: vec![ast::StringPart::Text(t.to_string())],
1018
+ })
1019
+ };
1020
+ ast::Fn {
1021
+ name: "toStr".to_string(),
1022
+ line: 0,
1023
+ column: 0,
1024
+ type_param: Some("Bool".to_string()),
1025
+ is_extern: false,
1026
+ params: Vec::new(),
1027
+ returns: Some(ast::Type {
1028
+ name: "Str".to_string(),
1029
+ generics: Vec::new(),
1030
+ }),
1031
+ body: ast::FnBody::Expr(ast::Expr::Ternary(Box::new(ast::TernaryExpr {
1032
+ condition: ast::Expr::Self_,
1033
+ then: lit("True"),
1034
+ else_: lit("False"),
1035
+ }))),
1036
+ }
1037
+ }
1038
+
913
1039
  /// A `Type`'s name for comparison purposes only — deliberately ignores `.generics`
914
1040
  /// entirely (on both sides), so a trait's bare return/param type (`cause() ->
915
1041
  /// Link`, unable to name a concrete payload since a trait has no instance to
@@ -1019,26 +1145,35 @@ fn checkTraitConformance(source: &ast::Source) -> Vec<CheckError> {
1019
1145
  }
1020
1146
 
1021
1147
  pub fn checkSource(source: &ast::Source) -> CheckResult<()> {
1022
- checkSourceInner(source)
1148
+ checkSourceFull(source).0
1023
1149
  }
1024
1150
 
1025
1151
  /// Type-checks the original source like `checkSource` and also returns the
1026
- /// instantiated type of every expression (keyed by expression address) for codegen.
1152
+ /// instantiated type of every expression (keyed by expression address) and the
1153
+ /// set of generic instantiations (see `TypedInfo`) for codegen.
1027
1154
  pub fn typedSource(source: &ast::Source) -> CheckResult<TypedInfo> {
1155
+ let (result, info) = checkSourceFull(source);
1156
+ result.map(|()| info)
1157
+ }
1158
+
1159
+ fn checkSourceFull(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1028
1160
  RECORD.with(|r| *r.borrow_mut() = Some(HashMap::new()));
1029
1161
  RECORD_BINDS.with(|r| *r.borrow_mut() = Some(Binds::new()));
1162
+ CALLS.with(|r| *r.borrow_mut() = Some(Vec::new()));
1030
- let result = checkSourceInner(source);
1163
+ let (result, mut info) = checkSourceInner(source);
1031
1164
  let mut recorded = RECORD.with(|r| r.borrow_mut().take()).unwrap_or_default();
1032
1165
  let binds = RECORD_BINDS.with(|r| r.borrow_mut().take()).unwrap_or_default();
1166
+ CALLS.with(|r| r.borrow_mut().take());
1167
+ TSUBST.with(|s| *s.borrow_mut() = None);
1168
+ INST_MODE.with(|m| m.set(false));
1033
1169
  // Zonk: apply the final substitution; a variable nothing ever constrained
1034
1170
  // (an unused `None`, an empty literal, ...) is defaulted to `Unit`, since
1035
1171
  // no value of it can ever be observed.
1036
- for t in recorded.values_mut() {
1172
+ for t in recorded.values_mut().chain(info.inst_expr_types.values_mut()) {
1037
1173
  *t = defaultVars(&substVars(t, &binds));
1038
1174
  }
1039
- result.map(|()| TypedInfo {
1040
- expr_types: recorded,
1175
+ info.expr_types = recorded;
1041
- })
1176
+ (result, info)
1042
1177
  }
1043
1178
 
1044
1179
  /// A generic type named without type arguments (`p: Parcel` for `enum
@@ -1118,7 +1253,7 @@ fn checkBareGenericTypes(source: &ast::Source) -> Vec<CheckError> {
1118
1253
  errors
1119
1254
  }
1120
1255
 
1121
- fn checkSourceInner(source: &ast::Source) -> CheckResult<()> {
1256
+ fn checkSourceInner(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1122
1257
  // Generic items are checked once, structurally (`TApp`/`TParam`), on the
1123
1258
  // ORIGINAL source.
1124
1259
  let mut errors: Vec<CheckError> = checkTraitConformance(source);
@@ -1196,15 +1331,46 @@ fn checkSourceInner(source: &ast::Source) -> CheckResult<()> {
1196
1331
  _ => None,
1197
1332
  })
1198
1333
  .collect();
1334
+ let file = source
1335
+ .module
1336
+ .as_ref()
1337
+ .map(|m| m.name.clone())
1338
+ .unwrap_or_else(|| "<source>".to_string());
1339
+ let fn_by_key: HashMap<(Option<String>, String), &ast::Fn> = source
1340
+ .items
1341
+ .iter()
1342
+ .filter_map(|i| match i {
1343
+ ast::Item::Fn(f) => Some(((f.type_param.clone(), f.name.clone()), f)),
1344
+ _ => None,
1345
+ })
1346
+ .collect();
1347
+ let mut inst = InstState {
1348
+ info: TypedInfo::default(),
1349
+ chains: Vec::new(),
1350
+ queue: std::collections::VecDeque::new(),
1351
+ file,
1352
+ };
1353
+ drainCalls();
1354
+
1355
+ // Roots: every non-generic fn and every test. A generic fn is only
1356
+ // name-resolved here (its body is type-checked per instantiation below).
1199
1357
  for item in &source.items {
1200
1358
  if let ast::Item::Fn(f) = item {
1359
+ CUR_LINE.with(|l| l.set(f.line));
1201
1360
  let mut local_errors = checkFn(f, &global_env, &ctx, &enums_by_name);
1361
+ if !fnGenericNames(f, &ctx).is_empty() {
1362
+ local_errors.retain(|e| isResolutionError(&e.message));
1363
+ drainCalls();
1364
+ } else {
1365
+ registerCalls(&mut inst, &fn_by_key, &ctx, None);
1366
+ }
1202
1367
  errors.append(&mut local_errors);
1203
1368
  }
1204
1369
  }
1205
1370
 
1206
1371
  for item in &source.items {
1207
1372
  if let ast::Item::Test(t) = item {
1373
+ CUR_LINE.with(|l| l.set(t.line));
1208
1374
  let mut env = global_env.clone();
1209
1375
  let mut block_errors = checkBlock(
1210
1376
  &t.body,
@@ -1215,13 +1381,171 @@ fn checkSourceInner(source: &ast::Source) -> CheckResult<()> {
1215
1381
  &ctx,
1216
1382
  );
1217
1383
  errors.append(&mut block_errors);
1384
+ registerCalls(&mut inst, &fn_by_key, &ctx, None);
1218
1385
  }
1219
1386
  }
1220
1387
 
1388
+ // Check each reached instantiation (BFS); its own calls enqueue more.
1389
+ let mut seen_msgs: std::collections::HashSet<String> = std::collections::HashSet::new();
1390
+ while let Some(id) = inst.queue.pop_front() {
1391
+ let i = inst.info.instantiations[id].clone();
1392
+ let Some(f) = fn_by_key.get(&(i.receiver.clone(), i.name.clone())).copied() else {
1393
+ continue;
1394
+ };
1395
+ let chain = inst.chains[id].clone();
1396
+ let subst: BTreeMap<String, PlumType> = i
1397
+ .generics
1398
+ .iter()
1399
+ .cloned()
1400
+ .zip(i.type_args.iter().cloned())
1401
+ .collect();
1402
+ TSUBST.with(|s| *s.borrow_mut() = Some(subst));
1403
+ INST_MODE.with(|m| m.set(true));
1404
+ CUR_LINE.with(|l| l.set(f.line));
1405
+ let saved = RECORD.with(|r| r.borrow_mut().replace(HashMap::new()));
1406
+ let errs = checkFn(f, &global_env, &ctx, &enums_by_name);
1407
+ let own = RECORD.with(|r| std::mem::replace(&mut *r.borrow_mut(), saved));
1408
+ TSUBST.with(|s| *s.borrow_mut() = None);
1409
+ INST_MODE.with(|m| m.set(false));
1410
+ for (ptr, t) in own.unwrap_or_default() {
1411
+ inst.info.inst_expr_types.insert((id, ptr), t);
1412
+ }
1413
+ for e in errs {
1414
+ let msg = match (e.message.strip_prefix("fn '"), e.message.find("': ")) {
1415
+ (Some(_), Some(k)) => e.message[k + 3..].to_string(),
1416
+ _ => e.message.clone(),
1417
+ };
1418
+ let full = format!("{}{}", chain, msg);
1419
+ if seen_msgs.insert(full.clone()) {
1420
+ errors.push(CheckError::at(e.line, e.column, full));
1421
+ }
1422
+ }
1423
+ registerCalls(&mut inst, &fn_by_key, &ctx, Some(id));
1424
+ }
1425
+
1426
+ let result = if errors.is_empty() { Ok(()) } else { Err(errors) };
1427
+ (result, inst.info)
1428
+ }
1429
+
1430
+ struct InstState {
1431
+ info: TypedInfo,
1432
+ /// Per instantiation id: the "in X[T] (called at f:l): " chain prefix.
1433
+ chains: Vec<String>,
1434
+ queue: std::collections::VecDeque<usize>,
1435
+ file: String,
1436
+ }
1437
+
1438
+ /// Errors a generic body keeps even when uninstantiated (name resolution and
1439
+ /// declaration structure); everything else is a type error found per instantiation.
1440
+ fn isResolutionError(msg: &str) -> bool {
1441
+ msg.contains("undefined name")
1442
+ || msg.contains("extern")
1443
+ || msg.contains("variadic")
1444
+ || msg.contains("missing a body")
1445
+ }
1446
+
1447
+ fn drainCalls() -> Vec<CallSite> {
1448
+ CALLS.with(|c| {
1449
+ c.borrow_mut()
1450
+ .as_mut()
1451
+ .map(std::mem::take)
1452
+ .unwrap_or_default()
1453
+ })
1454
+ }
1455
+
1456
+ /// Generic parameter names of `f`: its owner type's generics, then the free
1457
+ /// type parameters of its own signature. Empty = not generic.
1458
+ fn fnGenericNames(f: &ast::Fn, ctx: &CheckCtx) -> Vec<String> {
1459
+ let mut names: Vec<String> = match &f.type_param {
1460
+ Some(r) => enumGenericsOf(r, ctx).to_vec(),
1461
+ None => Vec::new(),
1462
+ };
1463
+ let mut sig = Vec::new();
1464
+ for p in &f.params {
1465
+ freeTParams(&paramTypeG(&p.ty), &mut sig);
1466
+ }
1467
+ if let Some(r) = &f.returns {
1468
+ freeTParams(&plumTypeFromAstG(r), &mut sig);
1469
+ }
1470
+ for n in sig {
1221
- if errors.is_empty() {
1471
+ if !names.contains(&n) {
1472
+ names.push(n);
1473
+ }
1474
+ }
1222
- Ok(())
1475
+ names
1476
+ }
1477
+
1478
+ fn hasFreeVars(t: &PlumType) -> bool {
1479
+ use PlumType::*;
1223
- } else {
1480
+ match t {
1481
+ TVar(_) | TParam(_) => true,
1482
+ TApp(_, a) => a.iter().any(hasFreeVars),
1483
+ TFun(ps, r) => ps.iter().any(hasFreeVars) || hasFreeVars(r),
1484
+ TVariadic(i) => hasFreeVars(i),
1485
+ TTags(m) => m.values().flatten().any(|t| hasFreeVars(t)),
1486
+ _ => false,
1487
+ }
1488
+ }
1489
+
1490
+ /// Turns the generic calls recorded while checking one body into new
1491
+ /// instantiations (memoized by item + concrete type args).
1224
- Err(errors)
1492
+ fn registerCalls(
1493
+ st: &mut InstState,
1494
+ fns: &HashMap<(Option<String>, String), &ast::Fn>,
1495
+ ctx: &CheckCtx,
1496
+ parent: Option<usize>,
1497
+ ) {
1498
+ let binds = RECORD_BINDS.with(|r| r.borrow().clone()).unwrap_or_default();
1499
+ let parent_chain = parent.map(|p| st.chains[p].clone()).unwrap_or_default();
1500
+ let depth = parent_chain.matches("(called at").count();
1501
+ for cs in drainCalls() {
1502
+ let Some(f) = fns.get(&(cs.recv.clone(), cs.name.clone())) else {
1503
+ continue;
1504
+ };
1505
+ let gens = fnGenericNames(f, ctx);
1506
+ if gens.is_empty() || depth >= 24 || st.info.instantiations.len() >= 20000 {
1507
+ continue;
1508
+ }
1509
+ let mut args = Vec::new();
1510
+ for g in &gens {
1511
+ match cs.args.get(g).map(|t| substVars(t, &binds)) {
1512
+ Some(t) if !hasFreeVars(&t) => args.push(t),
1513
+ _ => break,
1514
+ }
1515
+ }
1516
+ if args.len() != gens.len() {
1517
+ continue;
1518
+ }
1519
+ let item = match &cs.recv {
1520
+ Some(r) => format!("{}.{}", r, cs.name),
1521
+ None => cs.name.clone(),
1522
+ };
1523
+ let key = (item.clone(), args.clone());
1524
+ if st.info.inst_index.contains_key(&key) {
1525
+ continue;
1526
+ }
1527
+ let id = st.info.instantiations.len();
1528
+ let arg_str: Vec<String> = args.iter().map(|a| a.to_string()).collect();
1529
+ st.chains.push(format!(
1530
+ "{}in {}[{}] (called at {}:{}): ",
1531
+ parent_chain,
1532
+ item,
1533
+ arg_str.join(", "),
1534
+ st.file,
1535
+ cs.line
1536
+ ));
1537
+ st.info.instantiations.push(Instantiation {
1538
+ id,
1539
+ item,
1540
+ receiver: cs.recv.clone(),
1541
+ name: cs.name.clone(),
1542
+ generics: gens,
1543
+ type_args: args,
1544
+ parent,
1545
+ called_at_line: cs.line,
1546
+ });
1547
+ st.info.inst_index.insert(key, id);
1548
+ st.queue.push_back(id);
1225
1549
  }
1226
1550
  }
1227
1551
 
@@ -1306,6 +1630,10 @@ fn checkFn(
1306
1630
  generics.iter().map(|g| PlumType::TParam(g.clone())).collect(),
1307
1631
  )
1308
1632
  };
1633
+ let self_ty = match TSUBST.with(|s| s.borrow().clone()) {
1634
+ Some(m) => types::substitute(&self_ty, &m),
1635
+ None => self_ty,
1636
+ };
1309
1637
  env.insert("self".to_string(), TypeScheme::mono(self_ty));
1310
1638
  // The owner's declared trait bounds (`K: Hashable`) apply in its methods.
1311
1639
  if let Some(e) = enums.get(recv.as_str()) {
@@ -2055,13 +2383,29 @@ fn inferClassCallRaw(
2055
2383
  } else {
2056
2384
  BTreeMap::new()
2057
2385
  };
2386
+ let (inst, mut init_map) = instSigMap(sig, &owner);
2058
- let PlumType::TFun(_, ret) = instSig(sig, &owner) else {
2387
+ let PlumType::TFun(_, ret) = inst else {
2059
2388
  unreachable!("instSig preserves the TFun shape")
2060
2389
  };
2061
2390
  let mut binds = Binds::new();
2062
2391
  if let Some(exp) = expected {
2063
2392
  bindMatch(&ret, exp, &mut binds);
2064
2393
  }
2394
+ for g in gens {
2395
+ if let Some(t) = owner.get(g) {
2396
+ init_map.insert(g.clone(), t.clone());
2397
+ } else if !init_map.contains_key(g) {
2398
+ // Owner generic not in init's signature: pinned by the result type.
2399
+ if let PlumType::TApp(_, rargs) = &*ret {
2400
+ if let Some(i) = gens.iter().position(|x| x == g) {
2401
+ if let Some(a) = rargs.get(i) {
2402
+ init_map.insert(g.clone(), a.clone());
2403
+ }
2404
+ }
2405
+ }
2406
+ }
2407
+ }
2408
+ recordCall(Some(&call.type_name), "init", init_map);
2065
2409
  return Ok(substVars(&ret, &binds));
2066
2410
  }
2067
2411
  }
@@ -2545,7 +2889,13 @@ fn inferMethodCall(
2545
2889
  ctx: &CheckCtx,
2546
2890
  expected: Option<&PlumType>,
2547
2891
  ) -> Result<PlumType, String> {
2892
+ // `Bool.toStr` is a compiler intrinsic ("True"/"False"), not a source method.
2893
+ if matches!(obj_ty, PlumType::TBool) && name == "toStr" && args.is_empty() {
2894
+ return Ok(PlumType::TStr);
2895
+ }
2548
- // A method on a generic parameter resolves through its trait bounds only.
2896
+ // A method on a generic parameter: a declared bound's signature if there is
2897
+ // one; otherwise duck-typed — checked when the enclosing generic body is
2898
+ // checked per concrete instantiation.
2549
2899
  if let PlumType::TParam(p) = obj_ty {
2550
2900
  let prefix = format!("{}{}:", BOUND_KEY, p);
2551
2901
  for key in env.keys() {
@@ -2562,8 +2912,10 @@ fn inferMethodCall(
2562
2912
  return Ok(substVars(ret, &binds));
2563
2913
  }
2564
2914
  }
2915
+ // Not a type error: the erased pass of a generic body just cannot type
2916
+ // this call (its errors are discarded; see `isResolutionError`).
2565
2917
  return Err(format!(
2566
- "'{}' has no method '{}' (add a bound)",
2918
+ "'{}.{}' is resolved per instantiation",
2567
2919
  p, name
2568
2920
  ));
2569
2921
  }
@@ -2592,6 +2944,13 @@ fn inferMethodCall(
2592
2944
  if isTraitName(&class_name, ctx) {
2593
2945
  return Err(format!("trait '{}' has no method '{}'", class_name, name));
2594
2946
  }
2947
+ // Inside an instantiated generic body the receiver is a concrete type:
2948
+ // a declared type lacking the method is a real (duck-typing) error.
2949
+ if INST_MODE.with(|m| m.get())
2950
+ && ctx.enum_variants.values().any(|i| i.enum_name == class_name)
2951
+ {
2952
+ return Err(format!("{} has no method '{}'", obj_ty, name));
2953
+ }
2595
2954
  // Unmodeled method (e.g. builtin/std): allow, codegen will catch.
2596
2955
  return Ok(PlumType::TVar("_".to_string()));
2597
2956
  };
@@ -2604,7 +2963,11 @@ fn inferMethodCall(
2604
2963
  }
2605
2964
  _ => BTreeMap::new(),
2606
2965
  };
2966
+ let (inst, mut call_map) = instSigMap(sig, &owner);
2967
+ for (k, v) in &owner {
2968
+ call_map.insert(k.clone(), v.clone());
2969
+ }
2607
- let PlumType::TFun(params, ret) = instSig(sig, &owner) else {
2970
+ let PlumType::TFun(params, ret) = inst else {
2608
2971
  unreachable!("instSig preserves the TFun shape")
2609
2972
  };
2610
2973
  let mut binds = Binds::new();
@@ -2618,6 +2981,7 @@ fn inferMethodCall(
2618
2981
  .unwrap_or(params.len());
2619
2982
  let label = format!("method '{}.{}'", class_name, name);
2620
2983
  checkCallArgs(&label, &params, args, required, env, ctx, &mut binds)?;
2984
+ recordCall(Some(&class_name), name, call_map);
2621
2985
  Ok(substVars(&ret, &binds))
2622
2986
  }
2623
2987
 
@@ -3013,8 +3377,13 @@ fn inferExprInner(
3013
3377
  }
3014
3378
  return Ok(substVars(&result, &binds));
3015
3379
  }
3380
+ let mut call_map = BTreeMap::new();
3016
3381
  let callee = match env.get(&call.name) {
3382
+ Some(s) if !s.vars.is_empty() => {
3017
- Some(s) if !s.vars.is_empty() => Ok(instSig(&s.body, &BTreeMap::new())),
3383
+ let (t, m) = instSigMap(&s.body, &BTreeMap::new());
3384
+ call_map = m;
3385
+ Ok(t)
3386
+ }
3018
3387
  _ => lookup(env, &call.name),
3019
3388
  };
3020
3389
  match callee {
@@ -3030,6 +3399,7 @@ fn inferExprInner(
3030
3399
  .unwrap_or(param_types.len());
3031
3400
  let label = format!("call '{}'", call.name);
3032
3401
  checkCallArgs(&label, &param_types, &call.args, required, env, ctx, &mut binds)?;
3402
+ recordCall(None, &call.name, call_map);
3033
3403
  Ok(substVars(&ret, &binds))
3034
3404
  }
3035
3405
  Ok(_) => Err(format!("'{}' is not a function", call.name)),
plum-checker/src/types.rs CHANGED
@@ -1,6 +1,6 @@
1
1
  use std::collections::BTreeMap;
2
2
 
3
- #[derive(Debug, Clone, PartialEq)]
3
+ #[derive(Debug, Clone, PartialEq, Eq, Hash)]
4
4
  pub enum PlumType {
5
5
  TInt,
6
6
  TFloat,
plum-checker/tests/checker_tests.rs CHANGED
@@ -1983,12 +1983,87 @@ fn nested_generic_field() {
1983
1983
  }
1984
1984
 
1985
1985
  #[test]
1986
- fn unbounded_tparam_method_error() {
1986
+ fn unbounded_tparam_method_now_ok() {
1987
+ // Bounds are ignored: a method on a bare `T` is checked per instantiation.
1987
1988
  let r = checkSource(&parseClean("fun f(x: T) -> Int =\n x.hash()\n"));
1989
+ assert!(r.is_ok(), "uninstantiated unbounded T must not error: {:?}", r.err());
1990
+ }
1991
+
1992
+ const DUCK_DEFS: &str = "\
1993
+ enum Num =
1994
+ | Num(v: Int)
1995
+
1996
+ fun lt(self, o: Num) -> Bool =
1997
+ self.v < o.v
1998
+
1999
+ enum Plain =
2000
+ | Plain(v: Int)
2001
+
2002
+ fun maxOf(a: T, b: T) -> T =
2003
+ a.lt(b) ? b : a
2004
+
2005
+ ";
2006
+
1988
- assert!(
2007
+ #[test]
2008
+ fn duck_ok() {
1989
- errText(r).contains("'T' has no method 'hash' (add a bound)"),
2009
+ let src = format!(
1990
- "unbounded T must not resolve methods"
2010
+ "{}fun main() -> Num =\n maxOf(Num(v: 1), Num(v: 2))\n",
2011
+ DUCK_DEFS
2012
+ );
2013
+ let r = checkSource(&parseClean(&src));
2014
+ assert!(r.is_ok(), "{:?}", r.err());
2015
+ }
2016
+
2017
+ #[test]
2018
+ fn duck_missing_method_chain() {
2019
+ let src = format!(
2020
+ "{}fun main() -> Plain =\n maxOf(Plain(v: 1), Plain(v: 2))\n",
2021
+ DUCK_DEFS
1991
2022
  );
2023
+ let msg = errText(checkSource(&parseClean(&src)));
2024
+ assert!(msg.contains("called at"), "{}", msg);
2025
+ assert!(msg.contains("in maxOf[Plain]"), "{}", msg);
2026
+ assert!(msg.contains("Plain has no method 'lt'"), "{}", msg);
2027
+ }
2028
+
2029
+ #[test]
2030
+ fn uninstantiated_generic_not_type_errored() {
2031
+ let src = format!("{}fun unused(a: T) -> Int =\n a.nothing() + \"s\"\n", DUCK_DEFS);
2032
+ let r = checkSource(&parseClean(&src));
2033
+ assert!(r.is_ok(), "{:?}", r.err());
2034
+ }
2035
+
2036
+ #[test]
2037
+ fn nested_instantiation_chain() {
2038
+ let src = format!(
2039
+ "{}fun outer(a: T) -> T =\n maxOf(a, a)\n\nfun main() -> Plain =\n outer(Plain(v: 1))\n",
2040
+ DUCK_DEFS
2041
+ );
2042
+ let msg = errText(checkSource(&parseClean(&src)));
2043
+ let first = msg.find("in outer[Plain]").expect(&msg);
2044
+ let second = msg.find("in maxOf[Plain]").expect(&msg);
2045
+ assert!(first < second, "{}", msg);
2046
+ assert!(msg.matches("called at").count() >= 2, "{}", msg);
2047
+ assert!(msg.contains("Plain has no method 'lt'"), "{}", msg);
2048
+ }
2049
+
2050
+ #[test]
2051
+ fn instantiation_set_listed() {
2052
+ let src = format!(
2053
+ "{}fun main() -> Int =\n a := List(items: 1).map(|n| n * 2)\n b := List(items: \"x\").map(|s| s)\n 0\n",
2054
+ GENERIC_DEFS
2055
+ );
2056
+ let info = plum_checker::typedSource(&parseClean(&src)).unwrap_or_else(|e| panic!("{:?}", e));
2057
+ let has = |t: PlumType| {
2058
+ info.instantiations
2059
+ .iter()
2060
+ .any(|i| i.item == "List.map" && i.type_args.first() == Some(&t))
2061
+ };
2062
+ assert!(has(PlumType::TInt) && has(PlumType::TStr), "{:?}", info.instantiations);
2063
+ for i in &info.instantiations {
2064
+ assert_eq!(info.inst_index[&(i.item.clone(), i.type_args.clone())], i.id);
2065
+ }
2066
+ assert!(!info.inst_expr_types.is_empty());
1992
2067
  }
1993
2068
 
1994
2069
  #[test]
@@ -2077,9 +2152,13 @@ fun b() -> Str =
2077
2152
 
2078
2153
  #[test]
2079
2154
  fn generic_return_of_param_rejected_for_concrete_declared_return() {
2080
- // Under erasure a `T` value must never be accepted where `Int` is declared.
2155
+ // Duck typing: only an instantiation where `T` is not `Int` is rejected.
2081
- assert!(checkSource(&parseClean("fun f(x: T) -> Int =\n x\n")).is_err());
2156
+ assert!(checkSource(&parseClean("fun f(x: T) -> Int =\n x\n")).is_ok());
2157
+ assert!(checkSource(&parseClean(
2158
+ "fun f(x: T) -> Int =\n x\n\nfun main() -> Int =\n f(\"s\")\n"
2159
+ ))
2160
+ .is_err());
2082
- let method = "enum Box[T] =\n | Box(value: T)\n\n fun get() -> Int =\n self.value\n";
2161
+ let method = "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";
2083
2162
  assert!(checkSource(&parseClean(method)).is_err());
2084
2163
  assert!(checkSource(&parseClean("fun f(x: T) -> T =\n x\n")).is_ok());
2085
2164
  }
plum-core/src/loader.rs CHANGED
@@ -57,8 +57,6 @@ pub fn loadAndMergeWithOverrides(
57
57
  )?;
58
58
  }
59
59
 
60
- injectBoolIntrinsics(&mut items);
61
-
62
60
  Ok(Source {
63
61
  module: entry_source.module,
64
62
  imports: entry_source.imports,
@@ -67,32 +65,6 @@ pub fn loadAndMergeWithOverrides(
67
65
  })
68
66
  }
69
67
 
70
- /// `Bool` is a built-in i32 with no source file; its one method, `toStr`
71
- /// ("True"/"False" — the same text string interpolation emits), is a compiler
72
- /// intrinsic. It is supplied as a synthetic method item so the checker, `ToStr`
73
- /// trait conformance, dyn dispatch and codegen all see an ordinary method. Only
74
- /// injected once `Str` is in the program (the method returns `Str`).
75
- fn injectBoolIntrinsics(items: &mut Vec<Item>) {
76
- let has_str = items
77
- .iter()
78
- .any(|i| matches!(i, Item::Enum(e) if e.name == "Str"));
79
- if !has_str {
80
- return;
81
- }
82
- const SNIPPET: &str = "enum Bool =\n | Bool\n\n fun toStr(self) -> Str =\n self ? \"True\" : \"False\"\n";
83
- let mut parser = tree_sitter::Parser::new();
84
- parser
85
- .set_language(&tree_sitter_plum::LANGUAGE.into())
86
- .expect("plum grammar loads");
87
- let tree = parser.parse(SNIPPET, None).expect("intrinsic snippet parses");
88
- let source = AstParser::new(SNIPPET).parseSource(tree.root_node());
89
- for item in source.items {
90
- if let Item::Fn(f) = item {
91
- items.push(Item::Fn(f));
92
- }
93
- }
94
- }
95
-
96
68
  fn canonicalizeMaybeMissing(path: &Path) -> Result<PathBuf, String> {
97
69
  if let Ok(canon) = std::fs::canonicalize(path) {
98
70
  return Ok(canon);
plum-wasm-codegen/src/lib.rs CHANGED
@@ -1760,6 +1760,8 @@ fn compileSourceInner(
1760
1760
  // The source is compiled as written (generics erased, not specialized): the
1761
1761
  // checker's per-expression instantiated types drive every box/unbox decision.
1762
1762
  let types = typed.expr_types;
1763
+ // `Bool.toStr` is a checker/codegen builtin: compiled as one extra method, never added to the source.
1764
+ let bool_to_str_fn = plum_checker::needsBuiltinBoolToStr(source).then(plum_checker::builtinBoolToStr);
1763
1765
  let (global_env, methods, enum_variants, enum_params) = plum_checker::buildGlobalTables(source);
1764
1766
  let min_required = plum_checker::buildMinRequiredArgs(source);
1765
1767
 
@@ -2018,8 +2020,16 @@ fn compileSourceInner(
2018
2020
 
2019
2021
  // Register every function AND method signature up front (methods get an implicit
2020
2022
  // leading `self: pointer` param and are keyed as "Receiver::method").
2021
- for item in &source.items {
2023
+ for f in source
2024
+ .items
2025
+ .iter()
2026
+ .filter_map(|item| match item {
2022
- if let ast::Item::Fn(f) = item {
2027
+ ast::Item::Fn(f) => Some(f),
2028
+ _ => None,
2029
+ })
2030
+ .chain(bool_to_str_fn.iter())
2031
+ {
2032
+ {
2023
2033
  if f.is_extern {
2024
2034
  continue;
2025
2035
  }
@@ -2073,6 +2083,7 @@ fn compileSourceInner(
2073
2083
  ast::Item::Fn(f) if !f.is_extern => Some(f),
2074
2084
  _ => None,
2075
2085
  })
2086
+ .chain(bool_to_str_fn.iter())
2076
2087
  .collect();
2077
2088
 
2078
2089
  // ---- Discovery pre-pass: find every closure literal in every function body. ----