plum
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 +87 -0
- plum-checker/tests/checker_tests.rs +61 -0
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
|
+
}
|