Skip to main content

slopguard_core/
disable.rs

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
8/// Internal rule id for a disable directive that suppressed nothing, reported
9/// only when `--report-unused-disable` is set. Not a YAML rule.
10pub const UNUSED_DISABLE_RULE_ID: &str = "unused-disable";
11
12struct DisableDirective {
13    /// 1-based line of the `// slopguard-disable-next-line` comment itself.
14    comment_line: usize,
15    /// 1-based line the directive suppresses findings on (the line after it).
16    target_line: usize,
17    rule_id: Option<RuleId>,
18    /// The trimmed comment text, used as `matched_text` when reporting the
19    /// directive itself as unused.
20    comment_text: String,
21}
22
23/// The rule id of a directive, without its optional reason. The reason follows
24/// `--` (rule ids are kebab-case and never hold a double hyphen), with or
25/// without surrounding spaces; a directive holding only a reason (`-- why`)
26/// names no rule.
27fn 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
52/// Whether a `// slopguard-disable-next-line` directive suppresses `rule_id`
53/// on 1-based `line` of `source`. A directive with no rule id suppresses every
54/// rule; one naming a rule suppresses only that rule. Mirrors the filtering in
55/// [`filter_disabled`] for callers that hold a position rather than a finding
56/// (the `--fix` pass).
57pub 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
63/// Filter out findings that are suppressed by `// slopguard-disable-next-line`
64/// comments in the source code.
65pub 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
81/// Report the disable directives in `source` that suppressed nothing, given the
82/// file's raw (pre-suppression) findings.
83///
84/// A targeted directive (`// slopguard-disable-next-line rule-x`) is unused when
85/// no raw finding for `rule-x` lands on the following line; a blanket directive
86/// (no rule id) is unused when no raw finding at all lands there. Consumption is
87/// judged against the raw findings, so for an AI rule it reflects the AST
88/// pre-filter match, not the LLM verdict, and stays independent of the provider.
89///
90/// Each unused directive becomes a `warning`/`slop` [`Finding`] with rule id
91/// [`UNUSED_DISABLE_RULE_ID`], anchored on the comment line.
92pub 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        // Directive targets a rule, but no raw finding for that rule lands on the
262        // next line: the directive suppresses nothing.
263        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        // A raw finding for the targeted rule lands on the next line: used.
276        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        // Blanket directive with no raw finding on the next line.
285        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        // The directive targets no-expect-in-prod, but the finding on the next
306        // line is no-unwrap-in-prod: the directive suppressed nothing.
307        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}