Skip to main content

bhc_diagnostics/
render.rs

1//! Cargo-style diagnostic rendering.
2//!
3//! This module provides a renderer that produces error messages in the style
4//! of Rust's compiler, with:
5//! - Colored output
6//! - Source code snippets with line numbers
7//! - Underlines and carets for precise error locations
8//! - Multiple labeled spans
9//! - Notes and help messages
10
11use std::io::Write;
12
13use crate::{Diagnostic, Label, Severity, SourceMap, Suggestion};
14
15/// ANSI color codes for terminal output.
16pub mod colors {
17    /// Reset all formatting.
18    pub const RESET: &str = "\x1b[0m";
19    /// Bold text.
20    pub const BOLD: &str = "\x1b[1m";
21    /// Red text (for errors).
22    pub const RED: &str = "\x1b[31m";
23    /// Bold red text.
24    pub const BOLD_RED: &str = "\x1b[1;31m";
25    /// Yellow text (for warnings).
26    pub const YELLOW: &str = "\x1b[33m";
27    /// Bold yellow text.
28    pub const BOLD_YELLOW: &str = "\x1b[1;33m";
29    /// Blue text (for notes, line numbers).
30    pub const BLUE: &str = "\x1b[34m";
31    /// Bold blue text.
32    pub const BOLD_BLUE: &str = "\x1b[1;34m";
33    /// Cyan text (for notes).
34    pub const CYAN: &str = "\x1b[36m";
35    /// Bold cyan text.
36    pub const BOLD_CYAN: &str = "\x1b[1;36m";
37    /// Green text (for help).
38    pub const GREEN: &str = "\x1b[32m";
39    /// Bold green text.
40    pub const BOLD_GREEN: &str = "\x1b[1;32m";
41    /// Magenta text (for internal errors).
42    pub const MAGENTA: &str = "\x1b[35m";
43    /// Bold magenta text.
44    pub const BOLD_MAGENTA: &str = "\x1b[1;35m";
45    /// White text.
46    pub const WHITE: &str = "\x1b[37m";
47    /// Bold white text.
48    pub const BOLD_WHITE: &str = "\x1b[1;37m";
49}
50
51/// Configuration for the diagnostic renderer.
52#[derive(Clone, Debug)]
53pub struct RenderConfig {
54    /// Whether to use colors in output.
55    pub colors: bool,
56    /// Whether to show error codes.
57    pub show_codes: bool,
58    /// Number of context lines to show before/after the error.
59    pub context_lines: usize,
60    /// Maximum width for the output.
61    pub max_width: usize,
62}
63
64impl Default for RenderConfig {
65    fn default() -> Self {
66        Self {
67            colors: true,
68            show_codes: true,
69            context_lines: 0,
70            max_width: 140,
71        }
72    }
73}
74
75impl RenderConfig {
76    /// Create a config with colors enabled.
77    #[must_use]
78    pub fn colored() -> Self {
79        Self::default()
80    }
81
82    /// Create a config without colors (for testing or piping).
83    #[must_use]
84    pub fn plain() -> Self {
85        Self {
86            colors: false,
87            ..Self::default()
88        }
89    }
90}
91
92/// A Cargo-style diagnostic renderer.
93pub struct CargoRenderer<'a> {
94    source_map: &'a SourceMap,
95    config: RenderConfig,
96}
97
98impl<'a> CargoRenderer<'a> {
99    /// Create a new renderer with the given source map.
100    #[must_use]
101    pub fn new(source_map: &'a SourceMap) -> Self {
102        Self {
103            source_map,
104            config: RenderConfig::default(),
105        }
106    }
107
108    /// Create a new renderer with custom configuration.
109    #[must_use]
110    pub fn with_config(source_map: &'a SourceMap, config: RenderConfig) -> Self {
111        Self { source_map, config }
112    }
113
114    /// Get the color for a severity level.
115    fn severity_color(&self, severity: Severity) -> &'static str {
116        if !self.config.colors {
117            return "";
118        }
119        match severity {
120            Severity::Bug => colors::BOLD_MAGENTA,
121            Severity::Error => colors::BOLD_RED,
122            Severity::Warning => colors::BOLD_YELLOW,
123            Severity::Note => colors::BOLD_CYAN,
124            Severity::Help => colors::BOLD_GREEN,
125        }
126    }
127
128    /// Get the reset code if colors are enabled.
129    fn reset(&self) -> &'static str {
130        if self.config.colors {
131            colors::RESET
132        } else {
133            ""
134        }
135    }
136
137    /// Get blue color if enabled.
138    fn blue(&self) -> &'static str {
139        if self.config.colors {
140            colors::BOLD_BLUE
141        } else {
142            ""
143        }
144    }
145
146    /// Render a diagnostic to the given writer.
147    pub fn render(&self, diagnostic: &Diagnostic, w: &mut impl Write) -> std::io::Result<()> {
148        // Header: error[E0001]: message
149        self.render_header(diagnostic, w)?;
150
151        // Labels with source snippets
152        self.render_labels(diagnostic, w)?;
153
154        // Notes
155        for note in &diagnostic.notes {
156            self.render_note(note, w)?;
157        }
158
159        // Suggestions
160        for suggestion in &diagnostic.suggestions {
161            self.render_suggestion(suggestion, w)?;
162        }
163
164        writeln!(w)?;
165        Ok(())
166    }
167
168    /// Render the header line.
169    fn render_header(&self, diagnostic: &Diagnostic, w: &mut impl Write) -> std::io::Result<()> {
170        let color = self.severity_color(diagnostic.severity);
171        let reset = self.reset();
172
173        write!(w, "{color}{}{reset}", diagnostic.severity.label())?;
174
175        if self.config.show_codes {
176            if let Some(code) = &diagnostic.code {
177                write!(w, "{color}[{code}]{reset}")?;
178            }
179        }
180
181        writeln!(w, ": {color}{}{reset}", diagnostic.message)?;
182
183        Ok(())
184    }
185
186    /// Render all labels with source snippets.
187    fn render_labels(&self, diagnostic: &Diagnostic, w: &mut impl Write) -> std::io::Result<()> {
188        let blue = self.blue();
189        let reset = self.reset();
190
191        for (i, label) in diagnostic.labels.iter().enumerate() {
192            let Some(file) = self.source_map.get_file(label.span.file) else {
193                continue;
194            };
195
196            if label.span.span.is_dummy() {
197                continue;
198            }
199
200            let span_info = file.span_lines(label.span.span);
201
202            // Location line: --> file.hs:line:col
203            let arrow = if label.primary && i == 0 {
204                "-->"
205            } else {
206                "   "
207            };
208            writeln!(
209                w,
210                " {blue}{arrow}{reset} {}:{}:{}",
211                file.name, span_info.start_line, span_info.start_col
212            )?;
213
214            // Calculate the width needed for line numbers
215            let line_num_width = span_info.end_line.to_string().len().max(3);
216
217            // Empty line before source
218            writeln!(w, " {blue}{:>width$} |{reset}", "", width = line_num_width)?;
219
220            // Render each line in the span
221            if span_info.is_multiline() {
222                self.render_multiline_span(file, &span_info, label, line_num_width, w)?;
223            } else {
224                self.render_single_line_span(file, &span_info, label, line_num_width, w)?;
225            }
226        }
227
228        Ok(())
229    }
230
231    /// Render a single-line span with underline.
232    fn render_single_line_span(
233        &self,
234        file: &bhc_span::SourceFile,
235        span_info: &bhc_span::SpanLines,
236        label: &Label,
237        line_num_width: usize,
238        w: &mut impl Write,
239    ) -> std::io::Result<()> {
240        let blue = self.blue();
241        let reset = self.reset();
242        let underline_color = if label.primary {
243            self.severity_color(Severity::Error)
244        } else {
245            self.blue()
246        };
247
248        // Get the source line (0-indexed)
249        let line_idx = span_info.start_line - 1;
250        let Some(line_content) = file.line_content(line_idx) else {
251            return Ok(());
252        };
253
254        // Source line with line number
255        writeln!(
256            w,
257            " {blue}{:>width$} |{reset} {}",
258            span_info.start_line,
259            line_content,
260            width = line_num_width
261        )?;
262
263        // Underline
264        let start_col = span_info.start_col.saturating_sub(1);
265        let end_col = span_info.end_col.saturating_sub(1);
266        let underline_len = (end_col - start_col).max(1);
267
268        let padding = " ".repeat(start_col);
269        let underline = "^".repeat(underline_len);
270
271        write!(
272            w,
273            " {blue}{:>width$} |{reset} {padding}{underline_color}{underline}{reset}",
274            "",
275            width = line_num_width
276        )?;
277
278        // Label message on the same line if short, otherwise on next line
279        if !label.message.is_empty() {
280            writeln!(w, " {underline_color}{}{reset}", label.message)?;
281        } else {
282            writeln!(w)?;
283        }
284
285        Ok(())
286    }
287
288    /// Render a multi-line span.
289    fn render_multiline_span(
290        &self,
291        file: &bhc_span::SourceFile,
292        span_info: &bhc_span::SpanLines,
293        label: &Label,
294        line_num_width: usize,
295        w: &mut impl Write,
296    ) -> std::io::Result<()> {
297        let blue = self.blue();
298        let reset = self.reset();
299        let span_color = if label.primary {
300            self.severity_color(Severity::Error)
301        } else {
302            self.blue()
303        };
304
305        for line_num in span_info.start_line..=span_info.end_line {
306            let line_idx = line_num - 1;
307            let Some(line_content) = file.line_content(line_idx) else {
308                continue;
309            };
310
311            // Determine the span portion for this line
312            let (_start_col, end_col, show_start, show_end) =
313                if line_num == span_info.start_line {
314                    (span_info.start_col - 1, line_content.len(), true, false)
315                } else if line_num == span_info.end_line {
316                    (0, span_info.end_col - 1, false, true)
317                } else {
318                    (0, line_content.len(), false, false)
319                };
320
321            // Source line
322            if show_start {
323                // First line: show starting marker
324                writeln!(
325                    w,
326                    " {blue}{:>width$} |{reset}   {span_color}/{reset} {}",
327                    line_num,
328                    line_content,
329                    width = line_num_width
330                )?;
331            } else if show_end {
332                // Last line: show ending marker
333                writeln!(
334                    w,
335                    " {blue}{:>width$} |{reset} {span_color}|{reset} {}",
336                    line_num,
337                    line_content,
338                    width = line_num_width
339                )?;
340
341                // Underline for last line
342                let padding = " ".repeat(end_col);
343                writeln!(
344                    w,
345                    " {blue}{:>width$} |{reset} {span_color}|_{padding}^{reset}",
346                    "",
347                    width = line_num_width
348                )?;
349            } else {
350                // Middle line
351                writeln!(
352                    w,
353                    " {blue}{:>width$} |{reset} {span_color}|{reset} {}",
354                    line_num,
355                    line_content,
356                    width = line_num_width
357                )?;
358            }
359        }
360
361        // Label message
362        if !label.message.is_empty() {
363            writeln!(
364                w,
365                " {blue}{:>width$} |{reset} {span_color}{}{reset}",
366                "",
367                label.message,
368                width = line_num_width
369            )?;
370        }
371
372        Ok(())
373    }
374
375    /// Render a note.
376    fn render_note(&self, note: &str, w: &mut impl Write) -> std::io::Result<()> {
377        let blue = self.blue();
378        let reset = self.reset();
379
380        // Handle multi-line notes
381        for (i, line) in note.lines().enumerate() {
382            if i == 0 {
383                writeln!(w, " {blue}={reset} {blue}note{reset}: {line}")?;
384            } else {
385                writeln!(w, "          {line}")?;
386            }
387        }
388
389        Ok(())
390    }
391
392    /// Render a suggestion.
393    fn render_suggestion(&self, suggestion: &Suggestion, w: &mut impl Write) -> std::io::Result<()> {
394        let green = if self.config.colors {
395            colors::BOLD_GREEN
396        } else {
397            ""
398        };
399        let reset = self.reset();
400        let blue = self.blue();
401
402        writeln!(w, " {blue}={reset} {green}help{reset}: {}", suggestion.message)?;
403
404        if !suggestion.replacement.is_empty() {
405            writeln!(w, "          {blue}|{reset}")?;
406            for line in suggestion.replacement.lines() {
407                writeln!(w, "          {blue}|{reset} {green}{line}{reset}")?;
408            }
409        }
410
411        Ok(())
412    }
413
414    /// Render all diagnostics to stderr.
415    pub fn render_all(&self, diagnostics: &[Diagnostic]) {
416        let mut stderr = std::io::stderr().lock();
417        for diag in diagnostics {
418            let _ = self.render(diag, &mut stderr);
419        }
420    }
421
422    /// Render a diagnostic to a string.
423    #[must_use]
424    pub fn render_to_string(&self, diagnostic: &Diagnostic) -> String {
425        let mut buf = Vec::new();
426        let _ = self.render(diagnostic, &mut buf);
427        String::from_utf8_lossy(&buf).into_owned()
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use bhc_span::{FileId, FullSpan, Span};
435
436    fn create_test_source_map() -> SourceMap {
437        let mut sm = SourceMap::new();
438        sm.add_file(
439            "test.hs".to_string(),
440            "module Test where\n\nfoo :: Int -> Int\nfoo x = x + y\n\nmain = foo 42\n".to_string(),
441        );
442        sm
443    }
444
445    #[test]
446    fn test_simple_error() {
447        let sm = create_test_source_map();
448        let renderer = CargoRenderer::with_config(&sm, RenderConfig::plain());
449
450        let diag = Diagnostic::error("undefined variable `y`")
451            .with_code("E0001")
452            .with_label(
453                FullSpan::new(FileId::new(0), Span::from_raw(50, 51)),
454                "not found in this scope",
455            );
456
457        let output = renderer.render_to_string(&diag);
458
459        assert!(output.contains("error[E0001]"));
460        assert!(output.contains("undefined variable `y`"));
461        assert!(output.contains("test.hs:4:"));
462        assert!(output.contains("not found in this scope"));
463    }
464
465    #[test]
466    fn test_error_with_note() {
467        let sm = create_test_source_map();
468        let renderer = CargoRenderer::with_config(&sm, RenderConfig::plain());
469
470        let diag = Diagnostic::error("type mismatch")
471            .with_code("E0002")
472            .with_label(
473                FullSpan::new(FileId::new(0), Span::from_raw(50, 51)),
474                "expected `Int`, found `String`",
475            )
476            .with_note("consider using `show` to convert to String");
477
478        let output = renderer.render_to_string(&diag);
479
480        assert!(output.contains("= note:"));
481        assert!(output.contains("consider using `show`"));
482    }
483
484    #[test]
485    fn test_warning() {
486        let sm = create_test_source_map();
487        let renderer = CargoRenderer::with_config(&sm, RenderConfig::plain());
488
489        let diag = Diagnostic::warning("unused variable `x`")
490            .with_code("W0001")
491            .with_label(
492                FullSpan::new(FileId::new(0), Span::from_raw(40, 41)),
493                "this variable is never used",
494            );
495
496        let output = renderer.render_to_string(&diag);
497
498        assert!(output.contains("warning[W0001]"));
499        assert!(output.contains("unused variable"));
500    }
501
502    #[test]
503    fn test_suggestion() {
504        let sm = create_test_source_map();
505        let renderer = CargoRenderer::with_config(&sm, RenderConfig::plain());
506
507        let diag = Diagnostic::error("undefined variable `y`")
508            .with_label(
509                FullSpan::new(FileId::new(0), Span::from_raw(50, 51)),
510                "not found",
511            )
512            .with_suggestion(crate::Suggestion::new(
513                "did you mean `x`?",
514                FullSpan::new(FileId::new(0), Span::from_raw(50, 51)),
515                "x",
516                crate::Applicability::MaybeIncorrect,
517            ));
518
519        let output = renderer.render_to_string(&diag);
520
521        assert!(output.contains("= help:"));
522        assert!(output.contains("did you mean `x`?"));
523    }
524}