syn_miette/
lib.rs

1//! A [`syn::Error`] wrapper that provides pretty diagnostic messages using [`miette`].
2//!
3//! # Usage
4//! ```rust
5//! let source = r"
6//! pub struct {
7//!     num_yaks: usize
8//! }";
9//!
10//! let error = syn::parse_str::<syn::DeriveInput>(source).unwrap_err();
11//! let error = syn_miette::Error::new(error, source);
12//!
13//! assert_eq!(
14//!     error.render(), // only with `--feature render`
15//! "  × expected identifier
16//!    ╭─[2:12]
17//!  1 │ 
18//!  2 │ pub struct {
19//!    ·            ┬
20//!    ·            ╰── expected identifier
21//!  3 │     num_yaks: usize
22//!    ╰────
23//! "
24//! );
25//! ```
26//!
27//!
28//! Notably, [`Error`] properly renders children that have been [`syn::Error::combine`]-ed:
29//! ```text
30#![doc = include_str!("doc-snapshots/multiple")]
31//! ```
32
33#![allow(rustdoc::redundant_explicit_links)] // required for cargo-rdme
34#![cfg_attr(do_doc_cfg, feature(doc_cfg))]
35
36use std::{fmt, sync::Arc};
37
38use miette::{
39    Diagnostic, LabeledSpan, MietteSpanContents, SourceCode, SourceOffset, SourceSpan, SpanContents,
40};
41
42/// A [`syn::Error`] wrapper that provides pretty diagnostic messages.
43///
44/// See the [module documentation](mod@self) for more.
45#[derive(Debug, Clone)]
46pub struct Error {
47    source_code: MaybeNamedSource<Arc<str>>,
48    syn_error: syn::Error,
49}
50
51impl Error {
52    /// Create an error without a filename.
53    ///
54    /// ```text
55    ///   ╭────[1:1]
56    /// 1 │ struct Foo
57    ///   · ▲
58    ///   · ╰── unexpected end of input, expected one of: `where`, parentheses, curly braces, `;`
59    ///   ╰────
60    /// ```
61    ///
62    /// Note: if the source code and the [`syn::Error`] don't correlate, then
63    /// [rendering](miette::ReportHandler) will be incorrect, and may fail.
64    ///
65    /// This is because the [`syn::Error::span`] may be out-of-bounds.
66    pub fn new(syn_error: syn::Error, source_code: impl Into<Arc<str>>) -> Self {
67        Self {
68            source_code: MaybeNamedSource {
69                file_name: None,
70                source_code: source_code.into(),
71            },
72            syn_error,
73        }
74    }
75    /// Create an error with a filename for the source code.
76    ///
77    /// ```text
78    ///   ╭─[/path/to/file:1:1]
79    /// 1 │ struct Foo
80    ///   · ▲
81    ///   · ╰── unexpected end of input, expected one of: `where`, parentheses, curly braces, `;`
82    ///   ╰────
83    /// ```
84    ///
85    /// Note: if the source code and the [`syn::Error`] don't correlate, then
86    /// [rendering](miette::ReportHandler) will be incorrect, and may fail.
87    ///
88    /// This is because the [`syn::Error::span`] may be out-of-bounds.
89    pub fn new_named(
90        syn_error: syn::Error,
91        source_code: impl Into<Arc<str>>,
92        file_name: impl fmt::Display,
93    ) -> Self {
94        Self {
95            source_code: MaybeNamedSource {
96                file_name: Some(file_name.to_string()),
97                source_code: source_code.into(),
98            },
99            syn_error,
100        }
101    }
102    /// Get a reference to the source code.
103    pub fn source_code(&self) -> &Arc<str> {
104        &self.source_code.source_code
105    }
106    /// Get a shared reference to the [`syn::Error`].
107    pub fn get(&mut self) -> &syn::Error {
108        &self.syn_error
109    }
110    /// Get an exclusive reference to the [`syn::Error`], for e.g calling [`syn::Error::combine`].
111    pub fn get_mut(&mut self) -> &mut syn::Error {
112        &mut self.syn_error
113    }
114    /// Convert this back to the original [`syn::Error`], discarding the source code.
115    pub fn into_inner(self) -> syn::Error {
116        self.into()
117    }
118    /// Convenience method for fancy-rendering this error with [`miette::GraphicalTheme::unicode_nocolor`].
119    /// # Panics
120    /// - if [`miette::GraphicalReportHandler::render_report`] fails.
121    #[cfg_attr(do_doc_cfg, doc(cfg(feature = "render")))]
122    #[cfg(feature = "render")]
123    pub fn render(&self) -> String {
124        use miette::{GraphicalReportHandler, GraphicalTheme};
125        let mut s = String::new();
126        GraphicalReportHandler::new_themed(GraphicalTheme::unicode_nocolor())
127            .with_width(80)
128            .render_report(&mut s, self)
129            .unwrap();
130        s
131    }
132}
133
134impl From<Error> for syn::Error {
135    fn from(value: Error) -> Self {
136        value.syn_error
137    }
138}
139
140impl Diagnostic for Error {
141    fn source_code(&self) -> Option<&dyn SourceCode> {
142        Some(&self.source_code as &dyn SourceCode)
143    }
144
145    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
146        Some(Box::new((&self.syn_error).into_iter().map(
147            move |syn_error| {
148                let span = syn_error.span();
149                let span_start = span.start();
150                let span_end = span.end();
151                let start_offset = SourceOffset::from_location(
152                    &self.source_code.source_code,
153                    span_start.line,
154                    span_start.column + 1,
155                );
156                let end_offset = SourceOffset::from_location(
157                    &self.source_code.source_code,
158                    span_end.line,
159                    span_end.column + 1,
160                );
161                let length = end_offset.offset() - start_offset.offset();
162                LabeledSpan::new_with_span(
163                    Some(syn_error.to_string()),
164                    SourceSpan::new(start_offset, length),
165                )
166            },
167        )))
168    }
169}
170
171impl std::error::Error for Error {}
172
173impl fmt::Display for Error {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        self.syn_error.fmt(f)
176    }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180struct MaybeNamedSource<T> {
181    file_name: Option<String>,
182    source_code: T,
183}
184
185impl<T> SourceCode for MaybeNamedSource<T>
186where
187    T: SourceCode,
188{
189    fn read_span<'a>(
190        &'a self,
191        span: &SourceSpan,
192        context_lines_before: usize,
193        context_lines_after: usize,
194    ) -> Result<Box<dyn SpanContents<'a> + 'a>, miette::MietteError> {
195        let contents =
196            self.source_code
197                .read_span(span, context_lines_before, context_lines_after)?;
198        let data = contents.data();
199        let span = *contents.span();
200        let line = contents.line();
201        let column = contents.column();
202        let line_count = contents.line_count();
203        match self.file_name.clone() {
204            Some(name) => Ok(Box::new(MietteSpanContents::new_named(
205                name, data, span, line, column, line_count,
206            ))),
207            None => Ok(Box::new(MietteSpanContents::new(
208                data, span, line, column, line_count,
209            ))),
210        }
211    }
212}
213
214#[cfg(all(test, feature = "render"))]
215mod tests {
216    use std::{collections::HashMap, fs, path::Path};
217
218    use proc_macro2::{Ident, Span};
219    use syn::{parse::Parse, parse::ParseStream, DeriveInput};
220
221    use super::*;
222
223    enum Behaviour {
224        IncludeSource,
225        IncludeFilename,
226    }
227
228    #[test]
229    fn basic_parse() {
230        insta::assert_snapshot!(test_parse::<DeriveInput>(
231            "struct Foo",
232            Behaviour::IncludeSource
233        ));
234        insta::assert_snapshot!(test_parse::<DeriveInput>(
235            "struct Foo",
236            Behaviour::IncludeFilename
237        ));
238    }
239
240    #[test]
241    fn call_site() {
242        insta::assert_snapshot!(Error::new(
243            syn::Error::new(Span::call_site(), "the whole thing is fucked"),
244            "this is the source code"
245        )
246        .render());
247        insta::assert_snapshot!(Error::new(
248            syn::Error::new(Span::call_site(), "the whole thing is fucked"),
249            "this is the source code\nand it's fucked on multiple\nlines"
250        )
251        .render());
252    }
253
254    #[test]
255    fn combined() {
256        insta::assert_snapshot!(test_parse::<UniqueDeriveInputs>(
257            "struct Foo;\nenum Bar {}\nunion Foo {}",
258            Behaviour::IncludeSource
259        )
260        .and_doc_snapshot("multiple"));
261    }
262
263    struct UniqueDeriveInputs {}
264
265    impl Parse for UniqueDeriveInputs {
266        fn parse(input: ParseStream) -> syn::Result<Self> {
267            let mut seen = HashMap::<Ident, DeriveInput>::new();
268            while !input.is_empty() {
269                let parsed = input.parse::<DeriveInput>()?;
270                match seen.remove(&parsed.ident) {
271                    Some(duplicate) => {
272                        let mut root = syn::Error::new(
273                            parsed.ident.span(),
274                            format!("duplicate definition of `{}`", parsed.ident),
275                        );
276                        root.combine(syn::Error::new(
277                            duplicate.ident.span(),
278                            "initial definition here",
279                        ));
280                        return Err(root);
281                    }
282                    None => {
283                        seen.insert(parsed.ident.clone(), parsed);
284                    }
285                }
286            }
287            Ok(Self {})
288        }
289    }
290
291    #[track_caller]
292    fn test_parse<T: Parse>(source_code: &str, behaviour: Behaviour) -> String {
293        let Err(error) = syn::parse_str::<T>(source_code) else {
294            panic!("parsing succeeded where it was expected to fail")
295        };
296        let error = match behaviour {
297            Behaviour::IncludeSource => Error::new(error, source_code),
298            Behaviour::IncludeFilename => Error::new_named(error, source_code, "/path/to/file"),
299        };
300        error.render()
301    }
302
303    trait AndDocSnapshot: AsRef<str> {
304        #[track_caller]
305        fn and_doc_snapshot(&self, name: &str) -> &Self {
306            let path = Path::new(env!("CARGO_MANIFEST_DIR"))
307                .join("src/doc-snapshots")
308                .join(name);
309            match fs::read_to_string(&path)
310                .map(|it| it == self.as_ref())
311                .unwrap_or(false)
312            {
313                true => {}
314                false => match fs::write(path, self.as_ref()) {
315                    Ok(()) => panic!(
316                        "doc snapshot {} was out of date - a new one has been written",
317                        name
318                    ),
319                    Err(e) => panic!(
320                        "doc snapshot {} was out of date, and a new one couldn't be written: {}",
321                        name, e
322                    ),
323                },
324            }
325
326            self
327        }
328    }
329    impl<T> AndDocSnapshot for T where T: AsRef<str> {}
330}