1use std::time::Duration;
2
3use rustc_hash::FxHashMap;
4
5use crate::LisetteDiagnostic;
6use crate::diagnostic::IndexedSource;
7use miette::{GraphicalReportHandler, GraphicalTheme, ThemeCharacters, ThemeStyles};
8use owo_colors::{OwoColorize, Style};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum OutputFormat {
12 #[default]
13 Graphical,
14 Unix,
15}
16
17pub struct Filter {
18 pub errors_only: bool,
19 pub warnings_only: bool,
20}
21
22impl Filter {
23 pub fn show_errors(&self) -> bool {
24 !self.warnings_only
25 }
26
27 pub fn show_warnings(&self) -> bool {
28 !self.errors_only
29 }
30
31 pub fn show_info(&self) -> bool {
32 !self.errors_only && !self.warnings_only
33 }
34}
35
36fn format_time(elapsed: Duration) -> String {
37 if elapsed.as_secs() >= 1 {
38 format!("{:.2}s", elapsed.as_secs_f64())
39 } else if elapsed.as_millis() > 0 {
40 format!("{}ms", elapsed.as_millis())
41 } else {
42 format!("{}μs", elapsed.as_micros())
43 }
44}
45
46pub fn print_summary(file_count: usize, elapsed: Duration, errors: i32, warnings: i32, info: i32) {
47 let time_string = format_time(elapsed);
48 let use_color = std::env::var("NO_COLOR").is_err();
49 let time_display = if use_color {
50 format!("({})", time_string).dimmed().to_string()
51 } else {
52 format!("({})", time_string)
53 };
54 let files_str = if file_count == 1 {
55 "1 file"
56 } else {
57 &format!("{} files", file_count)
58 };
59
60 if errors == 0 && warnings == 0 && info == 0 {
61 eprintln!(" ✓ No issues · {} {}", files_str, time_display);
62 } else {
63 let mut parts = Vec::new();
64 if errors > 0 {
65 parts.push(if errors == 1 {
66 "1 error".to_string()
67 } else {
68 format!("{} errors", errors)
69 });
70 }
71 if warnings > 0 {
72 parts.push(if warnings == 1 {
73 "1 warning".to_string()
74 } else {
75 format!("{} warnings", warnings)
76 });
77 }
78 if info > 0 {
79 parts.push(format!("{} info", info));
80 }
81 let findings = format!("Found {}", parts.join(", "));
82 let findings_display = if use_color {
83 format!("{}", findings.bold())
84 } else {
85 findings
86 };
87 eprintln!(" ✖ {} · {} {}", findings_display, files_str, time_display);
88 }
89}
90
91fn color_handler(highlight: Style) -> GraphicalReportHandler {
92 let theme = GraphicalTheme {
93 characters: ThemeCharacters {
94 error: "🔴".into(),
95 warning: "🟡".into(),
96 advice: "🔵".into(),
97 ..ThemeCharacters::unicode()
98 },
99 styles: ThemeStyles {
100 error: Style::new().red(),
101 warning: Style::new().yellow(),
102 advice: Style::new().blue(),
103 link: Style::new(),
104 help: Style::new().dimmed(),
105 highlights: vec![highlight],
106 ..ThemeStyles::ansi()
107 },
108 };
109 GraphicalReportHandler::new_themed(theme).with_wrap_lines(false)
110}
111
112fn nocolor_handler() -> GraphicalReportHandler {
113 let theme = GraphicalTheme {
114 characters: ThemeCharacters {
115 error: "[error]".into(),
116 warning: "[warning]".into(),
117 advice: "[info]".into(),
118 ..ThemeCharacters::unicode()
119 },
120 styles: ThemeStyles::none(),
121 };
122 GraphicalReportHandler::new_themed(theme).with_wrap_lines(false)
123}
124
125fn render(
126 handler: &GraphicalReportHandler,
127 diagnostic: &LisetteDiagnostic,
128 source: &IndexedSource,
129 filename: &str,
130 use_color: bool,
131) {
132 let report = diagnostic
133 .clone()
134 .with_color(use_color)
135 .with_source_code(source.clone(), filename.to_string());
136 let mut output = String::new();
137 if handler.render_report(&mut output, report.as_ref()).is_ok() {
138 eprintln!("{}", output);
139 }
140}
141
142fn render_group<F: Fn(u32) -> Option<(String, String)>>(
143 diagnostics: &[&LisetteDiagnostic],
144 highlight: Style,
145 use_color: bool,
146 sources: &mut SourceCache<F>,
147) {
148 if diagnostics.is_empty() {
149 return;
150 }
151 let handler = if use_color {
152 color_handler(highlight)
153 } else {
154 nocolor_handler()
155 };
156 for diagnostic in diagnostics {
157 let (src, name) = sources.get(diagnostic.file_id());
158 render(&handler, diagnostic, &src, &name, use_color);
159 }
160}
161
162pub struct Counts {
163 pub files: usize,
164 pub errors: i32,
165 pub warnings: i32,
166 pub info: i32,
167}
168
169struct SourceCache<F> {
171 get_source: F,
172 default_source: IndexedSource,
173 default_filename: String,
174 cache: FxHashMap<u32, (IndexedSource, String)>,
175}
176
177impl<F: Fn(u32) -> Option<(String, String)>> SourceCache<F> {
178 fn new(get_source: F, default_source: &str, default_filename: &str) -> Self {
179 Self {
180 get_source,
181 default_source: IndexedSource::new(default_source),
182 default_filename: default_filename.to_string(),
183 cache: FxHashMap::default(),
184 }
185 }
186
187 fn get(&mut self, file_id: Option<u32>) -> (IndexedSource, String) {
188 let Some(fid) = file_id else {
189 return (self.default_source.clone(), self.default_filename.clone());
190 };
191 let default_source = &self.default_source;
192 let default_filename = &self.default_filename;
193 let get_source = &self.get_source;
194 let entry = self.cache.entry(fid).or_insert_with(|| {
195 get_source(fid)
196 .map(|(src, name)| (IndexedSource::new(&src), name))
197 .unwrap_or_else(|| (default_source.clone(), default_filename.clone()))
198 });
199 (entry.0.clone(), entry.1.clone())
200 }
201}
202
203fn partition_diagnostics<'a>(
204 errors: &'a [LisetteDiagnostic],
205 lints: &'a [LisetteDiagnostic],
206 filter: &Filter,
207) -> (
208 Vec<&'a LisetteDiagnostic>,
209 Vec<&'a LisetteDiagnostic>,
210 Vec<&'a LisetteDiagnostic>,
211) {
212 let mut error_bucket = Vec::new();
213 let mut warning_bucket = Vec::new();
214 let mut info_bucket = Vec::new();
215
216 for diagnostic in errors.iter().chain(lints.iter()) {
217 if diagnostic.is_error() {
218 if filter.show_errors() {
219 error_bucket.push(diagnostic);
220 }
221 } else if diagnostic.is_info() {
222 if filter.show_info() {
223 info_bucket.push(diagnostic);
224 }
225 } else if filter.show_warnings() {
226 warning_bucket.push(diagnostic);
227 }
228 }
229
230 (error_bucket, warning_bucket, info_bucket)
231}
232
233pub fn render_all(
234 errors: &[LisetteDiagnostic],
235 lints: &[LisetteDiagnostic],
236 get_source: impl Fn(u32) -> Option<(String, String)>,
237 file_count: usize,
238 filter: &Filter,
239 default_source: &str,
240 default_filename: &str,
241) -> Counts {
242 let (errors, warnings, info) = partition_diagnostics(errors, lints, filter);
243
244 let has_diagnostics = !errors.is_empty() || !warnings.is_empty() || !info.is_empty();
245 if has_diagnostics {
246 eprintln!(); }
248
249 let use_color = std::env::var("NO_COLOR").is_err();
250 let mut sources = SourceCache::new(get_source, default_source, default_filename);
251
252 render_group(&errors, Style::new().red(), use_color, &mut sources);
253 render_group(&warnings, Style::new().yellow(), use_color, &mut sources);
254 render_group(&info, Style::new().blue(), use_color, &mut sources);
255
256 Counts {
257 files: file_count.max(1),
258 errors: errors.len() as i32,
259 warnings: warnings.len() as i32,
260 info: info.len() as i32,
261 }
262}
263
264pub fn unix_line(diagnostic: &LisetteDiagnostic, source: &IndexedSource, filename: &str) -> String {
266 let mut line = String::new();
267 if let Some(offset) = diagnostic.location_offset() {
268 let (lineno, col) = source.line_col(offset);
269 line.push_str(&format!("{}:{}:{}: ", filename, lineno, col));
270 }
271 line.push_str(diagnostic.severity_word());
272 line.push_str(": ");
273 line.push_str(diagnostic.plain_message());
274 if let Some(code) = diagnostic.code_str() {
275 line.push_str(&format!(" [{}]", code));
276 }
277 line
278}
279
280pub fn render_unix(
283 errors: &[LisetteDiagnostic],
284 lints: &[LisetteDiagnostic],
285 get_source: impl Fn(u32) -> Option<(String, String)>,
286 file_count: usize,
287 filter: &Filter,
288 default_source: &str,
289 default_filename: &str,
290) -> (String, Counts) {
291 let (errors, warnings, info) = partition_diagnostics(errors, lints, filter);
292
293 let mut sources = SourceCache::new(get_source, default_source, default_filename);
294 let mut output = String::new();
295 for diagnostic in errors.iter().chain(warnings.iter()).chain(info.iter()) {
296 let (src, name) = sources.get(diagnostic.file_id());
297 output.push_str(&unix_line(diagnostic, &src, &name));
298 output.push('\n');
299 }
300
301 let counts = Counts {
302 files: file_count.max(1),
303 errors: errors.len() as i32,
304 warnings: warnings.len() as i32,
305 info: info.len() as i32,
306 };
307 (output, counts)
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn show_all() -> Filter {
315 Filter {
316 errors_only: false,
317 warnings_only: false,
318 }
319 }
320
321 #[test]
322 fn each_severity_lands_in_its_own_bucket() {
323 let errors = vec![LisetteDiagnostic::error("e")];
324 let lints = vec![LisetteDiagnostic::warn("w"), LisetteDiagnostic::info("i")];
325 let (errors, warnings, info) = partition_diagnostics(&errors, &lints, &show_all());
326 assert_eq!(errors.len(), 1);
327 assert_eq!(warnings.len(), 1);
328 assert_eq!(info.len(), 1);
329 }
330
331 #[test]
332 fn info_hidden_under_errors_only() {
333 let empty: Vec<LisetteDiagnostic> = Vec::new();
334 let lints = vec![LisetteDiagnostic::info("i")];
335 let filter = Filter {
336 errors_only: true,
337 warnings_only: false,
338 };
339 let (_, _, info) = partition_diagnostics(&empty, &lints, &filter);
340 assert!(info.is_empty());
341 }
342
343 #[test]
344 fn info_hidden_under_warnings_only() {
345 let empty: Vec<LisetteDiagnostic> = Vec::new();
346 let lints = vec![LisetteDiagnostic::info("i")];
347 let filter = Filter {
348 errors_only: false,
349 warnings_only: true,
350 };
351 let (_, _, info) = partition_diagnostics(&empty, &lints, &filter);
352 assert!(info.is_empty());
353 }
354
355 #[test]
356 fn unix_counts_and_labels_info_separately() {
357 let empty: Vec<LisetteDiagnostic> = Vec::new();
358 let lints = vec![LisetteDiagnostic::info("advisory")];
359 let (output, counts) = render_unix(&empty, &lints, |_| None, 1, &show_all(), "", "f.lis");
360 assert_eq!(counts.errors, 0);
361 assert_eq!(counts.warnings, 0);
362 assert_eq!(counts.info, 1);
363 assert!(output.contains("info: advisory"));
364 }
365}