sqruff_lib/core/rules/
noqa.rs

1use ahash::HashSet;
2use itertools::Itertools;
3use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet};
4use sqruff_lib_core::errors::SQLBaseError;
5use sqruff_lib_core::parser::segments::ErasedSegment;
6
7use crate::core::rules::{ErasedRule, LintResult};
8
9pub trait HasViolation {
10    fn source_position(&self) -> Option<(usize, usize)>;
11}
12
13impl HasViolation for SQLBaseError {
14    fn source_position(&self) -> Option<(usize, usize)> {
15        Some((self.line_no, self.line_pos))
16    }
17}
18
19impl HasViolation for LintResult {
20    fn source_position(&self) -> Option<(usize, usize)> {
21        self.anchor
22            .as_ref()?
23            .get_position_marker()
24            .map(|m| m.source_position())
25    }
26}
27
28/// The NoQA directive is a way to disable specific rules or all rules for a specific line or range of lines.
29/// Similar to flake8’s ignore, individual lines can be ignored by adding `-- noqa` to the end of the line.
30/// Additionally, specific rules can be ignored by quoting their code or the category.
31///
32/// ## Ignoring single line errors
33///
34/// The following example will ignore all errors on line 1.
35///
36/// ```sql
37/// -- Ignore all errors
38/// SeLeCt  1 from tBl ;    -- noqa
39///
40/// -- Ignore rule CP02 & rule CP03
41/// SeLeCt  1 from tBl ;    -- noqa: CP02,CP03
42/// ```
43///
44/// ## Ignoring multiple line errors
45///
46/// Similar to pylint’s “pylint directive”, ranges of lines can be ignored by adding `-- noqa:disable=<rule>[,...] | all` to the line.
47/// Following this directive, specified rules (or all rules, if “all” was specified)
48/// will be ignored until a corresponding `-– noqa:enable=<rule>[,…] | all`.
49///
50/// For example:
51///
52/// ```sql
53/// -- Ignore rule AL02 from this line forward
54/// SELECT col_a a FROM foo -- noqa: disable=AL02
55///
56/// -- Ignore all rules from this line forward
57/// SELECT col_a a FROM foo -- noqa: disable=all
58///
59/// -- Enforce all rules from this line forward
60/// SELECT col_a a FROM foo -- noqa: enable=all
61/// ```
62#[derive(Eq, PartialEq, Debug, Clone)]
63enum NoQADirective {
64    LineIgnoreAll(LineIgnoreAll),
65    LineIgnoreRules(LineIgnoreRules),
66    RangeIgnoreAll(RangeIgnoreAll),
67    RangeIgnoreRules(RangeIgnoreRules),
68}
69
70impl NoQADirective {
71    /// validate checks if the NoQADirective is valid by checking it against a rule set and returns
72    /// error if it is valid against a set of errors rules
73    #[allow(dead_code)]
74    fn validate_against_rules(&self, available_rules: &HashSet<&str>) -> Result<(), SQLBaseError> {
75        fn check_rules(
76            rules: &HashSet<String>,
77            available_rules: &HashSet<&str>,
78        ) -> Result<(), SQLBaseError> {
79            for rule in rules {
80                if !available_rules.contains(rule.as_str()) {
81                    return Err(SQLBaseError {
82                        fixable: false,
83                        line_no: 0,
84                        line_pos: 0,
85                        description: format!("Rule {rule} not found in rule set"),
86                        rule: None,
87                        source_slice: Default::default(),
88                    });
89                }
90            }
91            Ok(())
92        }
93
94        match self {
95            NoQADirective::LineIgnoreAll(_) => Ok(()),
96            NoQADirective::LineIgnoreRules(LineIgnoreRules { rules, .. }) => {
97                check_rules(rules, available_rules)
98            }
99            NoQADirective::RangeIgnoreAll(_) => Ok(()),
100            NoQADirective::RangeIgnoreRules(RangeIgnoreRules { rules, .. }) => {
101                check_rules(rules, available_rules)
102            }
103        }
104    }
105
106    /// Extract ignore mask entries from a comment string, returning a NoQADirective if found. It
107    /// does not validate the directive rules, only parses it.
108    fn parse_from_comment(
109        original_comment: &str,
110        // TODO eventually could refactor the type
111        line_no: usize,
112        line_pos: usize,
113    ) -> Result<Option<Self>, SQLBaseError> {
114        // Comment lines can also have noqa e.g.
115        //     --dafhsdkfwdiruweksdkjdaffldfsdlfjksd -- noqa: LT05
116        // Therefore extract last possible inline ignore.
117        let comment = original_comment.split("--").last();
118        if let Some(comment) = comment {
119            let comment = comment.trim();
120            if let Some(comment) = comment.strip_prefix(NOQA_PREFIX) {
121                let comment = comment.trim();
122                if comment.is_empty() {
123                    Ok(Some(NoQADirective::LineIgnoreAll(LineIgnoreAll {
124                        line_no,
125                        line_pos,
126                        raw_string: original_comment.to_string(),
127                    })))
128                } else if let Some(comment) = comment.strip_prefix(":") {
129                    let comment = comment.trim();
130                    if let Some(comment) = comment.strip_prefix("disable=") {
131                        let comment = comment.trim();
132                        if comment == "all" {
133                            Ok(Some(NoQADirective::RangeIgnoreAll(RangeIgnoreAll {
134                                line_no,
135                                line_pos,
136                                raw_string: original_comment.to_string(),
137                                action: IgnoreAction::Disable,
138                            })))
139                        } else {
140                            let rules: HashSet<_> = comment
141                                .split(",")
142                                .map(|rule| rule.trim().to_string())
143                                .filter(|rule| !rule.is_empty())
144                                .collect();
145                            if rules.is_empty() {
146                                Err(SQLBaseError {
147                                    fixable: false,
148                                    line_no,
149                                    line_pos,
150                                    description: "Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"
151                                        .into(),
152                                    rule: None,
153                                    source_slice: Default::default(),
154                                })
155                            } else {
156                                Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
157                                    line_no,
158                                    line_pos,
159                                    raw_string: original_comment.into(),
160                                    action: IgnoreAction::Disable,
161                                    rules,
162                                })))
163                            }
164                        }
165                    } else if let Some(comment) = comment.strip_prefix("enable=") {
166                        let comment = comment.trim();
167                        if comment == "all" {
168                            Ok(Some(NoQADirective::RangeIgnoreAll(RangeIgnoreAll {
169                                line_no,
170                                line_pos,
171                                action: IgnoreAction::Enable,
172                                raw_string: original_comment.to_string(),
173                            })))
174                        } else {
175                            let rules: HashSet<_> = comment
176                                .split(",")
177                                .map(|rule| rule.trim().to_string())
178                                .filter(|rule| !rule.is_empty())
179                                .collect();
180                            if rules.is_empty() {
181                                Err(SQLBaseError {
182                                    fixable: false,
183                                    line_no,
184                                    line_pos,
185                                    description:
186                                        "Malformed 'noqa' section. Expected 'noqa: <rule>[,...]'"
187                                            .to_string(),
188                                    rule: None,
189                                    source_slice: Default::default(),
190                                })
191                            } else {
192                                Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
193                                    line_no,
194                                    line_pos,
195                                    raw_string: original_comment.to_string(),
196                                    action: IgnoreAction::Enable,
197                                    rules,
198                                })))
199                            }
200                        }
201                    } else if !comment.is_empty() {
202                        let rules = comment.split(",").map_into().collect::<HashSet<String>>();
203                        if rules.is_empty() {
204                            Err(SQLBaseError {
205                                fixable: false,
206                                line_no,
207                                line_pos,
208                                description:
209                                    "Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"
210                                        .into(),
211                                rule: None,
212                                source_slice: Default::default(),
213                            })
214                        } else {
215                            Ok(Some(NoQADirective::LineIgnoreRules(LineIgnoreRules {
216                                line_no,
217                                line_pos: 0,
218                                raw_string: original_comment.into(),
219                                rules,
220                            })))
221                        }
222                    } else {
223                        Err(SQLBaseError {
224                            fixable: false,
225                            line_no,
226                            line_pos,
227                            description:
228                                "Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"
229                                    .into(),
230                            rule: None,
231                            source_slice: Default::default(),
232                        })
233                    }
234                } else {
235                    Err(SQLBaseError {
236                        fixable: false,
237                        line_no,
238                        line_pos,
239                        description:
240                            "Malformed 'noqa' section. Expected 'noqa' or 'noqa: <rule>[,...]'"
241                                .to_string(),
242                        rule: None,
243                        source_slice: Default::default(),
244                    })
245                }
246            } else {
247                Ok(None)
248            }
249        } else {
250            Ok(None)
251        }
252    }
253}
254
255#[derive(Eq, PartialEq, Debug, Clone, strum_macros::EnumString)]
256#[strum(serialize_all = "lowercase")]
257enum IgnoreAction {
258    Enable,
259    Disable,
260}
261
262#[derive(Eq, PartialEq, Debug, Clone)]
263struct RangeIgnoreAll {
264    line_no: usize,
265    line_pos: usize,
266    raw_string: String,
267    action: IgnoreAction,
268}
269
270#[derive(Eq, PartialEq, Debug, Clone)]
271struct RangeIgnoreRules {
272    line_no: usize,
273    line_pos: usize,
274    raw_string: String,
275    action: IgnoreAction,
276    rules: HashSet<String>,
277}
278
279#[derive(Eq, PartialEq, Debug, Clone)]
280struct LineIgnoreAll {
281    line_no: usize,
282    line_pos: usize,
283    raw_string: String,
284}
285
286#[derive(Eq, PartialEq, Debug, Clone)]
287struct LineIgnoreRules {
288    line_no: usize,
289    line_pos: usize,
290    raw_string: String,
291    rules: HashSet<String>,
292}
293
294#[derive(Debug, Clone, Default)]
295pub struct IgnoreMask {
296    ignore_list: Vec<NoQADirective>,
297}
298
299const NOQA_PREFIX: &str = "noqa";
300
301impl IgnoreMask {
302    /// Extract ignore mask entries from a comment segment
303    fn extract_ignore_from_comment(
304        comment: ErasedSegment,
305    ) -> Result<Option<NoQADirective>, SQLBaseError> {
306        // Trim any whitespace
307        let mut comment_content = comment.raw().trim();
308        // If we have leading or trailing block comment markers, also strip them.
309        // NOTE: We need to strip block comment markers from the start
310        // to ensure that noqa directives in the following form are followed:
311        // /* noqa: disable=all */
312        if comment_content.ends_with("*/") {
313            comment_content = comment_content[..comment_content.len() - 2].trim_end();
314        }
315        if comment_content.starts_with("/*") {
316            comment_content = comment_content[2..].trim_start();
317        }
318        let (line_no, line_pos) = comment
319            .get_position_marker()
320            .ok_or(SQLBaseError {
321                fixable: false,
322                line_no: 0,
323                line_pos: 0,
324                description: "Could not get position marker".to_string(),
325                rule: None,
326                source_slice: Default::default(),
327            })?
328            .source_position();
329        NoQADirective::parse_from_comment(comment_content, line_no, line_pos)
330    }
331
332    /// Parse a `noqa` directive from an erased segment.
333    ///
334    /// TODO - The output IgnoreMask should be validated against the ruleset.
335    pub fn from_tree(tree: &ErasedSegment) -> (IgnoreMask, Vec<SQLBaseError>) {
336        let mut ignore_list: Vec<NoQADirective> = vec![];
337        let mut violations: Vec<SQLBaseError> = vec![];
338        for comment in tree.recursive_crawl(
339            const {
340                &SyntaxSet::new(&[
341                    SyntaxKind::Comment,
342                    SyntaxKind::InlineComment,
343                    SyntaxKind::BlockComment,
344                ])
345            },
346            false,
347            &SyntaxSet::new(&[]),
348            false,
349        ) {
350            let ignore_entry = IgnoreMask::extract_ignore_from_comment(comment);
351            if let Err(err) = ignore_entry {
352                violations.push(err);
353            } else if let Ok(Some(ignore_entry)) = ignore_entry {
354                ignore_list.push(ignore_entry);
355            }
356        }
357        (IgnoreMask { ignore_list }, violations)
358    }
359
360    /// is_masked returns true if the IgnoreMask masks the violation
361    /// TODO - The parsing should also return warnings for rules that aren't used
362    pub fn is_masked(&self, violation: &impl HasViolation, rule: Option<&ErasedRule>) -> bool {
363        let Some((vline_no, vline_pos)) = violation.source_position() else {
364            return true;
365        };
366
367        let is_masked_by_line_rules = || {
368            for ignore in &self.ignore_list {
369                match ignore {
370                    NoQADirective::LineIgnoreAll(LineIgnoreAll { line_no, .. }) => {
371                        if vline_no == *line_no {
372                            return true;
373                        }
374                    }
375                    NoQADirective::LineIgnoreRules(LineIgnoreRules { line_no, rules, .. }) => {
376                        if vline_no == *line_no
377                            && let Some(rule) = rule
378                            && rules.contains(rule.code())
379                        {
380                            return true;
381                        }
382                    }
383                    _ => {}
384                }
385            }
386            false
387        };
388
389        // is_masked_by_range returns true if the violation is masked by the RangeIgnoreRules and
390        // RangeIgnoreAll components in the ignore mask
391        let is_masked_by_range_rules = || {
392            // Collect RangeIgnore directives
393            let mut directives = Vec::new();
394
395            for ignore in &self.ignore_list {
396                match ignore {
397                    NoQADirective::RangeIgnoreAll(RangeIgnoreAll {
398                        line_no, line_pos, ..
399                    }) => {
400                        directives.push((line_no, line_pos, ignore));
401                    }
402                    NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
403                        line_no, line_pos, ..
404                    }) => {
405                        directives.push((line_no, line_pos, ignore));
406                    }
407                    _ => {}
408                }
409            }
410
411            // Sort directives by line_no, line_pos
412            directives.sort_by(|(line_no1, line_pos1, _), (line_no2, line_pos2, _)| {
413                line_no1.cmp(line_no2).then(line_pos1.cmp(line_pos2))
414            });
415
416            // Initialize state
417            let mut all_rules_disabled = false;
418            let mut disabled_rules = <HashSet<String>>::default();
419
420            // For each directive
421            for (line_no, line_pos, ignore) in directives {
422                // Check if the directive is before the violation
423                if *line_no > vline_no {
424                    break;
425                }
426                if *line_no == vline_no && *line_pos > vline_pos {
427                    break;
428                }
429
430                // Process the directive
431                match ignore {
432                    NoQADirective::RangeIgnoreAll(RangeIgnoreAll { action, .. }) => match action {
433                        IgnoreAction::Disable => {
434                            all_rules_disabled = true;
435                        }
436                        IgnoreAction::Enable => {
437                            all_rules_disabled = false;
438                        }
439                    },
440                    NoQADirective::RangeIgnoreRules(RangeIgnoreRules { action, rules, .. }) => {
441                        match action {
442                            IgnoreAction::Disable => {
443                                for rule in rules {
444                                    disabled_rules.insert(rule.clone());
445                                }
446                            }
447                            IgnoreAction::Enable => {
448                                for rule in rules {
449                                    disabled_rules.remove(rule);
450                                }
451                            }
452                        }
453                    }
454                    _ => {}
455                }
456            }
457
458            // Check whether the violation is masked
459            if all_rules_disabled {
460                return true;
461            } else if let Some(rule) = rule
462                && disabled_rules.contains(rule.code())
463            {
464                return true;
465            }
466
467            false
468        };
469
470        is_masked_by_line_rules() || is_masked_by_range_rules()
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::core::config::FluffConfig;
478    use crate::core::linter::core::Linter;
479    use crate::core::rules::Erased;
480    use crate::core::rules::noqa::NoQADirective;
481    use itertools::Itertools;
482
483    #[test]
484    fn test_is_masked_single_line() {
485        let error = SQLBaseError {
486            fixable: true,
487            line_no: 2,
488            line_pos: 11,
489            description: "Implicit/explicit aliasing of columns.".to_string(),
490            rule: None,
491            source_slice: Default::default(),
492        };
493        let mask = IgnoreMask {
494            ignore_list: vec![NoQADirective::LineIgnoreRules(LineIgnoreRules {
495                line_no: 2,
496                line_pos: 13,
497                raw_string: "--noqa: AL02".to_string(),
498                rules: ["AL02".to_string()].into_iter().collect(),
499            })],
500        };
501        let not_mask_wrong_line = IgnoreMask {
502            ignore_list: vec![NoQADirective::LineIgnoreRules(LineIgnoreRules {
503                line_no: 3,
504                line_pos: 13,
505                raw_string: "--noqa: AL02".to_string(),
506                rules: ["AL02".to_string()].into_iter().collect(),
507            })],
508        };
509        let not_mask_wrong_rule = IgnoreMask {
510            ignore_list: vec![NoQADirective::LineIgnoreRules(LineIgnoreRules {
511                line_no: 3,
512                line_pos: 13,
513                raw_string: "--noqa: AL03".to_string(),
514                rules: ["AL03".to_string()].into_iter().collect(),
515            })],
516        };
517
518        assert!(!not_mask_wrong_line.is_masked(&error, None));
519        assert!(!not_mask_wrong_rule.is_masked(&error, None));
520        assert!(mask.is_masked(
521            &error,
522            Some(&crate::rules::aliasing::al02::RuleAL02::default().erased())
523        ));
524    }
525
526    #[test]
527    fn test_parse_noqa() {
528        let test_cases = vec![
529            ("", Ok::<Option<NoQADirective>, &'static str>(None)),
530            (
531                "noqa",
532                Ok(Some(NoQADirective::LineIgnoreAll(LineIgnoreAll {
533                    line_no: 0,
534                    line_pos: 0,
535                    raw_string: "noqa".to_string(),
536                }))),
537            ),
538            (
539                "noqa?",
540                Err("Malformed 'noqa' section. Expected 'noqa' or 'noqa: <rule>[,...]'"),
541            ),
542            (
543                "noqa:",
544                Err("Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"),
545            ),
546            (
547                "noqa: ",
548                Err("Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"),
549            ),
550            (
551                "noqa: LT01,LT02",
552                Ok(Some(NoQADirective::LineIgnoreRules(LineIgnoreRules {
553                    line_no: 0,
554                    line_pos: 0,
555                    raw_string: "noqa: LT01,LT02".into(),
556                    rules: ["LT01", "LT02"]
557                        .into_iter()
558                        .map_into()
559                        .collect::<HashSet<String>>(),
560                }))),
561            ),
562            (
563                "noqa: enable=LT01",
564                Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
565                    line_no: 0,
566                    line_pos: 0,
567                    raw_string: "noqa: enable=LT01".to_string(),
568                    action: IgnoreAction::Enable,
569                    rules: ["LT01"].into_iter().map_into().collect::<HashSet<String>>(),
570                }))),
571            ),
572            (
573                "noqa: disable=CP01",
574                Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
575                    line_no: 0,
576                    line_pos: 0,
577                    raw_string: "noqa: disable=CP01".to_string(),
578                    action: IgnoreAction::Disable,
579                    rules: ["CP01"].into_iter().map_into().collect::<HashSet<String>>(),
580                }))),
581            ),
582            (
583                "noqa: disable=all",
584                Ok(Some(NoQADirective::RangeIgnoreAll(RangeIgnoreAll {
585                    line_no: 0,
586                    line_pos: 0,
587                    raw_string: "noqa: disable=all".to_string(),
588                    action: IgnoreAction::Disable,
589                }))),
590            ),
591            // TODO Implement
592            // ("noqa: disable", Err("")),
593            (
594                "Inline comment before inline ignore -- noqa: disable=LT01,LT02",
595                Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
596                    line_no: 0,
597                    line_pos: 0,
598                    raw_string: "Inline comment before inline ignore -- noqa: disable=LT01,LT02"
599                        .to_string(),
600                    action: IgnoreAction::Disable,
601                    rules: ["LT01".to_string(), "LT02".to_string()]
602                        .into_iter()
603                        .collect(),
604                }))),
605            ),
606        ];
607
608        for (input, expected) in test_cases {
609            let result = NoQADirective::parse_from_comment(input, 0, 0);
610            match expected {
611                Ok(_) => assert_eq!(result.unwrap(), expected.unwrap()),
612                Err(err) => {
613                    assert!(result.is_err());
614                    let result_err = result.err().unwrap();
615                    assert_eq!(result_err.description, err);
616                }
617            }
618        }
619    }
620
621    #[test]
622    /// Test "noqa" feature at the higher "Linter" level.
623    fn test_linter_single_noqa() {
624        let linter = Linter::new(
625            FluffConfig::from_source(
626                r#"
627[sqruff]
628dialect = bigquery
629rules = AL02
630    "#,
631                None,
632            ),
633            None,
634            None,
635            false,
636        );
637
638        let sql = r#"SELECT
639    col_a a,
640    col_b b --noqa: AL02
641FROM foo
642"#;
643
644        let result = linter.lint_string(sql, None, false);
645        let violations = result.violations();
646
647        assert_eq!(violations.len(), 1);
648        assert_eq!(
649            violations.iter().map(|v| v.line_no).collect::<Vec<_>>(),
650            [2].to_vec()
651        );
652    }
653
654    #[test]
655    /// Test "noqa" feature at the higher "Linter" level and turn off noqa
656    fn test_linter_noqa_but_disabled() {
657        let linter_without_disabled = Linter::new(
658            FluffConfig::from_source(
659                r#"
660[sqruff]
661dialect = bigquery
662rules = AL02
663    "#,
664                None,
665            ),
666            None,
667            None,
668            false,
669        );
670        let linter_with_disabled = Linter::new(
671            FluffConfig::from_source(
672                r#"
673[sqruff]
674dialect = bigquery
675rules = AL02
676disable_noqa = True
677    "#,
678                None,
679            ),
680            None,
681            None,
682            false,
683        );
684
685        let sql = r#"SELECT
686    col_a a,
687    col_b b --noqa
688FROM foo
689    "#;
690        let result_with_disabled = linter_with_disabled.lint_string(sql, None, false);
691        let result_without_disabled = linter_without_disabled.lint_string(sql, None, false);
692
693        assert_eq!(result_without_disabled.violations().len(), 1);
694        assert_eq!(result_with_disabled.violations().len(), 2);
695    }
696
697    #[test]
698    fn test_range_code() {
699        let linter_without_disabled = Linter::new(
700            FluffConfig::from_source(
701                r#"
702[sqruff]
703dialect = bigquery
704rules = AL02
705    "#,
706                None,
707            ),
708            None,
709            None,
710            false,
711        );
712        let sql_disable_rule = r#"SELECT
713    col_a a,
714    col_c c, --noqa: disable=AL02
715    col_d d,
716    col_e e, --noqa: enable=AL02
717    col_f f
718FROM foo
719"#;
720
721        let sql_disable_all = r#"SELECT
722    col_a a,
723    col_c c, --noqa: disable=all
724    col_d d,
725    col_e e, --noqa: enable=all
726    col_f f
727FROM foo
728"#;
729        let result_rule = linter_without_disabled.lint_string(sql_disable_rule, None, false);
730        let result_all = linter_without_disabled.lint_string(sql_disable_all, None, false);
731
732        assert_eq!(result_rule.violations().len(), 3);
733        assert_eq!(result_all.violations().len(), 3);
734    }
735}