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 rule_id_text(rest: &str) -> &str {
28 rest.split_once("--").map_or(rest, |(id, _)| id).trim()
29}
30
31fn parse_directives(source: &str) -> Vec<DisableDirective> {
32 source
33 .lines()
34 .enumerate()
35 .filter_map(|(idx, line)| {
36 let rest = line
37 .trim()
38 .strip_prefix("//")?
39 .trim()
40 .strip_prefix(DISABLE_MARKER)?;
41 let id = rule_id_text(rest.trim());
42 Some(DisableDirective {
43 comment_line: idx + 1,
44 target_line: idx + 2,
45 rule_id: (!id.is_empty()).then(|| RuleId::from(id)),
46 comment_text: line.trim().to_string(),
47 })
48 })
49 .collect()
50}
51
52pub fn is_disabled(source: &str, line: usize, rule_id: &RuleId) -> bool {
58 parse_directives(source)
59 .iter()
60 .any(|d| d.target_line == line && d.rule_id.as_ref().is_none_or(|id| id == rule_id))
61}
62
63pub fn filter_disabled(findings: Vec<Finding>, source: &str) -> Vec<Finding> {
66 let directives = parse_directives(source);
67 if directives.is_empty() {
68 return findings;
69 }
70 findings
71 .into_iter()
72 .filter(|finding| {
73 !directives.iter().any(|d| {
74 d.target_line == finding.line
75 && d.rule_id.as_ref().is_none_or(|id| *id == finding.rule_id)
76 })
77 })
78 .collect()
79}
80
81pub fn unused_disable_findings(file: &Path, source: &str, raw: &[Finding]) -> Vec<Finding> {
93 parse_directives(source)
94 .into_iter()
95 .filter(|d| {
96 !raw.iter().any(|f| {
97 f.line == d.target_line && d.rule_id.as_ref().is_none_or(|id| *id == f.rule_id)
98 })
99 })
100 .map(|d| {
101 let message = match &d.rule_id {
102 Some(id) => format!(
103 "unused slopguard-disable-next-line for '{id}': it suppresses no finding"
104 ),
105 None => "unused slopguard-disable-next-line: it suppresses no finding".to_string(),
106 };
107 Finding {
108 rule_id: RuleId::from(UNUSED_DISABLE_RULE_ID),
109 severity: Severity::Warning,
110 category: Category::Slop,
111 message,
112 note: None,
113 fix: None,
114 file: file.to_path_buf(),
115 line: d.comment_line,
116 column: 1,
117 end_line: d.comment_line,
118 end_column: d.comment_text.chars().count() + 1,
119 matched_text: d.comment_text,
120 confidence: None,
121 escalated: false,
122 }
123 })
124 .collect()
125}
126
127#[cfg(test)]
128mod tests {
129 use std::path::{Path, PathBuf};
130
131 use crate::finding::Finding;
132 use crate::rule::{Category, RuleId, Severity};
133
134 use super::{filter_disabled, unused_disable_findings, UNUSED_DISABLE_RULE_ID};
135
136 fn make_finding(rule_id: &str, line: usize) -> Finding {
137 Finding {
138 rule_id: RuleId::from(rule_id),
139 severity: Severity::Error,
140 category: Category::Correctness,
141 message: "test".to_string(),
142 note: None,
143 fix: None,
144 file: PathBuf::from("test.rs"),
145 line,
146 column: 1,
147 end_line: line,
148 end_column: 10,
149 matched_text: "test".to_string(),
150 confidence: None,
151 escalated: false,
152 }
153 }
154
155 #[test]
156 fn disable_without_rule_id_suppresses_all_findings() {
157 let source = "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
158 let findings = vec![
159 make_finding("no-unwrap-in-prod", 3),
160 make_finding("no-expect-in-prod", 3),
161 ];
162 let result = filter_disabled(findings, source);
163 assert!(
164 result.is_empty(),
165 "all findings on the disabled line should be suppressed"
166 );
167 }
168
169 #[test]
170 fn disable_with_rule_id_suppresses_only_that_rule() {
171 let source = "fn main() {\n // slopguard-disable-next-line no-unwrap-in-prod\n foo().unwrap();\n}\n";
172 let findings = vec![
173 make_finding("no-unwrap-in-prod", 3),
174 make_finding("other-rule", 3),
175 ];
176 let result = filter_disabled(findings, source);
177 assert_eq!(result.len(), 1);
178 assert_eq!(result[0].rule_id, RuleId::from("other-rule"));
179 }
180
181 #[test]
182 fn disable_with_unknown_rule_id_suppresses_nothing() {
183 let source = "fn main() {\n // slopguard-disable-next-line nonexistent-rule\n foo().unwrap();\n}\n";
184 let findings = vec![make_finding("no-unwrap-in-prod", 3)];
185 let result = filter_disabled(findings, source);
186 assert_eq!(result.len(), 1);
187 assert_eq!(result[0].rule_id, RuleId::from("no-unwrap-in-prod"));
188 }
189
190 #[test]
191 fn disable_on_wrong_line_suppresses_nothing() {
192 let source = "// slopguard-disable-next-line\nfn main() {\n foo().unwrap();\n}\n";
193 let findings = vec![make_finding("no-unwrap-in-prod", 3)];
194 let result = filter_disabled(findings, source);
195 assert_eq!(result.len(), 1);
196 }
197
198 #[test]
199 fn disable_with_spacing_variants() {
200 let source_no_space =
201 "fn main() {\n //slopguard-disable-next-line\n foo().unwrap();\n}\n";
202 let findings1 = vec![make_finding("no-unwrap-in-prod", 3)];
203 assert!(filter_disabled(findings1, source_no_space).is_empty());
204
205 let source_extra_spaces =
206 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
207 let findings2 = vec![make_finding("no-unwrap-in-prod", 3)];
208 assert!(filter_disabled(findings2, source_extra_spaces).is_empty());
209
210 let source_leading_space =
211 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
212 let findings3 = vec![make_finding("no-unwrap-in-prod", 3)];
213 assert!(filter_disabled(findings3, source_leading_space).is_empty());
214 }
215
216 #[test]
217 fn disable_without_rule_id_multiple_findings_same_line() {
218 let source = "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
219 let findings = vec![
220 make_finding("rule-a", 3),
221 make_finding("rule-b", 3),
222 make_finding("rule-c", 3),
223 ];
224 let result = filter_disabled(findings, source);
225 assert!(result.is_empty());
226 }
227
228 #[test]
229 fn disable_with_rule_id_multiple_findings_same_line() {
230 let source =
231 "fn main() {\n // slopguard-disable-next-line rule-b\n foo().unwrap();\n}\n";
232 let findings = vec![
233 make_finding("rule-a", 3),
234 make_finding("rule-b", 3),
235 make_finding("rule-c", 3),
236 ];
237 let result = filter_disabled(findings, source);
238 assert_eq!(result.len(), 2);
239 assert_eq!(result[0].rule_id, RuleId::from("rule-a"));
240 assert_eq!(result[1].rule_id, RuleId::from("rule-c"));
241 }
242
243 #[test]
244 fn no_disable_comments_returns_all_findings() {
245 let source = "fn main() {\n foo().unwrap();\n}\n";
246 let findings = vec![make_finding("no-unwrap-in-prod", 2)];
247 let result = filter_disabled(findings, source);
248 assert_eq!(result.len(), 1);
249 }
250
251 #[test]
252 fn disable_on_last_line_no_panic() {
253 let source = "fn main() {}\n// slopguard-disable-next-line";
254 let findings: Vec<Finding> = vec![];
255 let result = filter_disabled(findings, source);
256 assert!(result.is_empty());
257 }
258
259 #[test]
260 fn unused_targeted_directive_is_reported() {
261 let source = "fn main() {\n // slopguard-disable-next-line no-unwrap-in-prod\n let x = 1;\n}\n";
264 let unused = unused_disable_findings(Path::new("test.rs"), source, &[]);
265 assert_eq!(unused.len(), 1);
266 assert_eq!(unused[0].rule_id, RuleId::from(UNUSED_DISABLE_RULE_ID));
267 assert_eq!(unused[0].line, 2, "anchored on the comment line");
268 assert_eq!(unused[0].severity, Severity::Warning);
269 assert_eq!(unused[0].category, Category::Slop);
270 assert!(unused[0].message.contains("no-unwrap-in-prod"));
271 }
272
273 #[test]
274 fn used_targeted_directive_is_not_reported() {
275 let source = "fn main() {\n // slopguard-disable-next-line no-unwrap-in-prod\n foo().unwrap();\n}\n";
277 let raw = vec![make_finding("no-unwrap-in-prod", 3)];
278 let unused = unused_disable_findings(Path::new("test.rs"), source, &raw);
279 assert!(unused.is_empty());
280 }
281
282 #[test]
283 fn unused_global_directive_is_reported() {
284 let source = "fn main() {\n // slopguard-disable-next-line\n let x = 1;\n}\n";
286 let unused = unused_disable_findings(Path::new("test.rs"), source, &[]);
287 assert_eq!(unused.len(), 1);
288 assert_eq!(unused[0].line, 2);
289 assert!(
290 !unused[0].message.contains("for '"),
291 "blanket directive message names no rule"
292 );
293 }
294
295 #[test]
296 fn used_global_directive_is_not_reported() {
297 let source = "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n}\n";
298 let raw = vec![make_finding("no-unwrap-in-prod", 3)];
299 let unused = unused_disable_findings(Path::new("test.rs"), source, &raw);
300 assert!(unused.is_empty());
301 }
302
303 #[test]
304 fn targeted_directive_for_other_rule_is_unused() {
305 let source = "fn main() {\n // slopguard-disable-next-line no-expect-in-prod\n foo().unwrap();\n}\n";
308 let raw = vec![make_finding("no-unwrap-in-prod", 3)];
309 let unused = unused_disable_findings(Path::new("test.rs"), source, &raw);
310 assert_eq!(unused.len(), 1);
311 assert!(unused[0].message.contains("no-expect-in-prod"));
312 }
313
314 #[test]
315 fn disable_with_reason_after_double_dash_suppresses_rule() {
316 let source = "fn main() {\n // slopguard-disable-next-line no-unwrap-in-prod -- startup invariant\n foo().unwrap();\n}\n";
317 let findings = vec![
318 make_finding("no-unwrap-in-prod", 3),
319 make_finding("no-expect-in-prod", 3),
320 ];
321 let result = filter_disabled(findings, source);
322 assert_eq!(result.len(), 1);
323 assert_eq!(result[0].rule_id, RuleId::from("no-expect-in-prod"));
324 }
325
326 #[test]
327 fn disable_with_unspaced_or_empty_reason_suppresses_rule() {
328 for directive in [
329 "// slopguard-disable-next-line no-unwrap-in-prod --",
330 "// slopguard-disable-next-line no-unwrap-in-prod --reason",
331 "// slopguard-disable-next-line no-unwrap-in-prod--reason",
332 ] {
333 let source = format!("fn main() {{\n {directive}\n foo().unwrap();\n}}\n");
334 let findings = vec![
335 make_finding("no-unwrap-in-prod", 3),
336 make_finding("no-expect-in-prod", 3),
337 ];
338 let result = filter_disabled(findings, &source);
339 assert_eq!(result.len(), 1, "{directive}");
340 assert_eq!(result[0].rule_id, RuleId::from("no-expect-in-prod"));
341 }
342 }
343
344 #[test]
345 fn disable_with_only_reason_is_blanket() {
346 let source = "fn main() {\n // slopguard-disable-next-line -- startup invariant\n foo().unwrap();\n}\n";
347 let findings = vec![
348 make_finding("no-unwrap-in-prod", 3),
349 make_finding("no-expect-in-prod", 3),
350 ];
351 assert!(filter_disabled(findings, source).is_empty());
352 }
353
354 #[test]
355 fn unused_disable_message_omits_the_reason() {
356 let source = "fn main() {\n // slopguard-disable-next-line no-unwrap-in-prod -- startup invariant\n let x = 1;\n}\n";
357 let unused = unused_disable_findings(Path::new("test.rs"), source, &[]);
358 assert_eq!(unused.len(), 1);
359 assert!(unused[0].message.contains("for 'no-unwrap-in-prod'"));
360 assert!(!unused[0].message.contains("startup invariant"));
361 }
362
363 #[test]
364 fn no_directives_reports_nothing() {
365 let source = "fn main() {\n foo().unwrap();\n}\n";
366 let raw = vec![make_finding("no-unwrap-in-prod", 2)];
367 assert!(unused_disable_findings(Path::new("test.rs"), source, &raw).is_empty());
368 }
369}