sqruff_lib/cli/
formatters.rs

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