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(())
+}