Skip to main content

wowlab_tidy/infra/
ignore.rs

1// #t(file: rust_duplicate_strings) The linter harness intentionally repeats source fixtures across independent rule scenarios.
2// #t(file: rust_default_hasher) trusted rule-name keys in cold dev-CLI maps; fast-hasher dependency not warranted
3
4//! Parsing and application of `#t(...)` suppression directives.
5
6use std::collections::HashMap;
7
8#[cfg(test)]
9use googletest::prelude::*;
10use winnow::token::any;
11
12use super::{
13    parse::{self, DirectiveResult, Scope},
14    scanner,
15};
16use crate::{Violation, violation};
17
18/// One parsed `#t(...)` directive with its source location, scope, targets, and reason.
19#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
20pub(crate) struct SuppressionEntry {
21    pub rel: String,
22    pub line: usize,
23    end_line: Option<usize>,
24    pub scope: Scope,
25    pub rules: Vec<String>,
26    pub values: std::collections::BTreeMap<String, usize>,
27    pub wildcard: bool,
28    pub reason: String,
29}
30
31impl SuppressionEntry {
32    /// Return whether this directive's scope includes a source line.
33    pub(crate) fn covers_line(&self, line: usize) -> bool {
34        match self.scope {
35            Scope::File => true,
36            Scope::NextLine | Scope::Block | Scope::Fn => self
37                .end_line
38                .is_some_and(|end| line > self.line && line <= end),
39        }
40    }
41
42    /// Return whether this directive targets the tagged violation.
43    pub(crate) fn suppresses(&self, violation: &Violation) -> bool {
44        let Some(rule) = violation.rule else {
45            return false;
46        };
47        let targets_rule = self.wildcard || self.rules.iter().any(|target| target == rule);
48
49        if !targets_rule {
50            return false;
51        }
52
53        self.covers_line(violation.line)
54    }
55}
56
57/// Map from 1-based line number to the list of rule names suppressed on that line.
58pub(crate) type LineSuppressMap = HashMap<usize, Vec<String>>;
59
60fn brace_depth_change(line: &str) -> (i32, bool) {
61    let mut delta: i32 = 0;
62    let mut found_open = false;
63    let mut input = line;
64
65    while let Some(ch) = parse::try_parse(&mut input, any) {
66        match ch {
67            '/' if parse::matches(input, '/') => return (delta, found_open),
68            '"' => scanner::skip_string(&mut input),
69            'r' if parse::matches(input, '#') || parse::matches(input, '"') => {
70                scanner::try_skip_raw_string(&mut input);
71            }
72            '\'' => scanner::skip_char_literal(&mut input),
73            '{' => {
74                delta += 1;
75                found_open = true;
76            }
77            '}' => {
78                delta -= 1;
79            }
80            _ => {}
81        }
82    }
83
84    (delta, found_open)
85}
86
87/// Pre-computed suppression state for a single file: per-line, per-file, and the raw entries.
88#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
89pub(crate) struct Suppressions {
90    pub lines: LineSuppressMap,
91    pub file_rules: Vec<String>,
92    pub file_all: bool,
93    pub entries: Vec<SuppressionEntry>,
94}
95
96const WILDCARD: &str = "*";
97const NEXT_LINE_OFFSET: usize = 2;
98
99/// Pre-compute the suppression map, pushing violations for malformed or unknown directives.
100// #t(fn: rust_cyclomatic_complexity) suppression parser inherently branches on directive variants
101pub(crate) fn suppressed_lines(
102    rel: &str,
103    lines: &[&str],
104    errors: &mut Vec<Violation>,
105    registered_rules: Option<&std::collections::HashSet<&str>>,
106) -> Suppressions {
107    let mut map: LineSuppressMap = HashMap::new();
108    let mut file_rules: Vec<String> = Vec::new();
109    let mut file_all = false;
110    let mut entries: Vec<SuppressionEntry> = Vec::new();
111
112    for (i, line) in lines.iter().enumerate() {
113        match parse::directive(line) {
114            Some(DirectiveResult::Valid(d)) => {
115                if let Some(known) = registered_rules {
116                    for name in &d.rules {
117                        if !known.contains(name.as_str()) {
118                            // #t(block: rust_alloc_in_loop) error messages are rare
119                            let msg = format!(
120                                "unknown rule `{name}` in #t directive — \
121                                 check for typos (use `tidy --list` to see all rules)"
122                            );
123
124                            errors.push(violation(rel, i + 1, msg));
125                        }
126                    }
127                }
128
129                let scope = d.scope;
130                let wildcard = d.wildcard;
131                let rule_names: Vec<String> = if wildcard {
132                    vec![WILDCARD.to_string()]
133                } else {
134                    d.rules
135                };
136
137                let directive_line = i + 1;
138                let end_line = match scope {
139                    Scope::File => {
140                        if wildcard {
141                            file_all = true;
142                        } else {
143                            file_rules.extend(
144                                rule_names
145                                    .iter()
146                                    .filter(|rule| !d.values.contains_key(rule.as_str()))
147                                    .cloned(),
148                            );
149                        }
150
151                        None
152                    }
153                    Scope::NextLine => {
154                        map.entry(i + NEXT_LINE_OFFSET)
155                            .or_default()
156                            // #t(rust_clone_in_loop) targets remain owned by both the line index and occurrence record
157                            .extend(rule_names.clone());
158
159                        Some(i + NEXT_LINE_OFFSET)
160                    }
161                    Scope::Block => {
162                        let mut end_line = directive_line;
163
164                        for (j, bline) in lines.iter().enumerate().skip(i + 1) {
165                            let t = bline.trim();
166
167                            if t.is_empty() || matches!(t, "}" | "};" | ")" | ");") {
168                                break;
169                            }
170
171                            end_line = j + 1;
172                            // #t(rust_clone_in_loop) need owned copy of rule names for each suppressed line
173                            map.entry(j + 1).or_default().extend(rule_names.clone());
174                        }
175
176                        Some(end_line)
177                    }
178                    Scope::Fn => {
179                        let mut depth: i32 = 0;
180                        let mut found_open = false;
181                        let mut end_line = directive_line;
182
183                        for (j, fline) in lines.iter().enumerate().skip(i + 1) {
184                            let (delta, has_open) = brace_depth_change(fline);
185
186                            depth += delta;
187                            found_open |= has_open;
188                            end_line = j + 1;
189                            // #t(rust_clone_in_loop) need owned copy of rule names for each suppressed line
190                            map.entry(j + 1).or_default().extend(rule_names.clone());
191
192                            if found_open && depth <= 0 {
193                                break;
194                            }
195                        }
196
197                        Some(end_line)
198                    }
199                };
200
201                // #t(block: rust_alloc_in_loop) entry needs owned copies; map consumes rule names by scope
202                entries.push(SuppressionEntry {
203                    rel: rel.to_string(),
204                    line: directive_line,
205                    end_line,
206                    scope,
207                    rules: rule_names,
208                    values: d.values,
209                    wildcard,
210                    reason: d.reason,
211                });
212            }
213            Some(DirectiveResult::MissingReason(scope)) => {
214                let name = scope.prefix();
215
216                errors.push(violation(
217                    rel,
218                    i + 1,
219                    format!("{name} requires a reason after the closing `)`"),
220                ));
221            }
222            Some(DirectiveResult::Malformed(msg)) => {
223                errors.push(violation(rel, i + 1, format!("#t directive error: {msg}")));
224            }
225            None => {}
226        }
227    }
228
229    Suppressions {
230        lines: map,
231        file_rules,
232        file_all,
233        entries,
234    }
235}
236
237/// Return `true` if `rule` is suppressed for the entire file (via `#t(file: ...)` or `#t(file: *)`).
238pub(crate) fn is_file_suppressed(suppressed: &Suppressions, rule: &str) -> bool {
239    suppressed.file_all || suppressed.file_rules.iter().any(|r| r == rule)
240}
241
242/// Filter out violations that are suppressed for their specific rule on that line.
243pub(crate) fn filter(suppressed: &Suppressions, violations: Vec<Violation>) -> Vec<Violation> {
244    if suppressed.lines.is_empty() {
245        return violations;
246    }
247
248    violations
249        .into_iter()
250        .filter(|v| {
251            let Some(rule) = v.rule else { return true };
252
253            suppressed
254                .lines
255                .get(&v.line)
256                .is_none_or(|rules| !rules.iter().any(|r| r == rule || r == WILDCARD))
257        })
258        .collect()
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn make_errors() -> Vec<Violation> {
266        Vec::new()
267    }
268
269    #[gtest]
270    fn empty_directive_is_error() -> Result<()> {
271        let lines = vec!["// #t()", "let x = 1;"];
272        let mut errors = make_errors();
273        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
274
275        verify_true!(sup.lines.is_empty())?;
276        verify_eq!(errors.len(), 1)?;
277
278        verify_true!(errors[0].message.contains("empty"))
279    }
280
281    #[gtest]
282    fn missing_reason_is_error() -> Result<()> {
283        let lines = vec!["// #t(rust_panic)", "let x = 1;"];
284        let mut errors = make_errors();
285        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
286
287        verify_true!(sup.lines.is_empty())?;
288        verify_eq!(errors.len(), 1)?;
289        verify_true!(errors[0].message.contains("requires a reason"))?;
290
291        Ok(())
292    }
293
294    #[gtest]
295    fn next_line_works() -> Result<()> {
296        let lines = vec![
297            "// #t(rust_panic) startup invariant",
298            "panic!(\"oh no\");",
299            "let y = 2;",
300        ];
301        let mut errors = make_errors();
302        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
303
304        verify_eq!(sup.lines.get(&2).or_fail()?, &["rust_panic"])?;
305        verify_false!(sup.lines.contains_key(&3))?;
306        verify_true!(errors.is_empty())?;
307
308        Ok(())
309    }
310
311    #[gtest]
312    fn multi_rule_works() -> Result<()> {
313        let lines = vec![
314            "// #t(rust_alloc_in_loop, rust_clone_in_loop) error path",
315            "let msg = format!(\"err: {}\", e);",
316        ];
317        let mut errors = make_errors();
318        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
319        let rules = sup.lines.get(&2).or_fail()?;
320
321        verify_eq!(rules, &["rust_alloc_in_loop", "rust_clone_in_loop"])?;
322        verify_true!(errors.is_empty())?;
323
324        Ok(())
325    }
326
327    #[gtest]
328    fn file_scope_works() -> Result<()> {
329        let lines = vec![
330            "// #t(file: rust_commented_code) generated file",
331            "let x = 1;",
332        ];
333        let mut errors = make_errors();
334        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
335
336        verify_true!(errors.is_empty())?;
337        verify_true!(is_file_suppressed(&sup, "rust_commented_code"))?;
338        verify_false!(is_file_suppressed(&sup, "rust_style"))?;
339
340        Ok(())
341    }
342
343    #[gtest]
344    fn block_scope_works() -> Result<()> {
345        let lines = vec![
346            "// #t(block: rust_wildcard_imports) barrel re-exports",
347            "use foo::*;",
348            "use bar::*;",
349            "",
350            "use baz::*;",
351        ];
352        let mut errors = make_errors();
353        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
354
355        verify_eq!(sup.lines.get(&2).or_fail()?, &["rust_wildcard_imports"])?;
356        verify_eq!(sup.lines.get(&3).or_fail()?, &["rust_wildcard_imports"])?;
357        verify_false!(sup.lines.contains_key(&5))?;
358        verify_true!(errors.is_empty())?;
359
360        Ok(())
361    }
362
363    #[gtest]
364    fn fn_scope_works() -> Result<()> {
365        let lines = vec![
366            "// #t(fn: rust_alloc_in_loop) error reporting function",
367            "fn validate() {",
368            "    let x = format!(\"a\");",
369            "    let y = format!(\"b\");",
370            "}",
371            "fn other() {}",
372        ];
373        let mut errors = make_errors();
374        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
375
376        verify_true!(sup.lines.contains_key(&2))?;
377        verify_true!(sup.lines.contains_key(&3))?;
378        verify_true!(sup.lines.contains_key(&4))?;
379        verify_true!(sup.lines.contains_key(&5))?;
380        verify_false!(sup.lines.contains_key(&6))?;
381        verify_true!(errors.is_empty())?;
382
383        Ok(())
384    }
385
386    #[gtest]
387    // #t(fn: rust_duplicate_strings) This linter scenario intentionally repeats source fixtures used to verify independent paths.
388    fn fn_scope_ignores_braces_in_strings() -> Result<()> {
389        let lines = vec![
390            // #t(rust_duplicate_strings) This linter fixture intentionally repeats source text across independent rule-harness scenarios.
391            "// #t(fn: rust_alloc_in_loop) error reporting function",
392            "fn validate() {",
393            "    let s = \"{ not a real brace }\";",
394            "    let x = format!(\"a\");",
395            "}",
396            "fn other() {}",
397        ];
398        let mut errors = make_errors();
399        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
400
401        verify_true!(sup.lines.contains_key(&2))?;
402        verify_true!(sup.lines.contains_key(&3))?;
403        verify_true!(sup.lines.contains_key(&4))?;
404        verify_true!(sup.lines.contains_key(&5))?;
405        verify_false!(sup.lines.contains_key(&6))?;
406        verify_true!(errors.is_empty())?;
407
408        Ok(())
409    }
410
411    #[gtest]
412    // #t(fn: rust_duplicate_strings) This linter scenario intentionally repeats source fixtures used to verify independent paths.
413    fn fn_scope_ignores_braces_in_comments() -> Result<()> {
414        let lines = vec![
415            // #t(rust_duplicate_strings) This linter fixture intentionally repeats source text across independent rule-harness scenarios.
416            "// #t(fn: rust_alloc_in_loop) error reporting function",
417            "fn validate() {",
418            "    // closing } here doesn't count",
419            "    let x = format!(\"a\");",
420            "}",
421            "fn other() {}",
422        ];
423        let mut errors = make_errors();
424        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
425
426        verify_true!(sup.lines.contains_key(&2))?;
427        verify_true!(sup.lines.contains_key(&3))?;
428        verify_true!(sup.lines.contains_key(&4))?;
429        verify_true!(sup.lines.contains_key(&5))?;
430        verify_false!(sup.lines.contains_key(&6))?;
431        verify_true!(errors.is_empty())?;
432
433        Ok(())
434    }
435
436    #[gtest]
437    // #t(fn: rust_duplicate_strings) This linter scenario intentionally repeats source fixtures used to verify independent paths.
438    fn fn_scope_ignores_braces_in_char_literals() -> Result<()> {
439        let lines = vec![
440            // #t(rust_duplicate_strings) This linter fixture intentionally repeats source text across independent rule-harness scenarios.
441            "// #t(fn: rust_alloc_in_loop) error reporting function",
442            "fn validate() {",
443            "    let c = '{';",
444            "    let d = '}';",
445            "    let x = format!(\"a\");",
446            "}",
447            "fn other() {}",
448        ];
449        let mut errors = make_errors();
450        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
451
452        verify_true!(sup.lines.contains_key(&2))?;
453        verify_true!(sup.lines.contains_key(&3))?;
454        verify_true!(sup.lines.contains_key(&4))?;
455        verify_true!(sup.lines.contains_key(&5))?;
456        verify_true!(sup.lines.contains_key(&6))?;
457        verify_false!(sup.lines.contains_key(&7))?;
458        verify_true!(errors.is_empty())?;
459
460        Ok(())
461    }
462}
463
464#[cfg(test)]
465mod filter_tests {
466    use super::*;
467
468    fn make_errors() -> Vec<Violation> {
469        Vec::new()
470    }
471
472    fn empty_suppressions() -> Suppressions {
473        Suppressions {
474            lines: HashMap::new(),
475            file_rules: Vec::new(),
476            file_all: false,
477            entries: Vec::new(),
478        }
479    }
480
481    #[gtest]
482    fn wildcard_works() -> Result<()> {
483        let lines = vec!["// #t(*) generated code", "let x = 1;"];
484        let mut errors = make_errors();
485        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
486
487        verify_eq!(sup.lines.get(&2).or_fail()?, &["*"])?;
488        verify_true!(errors.is_empty())?;
489
490        Ok(())
491    }
492
493    #[gtest]
494    fn wildcard_file_works() -> Result<()> {
495        let lines = vec!["// #t(file: *) fully generated"];
496        let mut errors = make_errors();
497        let sup = suppressed_lines("t.rs", &lines, &mut errors, None);
498
499        verify_true!(sup.file_all)?;
500        verify_true!(is_file_suppressed(&sup, "anything"))?;
501        verify_true!(errors.is_empty())?;
502
503        Ok(())
504    }
505
506    #[gtest]
507    fn wildcard_suppresses_any_rule() -> Result<()> {
508        let mut sup = empty_suppressions();
509
510        sup.lines.insert(2, vec!["*".to_string()]);
511
512        let violations = vec![
513            violation("t.rs", 2, "a").with_rule("panic"),
514            violation("t.rs", 2, "b").with_rule("unwrap_in_lib"),
515        ];
516        let filtered = filter(&sup, violations);
517
518        verify_true!(filtered.is_empty())?;
519
520        Ok(())
521    }
522
523    #[gtest]
524    fn suppression_only_applies_to_named_rule() -> Result<()> {
525        let mut sup = empty_suppressions();
526
527        sup.lines.insert(2, vec!["panic".to_string()]);
528
529        let violations = vec![
530            violation("t.rs", 2, "a").with_rule("panic"),
531            violation("t.rs", 2, "b").with_rule("unwrap_in_lib"),
532        ];
533        let filtered = filter(&sup, violations);
534
535        verify_eq!(filtered.len(), 1)?;
536        verify_eq!(filtered[0].rule, Some("unwrap_in_lib"))?;
537
538        Ok(())
539    }
540
541    #[gtest]
542    fn filter_removes_matching_rule_only() -> Result<()> {
543        let mut sup = empty_suppressions();
544
545        sup.lines.insert(2, vec!["panic".to_string()]);
546        sup.lines.insert(3, vec!["panic".to_string()]);
547
548        let violations = vec![
549            violation("t.rs", 1, "a").with_rule("panic"),
550            violation("t.rs", 2, "b").with_rule("panic"),
551            violation("t.rs", 3, "c").with_rule("style"),
552            violation("t.rs", 4, "d").with_rule("panic"),
553        ];
554        let filtered = filter(&sup, violations);
555
556        verify_eq!(filtered.len(), 3)?;
557        verify_eq!(filtered[0].line, 1)?;
558        verify_eq!(filtered[1].line, 3)?;
559        verify_eq!(filtered[2].line, 4)?;
560
561        Ok(())
562    }
563}