use std::collections::BTreeSet;
use crate::ast::protobuf::Token;
use crate::{line_col, Finding, LintError, Location, Severity, Suppression};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DirectiveKind {
Ignore { rule_id: String, reason: String },
Malformed { detail: &'static str },
}
pub(crate) fn parse_directive(token_text: &str) -> Option<DirectiveKind> {
let inner = token_text.strip_prefix("--").map(str::trim).or_else(|| {
token_text
.strip_prefix("/*")
.and_then(|s| s.strip_suffix("*/"))
.map(str::trim)
})?;
let rest = inner.strip_prefix("pgsafe:")?.trim_start();
let mut verb_split = rest.splitn(2, char::is_whitespace);
let verb = verb_split.next().unwrap_or("");
if verb != "ignore" {
return Some(DirectiveKind::Malformed {
detail: "unrecognized pgsafe directive (expected `ignore`)",
});
}
let after = verb_split.next().unwrap_or("").trim_start();
if after.is_empty() {
return Some(DirectiveKind::Malformed {
detail: "directive is missing a rule id",
});
}
let mut id_split = after.splitn(2, char::is_whitespace);
let rule_id = id_split.next().unwrap_or("").to_string();
let reason = id_split.next().unwrap_or("").trim().to_string();
Some(DirectiveKind::Ignore { rule_id, reason })
}
#[derive(Debug, Clone)]
pub(crate) struct Comment {
pub start: usize,
pub line: u32,
pub text: String,
}
pub(crate) fn scan_comments(sql: &str) -> Result<Vec<Comment>, LintError> {
let scan = crate::ast::scan(sql).map_err(|e| LintError::Parse(e.to_string()))?;
let mut out = Vec::new();
for tok in &scan.tokens {
if matches!(
Token::try_from(tok.token),
Ok(Token::SqlComment) | Ok(Token::CComment)
) {
let start = usize::try_from(tok.start).unwrap_or(0);
let end = usize::try_from(tok.end).unwrap_or(start);
let text = sql.get(start..end).unwrap_or("").to_string();
out.push(Comment {
start,
line: line_col(sql, start).0,
text,
});
}
}
Ok(out)
}
#[derive(Debug, Clone)]
pub(crate) struct Directive {
pub kind: DirectiveKind,
pub location: Location,
pub snippet: String,
pub start: usize,
}
pub(crate) fn directives_from(comments: &[Comment], sql: &str) -> Vec<Directive> {
comments
.iter()
.filter_map(|c| {
let kind = parse_directive(&c.text)?;
let (line, column) = line_col(sql, c.start);
Some(Directive {
kind,
location: Location {
byte: u32::try_from(c.start).unwrap_or(u32::MAX),
line,
column,
},
snippet: c.text.trim().to_string(),
start: c.start,
})
})
.collect()
}
#[derive(Debug, Clone)]
pub(crate) struct StatementGeom {
pub index: usize,
pub start: usize,
pub prologue_anchor: usize,
pub end: usize,
pub first_line: u32,
pub last_line: u32,
}
pub(crate) fn geometry(
sql: &str,
stmts: &[crate::ast::protobuf::RawStmt],
comments: &[Comment],
) -> Vec<StatementGeom> {
let comment_spans: Vec<(usize, usize)> = comments
.iter()
.map(|c| (c.start, c.start + c.text.len()))
.collect();
let mut out = Vec::with_capacity(stmts.len());
for (index, raw) in stmts.iter().enumerate() {
let off = usize::try_from(raw.stmt_location.max(0)).unwrap_or(0);
let len = usize::try_from(raw.stmt_len.max(0)).unwrap_or(0);
let raw_end = if len == 0 {
sql.len()
} else {
off.saturating_add(len).min(sql.len())
};
let slice = sql.get(off..raw_end).unwrap_or("");
let lead = slice.len() - slice.trim_start().len();
let trail = slice.len() - slice.trim_end().len();
let content_start = off + lead;
let start = skip_leading_comments(sql, content_start, &comment_spans);
let end = raw_end.saturating_sub(trail).max(start);
let first_line = line_col(sql, start).0;
let last_line = line_col(sql, end.saturating_sub(1).max(start)).0;
let prologue_anchor = compute_prologue_anchor(sql, start, &comment_spans);
out.push(StatementGeom {
index,
start,
prologue_anchor,
end,
first_line,
last_line,
});
}
out
}
fn line_start(sql: &str, pos: usize) -> usize {
sql[..pos].rfind('\n').map_or(0, |i| i + 1)
}
fn is_own_line_comment(sql: &str, ls: usize, le: usize, spans: &[(usize, usize)]) -> bool {
let line = match sql.get(ls..le) {
Some(s) => s,
None => return false,
};
let ws = line.len() - line.trim_start().len();
let first = ls + ws;
if first >= le {
return false;
}
let last = ls + line.trim_end().len() - 1;
let covered = |b: usize| spans.iter().any(|&(s, e)| s <= b && b < e);
covered(first) && covered(last)
}
fn compute_prologue_anchor(sql: &str, start: usize, spans: &[(usize, usize)]) -> usize {
let mut anchor = line_start(sql, start);
loop {
if anchor == 0 {
break;
}
let above_end = anchor - 1; let above_start = line_start(sql, above_end);
if is_own_line_comment(sql, above_start, above_end, spans) {
anchor = above_start;
} else {
break;
}
}
anchor
}
fn skip_leading_comments(sql: &str, mut pos: usize, comments: &[(usize, usize)]) -> usize {
loop {
let maybe = comments.iter().find(|&&(s, _)| s == pos);
let Some(&(_, end)) = maybe else { break };
pos = end;
let after = sql.get(pos..).unwrap_or("");
pos += after.len() - after.trim_start().len();
}
pos
}
pub(crate) fn attach(
directives: &[Directive],
geoms: &[StatementGeom],
comment_lines: &BTreeSet<u32>,
) -> Vec<Option<usize>> {
let mut code_lines: BTreeSet<u32> = BTreeSet::new();
for g in geoms {
for l in g.first_line..=g.last_line {
code_lines.insert(l);
}
}
directives
.iter()
.map(|d| {
let dl = d.location.line;
if let Some(g) = geoms
.iter()
.filter(|g| g.last_line == dl && g.end <= d.start)
.max_by_key(|g| g.end)
{
return Some(g.index);
}
let g = geoms
.iter()
.filter(|g| g.first_line > dl)
.min_by_key(|g| g.first_line)?;
let contiguous = ((dl + 1)..g.first_line)
.all(|l| comment_lines.contains(&l) && !code_lines.contains(&l));
contiguous.then_some(g.index)
})
.collect()
}
pub(crate) fn resolve(
sql: &str,
geoms: &[StatementGeom],
comments: &[Comment],
mut findings: Vec<Finding>,
known_rule_ids: &[&'static str],
new_table_dropped: &BTreeSet<usize>,
disabled_rules: &BTreeSet<String>,
) -> Result<Vec<Finding>, LintError> {
let comment_lines: BTreeSet<u32> = comments
.iter()
.flat_map(|c| {
let span = u32::try_from(c.text.matches('\n').count()).unwrap_or(0);
c.line..=c.line + span
})
.collect();
let directives = directives_from(comments, sql);
let attachment = attach(&directives, geoms, &comment_lines);
let is_known = |id: &str| known_rule_ids.contains(&id);
let mut used = vec![false; directives.len()];
for (di, dir) in directives.iter().enumerate() {
let Some(stmt_idx) = attachment[di] else {
continue;
};
if let DirectiveKind::Ignore { rule_id, reason } = &dir.kind {
if is_known(rule_id) && !reason.is_empty() {
let mut matched = false;
for f in &mut findings {
if f.statement_index == stmt_idx && f.rule_id == *rule_id {
matched = true;
if f.suppression.is_none() {
f.suppression = Some(Suppression {
reason: reason.clone(),
});
}
}
}
used[di] = matched;
}
}
}
let mut hygiene: Vec<Finding> = Vec::new();
for (di, dir) in directives.iter().enumerate() {
let stmt_idx = attachment[di].unwrap_or_else(|| fallback_index(geoms, dir.location.line));
let diag: Option<(&'static str, Severity, String)> = match &dir.kind {
DirectiveKind::Malformed { detail } => Some((
"suppression-malformed",
Severity::Error,
(*detail).to_string(),
)),
DirectiveKind::Ignore { rule_id, reason } => {
if !is_known(rule_id) {
Some((
"suppression-unknown-rule",
Severity::Error,
format!("directive targets unknown rule `{rule_id}`"),
))
} else if reason.is_empty() {
Some((
"suppression-missing-reason",
Severity::Error,
format!("directive for `{rule_id}` must include a reason"),
))
} else if !used[di]
&& !new_table_dropped.contains(&stmt_idx)
&& !disabled_rules.contains(rule_id.as_str())
{
Some((
"suppression-unused",
Severity::Warning,
format!("directive for `{rule_id}` matched no finding"),
))
} else {
None
}
}
};
if let Some((id, severity, message)) = diag {
hygiene.push(Finding {
rule_id: id.to_string(),
severity,
message,
guidance: hygiene_guidance(id),
statement_index: stmt_idx,
location: dir.location,
snippet: dir.snippet.clone(),
suppression: None,
fix: None,
});
}
}
findings.extend(hygiene);
findings.sort_by_key(|f| f.statement_index);
Ok(findings)
}
fn fallback_index(geoms: &[StatementGeom], line: u32) -> usize {
geoms
.iter()
.find(|g| g.first_line >= line)
.or_else(|| geoms.last())
.map_or(0, |g| g.index)
}
fn hygiene_guidance(id: &str) -> String {
match id {
"suppression-malformed" => {
"Use `-- pgsafe:ignore <rule-id> <reason>`; the only supported verb is `ignore`."
}
"suppression-unknown-rule" => {
"Check the rule id against the documented rules; ids must match exactly."
}
"suppression-missing-reason" => {
"Add a reason after the rule id so the override is auditable."
}
"suppression-unused" => {
"Remove the directive — it no longer suppresses anything (the finding is gone)."
}
_ => "",
}
.to_string()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
#[test]
fn parses_valid_line_directive() {
assert_eq!(
parse_directive("-- pgsafe:ignore drop-table superseded by v2"),
Some(DirectiveKind::Ignore {
rule_id: "drop-table".into(),
reason: "superseded by v2".into()
})
);
}
#[test]
fn parses_block_comment_directive() {
assert_eq!(
parse_directive("/* pgsafe:ignore truncate one-off */"),
Some(DirectiveKind::Ignore {
rule_id: "truncate".into(),
reason: "one-off".into()
})
);
}
#[test]
fn rule_id_without_reason_is_ignore_with_empty_reason() {
assert_eq!(
parse_directive("-- pgsafe:ignore drop-table"),
Some(DirectiveKind::Ignore {
rule_id: "drop-table".into(),
reason: String::new()
})
);
}
#[test]
fn no_rule_id_token_is_malformed() {
assert!(matches!(
parse_directive("-- pgsafe:ignore"),
Some(DirectiveKind::Malformed { .. })
));
}
#[test]
fn unknown_verb_is_malformed() {
assert!(matches!(
parse_directive("-- pgsafe:disable drop-table"),
Some(DirectiveKind::Malformed { .. })
));
}
#[test]
fn non_pgsafe_comment_is_not_a_directive() {
assert_eq!(parse_directive("-- just a note"), None);
assert_eq!(parse_directive("-- PGSAFE:IGNORE drop-table x"), None); }
#[test]
fn scan_excludes_directive_text_inside_a_string_literal() {
let sql = "SELECT '-- pgsafe:ignore drop-table x'; DROP TABLE y;";
let comments = scan_comments(sql).unwrap();
assert!(
directives_from(&comments, sql).is_empty(),
"string-literal content is not a directive"
);
}
#[test]
fn extracts_directive_with_position() {
let sql = "-- pgsafe:ignore drop-table reason here\nDROP TABLE x;";
let dirs = directives_from(&scan_comments(sql).unwrap(), sql);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].location.line, 1);
assert!(
matches!(&dirs[0].kind, DirectiveKind::Ignore { rule_id, .. } if rule_id == "drop-table")
);
}
fn attach_map(sql: &str) -> Vec<Option<usize>> {
let parsed = crate::ast::parse(sql).unwrap();
let comments = scan_comments(sql).unwrap();
let comment_lines: BTreeSet<u32> = comments.iter().map(|c| c.line).collect();
let dirs = directives_from(&comments, sql);
let geoms = geometry(sql, &parsed.protobuf.stmts, &comments);
attach(&dirs, &geoms, &comment_lines)
}
#[test]
fn preceding_directive_attaches_to_next_statement() {
assert_eq!(
attach_map("-- pgsafe:ignore drop-table r\nDROP TABLE x;"),
vec![Some(0)]
);
}
#[test]
fn preceding_through_a_plain_comment_still_attaches() {
assert_eq!(
attach_map("-- pgsafe:ignore drop-table r\n-- a note\nDROP TABLE x;"),
vec![Some(0)]
);
}
#[test]
fn blank_line_breaks_preceding_attachment() {
assert_eq!(
attach_map("-- pgsafe:ignore drop-table r\n\nDROP TABLE x;"),
vec![None]
);
}
#[test]
fn trailing_directive_attaches_to_its_statement() {
assert_eq!(
attach_map("DROP TABLE x; -- pgsafe:ignore drop-table r"),
vec![Some(0)]
);
}
#[test]
fn directive_routes_to_correct_statement_in_multi_statement_file() {
assert_eq!(
attach_map("DROP TABLE a;\n-- pgsafe:ignore drop-table r\nDROP TABLE b;"),
vec![Some(1)]
);
}
#[test]
fn stacked_directives_all_attach_to_following_statement() {
let sql =
"-- pgsafe:ignore drop-column r1\n-- pgsafe:ignore drop-table r2\nDROP TABLE x;";
assert_eq!(attach_map(sql), vec![Some(0), Some(0)]);
}
fn resolved(sql: &str) -> Vec<Finding> {
crate::lint_sql(sql, &crate::LintOptions::default()).unwrap()
}
fn ids(fs: &[Finding]) -> Vec<&str> {
fs.iter().map(|f| f.rule_id.as_str()).collect()
}
#[test]
fn valid_directive_suppresses_the_finding() {
let fs = resolved("-- pgsafe:ignore drop-table empty, confirmed\nDROP TABLE x;");
let dt = fs.iter().find(|f| f.rule_id == "drop-table").unwrap();
assert!(dt.is_suppressed());
assert_eq!(dt.suppression.as_ref().unwrap().reason, "empty, confirmed");
assert!(!ids(&fs).contains(&"suppression-unused"));
}
#[test]
fn missing_reason_does_not_suppress_and_emits_diagnostic() {
let fs = resolved("-- pgsafe:ignore drop-table\nDROP TABLE x;");
assert!(!fs
.iter()
.find(|f| f.rule_id == "drop-table")
.unwrap()
.is_suppressed());
assert!(ids(&fs).contains(&"suppression-missing-reason"));
}
#[test]
fn unknown_rule_does_not_suppress_and_emits_diagnostic() {
let fs = resolved("-- pgsafe:ignore drop-tabel typo\nDROP TABLE x;");
assert!(!fs
.iter()
.find(|f| f.rule_id == "drop-table")
.unwrap()
.is_suppressed());
assert!(ids(&fs).contains(&"suppression-unknown-rule"));
}
#[test]
fn unused_directive_emits_warning() {
let fs = resolved("-- pgsafe:ignore truncate stale\nDELETE FROM x;");
let unused = fs
.iter()
.find(|f| f.rule_id == "suppression-unused")
.unwrap();
assert_eq!(unused.severity, Severity::Warning);
}
#[test]
fn malformed_directive_emits_error_and_does_not_suppress() {
let fs = resolved("-- pgsafe:disable drop-table\nDROP TABLE x;");
let m = fs
.iter()
.find(|f| f.rule_id == "suppression-malformed")
.unwrap();
assert_eq!(m.severity, Severity::Error);
assert!(!fs
.iter()
.find(|f| f.rule_id == "drop-table")
.unwrap()
.is_suppressed());
}
#[test]
fn one_directive_suppresses_only_its_rule_on_a_multi_finding_statement() {
let sql = "-- pgsafe:ignore drop-column c retired\n\
ALTER TABLE t DROP COLUMN c, ADD COLUMN d int UNIQUE;";
let fs = resolved(sql);
assert!(fs
.iter()
.find(|f| f.rule_id == "drop-column")
.unwrap()
.is_suppressed());
assert!(!fs
.iter()
.find(|f| f.rule_id == "add-unique-constraint")
.unwrap()
.is_suppressed());
}
#[test]
fn returned_order_is_statement_then_hygiene() {
let fs = resolved("DROP TABLE a;\n-- pgsafe:ignore truncate stale\nDROP TABLE b;");
assert_eq!(
ids(&fs),
vec![
"drop-table",
"require-timeout",
"drop-table",
"require-timeout",
"suppression-unused",
]
);
}
#[test]
fn duplicate_directive_for_same_rule_is_not_unused() {
let fs = crate::lint_sql(
"-- pgsafe:ignore drop-table reason one\nDROP TABLE x; -- pgsafe:ignore drop-table reason two",
&crate::LintOptions::default(),
)
.unwrap();
assert!(fs
.iter()
.find(|f| f.rule_id == "drop-table")
.unwrap()
.is_suppressed());
assert!(!fs.iter().any(|f| f.rule_id == "suppression-unused"));
}
#[test]
fn multiline_block_comment_directive_attaches() {
let fs = crate::lint_sql(
"/* pgsafe:ignore drop-table\n confirmed empty */\nDROP TABLE x;",
&crate::LintOptions::default(),
)
.unwrap();
assert!(fs
.iter()
.find(|f| f.rule_id == "drop-table")
.unwrap()
.is_suppressed());
}
#[test]
fn suppressed_finding_location_points_at_the_statement_not_the_directive() {
let fs = crate::lint_sql(
"-- pgsafe:ignore drop-table confirmed empty\nDROP TABLE x;",
&crate::LintOptions::default(),
)
.unwrap();
let dt = fs.iter().find(|f| f.rule_id == "drop-table").unwrap();
assert!(dt.is_suppressed());
assert_eq!(
dt.location.line, 2,
"location must point at DROP (line 2), not the directive (line 1)"
);
assert!(
dt.snippet.starts_with("DROP TABLE"),
"snippet must not include the directive comment; got: {:?}",
dt.snippet
);
assert!(
!dt.snippet.contains("pgsafe"),
"snippet must not contain the directive text; got: {:?}",
dt.snippet
);
}
}