Repository: zkat/miette
Pull request #90 (2021-10-18 21:09:50)

feat(handlers): Add JSON handler
Closes: #29 

dvermd: As of #88, tests are to be run with `cargo test --features=json,fancy --all`

Patch:
diff --git a/src/handlers/json.rs b/src/handlers/json.rs
new file mode 100644
index 0000000..7508db0
--- /dev/null
+++ b/src/handlers/json.rs
@@ -0,0 +1,129 @@
+use std::fmt;
+
+use crate::{protocol::Diagnostic, ReportHandler, Severity};
+
+/**
+[ReportHandler] that renders json output.
+It's a machine-readable output.
+*/
+#[derive(Debug, Clone)]
+pub struct JSONReportHandler;
+
+impl JSONReportHandler {
+    /// Create a new [JSONReportHandler]. There are no customization
+    /// options.
+    pub fn new() -> Self {
+        Self
+    }
+}
+
+impl Default for JSONReportHandler {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+fn escape(input: &str) -> String {
+    input
+        .chars()
+        .map(|c| match c {
+            '"' => "\\\\\"".to_string(),
+            '\'' => "\\\\'".to_string(),
+            '\r' => "\\\\r".to_string(),
+            '\n' => "\\\\n".to_string(),
+            '\t' => "\\\\t".to_string(),
+            '\u{08}' => "\\\\b".to_string(),
+            '\u{0c}' => "\\\\f".to_string(),
+            c => format!("{}", c),
+        })
+        .collect()
+}
+
+impl JSONReportHandler {
+    /// Render a [Diagnostic]. This function is mostly internal and meant to
+    /// be called by the toplevel [ReportHandler] handler, but is
+    /// made public to make it easier (possible) to test in isolation from
+    /// global state.
+    pub fn render_report(
+        &self,
+        f: &mut impl fmt::Write,
+        diagnostic: &(dyn Diagnostic),
+    ) -> fmt::Result {
+        write!(f, r#"{{"message": "{}","#, escape(&diagnostic.to_string()))?;
+        if let Some(code) = diagnostic.code() {
+            write!(f, r#""code": "{}","#, escape(&code.to_string()))?;
+        }
+        let severity = match diagnostic.severity() {
+            Some(Severity::Error) | None => "error",
+            Some(Severity::Warning) => "warning",
+            Some(Severity::Advice) => "advice",
+        };
+        write!(f, r#""severity": "{:}","#, severity)?;
+        if let Some(url) = diagnostic.url() {
+            write!(f, r#""url": "{}","#, &url.to_string())?;
+        }
+        if let Some(help) = diagnostic.help() {
+            write!(f, r#""help": "{}","#, escape(&help.to_string()))?;
+        }
+        if diagnostic.source_code().is_some() {
+            self.render_snippets(f, diagnostic)?;
+        }
+        if let Some(labels) = diagnostic.labels() {
+            write!(f, r#""labels": ["#)?;
+            let mut add_comma = false;
+            for label in labels {
+                if add_comma {
+                    write!(f, ",")?;
+                } else {
+                    add_comma = true;
+                }
+                write!(f, "{{")?;
+                if let Some(label_name) = label.label() {
+                    write!(f, r#""label": "{}","#, escape(label_name))?;
+                }
+                write!(f, r#""span": {{"#)?;
+                write!(f, r#""offset": {},"#, label.offset())?;
+                write!(f, r#""length": {}"#, label.len())?;
+
+                write!(f, "}}}}")?;
+            }
+            write!(f, "],")?;
+        } else {
+            write!(f, r#""labels": [],"#)?;
+        }
+        if let Some(relateds) = diagnostic.related() {
+            write!(f, r#""related": ["#)?;
+            for related in relateds {
+                self.render_report(f, related)?;
+            }
+            write!(f, "]")?;
+        } else {
+            write!(f, r#""related": []"#)?;
+        }
+        write!(f, "}}")
+    }
+
+    fn render_snippets(
+        &self,
+        f: &mut impl fmt::Write,
+        diagnostic: &(dyn Diagnostic),
+    ) -> fmt::Result {
+        if let Some(source) = diagnostic.source_code() {
+            if let Some(mut labels) = diagnostic.labels() {
+                if let Some(label) = labels.next() {
+                    if let Ok(span_content) = source.read_span(label.inner(), 0, 0) {
+                        let filename = span_content.name().unwrap_or_default();
+                        return write!(f, r#""filename": "{}","#, escape(filename));
+                    }
+                }
+            }
+        }
+        write!(f, r#""filename": "","#)
+    }
+}
+
+impl ReportHandler for JSONReportHandler {
+    fn debug(&self, diagnostic: &(dyn Diagnostic), f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        self.render_report(f, diagnostic)
+    }
+}
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
index 7823937..5df8e73 100644
--- a/src/handlers/mod.rs
+++ b/src/handlers/mod.rs
@@ -8,6 +8,8 @@ pub use debug::*;
 #[cfg(feature = "fancy")]
 pub use graphical::*;
 #[allow(unreachable_pub)]
+pub use json::*;
+#[allow(unreachable_pub)]
 pub use narratable::*;
 #[allow(unreachable_pub)]
 #[cfg(feature = "fancy")]
@@ -16,6 +18,7 @@ pub use theme::*;
 mod debug;
 #[cfg(feature = "fancy")]
 mod graphical;
+mod json;
 mod narratable;
 #[cfg(feature = "fancy")]
 mod theme;


Test patch:
diff --git a/tests/test_json.rs b/tests/test_json.rs
new file mode 100644
index 0000000..c56217c
--- /dev/null
+++ b/tests/test_json.rs
@@ -0,0 +1,789 @@
+mod json_report_handler {
+
+    use miette::{Diagnostic, MietteError, NamedSource, Report, SourceSpan};
+
+    use miette::JSONReportHandler;
+
+    use thiserror::Error;
+
+    fn fmt_report(diag: Report) -> String {
+        let mut out = String::new();
+        JSONReportHandler::new()
+            .render_report(&mut out, diag.as_ref())
+            .unwrap();
+        out
+    }
+
+    #[test]
+    fn single_line_with_wide_char() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label("this bit here")]
+            highlight: SourceSpan,
+        }
+
+        let src = "source\n  👼🏼text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight: (9, 6).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "this bit here",
+                    "span": {
+                        "offset": 9,
+                        "length": 6
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn single_line_highlight() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label("this bit here")]
+            highlight: SourceSpan,
+        }
+
+        let src = "source\n  text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight: (9, 4).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "this bit here",
+                    "span": {
+                        "offset": 9,
+                        "length": 4
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn single_line_highlight_offset_zero() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label("this bit here")]
+            highlight: SourceSpan,
+        }
+
+        let src = "source\n  text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight: (0, 0).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "this bit here",
+                    "span": {
+                        "offset": 0,
+                        "length": 0
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn single_line_highlight_with_empty_span() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label("this bit here")]
+            highlight: SourceSpan,
+        }
+
+        let src = "source\n  text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight: (9, 0).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "this bit here",
+                    "span": {
+                        "offset": 9,
+                        "length": 0
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn single_line_highlight_no_label() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label]
+            highlight: SourceSpan,
+        }
+
+        let src = "source\n  text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight: (9, 4).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "span": {
+                        "offset": 9,
+                        "length": 4
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn single_line_highlight_at_line_start() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label("this bit here")]
+            highlight: SourceSpan,
+        }
+
+        let src = "source\ntext\n  here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight: (7, 4).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "this bit here",
+                    "span": {
+                        "offset": 7,
+                        "length": 4
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn multiple_same_line_highlights() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label = "x"]
+            highlight1: SourceSpan,
+            #[label = "y"]
+            highlight2: SourceSpan,
+            #[label = "z"]
+            highlight3: SourceSpan,
+        }
+
+        let src = "source\n  text text text text text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight1: (9, 4).into(),
+            highlight2: (14, 4).into(),
+            highlight3: (24, 4).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "x",
+                    "span": {
+                        "offset": 9,
+                        "length": 4
+                    }
+                },
+                {
+                    "label": "y",
+                    "span": {
+                        "offset": 14,
+                        "length": 4
+                    }
+                },
+                {
+                    "label": "z",
+                    "span": {
+                        "offset": 24,
+                        "length": 4
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn multiline_highlight_adjacent() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label = "these two lines"]
+            highlight: SourceSpan,
+        }
+
+        let src = "source\n  text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight: (9, 11).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "these two lines",
+                    "span": {
+                        "offset": 9,
+                        "length": 11
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn multiline_highlight_flyby() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label = "block 1"]
+            highlight1: SourceSpan,
+            #[label = "block 2"]
+            highlight2: SourceSpan,
+        }
+
+        let src = r#"line1
+    line2
+    line3
+    line4
+    line5
+    "#
+        .to_string();
+        let len = src.len();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight1: (0, len).into(),
+            highlight2: (10, 9).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "block 1",
+                    "span": {
+                        "offset": 0,
+                        "length": 50
+                    }
+                },
+                {
+                    "label": "block 2",
+                    "span": {
+                        "offset": 10,
+                        "length": 9
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn multiline_highlight_no_label() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("wtf?!\nit broke :(")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source]
+            source: Inner,
+            #[source_code]
+            src: NamedSource,
+            #[label = "block 1"]
+            highlight1: SourceSpan,
+            #[label]
+            highlight2: SourceSpan,
+        }
+
+        #[derive(Debug, Error)]
+        #[error("something went wrong\n\nHere's a more detailed explanation of everything that actually went wrong because it's actually important.\n")]
+        struct Inner(#[source] InnerInner);
+
+        #[derive(Debug, Error)]
+        #[error("very much went wrong")]
+        struct InnerInner;
+
+        let src = r#"line1
+    line2
+    line3
+    line4
+    line5
+    "#
+        .to_string();
+        let len = src.len();
+        let err = MyBad {
+            source: Inner(InnerInner),
+            src: NamedSource::new("bad_file.rs", src),
+            highlight1: (0, len).into(),
+            highlight2: (10, 9).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "wtf?!\\nit broke :(",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "block 1",
+                    "span": {
+                        "offset": 0,
+                        "length": 50
+                    }
+                },
+                {
+                    "span": {
+                        "offset": 10,
+                        "length": 9
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn multiple_multiline_highlights_adjacent() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label = "this bit here"]
+            highlight1: SourceSpan,
+            #[label = "also this bit"]
+            highlight2: SourceSpan,
+        }
+
+        let src = "source\n  text\n    here\nmore here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight1: (0, 10).into(),
+            highlight2: (20, 6).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "this bit here",
+                    "span": {
+                        "offset": 0,
+                        "length": 10
+                    }
+                },
+                {
+                    "label": "also this bit",
+                    "span": {
+                        "offset": 20,
+                        "length": 6
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn multiple_multiline_highlights_overlapping_lines() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label = "this bit here"]
+            highlight1: SourceSpan,
+            #[label = "also this bit"]
+            highlight2: SourceSpan,
+        }
+
+        let src = "source\n  text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight1: (0, 8).into(),
+            highlight2: (9, 10).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "this bit here",
+                    "span": {
+                        "offset": 0,
+                        "length": 8
+                    }
+                },
+                {
+                    "label": "also this bit",
+                    "span": {
+                        "offset": 9,
+                        "length": 10
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn multiple_multiline_highlights_overlapping_offsets() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label = "this bit here"]
+            highlight1: SourceSpan,
+            #[label = "also this bit"]
+            highlight2: SourceSpan,
+        }
+
+        let src = "source\n  text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src),
+            highlight1: (0, 8).into(),
+            highlight2: (10, 10).into(),
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "this bit here",
+                    "span": {
+                        "offset": 0,
+                        "length": 8
+                    }
+                },
+                {
+                    "label": "also this bit",
+                    "span": {
+                        "offset": 10,
+                        "length": 10
+                    }
+                }
+            ],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn url() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(help("try doing it better next time?"), url("https://example.com"))]
+        struct MyBad;
+
+        let err = MyBad;
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "severity": "error",
+            "url": "https://example.com",
+            "help": "try doing it better next time?",
+            "labels": [],
+            "related": []
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+
+    #[test]
+    fn related() -> Result<(), MietteError> {
+        #[derive(Debug, Diagnostic, Error)]
+        #[error("oops!")]
+        #[diagnostic(code(oops::my::bad), help("try doing it better next time?"))]
+        struct MyBad {
+            #[source_code]
+            src: NamedSource,
+            #[label("this bit here")]
+            highlight: SourceSpan,
+            #[related]
+            related: Vec<MyBad>,
+        }
+
+        let src = "source\n  text\n    here".to_string();
+        let err = MyBad {
+            src: NamedSource::new("bad_file.rs", src.clone()),
+            highlight: (9, 4).into(),
+            related: vec![MyBad {
+                src: NamedSource::new("bad_file2.rs", src),
+                highlight: (0, 6).into(),
+                related: vec![],
+            }],
+        };
+        let out = fmt_report(err.into());
+        println!("Error: {}", out);
+        let expected: String = r#"
+        {
+            "message": "oops!",
+            "code": "oops::my::bad",
+            "severity": "error",
+            "help": "try doing it better next time?",
+            "filename": "bad_file.rs",
+            "labels": [
+                {
+                    "label": "this bit here",
+                    "span": {
+                        "offset": 9,
+                        "length": 4
+                    }
+                }
+            ],
+            "related": [{
+                "message": "oops!",
+                "code": "oops::my::bad",
+                "severity": "error",
+                "help": "try doing it better next time?",
+                "filename": "bad_file2.rs",
+                "labels": [
+                    {
+                        "label": "this bit here",
+                        "span": {
+                            "offset": 0,
+                            "length": 6
+                        }
+                    }
+                ],
+                "related": []
+            }]
+        }"#
+        .lines()
+        .into_iter()
+        .map(|s| s.trim_matches(|c| c == ' ' || c == '\n'))
+        .collect();
+        assert_eq!(expected, out);
+        Ok(())
+    }
+}