Skip to main content

bhc_diagnostics/
lib.rs

1//! Error reporting and diagnostics for BHC.
2//!
3//! This crate provides rich error reporting with source code snippets,
4//! suggestions, and structured diagnostic output in the style of Rust/Cargo.
5//!
6//! ## Features
7//!
8//! - Cargo-style terminal output with colors and underlines
9//! - Machine-readable JSON diagnostic format
10//! - Error code explanations via `--explain`
11//! - Multi-line span support with context
12//!
13//! ## Example
14//!
15//! ```ignore
16//! use bhc_diagnostics::{Diagnostic, SourceMap, CargoRenderer};
17//!
18//! let mut sm = SourceMap::new();
19//! sm.add_file("test.hs".into(), "foo = x + 1".into());
20//!
21//! let diag = Diagnostic::error("undefined variable")
22//!     .with_code("E0003")
23//!     .with_label(span, "not found in scope");
24//!
25//! let renderer = CargoRenderer::new(&sm);
26//! renderer.render_all(&[diag]);
27//! ```
28
29#![warn(missing_docs)]
30
31pub mod explain;
32pub mod json;
33pub mod lsp;
34pub mod render;
35
36use bhc_span::{FileId, SourceFile};
37pub use bhc_span::{FullSpan, Span};
38use serde::{Deserialize, Serialize};
39use std::io::Write;
40
41// Re-exports for convenience
42pub use explain::{all_error_codes, format_explanation, get_explanation, print_explanation};
43pub use json::{diagnostic_to_json, diagnostics_to_json, to_json_lines, to_json_string};
44pub use json::{JsonApplicability, JsonDiagnostic, JsonSeverity, JsonSpan, JsonSuggestion};
45pub use lsp::{
46    publish_diagnostics, to_code_actions, to_hover, to_lsp_diagnostic, to_lsp_diagnostics,
47    LspCodeAction, LspDiagnostic, LspHover, LspRange, LspSeverity, LspTextEdit,
48    PublishDiagnosticsParams,
49};
50pub use render::{colors, CargoRenderer, RenderConfig};
51
52/// The severity level of a diagnostic.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum Severity {
55    /// A bug in the compiler itself.
56    Bug,
57    /// A fatal error that prevents compilation.
58    Error,
59    /// A warning that doesn't prevent compilation.
60    Warning,
61    /// A note providing additional context.
62    Note,
63    /// Help text with suggestions.
64    Help,
65}
66
67impl Severity {
68    /// Get the ANSI color code for this severity.
69    #[must_use]
70    pub fn color(self) -> &'static str {
71        match self {
72            Self::Bug => "\x1b[1;35m",      // Bold magenta
73            Self::Error => "\x1b[1;31m",    // Bold red
74            Self::Warning => "\x1b[1;33m",  // Bold yellow
75            Self::Note => "\x1b[1;36m",     // Bold cyan
76            Self::Help => "\x1b[1;32m",     // Bold green
77        }
78    }
79
80    /// Get the label for this severity.
81    #[must_use]
82    pub fn label(self) -> &'static str {
83        match self {
84            Self::Bug => "internal compiler error",
85            Self::Error => "error",
86            Self::Warning => "warning",
87            Self::Note => "note",
88            Self::Help => "help",
89        }
90    }
91}
92
93/// A labeled span for diagnostics.
94#[derive(Clone, Debug)]
95pub struct Label {
96    /// The span being labeled.
97    pub span: FullSpan,
98    /// The message for this label.
99    pub message: String,
100    /// Whether this is the primary label.
101    pub primary: bool,
102}
103
104impl Label {
105    /// Create a primary label.
106    #[must_use]
107    pub fn primary(span: FullSpan, message: impl Into<String>) -> Self {
108        Self {
109            span,
110            message: message.into(),
111            primary: true,
112        }
113    }
114
115    /// Create a secondary label.
116    #[must_use]
117    pub fn secondary(span: FullSpan, message: impl Into<String>) -> Self {
118        Self {
119            span,
120            message: message.into(),
121            primary: false,
122        }
123    }
124}
125
126/// A diagnostic message with source locations and suggestions.
127#[derive(Clone, Debug)]
128pub struct Diagnostic {
129    /// The severity of this diagnostic.
130    pub severity: Severity,
131    /// The main message.
132    pub message: String,
133    /// An optional error code.
134    pub code: Option<String>,
135    /// Labeled spans with messages.
136    pub labels: Vec<Label>,
137    /// Additional notes.
138    pub notes: Vec<String>,
139    /// Suggested fixes.
140    pub suggestions: Vec<Suggestion>,
141}
142
143impl Diagnostic {
144    /// Create a new error diagnostic.
145    #[must_use]
146    pub fn error(message: impl Into<String>) -> Self {
147        Self {
148            severity: Severity::Error,
149            message: message.into(),
150            code: None,
151            labels: Vec::new(),
152            notes: Vec::new(),
153            suggestions: Vec::new(),
154        }
155    }
156
157    /// Create a new warning diagnostic.
158    #[must_use]
159    pub fn warning(message: impl Into<String>) -> Self {
160        Self {
161            severity: Severity::Warning,
162            message: message.into(),
163            code: None,
164            labels: Vec::new(),
165            notes: Vec::new(),
166            suggestions: Vec::new(),
167        }
168    }
169
170    /// Create a new bug diagnostic (internal compiler error).
171    #[must_use]
172    pub fn bug(message: impl Into<String>) -> Self {
173        Self {
174            severity: Severity::Bug,
175            message: message.into(),
176            code: None,
177            labels: Vec::new(),
178            notes: Vec::new(),
179            suggestions: Vec::new(),
180        }
181    }
182
183    /// Add an error code.
184    #[must_use]
185    pub fn with_code(mut self, code: impl Into<String>) -> Self {
186        self.code = Some(code.into());
187        self
188    }
189
190    /// Add a primary label.
191    #[must_use]
192    pub fn with_label(mut self, span: FullSpan, message: impl Into<String>) -> Self {
193        self.labels.push(Label::primary(span, message));
194        self
195    }
196
197    /// Add a secondary label.
198    #[must_use]
199    pub fn with_secondary_label(mut self, span: FullSpan, message: impl Into<String>) -> Self {
200        self.labels.push(Label::secondary(span, message));
201        self
202    }
203
204    /// Add a note.
205    #[must_use]
206    pub fn with_note(mut self, note: impl Into<String>) -> Self {
207        self.notes.push(note.into());
208        self
209    }
210
211    /// Add a suggestion.
212    #[must_use]
213    pub fn with_suggestion(mut self, suggestion: Suggestion) -> Self {
214        self.suggestions.push(suggestion);
215        self
216    }
217
218    /// Check if this is an error.
219    #[must_use]
220    pub fn is_error(&self) -> bool {
221        matches!(self.severity, Severity::Error | Severity::Bug)
222    }
223}
224
225/// A suggested fix for a diagnostic.
226#[derive(Clone, Debug)]
227pub struct Suggestion {
228    /// The message describing the suggestion.
229    pub message: String,
230    /// The span to replace.
231    pub span: FullSpan,
232    /// The replacement text.
233    pub replacement: String,
234    /// The applicability of this suggestion.
235    pub applicability: Applicability,
236}
237
238impl Suggestion {
239    /// Create a new suggestion.
240    #[must_use]
241    pub fn new(
242        message: impl Into<String>,
243        span: FullSpan,
244        replacement: impl Into<String>,
245        applicability: Applicability,
246    ) -> Self {
247        Self {
248            message: message.into(),
249            span,
250            replacement: replacement.into(),
251            applicability,
252        }
253    }
254}
255
256/// How applicable a suggestion is.
257#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
258pub enum Applicability {
259    /// The fix is definitely what the user wants.
260    MachineApplicable,
261    /// The fix might be what the user wants.
262    MaybeIncorrect,
263    /// The fix has placeholders the user must fill in.
264    HasPlaceholders,
265    /// The fix is just a hint, not directly applicable.
266    Unspecified,
267}
268
269/// A handler for collecting and emitting diagnostics.
270#[derive(Debug, Default)]
271pub struct DiagnosticHandler {
272    diagnostics: Vec<Diagnostic>,
273    error_count: usize,
274    warning_count: usize,
275}
276
277impl DiagnosticHandler {
278    /// Create a new diagnostic handler.
279    #[must_use]
280    pub fn new() -> Self {
281        Self::default()
282    }
283
284    /// Emit a diagnostic.
285    pub fn emit(&mut self, diagnostic: Diagnostic) {
286        match diagnostic.severity {
287            Severity::Error | Severity::Bug => self.error_count += 1,
288            Severity::Warning => self.warning_count += 1,
289            _ => {}
290        }
291        self.diagnostics.push(diagnostic);
292    }
293
294    /// Check if any errors have been emitted.
295    #[must_use]
296    pub fn has_errors(&self) -> bool {
297        self.error_count > 0
298    }
299
300    /// Get the number of errors.
301    #[must_use]
302    pub fn error_count(&self) -> usize {
303        self.error_count
304    }
305
306    /// Get the number of warnings.
307    #[must_use]
308    pub fn warning_count(&self) -> usize {
309        self.warning_count
310    }
311
312    /// Get all diagnostics.
313    #[must_use]
314    pub fn diagnostics(&self) -> &[Diagnostic] {
315        &self.diagnostics
316    }
317
318    /// Take all diagnostics, leaving the handler empty.
319    pub fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
320        self.error_count = 0;
321        self.warning_count = 0;
322        std::mem::take(&mut self.diagnostics)
323    }
324}
325
326/// A source map for looking up files and locations.
327#[derive(Debug, Default)]
328pub struct SourceMap {
329    files: Vec<SourceFile>,
330}
331
332impl SourceMap {
333    /// Create a new empty source map.
334    #[must_use]
335    pub fn new() -> Self {
336        Self::default()
337    }
338
339    /// Add a file to the source map.
340    pub fn add_file(&mut self, name: String, src: String) -> FileId {
341        let id = FileId::new(self.files.len() as u32);
342        self.files.push(SourceFile::new(id, name, src));
343        id
344    }
345
346    /// Get a file by ID.
347    #[must_use]
348    pub fn get_file(&self, id: FileId) -> Option<&SourceFile> {
349        self.files.get(id.0 as usize)
350    }
351
352    /// Get the number of files.
353    #[must_use]
354    pub fn len(&self) -> usize {
355        self.files.len()
356    }
357
358    /// Check if the source map is empty.
359    #[must_use]
360    pub fn is_empty(&self) -> bool {
361        self.files.is_empty()
362    }
363}
364
365/// Render diagnostics to a writer.
366pub struct DiagnosticRenderer<'a> {
367    source_map: &'a SourceMap,
368    use_colors: bool,
369}
370
371impl<'a> DiagnosticRenderer<'a> {
372    /// Create a new renderer.
373    #[must_use]
374    pub fn new(source_map: &'a SourceMap) -> Self {
375        Self {
376            source_map,
377            use_colors: true,
378        }
379    }
380
381    /// Disable colors.
382    #[must_use]
383    pub fn without_colors(mut self) -> Self {
384        self.use_colors = false;
385        self
386    }
387
388    /// Render a diagnostic to the given writer.
389    pub fn render(&self, diagnostic: &Diagnostic, w: &mut impl Write) -> std::io::Result<()> {
390        let reset = if self.use_colors { "\x1b[0m" } else { "" };
391        let color = if self.use_colors {
392            diagnostic.severity.color()
393        } else {
394            ""
395        };
396
397        // Header
398        write!(w, "{}{}", color, diagnostic.severity.label())?;
399        if let Some(code) = &diagnostic.code {
400            write!(w, "[{code}]")?;
401        }
402        writeln!(w, "{reset}: {}", diagnostic.message)?;
403
404        // Labels
405        for label in &diagnostic.labels {
406            if let Some(file) = self.source_map.get_file(label.span.file) {
407                let loc = file.lookup_line_col(label.span.span.lo);
408                let arrow = if label.primary { "-->" } else { "   " };
409                writeln!(w, " {arrow} {}:{}:{}", file.name, loc.line, loc.col)?;
410
411                // Show source line
412                if !label.span.span.is_dummy() {
413                    let source = file.source_text(label.span.span);
414                    writeln!(w, "   |")?;
415                    writeln!(w, "   | {source}")?;
416                    writeln!(w, "   | {}", "^".repeat(source.len().max(1)))?;
417                    if !label.message.is_empty() {
418                        writeln!(w, "   | {}", label.message)?;
419                    }
420                }
421            }
422        }
423
424        // Notes
425        for note in &diagnostic.notes {
426            writeln!(w, " = note: {note}")?;
427        }
428
429        // Suggestions
430        for suggestion in &diagnostic.suggestions {
431            writeln!(w, " = help: {}", suggestion.message)?;
432            if !suggestion.replacement.is_empty() {
433                writeln!(w, "   |")?;
434                writeln!(w, "   | {}", suggestion.replacement)?;
435            }
436        }
437
438        writeln!(w)?;
439        Ok(())
440    }
441
442    /// Render all diagnostics to stderr.
443    pub fn render_all(&self, diagnostics: &[Diagnostic]) {
444        let mut stderr = std::io::stderr().lock();
445        for diag in diagnostics {
446            let _ = self.render(diag, &mut stderr);
447        }
448    }
449}
450
451/// Trait for types that can produce diagnostics.
452pub trait IntoDiagnostic {
453    /// Convert into a diagnostic.
454    fn into_diagnostic(self) -> Diagnostic;
455}
456
457impl IntoDiagnostic for Diagnostic {
458    fn into_diagnostic(self) -> Diagnostic {
459        self
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_diagnostic_builder() {
469        let span = FullSpan::new(FileId::new(0), Span::from_raw(10, 20));
470
471        let diag = Diagnostic::error("type mismatch")
472            .with_code("E0001")
473            .with_label(span, "expected `Int`, found `String`")
474            .with_note("consider using `show` to convert to String");
475
476        assert!(diag.is_error());
477        assert_eq!(diag.code, Some("E0001".to_string()));
478        assert_eq!(diag.labels.len(), 1);
479        assert_eq!(diag.notes.len(), 1);
480    }
481
482    #[test]
483    fn test_diagnostic_handler() {
484        let mut handler = DiagnosticHandler::new();
485
486        handler.emit(Diagnostic::error("error 1"));
487        handler.emit(Diagnostic::warning("warning 1"));
488        handler.emit(Diagnostic::error("error 2"));
489
490        assert!(handler.has_errors());
491        assert_eq!(handler.error_count(), 2);
492        assert_eq!(handler.warning_count(), 1);
493    }
494}