Skip to main content

flowscope_cli/output/
lint.rs

1//! Lint output formatting (sqlfluff-style).
2
3use flowscope_core::{linter::config::canonicalize_rule_code, Severity};
4use owo_colors::OwoColorize;
5use std::fmt::Write;
6use std::time::Duration;
7
8/// Per-file lint result used by the formatter.
9pub struct FileLintResult {
10    pub name: String,
11    pub sql: String,
12    pub issues: Vec<LintIssue>,
13}
14
15/// A lint issue resolved to line:col.
16pub struct LintIssue {
17    pub line: usize,
18    pub col: usize,
19    pub code: String,
20    pub message: String,
21    pub severity: Severity,
22}
23
24/// Convert a byte offset into a 1-based (line, col) pair.
25pub 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
45/// Format lint results as human-readable sqlfluff-style text.
46pub 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    // Sort issues by line, then column
94    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
198/// Format lint results as JSON.
199pub 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        // offset 9 = 'F' on second line
253        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        // offset 14 = 't' on second line
260        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}