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
32fn format_time(elapsed: Duration) -> String {
33    if elapsed.as_secs() >= 1 {
34        format!("{:.2}s", elapsed.as_secs_f64())
35    } else if elapsed.as_millis() > 0 {
36        format!("{}ms", elapsed.as_millis())
37    } else {
38        format!("{}μs", elapsed.as_micros())
39    }
40}
41
42pub fn print_summary(file_count: usize, elapsed: Duration, errors: i32, warnings: i32) {
43    let time_string = format_time(elapsed);
44    let use_color = std::env::var("NO_COLOR").is_err();
45    let time_display = if use_color {
46        format!("({})", time_string).dimmed().to_string()
47    } else {
48        format!("({})", time_string)
49    };
50    let files_str = if file_count == 1 {
51        "1 file"
52    } else {
53        &format!("{} files", file_count)
54    };
55
56    if errors == 0 && warnings == 0 {
57        eprintln!("  ✓ No issues · {} {}", files_str, time_display);
58    } else {
59        let mut parts = Vec::new();
60        if errors > 0 {
61            parts.push(if errors == 1 {
62                "1 error".to_string()
63            } else {
64                format!("{} errors", errors)
65            });
66        }
67        if warnings > 0 {
68            parts.push(if warnings == 1 {
69                "1 warning".to_string()
70            } else {
71                format!("{} warnings", warnings)
72            });
73        }
74        let findings = format!("Found {}", parts.join(", "));
75        let findings_display = if use_color {
76            format!("{}", findings.bold())
77        } else {
78            findings
79        };
80        eprintln!("  ✖ {} · {} {}", findings_display, files_str, time_display);
81    }
82}
83
84fn color_handler(highlight: Style) -> GraphicalReportHandler {
85    let theme = GraphicalTheme {
86        characters: ThemeCharacters {
87            error: "🔴".into(),
88            warning: "🟡".into(),
89            ..ThemeCharacters::unicode()
90        },
91        styles: ThemeStyles {
92            error: Style::new().red(),
93            warning: Style::new().yellow(),
94            link: Style::new(),
95            help: Style::new().dimmed(),
96            highlights: vec![highlight],
97            ..ThemeStyles::ansi()
98        },
99    };
100    GraphicalReportHandler::new_themed(theme).with_wrap_lines(false)
101}
102
103fn nocolor_handler() -> GraphicalReportHandler {
104    let theme = GraphicalTheme {
105        characters: ThemeCharacters {
106            error: "[error]".into(),
107            warning: "[warning]".into(),
108            ..ThemeCharacters::unicode()
109        },
110        styles: ThemeStyles::none(),
111    };
112    GraphicalReportHandler::new_themed(theme).with_wrap_lines(false)
113}
114
115fn render(
116    handler: &GraphicalReportHandler,
117    diagnostic: &LisetteDiagnostic,
118    source: &IndexedSource,
119    filename: &str,
120    use_color: bool,
121) {
122    let report = diagnostic
123        .clone()
124        .with_color(use_color)
125        .with_source_code(source.clone(), filename.to_string());
126    let mut output = String::new();
127    if handler.render_report(&mut output, report.as_ref()).is_ok() {
128        eprintln!("{}", output);
129    }
130}
131
132pub struct Counts {
133    pub files: usize,
134    pub errors: i32,
135    pub warnings: i32,
136}
137
138/// Resolves a `file_id` to its source, falling back to the entry file.
139struct SourceCache<F> {
140    get_source: F,
141    default_source: IndexedSource,
142    default_filename: String,
143    cache: FxHashMap<u32, (IndexedSource, String)>,
144}
145
146impl<F: Fn(u32) -> Option<(String, String)>> SourceCache<F> {
147    fn new(get_source: F, default_source: &str, default_filename: &str) -> Self {
148        Self {
149            get_source,
150            default_source: IndexedSource::new(default_source),
151            default_filename: default_filename.to_string(),
152            cache: FxHashMap::default(),
153        }
154    }
155
156    fn get(&mut self, file_id: Option<u32>) -> (IndexedSource, String) {
157        let Some(fid) = file_id else {
158            return (self.default_source.clone(), self.default_filename.clone());
159        };
160        let default_source = &self.default_source;
161        let default_filename = &self.default_filename;
162        let get_source = &self.get_source;
163        let entry = self.cache.entry(fid).or_insert_with(|| {
164            get_source(fid)
165                .map(|(src, name)| (IndexedSource::new(&src), name))
166                .unwrap_or_else(|| (default_source.clone(), default_filename.clone()))
167        });
168        (entry.0.clone(), entry.1.clone())
169    }
170}
171
172fn partition_diagnostics<'a>(
173    errors: &'a [LisetteDiagnostic],
174    lints: &'a [LisetteDiagnostic],
175    filter: &Filter,
176) -> (Vec<&'a LisetteDiagnostic>, Vec<&'a LisetteDiagnostic>) {
177    let (errors, infer_warnings): (Vec<_>, Vec<_>) = if filter.show_errors() {
178        errors.iter().partition(|d| d.is_error())
179    } else {
180        (Vec::new(), Vec::new())
181    };
182
183    let warnings: Vec<_> = if filter.show_warnings() {
184        infer_warnings.into_iter().chain(lints.iter()).collect()
185    } else {
186        Vec::new()
187    };
188
189    (errors, warnings)
190}
191
192pub fn render_all(
193    errors: &[LisetteDiagnostic],
194    lints: &[LisetteDiagnostic],
195    get_source: impl Fn(u32) -> Option<(String, String)>,
196    file_count: usize,
197    filter: &Filter,
198    default_source: &str,
199    default_filename: &str,
200) -> Counts {
201    let (errors, warnings) = partition_diagnostics(errors, lints, filter);
202
203    let has_diagnostics = !errors.is_empty() || !warnings.is_empty();
204    if has_diagnostics {
205        eprintln!(); // Blank line before first diagnostic
206    }
207
208    let use_color = std::env::var("NO_COLOR").is_err();
209    let mut sources = SourceCache::new(get_source, default_source, default_filename);
210
211    if !errors.is_empty() {
212        let handler = if use_color {
213            color_handler(Style::new().red())
214        } else {
215            nocolor_handler()
216        };
217        for error in &errors {
218            let (src, name) = sources.get(error.file_id());
219            render(&handler, error, &src, &name, use_color);
220        }
221    }
222
223    if !warnings.is_empty() {
224        let handler = if use_color {
225            color_handler(Style::new().yellow())
226        } else {
227            nocolor_handler()
228        };
229        for warning in &warnings {
230            let (src, name) = sources.get(warning.file_id());
231            render(&handler, warning, &src, &name, use_color);
232        }
233    }
234
235    Counts {
236        files: file_count.max(1),
237        errors: errors.len() as i32,
238        warnings: warnings.len() as i32,
239    }
240}
241
242/// Renders one diagnostic as `file:line:col: severity: message [code]`.
243pub fn unix_line(diagnostic: &LisetteDiagnostic, source: &IndexedSource, filename: &str) -> String {
244    let mut line = String::new();
245    if let Some(offset) = diagnostic.location_offset() {
246        let (lineno, col) = source.line_col(offset);
247        line.push_str(&format!("{}:{}:{}: ", filename, lineno, col));
248    }
249    line.push_str(diagnostic.severity_word());
250    line.push_str(": ");
251    line.push_str(diagnostic.plain_message());
252    if let Some(code) = diagnostic.code_str() {
253        line.push_str(&format!(" [{}]", code));
254    }
255    line
256}
257
258/// Builds the stdout text (one diagnostic per line, no color, no banner) and the
259/// counts the caller needs for the stderr summary and exit code.
260pub fn render_unix(
261    errors: &[LisetteDiagnostic],
262    lints: &[LisetteDiagnostic],
263    get_source: impl Fn(u32) -> Option<(String, String)>,
264    file_count: usize,
265    filter: &Filter,
266    default_source: &str,
267    default_filename: &str,
268) -> (String, Counts) {
269    let (errors, warnings) = partition_diagnostics(errors, lints, filter);
270
271    let mut sources = SourceCache::new(get_source, default_source, default_filename);
272    let mut output = String::new();
273    for diagnostic in errors.iter().chain(warnings.iter()) {
274        let (src, name) = sources.get(diagnostic.file_id());
275        output.push_str(&unix_line(diagnostic, &src, &name));
276        output.push('\n');
277    }
278
279    let counts = Counts {
280        files: file_count.max(1),
281        errors: errors.len() as i32,
282        warnings: warnings.len() as i32,
283    };
284    (output, counts)
285}