zeph_tools/filter/
clippy.rs1use 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}