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) = if line_num == span_info.start_line {
313                (span_info.start_col - 1, line_content.len(), true, false)
314            } else if line_num == span_info.end_line {
315                (0, span_info.end_col - 1, false, true)
316            } else {
317                (0, line_content.len(), false, false)
318            };
319
320            // Source line
321            if show_start {
322                // First line: show starting marker
323                writeln!(
324                    w,
325                    " {blue}{:>width$} |{reset}   {span_color}/{reset} {}",
326                    line_num,
327                    line_content,
328                    width = line_num_width
329                )?;
330            } else if show_end {
331                // Last line: show ending marker
332                writeln!(
333                    w,
334                    " {blue}{:>width$} |{reset} {span_color}|{reset} {}",
335                    line_num,
336                    line_content,
337                    width = line_num_width
338                )?;
339
340                // Underline for last line
341                let padding = " ".repeat(end_col);
342                writeln!(
343                    w,
344                    " {blue}{:>width$} |{reset} {span_color}|_{padding}^{reset}",
345                    "",
346                    width = line_num_width
347                )?;
348            } else {
349                // Middle line
350                writeln!(
351                    w,
352                    " {blue}{:>width$} |{reset} {span_color}|{reset} {}",
353                    line_num,
354                    line_content,
355                    width = line_num_width
356                )?;
357            }
358        }
359
360        // Label message
361        if !label.message.is_empty() {
362            writeln!(
363                w,
364                " {blue}{:>width$} |{reset} {span_color}{}{reset}",
365                "",
366                label.message,
367                width = line_num_width
368            )?;
369        }
370
371        Ok(())
372    }
373
374    /// Render a note.
375    fn render_note(&self, note: &str, w: &mut impl Write) -> std::io::Result<()> {
376        let blue = self.blue();
377        let reset = self.reset();
378
379        // Handle multi-line notes
380        for (i, line) in note.lines().enumerate() {
381            if i == 0 {
382                writeln!(w, " {blue}={reset} {blue}note{reset}: {line}")?;
383            } else {
384                writeln!(w, "          {line}")?;
385            }
386        }
387
388        Ok(())
389    }
390
391    /// Render a suggestion.
392    fn render_suggestion(
393        &self,
394        suggestion: &Suggestion,
395        w: &mut impl Write,
396    ) -> std::io::Result<()> {
397        let green = if self.config.colors {
398            colors::BOLD_GREEN
399        } else {
400            ""
401        };
402        let reset = self.reset();
403        let blue = self.blue();
404
405        writeln!(
406            w,
407            " {blue}={reset} {green}help{reset}: {}",
408            suggestion.message
409        )?;
410
411        if !suggestion.replacement.is_empty() {
412            writeln!(w, "          {blue}|{reset}")?;
413            for line in suggestion.replacement.lines() {
414                writeln!(w, "          {blue}|{reset} {green}{line}{reset}")?;
415            }
416        }
417
418        Ok(())
419    }
420
421    /// Render all diagnostics to stderr.
422    pub fn render_all(&self, diagnostics: &[Diagnostic]) {
423        let mut stderr = std::io::stderr().lock();
424        for diag in diagnostics {
425            let _ = self.render(diag, &mut stderr);
426        }
427    }
428
429    /// Render a diagnostic to a string.
430    #[must_use]
431    pub fn render_to_string(&self, diagnostic: &Diagnostic) -> String {
432        let mut buf = Vec::new();
433        let _ = self.render(diagnostic, &mut buf);
434        String::from_utf8_lossy(&buf).into_owned()
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use bhc_span::{FileId, FullSpan, Span};
442
443    fn create_test_source_map() -> SourceMap {
444        let mut sm = SourceMap::new();
445        sm.add_file(
446            "test.hs".to_string(),
447            "module Test where\n\nfoo :: Int -> Int\nfoo x = x + y\n\nmain = foo 42\n".to_string(),
448        );
449        sm
450    }
451
452    #[test]
453    fn test_simple_error() {
454        let sm = create_test_source_map();
455        let renderer = CargoRenderer::with_config(&sm, RenderConfig::plain());
456
457        let diag = Diagnostic::error("undefined variable `y`")
458            .with_code("E0001")
459            .with_label(
460                FullSpan::new(FileId::new(0), Span::from_raw(50, 51)),
461                "not found in this scope",
462            );
463
464        let output = renderer.render_to_string(&diag);
465
466        assert!(output.contains("error[E0001]"));
467        assert!(output.contains("undefined variable `y`"));
468        assert!(output.contains("test.hs:4:"));
469        assert!(output.contains("not found in this scope"));
470    }
471
472    #[test]
473    fn test_error_with_note() {
474        let sm = create_test_source_map();
475        let renderer = CargoRenderer::with_config(&sm, RenderConfig::plain());
476
477        let diag = Diagnostic::error("type mismatch")
478            .with_code("E0002")
479            .with_label(
480                FullSpan::new(FileId::new(0), Span::from_raw(50, 51)),
481                "expected `Int`, found `String`",
482            )
483            .with_note("consider using `show` to convert to String");
484
485        let output = renderer.render_to_string(&diag);
486
487        assert!(output.contains("= note:"));
488        assert!(output.contains("consider using `show`"));
489    }
490
491    #[test]
492    fn test_warning() {
493        let sm = create_test_source_map();
494        let renderer = CargoRenderer::with_config(&sm, RenderConfig::plain());
495
496        let diag = Diagnostic::warning("unused variable `x`")
497            .with_code("W0001")
498            .with_label(
499                FullSpan::new(FileId::new(0), Span::from_raw(40, 41)),
500                "this variable is never used",
501            );
502
503        let output = renderer.render_to_string(&diag);
504
505        assert!(output.contains("warning[W0001]"));
506        assert!(output.contains("unused variable"));
507    }
508
509    #[test]
510    fn test_suggestion() {
511        let sm = create_test_source_map();
512        let renderer = CargoRenderer::with_config(&sm, RenderConfig::plain());
513
514        let diag = Diagnostic::error("undefined variable `y`")
515            .with_label(
516                FullSpan::new(FileId::new(0), Span::from_raw(50, 51)),
517                "not found",
518            )
519            .with_suggestion(crate::Suggestion::new(
520                "did you mean `x`?",
521                FullSpan::new(FileId::new(0), Span::from_raw(50, 51)),
522                "x",
523                crate::Applicability::MaybeIncorrect,
524            ));
525
526        let output = renderer.render_to_string(&diag);
527
528        assert!(output.contains("= help:"));
529        assert!(output.contains("did you mean `x`?"));
530    }
531}