Skip to main content

zeph_tools/filter/
clippy.rs

1use std::collections::BTreeMap;
2use std::fmt::Write;
3use std::sync::LazyLock;
4
5use regex::Regex;
6
7use super::{
8    ClippyFilterConfig, CommandMatcher, FilterConfidence, FilterResult, OutputFilter,
9    cargo_build::is_cargo_noise, make_result,
10};
11
12static CLIPPY_MATCHER: LazyLock<CommandMatcher> = LazyLock::new(|| {
13    CommandMatcher::Custom(Box::new(|cmd| {
14        let c = cmd.to_lowercase();
15        let tokens: Vec<&str> = c.split_whitespace().collect();
16        tokens.first() == Some(&"cargo") && tokens.iter().skip(1).any(|t| *t == "clippy")
17    }))
18});
19
20static LINT_RULE_RE: LazyLock<Regex> =
21    LazyLock::new(|| Regex::new(r"#\[warn\(([^)]+)\)\]").unwrap());
22
23static LOCATION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*-->\s*(.+:\d+)").unwrap());
24
25pub struct ClippyFilter;
26
27impl ClippyFilter {
28    #[must_use]
29    pub fn new(_config: ClippyFilterConfig) -> Self {
30        Self
31    }
32}
33
34impl OutputFilter for ClippyFilter {
35    fn name(&self) -> &'static str {
36        "clippy"
37    }
38
39    fn matcher(&self) -> &CommandMatcher {
40        &CLIPPY_MATCHER
41    }
42
43    fn filter(&self, _command: &str, raw_output: &str, exit_code: i32) -> FilterResult {
44        let has_error = raw_output.contains("error[") || raw_output.contains("error:");
45        if has_error && exit_code != 0 {
46            return make_result(
47                raw_output,
48                raw_output.to_owned(),
49                FilterConfidence::Fallback,
50            );
51        }
52
53        let mut warnings: BTreeMap<String, Vec<String>> = BTreeMap::new();
54        let mut pending_location: Option<String> = None;
55
56        for line in raw_output.lines() {
57            if let Some(caps) = LOCATION_RE.captures(line) {
58                pending_location = Some(caps[1].to_owned());
59            }
60
61            if let Some(caps) = LINT_RULE_RE.captures(line) {
62                let rule = caps[1].to_owned();
63                if let Some(loc) = pending_location.take() {
64                    warnings.entry(rule).or_default().push(loc);
65                }
66            }
67        }
68
69        if warnings.is_empty() {
70            let kept: Vec<&str> = raw_output.lines().filter(|l| !is_cargo_noise(l)).collect();
71            if kept.len() < raw_output.lines().count() {
72                let output = kept.join("\n");
73                return make_result(raw_output, output, FilterConfidence::Partial);
74            }
75            return make_result(
76                raw_output,
77                raw_output.to_owned(),
78                FilterConfidence::Fallback,
79            );
80        }
81
82        let total: usize = warnings.values().map(Vec::len).sum();
83        let rules = warnings.len();
84        let mut output = String::new();
85
86        for (rule, locations) in &warnings {
87            let count = locations.len();
88            let label = if count == 1 { "warning" } else { "warnings" };
89            let _ = writeln!(output, "{rule} ({count} {label}):");
90            for loc in locations {
91                let _ = writeln!(output, "  {loc}");
92            }
93            output.push('\n');
94        }
95        let _ = write!(output, "{total} warnings total ({rules} rules)");
96
97        make_result(raw_output, output, FilterConfidence::Full)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn make_filter() -> ClippyFilter {
106        ClippyFilter::new(ClippyFilterConfig::default())
107    }
108
109    #[test]
110    fn matches_clippy() {
111        let f = make_filter();
112        assert!(f.matcher().matches("cargo clippy --workspace"));
113        assert!(f.matcher().matches("cargo clippy -- -D warnings"));
114        assert!(f.matcher().matches("cargo +nightly clippy"));
115        assert!(!f.matcher().matches("cargo build"));
116        assert!(!f.matcher().matches("cargo test"));
117    }
118
119    #[test]
120    fn filter_groups_warnings() {
121        let f = make_filter();
122        let raw = "\
123warning: needless pass by value
124  --> src/foo.rs:12:5
125   |
126   = help: ...
127   = note: `#[warn(clippy::needless_pass_by_value)]` on by default
128
129warning: needless pass by value
130  --> src/bar.rs:45:10
131   |
132   = help: ...
133   = note: `#[warn(clippy::needless_pass_by_value)]` on by default
134
135warning: unused import
136  --> src/main.rs:5:1
137   |
138   = note: `#[warn(clippy::unused_imports)]` on by default
139
140warning: `my-crate` (lib) generated 3 warnings
141";
142        let result = f.filter("cargo clippy", raw, 0);
143        assert!(
144            result
145                .output
146                .contains("clippy::needless_pass_by_value (2 warnings):")
147        );
148        assert!(result.output.contains("src/foo.rs:12"));
149        assert!(result.output.contains("src/bar.rs:45"));
150        assert!(
151            result
152                .output
153                .contains("clippy::unused_imports (1 warning):")
154        );
155        assert!(result.output.contains("3 warnings total (2 rules)"));
156        assert_eq!(result.confidence, FilterConfidence::Full);
157    }
158
159    #[test]
160    fn filter_error_preserves_full() {
161        let f = make_filter();
162        let raw = "error[E0308]: mismatched types\n  --> src/main.rs:10:5\nfull details here";
163        let result = f.filter("cargo clippy", raw, 1);
164        assert_eq!(result.output, raw);
165        assert_eq!(result.confidence, FilterConfidence::Fallback);
166    }
167
168    #[test]
169    fn filter_no_warnings_strips_noise() {
170        let f = make_filter();
171        let raw = "Checking my-crate v0.1.0\n    Finished dev [unoptimized] target(s)";
172        let result = f.filter("cargo clippy", raw, 0);
173        assert!(result.output.is_empty());
174        assert_eq!(result.confidence, FilterConfidence::Partial);
175    }
176
177    #[test]
178    fn clippy_grouped_warnings_snapshot() {
179        let f = make_filter();
180        let raw = "\
181warning: needless pass by value
182  --> src/foo.rs:12:5
183   |
184   = help: use a reference instead
185   = note: `#[warn(clippy::needless_pass_by_value)]` on by default
186
187warning: needless pass by value
188  --> src/bar.rs:45:10
189   |
190   = help: use a reference instead
191   = note: `#[warn(clippy::needless_pass_by_value)]` on by default
192
193warning: unused import
194  --> src/main.rs:5:1
195   |
196   = note: `#[warn(clippy::unused_imports)]` on by default
197
198warning: `my-crate` (lib) generated 3 warnings
199";
200        let result = f.filter("cargo clippy", raw, 0);
201        insta::assert_snapshot!(result.output);
202    }
203}