plum

#treesitter#compiler#wasm

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

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


cf9e06f — Peter John 2026-10-25T17:43:07+05:30
fix(checker): enforce enum variant exhaustiveness in match (recursive through payloads)
plum-checker/src/lib.rs CHANGED
@@ -1220,6 +1220,169 @@ fn checkStmt(
1220
1220
  errors
1221
1221
  }
1222
1222
 
1223
+ enum CoverFail {
1224
+ /// Not covered, but no constructor set to blame here (literal/opaque column
1225
+ /// or no rows) — the enclosing constructor is reported as missing instead.
1226
+ Opaque,
1227
+ /// Uncovered constructors of the type labelled `.0`.
1228
+ Reported(String, Vec<String>),
1229
+ }
1230
+
1231
+ struct CoverCtor {
1232
+ name: String,
1233
+ fields: Vec<PlumType>,
1234
+ }
1235
+
1236
+ static COVER_WILDCARD: ast::CasePattern = ast::CasePattern::Wildcard;
1237
+
1238
+ fn patCatchAll(p: &ast::CasePattern) -> bool {
1239
+ match p {
1240
+ ast::CasePattern::Wildcard => true,
1241
+ ast::CasePattern::Name(n) => !n.chars().next().is_some_and(|c| c.is_uppercase()),
1242
+ _ => false,
1243
+ }
1244
+ }
1245
+
1246
+ /// A constructor pattern: `Ok(n)` / `Red` / `#Tag` -> (name, sub-patterns).
1247
+ fn patCtor(p: &ast::CasePattern) -> Option<(&str, &[ast::CasePattern])> {
1248
+ match p {
1249
+ ast::CasePattern::Class { name, fields } => Some((name.as_str(), fields.as_slice())),
1250
+ ast::CasePattern::Name(n) if n.chars().next().is_some_and(|c| c.is_uppercase()) => {
1251
+ Some((n.as_str(), &[]))
1252
+ }
1253
+ _ => None,
1254
+ }
1255
+ }
1256
+
1257
+ /// The constructors of a column type (label for messages, constructors), if it
1258
+ /// is an enum, `Bool` or a tag union.
1259
+ fn coverCtors(ty: &PlumType, ctx: &CheckCtx) -> Option<(String, Vec<CoverCtor>)> {
1260
+ match ty {
1261
+ PlumType::TBool => Some((
1262
+ "Bool".to_string(),
1263
+ ["True", "False"]
1264
+ .iter()
1265
+ .map(|n| CoverCtor {
1266
+ name: n.to_string(),
1267
+ fields: Vec::new(),
1268
+ })
1269
+ .collect(),
1270
+ )),
1271
+ PlumType::TTags(tags) => Some((
1272
+ ty.to_string(),
1273
+ tags.iter()
1274
+ .map(|(n, payload)| CoverCtor {
1275
+ name: n.clone(),
1276
+ fields: payload.iter().map(|p| (**p).clone()).collect(),
1277
+ })
1278
+ .collect(),
1279
+ )),
1280
+ PlumType::TNamed(enum_name) => {
1281
+ let mut vs: Vec<(&String, &EnumVariantInfo)> = ctx
1282
+ .enum_variants
1283
+ .iter()
1284
+ .filter(|(_, i)| &i.enum_name == enum_name)
1285
+ .collect();
1286
+ if vs.is_empty() {
1287
+ return None;
1288
+ }
1289
+ vs.sort_by_key(|(_, i)| i.tag);
1290
+ Some((
1291
+ variantBaseName(enum_name).to_string(),
1292
+ vs.into_iter()
1293
+ .map(|(k, i)| CoverCtor {
1294
+ name: variantBaseName(k).to_string(),
1295
+ // A discriminant enum's field types are its shared
1296
+ // params, which patterns never destructure.
1297
+ fields: if ctx.enum_params.contains_key(enum_name) {
1298
+ Vec::new()
1299
+ } else {
1300
+ i.field_types.clone()
1301
+ },
1302
+ })
1303
+ .collect(),
1304
+ ))
1305
+ }
1306
+ _ => None,
1307
+ }
1308
+ }
1309
+
1310
+ /// Pattern-matrix coverage check: do `rows` (one pattern per column of
1311
+ /// `types`) match every possible value? Constructor sets are expanded only
1312
+ /// where some row actually destructures, so recursive enums terminate.
1313
+ fn coverRows<'a>(
1314
+ rows: Vec<Vec<&'a ast::CasePattern>>,
1315
+ types: &[PlumType],
1316
+ ctx: &CheckCtx,
1317
+ ) -> Result<(), CoverFail> {
1318
+ let Some((ty, rest)) = types.split_first() else {
1319
+ return if rows.is_empty() {
1320
+ Err(CoverFail::Opaque)
1321
+ } else {
1322
+ Ok(())
1323
+ };
1324
+ };
1325
+ let ctors = coverCtors(ty, ctx);
1326
+ let has_ctor_pat = rows.iter().any(|r| patCtor(r[0]).is_some());
1327
+ let (label, ctors) = match ctors {
1328
+ Some(c) if has_ctor_pat => c,
1329
+ other => {
1330
+ // Unresolved/opaque column: a constructor pattern against an
1331
+ // unknown type is given the benefit of the doubt.
1332
+ let unknown = other.is_none();
1333
+ let default: Vec<Vec<&ast::CasePattern>> = rows
1334
+ .iter()
1335
+ .filter(|r| patCatchAll(r[0]) || (unknown && patCtor(r[0]).is_some()))
1336
+ .map(|r| r[1..].to_vec())
1337
+ .collect();
1338
+ return coverRows(default, rest, ctx);
1339
+ }
1340
+ };
1341
+ let mut missing: Vec<String> = Vec::new();
1342
+ let mut deeper: Option<CoverFail> = None;
1343
+ for c in &ctors {
1344
+ let mut sub: Vec<Vec<&ast::CasePattern>> = Vec::new();
1345
+ for r in &rows {
1346
+ let mut row: Vec<&ast::CasePattern> = Vec::new();
1347
+ if patCatchAll(r[0]) {
1348
+ row.extend(std::iter::repeat(&COVER_WILDCARD).take(c.fields.len()));
1349
+ } else if let Some((n, fs)) = patCtor(r[0]) {
1350
+ if tagLiteralName(variantBaseName(n)) != c.name {
1351
+ continue;
1352
+ }
1353
+ if let ast::CasePattern::Name(_) = r[0] {
1354
+ // Bare `Circle` against a payload variant: tag check only.
1355
+ row.extend(std::iter::repeat(&COVER_WILDCARD).take(c.fields.len()));
1356
+ } else if fs.len() == c.fields.len() {
1357
+ row.extend(fs.iter());
1358
+ } else {
1359
+ continue;
1360
+ }
1361
+ } else {
1362
+ continue;
1363
+ }
1364
+ row.extend(r[1..].iter().copied());
1365
+ sub.push(row);
1366
+ }
1367
+ let mut tys = c.fields.clone();
1368
+ tys.extend(rest.iter().cloned());
1369
+ match coverRows(sub, &tys, ctx) {
1370
+ Ok(()) => {}
1371
+ Err(CoverFail::Opaque) => missing.push(c.name.clone()),
1372
+ Err(e @ CoverFail::Reported(..)) => {
1373
+ deeper.get_or_insert(e);
1374
+ }
1375
+ }
1376
+ }
1377
+ if !missing.is_empty() {
1378
+ Err(CoverFail::Reported(label, missing))
1379
+ } else if let Some(e) = deeper {
1380
+ Err(e)
1381
+ } else {
1382
+ Ok(())
1383
+ }
1384
+ }
1385
+
1223
1386
  fn checkMatch(
1224
1387
  m: &ast::Match,
1225
1388
  env: &TypeEnv,
@@ -1245,144 +1408,29 @@ fn checkMatch(
1245
1408
  return errors; // a subject failed to type — cases can't be checked meaningfully
1246
1409
  }
1247
1410
 
1248
- // A match over a tag union without a catch-all must cover every tag.
1249
- if let [PlumType::TTags(tags)] = subject_types.as_slice() {
1250
- let mut covered: std::collections::BTreeSet<&str> = Default::default();
1251
- let mut catch_all = false;
1252
- for case in &m.cases {
1253
- if case.guard.is_some() {
1254
- continue;
1255
- }
1256
- match case.patterns.first() {
1257
- Some(ast::CasePattern::Wildcard) => catch_all = true,
1258
- Some(ast::CasePattern::Name(n)) => {
1259
- if tags.contains_key(n) {
1260
- covered.insert(n.as_str());
1261
- } else {
1262
- catch_all = true; // a binding
1263
- }
1264
- }
1265
- Some(ast::CasePattern::Class { name, fields }) => {
1266
- // `B(0)` is refutable and does not cover `B`.
1267
- let irrefutable = |p: &ast::CasePattern| match p {
1268
- ast::CasePattern::Wildcard => true,
1269
- ast::CasePattern::Name(n) => {
1270
- !n.chars().next().is_some_and(|c| c.is_uppercase())
1271
- }
1272
- _ => false,
1273
- };
1274
- if fields.iter().all(irrefutable) {
1275
- covered.insert(name.as_str());
1276
- }
1277
- }
1278
- _ => {}
1279
- }
1280
- }
1281
- if !catch_all {
1282
- let missing: Vec<&str> = tags
1283
- .keys()
1284
- .map(|k| k.as_str())
1285
- .filter(|k| !covered.contains(k))
1286
- .collect();
1287
- if !missing.is_empty() {
1288
- errors.push(CheckError::at(
1289
- fn_pos.0,
1290
- fn_pos.1,
1291
- format!(
1292
- "fn '{}': non-exhaustive match on {}: missing {}",
1293
- fn_name,
1294
- PlumType::TTags(tags.clone()),
1295
- missing.join(", ")
1296
- ),
1297
- ));
1298
- }
1299
- }
1300
- }
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
- }
1411
+ // Exhaustiveness: enum / Bool / tag-union subjects must cover every
1412
+ // constructor (recursively through variant payloads) unless a catch-all
1413
+ // arm exists. Literal (Int/Str/Float) subjects are left alone.
1414
+ if let [subject_ty] = subject_types.as_slice() {
1415
+ let rows: Vec<Vec<&ast::CasePattern>> = m
1416
+ .cases
1417
+ .iter()
1418
+ .filter(|c| c.guard.is_none() && c.patterns.len() == 1)
1419
+ .map(|c| vec![&c.patterns[0]])
1420
+ .collect();
1421
+ if let Err(CoverFail::Reported(label, missing)) =
1422
+ coverRows(rows, std::slice::from_ref(subject_ty), ctx)
1423
+ {
1424
+ errors.push(CheckError::at(
1425
+ fn_pos.0,
1426
+ fn_pos.1,
1427
+ format!(
1428
+ "fn '{}': non-exhaustive match on {}: missing {}",
1429
+ fn_name,
1430
+ label,
1431
+ missing.join(", ")
1432
+ ),
1433
+ ));
1386
1434
  }
1387
1435
  }
