Skip to main content

lisette_diagnostics/
render.rs

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(if info == 1 {
80                "1 advisory".to_string()
81            } else {
82                format!("{} advisories", info)
83            });
84        }
85        let findings = format!("Found {}", parts.join(", "));
86        let findings_display = if use_color {
87            format!("{}", findings.bold())
88        } else {
89            findings
90        };
91        eprintln!("  ✖ {} · {} {}", findings_display, files_str, time_display);
92    }
93}
94
95fn color_handler(highlight: Style) -> GraphicalReportHandler {
96    let theme = GraphicalTheme {
97        characters: ThemeCharacters {
98            error: "🔴".into(),
99            warning: "🟡".into(),
100            advice: "🔵".into(),
101            ..ThemeCharacters::unicode()
102        },
103        styles: ThemeStyles {
104            error: Style::new().red(),
105            warning: Style::new().yellow(),
106            advice: Style::new().blue(),
107            link: Style::new(),
108            help: Style::new().dimmed(),
109            highlights: vec![highlight],
110            ..ThemeStyles::ansi()
111        },
112    };
113    GraphicalReportHandler::new_themed(theme).with_wrap_lines(false)
114}
115
116fn nocolor_handler() -> GraphicalReportHandler {
117    let theme = GraphicalTheme {
118        characters: ThemeCharacters {
119            error: "[error]".into(),
120            warning: "[warning]".into(),
121            advice: "[info]".into(),
122            ..ThemeCharacters::unicode()
123        },
124        styles: ThemeStyles::none(),
125    };
126    GraphicalReportHandler::new_themed(theme).with_wrap_lines(false)
127}
128
129fn render(
130    handler: &GraphicalReportHandler,
131    diagnostic: &LisetteDiagnostic,
132    source: &IndexedSource,
133    filename: &str,
134    use_color: bool,
135) {
136    let report = diagnostic
137        .clone()
138        .with_color(use_color)
139        .with_source_code(source.clone(), filename.to_string());
140    let mut output = String::new();
141    if handler.render_report(&mut output, report.as_ref()).is_ok() {
142        eprintln!("{}", output);
143    }
144}
145
146fn render_group<F: Fn(u32) -> Option<(String, String)>>(
147    diagnostics: &[&LisetteDiagnostic],
148    highlight: Style,
149    use_color: bool,
150    sources: &mut SourceCache<F>,
151) {
152    if diagnostics.is_empty() {
153        return;
154    }
155    let handler = if use_color {
156        color_handler(highlight)
157    } else {
158        nocolor_handler()
159    };
160    for diagnostic in diagnostics {
161        let (src, name) = sources.get(diagnostic.file_id());
162        render(&handler, diagnostic, &src, &name, use_color);
163    }
164}
165
166pub struct Counts {
167    pub files: usize,
168    pub errors: i32,
169    pub warnings: i32,
170    pub info: i32,
171}
172
173/// Resolves a `file_id` to its source, falling back to the entry file.
174struct SourceCache<F> {
175    get_source: F,
176    default_source: IndexedSource,
177    default_filename: String,
178    cache: FxHashMap<u32, (IndexedSource, String)>,
179}
180
181impl<F: Fn(u32) -> Option<(String, String)>> SourceCache<F> {
182    fn new(get_source: F, default_source: &str, default_filename: &str) -> Self {
183        Self {
184            get_source,
185            default_source: IndexedSource::new(default_source),
186            default_filename: default_filename.to_string(),
187            cache: FxHashMap::default(),
188        }
189    }
190
191    fn get(&mut self, file_id: Option<u32>) -> (IndexedSource, String) {
192        let Some(fid) = file_id else {
193            return (self.default_source.clone(), self.default_filename.clone());
194        };
195        let default_source = &self.default_source;
196        let default_filename = &self.default_filename;
197        let get_source = &self.get_source;
198        let entry = self.cache.entry(fid).or_insert_with(|| {
199            get_source(fid)
200                .map(|(src, name)| (IndexedSource::new(&src), name))
201                .unwrap_or_else(|| (default_source.clone(), default_filename.clone()))
202        });
203        (entry.0.clone(), entry.1.clone())
204    }
205}
206
207fn partition_diagnostics<'a>(
208    errors: &'a [LisetteDiagnostic],
209    lints: &'a [LisetteDiagnostic],
210    filter: &Filter,
211) -> (
212    Vec<&'a LisetteDiagnostic>,
213    Vec<&'a LisetteDiagnostic>,
214    Vec<&'a LisetteDiagnostic>,
215) {
216    let mut error_bucket = Vec::new();
217    let mut warning_bucket = Vec::new();
218    let mut info_bucket = Vec::new();
219
220    for diagnostic in errors.iter().chain(lints.iter()) {
221        if diagnostic.is_error() {
222            if filter.show_errors() {
223                error_bucket.push(diagnostic);
224            }
225        } else if diagnostic.is_info() {
226            if filter.show_info() {
227                info_bucket.push(diagnostic);
228            }
229        } else if filter.show_warnings() {
230            warning_bucket.push(diagnostic);
231        }
232    }
233
234    (error_bucket, warning_bucket, info_bucket)
235}
236
237pub fn render_all(
238    errors: &[LisetteDiagnostic],
239    lints: &[LisetteDiagnostic],
240    get_source: impl Fn(u32) -> Option<(String, String)>,
241    file_count: usize,
242    filter: &Filter,
243    default_source: &str,
244    default_filename: &str,
245) -> Counts {
246    let (errors, warnings, info) = partition_diagnostics(errors, lints, filter);
247
248    let has_diagnostics = !errors.is_empty() || !warnings.is_empty() || !info.is_empty();
249    if has_diagnostics {
250        eprintln!(); // Blank line before first diagnostic
251    }
252
253    let use_color = std::env::var("NO_COLOR").is_err();
254    let mut sources = SourceCache::new(get_source, default_source, default_filename);
255
256    render_group(&errors, Style::new().red(), use_color, &mut sources);
257    render_group(&warnings, Style::new().yellow(), use_color, &mut sources);
258    render_group(&info, Style::new().blue(), use_color, &mut sources);
259
260    Counts {
261        files: file_count.max(1),
262        errors: errors.len() as i32,
263        warnings: warnings.len() as i32,
264        info: info.len() as i32,
265    }
266}
267
268/// Renders one diagnostic as `file:line:col: severity: message [code]`.
269pub fn unix_line(diagnostic: &LisetteDiagnostic, source: &IndexedSource, filename: &str) -> String {
270    let mut line = String::new();
271    if let Some(offset) = diagnostic.location_offset() {
272        let (lineno, col) = source.line_col(offset);
273        line.push_str(&format!("{}:{}:{}: ", filename, lineno, col));
274    }
275    line.push_str(diagnostic.severity_word());
276    line.push_str(": ");
277    line.push_str(diagnostic.plain_message());
278    if let Some(code) = diagnostic.code_str() {
279        line.push_str(&format!(" [{}]", code));
280    }
281    line
282}
283
284/// Builds the stdout text (one diagnostic per line, no color, no banner) and the
285/// counts the caller needs for the stderr summary and exit code.
286pub fn render_unix(
287    errors: &[LisetteDiagnostic],
288    lints: &[LisetteDiagnostic],
289    get_source: impl Fn(u32) -> Option<(String, String)>,
290    file_count: usize,
291    filter: &Filter,
292    default_source: &str,
293    default_filename: &str,
294) -> (String, Counts) {
295    let (errors, warnings, info) = partition_diagnostics(errors, lints, filter);
296
297    let mut sources = SourceCache::new(get_source, default_source, default_filename);
298    let mut output = String::new();
299    for diagnostic in errors.iter().chain(warnings.iter()).chain(info.iter()) {
300        let (src, name) = sources.get(diagnostic.file_id());
301        output.push_str(&unix_line(diagnostic, &src, &name));
302        output.push('\n');
303    }
304
305    let counts = Counts {
306        files: file_count.max(1),
307        errors: errors.len() as i32,
308        warnings: warnings.len() as i32,
309        info: info.len() as i32,
310    };
311    (output, counts)
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn show_all() -> Filter {
319        Filter {
320            errors_only: false,
321            warnings_only: false,
322        }
323    }
324
325    #[test]
326    fn each_severity_lands_in_its_own_bucket() {
327        let errors = vec![LisetteDiagnostic::error("e")];
328        let lints = vec![LisetteDiagnostic::warn("w"), LisetteDiagnostic::info("i")];
329        let (errors, warnings, info) = partition_diagnostics(&errors, &lints, &show_all());
330        assert_eq!(errors.len(), 1);
331        assert_eq!(warnings.len(), 1);
332        assert_eq!(info.len(), 1);
333    }
334
335    #[test]
336    fn info_hidden_under_errors_only() {
337        let empty: Vec<LisetteDiagnostic> = Vec::new();
338        let lints = vec![LisetteDiagnostic::info("i")];
339        let filter = Filter {
340            errors_only: true,
341            warnings_only: false,
342        };
343        let (_, _, info) = partition_diagnostics(&empty, &lints, &filter);
344        assert!(info.is_empty());
345    }
346
347    #[test]
348    fn info_hidden_under_warnings_only() {
349        let empty: Vec<LisetteDiagnostic> = Vec::new();
350        let lints = vec![LisetteDiagnostic::info("i")];
351        let filter = Filter {
352            errors_only: false,
353            warnings_only: true,
354        };
355        let (_, _, info) = partition_diagnostics(&empty, &lints, &filter);
356        assert!(info.is_empty());
357    }
358
359    #[test]
360    fn unix_counts_and_labels_info_separately() {
361        let empty: Vec<LisetteDiagnostic> = Vec::new();
362        let lints = vec![LisetteDiagnostic::info("advisory")];
363        let (output, counts) = render_unix(&empty, &lints, |_| None, 1, &show_all(), "", "f.lis");
364        assert_eq!(counts.errors, 0);
365        assert_eq!(counts.warnings, 0);
366        assert_eq!(counts.info, 1);
367        assert!(output.contains("info: advisory"));
368    }
369}