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