plum

#treesitter#compiler#wasm

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

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


45a1469 — Peter John 2026-10-25T17:31:17+05:30
fix(checker): enforce tag exhaustiveness inside variant payloads (Err(Tag) arms)
plum-checker/src/lib.rs CHANGED
@@ -1299,6 +1299,93 @@ fn checkMatch(
1299
1299
  }
1300
1300
  }
1301
1301
 
1302
+ // A variant field that is itself a tag union (`Err(E)` of `Try[T, [A, B]]`)
1303
+ // must have every tag covered across that variant's arms, unless an arm
1304
+ // catches everything. Only irrefutable sub-patterns count (see `B(0)` above).
1305
+ if let [PlumType::TNamed(enum_name)] = subject_types.as_slice() {
1306
+ let top_catch_all = m.cases.iter().any(|c| {
1307
+ c.guard.is_none()
1308
+ && match c.patterns.first() {
1309
+ Some(ast::CasePattern::Wildcard) => true,
1310
+ Some(ast::CasePattern::Name(n)) => {
1311
+ !n.chars().next().is_some_and(|ch| ch.is_uppercase())
1312
+ }
1313
+ _ => false,
1314
+ }
1315
+ });
1316
+ if !top_catch_all {
1317
+ let irrefutable = |p: &ast::CasePattern| match p {
1318
+ ast::CasePattern::Wildcard => true,
1319
+ ast::CasePattern::Name(n) => !n.chars().next().is_some_and(|c| c.is_uppercase()),
1320
+ _ => false,
1321
+ };
1322
+ for (vname, info) in ctx.enum_variants.iter() {
1323
+ if &info.enum_name != enum_name {
1324
+ continue;
1325
+ }
1326
+ let base = variantBaseName(vname);
1327
+ for (idx, fty) in info.field_types.iter().enumerate() {
1328
+ let PlumType::TTags(tags) = fty else { continue };
1329
+ let mut seen_arm = false;
1330
+ let mut catch_all = false;
1331
+ let mut covered: std::collections::BTreeSet<&str> = Default::default();
1332
+ for case in &m.cases {
1333
+ if case.guard.is_some() {
1334
+ continue;
1335
+ }
1336
+ let Some(ast::CasePattern::Class { name, fields }) = case.patterns.first()
1337
+ else {
1338
+ continue;
1339
+ };
1340
+ if variantBaseName(name) != base || fields.len() != info.field_types.len() {
1341
+ continue;
1342
+ }
1343
+ seen_arm = true;
1344
+ let others_ok = fields
1345
+ .iter()
1346
+ .enumerate()
1347
+ .all(|(j, f)| j == idx || irrefutable(f));
1348
+ if !others_ok {
1349
+ continue;
1350
+ }
1351
+ match &fields[idx] {
1352
+ p if irrefutable(p) => catch_all = true,
1353
+ ast::CasePattern::Name(t) => {
1354
+ covered.insert(tagLiteralName(t));
1355
+ }
1356
+ ast::CasePattern::Class { name: t, fields: tf }
1357
+ if tf.iter().all(irrefutable) =>
1358
+ {
1359
+ covered.insert(tagLiteralName(t));
1360
+ }
1361
+ _ => {}
1362
+ }
1363
+ }
1364
+ if !seen_arm || catch_all {
1365
+ continue;
1366
+ }
1367
+ let missing: Vec<&str> = tags
1368
+ .keys()
1369
+ .map(|k| k.as_str())
1370
+ .filter(|k| !covered.contains(k))
1371
+ .collect();
1372
+ if !missing.is_empty() {
1373
+ errors.push(CheckError::at(
1374
+ fn_pos.0,
1375
+ fn_pos.1,
1376
+ format!(
1377
+ "fn '{}': non-exhaustive match on {}: missing {}",
1378
+ fn_name,
1379
+ fty,
1380
+ missing.join(", ")
1381
+ ),
1382
+ ));
1383
+ }
1384
+ }
1385
+ }
1386
+ }
1387
+ }
1388
+
1302
1389
  for case in &m.cases {
1303
1390
  let mut case_env = env.clone();
1304
1391
  if case.patterns.len() == subject_types.len() {
plum-checker/tests/checker_tests.rs CHANGED
@@ -1788,3 +1788,64 @@ fun main() -> Int =
1788
1788
  let r = checkTry(src);
1789
1789
  assert!(r.is_ok(), "expected Ok, got {}", errText(r));
1790
1790
  }
1791
+
1792
+ const NESTED_E: &str = "[BadNumStr, Negative, TooOld(Int)]";
1793
+
1794
+ fn nestedMatch(arms: &str) -> Result<(), Vec<CheckError>> {
1795
+ checkTry(&format!(
1796
+ "\
1797
+ fun parseAge(s: Str) -> Try[Int, {}] =
1798
+ Err(BadNumStr)
1799
+
1800
+ fun describe(s: Str) -> Str =
1801
+ match parseAge(s)
1802
+ {}",
1803
+ NESTED_E, arms
1804
+ ))
1805
+ }
1806
+
1807
+ #[test]
1808
+ fn nested_tag_missing_in_err_is_rejected() {
1809
+ let r = errText(nestedMatch(
1810
+ " Ok(n) => \"ok\"\n Err(BadNumStr) => \"nan\"\n Err(Negative) => \"neg\"\n",
1811
+ ));
1812
+ assert!(
1813
+ r.contains("non-exhaustive match on [BadNumStr,Negative,TooOld(Int)]: missing TooOld"),
1814
+ "got: {}",
1815
+ r
1816
+ );
1817
+ }
1818
+
1819
+ #[test]
1820
+ fn nested_tags_all_covered_passes() {
1821
+ let r = nestedMatch(
1822
+ " Ok(n) => \"ok\"\n Err(BadNumStr) => \"nan\"\n Err(Negative) => \"neg\"\n Err(TooOld(a)) => \"old\"\n",
1823
+ );
1824
+ assert!(r.is_ok(), "got {:?}", r.err());
1825
+ let r = nestedMatch(
1826
+ " Ok(n) => \"ok\"\n Err(BadNumStr) => \"nan\"\n Err(Negative) => \"neg\"\n Err(TooOld(_)) => \"old\"\n",
1827
+ );
1828
+ assert!(r.is_ok(), "got {:?}", r.err());
1829
+ }
1830
+
1831
+ #[test]
1832
+ fn nested_err_catch_all_passes() {
1833
+ let r = nestedMatch(" Ok(n) => \"ok\"\n Err(BadNumStr) => \"nan\"\n Err(_) => \"other\"\n");
1834
+ assert!(r.is_ok(), "got {:?}", r.err());
1835
+ let r = nestedMatch(" Ok(n) => \"ok\"\n Err(BadNumStr) => \"nan\"\n Err(e) => \"other\"\n");
1836
+ assert!(r.is_ok(), "got {:?}", r.err());
1837
+ }
1838
+
1839
+ #[test]
1840
+ fn nested_top_level_wildcard_passes() {
1841
+ let r = nestedMatch(" Ok(n) => \"ok\"\n Err(BadNumStr) => \"nan\"\n _ => \"other\"\n");
1842
+ assert!(r.is_ok(), "got {:?}", r.err());
1843
+ }
1844
+
1845
+ #[test]
1846
+ fn nested_refutable_payload_does_not_cover_tag() {
1847
+ let r = errText(nestedMatch(
1848
+ " Ok(n) => \"ok\"\n Err(BadNumStr) => \"nan\"\n Err(Negative) => \"neg\"\n Err(TooOld(0)) => \"old\"\n",
1849
+ ));
1850
+ assert!(r.contains("missing TooOld"), "got: {}", r);
1851
+ }