Skip to main content

diffguard_domain/
suppression.rs

1//! Inline suppression directive parsing.
2//!
3//! This module provides support for inline suppression directives that allow
4//! developers to suppress specific rule matches on a line-by-line basis.
5//!
6//! # Supported Formats
7//!
8//! - `diffguard: ignore <rule_id>` - suppresses the match on the same line
9//! - `diffguard: ignore-next-line <rule_id>` - suppresses matches on the next line
10//! - `diffguard: ignore *` or `diffguard: ignore-all` - suppresses all rules on the line
11//!
12//! Multiple rules can be specified by separating with commas:
13//! - `diffguard: ignore rule1, rule2`
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! let x = y.unwrap(); // diffguard: ignore rust.no_unwrap
19//! // diffguard: ignore-next-line rust.no_dbg
20//! dbg!(value);
21//! ```
22
23use std::collections::HashSet;
24
25/// Represents the type of suppression directive found.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum SuppressionKind {
28    /// Suppress on the same line as the directive.
29    SameLine,
30    /// Suppress on the next line after the directive.
31    NextLine,
32}
33
34/// A parsed suppression directive.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Suppression {
37    /// The kind of suppression (same line or next line).
38    pub kind: SuppressionKind,
39    /// The rule IDs to suppress, or None for wildcard (suppress all).
40    /// When None, all rules are suppressed.
41    pub rule_ids: Option<HashSet<String>>,
42}
43
44impl Suppression {
45    /// Returns true if this suppression applies to the given rule ID.
46    pub fn suppresses(&self, rule_id: &str) -> bool {
47        match &self.rule_ids {
48            None => true, // Wildcard - suppress all
49            Some(ids) => ids.contains(rule_id),
50        }
51    }
52
53    /// Returns true if this is a wildcard suppression (suppresses all rules).
54    pub fn is_wildcard(&self) -> bool {
55        self.rule_ids.is_none()
56    }
57}
58
59/// The suppression directive prefix.
60const DIRECTIVE_PREFIX: &str = "diffguard:";
61
62/// Parse a line for suppression directives.
63///
64/// Returns None if no directive is found, or Some(Suppression) if a valid
65/// directive is present.
66///
67/// This function should be called on the raw line BEFORE preprocessing
68/// (so that comment content is visible).
69pub fn parse_suppression(line: &str) -> Option<Suppression> {
70    let lower = line.to_ascii_lowercase();
71    lower
72        .match_indices(DIRECTIVE_PREFIX)
73        .next()
74        .and_then(|(idx, _)| parse_suppression_at(line, idx))
75}
76
77/// Parse a line for suppression directives, but only if the directive
78/// occurs inside a masked comment span.
79///
80/// `masked_comments` should be the output of the comments-only preprocessor
81/// for the same line and language. The directive is accepted only if the
82/// directive prefix is fully masked (spaces) in `masked_comments`.
83#[allow(clippy::collapsible_if)]
84pub fn parse_suppression_in_comments(line: &str, masked_comments: &str) -> Option<Suppression> {
85    if line.len() != masked_comments.len() {
86        return None;
87    }
88
89    let lower = line.to_ascii_lowercase();
90    let needle = DIRECTIVE_PREFIX.as_bytes();
91    let masked = masked_comments.as_bytes();
92
93    for (idx, _) in lower.match_indices(DIRECTIVE_PREFIX) {
94        let in_comment = masked[idx..idx + needle.len()].iter().all(|b| *b == b' ');
95        if in_comment {
96            if let Some(suppression) = parse_suppression_at(line, idx) {
97                return Some(suppression);
98            }
99        }
100    }
101
102    None
103}
104
105/// Parse a suppression directive at a known prefix offset.
106fn parse_suppression_at(line: &str, prefix_start: usize) -> Option<Suppression> {
107    let after_prefix = line.get(prefix_start + DIRECTIVE_PREFIX.len()..)?;
108    let after_prefix = after_prefix.trim_start();
109
110    // Check for "ignore-next-line" first (longer match)
111    if let Some(rest) = strip_prefix_ci(after_prefix, "ignore-next-line") {
112        let rule_ids = parse_rule_ids(rest);
113        return Some(Suppression {
114            kind: SuppressionKind::NextLine,
115            rule_ids,
116        });
117    }
118
119    // Check for "ignore-all" (explicit wildcard)
120    if strip_prefix_ci(after_prefix, "ignore-all").is_some() {
121        return Some(Suppression {
122            kind: SuppressionKind::SameLine,
123            rule_ids: None,
124        });
125    }
126
127    // Check for "ignore"
128    if let Some(rest) = strip_prefix_ci(after_prefix, "ignore") {
129        let rule_ids = parse_rule_ids(rest);
130        return Some(Suppression {
131            kind: SuppressionKind::SameLine,
132            rule_ids,
133        });
134    }
135
136    None
137}
138
139/// Strip a prefix case-insensitively and return the remainder.
140fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
141    let s_lower = s.to_ascii_lowercase();
142    if s_lower.starts_with(prefix) {
143        Some(&s[prefix.len()..])
144    } else {
145        None
146    }
147}
148
149/// Parse rule IDs from the remainder of a directive.
150///
151/// Returns None for wildcard (*), or Some(HashSet) of rule IDs.
152fn parse_rule_ids(rest: &str) -> Option<HashSet<String>> {
153    // Strip any trailing block comment closer (*/)
154    let rest = rest.trim();
155    let rest = rest.strip_suffix("*/").unwrap_or(rest).trim();
156
157    // Empty means wildcard (suppress all)
158    if rest.is_empty() {
159        return None;
160    }
161
162    // Check for explicit wildcard
163    if rest == "*" {
164        return None;
165    }
166
167    // Parse comma-separated rule IDs
168    let mut ids = HashSet::new();
169    for part in rest.split(',') {
170        let id = part.trim();
171        if !id.is_empty() && id != "*" {
172            ids.insert(id.to_string());
173        } else if id == "*" {
174            // Wildcard in the list
175            return None;
176        }
177    }
178
179    if ids.is_empty() { None } else { Some(ids) }
180}
181
182/// Tracks suppression state for a file being processed.
183///
184/// This struct manages the "ignore-next-line" state that carries over
185/// between lines.
186#[derive(Debug, Clone, Default)]
187pub struct SuppressionTracker {
188    /// Suppressions that apply to the next line.
189    pending_next_line: Vec<Suppression>,
190}
191
192impl SuppressionTracker {
193    /// Create a new suppression tracker.
194    pub fn new() -> Self {
195        Self::default()
196    }
197
198    /// Reset the tracker state (e.g., when switching files).
199    pub fn reset(&mut self) {
200        self.pending_next_line.clear();
201    }
202
203    /// Process a line and return the effective suppressions for this line.
204    ///
205    /// This method:
206    /// 1. Parses any directive in the current line
207    /// 2. Applies any pending "next-line" suppressions from the previous line
208    /// 3. Updates the pending state for the next line
209    ///
210    /// Returns the combined set of suppressions that apply to this line.
211    pub fn process_line(&mut self, line: &str, masked_comments: &str) -> EffectiveSuppressions {
212        // Collect pending suppressions for this line
213        let mut same_line_suppressions: Vec<Suppression> = Vec::new();
214        let mut next_line_suppressions: Vec<Suppression> = Vec::new();
215
216        // Apply pending "next-line" suppressions from previous line
217        same_line_suppressions.append(&mut self.pending_next_line);
218
219        // Parse the current line for directives
220        if let Some(suppression) = parse_suppression_in_comments(line, masked_comments) {
221            match suppression.kind {
222                SuppressionKind::SameLine => {
223                    same_line_suppressions.push(suppression);
224                }
225                SuppressionKind::NextLine => {
226                    next_line_suppressions.push(suppression);
227                }
228            }
229        }
230
231        // Update pending state for the next line
232        self.pending_next_line = next_line_suppressions;
233
234        EffectiveSuppressions::from_suppressions(same_line_suppressions)
235    }
236}
237
238/// The effective suppressions for a single line.
239#[derive(Debug, Clone, Default)]
240pub struct EffectiveSuppressions {
241    /// If true, all rules are suppressed (wildcard).
242    pub suppress_all: bool,
243    /// Set of specific rule IDs that are suppressed.
244    pub suppressed_rules: HashSet<String>,
245}
246
247impl EffectiveSuppressions {
248    /// Create from a list of suppressions.
249    fn from_suppressions(suppressions: Vec<Suppression>) -> Self {
250        let mut result = Self::default();
251
252        for s in suppressions {
253            match s.rule_ids {
254                None => {
255                    result.suppress_all = true;
256                }
257                Some(ids) => {
258                    result.suppressed_rules.extend(ids);
259                }
260            }
261        }
262
263        result
264    }
265
266    /// Returns true if the given rule should be suppressed.
267    pub fn is_suppressed(&self, rule_id: &str) -> bool {
268        self.suppress_all || self.suppressed_rules.contains(rule_id)
269    }
270
271    /// Returns true if no suppressions are active.
272    pub fn is_empty(&self) -> bool {
273        !self.suppress_all && self.suppressed_rules.is_empty()
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::preprocess::{Language, PreprocessOptions, Preprocessor};
281
282    fn masked_comments(line: &str, lang: Language) -> String {
283        let mut p = Preprocessor::with_language(PreprocessOptions::comments_only(), lang);
284        p.sanitize_line(line)
285    }
286
287    // ==================== parse_suppression tests ====================
288
289    #[test]
290    fn parse_same_line_ignore_single_rule() {
291        let line = "let x = y.unwrap(); // diffguard: ignore rust.no_unwrap";
292        let suppression = parse_suppression(line).expect("should parse");
293
294        assert_eq!(suppression.kind, SuppressionKind::SameLine);
295        assert!(!suppression.is_wildcard());
296        assert!(suppression.suppresses("rust.no_unwrap"));
297        assert!(!suppression.suppresses("other.rule"));
298    }
299
300    #[test]
301    fn parse_same_line_ignore_multiple_rules() {
302        let line = "// diffguard: ignore rule1, rule2, rule3";
303        let suppression = parse_suppression(line).expect("should parse");
304
305        assert_eq!(suppression.kind, SuppressionKind::SameLine);
306        assert!(!suppression.is_wildcard());
307        assert!(suppression.suppresses("rule1"));
308        assert!(suppression.suppresses("rule2"));
309        assert!(suppression.suppresses("rule3"));
310        assert!(!suppression.suppresses("rule4"));
311    }
312
313    #[test]
314    fn parse_same_line_ignore_wildcard_star() {
315        let line = "// diffguard: ignore *";
316        let suppression = parse_suppression(line).expect("should parse");
317
318        assert_eq!(suppression.kind, SuppressionKind::SameLine);
319        assert!(suppression.is_wildcard());
320        assert!(suppression.suppresses("any.rule"));
321        assert!(suppression.suppresses("other.rule"));
322    }
323
324    #[test]
325    fn parse_same_line_ignore_all() {
326        let line = "// diffguard: ignore-all";
327        let suppression = parse_suppression(line).expect("should parse");
328
329        assert_eq!(suppression.kind, SuppressionKind::SameLine);
330        assert!(suppression.is_wildcard());
331        assert!(suppression.suppresses("any.rule"));
332    }
333
334    #[test]
335    fn parse_same_line_ignore_empty_means_wildcard() {
336        let line = "// diffguard: ignore";
337        let suppression = parse_suppression(line).expect("should parse");
338
339        assert_eq!(suppression.kind, SuppressionKind::SameLine);
340        assert!(suppression.is_wildcard());
341    }
342
343    #[test]
344    fn parse_next_line_ignore_single_rule() {
345        let line = "// diffguard: ignore-next-line rust.no_dbg";
346        let suppression = parse_suppression(line).expect("should parse");
347
348        assert_eq!(suppression.kind, SuppressionKind::NextLine);
349        assert!(!suppression.is_wildcard());
350        assert!(suppression.suppresses("rust.no_dbg"));
351        assert!(!suppression.suppresses("other.rule"));
352    }
353
354    #[test]
355    fn parse_next_line_ignore_wildcard() {
356        let line = "// diffguard: ignore-next-line *";
357        let suppression = parse_suppression(line).expect("should parse");
358
359        assert_eq!(suppression.kind, SuppressionKind::NextLine);
360        assert!(suppression.is_wildcard());
361    }
362
363    #[test]
364    fn parse_next_line_ignore_empty_means_wildcard() {
365        let line = "// diffguard: ignore-next-line";
366        let suppression = parse_suppression(line).expect("should parse");
367
368        assert_eq!(suppression.kind, SuppressionKind::NextLine);
369        assert!(suppression.is_wildcard());
370    }
371
372    #[test]
373    fn parse_case_insensitive() {
374        let line = "// DIFFGUARD: IGNORE rule.id";
375        let suppression = parse_suppression(line).expect("should parse");
376
377        assert_eq!(suppression.kind, SuppressionKind::SameLine);
378        assert!(suppression.suppresses("rule.id"));
379    }
380
381    #[test]
382    fn parse_mixed_case() {
383        let line = "// DiffGuard: Ignore-Next-Line rule.id";
384        let suppression = parse_suppression(line).expect("should parse");
385
386        assert_eq!(suppression.kind, SuppressionKind::NextLine);
387        assert!(suppression.suppresses("rule.id"));
388    }
389
390    #[test]
391    fn parse_in_hash_comment() {
392        let line = "x = 1  # diffguard: ignore python.no_print";
393        let suppression = parse_suppression(line).expect("should parse");
394
395        assert_eq!(suppression.kind, SuppressionKind::SameLine);
396        assert!(suppression.suppresses("python.no_print"));
397    }
398
399    #[test]
400    fn parse_in_block_comment() {
401        let line = "let x = y.unwrap(); /* diffguard: ignore rust.no_unwrap */";
402        let suppression = parse_suppression(line).expect("should parse");
403
404        assert_eq!(suppression.kind, SuppressionKind::SameLine);
405        assert!(suppression.suppresses("rust.no_unwrap"));
406    }
407
408    #[test]
409    fn parse_no_directive_returns_none() {
410        let line = "let x = y.unwrap();";
411        assert!(parse_suppression(line).is_none());
412    }
413
414    #[test]
415    fn parse_unrelated_comment_returns_none() {
416        let line = "// This is a normal comment";
417        assert!(parse_suppression(line).is_none());
418    }
419
420    #[test]
421    fn parse_partial_directive_returns_none() {
422        let line = "// diffguard";
423        assert!(parse_suppression(line).is_none());
424    }
425
426    #[test]
427    fn parse_in_comments_length_mismatch_returns_none() {
428        let line = "let x = 1; // diffguard: ignore rust.no_unwrap";
429        let masked = "short";
430        assert!(parse_suppression_in_comments(line, masked).is_none());
431    }
432
433    #[test]
434    fn parse_in_string_is_ignored_when_not_in_comment() {
435        let line = "let x = \"diffguard: ignore rust.no_unwrap\";";
436        let masked = masked_comments(line, Language::Rust);
437        assert!(parse_suppression_in_comments(line, &masked).is_none());
438    }
439
440    #[test]
441    fn parse_in_comment_is_detected() {
442        let line = "let x = 1; // diffguard: ignore rust.no_unwrap";
443        let masked = masked_comments(line, Language::Rust);
444        let suppression = parse_suppression_in_comments(line, &masked).expect("should parse");
445        assert!(suppression.suppresses("rust.no_unwrap"));
446    }
447
448    #[test]
449    fn parse_in_python_hash_comment_is_detected() {
450        let line = "x = 1  # diffguard: ignore python.no_print";
451        let masked = masked_comments(line, Language::Python);
452        let suppression = parse_suppression_in_comments(line, &masked).expect("should parse");
453        assert!(suppression.suppresses("python.no_print"));
454    }
455
456    #[test]
457    fn parse_string_then_comment_prefers_comment_directive() {
458        let line =
459            r#"let x = "diffguard: ignore rust.no_unwrap"; // diffguard: ignore rust.no_dbg"#;
460        let masked = masked_comments(line, Language::Rust);
461        let suppression = parse_suppression_in_comments(line, &masked).expect("should parse");
462        assert!(suppression.suppresses("rust.no_dbg"));
463        assert!(!suppression.suppresses("rust.no_unwrap"));
464    }
465
466    #[test]
467    fn parse_directive_with_extra_whitespace() {
468        let line = "//   diffguard:   ignore   rule.id  ";
469        let suppression = parse_suppression(line).expect("should parse");
470
471        assert_eq!(suppression.kind, SuppressionKind::SameLine);
472        assert!(suppression.suppresses("rule.id"));
473    }
474
475    #[test]
476    fn parse_multiple_rules_with_varying_whitespace() {
477        let line = "// diffguard: ignore rule1,rule2,  rule3  ,rule4";
478        let suppression = parse_suppression(line).expect("should parse");
479
480        assert!(suppression.suppresses("rule1"));
481        assert!(suppression.suppresses("rule2"));
482        assert!(suppression.suppresses("rule3"));
483        assert!(suppression.suppresses("rule4"));
484    }
485
486    #[test]
487    fn parse_wildcard_in_list_becomes_wildcard() {
488        let line = "// diffguard: ignore rule1, *, rule2";
489        let suppression = parse_suppression(line).expect("should parse");
490
491        // If there's a wildcard in the list, it becomes a full wildcard
492        assert!(suppression.is_wildcard());
493    }
494
495    #[test]
496    fn parse_suppression_at_unknown_directive_returns_none() {
497        let line = "// diffguard: nope rust.no_unwrap";
498        let prefix_start = line.find(DIRECTIVE_PREFIX).expect("prefix");
499        assert!(parse_suppression_at(line, prefix_start).is_none());
500    }
501
502    #[test]
503    fn parse_suppression_in_comments_skips_invalid_directive() {
504        let line = "// diffguard: nope rust.no_unwrap";
505        let masked = masked_comments(line, Language::Rust);
506        assert!(parse_suppression_in_comments(line, &masked).is_none());
507    }
508
509    #[test]
510    fn parse_rule_ids_empty_returns_none() {
511        assert!(parse_rule_ids("   ").is_none());
512        assert!(parse_rule_ids(" , , ").is_none());
513    }
514
515    // ==================== SuppressionTracker tests ====================
516
517    #[test]
518    fn tracker_same_line_suppression() {
519        let mut tracker = SuppressionTracker::new();
520
521        let line = "let x = y.unwrap(); // diffguard: ignore rust.no_unwrap";
522        let masked = masked_comments(line, Language::Rust);
523        let effective = tracker.process_line(line, &masked);
524
525        assert!(effective.is_suppressed("rust.no_unwrap"));
526        assert!(!effective.is_suppressed("other.rule"));
527    }
528
529    #[test]
530    fn tracker_next_line_suppression() {
531        let mut tracker = SuppressionTracker::new();
532
533        // First line has the directive
534        let line1 = "// diffguard: ignore-next-line rust.no_dbg";
535        let masked1 = masked_comments(line1, Language::Rust);
536        let effective1 = tracker.process_line(line1, &masked1);
537        assert!(!effective1.is_suppressed("rust.no_dbg")); // Not suppressed on directive line
538
539        // Second line should be suppressed
540        let line2 = "dbg!(value);";
541        let masked2 = masked_comments(line2, Language::Rust);
542        let effective2 = tracker.process_line(line2, &masked2);
543        assert!(effective2.is_suppressed("rust.no_dbg"));
544
545        // Third line should not be suppressed
546        let line3 = "dbg!(other);";
547        let masked3 = masked_comments(line3, Language::Rust);
548        let effective3 = tracker.process_line(line3, &masked3);
549        assert!(!effective3.is_suppressed("rust.no_dbg"));
550    }
551
552    #[test]
553    fn tracker_both_same_and_next_line() {
554        let mut tracker = SuppressionTracker::new();
555
556        // Line with both same-line and next-line suppressions
557        let line1 = "// diffguard: ignore-next-line rule1";
558        let masked1 = masked_comments(line1, Language::Rust);
559        let effective1 = tracker.process_line(line1, &masked1);
560        assert!(!effective1.is_suppressed("rule1"));
561
562        let line2 = "x = 1 // diffguard: ignore rule2";
563        let masked2 = masked_comments(line2, Language::Rust);
564        let effective2 = tracker.process_line(line2, &masked2);
565        assert!(effective2.is_suppressed("rule1")); // From previous line
566        assert!(effective2.is_suppressed("rule2")); // From same line
567    }
568
569    #[test]
570    fn tracker_wildcard_suppression() {
571        let mut tracker = SuppressionTracker::new();
572
573        let line = "// diffguard: ignore *";
574        let masked = masked_comments(line, Language::Rust);
575        let effective = tracker.process_line(line, &masked);
576        assert!(effective.is_suppressed("any.rule"));
577        assert!(effective.is_suppressed("other.rule"));
578        assert!(effective.suppress_all);
579    }
580
581    #[test]
582    fn tracker_reset_clears_pending() {
583        let mut tracker = SuppressionTracker::new();
584
585        // Set up a pending next-line suppression
586        let line1 = "// diffguard: ignore-next-line rule1";
587        let masked1 = masked_comments(line1, Language::Rust);
588        tracker.process_line(line1, &masked1);
589
590        // Reset (simulates file change)
591        tracker.reset();
592
593        // Next line should NOT be suppressed
594        let line2 = "some code";
595        let masked2 = masked_comments(line2, Language::Rust);
596        let effective = tracker.process_line(line2, &masked2);
597        assert!(!effective.is_suppressed("rule1"));
598    }
599
600    #[test]
601    fn tracker_multiple_next_line_directives() {
602        let mut tracker = SuppressionTracker::new();
603
604        // Two consecutive next-line directives
605        let line1 = "// diffguard: ignore-next-line rule1";
606        let masked1 = masked_comments(line1, Language::Rust);
607        tracker.process_line(line1, &masked1);
608        let line2 = "// diffguard: ignore-next-line rule2";
609        let masked2 = masked_comments(line2, Language::Rust);
610        let effective1 = tracker.process_line(line2, &masked2);
611
612        // First directive was "consumed" by the second line,
613        // so rule1 applies to line 2
614        assert!(effective1.is_suppressed("rule1"));
615
616        // Second directive applies to line 3
617        let line3 = "actual code";
618        let masked3 = masked_comments(line3, Language::Rust);
619        let effective2 = tracker.process_line(line3, &masked3);
620        assert!(effective2.is_suppressed("rule2"));
621        assert!(!effective2.is_suppressed("rule1"));
622    }
623
624    // ==================== EffectiveSuppressions tests ====================
625
626    #[test]
627    fn effective_suppressions_is_empty() {
628        let effective = EffectiveSuppressions::default();
629        assert!(effective.is_empty());
630        assert!(!effective.is_suppressed("any.rule"));
631    }
632
633    #[test]
634    fn effective_suppressions_specific_rules() {
635        let mut effective = EffectiveSuppressions::default();
636        effective.suppressed_rules.insert("rule1".to_string());
637        effective.suppressed_rules.insert("rule2".to_string());
638
639        assert!(!effective.is_empty());
640        assert!(effective.is_suppressed("rule1"));
641        assert!(effective.is_suppressed("rule2"));
642        assert!(!effective.is_suppressed("rule3"));
643    }
644
645    #[test]
646    fn effective_suppressions_wildcard() {
647        let effective = EffectiveSuppressions {
648            suppress_all: true,
649            ..Default::default()
650        };
651
652        assert!(!effective.is_empty());
653        assert!(effective.is_suppressed("any.rule"));
654        assert!(effective.is_suppressed("other.rule"));
655    }
656}