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