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