From 53b246829a2cf6317fe1ac0cf7603e37ffde349f Mon Sep 17 00:00:00 2001 From: dvermd <315743+dvermd@users.noreply.github.com> Date: Mon, 15 Nov 2021 17:49:22 +0100 Subject: [PATCH] feat(handlers): Add JSON handler (#90) --- src/handlers/json.rs | 129 +++++++ src/handlers/mod.rs | 3 + tests/test_json.rs | 789 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 921 insertions(+) create mode 100644 src/handlers/json.rs create mode 100644 tests/test_json.rs 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; 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, + } + + 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(()) + } +}