use crate::{Finding, Location, Severity};
use anstyle::{AnsiColor, Style};
use std::io::IsTerminal;
#[derive(Debug, Clone)]
pub struct Styling {
danger: Style,
warning: Style,
success: Style,
accent: Style,
muted: Style,
strong: Style,
glyphs: bool,
}
impl Styling {
#[must_use]
pub fn plain() -> Self {
let n = Style::new();
Self {
danger: n,
warning: n,
success: n,
accent: n,
muted: n,
strong: n,
glyphs: false,
}
}
#[must_use]
pub fn ansi() -> Self {
let fg = |c| Style::new().fg_color(Some(c));
Self {
danger: fg(AnsiColor::Red.into()).bold(),
warning: fg(AnsiColor::Yellow.into()).bold(),
success: fg(AnsiColor::Green.into()).bold(),
accent: fg(AnsiColor::Cyan.into()),
muted: Style::new().dimmed(),
strong: Style::new().bold(),
glyphs: true,
}
}
#[must_use]
pub fn resolve(color: ColorWhen) -> Styling {
let on = color_on(
color,
env_set("CLICOLOR_FORCE"),
env_set("NO_COLOR"),
std::io::stdout().is_terminal(),
);
if on {
Styling::ansi()
} else {
Styling::plain()
}
}
#[must_use]
pub fn danger(&self, s: &str) -> String {
paint(self.danger, s)
}
#[must_use]
pub fn warning(&self, s: &str) -> String {
paint(self.warning, s)
}
#[must_use]
pub fn success(&self, s: &str) -> String {
paint(self.success, s)
}
#[must_use]
pub fn accent(&self, s: &str) -> String {
paint(self.accent, s)
}
#[must_use]
pub fn muted(&self, s: &str) -> String {
paint(self.muted, s)
}
#[must_use]
pub fn strong(&self, s: &str) -> String {
paint(self.strong, s)
}
fn severity(&self, s: Severity, text: &str) -> String {
match s {
Severity::Error => self.danger(text),
Severity::Warning => self.warning(text),
}
}
fn glyph(&self, s: Severity) -> &'static str {
if !self.glyphs {
return "";
}
match s {
Severity::Error => "✗",
Severity::Warning => "⚠",
}
}
}
fn paint(style: Style, text: &str) -> String {
format!("{}{}{}", style.render(), text, style.render_reset())
}
fn env_set(name: &str) -> bool {
std::env::var_os(name).is_some_and(|v| !v.is_empty() && v != "0")
}
fn color_on(color: ColorWhen, clicolor_force: bool, no_color: bool, is_tty: bool) -> bool {
match color {
ColorWhen::Never => false,
ColorWhen::Always => true,
ColorWhen::Auto => {
if clicolor_force {
true
} else if no_color {
false
} else {
is_tty
}
}
}
}
pub const SCHEMA_VERSION: u32 = 2;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum ColorWhen {
Auto,
Always,
Never,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum FailOn {
Error,
Warning,
Never,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum Format {
Human,
Json,
Github,
Sarif,
}
#[non_exhaustive]
#[derive(Debug, serde::Serialize)]
pub struct FileReport {
#[serde(rename = "file")]
pub name: String,
pub findings: Vec<Finding>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub fn lint_input(name: impl Into<String>, sql: &str, options: &crate::LintOptions) -> FileReport {
match crate::lint_sql(sql, options) {
Ok(findings) => FileReport {
name: name.into(),
findings,
error: None,
},
Err(e) => FileReport {
name: name.into(),
findings: Vec::new(),
error: Some(e.to_string()),
},
}
}
#[must_use]
pub fn gate(findings: &[Finding], fail_on: FailOn) -> bool {
let min = match fail_on {
FailOn::Never => return false,
FailOn::Warning => Severity::Warning,
FailOn::Error => Severity::Error,
};
findings
.iter()
.any(|f| !f.is_suppressed() && f.severity >= min)
}
#[derive(serde::Serialize)]
struct Report<'a> {
schema_version: u32,
files: &'a [FileReport],
}
#[must_use]
pub fn render_finding_human(file: &str, f: &Finding) -> String {
let suffix = match &f.suppression {
Some(s) => format!(" — suppressed: {}", s.reason),
None => String::new(),
};
let mut out = format!(
"{}: {} [{}] statement #{} (line {}, col {}){}\n",
file, f.severity, f.rule_id, f.statement_index, f.location.line, f.location.column, suffix
);
out.push_str(&format!(" {}\n", f.message));
if f.suppression.is_none() {
out.push_str(&format!(" fix: {}\n", f.guidance));
}
if !f.snippet.is_empty() {
for line in f.snippet.split('\n') {
out.push_str(&format!(" | {line}\n"));
}
}
out
}
fn statement_header(file: &str, location: Location, snippet: &str, st: &Styling) -> String {
let loc = st.muted(&format!("{file}:{}:{}", location.line, location.column));
if snippet.is_empty() {
format!("{loc}\n")
} else if snippet.contains('\n') {
let mut out = format!("{loc}\n");
for line in snippet.split('\n') {
out.push_str(&format!("{} {line}\n", st.muted(" |")));
}
out
} else {
format!("{loc} {snippet}\n")
}
}
#[must_use]
pub fn render_statement_header(file: &str, location: Location, snippet: &str) -> String {
statement_header(file, location, snippet, &Styling::plain())
}
fn finding_body(f: &Finding, st: &Styling) -> String {
if let Some(s) = &f.suppression {
let block = format!(
" {} [{}] — suppressed: {}\n {}\n",
f.severity, f.rule_id, s.reason, f.message
);
return st.muted(&block);
}
let glyph = st.glyph(f.severity);
let prefix = if glyph.is_empty() {
String::new()
} else {
format!("{} ", st.severity(f.severity, glyph))
};
let mut out = format!(
" {}{} [{}]\n",
prefix,
st.severity(f.severity, &f.severity.to_string()),
st.strong(&f.rule_id),
);
out.push_str(&format!(" {}\n", f.message));
out.push_str(&st.muted(&format!(" fix: {}\n", f.guidance)));
out
}
#[must_use]
pub fn render_finding_body(f: &Finding) -> String {
finding_body(f, &Styling::plain())
}
#[must_use]
pub fn render_human_styled(reports: &[FileReport], st: &Styling) -> String {
let mut out = String::new();
let mut first = true;
for r in reports {
let mut i = 0;
while i < r.findings.len() {
if !first {
out.push('\n');
}
first = false;
let head = &r.findings[i];
out.push_str(&statement_header(&r.name, head.location, &head.snippet, st));
let stmt = head.statement_index;
while i < r.findings.len() && r.findings[i].statement_index == stmt {
out.push_str(&finding_body(&r.findings[i], st));
i += 1;
}
}
}
out
}
#[must_use]
pub fn render_human(reports: &[FileReport]) -> String {
render_human_styled(reports, &Styling::plain())
}
fn tally(reports: &[FileReport]) -> (usize, usize, usize) {
let (mut errors, mut warnings, mut suppressed) = (0, 0, 0);
for r in reports {
for f in &r.findings {
if f.is_suppressed() {
suppressed += 1;
} else if f.severity == Severity::Error {
errors += 1;
} else {
warnings += 1;
}
}
}
(errors, warnings, suppressed)
}
fn count(n: usize, word: &str) -> String {
if n == 1 {
format!("{n} {word}")
} else {
format!("{n} {word}s")
}
}
#[must_use]
pub fn render_summary(reports: &[FileReport], st: &Styling) -> Option<String> {
let (errors, warnings, suppressed) = tally(reports);
if errors == 0 && warnings == 0 && suppressed == 0 {
return None;
}
let mut clauses = Vec::new();
if errors > 0 {
clauses.push(st.danger(&count(errors, "error")));
}
if warnings > 0 {
clauses.push(st.warning(&count(warnings, "warning")));
}
let supp = st.muted(&format!("{suppressed} suppressed"));
let files = count(reports.len(), "file");
let body = if clauses.is_empty() {
format!("{supp} in {files}")
} else {
let mut b = clauses.join(", ");
if suppressed > 0 {
b.push_str(&format!(" ({supp})"));
}
format!("{b} in {files}")
};
Some(format!("Summary: {body}"))
}
#[must_use]
pub fn render_errors(reports: &[FileReport]) -> String {
let mut out = String::new();
for r in reports {
if let Some(err) = &r.error {
out.push_str(&format!("{}: {}\n", r.name, err));
}
}
out
}
pub fn render_json(reports: &[FileReport]) -> Result<String, String> {
let report = Report {
schema_version: SCHEMA_VERSION,
files: reports,
};
serde_json::to_string_pretty(&report)
.map_err(|e| format!("failed to serialize JSON output: {e}"))
}
fn gh_escape_data(s: &str) -> String {
s.replace('%', "%25")
.replace('\r', "%0D")
.replace('\n', "%0A")
}
fn gh_escape_prop(s: &str) -> String {
gh_escape_data(s).replace(',', "%2C").replace(':', "%3A")
}
#[must_use]
pub fn render_github(reports: &[FileReport]) -> String {
let mut out = String::new();
for r in reports {
if let Some(err) = &r.error {
out.push_str(&format!(
"::error file={}::{}\n",
gh_escape_prop(&r.name),
gh_escape_data(err)
));
continue;
}
for f in &r.findings {
if f.is_suppressed() {
continue;
}
let level = match f.severity {
Severity::Error => "error",
Severity::Warning => "warning",
};
out.push_str(&format!(
"::{level} file={},line={},col={},title={}::{}\n",
gh_escape_prop(&r.name),
f.location.line,
f.location.column,
gh_escape_prop(&format!("pgsafe({})", f.rule_id)),
gh_escape_data(&f.message),
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn findings(sql: &str) -> Vec<Finding> {
crate::lint_sql(sql, &crate::LintOptions::default()).unwrap()
}
#[test]
fn lint_input_reports_findings_for_valid_sql() {
let r = lint_input(
"m.sql",
"CREATE INDEX i ON t (x);",
&crate::LintOptions::default(),
);
assert_eq!(r.name, "m.sql");
assert!(r.error.is_none());
assert!(r
.findings
.iter()
.any(|f| f.rule_id == "add-index-non-concurrent"));
}
#[test]
fn lint_input_captures_parse_errors() {
let r = lint_input("bad.sql", "ALTER TABLE;", &crate::LintOptions::default());
assert!(r.findings.is_empty());
assert!(r.error.as_deref().unwrap().contains("parse error"));
}
#[test]
fn never_never_gates() {
assert!(!gate(&findings("VACUUM FULL t;"), FailOn::Never));
}
#[test]
fn warning_gates_on_a_warning() {
assert!(gate(&findings("DROP TABLE x;"), FailOn::Warning));
}
#[test]
fn error_does_not_gate_on_a_warning_but_does_on_an_error() {
assert!(!gate(&findings("DROP TABLE x;"), FailOn::Error));
assert!(gate(&findings("VACUUM FULL t;"), FailOn::Error));
}
#[test]
fn no_findings_never_gates() {
assert!(!gate(
&findings("CREATE INDEX CONCURRENTLY i ON t (x);"),
FailOn::Warning
));
}
#[test]
fn render_finding_human_has_id_severity_and_fix() {
let f = &lint_input("m.sql", "VACUUM FULL t;", &crate::LintOptions::default()).findings[0];
let s = render_finding_human("m.sql", f);
assert!(s.contains("error [vacuum-full-cluster]"));
assert!(s.contains(" fix: "));
}
#[test]
fn render_finding_human_gutters_each_line_of_a_multiline_snippet() {
let f = &lint_input(
"m.sql",
"CREATE INDEX i\n ON t (x);",
&crate::LintOptions::default(),
)
.findings[0];
let s = render_finding_human("m.sql", f);
assert!(
s.contains(" | ON t (x)\n"),
"continuation must be guttered:\n{s}"
);
assert!(
!s.contains("\n ON t (x)\n"),
"continuation must not fall to column 0:\n{s}"
);
}
#[test]
fn render_finding_human_keeps_a_single_line_snippet_inline() {
let f = &lint_input("m.sql", "VACUUM FULL t;", &crate::LintOptions::default()).findings[0];
let s = render_finding_human("m.sql", f);
assert!(s.contains(" | VACUUM FULL t\n"), "{s}");
}
#[test]
fn render_human_groups_findings_by_statement() {
let reports = vec![lint_input(
"m.sql",
"DROP TABLE users;\nCREATE INDEX i ON t (x);",
&crate::LintOptions::default(),
)];
let s = render_human(&reports);
assert_eq!(
s.matches("m.sql:").count(),
2,
"one header per statement:\n{s}"
);
assert!(s.contains("m.sql:1:1 DROP TABLE users\n"));
assert!(s.contains("m.sql:2:1 CREATE INDEX i ON t (x)\n"));
assert!(s.contains("\n warning [drop-table]\n "));
assert!(s.contains("\n error [add-index-non-concurrent]\n "));
assert!(
s.contains("\n\nm.sql:2:1"),
"blank line between groups:\n{s}"
);
}
#[test]
fn a_multiline_statement_renders_as_an_indented_gutter_block() {
let reports = vec![lint_input(
"m.sql",
"CREATE INDEX i\n ON t (x);",
&crate::LintOptions::default(),
)];
let s = render_human(&reports);
assert!(
s.contains("m.sql:1:1\n"),
"location should stand alone:\n{s}"
);
assert!(
s.contains(" | CREATE INDEX i\n"),
"first line in gutter:\n{s}"
);
assert!(
s.contains(" | ON t (x)\n"),
"continuation preserved in gutter:\n{s}"
);
assert!(
!s.contains("\n ON t (x)\n"),
"continuation must not fall to column 0:\n{s}"
);
}
#[test]
fn a_single_line_statement_stays_inline_with_the_location() {
let reports = vec![lint_input(
"m.sql",
"DROP TABLE users;",
&crate::LintOptions::default(),
)];
let s = render_human(&reports);
assert!(s.contains("m.sql:1:1 DROP TABLE users\n"), "{s}");
assert!(
!s.contains(" | DROP TABLE"),
"single-line must not use the gutter:\n{s}"
);
}
#[test]
fn render_json_is_the_versioned_envelope() {
let reports = vec![lint_input(
"<stdin>",
"CREATE INDEX i ON t (x);",
&crate::LintOptions::default(),
)];
let s = render_json(&reports).unwrap();
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["schema_version"], 2);
assert_eq!(v["files"][0]["file"], "<stdin>");
assert_eq!(
v["files"][0]["findings"][0]["rule_id"],
"add-index-non-concurrent"
);
}
#[test]
fn render_errors_lists_parse_failures_only() {
let reports = vec![
lint_input(
"ok.sql",
"CREATE INDEX CONCURRENTLY i ON t (x);",
&crate::LintOptions::default(),
),
lint_input("bad.sql", "ALTER TABLE;", &crate::LintOptions::default()),
];
let s = render_errors(&reports);
assert!(s.contains("bad.sql: parse error"));
assert!(!s.contains("ok.sql"));
}
#[test]
fn gh_escape_helpers_encode_special_chars() {
assert_eq!(super::gh_escape_data("a%b\nc\rd"), "a%25b%0Ac%0Dd");
assert_eq!(super::gh_escape_prop("x,y:z"), "x%2Cy%3Az");
}
#[test]
fn render_github_emits_severity_keyed_annotations() {
let reports = vec![lint_input(
"m.sql",
"VACUUM FULL t;",
&crate::LintOptions::default(),
)];
let s = render_github(&reports);
assert!(s.contains("::error file=m.sql,line=1,col=1,title=pgsafe(vacuum-full-cluster)::"));
assert!(s.contains("::warning file=m.sql,line=1,col=1,title=pgsafe(require-timeout)::"));
}
#[test]
fn render_github_skips_suppressed_findings() {
let sql = "-- pgsafe:ignore vacuum-full-cluster reviewed\nVACUUM FULL t;";
let reports = vec![lint_input("m.sql", sql, &crate::LintOptions::default())];
let s = render_github(&reports);
assert!(!s.contains("title=pgsafe(vacuum-full-cluster)"));
assert!(s.contains("title=pgsafe(require-timeout)"));
}
#[test]
fn render_github_annotates_a_parse_error() {
let reports = vec![lint_input(
"bad.sql",
"ALTER TABLE;",
&crate::LintOptions::default(),
)];
let s = render_github(&reports);
assert!(s.starts_with("::error file=bad.sql::"), "got: {s}");
assert!(s.contains("parse error"));
}
#[test]
fn ansi_paints_escapes_plain_does_not() {
let ansi = Styling::ansi();
let plain = Styling::plain();
assert!(ansi.danger("error").contains('\u{1b}'));
assert_eq!(plain.danger("error"), "error");
assert_eq!(ansi.glyph(Severity::Error), "✗");
assert_eq!(ansi.glyph(Severity::Warning), "⚠");
assert_eq!(plain.glyph(Severity::Error), "");
}
#[test]
fn role_painters_use_expected_sgr() {
let a = Styling::ansi();
assert!(a.danger("x").contains("\u{1b}[1m") && a.danger("x").contains("\u{1b}[31m"));
assert!(a.warning("x").contains("\u{1b}[33m"));
assert!(a.success("x").contains("\u{1b}[32m"));
assert!(a.accent("x").contains("\u{1b}[36m"));
assert!(a.muted("x").contains("\u{1b}[2m"));
assert!(a.strong("x").contains("\u{1b}[1m"));
let p = Styling::plain();
for painted in [
p.danger("x"),
p.warning("x"),
p.success("x"),
p.accent("x"),
p.muted("x"),
p.strong("x"),
] {
assert_eq!(painted, "x");
}
}
#[test]
fn summary_counts_are_bold_in_ansi() {
let reports = vec![lint_input(
"m.sql",
"VACUUM FULL t;",
&crate::LintOptions::default(),
)];
let s = render_summary(&reports, &Styling::ansi()).unwrap();
assert!(
s.contains("\u{1b}[1m"),
"summary counts should be bold: {s}"
);
}
#[test]
fn plain_render_is_byte_identical_and_escape_free() {
let reports = vec![lint_input(
"m.sql",
"DROP TABLE users;\nVACUUM FULL t;",
&crate::LintOptions::default(),
)];
let s = render_human(&reports);
assert!(
!s.contains('\u{1b}'),
"plain output must have no escapes:\n{s}"
);
assert!(
!s.contains('✗') && !s.contains('⚠'),
"plain output must have no glyphs"
);
assert_eq!(s, render_human_styled(&reports, &Styling::plain()));
assert!(s.contains("m.sql:1:1 DROP TABLE users\n"));
assert!(s.contains("\n warning [drop-table]\n "));
}
#[test]
fn ansi_render_adds_escapes_and_severity_glyphs() {
let reports = vec![lint_input(
"m.sql",
"VACUUM FULL t;",
&crate::LintOptions::default(),
)];
let s = render_human_styled(&reports, &Styling::ansi());
assert!(s.contains('\u{1b}'), "ansi output must contain escapes");
assert!(s.contains('✗'), "vacuum-full-cluster is an error → ✗");
assert!(s.contains('⚠'), "require-timeout is a warning → ⚠");
}
#[test]
fn summary_is_none_on_a_clean_run() {
let reports = vec![lint_input(
"ok.sql",
"CREATE INDEX CONCURRENTLY i ON t (x);",
&crate::LintOptions::default(),
)];
assert!(render_summary(&reports, &Styling::plain()).is_none());
}
#[test]
fn summary_counts_and_pluralizes() {
let reports = vec![lint_input(
"m.sql",
"VACUUM FULL t;",
&crate::LintOptions::default(),
)];
assert_eq!(
render_summary(&reports, &Styling::plain()).unwrap(),
"Summary: 1 error, 1 warning in 1 file"
);
}
#[test]
fn summary_parenthesizes_suppressed_and_colors_in_ansi() {
let sql = "-- pgsafe:ignore drop-table cleanup\nDROP TABLE x;\nVACUUM FULL t;";
let reports = vec![lint_input("m.sql", sql, &crate::LintOptions::default())];
let plain = render_summary(&reports, &Styling::plain()).unwrap();
assert!(plain.starts_with("Summary: 1 error, "), "{plain}");
assert!(plain.contains("(1 suppressed)"), "{plain}");
assert!(plain.ends_with(" in 1 file"), "{plain}");
assert!(render_summary(&reports, &Styling::ansi())
.unwrap()
.contains('\u{1b}'));
}
#[test]
fn summary_counts_pluralize_at_two() {
let reports = vec![
lint_input("a.sql", "VACUUM FULL t;", &crate::LintOptions::default()),
lint_input("b.sql", "VACUUM FULL t;", &crate::LintOptions::default()),
];
assert_eq!(
render_summary(&reports, &Styling::plain()).unwrap(),
"Summary: 2 errors, 2 warnings in 2 files"
);
}
#[test]
fn summary_stands_alone_when_only_suppressed_findings_exist() {
let sql = "-- pgsafe:ignore drop-table cleanup\n\
-- pgsafe:ignore require-timeout reviewed\n\
DROP TABLE x;";
let reports = vec![lint_input("m.sql", sql, &crate::LintOptions::default())];
assert!(
reports[0].findings.iter().all(Finding::is_suppressed),
"fixture must have zero unsuppressed findings: {:?}",
reports[0].findings
);
assert_eq!(
render_summary(&reports, &Styling::plain()).unwrap(),
"Summary: 2 suppressed in 1 file"
);
}
#[test]
fn color_on_follows_precedence() {
use ColorWhen::*;
assert!(!color_on(Never, true, false, true));
assert!(color_on(Always, false, true, false));
assert!(color_on(Auto, true, true, false));
assert!(!color_on(Auto, false, true, true));
assert!(color_on(Auto, false, false, true));
assert!(!color_on(Auto, false, false, false));
}
#[test]
fn resolve_always_colors_never_plain_regardless_of_env() {
assert!(Styling::resolve(ColorWhen::Always)
.danger("x")
.contains('\u{1b}'));
assert_eq!(Styling::resolve(ColorWhen::Never).danger("x"), "x");
}
}