use std::{
ffi::OsStr,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use syn::{
Attribute, ExprMatch, ImplItemFn, ItemFn, ItemMod, Meta, Pat, spanned::Spanned, visit::Visit,
};
use crate::asserter::{for_each_rs_file, read_allowlist, read_search_dirs};
const TARGET_ENUMS: &[&str] = &["OpRecord", "OpKind"];
pub fn run(args: Vec<String>) -> Result<()> {
if let Some(arg) = args.first() {
bail!(
"check-oprecord-exhaustiveness: unexpected argument '{arg}' (configured via env vars: \
HEDDLE_OPRECORD_EXHAUSTIVENESS_SEARCH_DIRS, HEDDLE_OPRECORD_EXHAUSTIVENESS_ALLOWLIST)"
);
}
check(
&read_search_dirs("HEDDLE_OPRECORD_EXHAUSTIVENESS_SEARCH_DIRS"),
&read_allowlist("HEDDLE_OPRECORD_EXHAUSTIVENESS_ALLOWLIST"),
)
}
fn check(search_dirs: &[PathBuf], allowlist: &[String]) -> Result<()> {
let mut hits: Vec<Hit> = Vec::new();
let mut files_scanned = 0usize;
for dir in search_dirs {
scan_dir(dir, &mut hits, &mut files_scanned)
.with_context(|| format!("scan {}", dir.display()))?;
}
let mut failed = 0usize;
for hit in &hits {
let key = format!("{}:{}", hit.path.display(), hit.line);
if allowlist.iter().any(|entry| entry == &key) {
println!("ok: exempt: {key} — {}", hit.snippet.trim());
continue;
}
eprintln!(
"::error::non-exhaustive {} match at {key}: a wildcard arm (`{}`) silently swallows \
unhandled variants — {}",
hit.enum_name,
hit.arm,
hit.snippet.trim()
);
failed += 1;
}
if failed > 0 {
eprintln!(
"\n::error::Found {failed} non-exhaustive match(es) over a target enum ({}). A \
production `match` over these enums must name every variant (group no-op variants in an explicit \
`A | B | C => {{}}` arm), so adding a variant is a COMPILE error until every consumer is updated — \
that is what closes the \"new variant not propagated to every consumer\" class. Replace the \
catch-all `_ =>` arm with explicit per-variant arms.\n\
\n\
If a site is a legitimate exception, add a `path:line` entry (of the wildcard arm) to \
HEDDLE_OPRECORD_EXHAUSTIVENESS_ALLOWLIST with a one-line justification.",
TARGET_ENUMS.join("/")
);
bail!("asserter failed");
}
println!(
"asserter clean: every production match over {} is exhaustive ({files_scanned} file(s) \
scanned)",
TARGET_ENUMS.join("/")
);
Ok(())
}
#[derive(Debug)]
struct Hit {
path: PathBuf,
line: usize,
enum_name: String,
arm: String,
snippet: String,
}
fn scan_dir(dir: &Path, hits: &mut Vec<Hit>, files_scanned: &mut usize) -> Result<()> {
for_each_rs_file(dir, files_scanned, is_test_path, |path, source| {
let file = syn::parse_file(source).with_context(|| format!("parse {}", path.display()))?;
let lines: Vec<&str> = source.lines().collect();
let mut visitor = Finder {
path: path.to_path_buf(),
lines: &lines,
hits,
};
visitor.visit_file(&file);
Ok(())
})
}
fn is_test_path(path: &Path) -> bool {
for component in path.components() {
if component.as_os_str() == "tests" {
return true;
}
}
path.file_name()
.and_then(OsStr::to_str)
.map(|name| name.ends_with("_tests.rs") || name == "tests.rs")
.unwrap_or(false)
}
fn is_cfg_test(attrs: &[Attribute]) -> bool {
fn meta_mentions_test(meta: &Meta) -> bool {
match meta {
Meta::Path(path) => path.is_ident("test"),
Meta::List(list) if list.path.is_ident("cfg") => {
list.tokens.to_string().contains("test")
}
Meta::List(list) if list.path.is_ident("all") || list.path.is_ident("any") => {
list.tokens.to_string().contains("test")
}
_ => false,
}
}
attrs
.iter()
.any(|attr| attr.path().is_ident("cfg") && meta_mentions_test(&attr.meta))
}
struct Finder<'a> {
path: PathBuf,
lines: &'a [&'a str],
hits: &'a mut Vec<Hit>,
}
impl<'ast> Visit<'ast> for Finder<'_> {
fn visit_item_mod(&mut self, node: &'ast ItemMod) {
if is_cfg_test(&node.attrs) {
return;
}
syn::visit::visit_item_mod(self, node);
}
fn visit_item_fn(&mut self, node: &'ast ItemFn) {
if is_cfg_test(&node.attrs) {
return;
}
syn::visit::visit_item_fn(self, node);
}
fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) {
if is_cfg_test(&node.attrs) {
return;
}
syn::visit::visit_impl_item_fn(self, node);
}
fn visit_expr_match(&mut self, node: &'ast ExprMatch) {
if let Some(enum_name) = matched_target_enum(node) {
for arm in &node.arms {
if arm.guard.is_none() && pat_is_catch_all(&arm.pat) {
let line = arm.pat.span().start().line;
let snippet = self
.lines
.get(line.saturating_sub(1))
.copied()
.unwrap_or("")
.to_string();
self.hits.push(Hit {
path: self.path.clone(),
line,
enum_name: enum_name.clone(),
arm: pat_text(&arm.pat),
snippet,
});
}
}
}
syn::visit::visit_expr_match(self, node);
}
}
fn matched_target_enum(node: &ExprMatch) -> Option<String> {
node.arms.iter().find_map(|arm| pat_target_enum(&arm.pat))
}
fn pat_target_enum(pat: &Pat) -> Option<String> {
let path = match pat {
Pat::Path(p) => Some(&p.path),
Pat::TupleStruct(ts) => Some(&ts.path),
Pat::Struct(s) => Some(&s.path),
Pat::Or(or) => return or.cases.iter().find_map(pat_target_enum),
Pat::Paren(p) => return pat_target_enum(&p.pat),
Pat::Reference(r) => return pat_target_enum(&r.pat),
_ => None,
}?;
let first = path.segments.first()?.ident.to_string();
TARGET_ENUMS
.iter()
.find(|name| **name == first)
.map(|name| (*name).to_string())
}
fn pat_is_catch_all(pat: &Pat) -> bool {
match pat {
Pat::Wild(_) => true,
Pat::Ident(p) => p.subpat.is_none() && is_binding_ident(&p.ident.to_string()),
Pat::Or(or) => or.cases.iter().any(pat_is_catch_all),
Pat::Paren(p) => pat_is_catch_all(&p.pat),
Pat::Reference(r) => pat_is_catch_all(&r.pat),
_ => false,
}
}
fn is_binding_ident(ident: &str) -> bool {
ident
.chars()
.next()
.is_some_and(|c| c == '_' || c.is_lowercase())
}
fn pat_text(pat: &Pat) -> String {
match pat {
Pat::Wild(_) => "_".to_string(),
Pat::Ident(p) => p.ident.to_string(),
Pat::Or(or) => or
.cases
.iter()
.map(pat_text)
.collect::<Vec<_>>()
.join(" | "),
Pat::Paren(p) => pat_text(&p.pat),
Pat::Reference(r) => format!("&{}", pat_text(&r.pat)),
_ => "<catch-all>".to_string(),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn scan_source(src: &str) -> Vec<Hit> {
let file = syn::parse_file(src).expect("parse");
let lines: Vec<&str> = src.lines().collect();
let mut hits = Vec::new();
let mut v = Finder {
path: PathBuf::from("test.rs"),
lines: &lines,
hits: &mut hits,
};
v.visit_file(&file);
hits
}
#[test]
fn flags_wildcard_arm_over_oprecord() {
let hits = scan_source(
"fn f(op: &OpRecord) { match op { \
OpRecord::Snapshot { .. } => a(), \
_ => {} \
} }",
);
assert_eq!(hits.len(), 1, "wildcard over OpRecord must be flagged");
assert_eq!(hits[0].enum_name, "OpRecord");
}
#[test]
fn flags_bare_binding_catch_all() {
let hits = scan_source(
"fn f(op: &OpRecord) -> u8 { match op { \
OpRecord::Goto { .. } => 1, \
other => g(other), \
} }",
);
assert_eq!(hits.len(), 1, "bare binding catch-all must be flagged");
}
#[test]
fn flags_wildcard_inside_or_pattern() {
let hits = scan_source(
"fn f(op: &OpRecord) { match op { \
OpRecord::Snapshot { .. } | _ => {} \
} }",
);
assert_eq!(
hits.len(),
1,
"wildcard inside an or-pattern must be flagged"
);
}
#[test]
fn flags_o_p_kind_match_too() {
let hits = scan_source("fn f(k: OpKind) { match k { OpKind::Snapshot => a(), _ => {} } }");
assert_eq!(hits.len(), 1, "OpKind is also a target enum");
assert_eq!(hits[0].enum_name, "OpKind");
}
#[test]
fn ignores_exhaustive_match() {
let hits = scan_source(
"fn f(op: &OpRecord) { match op { \
OpRecord::Snapshot { .. } => a(), \
OpRecord::Goto { .. } | OpRecord::Fork { .. } => b(), \
} }",
);
assert!(hits.is_empty(), "an exhaustive match must not be flagged");
}
#[test]
fn ignores_guarded_catch_all() {
let hits = scan_source(
"fn f(op: &OpRecord) { match op { \
OpRecord::Snapshot { .. } => a(), \
_ if cond() => b(), \
OpRecord::Goto { .. } => c(), \
} }",
);
assert!(
hits.is_empty(),
"a guarded catch-all is not a silent swallow"
);
}
#[test]
fn ignores_wildcard_over_non_target_enum() {
let hits =
scan_source("fn f(kind: &str) -> u8 { match kind { \"snapshot\" => 1, _ => 0 } }");
assert!(hits.is_empty(), "non-target matches keep their wildcard");
}
#[test]
fn ignores_matches_macro() {
let hits =
scan_source("fn f(op: &OpRecord) -> bool { matches!(op, OpRecord::Snapshot { .. }) }");
assert!(hits.is_empty(), "matches! predicate must not be flagged");
}
#[test]
fn ignores_inline_cfg_test_module() {
let hits = scan_source(
"fn prod() {} \
#[cfg(test)] mod tests { \
fn t(op: &OpRecord) { match op { OpRecord::Snapshot { .. } => a(), _ => {} } } \
}",
);
assert!(
hits.is_empty(),
"inline #[cfg(test)] module must be skipped"
);
}
const PLANTED: &str = "fn f(op: &OpRecord) { match op { \
OpRecord::Snapshot { .. } => a(), \
_ => {} \
} }";
#[test]
fn check_bails_on_planted_site_and_exempts_via_allowlist() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("bypass.rs"), PLANTED).unwrap();
let dirs = vec![dir.path().to_path_buf()];
assert!(
check(&dirs, &[]).is_err(),
"a planted wildcard over OpRecord must fail the check"
);
let key = format!("{}:1", dir.path().join("bypass.rs").display());
assert!(
check(&dirs, &[key]).is_ok(),
"an allowlisted site must pass the check"
);
}
#[test]
fn check_passes_on_clean_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("clean.rs"),
"fn f(op: &OpRecord) { match op { \
OpRecord::Snapshot { .. } => a(), \
OpRecord::Goto { .. } => b(), \
} }",
)
.unwrap();
assert!(check(&[dir.path().to_path_buf()], &[]).is_ok());
}
#[test]
fn production_tree_oprecord_matches_are_exhaustive() {
let crates_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("devtools crate dir has a parent (the crates/ dir)")
.to_path_buf();
let mut hits = Vec::new();
let mut scanned = 0usize;
scan_dir(&crates_dir, &mut hits, &mut scanned).expect("scan crates/");
assert!(
scanned > 0,
"expected to scan some files under {crates_dir:?}"
);
assert!(
hits.is_empty(),
"non-exhaustive OpRecord/OpKind match(es) found: {:?}",
hits.iter()
.map(|h| format!(
"{}:{} ({} arm `{}`)",
h.path.display(),
h.line,
h.enum_name,
h.arm
))
.collect::<Vec<_>>()
);
}
}