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