diff --git a/README.md b/README.md index b0819d3..0483998 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ diagnostic error code: ruget::api::bad_json - [... in libraries](#-in-libraries) - [... in application code](#-in-application-code) - [... in `main()`](#-in-main) + - [... diagnostic code URLs](#-diagnostic-code-urls) - [... snippets](#-snippets) - [Acknowledgements](#acknowledgements) - [License](#license) @@ -42,6 +43,7 @@ diagnostic error code: ruget::api::bad_json - Generic [Diagnostic] protocol, compatible (and dependent on) `std::error::Error`. - Unique error codes on every [Diagnostic]. +- Custom links to get more details on error codes. - Super handy derive macro for defining diagnostic metadata. - Lightweight [`anyhow`](https://docs.rs/anyhow)/[`eyre`](https://docs.rs/eyre)-style error wrapper type, [DiagnosticReport], which can be returned from `main`. @@ -54,6 +56,7 @@ The `miette` crate also comes bundles with a default [DiagnosticReportPrinter] w - Screen reader/braille support, gated on [`NO_COLOR`](http://no-color.org/), and other heuristics. - Fully customizable graphical theming (or overriding the printers entirely). - Cause chain printing +- Turns diagnostic codes into links in [supported terminals](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda). ## Installing @@ -219,6 +222,55 @@ fn pretend_this_is_main() -> DiagnosticResult<()> { } ``` +### ... diagnostic code URLs + +`miette` supports providing a URL for individual diagnostics. This URL will be +displayed as an actual link in supported terminals, like so: + + + +To use this, you can add a `url()` sub-param to your `#[diagnostic]` attribute: + +```rust +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Error, Diagnostic, Debug)] +#[diagnostic( + code(my_app::my_error), + // You can do formatting! + url("https://my_website.com/error_codes#{}", self.code()) +)] +struct MyErr; +``` + +Additionally, if you're developing a library and your error type is exported +from your crate's top level, you can use a special `url(docsrs)` option +instead of manually constructing the URL. This will automatically create a +link to this diagnostic on `docs.rs`, so folks can just go straight to +your (very high quality and detailed!) documentation on this diagnostic: + +```rust +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Error, Diagnostic, Debug)] +#[diagnostic( + code(my_app::my_error), + // Will link users to https://docs.rs/my_crate/0.0.0/my_crate/struct.MyErr.html + url(docsrs) +)] +struct MyErr; +``` + ### ... snippets Along with its general error handling and reporting features, `miette` also diff --git a/images/code_linking.png b/images/code_linking.png new file mode 100644 index 0000000..48790e6 Binary files /dev/null and b/images/code_linking.png differ diff --git a/miette-derive/src/diagnostic.rs b/miette-derive/src/diagnostic.rs index 6c14467..421da7f 100644 --- a/miette-derive/src/diagnostic.rs +++ b/miette-derive/src/diagnostic.rs @@ -7,6 +7,7 @@ use crate::diagnostic_arg::DiagnosticArg; use crate::help::Help; use crate::severity::Severity; use crate::snippets::Snippets; +use crate::url::Url; pub enum Diagnostic { Struct { @@ -17,6 +18,7 @@ pub enum Diagnostic { severity: Option, help: Option, snippets: Option, + url: Option, }, Enum { ident: syn::Ident, @@ -32,6 +34,7 @@ pub struct DiagnosticVariant { pub severity: Option, pub help: Option, pub snippets: Option, + pub url: Option, } impl Diagnostic { @@ -45,12 +48,16 @@ impl Diagnostic { let mut code = None; let mut severity = None; let mut help = None; + let mut url = None; for arg in args { match arg { DiagnosticArg::Code(new_code) => { // TODO: error on multiple? code = Some(new_code); } + DiagnosticArg::Url(u) => { + url = Some(u); + } DiagnosticArg::Severity(sev) => { severity = Some(sev); } @@ -69,6 +76,7 @@ impl Diagnostic { help, severity, snippets, + url, } } else { // Also handle when there's multiple `#[diagnostic]` attrs? @@ -88,6 +96,7 @@ impl Diagnostic { let mut code = None; let mut severity = None; let mut help = None; + let mut url = None; for arg in args { match arg { DiagnosticArg::Code(new_code) => { @@ -100,6 +109,9 @@ impl Diagnostic { DiagnosticArg::Help(hl) => { help = Some(hl); } + DiagnosticArg::Url(u) => { + url = Some(u); + } } } let snippets = Snippets::from_fields(&var.fields)?; @@ -113,6 +125,7 @@ impl Diagnostic { help, severity, snippets, + url, }); } else { // Also handle when there's multiple `#[diagnostic]` attrs? @@ -147,12 +160,14 @@ impl Diagnostic { severity, help, snippets, + url, } => { 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(fields)); let sev_body = severity.as_ref().and_then(|x| x.gen_struct()); let snip_body = snippets.as_ref().and_then(|x| x.gen_struct()); + let url_body = url.as_ref().and_then(|x| x.gen_struct(ident, fields)); quote! { impl #impl_generics miette::Diagnostic for #ident #ty_generics #where_clause { @@ -160,6 +175,7 @@ impl Diagnostic { #help_body #sev_body #snip_body + #url_body } } } @@ -173,13 +189,14 @@ impl Diagnostic { let help_body = Help::gen_enum(variants); let sev_body = Severity::gen_enum(variants); let snip_body = Snippets::gen_enum(variants); - + let url_body = Url::gen_enum(ident, variants); quote! { impl #impl_generics miette::Diagnostic for #ident #ty_generics #where_clause { #code_body #help_body #sev_body #snip_body + #url_body } } } diff --git a/miette-derive/src/diagnostic_arg.rs b/miette-derive/src/diagnostic_arg.rs index c250ab2..69cc784 100644 --- a/miette-derive/src/diagnostic_arg.rs +++ b/miette-derive/src/diagnostic_arg.rs @@ -3,11 +3,13 @@ use syn::parse::{Parse, ParseStream}; use crate::code::Code; use crate::help::Help; use crate::severity::Severity; +use crate::url::Url; pub enum DiagnosticArg { Code(Code), Severity(Severity), Help(Help), + Url(Url), } impl Parse for DiagnosticArg { @@ -19,6 +21,8 @@ impl Parse for DiagnosticArg { Ok(DiagnosticArg::Severity(input.parse()?)) } else if ident == "help" { Ok(DiagnosticArg::Help(input.parse()?)) + } else if ident == "url" { + Ok(DiagnosticArg::Url(input.parse()?)) } else { Err(syn::Error::new( ident.span(), diff --git a/miette-derive/src/lib.rs b/miette-derive/src/lib.rs index dce8a16..9a6d616 100644 --- a/miette-derive/src/lib.rs +++ b/miette-derive/src/lib.rs @@ -10,6 +10,7 @@ mod fmt; mod help; mod severity; mod snippets; +mod url; mod utils; #[proc_macro_derive(Diagnostic, attributes(diagnostic, snippet, highlight))] diff --git a/miette-derive/src/url.rs b/miette-derive/src/url.rs new file mode 100644 index 0000000..0097700 --- /dev/null +++ b/miette-derive/src/url.rs @@ -0,0 +1,204 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + parenthesized, + parse::{Parse, ParseStream}, + spanned::Spanned, + Fields, Token, +}; + +use crate::diagnostic::DiagnosticVariant; +use crate::fmt::{self, Display}; + +pub enum Url { + Display(Display), + DocsRs, +} + +impl Parse for Url { + fn parse(input: ParseStream) -> syn::Result { + let ident = input.parse::()?; + if ident == "url" { + let la = input.lookahead1(); + if la.peek(syn::token::Paren) { + let content; + parenthesized!(content in input); + if content.peek(syn::LitStr) { + let fmt = content.parse()?; + let args = if content.is_empty() { + TokenStream::new() + } else { + content.parse::()?; + fmt::parse_token_expr(&content, false)? + }; + let display = Display { + fmt, + args, + has_bonus_display: false, + }; + Ok(Url::Display(display)) + } else { + let option = content.parse::()?; + if option == "docsrs" { + Ok(Url::DocsRs) + } else { + Err(syn::Error::new(option.span(), "Invalid argument to url() sub-attribute. It must be either a string or a plain `docsrs` identifier")) + } + } + } else { + input.parse::()?; + Ok(Url::Display(Display { + fmt: input.parse()?, + args: TokenStream::new(), + has_bonus_display: false, + })) + } + } else { + Err(syn::Error::new(ident.span(), "not a url")) + } + } +} + +impl Url { + pub(crate) fn gen_enum( + enum_name: &syn::Ident, + variants: &[DiagnosticVariant], + ) -> Option { + let url_pairs = variants + .iter() + .filter(|v| v.url.is_some()) + .map( + |DiagnosticVariant { + ref ident, + ref url, + ref fields, + .. + }| { + let member_idents = fields.iter().enumerate().map(|(i, field)| { + field + .ident + .as_ref() + .cloned() + .unwrap_or_else(|| format_ident!("_{}", i)) + }); + let members: HashSet = fields.iter().enumerate().map(|(i, field)| { + if let Some(ident) = field.ident.as_ref().cloned() { + syn::Member::Named(ident) + } else { + syn::Member::Unnamed(syn::Index { index: i as u32, span: field.span() }) + } + }).collect(); + let (fmt, args) = match url.as_ref().expect("MIETTE BUG: we already checked for `Some`") { + Url::Display(display) => { + let mut display = display.clone(); + display.expand_shorthand(&members); + let Display { fmt, args, .. } = display; + (fmt.value(), args) + } + Url::DocsRs => { + let fmt = "https://docs.rs/{crate_name}/{crate_version}/{crate_name}/{item_path}".into(); + let item_path = format!("enum.{}.html#variant.{}", enum_name, ident); + let args = quote! { + crate_name=env!("CARGO_PKG_NAME"), + crate_version=env!("CARGO_PKG_VERSION"), + item_path=#item_path + }; + (fmt, args) + } + }; + match fields { + syn::Fields::Named(_) => { + quote! { Self::#ident{ #(#member_idents),* } => std::option::Option::Some(std::boxed::Box::new(format!(#fmt, #args))), } + } + syn::Fields::Unnamed(_) => { + quote! { Self::#ident( #(#member_idents),* ) => std::option::Option::Some(std::boxed::Box::new(format!(#fmt, #args))), } + } + syn::Fields::Unit => + quote! { Self::#ident => std::option::Option::Some(std::boxed::Box::new(format!(#fmt, #args))), }, + } + }, + ) + .collect::>(); + if url_pairs.is_empty() { + None + } else { + Some(quote! { + fn url<'a>(&'a self) -> std::option::Option> { + #[allow(unused_variables, deprecated)] + match self { + #(#url_pairs)* + _ => None, + } + } + }) + } + } + + pub(crate) fn gen_struct( + &self, + struct_name: &syn::Ident, + fields: &Fields, + ) -> Option { + let members: HashSet = fields + .iter() + .enumerate() + .map(|(i, field)| { + if let Some(ident) = field.ident.as_ref().cloned() { + syn::Member::Named(ident) + } else { + syn::Member::Unnamed(syn::Index { + index: i as u32, + span: field.span(), + }) + } + }) + .collect(); + let (fmt, args) = match self { + Url::Display(display) => { + let mut display = display.clone(); + display.expand_shorthand(&members); + let Display { fmt, args, .. } = display; + (fmt.value(), args) + } + Url::DocsRs => { + let fmt = + "https://docs.rs/{crate_name}/{crate_version}/{crate_name}/{item_path}".into(); + let item_path = format!("struct.{}.html", struct_name); + let args = quote! { + crate_name=env!("CARGO_PKG_NAME"), + crate_version=env!("CARGO_PKG_VERSION"), + item_path=#item_path + }; + (fmt, args) + } + }; + let members = members.iter(); + let fields_pat = match fields { + Fields::Named(_) => quote! { + let Self { #(#members),* } = self; + }, + Fields::Unnamed(_) => { + let vars = members.map(|member| { + if let syn::Member::Unnamed(member) = member { + format_ident!("_{}", member) + } else { + unreachable!() + } + }); + quote! { + let Self(#(#vars),*) = self; + } + } + Fields::Unit => quote! {}, + }; + Some(quote! { + fn url<'a>(&'a self) -> std::option::Option> { + #[allow(unused_variables, deprecated)] + #fields_pat + std::option::Option::Some(std::boxed::Box::new(format!(#fmt, #args))) + } + }) + } +} diff --git a/src/printer/graphical_printer.rs b/src/printer/graphical_printer.rs index 29c9c1a..d9dd2f9 100644 --- a/src/printer/graphical_printer.rs +++ b/src/printer/graphical_printer.rs @@ -25,6 +25,7 @@ miette::set_printer(GraphicalReportPrinter::new_themed(GraphicalTheme::unicode_n */ #[derive(Debug, Clone)] pub struct GraphicalReportPrinter { + pub(crate) linkify_code: bool, pub(crate) theme: GraphicalTheme, } @@ -33,13 +34,23 @@ impl GraphicalReportPrinter { /// [GraphicalTheme]. This will use both unicode characters and colors. pub fn new() -> Self { Self { + linkify_code: true, theme: GraphicalTheme::default(), } } ///Create a new [GraphicalReportPrinter] with a given [GraphicalTheme]. pub fn new_themed(theme: GraphicalTheme) -> Self { - Self { theme } + Self { + linkify_code: true, + theme, + } + } + + /// Disables error code linkification using [Diagnostic::url]. + pub fn without_code_linking(mut self) -> Self { + self.linkify_code = false; + self } } @@ -79,14 +90,16 @@ impl GraphicalReportPrinter { Some(Severity::Warning) => (self.theme.styles.warning, self.theme.characters.warning), Some(Severity::Advice) => (self.theme.styles.advice, self.theme.characters.point_right), }; - let code = diagnostic.code(); - writeln!( - f, - "{}[{}]{}", - self.theme.characters.hbar.to_string().repeat(4), - code.style(self.theme.styles.code), - self.theme.characters.hbar.to_string().repeat(20), - )?; + write!(f, "{}", self.theme.characters.hbar.to_string().repeat(4))?; + if self.linkify_code && diagnostic.url().is_some() { + let url = diagnostic.url().unwrap(); // safe + let code = format!("{} (click for details)", diagnostic.code()); + let link = format!("\u{1b}]8;;{}\u{1b}\\{}\u{1b}]8;;\u{1b}\\", url, code); + write!(f, "[{}]", link.style(self.theme.styles.code))?; + } else { + write!(f, "[{}]", diagnostic.code().style(self.theme.styles.code))?; + } + writeln!(f, "{}", self.theme.characters.hbar.to_string().repeat(20),)?; writeln!(f)?; writeln!( f, diff --git a/src/printer/mod.rs b/src/printer/mod.rs index cfd3d3f..74dc385 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -50,9 +50,7 @@ fn get_default_printer() -> Box Self { Self { characters: ThemeCharacters::unicode(), - styles: ThemeStyles::ansi(), + styles: ThemeStyles::rgb(), } } diff --git a/src/protocol.rs b/src/protocol.rs index 8681da6..95b43a2 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -38,6 +38,11 @@ pub trait Diagnostic: std::error::Error { None } + /// URL to visit for a more details explanation/help about this Diagnostic. + fn url<'a>(&'a self) -> Option> { + None + } + /// Additional contextual snippets. This is typically used for adding /// marked-up source file output the way compilers often do. fn snippets(&self) -> Option> + '_>> { diff --git a/tests/derive.rs b/tests/derive.rs index eaca501..abc11bc 100644 --- a/tests/derive.rs +++ b/tests/derive.rs @@ -293,3 +293,32 @@ fn test_snippet_enum() { ), } } + +#[test] +fn url_basic() { + #[derive(Debug, Diagnostic, Error)] + #[error("welp")] + #[diagnostic(code(foo::bar::baz), url("https://example.com/foo/bar"))] + struct Foo {} + + assert_eq!( + "https://example.com/foo/bar".to_string(), + Foo {}.url().unwrap().to_string() + ); +} + +#[test] +fn url_docsrs() { + #[derive(Debug, Diagnostic, Error)] + #[error("welp")] + #[diagnostic(code(foo::bar::baz), url(docsrs))] + struct Foo {} + + assert_eq!( + format!( + "https://docs.rs/miette/{}/miette/struct.Foo.html", + env!("CARGO_PKG_VERSION") + ), + Foo {}.url().unwrap().to_string() + ); +} diff --git a/tests/printer.rs b/tests/printer.rs index cc9070e..dfd5858 100644 --- a/tests/printer.rs +++ b/tests/printer.rs @@ -407,3 +407,45 @@ fn multiple_multiline_highlights_overlapping_offsets() -> Result<(), MietteError assert_eq!("Error [oops::my::bad]: oops!\n\n[bad_file.rs] This is the part that broke:\n\n 1 │ source\n 2 │ text\n · ──┬─\n · ╰── this bit here\n 3 │ here\n\n﹦ try doing it better next time?\n".to_string(), out); Ok(()) } + +#[test] +fn url_links() -> Result<(), MietteError> { + #[derive(Debug, Diagnostic, Error)] + #[error("oops!")] + #[diagnostic( + code(oops::my::bad), + help("try doing it better next time?"), + url("https://example.com") + )] + struct MyBad; + let err = MyBad; + let out = fmt_report(err.into()); + println!("{}", out); + assert!(out.contains("https://example.com")); + assert!(out.contains("click for details")); + assert!(out.contains("oops::my::bad")); + Ok(()) +} + +#[test] +fn disable_url_links() -> Result<(), MietteError> { + #[derive(Debug, Diagnostic, Error)] + #[error("oops!")] + #[diagnostic( + code(oops::my::bad), + help("try doing it better next time?"), + url("https://example.com") + )] + struct MyBad; + let err = MyBad; + let mut out = String::new(); + GraphicalReportPrinter::new_themed(GraphicalTheme::unicode_nocolor()) + .without_code_linking() + .render_report(&mut out, &err) + .unwrap(); + println!("{}", out); + assert!(!out.contains("https://example.com")); + assert!(!out.contains("click for details")); + assert!(out.contains("oops::my::bad")); + Ok(()) +}