use flowscope_core::{linter::config::canonicalize_rule_code, Severity};
use owo_colors::OwoColorize;
use std::fmt::Write;
use std::time::Duration;
pub struct FileLintResult {
pub name: String,
pub sql: String,
pub issues: Vec<LintIssue>,
}
pub struct LintIssue {
pub line: usize,
pub col: usize,
pub code: String,
pub message: String,
pub severity: Severity,
}
pub fn offset_to_line_col(sql: &str, offset: usize) -> (usize, usize) {
let offset = offset.min(sql.len());
let mut line = 1usize;
let mut col = 1usize;
for (i, ch) in sql.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
pub fn format_lint_results(results: &[FileLintResult], colored: bool, elapsed: Duration) -> String {
let mut out = String::new();
let mut total_pass = 0usize;
let mut total_fail = 0usize;
let mut total_violations = 0usize;
for file in results {
let has_issues = !file.issues.is_empty();
if has_issues {
total_fail += 1;
total_violations += file.issues.len();
} else {
total_pass += 1;
}
write_file_section(&mut out, file, colored);
}
write_summary(
&mut out,
total_pass,
total_fail,
total_violations,
colored,
elapsed,
);
out
}
fn write_file_section(out: &mut String, file: &FileLintResult, colored: bool) {
let status = if file.issues.is_empty() {
if colored {
"PASS".green().to_string()
} else {
"PASS".to_string()
}
} else if colored {
"FAIL".red().to_string()
} else {
"FAIL".to_string()
};
writeln!(out, "== [{}] {}", file.name, status).unwrap();
let mut sorted: Vec<&LintIssue> = file.issues.iter().collect();
sorted.sort_by_key(|i| (i.line, i.col));
for issue in sorted {
let display_code = sqlfluff_display_code(&issue.code);
let code_str = if colored {
match issue.severity {
Severity::Error => display_code.red().to_string(),
Severity::Warning => display_code.yellow().to_string(),
Severity::Info => display_code.blue().to_string(),
}
} else {
display_code
};
writeln!(
out,
"L:{:>4} | P:{:>4} | {} | {}",
issue.line, issue.col, code_str, issue.message
)
.unwrap();
}
}
fn sqlfluff_display_code(code: &str) -> String {
let Some(canonical) = canonicalize_rule_code(code) else {
return code.to_string();
};
let Some(suffix) = canonical.strip_prefix("LINT_") else {
return code.to_string();
};
let Some((group, number)) = suffix.split_once('_') else {
return code.to_string();
};
if group.len() != 2 || !group.chars().all(|ch| ch.is_ascii_alphabetic()) {
return code.to_string();
}
let Ok(number) = number.parse::<usize>() else {
return code.to_string();
};
if number == 0 {
return code.to_string();
}
if number >= 100 {
format!("{group}{number:03}")
} else {
format!("{group}{number:02}")
}
}
fn write_summary(
out: &mut String,
pass: usize,
fail: usize,
violations: usize,
colored: bool,
elapsed: Duration,
) {
writeln!(out, "All Finished in {}!", format_elapsed(elapsed)).unwrap();
let summary = format!(
" {} passed. {} failed. {} violations found.",
pass_str(pass, colored),
fail_str(fail, colored),
violations
);
writeln!(out, "{summary}").unwrap();
}
fn format_elapsed(elapsed: Duration) -> String {
let secs = elapsed.as_secs_f64();
if secs >= 1.0 {
format!("{secs:.2}s")
} else if elapsed.as_millis() >= 1 {
format!("{}ms", elapsed.as_millis())
} else {
format!("{}us", elapsed.as_micros())
}
}
fn pass_str(count: usize, colored: bool) -> String {
let s = format!("{count} file{}", if count == 1 { "" } else { "s" });
if colored && count > 0 {
s.green().to_string()
} else {
s
}
}
fn fail_str(count: usize, colored: bool) -> String {
let s = format!("{count} file{}", if count == 1 { "" } else { "s" });
if colored && count > 0 {
s.red().to_string()
} else {
s
}
}
pub fn format_lint_json(results: &[FileLintResult], compact: bool) -> String {
let json_results: Vec<serde_json::Value> = results
.iter()
.map(|file| {
let violations: Vec<serde_json::Value> = file
.issues
.iter()
.map(|issue| {
serde_json::json!({
"line": issue.line,
"column": issue.col,
"code": sqlfluff_display_code(&issue.code),
"message": issue.message,
"severity": match issue.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
}
})
})
.collect();
serde_json::json!({
"file": file.name,
"violations": violations
})
})
.collect();
if compact {
serde_json::to_string(&json_results).unwrap_or_default()
} else {
serde_json::to_string_pretty(&json_results).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offset_to_line_col_start() {
assert_eq!(offset_to_line_col("SELECT 1", 0), (1, 1));
}
#[test]
fn test_offset_to_line_col_same_line() {
assert_eq!(offset_to_line_col("SELECT 1", 7), (1, 8));
}
#[test]
fn test_offset_to_line_col_second_line() {
let sql = "SELECT 1\nFROM t";
assert_eq!(offset_to_line_col(sql, 9), (2, 1));
}
#[test]
fn test_offset_to_line_col_mid_second_line() {
let sql = "SELECT 1\nFROM t";
assert_eq!(offset_to_line_col(sql, 14), (2, 6));
}
#[test]
fn test_offset_to_line_col_past_end() {
let sql = "SELECT 1";
assert_eq!(offset_to_line_col(sql, 100), (1, 9));
}
#[test]
fn test_offset_to_line_col_utf8_chars() {
let sql = "SELECT 'é' UNION SELECT 1";
let union_offset = sql.find("UNION").expect("UNION position");
assert_eq!(offset_to_line_col(sql, union_offset), (1, 12));
}
#[test]
fn test_format_lint_pass() {
let results = vec![FileLintResult {
name: "clean.sql".to_string(),
sql: String::new(),
issues: vec![],
}];
let output = format_lint_results(&results, false, Duration::from_millis(250));
assert!(output.contains("PASS"));
assert!(output.contains("All Finished in 250ms!"));
assert!(output.contains("clean.sql"));
assert!(output.contains("1 file passed"));
assert!(output.contains("0 files failed"));
assert!(output.contains("0 violations"));
}
#[test]
fn test_format_lint_fail() {
let results = vec![FileLintResult {
name: "bad.sql".to_string(),
sql: String::new(),
issues: vec![
LintIssue {
line: 3,
col: 12,
code: "LINT_AM_007".to_string(),
message: "Use UNION DISTINCT or UNION ALL instead of bare UNION.".to_string(),
severity: Severity::Info,
},
LintIssue {
line: 7,
col: 1,
code: "LINT_ST_006".to_string(),
message: "CTE 'unused' is defined but never referenced.".to_string(),
severity: Severity::Info,
},
],
}];
let output = format_lint_results(&results, false, Duration::from_secs_f64(1.5));
assert!(output.contains("FAIL"));
assert!(output.contains("All Finished in 1.50s!"));
assert!(output.contains("bad.sql"));
assert!(output.contains("AM07"));
assert!(output.contains("ST06"));
assert!(output.contains("L: 3 | P: 12"));
assert!(output.contains("L: 7 | P: 1"));
assert!(output.contains("2 violations"));
}
#[test]
fn test_sqlfluff_display_code() {
assert_eq!(sqlfluff_display_code("LINT_AM_007"), "AM07");
assert_eq!(sqlfluff_display_code("lt5"), "LT05");
assert_eq!(sqlfluff_display_code("PARSE_ERROR"), "PARSE_ERROR");
}
#[test]
fn test_summary_formatting() {
let results = vec![
FileLintResult {
name: "a.sql".to_string(),
sql: String::new(),
issues: vec![],
},
FileLintResult {
name: "b.sql".to_string(),
sql: String::new(),
issues: vec![LintIssue {
line: 1,
col: 1,
code: "LINT_AM_007".to_string(),
message: "test".to_string(),
severity: Severity::Info,
}],
},
];
let output = format_lint_results(&results, false, Duration::from_micros(700));
assert!(output.contains("All Finished in 700us!"));
assert!(output.contains("1 file passed"));
assert!(output.contains("1 file failed"));
assert!(output.contains("1 violations"));
}
#[test]
fn test_format_lint_json() {
let results = vec![FileLintResult {
name: "test.sql".to_string(),
sql: String::new(),
issues: vec![LintIssue {
line: 1,
col: 8,
code: "LINT_AM_007".to_string(),
message: "Use UNION DISTINCT or UNION ALL.".to_string(),
severity: Severity::Info,
}],
}];
let json = format_lint_json(&results, false);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["file"], "test.sql");
assert_eq!(arr[0]["violations"][0]["code"], "AM07");
assert_eq!(arr[0]["violations"][0]["line"], 1);
assert_eq!(arr[0]["violations"][0]["column"], 8);
}
}