1388
1436
 
plum-checker/tests/checker_tests.rs CHANGED
@@ -580,9 +580,9 @@ enum Maybe[T] =
580
580
  fun get() -> Int =
581
581
  o = Just(5)
582
582
  match o
583
- Some(v) =>
583
+ Just(v) =>
584
584
  v
585
- None =>
585
+ Nothing =>
586
586
  0
587
587
  ";
588
588
  let source = parse(src);
@@ -636,17 +636,17 @@ enum Maybe[T] =
636
636
  fun useInt() -> Int =
637
637
  o = Just(5)
638
638
  match o
639
- Some(v) =>
639
+ Just(v) =>
640
640
  v
641
- None =>
641
+ Nothing =>
642
642
  0
643
643
 
644
644
  fun useStr() -> Str =
645
645
  o = Just(\"x\")
646
646
  match o
647
- Some(v) =>
647
+ Just(v) =>
648
648
  v
649
- None =>
649
+ Nothing =>
650
650
  \"z\"
651
651
  ";
652
652
  let source = parse(src);
@@ -730,9 +730,9 @@ enum Maybe[T] =
730
730
 
731
731
  fun unwrapOr(o: Maybe, default: Int) -> Int =
732
732
  match o
733
- Some(v) =>
733
+ Just(v) =>
734
734
  v
735
- None =>
735
+ Nothing =>
736
736
  default
737
737
 
738
738
  fun use() -> Int =
@@ -1849,3 +1849,113 @@ fn nested_refutable_payload_does_not_cover_tag() {
1849
1849
  ));
