feat(links): added URL linking support and automatic docs.rs link generation

Fixes: https://github.com/zkat/miette/issues/17
This commit is contained in:
Kat Marchán 2021-08-22 15:41:53 -07:00
parent 3546dcec98
commit 7e76e2dea4
No known key found for this signature in database
GPG Key ID: AEB529C08A3C7E9E
13 changed files with 382 additions and 14 deletions

View File

@ -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:
<img
src="https://raw.githubusercontent.com/zkat/miette/main/images/code_linking.png"
alt=" Example showing the graphical report printer for miette pretty-printing
an error code. The code is underlined and followed by text saying to 'click
here'. A hover tooltip shows a full-fledged URL that can be Ctrl+Clicked to
open in a browser.
\
This feature is also available in the narratable printer. It will add a line after printing the error code showing a plain URL that you can visit.
">
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

BIN
images/code_linking.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View File

@ -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<Severity>,
help: Option<Help>,
snippets: Option<Snippets>,
url: Option<Url>,
},
Enum {
ident: syn::Ident,
@ -32,6 +34,7 @@ pub struct DiagnosticVariant {
pub severity: Option<Severity>,
pub help: Option<Help>,
pub snippets: Option<Snippets>,
pub url: Option<Url>,
}
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
}
}
}

View File

@ -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(),

View File

@ -10,6 +10,7 @@ mod fmt;
mod help;
mod severity;
mod snippets;
mod url;
mod utils;
#[proc_macro_derive(Diagnostic, attributes(diagnostic, snippet, highlight))]

204
miette-derive/src/url.rs Normal file
View File

@ -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<Self> {
let ident = input.parse::<syn::Ident>()?;
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::<Token![,]>()?;
fmt::parse_token_expr(&content, false)?
};
let display = Display {
fmt,
args,
has_bonus_display: false,
};
Ok(Url::Display(display))
} else {
let option = content.parse::<syn::Ident>()?;
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::<Token![=]>()?;
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<TokenStream> {
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<syn::Member> = 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::<Vec<_>>();
if url_pairs.is_empty() {
None
} else {
Some(quote! {
fn url<'a>(&'a self) -> std::option::Option<std::boxed::Box<dyn std::fmt::Display + 'a>> {
#[allow(unused_variables, deprecated)]
match self {
#(#url_pairs)*
_ => None,
}
}
})
}
}
pub(crate) fn gen_struct(
&self,
struct_name: &syn::Ident,
fields: &Fields,
) -> Option<TokenStream> {
let members: HashSet<syn::Member> = 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<std::boxed::Box<dyn std::fmt::Display + 'a>> {
#[allow(unused_variables, deprecated)]
#fields_pat
std::option::Option::Some(std::boxed::Box::new(format!(#fmt, #args)))
}
})
}
}

View File

@ -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,

View File

@ -50,9 +50,7 @@ fn get_default_printer() -> Box<dyn DiagnosticReportPrinter + Send + Sync + 'sta
atty::is(Stream::Stdout) && atty::is(Stream::Stderr) && !ci_info::is_ci()
};
if fancy {
Box::new(GraphicalReportPrinter {
theme: GraphicalTheme::default(),
})
Box::new(GraphicalReportPrinter::new())
} else {
Box::new(NarratableReportPrinter)
}

View File

@ -76,6 +76,9 @@ impl NarratableReportPrinter {
writeln!(f, "diagnostic help: {}", help)?;
}
writeln!(f, "diagnostic error code: {}", diagnostic.code())?;
if let Some(url) = diagnostic.url() {
writeln!(f, "For more details, see {}", url)?;
}
Ok(())
}

View File

@ -31,7 +31,7 @@ impl GraphicalTheme {
pub fn unicode() -> Self {
Self {
characters: ThemeCharacters::unicode(),
styles: ThemeStyles::ansi(),
styles: ThemeStyles::rgb(),
}
}

View File

@ -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<Box<dyn Display + 'a>> {
None
}
/// Additional contextual snippets. This is typically used for adding
/// marked-up source file output the way compilers often do.
fn snippets(&self) -> Option<Box<dyn Iterator<Item = DiagnosticSnippet<'_>> + '_>> {

View File

@ -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()
);
}

View File

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