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(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
169/// Resolves a `file_id` to its source, falling back to the entry file.
170struct 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!(); // Blank line before first diagnostic
247    }
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
264/// Renders one diagnostic as `file:line:col: severity: message [code]`.
265pub 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
280/// Builds the stdout text (one diagnostic per line, no color, no banner) and the
281/// counts the caller needs for the stderr summary and exit code.
282pub 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}