1use std::path::Path;
2
3use crate::finding::Finding;
4use crate::rule::{Category, RuleId, Severity};
5
6const DISABLE_MARKER: &str = "slopguard-disable-next-line";
7
8pub const UNUSED_DISABLE_RULE_ID: &str = "unused-disable";
11
12struct DisableDirective {
13 comment_line: usize,
15 target_line: usize,
17 rule_id: Option<RuleId>,
18 comment_text: String,
21}
22
23fn parse_directives(source: &str) -> Vec<DisableDirective> {
24 source
25 .lines()
26 .enumerate()
27 .filter_map(|(idx, line)| {
28 let rest = line
29 .trim()
30 .strip_prefix("//")?
31 .trim()
32 .strip_prefix(DISABLE_MARKER)?;
33 let rest = rest.trim();
34 Some(DisableDirective {
35 comment_line: idx + 1,
36 target_line: idx + 2,
37 rule_id: (!rest.is_empty()).then(|| RuleId::from(rest)),
38 comment_text: line.trim().to_string(),
39 })
40 })
41 .collect()
42}
43
44pub fn is_disabled(source: &str, line: usize, rule_id: &RuleId) -> bool {
50 parse_directives(source)
51 .iter()
52 .any(|d| d.target_line == line && d.rule_id.as_ref().is_none_or(|id| id == rule_id))
53}
54
55pub fn filter_disabled(findings: Vec<Finding>, source: &str) -> Vec<Finding> {
58 let directives = parse_directives(source);
59 if directives.is_empty() {
60 return findings;
61 }
62 findings
63 .into_iter()
64 .filter(|finding| {
65 !directives.iter().any(|d| {
66 d.target_line == finding.line
67 && d.rule_id.as_ref().is_none_or(|id| *id == finding.rule_id)
68 })
69 })
70 .collect()
71}
72
73pub fn unused_disable_findings(file: &Path, source: &str, raw: &[Finding]) -> Vec<Finding> {
85 parse_directives(source)
86 .into_iter()
87 .filter(|d| {
88 !raw.iter().any(|f| {
89 f.line == d.target_line && d.rule_id.as_ref().is_none_or(|id| *id == f.rule_id)
90 })
91 })
92 .map(|d| {
93 let message = match &d.rule_id {
94 Some(id) => format!(
95 "unused slopguard-disable-next-line for '{id}': it suppresses no finding"
96 ),
97 None => "unused slopguard-disable-next-line: it suppresses no finding".to_string(),
98 };
99 Finding {
100 rule_id: RuleId::from(UNUSED_DISABLE_RULE_ID),
101 severity: Severity::Warning,
102 category: Category::Slop,
103 message,
104 note: None,
105 fix: None,
106 file: file.to_path_buf(),
107 line: d.comment_line,
108 column: 1,
109 end_line: d.comment_line,
110 end_column: d.comment_text.chars().count() + 1,
111 matched_text: d.comment_text,
112 confidence: None,
113 escalated: false,
114 }
115 })
116 .collect()
117}
118
119#[cfg(test)]
120mod tests {
121 use std::path::{Path, PathBuf};
122
123 use crate::finding::Finding;
124 use crate::rule::{Category, RuleId, Severity};
125
126 use super::{filter_disabled, unused_disable_findings, UNUSED_DISABLE_RULE_ID};
127
128 fn make_finding(rule_id: &str, line: usize) -> Finding {
129 Finding {
130 rule_id: RuleId::from(rule_id),
131 severity: Severity::Error,
132 category: Category::Correctness,
133 message: "test".to_string(),
134 note: None,
135 fix: None,
136 file: PathBuf::from("test.rs"),
137 line,
138 column: 1,
139 end_line: line,
140 end_column: 10,
141 matched_text: "test".to_string(),
142 confidence: None,
143 escalated: false,
144 }
145 }
146
147 #[test]
148 fn disable_without_rule_id_suppresses_all_findings() {
149 let source = "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
150 let findings = vec![
151 make_finding("no-unwrap-in-prod", 3),
152 make_finding("no-expect-in-prod", 3),
153 ];
154 let result = filter_disabled(findings, source);
155 assert!(
156 result.is_empty(),
157 "all findings on the disabled line should be suppressed"
158 );
159 }
160
161 #[test]
162 fn disable_with_rule_id_suppresses_only_that_rule() {
163 let source = "fn main() {\n // slopguard-disable-next-line no-unwrap-in-prod\n foo().unwrap();\n}\n";
164 let findings = vec![
165 make_finding("no-unwrap-in-prod", 3),
166 make_finding("other-rule", 3),
167 ];
168 let result = filter_disabled(findings, source);
169 assert_eq!(result.len(), 1);
170 assert_eq!(result[0].rule_id, RuleId::from("other-rule"));
171 }
172
173 #[test]
174 fn disable_with_unknown_rule_id_suppresses_nothing() {
175 let source = "fn main() {\n // slopguard-disable-next-line nonexistent-rule\n foo().unwrap();\n}\n";
176 let findings = vec![make_finding("no-unwrap-in-prod", 3)];
177 let result = filter_disabled(findings, source);
178 assert_eq!(result.len(), 1);
179 assert_eq!(result[0].rule_id, RuleId::from("no-unwrap-in-prod"));
180 }
181
182 #[test]
183 fn disable_on_wrong_line_suppresses_nothing() {
184 let source = "// slopguard-disable-next-line\nfn main() {\n foo().unwrap();\n}\n";
185 let findings = vec![make_finding("no-unwrap-in-prod", 3)];
186 let result = filter_disabled(findings, source);
187 assert_eq!(result.len(), 1);
188 }
189
190 #[test]
191 fn disable_with_spacing_variants() {
192 let source_no_space =
193 "fn main() {\n //slopguard-disable-next-line\n foo().unwrap();\n}\n";
194 let findings1 = vec![make_finding("no-unwrap-in-prod", 3)];
195 assert!(filter_disabled(findings1, source_no_space).is_empty());
196
197 let source_extra_spaces =
198 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
199 let findings2 = vec![make_finding("no-unwrap-in-prod", 3)];
200 assert!(filter_disabled(findings2, source_extra_spaces).is_empty());
201
202 let source_leading_space =
203 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
204 let findings3 = vec![make_finding("no-unwrap-in-prod", 3)];
205 assert!(filter_disabled(findings3, source_leading_space).is_empty());
206 }
207
208 #[test]
209 fn disable_without_rule_id_multiple_findings_same_line() {
210 let source = "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
211 let findings = vec![
212 make_finding("rule-a", 3),
213 make_finding("rule-b", 3),
214 make_finding("rule-c", 3),
215 ];
216 let result = filter_disabled(findings, source);
217 assert!(result.is_empty());
218 }
219
220 #[test]
221 fn disable_with_rule_id_multiple_findings_same_line() {
222 let source =
223 "fn main() {\n // slopguard-disable-next-line rule-b\n foo().unwrap();\n}\n";
224 let findings = vec![
225 make_finding("rule-a", 3),
226 make_finding("rule-b", 3),
227 make_finding("rule-c", 3),
228 ];
229 let result = filter_disabled(findings, source);
230 assert_eq!(result.len(), 2);
231 assert_eq!(result[0].rule_id, RuleId::from("rule-a"));
232 assert_eq!(result[1].rule_id, RuleId::from("rule-c"));
233 }
234
235 #[test]
236 fn no_disable_comments_returns_all_findings() {
237 let source = "fn main() {\n foo().unwrap();\n}\n";
238 let findings = vec![make_finding("no-unwrap-in-prod", 2)];
239 let result = filter_disabled(findings, source);
240 assert_eq!(result.len(), 1);
241 }
242
243 #[test]
244 fn disable_on_last_line_no_panic() {
245 let source = "fn main() {}\n// slopguard-disable-next-line";
246 let findings: Vec<Finding> = vec![];
247 let result = filter_disabled(findings, source);
248 assert!(result.is_empty());
249 }
250
251 #[test]
252 fn unused_targeted_directive_is_reported() {
253 let source = "fn main() {\n // slopguard-disable-next-line no-unwrap-in-prod\n let x = 1;\n}\n";
256 let unused = unused_disable_findings(Path::new("test.rs"), source, &[]);
257 assert_eq!(unused.len(), 1);
258 assert_eq!(unused[0].rule_id, RuleId::from(UNUSED_DISABLE_RULE_ID));
259 assert_eq!(unused[0].line, 2, "anchored on the comment line");
260 assert_eq!(unused[0].severity, Severity::Warning);
261 assert_eq!(unused[0].category, Category::Slop);
262 assert!(unused[0].message.contains("no-unwrap-in-prod"));
263 }
264
265 #[test]
266 fn used_targeted_directive_is_not_reported() {
267 let source = "fn main() {\n // slopguard-disable-next-line no-unwrap-in-prod\n foo().unwrap();\n}\n";
269 let raw = vec![make_finding("no-unwrap-in-prod", 3)];
270 let unused = unused_disable_findings(Path::new("test.rs"), source, &raw);
271 assert!(unused.is_empty());
272 }
273
274 #[test]
275 fn unused_global_directive_is_reported() {
276 let source = "fn main() {\n // slopguard-disable-next-line\n let x = 1;\n}\n";
278 let unused = unused_disable_findings(Path::new("test.rs"), source, &[]);
279 assert_eq!(unused.len(), 1);
280 assert_eq!(unused[0].line, 2);
281 assert!(
282 !unused[0].message.contains("for '"),
283 "blanket directive message names no rule"
284 );
285 }
286
287 #[test]
288 fn used_global_directive_is_not_reported() {
289 let source = "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
290 let raw = vec![make_finding("no-unwrap-in-prod", 3)];
291 let unused = unused_disable_findings(Path::new("test.rs"), source, &raw);
292 assert!(unused.is_empty());
293 }
294
295 #[test]
296 fn targeted_directive_for_other_rule_is_unused() {
297 let source = "fn main() {\n // slopguard-disable-next-line no-expect-in-prod\n foo().unwrap();\n}\n";
300 let raw = vec![make_finding("no-unwrap-in-prod", 3)];
301 let unused = unused_disable_findings(Path::new("test.rs"), source, &raw);
302 assert_eq!(unused.len(), 1);
303 assert!(unused[0].message.contains("no-expect-in-prod"));
304 }
305
306 #[test]
307 fn no_directives_reports_nothing() {
308 let source = "fn main() {\n foo().unwrap();\n}\n";
309 let raw = vec![make_finding("no-unwrap-in-prod", 2)];
310 assert!(unused_disable_findings(Path::new("test.rs"), source, &raw).is_empty());
311 }
312}