Repository: zkat/miette
Pull request #18 (2021-08-16 16:06:45)

Support for deriving snippet method
Fixes: #11 


Patch:
diff --git a/miette-derive/src/code.rs b/miette-derive/src/code.rs
index f607f49..c1edd17 100644
--- a/miette-derive/src/code.rs
+++ b/miette-derive/src/code.rs
@@ -6,7 +6,7 @@ use syn::{
     Token,
 };
 
-use crate::diagnostic::{Diagnostic, DiagnosticVariant};
+use crate::diagnostic::DiagnosticVariant;
 
 #[derive(Debug)]
 pub struct Code(pub String);
@@ -44,10 +44,7 @@ impl Parse for Code {
 }
 
 impl Code {
-    pub(crate) fn gen_enum(
-        _diag: &Diagnostic,
-        variants: &[DiagnosticVariant],
-    ) -> Option<TokenStream> {
+    pub(crate) fn gen_enum(variants: &[DiagnosticVariant]) -> Option<TokenStream> {
         let code_pairs = variants.iter().map(
             |DiagnosticVariant {
                  ref ident,
diff --git a/miette-derive/src/diagnostic.rs b/miette-derive/src/diagnostic.rs
index 790a097..8e9a47a 100644
--- a/miette-derive/src/diagnostic.rs
+++ b/miette-derive/src/diagnostic.rs
@@ -6,6 +6,7 @@ use crate::code::Code;
 use crate::diagnostic_arg::DiagnosticArg;
 use crate::help::Help;
 use crate::severity::Severity;
+use crate::snippets::Snippets;
 
 pub enum Diagnostic {
     Struct {
@@ -14,6 +15,7 @@ pub enum Diagnostic {
         code: Code,
         severity: Option<Severity>,
         help: Option<Help>,
+        snippets: Option<Snippets>,
     },
     Enum {
         ident: syn::Ident,
@@ -28,12 +30,13 @@ pub struct DiagnosticVariant {
     pub code: Code,
     pub severity: Option<Severity>,
     pub help: Option<Help>,
+    pub snippets: Option<Snippets>,
 }
 
 impl Diagnostic {
     pub fn from_derive_input(input: DeriveInput) -> Result<Self, syn::Error> {
         Ok(match input.data {
-            syn::Data::Struct(_) => {
+            syn::Data::Struct(data_struct) => {
                 if let Some(attr) = input.attrs.iter().find(|x| x.path.is_ident("diagnostic")) {
                     let args = attr.parse_args_with(
                         Punctuated::<DiagnosticArg, Token![,]>::parse_terminated,
@@ -53,6 +56,7 @@ impl Diagnostic {
                             DiagnosticArg::Help(hl) => help = Some(hl),
                         }
                     }
+                    let snippets = Snippets::from_fields(&data_struct.fields)?;
                     let ident = input.ident.clone();
                     Diagnostic::Struct {
                         ident: input.ident,
@@ -62,6 +66,7 @@ impl Diagnostic {
                         })?,
                         help,
                         severity,
+                        snippets,
                     }
                 } else {
                     // Also handle when there's multiple `#[diagnostic]` attrs?
@@ -95,6 +100,7 @@ impl Diagnostic {
                                 }
                             }
                         }
+                        let snippets = Snippets::from_fields(&var.fields)?;
                         let ident = input.ident.clone();
                         vars.push(DiagnosticVariant {
                             ident: var.ident,
@@ -104,6 +110,7 @@ impl Diagnostic {
                             })?,
                             help,
                             severity,
+                            snippets,
                         });
                     } else {
                         // Also handle when there's multiple `#[diagnostic]` attrs?
@@ -136,17 +143,20 @@ impl Diagnostic {
                 code,
                 severity,
                 help,
+                snippets,
             } => {
                 let (impl_generics, ty_generics, where_clause) = &generics.split_for_impl();
                 let code_body = code.gen_struct();
                 let help_body = help.as_ref().and_then(|x| x.gen_struct());
                 let sev_body = severity.as_ref().and_then(|x| x.gen_struct());
+                let snip_body = snippets.as_ref().and_then(|x| x.gen_struct());
 
                 quote! {
                     impl #impl_generics miette::Diagnostic for #ident #ty_generics #where_clause {
                         #code_body
                         #help_body
                         #sev_body
+                        #snip_body
                     }
                 }
             }
@@ -156,15 +166,17 @@ impl Diagnostic {
                 variants,
             } => {
                 let (impl_generics, ty_generics, where_clause) = &generics.split_for_impl();
-                let code_body = Code::gen_enum(self, variants);
-                let help_body = Help::gen_enum(self, variants);
-                let sev_body = Severity::gen_enum(self, variants);
+                let code_body = Code::gen_enum(variants);
+                let help_body = Help::gen_enum(variants);
+                let sev_body = Severity::gen_enum(variants);
+                let snip_body = Snippets::gen_enum(variants);
 
                 quote! {
                     impl #impl_generics miette::Diagnostic for #ident #ty_generics #where_clause {
                         #code_body
                         #help_body
                         #sev_body
+                        #snip_body
                     }
                 }
             }
diff --git a/miette-derive/src/help.rs b/miette-derive/src/help.rs
index 22e98b6..10dde3e 100644
--- a/miette-derive/src/help.rs
+++ b/miette-derive/src/help.rs
@@ -6,7 +6,7 @@ use syn::{
     Token,
 };
 
-use crate::diagnostic::{Diagnostic, DiagnosticVariant};
+use crate::diagnostic::DiagnosticVariant;
 
 pub struct Help {
     pub fmt: String,
@@ -65,10 +65,7 @@ impl Parse for Help {
     }
 }
 impl Help {
-    pub(crate) fn gen_enum(
-        _diag: &Diagnostic,
-        variants: &[DiagnosticVariant],
-    ) -> Option<TokenStream> {
+    pub(crate) fn gen_enum(variants: &[DiagnosticVariant]) -> Option<TokenStream> {
         let help_pairs = variants
             .iter()
             .filter(|v| v.help.is_some())
diff --git a/miette-derive/src/lib.rs b/miette-derive/src/lib.rs
index a88d868..72f2f64 100644
--- a/miette-derive/src/lib.rs
+++ b/miette-derive/src/lib.rs
@@ -8,8 +8,9 @@ mod diagnostic;
 mod diagnostic_arg;
 mod help;
 mod severity;
+mod snippets;
 
-#[proc_macro_derive(Diagnostic, attributes(diagnostic))]
+#[proc_macro_derive(Diagnostic, attributes(diagnostic, snippet, highlight))]
 pub fn derive_diagnostic(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
     let input = parse_macro_input!(input as DeriveInput);
     let cmd = match Diagnostic::from_derive_input(input) {
diff --git a/miette-derive/src/severity.rs b/miette-derive/src/severity.rs
index c585eef..df9b562 100644
--- a/miette-derive/src/severity.rs
+++ b/miette-derive/src/severity.rs
@@ -6,7 +6,7 @@ use syn::{
     Token,
 };
 
-use crate::diagnostic::{Diagnostic, DiagnosticVariant};
+use crate::diagnostic::DiagnosticVariant;
 
 pub struct Severity(pub syn::Path);
 
@@ -36,10 +36,7 @@ impl Parse for Severity {
     }
 }
 impl Severity {
-    pub(crate) fn gen_enum(
-        _diag: &Diagnostic,
-        variants: &[DiagnosticVariant],
-    ) -> Option<TokenStream> {
+    pub(crate) fn gen_enum(variants: &[DiagnosticVariant]) -> Option<TokenStream> {
         let sev_pairs = variants
             .iter()
             .filter(|v| v.severity.is_some())
diff --git a/miette-derive/src/snippets.rs b/miette-derive/src/snippets.rs
new file mode 100644
index 0000000..10f9639
--- /dev/null
+++ b/miette-derive/src/snippets.rs
@@ -0,0 +1,409 @@
+use std::collections::HashMap;
+
+use proc_macro2::TokenStream;
+use quote::{format_ident, quote, ToTokens};
+use syn::{
+    parse::{Parse, ParseStream},
+    punctuated::Punctuated,
+    spanned::Spanned,
+    Token,
+};
+
+use crate::diagnostic::DiagnosticVariant;
+
+pub struct Snippets(Vec<Snippet>);
+
+struct Snippet {
+    message: Option<MemberOrString>,
+    highlights: Vec<Highlight>,
+    source_name: MemberOrString,
+    source: syn::Member,
+    snippet: syn::Member,
+}
+
+struct Highlight {
+    highlight: syn::Member,
+    label: Option<MemberOrString>,
+}
+
+struct SnippetAttr {
+    source: syn::Member,
+    source_name: MemberOrString,
+    message: Option<MemberOrString>,
+}
+
+struct HighlightAttr {
+    snippet: syn::Member,
+    label: Option<MemberOrString>,
+}
+
+enum MemberOrString {
+    Member(syn::Member),
+    String(syn::LitStr),
+}
+
+impl ToTokens for MemberOrString {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        use MemberOrString::*;
+        match self {
+            Member(member) => member.to_tokens(tokens),
+            String(string) => string.to_tokens(tokens),
+        }
+    }
+}
+
+impl Parse for MemberOrString {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let lookahead = input.lookahead1();
+        if lookahead.peek(syn::Ident) || lookahead.peek(syn::LitInt) {
+            Ok(MemberOrString::Member(input.parse()?))
+        } else if lookahead.peek(syn::LitStr) {
+            Ok(MemberOrString::String(input.parse()?))
+        } else {
+            Err(syn::Error::new(
+                input.span(),
+                "Expected a string or a field reference.",
+            ))
+        }
+    }
+}
+
+impl Parse for SnippetAttr {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let punc = Punctuated::<MemberOrString, Token![,]>::parse_terminated(input)?;
+        let span = input.span();
+        let mut iter = punc.into_iter();
+        let source = match iter.next() {
+            Some(MemberOrString::Member(member)) => member,
+            _ => {
+                return Err(syn::Error::new(
+                    span,
+                    "Source must be an identifier that refers to a Source for this snippet.",
+                ))
+            }
+        };
+        let src_name = iter
+            .next()
+            .ok_or_else(|| syn::Error::new(span, "Expected a source name."))?;
+        let message = iter.next();
+        Ok(SnippetAttr {
+            source,
+            source_name: src_name,
+            message,
+        })
+    }
+}
+
+impl Parse for HighlightAttr {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let punc = Punctuated::<MemberOrString, Token![,]>::parse_terminated(input)?;
+        let span = input.span();
+        let mut iter = punc.into_iter();
+        let snippet =
+            match iter.next() {
+                Some(MemberOrString::Member(member)) => member,
+                _ => return Err(syn::Error::new(
+                    span,
+                    "must be an identifier that refers to something with a #[snippet] attribute.",
+                )),
+            };
+        let label = iter.next();
+        Ok(HighlightAttr { snippet, label })
+    }
+}
+
+impl Snippets {
+    pub fn from_fields(fields: &syn::Fields) -> syn::Result<Option<Self>> {
+        match fields {
+            syn::Fields::Named(named) => Self::from_fields_vec(named.named.iter().collect()),
+            syn::Fields::Unnamed(unnamed) => {
+                Self::from_fields_vec(unnamed.unnamed.iter().collect())
+            }
+            syn::Fields::Unit => Ok(None),
+        }
+    }
+
+    fn from_fields_vec(fields: Vec<&syn::Field>) -> syn::Result<Option<Self>> {
+        let mut snippets = HashMap::new();
+        // First we collect all the contexts
+        for (i, field) in fields.iter().enumerate() {
+            for attr in &field.attrs {
+                if attr.path.is_ident("snippet") {
+                    let snippet = if let Some(ident) = field.ident.clone() {
+                        syn::Member::Named(ident)
+                    } else {
+                        syn::Member::Unnamed(syn::Index {
+                            index: i as u32,
+                            span: field.span(),
+                        })
+                    };
+                    let SnippetAttr {
+                        source,
+                        message,
+                        source_name,
+                    } = attr.parse_args::<SnippetAttr>()?;
+                    // TODO: useful error when source refers to a field that doesn't exist.
+                    snippets.insert(
+                        snippet.clone(),
+                        Snippet {
+                            message,
+                            highlights: Vec::new(),
+                            source_name,
+                            source,
+                            snippet,
+                        },
+                    );
+                }
+            }
+        }
+        // Then we loop again looking for highlights
+        for (i, field) in fields.iter().enumerate() {
+            for attr in &field.attrs {
+                if attr.path.is_ident("highlight") {
+                    let HighlightAttr { snippet, label } = attr.parse_args::<HighlightAttr>()?;
+                    if let Some(snippet) = snippets.get_mut(&snippet) {
+                        let member = if let Some(ident) = field.ident.clone() {
+                            syn::Member::Named(ident)
+                        } else {
+                            syn::Member::Unnamed(syn::Index {
+                                index: i as u32,
+                                span: field.span(),
+                            })
+                        };
+                        snippet.highlights.push(Highlight {
+                            label,
+                            highlight: member,
+                        });
+                    } else {
+                        return Err(syn::Error::new(snippet.span(), "Highlight must refer to an existing field with a #[snippet(...)] attribute."));
+                    }
+                }
+            }
+        }
+        if snippets.is_empty() {
+            Ok(None)
+        } else {
+            Ok(Some(Snippets(snippets.into_values().collect())))
+        }
+    }
+
+    pub(crate) fn gen_struct(&self) -> Option<TokenStream> {
+        let snippets = self.0.iter().map(|snippet| {
+            // snippet message
+            let msg = snippet
+                .message
+                .as_ref()
+                .map(|msg| match msg {
+                    MemberOrString::String(str) => {
+                        quote! {
+                            message: std::option::Option::Some(#str.into()),
+                        }
+                    }
+                    MemberOrString::Member(m) => {
+                        quote! {
+                            message: std::option::Option::Some(self.#m.clone()),
+                        }
+                    }
+                })
+                .unwrap_or_else(|| {
+                    quote! {
+                        message: std::option::Option::None,
+                    }
+                });
+
+            // Source field
+            let src_ident = &snippet.source;
+            let src_ident = quote! {
+                // TODO: I don't like this. Think about it more and maybe improve protocol?
+                source: self.#src_ident.clone(),
+            };
+
+            // Source name
+            let src_name = match &snippet.source_name {
+                MemberOrString::String(str) => {
+                    quote! {
+                        source_name: #str.into(),
+                    }
+                }
+                MemberOrString::Member(member) => quote! {
+                    source_name: self.#member.clone(),
+                },
+            };
+
+            // Context
+            let context = &snippet.snippet;
+            let context = quote! {
+                context: self.#context.clone(),
+            };
+
+            // Highlights
+            let highlights = snippet.highlights.iter().map(|highlight| {
+                let Highlight { highlight, label } = highlight;
+                quote! {
+                    (#label.into(), self.#highlight.clone())
+                }
+            });
+            let highlights = quote! {
+                highlights: std::option::Option::Some(vec![
+                    #(#highlights),*
+                ]),
+            };
+
+            // Generate the snippet itself
+            quote! {
+                miette::DiagnosticSnippet {
+                    #msg
+                    #src_name
+                    #src_ident
+                    #context
+                    #highlights
+                }
+            }
+        });
+        Some(quote! {
+            fn snippets(&self) -> std::option::Option<std::boxed::Box<dyn std::iter::Iterator<Item = miette::DiagnosticSnippet>>> {
+                Some(Box::new(vec![
+                    #(#snippets),*
+                ].into_iter()))
+            }
+        })
+    }
+
+    pub(crate) fn gen_enum(variants: &[DiagnosticVariant]) -> Option<TokenStream> {
+        let variant_arms = variants.iter().map(|variant| {
+            variant.snippets.as_ref().map(|snippets| {
+                let variant_snippets = snippets.0.iter().map(|snippet| {
+                    // snippet message
+                    let msg = snippet
+                        .message
+                        .as_ref()
+                        .map(|msg| match msg {
+                            MemberOrString::String(str) => {
+                                quote! {
+                                    message: std::option::Option::Some(#str.into()),
+                                }
+                            }
+                            MemberOrString::Member(m) => {
+                                let m = match m {
+                                    syn::Member::Named(id) => id.clone(),
+                                    syn::Member::Unnamed(syn::Index { index, .. }) => {
+                                        format_ident!("_{}", index)
+                                    }
+                                };
+                                quote! {
+                                    message: std::option::Option::Some(#m.clone()),
+                                }
+                            }
+                        })
+                        .unwrap_or_else(|| {
+                            quote! {
+                                message: std::option::Option::None,
+                            }
+                        });
+
+                    // Source field
+                    let src_ident = match &snippet.source {
+                        syn::Member::Named(id) => id.clone(),
+                        syn::Member::Unnamed(syn::Index { index, .. }) => {
+                            format_ident!("_{}", index)
+                        }
+                    };
+                    let src_ident = quote! {
+                        // TODO: I don't like this. Think about it more and maybe improve protocol?
+                        source: #src_ident.clone(),
+                    };
+
+                    // Source name
+                    let src_name = match &snippet.source_name {
+                        MemberOrString::String(str) => {
+                            quote! {
+                                source_name: #str.into(),
+                            }
+                        }
+                        MemberOrString::Member(m) => {
+                            let m = match m {
+                                syn::Member::Named(id) => id.clone(),
+                                syn::Member::Unnamed(syn::Index { index, .. }) => {
+                                    format_ident!("_{}", index)
+                                }
+                            };
+                            quote! {
+                                source_name: #m.clone(),
+                            }
+                        }
+                    };
+
+                    // Context
+                    let context = match &snippet.snippet {
+                        syn::Member::Named(id) => id.clone(),
+                        syn::Member::Unnamed(syn::Index { index, .. }) => {
+                            format_ident!("_{}", index)
+                        }
+                    };
+                    let context = quote! {
+                        context: #context.clone(),
+                    };
+
+                    // Highlights
+                    let highlights = snippet.highlights.iter().map(|highlight| {
+                        let Highlight { highlight, label } = highlight;
+                        let m = match highlight {
+                            syn::Member::Named(id) => id.clone(),
+                            syn::Member::Unnamed(syn::Index { index, .. }) => {
+                                format_ident!("_{}", index)
+                            }
+                        };
+                        quote! {
+                            (#label.into(), #m.clone())
+                        }
+                    });
+                    let highlights = quote! {
+                        highlights: std::option::Option::Some(vec![
+                            #(#highlights),*
+                        ]),
+                    };
+
+                    // Generate the snippet itself
+                    quote! {
+                        miette::DiagnosticSnippet {
+                            #msg
+                            #src_name
+                            #src_ident
+                            #context
+                            #highlights
+                        }
+                    }
+                });
+                let variant_name = variant.ident.clone();
+                let members = variant.fields.iter().enumerate().map(|(i, field)| {
+                    field
+                        .ident
+                        .as_ref()
+                        .cloned()
+                        .unwrap_or_else(|| format_ident!("_{}", i))
+                });
+                match &variant.fields {
+                    syn::Fields::Unit => None,
+                    syn::Fields::Named(_) => Some(quote! {
+                        Self::#variant_name { #(#members),* } => std::option::Option::Some(std::boxed::Box::new(vec![
+                            #(#variant_snippets),*
+                        ].into_iter())),
+                    }),
+                    syn::Fields::Unnamed(_) => Some(quote! {
+                        Self::#variant_name(#(#members),*) => std::option::Option::Some(Box::new(vec![
+                            #(#variant_snippets),*
+                        ].into_iter())),
+                    }),
+                }
+            })
+        });
+        Some(quote! {
+            fn snippets(&self) -> std::option::Option<std::boxed::Box<dyn std::iter::Iterator<Item = miette::DiagnosticSnippet>>> {
+                match self {
+                    #(#variant_arms)*
+                    _ => std::option::Option::None,
+                }
+            }
+        })
+    }
+}


Test patch:
diff --git a/tests/derive.rs b/tests/derive.rs
index bf1ddff..a8d6df2 100644
--- a/tests/derive.rs
+++ b/tests/derive.rs
@@ -1,4 +1,6 @@
-use miette::{Diagnostic, Severity};
+use std::sync::Arc;
+
+use miette::{Diagnostic, Severity, SourceSpan};
 use thiserror::Error;
 
 #[test]
@@ -156,3 +158,108 @@ fn fmt_help() {
 
     assert_eq!("1 bar".to_string(), FooEnum::X.help().unwrap().to_string());
 }
+
+#[test]
+fn test_snippet_named_struct() {
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("welp")]
+    #[diagnostic(code(foo::bar::baz))]
+    struct Foo {
+        // The actual "source code" our contexts will be using. This can be
+        // reused by multiple contexts!
+        //
+        // The `Arc` is so you don't have to clone the entire thing into this
+        // Diagnostic. We just need to be able to read it~
+        src: Arc<String>,
+
+        // The "snippet" span. This is the span that will be displayed to
+        // users. It should be a big enough slice of the Source to provide
+        // reasonable context, but still somewhat compact.
+        //
+        // You can have as many of these #[snippet] fields as you want, and
+        // even feed them from different sources!
+        //
+        // Example display:
+        //   / [my_snippet]: hi this is where the thing went wrong.
+        // 1 | hello
+        // 2 |     world
+        #[snippet(src, "my_snippet.rs", "hi this is where the thing went wrong")]
+        snip: SourceSpan,
+
+        // "Highlights" are the specific highlights _inside_ the snippet.
+        // These will be used to underline/point to specific sections of the
+        // #[snippet] they refer to. As such, these SourceSpans must be within
+        // the bounds of their referenced snippet.
+        //
+        // Example display:
+        // 1 | var1 + var2
+        //   | ^^^^   ^^^^ - var 2
+        //   | |
+        //   | var 1
+        #[highlight(snip, "var 1")]
+        var1: SourceSpan,
+        #[highlight(snip, "var 2")]
+        var2: SourceSpan,
+
+        // Now with member source names
+        filename: String,
+        second_message: String,
+        #[snippet(src, filename, second_message)]
+        snip2: SourceSpan,
+    }
+}
+
+#[test]
+fn test_snippet_unnamed_struct() {
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("welp")]
+    #[diagnostic(code(foo::bar::baz))]
+    struct Foo(
+        Arc<String>,
+        #[snippet(0, "my_snippet.rs", "hi")] SourceSpan,
+        #[highlight(1, "var 1")] SourceSpan,
+        #[highlight(1, "var 2")] SourceSpan,
+        // referenced source name
+        String,
+        String,
+        #[snippet(0, 4, 5)] SourceSpan,
+        #[highlight(6, "var 3")] SourceSpan,
+        #[highlight(6, "var 4")] SourceSpan,
+    );
+}
+
+#[test]
+fn test_snippet_enum() {
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("welp")]
+    #[allow(dead_code)]
+    enum Foo {
+        #[diagnostic(code(foo::a))]
+        A {
+            src: Arc<String>,
+            #[snippet(src, "my_snippet.rs", "hi this is where the thing went wrong")]
+            snip: SourceSpan,
+            #[highlight(snip, "var 1")]
+            var1: SourceSpan,
+            #[highlight(snip, "var 2")]
+            var2: SourceSpan,
+            filename: String,
+            second_message: String,
+            #[snippet(src, filename, second_message)]
+            snip2: SourceSpan,
+        },
+        #[diagnostic(code(foo::b))]
+        B(
+            Arc<String>,
+            #[snippet(0, "my_snippet.rs", "hi")] SourceSpan,
+            #[highlight(1, "var 1")] SourceSpan,
+            #[highlight(1, "var 2")] SourceSpan,
+            // referenced source name
+            String,
+            String,
+            #[snippet(0, 4, 5)] SourceSpan,
+            #[highlight(6, "var 3")] SourceSpan,
+            #[highlight(6, "var 4")] SourceSpan,
+        ),
+    }
+}