use ahash::HashSet;
use itertools::Itertools;
use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet};
use sqruff_lib_core::errors::SQLBaseError;
use sqruff_lib_core::parser::segments::base::ErasedSegment;
#[derive(Eq, PartialEq, Debug)]
enum NoQADirective {
LineIgnoreAll(LineIgnoreAll),
LineIgnoreRules(LineIgnoreRules),
RangeIgnoreAll(RangeIgnoreAll),
RangeIgnoreRules(RangeIgnoreRules),
}
impl NoQADirective {
#[allow(dead_code)]
fn validate_against_rules(&self, available_rules: &HashSet<&str>) -> Result<(), SQLBaseError> {
fn check_rules(
rules: &HashSet<String>,
available_rules: &HashSet<&str>,
) -> Result<(), SQLBaseError> {
for rule in rules {
if !available_rules.contains(rule.as_str()) {
return Err(SQLBaseError {
fatal: true,
ignore: false,
warning: false,
line_no: 0,
line_pos: 0,
description: format!("Rule {} not found in rule set", rule),
rule: None,
source_slice: Default::default(),
});
}
}
Ok(())
}
match self {
NoQADirective::LineIgnoreAll(_) => Ok(()),
NoQADirective::LineIgnoreRules(LineIgnoreRules { rules, .. }) => {
check_rules(rules, available_rules)
}
NoQADirective::RangeIgnoreAll(_) => Ok(()),
NoQADirective::RangeIgnoreRules(RangeIgnoreRules { rules, .. }) => {
check_rules(rules, available_rules)
}
}
}
fn parse_from_comment(
original_comment: &str,
line_no: usize,
line_pos: usize,
) -> Result<Option<Self>, SQLBaseError> {
let comment = original_comment.split("--").last();
if let Some(comment) = comment {
let comment = comment.trim();
if let Some(comment) = comment.strip_prefix(NOQA_PREFIX) {
let comment = comment.trim();
if comment.is_empty() {
Ok(Some(NoQADirective::LineIgnoreAll(LineIgnoreAll {
line_no,
line_pos,
raw_string: original_comment.to_string(),
})))
} else if let Some(comment) = comment.strip_prefix(":") {
let comment = comment.trim();
if let Some(comment) = comment.strip_prefix("disable=") {
let comment = comment.trim();
if comment == "all" {
Ok(Some(NoQADirective::RangeIgnoreAll(RangeIgnoreAll {
line_no,
line_pos,
raw_string: original_comment.to_string(),
action: IgnoreAction::Disable,
})))
} else {
let rules: HashSet<_> = comment
.split(",")
.map(|rule| rule.trim().to_string())
.filter(|rule| !rule.is_empty())
.collect();
if rules.is_empty() {
Err(SQLBaseError {
fatal: true,
ignore: false,
warning: false,
line_no,
line_pos,
description: "Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"
.into(),
rule: None,
source_slice: Default::default(),
})
} else {
Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
line_no,
line_pos,
raw_string: original_comment.into(),
action: IgnoreAction::Disable,
rules,
})))
}
}
} else if let Some(comment) = comment.strip_prefix("enable=") {
let comment = comment.trim();
if comment == "all" {
Ok(Some(NoQADirective::RangeIgnoreAll(RangeIgnoreAll {
line_no,
line_pos,
action: IgnoreAction::Enable,
raw_string: original_comment.to_string(),
})))
} else {
let rules: HashSet<_> = comment
.split(",")
.map(|rule| rule.trim().to_string())
.filter(|rule| !rule.is_empty())
.collect();
if rules.is_empty() {
Err(SQLBaseError {
fatal: true,
ignore: false,
warning: false,
line_no,
line_pos,
description:
"Malformed 'noqa' section. Expected 'noqa: <rule>[,...]'"
.to_string(),
rule: None,
source_slice: Default::default(),
})
} else {
Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
line_no,
line_pos,
raw_string: original_comment.to_string(),
action: IgnoreAction::Enable,
rules,
})))
}
}
} else if !comment.is_empty() {
let rules = comment.split(",").map_into().collect::<HashSet<String>>();
if rules.is_empty() {
Err(SQLBaseError {
fatal: true,
ignore: false,
warning: false,
line_no,
line_pos,
description:
"Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"
.into(),
rule: None,
source_slice: Default::default(),
})
} else {
return Ok(Some(NoQADirective::LineIgnoreRules(LineIgnoreRules {
line_no,
line_pos: 0,
raw_string: original_comment.into(),
rules,
})));
}
} else {
Err(SQLBaseError {
fatal: true,
ignore: false,
warning: false,
line_no,
line_pos,
description:
"Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"
.into(),
rule: None,
source_slice: Default::default(),
})
}
} else {
Err(SQLBaseError {
fatal: true,
ignore: false,
warning: false,
line_no,
line_pos,
description:
"Malformed 'noqa' section. Expected 'noqa' or 'noqa: <rule>[,...]'"
.to_string(),
rule: None,
source_slice: Default::default(),
})
}
} else {
Ok(None)
}
} else {
Ok(None)
}
}
}
#[derive(Eq, PartialEq, Debug, strum_macros::EnumString)]
#[strum(serialize_all = "lowercase")]
enum IgnoreAction {
Enable,
Disable,
}
#[derive(Eq, PartialEq, Debug)]
struct RangeIgnoreAll {
line_no: usize,
line_pos: usize,
raw_string: String,
action: IgnoreAction,
}
#[derive(Eq, PartialEq, Debug)]
struct RangeIgnoreRules {
line_no: usize,
line_pos: usize,
raw_string: String,
action: IgnoreAction,
rules: HashSet<String>,
}
#[derive(Eq, PartialEq, Debug)]
struct LineIgnoreAll {
line_no: usize,
line_pos: usize,
raw_string: String,
}
#[derive(Eq, PartialEq, Debug)]
struct LineIgnoreRules {
line_no: usize,
line_pos: usize,
raw_string: String,
rules: HashSet<String>,
}
#[derive(Debug, Default)]
pub struct IgnoreMask {
ignore_list: Vec<NoQADirective>,
}
const NOQA_PREFIX: &str = "noqa";
impl IgnoreMask {
fn extract_ignore_from_comment(
comment: ErasedSegment,
) -> Result<Option<NoQADirective>, SQLBaseError> {
let mut comment_content = comment.raw().trim();
if comment_content.ends_with("*/") {
comment_content = comment_content[..comment_content.len() - 2].trim_end();
}
if comment_content.starts_with("/*") {
comment_content = comment_content[2..].trim_start();
}
let (line_no, line_pos) = comment
.get_position_marker()
.ok_or(SQLBaseError {
fatal: true,
ignore: false,
warning: false,
line_no: 0,
line_pos: 0,
description: "Could not get position marker".to_string(),
rule: None,
source_slice: Default::default(),
})?
.source_position();
NoQADirective::parse_from_comment(comment_content, line_no, line_pos)
}
pub fn from_tree(tree: &ErasedSegment) -> (IgnoreMask, Vec<SQLBaseError>) {
let mut ignore_list: Vec<NoQADirective> = vec![];
let mut violations: Vec<SQLBaseError> = vec![];
for comment in tree.recursive_crawl(
const {
&SyntaxSet::new(&[
SyntaxKind::Comment,
SyntaxKind::InlineComment,
SyntaxKind::BlockComment,
])
},
false,
&SyntaxSet::new(&[]),
false,
) {
let ignore_entry = IgnoreMask::extract_ignore_from_comment(comment);
if let Err(err) = ignore_entry {
violations.push(err);
} else if let Ok(Some(ignore_entry)) = ignore_entry {
ignore_list.push(ignore_entry);
}
}
(IgnoreMask { ignore_list }, violations)
}
pub fn is_masked(&self, violation: &SQLBaseError) -> bool {
fn is_masked_by_line_rules(ignore_mask: &IgnoreMask, violation: &SQLBaseError) -> bool {
for ignore in &ignore_mask.ignore_list {
match ignore {
NoQADirective::LineIgnoreAll(LineIgnoreAll { line_no, .. }) => {
if violation.line_no == *line_no {
return true;
}
}
NoQADirective::LineIgnoreRules(LineIgnoreRules { line_no, rules, .. }) => {
if violation.line_no == *line_no {
if let Some(rule) = &violation.rule {
if rules.contains(rule.code) {
return true;
}
}
}
}
_ => {}
}
}
false
}
fn is_masked_by_range_rules(ignore_mask: &IgnoreMask, violation: &SQLBaseError) -> bool {
let mut directives = Vec::new();
for ignore in &ignore_mask.ignore_list {
match ignore {
NoQADirective::RangeIgnoreAll(RangeIgnoreAll {
line_no, line_pos, ..
}) => {
directives.push((line_no, line_pos, ignore));
}
NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
line_no, line_pos, ..
}) => {
directives.push((line_no, line_pos, ignore));
}
_ => {}
}
}
directives.sort_by(|(line_no1, line_pos1, _), (line_no2, line_pos2, _)| {
line_no1.cmp(line_no2).then(line_pos1.cmp(line_pos2))
});
let mut all_rules_disabled = false;
let mut disabled_rules = <HashSet<String>>::default();
for (line_no, line_pos, ignore) in directives {
if *line_no > violation.line_no {
break;
}
if *line_no == violation.line_no && *line_pos > violation.line_pos {
break;
}
match ignore {
NoQADirective::RangeIgnoreAll(RangeIgnoreAll { action, .. }) => match action {
IgnoreAction::Disable => {
all_rules_disabled = true;
}
IgnoreAction::Enable => {
all_rules_disabled = false;
}
},
NoQADirective::RangeIgnoreRules(RangeIgnoreRules { action, rules, .. }) => {
match action {
IgnoreAction::Disable => {
for rule in rules {
disabled_rules.insert(rule.clone());
}
}
IgnoreAction::Enable => {
for rule in rules {
disabled_rules.remove(rule);
}
}
}
}
_ => {}
}
}
if all_rules_disabled {
return true;
} else if let Some(rule) = &violation.rule {
if disabled_rules.contains(rule.code) {
return true;
}
}
false
}
is_masked_by_line_rules(self, violation) || is_masked_by_range_rules(self, violation)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::FluffConfig;
use crate::core::linter::core::Linter;
use crate::core::rules::noqa::NoQADirective;
use itertools::Itertools;
use sqruff_lib_core::errors::ErrorStructRule;
#[test]
fn test_is_masked_single_line() {
let error = SQLBaseError {
fatal: false,
ignore: false,
warning: false,
line_no: 2,
line_pos: 11,
description: "Implicit/explicit aliasing of columns.".to_string(),
rule: Some(ErrorStructRule {
name: "aliasing.column",
code: "AL02",
}),
source_slice: Default::default(),
};
let mask = IgnoreMask {
ignore_list: vec![NoQADirective::LineIgnoreRules(LineIgnoreRules {
line_no: 2,
line_pos: 13,
raw_string: "--noqa: AL02".to_string(),
rules: ["AL02".to_string()].into_iter().collect(),
})],
};
let not_mask_wrong_line = IgnoreMask {
ignore_list: vec![NoQADirective::LineIgnoreRules(LineIgnoreRules {
line_no: 3,
line_pos: 13,
raw_string: "--noqa: AL02".to_string(),
rules: ["AL02".to_string()].into_iter().collect(),
})],
};
let not_mask_wrong_rule = IgnoreMask {
ignore_list: vec![NoQADirective::LineIgnoreRules(LineIgnoreRules {
line_no: 3,
line_pos: 13,
raw_string: "--noqa: AL03".to_string(),
rules: ["AL03".to_string()].into_iter().collect(),
})],
};
assert!(!not_mask_wrong_line.is_masked(&error));
assert!(!not_mask_wrong_rule.is_masked(&error));
assert!(mask.is_masked(&error));
}
#[test]
fn test_parse_noqa() {
let test_cases = vec![
("", Ok::<Option<NoQADirective>, &'static str>(None)),
(
"noqa",
Ok(Some(NoQADirective::LineIgnoreAll(LineIgnoreAll {
line_no: 0,
line_pos: 0,
raw_string: "noqa".to_string(),
}))),
),
(
"noqa?",
Err("Malformed 'noqa' section. Expected 'noqa' or 'noqa: <rule>[,...]'"),
),
(
"noqa:",
Err("Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"),
),
(
"noqa: ",
Err("Malformed 'noqa' section. Expected 'noqa: <rule>[,...] | all'"),
),
(
"noqa: LT01,LT02",
Ok(Some(NoQADirective::LineIgnoreRules(LineIgnoreRules {
line_no: 0,
line_pos: 0,
raw_string: "noqa: LT01,LT02".into(),
rules: ["LT01", "LT02"]
.into_iter()
.map_into()
.collect::<HashSet<String>>(),
}))),
),
(
"noqa: enable=LT01",
Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
line_no: 0,
line_pos: 0,
raw_string: "noqa: enable=LT01".to_string(),
action: IgnoreAction::Enable,
rules: ["LT01"].into_iter().map_into().collect::<HashSet<String>>(),
}))),
),
(
"noqa: disable=CP01",
Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
line_no: 0,
line_pos: 0,
raw_string: "noqa: disable=CP01".to_string(),
action: IgnoreAction::Disable,
rules: ["CP01"].into_iter().map_into().collect::<HashSet<String>>(),
}))),
),
(
"noqa: disable=all",
Ok(Some(NoQADirective::RangeIgnoreAll(RangeIgnoreAll {
line_no: 0,
line_pos: 0,
raw_string: "noqa: disable=all".to_string(),
action: IgnoreAction::Disable,
}))),
),
(
"Inline comment before inline ignore -- noqa: disable=LT01,LT02",
Ok(Some(NoQADirective::RangeIgnoreRules(RangeIgnoreRules {
line_no: 0,
line_pos: 0,
raw_string: "Inline comment before inline ignore -- noqa: disable=LT01,LT02"
.to_string(),
action: IgnoreAction::Disable,
rules: ["LT01".to_string(), "LT02".to_string()]
.into_iter()
.collect(),
}))),
),
];
for (input, expected) in test_cases {
let result = NoQADirective::parse_from_comment(input, 0, 0);
match expected {
Ok(_) => assert_eq!(result.unwrap(), expected.unwrap()),
Err(err) => {
assert!(result.is_err());
let result_err = result.err().unwrap();
assert_eq!(result_err.description, err);
assert!(result_err.fatal);
}
}
}
}
#[test]
fn test_linter_single_noqa() {
let linter = Linter::new(
FluffConfig::from_source(
r#"
[sqruff]
dialect = bigquery
rules = AL02
"#,
),
None,
None,
);
let sql = r#"SELECT
col_a a,
col_b b --noqa: AL02
FROM foo
"#;
let result = linter.lint_string(sql, None, false);
let violations = result.get_violations(None);
assert_eq!(violations.len(), 1);
assert_eq!(
violations.iter().map(|v| v.line_no).collect::<Vec<_>>(),
[2].iter().cloned().collect::<Vec<_>>()
);
}
#[test]
fn test_linter_noqa_but_disabled() {
let linter_without_disabled = Linter::new(
FluffConfig::from_source(
r#"
[sqruff]
dialect = bigquery
rules = AL02
"#,
),
None,
None,
);
let linter_with_disabled = Linter::new(
FluffConfig::from_source(
r#"
[sqruff]
dialect = bigquery
rules = AL02
disable_noqa = True
"#,
),
None,
None,
);
let sql = r#"SELECT
col_a a,
col_b b --noqa
FROM foo
"#;
let result_with_disabled = linter_with_disabled.lint_string(sql, None, false);
let result_without_disabled = linter_without_disabled.lint_string(sql, None, false);
assert_eq!(result_without_disabled.get_violations(None).len(), 1);
assert_eq!(result_with_disabled.get_violations(None).len(), 2);
}
#[test]
fn test_range_code() {
let linter_without_disabled = Linter::new(
FluffConfig::from_source(
r#"
[sqruff]
dialect = bigquery
rules = AL02
"#,
),
None,
None,
);
let sql_disable_rule = r#"SELECT
col_a a,
col_c c, --noqa: disable=AL02
col_d d,
col_e e, --noqa: enable=AL02
col_f f
FROM foo
"#;
let sql_disable_all = r#"SELECT
col_a a,
col_c c, --noqa: disable=all
col_d d,
col_e e, --noqa: enable=all
col_f f
FROM foo
"#;
let result_rule = linter_without_disabled.lint_string(sql_disable_rule, None, false);
let result_all = linter_without_disabled.lint_string(sql_disable_all, None, false);
assert_eq!(result_rule.get_violations(None).len(), 3);
assert_eq!(result_all.get_violations(None).len(), 3);
}
}