Skip to main content

rs_hack/commands/
match_audit.rs

1//! `match-audit` command: detect match expressions that are missing enum variants.
2
3use std::path::PathBuf;
4
5use anyhow::{Result, bail};
6use syn::visit::Visit;
7
8use crate::files::collect_rust_files_with_exclusions;
9
10#[derive(Debug)]
11pub struct MatchReport {
12    pub enum_name: String,
13    pub all_variants: Vec<String>,
14    pub match_sites: Vec<MatchSite>,
15}
16
17#[derive(Debug)]
18pub struct MatchSite {
19    pub fn_name: String,
20    pub file_path: String,
21    pub line: usize,
22    pub missing_variants: Vec<String>,
23    pub has_wildcard: bool,
24}
25
26pub fn run(paths: &[PathBuf], enum_name: &str, exclude: &[String]) -> Result<MatchReport> {
27    let files = collect_rust_files_with_exclusions(paths, exclude)?;
28
29    // Pass 1: find the enum definition and collect its variants
30    let mut all_variants: Vec<String> = Vec::new();
31
32    for file in &files {
33        let content = match std::fs::read_to_string(file) {
34            Ok(c) => c,
35            Err(e) => {
36                eprintln!("⚠️  Skipping {}: {}", file.display(), e);
37                continue;
38            }
39        };
40        let syntax = match syn::parse_file(&content) {
41            Ok(s) => s,
42            Err(e) => {
43                eprintln!("⚠️  Skipping {} (parse error): {}", file.display(), e);
44                continue;
45            }
46        };
47
48        for item in &syntax.items {
49            if let syn::Item::Enum(e) = item
50                && e.ident == enum_name
51            {
52                all_variants = e.variants.iter().map(|v| v.ident.to_string()).collect();
53                break;
54            }
55        }
56        if !all_variants.is_empty() {
57            break;
58        }
59    }
60
61    if all_variants.is_empty() {
62        bail!(
63            "Enum '{}' not found in any of the scanned files. \
64             Make sure the paths include the file that defines this enum.",
65            enum_name
66        );
67    }
68
69    // Pass 2: walk match expressions and collect sites
70    let mut match_sites: Vec<MatchSite> = Vec::new();
71
72    for file in &files {
73        let content = match std::fs::read_to_string(file) {
74            Ok(c) => c,
75            Err(e) => {
76                eprintln!("⚠️  Skipping {}: {}", file.display(), e);
77                continue;
78            }
79        };
80        let syntax = match syn::parse_file(&content) {
81            Ok(s) => s,
82            Err(e) => {
83                eprintln!("⚠️  Skipping {} (parse error): {}", file.display(), e);
84                continue;
85            }
86        };
87
88        let mut visitor = MatchAuditVisitor {
89            enum_name,
90            all_variants: &all_variants,
91            file_path: file.to_string_lossy().to_string(),
92            fn_stack: Vec::new(),
93            sites: Vec::new(),
94        };
95        visitor.visit_file(&syntax);
96        match_sites.extend(visitor.sites);
97    }
98
99    Ok(MatchReport {
100        enum_name: enum_name.to_string(),
101        all_variants,
102        match_sites,
103    })
104}
105
106pub fn render(report: &MatchReport) {
107    println!("Match audit for enum {}:", report.enum_name);
108    println!("  Known variants: {}", report.all_variants.join(", "));
109    println!();
110
111    if report.match_sites.is_empty() {
112        println!(
113            "  No match expressions found for enum {}.",
114            report.enum_name
115        );
116        return;
117    }
118
119    println!("Missing variants:");
120    let mut any_missing = false;
121    for site in &report.match_sites {
122        if site.has_wildcard {
123            println!(
124                "  {} ({}:{}): (wildcard — covers all)",
125                site.fn_name, site.file_path, site.line
126            );
127        } else if site.missing_variants.is_empty() {
128            println!("  {}: complete", site.fn_name);
129        } else {
130            println!(
131                "  {} ({}:{}): {}",
132                site.fn_name,
133                site.file_path,
134                site.line,
135                site.missing_variants.join(", ")
136            );
137            any_missing = true;
138        }
139    }
140    if !any_missing {
141        println!("  All match expressions are complete.");
142    }
143}
144
145// ---- visitor ----------------------------------------------------------------
146
147struct MatchAuditVisitor<'a> {
148    enum_name: &'a str,
149    all_variants: &'a [String],
150    file_path: String,
151    fn_stack: Vec<String>,
152    sites: Vec<MatchSite>,
153}
154
155impl<'a> MatchAuditVisitor<'a> {
156    fn current_fn(&self) -> String {
157        self.fn_stack
158            .last()
159            .cloned()
160            .unwrap_or_else(|| "<top-level>".to_string())
161    }
162}
163
164impl<'ast, 'a> Visit<'ast> for MatchAuditVisitor<'a> {
165    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
166        self.fn_stack.push(node.sig.ident.to_string());
167        syn::visit::visit_item_fn(self, node);
168        self.fn_stack.pop();
169    }
170
171    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
172        self.fn_stack.push(node.sig.ident.to_string());
173        syn::visit::visit_impl_item_fn(self, node);
174        self.fn_stack.pop();
175    }
176
177    fn visit_expr_match(&mut self, node: &'ast syn::ExprMatch) {
178        // Collect arms that reference our enum
179        let mut variants_seen: Vec<String> = Vec::new();
180        let mut has_wildcard = false;
181
182        for arm in &node.arms {
183            if arm_has_wildcard(&arm.pat) {
184                has_wildcard = true;
185            }
186            collect_enum_variants_from_pat(&arm.pat, self.enum_name, &mut variants_seen);
187        }
188
189        // Only report if at least one arm used our enum (wildcard alone is not enough —
190        // every `match _ => ...` would otherwise show up under every audit).
191        if !variants_seen.is_empty() {
192            let missing: Vec<String> = if has_wildcard {
193                vec![]
194            } else {
195                self.all_variants
196                    .iter()
197                    .filter(|v| !variants_seen.contains(v))
198                    .cloned()
199                    .collect()
200            };
201
202            // Approximate line number via proc_macro2 span
203            let line = node.match_token.span.start().line;
204
205            self.sites.push(MatchSite {
206                fn_name: self.current_fn(),
207                file_path: self.file_path.clone(),
208                line,
209                missing_variants: missing,
210                has_wildcard,
211            });
212        }
213
214        syn::visit::visit_expr_match(self, node);
215    }
216}
217
218/// Return true if this pattern is a wildcard (`_`) or an ident that acts as a catch-all.
219fn arm_has_wildcard(pat: &syn::Pat) -> bool {
220    match pat {
221        syn::Pat::Wild(_) => true,
222        syn::Pat::Ident(pi) if pi.ident == "_" => true,
223        syn::Pat::Or(po) => po.cases.iter().any(arm_has_wildcard),
224        _ => false,
225    }
226}
227
228/// Walk a pattern and push enum variant names into `out` when the second-to-last
229/// path segment equals `enum_name`.
230fn collect_enum_variants_from_pat(pat: &syn::Pat, enum_name: &str, out: &mut Vec<String>) {
231    match pat {
232        syn::Pat::Path(pp) => {
233            check_path(&pp.path, enum_name, out);
234        }
235        syn::Pat::TupleStruct(pts) => {
236            check_path(&pts.path, enum_name, out);
237        }
238        syn::Pat::Struct(ps) => {
239            check_path(&ps.path, enum_name, out);
240        }
241        syn::Pat::Or(po) => {
242            for case in &po.cases {
243                collect_enum_variants_from_pat(case, enum_name, out);
244            }
245        }
246        syn::Pat::Tuple(pt) => {
247            for elem in &pt.elems {
248                collect_enum_variants_from_pat(elem, enum_name, out);
249            }
250        }
251        syn::Pat::Reference(pr) => collect_enum_variants_from_pat(&pr.pat, enum_name, out),
252        syn::Pat::Paren(pp) => collect_enum_variants_from_pat(&pp.pat, enum_name, out),
253        _ => {}
254    }
255}
256
257fn check_path(path: &syn::Path, enum_name: &str, out: &mut Vec<String>) {
258    let segs: Vec<&syn::PathSegment> = path.segments.iter().collect();
259    if segs.len() >= 2 {
260        let second_to_last = segs[segs.len() - 2].ident.to_string();
261        if second_to_last == enum_name {
262            let variant = segs.last().unwrap().ident.to_string();
263            if !out.contains(&variant) {
264                out.push(variant);
265            }
266        }
267    }
268}