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(§ion, section_color);
182 out_buff.push_str(§ion);
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 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}