Line data Source code
1 : //! The text output of the CLI
2 :
3 : use crate::lint::{line_column, Diagnostic, Severity};
4 : use std::time::Duration;
5 :
6 : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
7 : pub enum Color {
8 : Bold,
9 : Dim,
10 : Red,
11 : Yellow,
12 : Green,
13 : Cyan,
14 : }
15 :
16 : /// Applies text styles with ANSI escapes, they do not change the visible width
17 : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
18 : pub struct Style {
19 : /// Whether to emit ANSI escapes at all
20 : pub colors: bool,
21 : }
22 :
23 : impl Style {
24 : /// Colors when stdout is a terminal. `FORCE_COLOR` (other than `0`) forces them, `NO_COLOR` turns them off.
25 0 : pub fn for_stdout() -> Self {
26 : use std::io::IsTerminal;
27 0 : let set = |name| std::env::var_os(name).is_some_and(|v| !v.is_empty());
28 0 : let colors = match std::env::var("FORCE_COLOR") {
29 0 : Ok(v) if !v.is_empty() => v != "0",
30 0 : _ => !set("NO_COLOR") && std::io::stdout().is_terminal(),
31 : };
32 0 : Style { colors }
33 0 : }
34 :
35 98 : pub fn apply(self, color: Color, text: &str) -> String {
36 98 : if !self.colors {
37 96 : return text.to_string();
38 2 : }
39 2 : let (on, off) = match color {
40 1 : Color::Bold => (1, 22),
41 0 : Color::Dim => (2, 22),
42 1 : Color::Red => (31, 39),
43 0 : Color::Green => (32, 39),
44 0 : Color::Yellow => (33, 39),
45 0 : Color::Cyan => (36, 39),
46 : };
47 2 : format!("\x1b[{on}m{text}\x1b[{off}m")
48 98 : }
49 : }
50 :
51 : /// "warning"
52 : const LABEL_WIDTH: usize = 7;
53 : /// "analyze", "failure"
54 : const STAGE_WIDTH: usize = 7;
55 :
56 45 : fn plural(n: usize, word: &str) -> String {
57 45 : format!("{n} {word}{}", if n == 1 { "" } else { "s" })
58 45 : }
59 :
60 : /// A duration in milliseconds, in seconds from one second on
61 5 : pub fn duration(d: Duration) -> String {
62 5 : let ms = d.as_secs_f64() * 1000.0;
63 5 : if ms < 1000.0 {
64 4 : format!("{} ms", ms.round())
65 : } else {
66 1 : format!("{:.1} s", ms / 1000.0)
67 : }
68 5 : }
69 :
70 11 : fn severity_color(severity: Severity) -> Color {
71 11 : match severity {
72 9 : Severity::Error => Color::Red,
73 2 : Severity::Warning => Color::Yellow,
74 : }
75 11 : }
76 :
77 : /// The source line of a diagnostic and the underline of its span
78 11 : fn code_frame(text: &str, d: &Diagnostic) -> Option<(String, String)> {
79 11 : let location = d.location?;
80 : // Columns count code points, tabs are shown as a single space to keep the caret aligned
81 10 : let line = text.split('\n').nth(location.line - 1).unwrap_or("");
82 10 : let line = line.strip_suffix('\r').unwrap_or(line).replace('\t', " ");
83 10 : let (end_line, end_column) = line_column(text, location.offset + location.length);
84 10 : let end_column = if end_line == location.line { end_column } else { line.chars().count() + 1 };
85 10 : let width = end_column.saturating_sub(location.column).max(1);
86 10 : Some((line, format!("{}{}", " ".repeat(location.column - 1), "^".repeat(width))))
87 11 : }
88 :
89 : /// Format the diagnostics of one script, the file name followed by one indented line per finding
90 11 : pub fn format_file(file: &str, text: &str, diagnostics: &[Diagnostic], style: Style) -> String {
91 11 : let locations: Vec<String> = diagnostics
92 11 : .iter()
93 11 : .map(|d| d.location.map(|l| format!("{}:{}", l.line, l.column)).unwrap_or_default())
94 11 : .collect();
95 11 : let location_width = locations.iter().map(String::len).max().unwrap_or(0);
96 11 : let indent = " ".repeat(2 + location_width + 2);
97 11 : let continuation = " ".repeat(indent.len() + LABEL_WIDTH + 2 + STAGE_WIDTH + 2);
98 :
99 11 : let mut out = vec![style.apply(Color::Bold, file)];
100 11 : for (d, location) in diagnostics.iter().zip(&locations) {
101 11 : let color = severity_color(d.severity);
102 11 : let mut lines = d.message.split('\n');
103 11 : out.push(format!(
104 : " {} {} {} {}",
105 11 : style.apply(Color::Cyan, &format!("{location:location_width$}")),
106 11 : style.apply(color, &format!("{:LABEL_WIDTH$}", d.severity.name())),
107 11 : style.apply(Color::Dim, &format!("{:STAGE_WIDTH$}", d.stage.name())),
108 11 : lines.next().unwrap_or(""),
109 : ));
110 11 : out.extend(lines.map(|line| format!("{continuation}{line}")));
111 11 : if let Some((line, underline)) = code_frame(text, d) {
112 10 : let bar = style.apply(Color::Dim, "│");
113 10 : out.push(format!("{indent}{bar} {line}"));
114 10 : out.push(format!("{indent}{bar} {}", style.apply(color, &underline)));
115 10 : }
116 : }
117 11 : out.join("\n")
118 11 : }
119 :
120 15 : pub fn format_summary(files: usize, errors: usize, warnings: usize, style: Style) -> String {
121 30 : let count = |n, word, color| style.apply(if n > 0 { color } else { Color::Green }, &plural(n, word));
122 15 : format!("{}, {}, {}", plural(files, "file"), count(errors, "error", Color::Red), count(warnings, "warning", Color::Yellow))
123 15 : }
124 :
125 : /// Where the time of a run went
126 2 : pub fn format_timings(lint: Duration, style: Style) -> String {
127 2 : format!("{} scan, parse and analyze {}", style.apply(Color::Dim, "dashql-core"), duration(lint))
128 2 : }
129 :
130 : #[cfg(test)]
131 : mod tests {
132 : use super::*;
133 : use crate::lint::{Location, Stage};
134 :
135 : const PLAIN: Style = Style { colors: false };
136 :
137 1 : fn parse_error() -> Diagnostic {
138 1 : Diagnostic {
139 1 : stage: Stage::Parse,
140 1 : severity: Severity::Error,
141 1 : message: "syntax error, unexpected SEMICOLON".into(),
142 1 : location: Some(Location { line: 2, column: 11, offset: 20, length: 1 }),
143 1 : }
144 1 : }
145 :
146 : #[test]
147 1 : fn shows_diagnostics_with_the_underlined_source_line() {
148 1 : let text = "select 1;\nselect 1 +;\n";
149 1 : assert_eq!(
150 1 : format_file("q.sql", text, &[parse_error()], PLAIN),
151 1 : [
152 1 : "q.sql",
153 1 : " 2:11 error parse syntax error, unexpected SEMICOLON",
154 1 : " │ select 1 +;",
155 1 : " │ ^",
156 1 : ]
157 1 : .join("\n"),
158 : );
159 1 : }
160 :
161 : #[test]
162 1 : fn underlines_the_whole_span_in_code_points() {
163 1 : let text = "select 'ä' + bogus;";
164 1 : let d = Diagnostic {
165 1 : stage: Stage::Analyze,
166 1 : severity: Severity::Warning,
167 1 : message: "hm".into(),
168 1 : location: Some(Location { line: 1, column: 14, offset: 14, length: 5 }),
169 1 : };
170 1 : let out = format_file("q.sql", text, &[d], PLAIN);
171 1 : assert_eq!(out.split('\n').skip(2).collect::<Vec<_>>(), [" │ select 'ä' + bogus;", " │ ^^^^^"]);
172 1 : }
173 :
174 : #[test]
175 1 : fn indents_multi_line_messages_and_failures() {
176 1 : let failure = Diagnostic { stage: Stage::Failure, severity: Severity::Error, message: "boom\nmore".into(), location: None };
177 1 : assert_eq!(
178 1 : format_file("q.sql", "", &[failure], PLAIN),
179 1 : ["q.sql", " error failure boom", " more"].join("\n"),
180 : );
181 1 : }
182 :
183 : #[test]
184 1 : fn styles_without_changing_the_text() {
185 1 : let style = Style { colors: true };
186 1 : assert_eq!(style.apply(Color::Red, "x"), "\x1b[31mx\x1b[39m");
187 1 : assert_eq!(style.apply(Color::Bold, "x"), "\x1b[1mx\x1b[22m");
188 1 : }
189 :
190 : #[test]
191 1 : fn counts_files_errors_and_warnings() {
192 1 : assert_eq!(format_summary(1, 0, 2, PLAIN), "1 file, 0 errors, 2 warnings");
193 1 : assert_eq!(format_summary(3, 1, 0, PLAIN), "3 files, 1 error, 0 warnings");
194 1 : }
195 :
196 : #[test]
197 1 : fn formats_durations() {
198 1 : assert_eq!(duration(Duration::from_micros(400)), "0 ms");
199 1 : assert_eq!(duration(Duration::from_millis(999)), "999 ms");
200 1 : assert_eq!(duration(Duration::from_millis(1260)), "1.3 s");
201 1 : assert_eq!(format_timings(Duration::from_millis(42), PLAIN), "dashql-core scan, parse and analyze 42 ms");
202 1 : }
203 : }
|