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
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
44/// Whether a `// slopguard-disable-next-line` directive suppresses `rule_id`
45/// on 1-based `line` of `source`. A directive with no rule id suppresses every
46/// rule; one naming a rule suppresses only that rule. Mirrors the filtering in
47/// [`filter_disabled`] for callers that hold a position rather than a finding
48/// (the `--fix` pass).
49pub 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
55/// Filter out findings that are suppressed by `// slopguard-disable-next-line`
56/// comments in the source code.
57pub 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
73/// Report the disable directives in `source` that suppressed nothing, given the
74/// file's raw (pre-suppression) findings.
75///
76/// A targeted directive (`// slopguard-disable-next-line rule-x`) is unused when
77/// no raw finding for `rule-x` lands on the following line; a blanket directive
78/// (no rule id) is unused when no raw finding at all lands there. Consumption is
79/// judged against the raw findings, so for an AI rule it reflects the AST
80/// pre-filter match, not the LLM verdict, and stays independent of the provider.
81///
82/// Each unused directive becomes a `warning`/`slop` [`Finding`] with rule id
83/// [`UNUSED_DISABLE_RULE_ID`], anchored on the comment line.
84pub 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        // Directive targets a rule, but no raw finding for that rule lands on the
254        // next line: the directive suppresses nothing.
255        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        // A raw finding for the targeted rule lands on the next line: used.
268        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        // Blanket directive with no raw finding on the next line.
277        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        // The directive targets no-expect-in-prod, but the finding on the next
298        // line is no-unwrap-in-prod: the directive suppressed nothing.
299        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}