1use flowscope_core::{linter::config::canonicalize_rule_code, Severity};
4use owo_colors::OwoColorize;
5use std::fmt::Write;
6use std::time::Duration;
7
8pub struct FileLintResult {
10 pub name: String,
11 pub sql: String,
12 pub issues: Vec<LintIssue>,
13}
14
15pub struct LintIssue {
17 pub line: usize,
18 pub col: usize,
19 pub code: String,
20 pub message: String,
21 pub severity: Severity,
22}
23
24pub fn offset_to_line_col(sql: &str, offset: usize) -> (usize, usize) {
26 let offset = offset.min(sql.len());
27 let mut line = 1usize;
28 let mut col = 1usize;
29
30 for (i, ch) in sql.char_indices() {
31 if i >= offset {
32 break;
33 }
34 if ch == '\n' {
35 line += 1;
36 col = 1;
37 } else {
38 col += 1;
39 }
40 }
41
42 (line, col)
43}
44
45pub fn format_lint_results(results: &[FileLintResult], colored: bool, elapsed: Duration) -> String {
47 let mut out = String::new();
48
49 let mut total_pass = 0usize;
50 let mut total_fail = 0usize;
51 let mut total_violations = 0usize;
52
53 for file in results {
54 let has_issues = !file.issues.is_empty();
55
56 if has_issues {
57 total_fail += 1;
58 total_violations += file.issues.len();
59 } else {
60 total_pass += 1;
61 }
62
63 write_file_section(&mut out, file, colored);
64 }
65
66 write_summary(
67 &mut out,
68 total_pass,
69 total_fail,
70 total_violations,
71 colored,
72 elapsed,
73 );
74
75 out
76}
77
78fn write_file_section(out: &mut String, file: &FileLintResult, colored: bool) {
79 let status = if file.issues.is_empty() {
80 if colored {
81 "PASS".green().to_string()
82 } else {
83 "PASS".to_string()
84 }
85 } else if colored {
86 "FAIL".red().to_string()
87 } else {
88 "FAIL".to_string()
89 };
90
91 writeln!(out, "== [{}] {}", file.name, status).unwrap();
92
93 let mut sorted: Vec<&LintIssue> = file.issues.iter().collect();
95 sorted.sort_by_key(|i| (i.line, i.col));
96
97 for issue in sorted {
98 let display_code = sqlfluff_display_code(&issue.code);
99 let code_str = if colored {
100 match issue.severity {
101 Severity::Error => display_code.red().to_string(),
102 Severity::Warning => display_code.yellow().to_string(),
103 Severity::Info => display_code.blue().to_string(),
104 }
105 } else {
106 display_code
107 };
108
109 writeln!(
110 out,
111 "L:{:>4} | P:{:>4} | {} | {}",
112 issue.line, issue.col, code_str, issue.message
113 )
114 .unwrap();
115 }
116}
117
118fn sqlfluff_display_code(code: &str) -> String {
119 let Some(canonical) = canonicalize_rule_code(code) else {
120 return code.to_string();
121 };
122
123 let Some(suffix) = canonical.strip_prefix("LINT_") else {
124 return code.to_string();
125 };
126
127 let Some((group, number)) = suffix.split_once('_') else {
128 return code.to_string();
129 };
130
131 if group.len() != 2 || !group.chars().all(|ch| ch.is_ascii_alphabetic()) {
132 return code.to_string();
133 }
134
135 let Ok(number) = number.parse::<usize>() else {
136 return code.to_string();
137 };
138
139 if number == 0 {
140 return code.to_string();
141 }
142
143 if number >= 100 {
144 format!("{group}{number:03}")
145 } else {
146 format!("{group}{number:02}")
147 }
148}
149
150fn write_summary(
151 out: &mut String,
152 pass: usize,
153 fail: usize,
154 violations: usize,
155 colored: bool,
156 elapsed: Duration,
157) {
158 writeln!(out, "All Finished in {}!", format_elapsed(elapsed)).unwrap();
159
160 let summary = format!(
161 " {} passed. {} failed. {} violations found.",
162 pass_str(pass, colored),
163 fail_str(fail, colored),
164 violations
165 );
166 writeln!(out, "{summary}").unwrap();
167}
168
169fn format_elapsed(elapsed: Duration) -> String {
170 let secs = elapsed.as_secs_f64();
171 if secs >= 1.0 {
172 format!("{secs:.2}s")
173 } else if elapsed.as_millis() >= 1 {
174 format!("{}ms", elapsed.as_millis())
175 } else {
176 format!("{}us", elapsed.as_micros())
177 }
178}
179
180fn pass_str(count: usize, colored: bool) -> String {
181 let s = format!("{count} file{}", if count == 1 { "" } else { "s" });
182 if colored && count > 0 {
183 s.green().to_string()
184 } else {
185 s
186 }
187}
188
189fn fail_str(count: usize, colored: bool) -> String {
190 let s = format!("{count} file{}", if count == 1 { "" } else { "s" });
191 if colored && count > 0 {
192 s.red().to_string()
193 } else {
194 s
195 }
196}
197
198pub fn format_lint_json(results: &[FileLintResult], compact: bool) -> String {
200 let json_results: Vec<serde_json::Value> = results
201 .iter()
202 .map(|file| {
203 let violations: Vec<serde_json::Value> = file
204 .issues
205 .iter()
206 .map(|issue| {
207 serde_json::json!({
208 "line": issue.line,
209 "column": issue.col,
210 "code": sqlfluff_display_code(&issue.code),
211 "message": issue.message,
212 "severity": match issue.severity {
213 Severity::Error => "error",
214 Severity::Warning => "warning",
215 Severity::Info => "info",
216 }
217 })
218 })
219 .collect();
220
221 serde_json::json!({
222 "file": file.name,
223 "violations": violations
224 })
225 })
226 .collect();
227
228 if compact {
229 serde_json::to_string(&json_results).unwrap_or_default()
230 } else {
231 serde_json::to_string_pretty(&json_results).unwrap_or_default()
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn test_offset_to_line_col_start() {
241 assert_eq!(offset_to_line_col("SELECT 1", 0), (1, 1));
242 }
243
244 #[test]
245 fn test_offset_to_line_col_same_line() {
246 assert_eq!(offset_to_line_col("SELECT 1", 7), (1, 8));
247 }
248
249 #[test]
250 fn test_offset_to_line_col_second_line() {
251 let sql = "SELECT 1\nFROM t";
252 assert_eq!(offset_to_line_col(sql, 9), (2, 1));
254 }
255
256 #[test]
257 fn test_offset_to_line_col_mid_second_line() {
258 let sql = "SELECT 1\nFROM t";
259 assert_eq!(offset_to_line_col(sql, 14), (2, 6));
261 }
262
263 #[test]
264 fn test_offset_to_line_col_past_end() {
265 let sql = "SELECT 1";
266 assert_eq!(offset_to_line_col(sql, 100), (1, 9));
267 }
268
269 #[test]
270 fn test_offset_to_line_col_utf8_chars() {
271 let sql = "SELECT 'é' UNION SELECT 1";
272 let union_offset = sql.find("UNION").expect("UNION position");
273 assert_eq!(offset_to_line_col(sql, union_offset), (1, 12));
274 }
275
276 #[test]
277 fn test_format_lint_pass() {
278 let results = vec![FileLintResult {
279 name: "clean.sql".to_string(),
280 sql: String::new(),
281 issues: vec![],
282 }];
283
284 let output = format_lint_results(&results, false, Duration::from_millis(250));
285 assert!(output.contains("PASS"));
286 assert!(output.contains("All Finished in 250ms!"));
287 assert!(output.contains("clean.sql"));
288 assert!(output.contains("1 file passed"));
289 assert!(output.contains("0 files failed"));
290 assert!(output.contains("0 violations"));
291 }
292
293 #[test]
294 fn test_format_lint_fail() {
295 let results = vec![FileLintResult {
296 name: "bad.sql".to_string(),
297 sql: String::new(),
298 issues: vec![
299 LintIssue {
300 line: 3,
301 col: 12,
302 code: "LINT_AM_007".to_string(),
303 message: "Use UNION DISTINCT or UNION ALL instead of bare UNION.".to_string(),
304 severity: Severity::Info,
305 },
306 LintIssue {
307 line: 7,
308 col: 1,
309 code: "LINT_ST_006".to_string(),
310 message: "CTE 'unused' is defined but never referenced.".to_string(),
311 severity: Severity::Info,
312 },
313 ],
314 }];
315
316 let output = format_lint_results(&results, false, Duration::from_secs_f64(1.5));
317 assert!(output.contains("FAIL"));
318 assert!(output.contains("All Finished in 1.50s!"));
319 assert!(output.contains("bad.sql"));
320 assert!(output.contains("AM07"));
321 assert!(output.contains("ST06"));
322 assert!(output.contains("L: 3 | P: 12"));
323 assert!(output.contains("L: 7 | P: 1"));
324 assert!(output.contains("2 violations"));
325 }
326
327 #[test]
328 fn test_sqlfluff_display_code() {
329 assert_eq!(sqlfluff_display_code("LINT_AM_007"), "AM07");
330 assert_eq!(sqlfluff_display_code("lt5"), "LT05");
331 assert_eq!(sqlfluff_display_code("PARSE_ERROR"), "PARSE_ERROR");
332 }
333
334 #[test]
335 fn test_summary_formatting() {
336 let results = vec![
337 FileLintResult {
338 name: "a.sql".to_string(),
339 sql: String::new(),
340 issues: vec![],
341 },
342 FileLintResult {
343 name: "b.sql".to_string(),
344 sql: String::new(),
345 issues: vec![LintIssue {
346 line: 1,
347 col: 1,
348 code: "LINT_AM_007".to_string(),
349 message: "test".to_string(),
350 severity: Severity::Info,
351 }],
352 },
353 ];
354
355 let output = format_lint_results(&results, false, Duration::from_micros(700));
356 assert!(output.contains("All Finished in 700us!"));
357 assert!(output.contains("1 file passed"));
358 assert!(output.contains("1 file failed"));
359 assert!(output.contains("1 violations"));
360 }
361
362 #[test]
363 fn test_format_lint_json() {
364 let results = vec![FileLintResult {
365 name: "test.sql".to_string(),
366 sql: String::new(),
367 issues: vec![LintIssue {
368 line: 1,
369 col: 8,
370 code: "LINT_AM_007".to_string(),
371 message: "Use UNION DISTINCT or UNION ALL.".to_string(),
372 severity: Severity::Info,
373 }],
374 }];
375
376 let json = format_lint_json(&results, false);
377 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
378 let arr = parsed.as_array().unwrap();
379 assert_eq!(arr.len(), 1);
380 assert_eq!(arr[0]["file"], "test.sql");
381 assert_eq!(arr[0]["violations"][0]["code"], "AM07");
382 assert_eq!(arr[0]["violations"][0]["line"], 1);
383 assert_eq!(arr[0]["violations"][0]["column"], 8);
384 }
385}