sqruff_lib/cli/
formatters.rs

1use super::utils::*;
2use std::borrow::Cow;
3use std::io::{Stderr, Write};
4use std::sync::atomic::{AtomicBool, AtomicUsize};
5
6use anstyle::{AnsiColor, Effects, Style};
7use itertools::enumerate;
8use sqruff_lib_core::errors::SQLBaseError;
9
10use crate::core::config::FluffConfig;
11use crate::core::linter::linted_file::LintedFile;
12
13const LIGHT_GREY: Style = AnsiColor::Black.on_default().effects(Effects::BOLD);
14
15pub trait Formatter: Send + Sync {
16    fn dispatch_template_header(
17        &self,
18        f_name: String,
19        linter_config: FluffConfig,
20        file_config: FluffConfig,
21    );
22
23    fn dispatch_parse_header(&self, f_name: String);
24
25    fn dispatch_file_violations(&self, linted_file: &LintedFile, only_fixable: bool);
26
27    fn has_fail(&self) -> bool;
28
29    fn completion_message(&self);
30}
31
32pub struct OutputStreamFormatter {
33    output_stream: Option<Stderr>,
34    plain_output: bool,
35    filter_empty: bool,
36    verbosity: i32,
37    output_line_length: usize,
38    pub has_fail: AtomicBool,
39    files_dispatched: AtomicUsize,
40}
41
42impl Formatter for OutputStreamFormatter {
43    fn dispatch_file_violations(&self, linted_file: &LintedFile, only_fixable: bool) {
44        if self.verbosity < 0 {
45            return;
46        }
47
48        let s = self.format_file_violations(
49            &linted_file.path,
50            linted_file.get_violations(only_fixable.then_some(true)),
51        );
52
53        self.dispatch(&s);
54        self.files_dispatched
55            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
56    }
57
58    fn has_fail(&self) -> bool {
59        self.has_fail.load(std::sync::atomic::Ordering::SeqCst)
60    }
61
62    fn completion_message(&self) {
63        let count = self
64            .files_dispatched
65            .load(std::sync::atomic::Ordering::SeqCst);
66        let message = format!("The linter processed {count} file(s).\n");
67        self.dispatch(&message);
68
69        let message = if self.plain_output {
70            "All Finished\n"
71        } else {
72            "All Finished 📜 🎉\n"
73        };
74        self.dispatch(message);
75    }
76    fn dispatch_template_header(
77        &self,
78        _f_name: String,
79        _linter_config: FluffConfig,
80        _file_config: FluffConfig,
81    ) {
82    }
83
84    fn dispatch_parse_header(&self, _f_name: String) {}
85}
86
87impl OutputStreamFormatter {
88    pub fn new(output_stream: Option<Stderr>, nocolor: bool, verbosity: i32) -> Self {
89        Self {
90            output_stream,
91            plain_output: should_produce_plain_output(nocolor),
92            filter_empty: true,
93            verbosity,
94            output_line_length: 80,
95            has_fail: false.into(),
96            files_dispatched: 0.into(),
97        }
98    }
99
100    fn dispatch(&self, s: &str) {
101        if !self.filter_empty || !s.trim().is_empty() {
102            if let Some(output_stream) = &self.output_stream {
103                _ = output_stream.lock().write(s.as_bytes()).unwrap();
104            }
105        }
106    }
107
108    fn format_file_violations(&self, fname: &str, mut violations: Vec<SQLBaseError>) -> String {
109        let mut text_buffer = String::new();
110
111        let fails = violations
112            .iter()
113            .filter(|violation| !violation.ignore && !violation.warning)
114            .count();
115        let warns = violations
116            .iter()
117            .filter(|violation| violation.warning)
118            .count();
119        let show = fails + warns > 0;
120
121        if self.verbosity > 0 || show {
122            let text = self.format_filename(fname, fails == 0);
123            text_buffer.push_str(&text);
124            text_buffer.push('\n');
125        }
126
127        if show {
128            violations.sort_by(|a, b| {
129                a.line_no
130                    .cmp(&b.line_no)
131                    .then_with(|| a.line_pos.cmp(&b.line_pos))
132            });
133
134            for violation in violations {
135                let text = self.format_violation(violation, self.output_line_length);
136                text_buffer.push_str(&text);
137                text_buffer.push('\n');
138            }
139        }
140
141        text_buffer
142    }
143
144    fn colorize<'a>(&self, s: &'a str, style: Style) -> Cow<'a, str> {
145        colorize_helper(self.plain_output, s, style)
146    }
147
148    fn format_filename(&self, filename: &str, success: bool) -> String {
149        let status = if success { Status::Pass } else { Status::Fail };
150
151        let color = match status {
152            Status::Pass | Status::Fixed => AnsiColor::Green,
153            Status::Fail | Status::Error => {
154                self.has_fail
155                    .store(true, std::sync::atomic::Ordering::SeqCst);
156                AnsiColor::Red
157            }
158        }
159        .on_default();
160
161        let filename = self.colorize(filename, LIGHT_GREY);
162        let status = self.colorize(status.as_str(), color);
163
164        format!("== [{filename}] {status}")
165    }
166
167    fn format_violation(
168        &self,
169        violation: impl Into<SQLBaseError>,
170        max_line_length: usize,
171    ) -> String {
172        let violation: SQLBaseError = violation.into();
173        let desc = violation.desc();
174
175        let severity = if violation.ignore {
176            "IGNORE: "
177        } else if violation.warning {
178            "WARNING: "
179        } else {
180            ""
181        };
182
183        let line_elem = format!("{:4}", violation.line_no);
184        let pos_elem = format!("{:4}", violation.line_pos);
185
186        let mut desc = format!("{severity}{desc}");
187
188        if let Some(rule) = &violation.rule {
189            let text = self.colorize(rule.name, LIGHT_GREY);
190            let text = format!(" [{text}]");
191            desc.push_str(&text);
192        }
193
194        let split_desc = split_string_on_spaces(&desc, max_line_length - 25);
195        let mut section_color = if violation.ignore || violation.warning {
196            LIGHT_GREY
197        } else {
198            AnsiColor::Blue.on_default()
199        };
200
201        let mut out_buff = String::new();
202        for (idx, line) in enumerate(split_desc) {
203            if idx == 0 {
204                let rule_code = format!("{:>4}", violation.rule_code());
205
206                if rule_code.contains("PRS") {
207                    section_color = AnsiColor::Red.on_default();
208                }
209
210                let section = format!("L:{line_elem} | P:{pos_elem} | {rule_code} | ");
211                let section = self.colorize(&section, section_color);
212                out_buff.push_str(&section);
213            } else {
214                out_buff.push_str(&format!(
215                    "\n{}{}",
216                    " ".repeat(23),
217                    self.colorize("| ", section_color),
218                ));
219            }
220            out_buff.push_str(line);
221        }
222
223        out_buff
224    }
225}
226
227#[derive(Clone, Copy)]
228pub enum Status {
229    Pass,
230    Fixed,
231    Fail,
232    Error,
233}
234
235impl Status {
236    fn as_str(self) -> &'static str {
237        match self {
238            Status::Pass => "PASS",
239            Status::Fixed => "FIXED",
240            Status::Fail => "FAIL",
241            Status::Error => "ERROR",
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use anstyle::AnsiColor;
249    use fancy_regex::Regex;
250    use sqruff_lib_core::dialects::syntax::SyntaxKind;
251    use sqruff_lib_core::errors::{ErrorStructRule, SQLLintError};
252    use sqruff_lib_core::parser::markers::PositionMarker;
253    use sqruff_lib_core::parser::segments::base::SegmentBuilder;
254
255    use super::OutputStreamFormatter;
256    use crate::cli::formatters::split_string_on_spaces;
257
258    #[test]
259    fn test_short_string() {
260        assert_eq!(split_string_on_spaces("abc", 100), vec!["abc"]);
261    }
262
263    #[test]
264    fn test_split_with_line_length() {
265        assert_eq!(
266            split_string_on_spaces("abc def ghi", 7),
267            vec!["abc def", "ghi"]
268        );
269    }
270
271    #[test]
272    fn test_preserve_multi_space() {
273        assert_eq!(
274            split_string_on_spaces("a '   ' b c d e f", 11),
275            vec!["a '   ' b c", "d e f"]
276        );
277    }
278
279    fn escape_ansi(line: &str) -> String {
280        let ansi_escape = Regex::new("\x1B\\[[0-9]+(?:;[0-9]+)?m").unwrap();
281        ansi_escape.replace_all(line, "").into_owned()
282    }
283
284    fn mk_formatter() -> OutputStreamFormatter {
285        OutputStreamFormatter::new(None, false, 0)
286    }
287
288    #[test]
289    fn test_cli_formatters_filename_nocol() {
290        let formatter = mk_formatter();
291        let actual = formatter.format_filename("blahblah", true);
292
293        assert_eq!(escape_ansi(&actual), "== [blahblah] PASS");
294    }
295
296    #[test]
297    fn test_cli_formatters_violation() {
298        let formatter = mk_formatter();
299
300        let s = SegmentBuilder::token(0, "foobarbar", SyntaxKind::Word)
301            .with_position(PositionMarker::new(
302                10..19,
303                10..19,
304                "      \n\n  foobarbar".into(),
305                None,
306                None,
307            ))
308            .finish();
309
310        let mut v = SQLLintError::new("DESC", s, false, vec![]);
311
312        v.rule = Some(ErrorStructRule {
313            name: "some-name",
314            code: "DESC",
315        });
316
317        let f = formatter.format_violation(v, 90);
318
319        assert_eq!(escape_ansi(&f), "L:   3 | P:   3 | DESC | DESC [some-name]");
320    }
321
322    #[test]
323    fn test_cli_helpers_colorize() {
324        let mut formatter = mk_formatter();
325        // Force color output for this test.
326        formatter.plain_output = false;
327
328        let actual = formatter.colorize("foo", AnsiColor::Red.on_default());
329        assert_eq!(actual, "\u{1b}[31mfoo\u{1b}[0m");
330    }
331}