1850
1850
  assert!(r.contains("missing TooOld"), "got: {}", r);
1851
1851
  }
1852
+
1853
+ // ---------- enum variant exhaustiveness ----------
1854
+
1855
+ fn enumMatch(prelude: &str, arms: &str) -> Result<(), Vec<CheckError>> {
1856
+ checkSource(&parseClean(&format!("{}{}", prelude, arms)))
1857
+ }
1858
+
1859
+ const COLOR_DEF: &str = "\
1860
+ enum Color =
1861
+ | Red
1862
+ | Green
1863
+ | Blue
1864
+
1865
+ fun pick() -> Color =
1866
+ Red
1867
+
1868
+ fun name() -> Str =
1869
+ color := pick()
1870
+ match color
1871
+ ";
1872
+
1873
+ #[test]
1874
+ fn missing_try_ok_variant_is_rejected() {
1875
+ let r = errText(nestedMatch(" Err(BadNumStr) => \"nan\"\n Err(_) => \"e\"\n"));
1876
+ assert!(r.contains("non-exhaustive match on Try: missing Ok"), "got: {}", r);
1877
+ }
1878
+
1879
+ #[test]
1880
+ fn missing_enum_variant_is_rejected() {
1881
+ let r = errText(enumMatch(COLOR_DEF, " Red => \"r\"\n Green => \"g\"\n"));
1882
+ assert!(r.contains("non-exhaustive match on Color: missing Blue"), "got: {}", r);
1883
+ let ok = enumMatch(COLOR_DEF, " Red => \"r\"\n Green => \"g\"\n Blue => \"b\"\n");
1884
+ assert!(ok.is_ok(), "got {:?}", ok.err());
1885
+ }
1886
+
1887
+ #[test]
1888
+ fn wildcard_and_binding_arms_make_enum_match_exhaustive() {
1889
+ let w = enumMatch(COLOR_DEF, " Red => \"r\"\n _ => \"x\"\n");
1890
+ assert!(w.is_ok(), "got {:?}", w.err());
1891
+ let b = enumMatch(COLOR_DEF, " Red => \"r\"\n other => \"x\"\n");
1892
+ assert!(b.is_ok(), "got {:?}", b.err());
1893
+ }
1894
+
1895
+ const WRAP_DEF: &str = "\
1896
+ enum Inner =
1897
+ | Present(Int)
1898
+ | Absent
1899
+
1900
+ enum Outer =
1901
+ | Wrap(Inner)
1902
+ | Empty
1903
+
1904
+ fun mk() -> Outer =
1905
+ Empty
1906
+
1907
+ fun name() -> Str =
1908
+ o := mk()
1909
+ match o
1910
+ ";
1911
+
1912
+ #[test]
1913
+ fn nested_enum_payload_coverage() {
1914
+ let ok = enumMatch(
1915
+ WRAP_DEF,
1916
+ " Wrap(Present(v)) => \"p\"\n Wrap(Absent) => \"a\"\n Empty => \"e\"\n",
1917
+ );
1918
+ assert!(ok.is_ok(), "got {:?}", ok.err());
1919
+ let r = errText(enumMatch(
1920
+ WRAP_DEF,
1921
+ " Wrap(Present(v)) => \"p\"\n Empty => \"e\"\n",
1922
+ ));
1923
+ assert!(r.contains("non-exhaustive match on Inner: missing Absent"), "got: {}", r);
1924
+ let lit = errText(enumMatch(
1925
+ WRAP_DEF,
1926
+ " Wrap(Present(0)) => \"p\"\n Wrap(Absent) => \"a\"\n Empty => \"e\"\n",
1927
+ ));
1928
+ assert!(lit.contains("non-exhaustive match on Inner: missing Present"), "got: {}", lit);
1929
+ }
1930
+
1931
+ #[test]
1932
+ fn bool_match_exhaustiveness() {
1933
+ let def = "\
1934
+ enum Bool =
1935
+ | True
1936
+ | False
1937
+
1938
+ fun f(b: Bool) -> Int =
1939
+ match b
1940
+ ";
1941
+ let ok = enumMatch(def, " True => 1\n False => 0\n");
1942
+ assert!(ok.is_ok(), "got {:?}", ok.err());
1943
+ let r = errText(enumMatch(def, " True => 1\n"));
1944
+ assert!(r.contains("non-exhaustive match on Bool: missing False"), "got: {}", r);
1945
+ }
1946
+
1947
+ #[test]
1948
+ fn discriminant_enum_match_exhaustiveness() {
1949
+ let def = "\
1950
+ enum Step(n: Int) =
1951
+ | ReadMin(10)
1952
+ | ReadMax(20)
1953
+
1954
+ fun f(s: Step) -> Int =
1955
+ match s
1956
+ ";
1957
+ let ok = enumMatch(def, " ReadMin => 1\n ReadMax => 2\n");
1958
+ assert!(ok.is_ok(), "got {:?}", ok.err());
1959
+ let r = errText(enumMatch(def, " ReadMin => 1\n"));
1960
+ assert!(r.contains("non-exhaustive match on Step: missing ReadMax"), "got: {}", r);
1961
+ }