Skip to main content

alba_syntax/
diagnostics.rs

1//! Turns spanned parser/lexer errors into rendered, Rust-quality
2//! diagnostics: the source line, a caret under the exact span, and help
3//! text.
4//!
5//! [`Diagnostic`] implements [`miette::Diagnostic`] so it can be handed to
6//! any of miette's report handlers. [`render_diagnostic`] wraps that up
7//! with a fixed, platform-independent [`miette::GraphicalReportHandler`]
8//! configuration, since a rendered report is otherwise sensitive to color
9//! support, terminal width, and unicode support, which vary across
10//! machines and CI runners.
11
12use miette::{Diagnostic as MietteDiagnostic, GraphicalReportHandler, GraphicalTheme};
13use miette::{LabeledSpan, NamedSource, SourceCode, SourceSpan};
14use std::fmt;
15
16use crate::parser::ParseError;
17use crate::token::Span;
18
19/// A renderable diagnostic: a message, an optional label on the offending
20/// span, and optional help text. Built from a [`ParseError`] via
21/// [`ParseError::into_diagnostic`].
22///
23/// Deliberately does not carry the source text or a display path: those are
24/// supplied separately to [`render_diagnostic`], matching how a caller
25/// (typically a CLI reading a file) already has both on hand and shouldn't
26/// need to clone the source into every error it produces.
27#[derive(Debug, Clone)]
28pub struct Diagnostic {
29    message: String,
30    span: Option<SourceSpan>,
31    help: Option<String>,
32}
33
34impl Diagnostic {
35    /// Creates a diagnostic from a message, an optional byte-offset span
36    /// into the source, and optional help text.
37    ///
38    /// This is the constructor other crates use to lift their own errors
39    /// into something [`render_diagnostic`] can render, without this crate
40    /// exposing `Diagnostic`'s fields (deliberately private — see the
41    /// struct's doc comment). `alba-core`'s `CoreError::into_diagnostic` is
42    /// the first such caller: `CoreError` already carries a message, an
43    /// optional [`Span`], and optional help in the same shape a
44    /// [`ParseError`] does, so it goes through this constructor rather than
45    /// duplicating `Diagnostic`'s internals or `alba-syntax` growing a
46    /// dependency on `alba-core`'s error type.
47    ///
48    /// `span` is `None` for a failure that has no place in the source to
49    /// point at — a beam named on the command line that does not exist, for
50    /// instance. Such a diagnostic renders as the message and its help
51    /// alone: asserting a source position that is not where the mistake is
52    /// misleads a reader more than showing none does.
53    pub fn new(message: impl Into<String>, span: Option<Span>, help: Option<String>) -> Self {
54        Self {
55            message: message.into(),
56            span: span.map(to_source_span),
57            help,
58        }
59    }
60}
61
62/// Converts our `[start, end)` byte-offset [`Span`] into miette's
63/// offset-plus-length [`SourceSpan`]. Zero-width spans (for example the
64/// lexer's `Eof` token, or a parse error pointing at end of input) are
65/// preserved as zero-length spans rather than special-cased: miette renders
66/// those as a single caret at that offset instead of an underlined range,
67/// which is exactly the behavior we want, and never panics on them.
68fn to_source_span(span: Span) -> SourceSpan {
69    SourceSpan::new(span.start.into(), span.end.saturating_sub(span.start))
70}
71
72impl fmt::Display for Diagnostic {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        write!(f, "{}", self.message)
75    }
76}
77
78impl std::error::Error for Diagnostic {}
79
80impl MietteDiagnostic for Diagnostic {
81    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
82        self.help
83            .as_ref()
84            .map(|help| Box::new(help) as Box<dyn fmt::Display + 'a>)
85    }
86
87    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
88        let span = self.span?;
89        Some(Box::new(std::iter::once(LabeledSpan::underline(span))))
90    }
91}
92
93impl ParseError {
94    /// Converts this parse error into a renderable [`Diagnostic`].
95    ///
96    /// Lex errors are already surfaced as [`ParseError`] by
97    /// [`crate::parser::tokenize`] (message and span preserved, no help),
98    /// so this single conversion covers both lexing and parsing failures in
99    /// practice.
100    pub fn into_diagnostic(self) -> Diagnostic {
101        Diagnostic::new(self.message, Some(self.span), self.help)
102    }
103}
104
105/// Renders `err` against `source` (identified as `path` in the output)
106/// using miette's graphical report handler, producing the source line, a
107/// caret under the exact span, and the help text if present.
108///
109/// The handler is fixed to unicode box-drawing, no color, and an explicit
110/// 80-column width, so the rendered string is byte-identical regardless of
111/// the terminal, OS, or CI runner it's produced on (see the module doc
112/// comment).
113pub fn render_diagnostic(source: &str, path: &str, err: &Diagnostic) -> String {
114    let report = WithSource {
115        source: NamedSource::new(path, source.to_string()),
116        inner: err,
117    };
118    let mut rendered = String::new();
119    handler()
120        .render_report(&mut rendered, &report)
121        .expect("rendering a diagnostic into a String cannot fail");
122    rendered
123}
124
125/// The single, deterministic [`GraphicalReportHandler`] configuration used
126/// by [`render_diagnostic`]. Unicode (not ASCII) box-drawing, no color, and
127/// a fixed width: none of those depend on the environment the code runs in,
128/// which is what makes the insta snapshots in `tests/diagnostics.rs` stable
129/// across macOS, Linux, and Windows.
130fn handler() -> GraphicalReportHandler {
131    GraphicalReportHandler::new_themed(GraphicalTheme::unicode_nocolor()).with_width(80)
132}
133
134/// Pairs a [`Diagnostic`] with the source text it refers to, so the pair
135/// together implements [`miette::Diagnostic`] with `source_code()`
136/// present. Kept private and constructed only inside [`render_diagnostic`]:
137/// `Diagnostic` itself deliberately stays source-free (see its doc
138/// comment), so something has to carry the two together at render time.
139struct WithSource<'a> {
140    source: NamedSource<String>,
141    inner: &'a Diagnostic,
142}
143
144impl fmt::Debug for WithSource<'_> {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        fmt::Debug::fmt(self.inner, f)
147    }
148}
149
150impl fmt::Display for WithSource<'_> {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        fmt::Display::fmt(self.inner, f)
153    }
154}
155
156impl std::error::Error for WithSource<'_> {}
157
158impl MietteDiagnostic for WithSource<'_> {
159    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
160        self.inner.help()
161    }
162
163    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
164        self.inner.labels()
165    }
166
167    fn source_code(&self) -> Option<&dyn SourceCode> {
168        Some(&self.source)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::parse;
176
177    /// `Eof`'s zero-width span must render without panicking and without
178    /// producing nothing at all. Exercised through the real parser (a beam
179    /// block left open, so parsing runs off the end of input) rather than
180    /// building a synthetic zero-width `Diagnostic` by hand.
181    #[test]
182    fn renders_zero_width_eof_span_without_panicking() {
183        let src = "beam x {";
184        let err = parse(src).unwrap_err();
185        assert_eq!(err.span.start, err.span.end, "expected a zero-width span");
186
187        let rendered = render_diagnostic(src, "Beamfile", &err.into_diagnostic());
188
189        assert!(!rendered.is_empty());
190        assert!(rendered.contains("Beamfile"));
191    }
192
193    /// A diagnostic with no span renders as a plain sentence: the message
194    /// and its help, with no source excerpt and no caret pointing at a
195    /// place that has nothing to do with the failure.
196    #[test]
197    fn renders_a_diagnostic_without_a_span_as_a_plain_sentence() {
198        let diagnostic = Diagnostic::new(
199            "unknown beam `biuld`",
200            None,
201            Some("did you mean `build`?".to_string()),
202        );
203
204        let rendered = render_diagnostic("beam build { run \"x\" }", "Beamfile", &diagnostic);
205
206        assert!(rendered.contains("unknown beam `biuld`"));
207        assert!(rendered.contains("did you mean `build`?"));
208        assert!(
209            !rendered.contains("Beamfile:"),
210            "no source position may be asserted, got: {rendered}"
211        );
212    }
213}