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}