diff --git a/Cargo.toml b/Cargo.toml index 9fcf9bd..85df66e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ miette-derive = { version = "=0.12.0", path = "miette-derive" } once_cell = "1.8.0" owo-colors = "2.0.0" atty = "0.2.14" +ci_info = "0.14.2" [dev-dependencies] thiserror = "1.0.26" diff --git a/src/printer/mod.rs b/src/printer/mod.rs index f3b5fbd..38694ca 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -4,15 +4,18 @@ but largely meant to be an example. */ use std::fmt; +use atty::Stream; use once_cell::sync::OnceCell; use crate::protocol::{Diagnostic, DiagnosticReportPrinter, Severity}; use crate::MietteError; pub use default_printer::*; +pub use narratable_printer::*; pub use theme::*; mod default_printer; +mod narratable_printer; mod theme; static REPORTER: OnceCell> = @@ -31,12 +34,24 @@ pub fn set_reporter( /// Used by [DiagnosticReport] to fetch the reporter that will be used to /// print stuff out. pub fn get_reporter() -> &'static (dyn DiagnosticReportPrinter + Send + Sync + 'static) { - &**REPORTER.get_or_init(|| { + &**REPORTER.get_or_init(get_default_printer) +} + +fn get_default_printer() -> Box { + let fancy = if let Ok(string) = std::env::var("NO_COLOR") { + string == "0" + } else if let Ok(string) = std::env::var("CLICOLOR") { + string != "0" || string == "1" + } else { + atty::is(Stream::Stdout) && atty::is(Stream::Stderr) && !ci_info::is_ci() + }; + if fancy { Box::new(DefaultReportPrinter { - // TODO: color support detection here? theme: MietteTheme::default(), }) - }) + } else { + Box::new(NarratableReportPrinter) + } } /// Literally what it says on the tin. diff --git a/src/printer/narratable_printer.rs b/src/printer/narratable_printer.rs new file mode 100644 index 0000000..b6398eb --- /dev/null +++ b/src/printer/narratable_printer.rs @@ -0,0 +1,204 @@ +use std::fmt; + +use crate::chain::Chain; +use crate::protocol::{Diagnostic, DiagnosticReportPrinter, DiagnosticSnippet, Severity}; +use crate::{SourceSpan, SpanContents}; + +/** +Reference implementation of the [DiagnosticReportPrinter] trait. This is generally +good enough for simple use-cases, and is the default one installed with `miette`, +but you might want to implement your own if you want custom reporting for your +tool or app. +*/ +pub struct NarratableReportPrinter; + +impl NarratableReportPrinter { + pub fn new() -> Self { + Self + } +} + +impl Default for NarratableReportPrinter { + fn default() -> Self { + Self::new() + } +} + +impl NarratableReportPrinter { + pub fn render_report( + &self, + f: &mut impl fmt::Write, + diagnostic: &(dyn Diagnostic), + ) -> fmt::Result { + self.render_header(f, diagnostic)?; + self.render_causes(f, diagnostic)?; + + if let Some(snippets) = diagnostic.snippets() { + for snippet in snippets { + writeln!(f)?; + self.render_snippet(f, &snippet)?; + } + } + + self.render_footer(f, diagnostic)?; + Ok(()) + } + + fn render_header(&self, f: &mut impl fmt::Write, diagnostic: &(dyn Diagnostic)) -> fmt::Result { + writeln!(f, "{}", diagnostic)?; + let severity = match diagnostic.severity() { + Some(Severity::Error) | None => "error", + Some(Severity::Warning) => "warning", + Some(Severity::Advice) => "advice", + }; + writeln!(f, " Diagnostic severity: {}", severity)?; + Ok(()) + } + + fn render_causes(&self, f: &mut impl fmt::Write, diagnostic: &(dyn Diagnostic)) -> fmt::Result { + if let Some(cause) = diagnostic.source() { + for error in Chain::new(cause) { + writeln!(f, " Caused by: {}", error)?; + } + } + + Ok(()) + } + + fn render_footer(&self, f: &mut impl fmt::Write, diagnostic: &(dyn Diagnostic)) -> fmt::Result { + if let Some(help) = diagnostic.help() { + writeln!(f, "diagnostic help: {}", help)?; + } + writeln!(f, "diagnostic error code: {}", diagnostic.code())?; + Ok(()) + } + + fn render_snippet(&self, f: &mut impl fmt::Write, snippet: &DiagnosticSnippet) -> fmt::Result { + let (contents, lines) = self.get_lines(snippet)?; + + write!(f, "Begin snippet")?; + if let Some(filename) = snippet.context.label() { + write!(f, " for {}", filename,)?; + } + writeln!( + f, + " starting at line {}, column {}", + contents.line() + 1, + contents.column() + 1 + )?; + writeln!(f)?; + + // Highlights are the bits we're going to underline in our overall + // snippet, and we need to do some analysis first to come up with + // gutter size. + let mut highlights = snippet.highlights.clone().unwrap_or_else(Vec::new); + // sorting is your friend. + highlights.sort_unstable_by_key(|h| h.offset()); + + // Now it's time for the fun part--actually rendering everything! + for line in &lines { + writeln!(f, "snippet line {}: {}", line.line_number, line.text)?; + let relevant = highlights.iter().filter(|hl| line.span_starts(hl)); + for hl in relevant { + let contents = snippet.source.read_span(hl).map_err(|_| fmt::Error)?; + if contents.line() + 1 == line.line_number { + write!( + f, + " highlight starting at line {}, column {}", + contents.line() + 1, + contents.column() + 1 + )?; + if let Some(label) = hl.label() { + write!(f, ": {}", label)?; + } + writeln!(f)?; + } + } + } + writeln!(f)?; + Ok(()) + } + + fn get_lines<'a>( + &'a self, + snippet: &'a DiagnosticSnippet, + ) -> Result<(Box, Vec), fmt::Error> { + let context_data = snippet + .source + .read_span(&snippet.context) + .map_err(|_| fmt::Error)?; + let context = std::str::from_utf8(context_data.data()).expect("Bad utf8 detected"); + let mut line = context_data.line(); + let mut column = context_data.column(); + let mut offset = snippet.context.offset(); + let mut line_offset = offset; + let mut iter = context.chars().peekable(); + let mut line_str = String::new(); + let mut lines = Vec::new(); + while let Some(char) = iter.next() { + offset += char.len_utf8(); + match char { + '\r' => { + if iter.next_if_eq(&'\n').is_some() { + offset += 1; + line += 1; + column = 0; + } else { + line_str.push(char); + column += 1; + } + } + '\n' => { + line += 1; + column = 0; + } + _ => { + line_str.push(char); + column += 1; + } + } + if iter.peek().is_none() { + line += 1; + } + + if column == 0 || iter.peek().is_none() { + lines.push(Line { + line_number: line, + offset: line_offset, + text: line_str.clone(), + }); + line_str.clear(); + line_offset = offset; + } + } + Ok((context_data, lines)) + } +} + +impl DiagnosticReportPrinter for NarratableReportPrinter { + fn debug(&self, diagnostic: &(dyn Diagnostic), f: &mut fmt::Formatter<'_>) -> fmt::Result { + if f.alternate() { + return fmt::Debug::fmt(diagnostic, f); + } + + self.render_report(f, diagnostic) + } +} + +/* +Support types +*/ + +struct Line { + line_number: usize, + offset: usize, + text: String, +} + +impl Line { + // Does this line contain the *beginning* of this multiline span? + // This assumes self.span_applies() is true already. + fn span_starts(&self, span: &SourceSpan) -> bool { + span.offset() >= self.offset + } +} diff --git a/tests/narrated.rs b/tests/narrated.rs new file mode 100644 index 0000000..aa8c287 --- /dev/null +++ b/tests/narrated.rs @@ -0,0 +1,443 @@ +use miette::{ + DefaultReportPrinter, Diagnostic, DiagnosticReport, MietteError, MietteTheme, + NarratableReportPrinter, SourceSpan, +}; +use thiserror::Error; + +fn fmt_report(diag: DiagnosticReport) -> String { + let mut out = String::new(); + // Mostly for dev purposes. + if std::env::var("STYLE").is_ok() { + DefaultReportPrinter::new_themed(MietteTheme::unicode()) + .render_report(&mut out, diag.inner()) + .unwrap(); + } else { + NarratableReportPrinter + .render_report(&mut out, diag.inner()) + .unwrap(); + }; + out +} + +#[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 { + src: String, + #[snippet(src, "This is the part that broke")] + ctx: SourceSpan, + #[highlight(ctx)] + highlight: SourceSpan, + } + + let src = "source\n text\n here".to_string(); + let len = src.len(); + let err = MyBad { + src, + ctx: ("bad_file.rs", 0, len).into(), + highlight: ("this bit here", 9, 4).into(), + }; + let out = fmt_report(err.into()); + println!("{}", out); + let expected = r#" +oops! + Diagnostic severity: error + +Begin snippet for bad_file.rs starting at line 1, column 1 + +snippet line 1: source +snippet line 2: text + highlight starting at line 2, column 3: this bit here +snippet line 3: here + +diagnostic help: try doing it better next time? +diagnostic error code: oops::my::bad +"# + .trim_start() + .to_string(); + 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 { + src: String, + #[snippet(src, "This is the part that broke")] + ctx: SourceSpan, + #[highlight(ctx)] + highlight: SourceSpan, + } + + let src = "source\n text\n here".to_string(); + let len = src.len(); + let err = MyBad { + src, + ctx: ("bad_file.rs", 0, len).into(), + highlight: (9, 4).into(), + }; + let out = fmt_report(err.into()); + println!("{}", out); + let expected = r#" +oops! + Diagnostic severity: error + +Begin snippet for bad_file.rs starting at line 1, column 1 + +snippet line 1: source +snippet line 2: text + highlight starting at line 2, column 3 +snippet line 3: here + +diagnostic help: try doing it better next time? +diagnostic error code: oops::my::bad +"# + .trim_start() + .to_string(); + 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 { + src: String, + #[snippet(src, "This is the part that broke")] + ctx: SourceSpan, + #[highlight(ctx)] + highlight1: SourceSpan, + #[highlight(ctx)] + highlight2: SourceSpan, + } + + let src = "source\n text text text text text\n here".to_string(); + let len = src.len(); + let err = MyBad { + src, + ctx: ("bad_file.rs", 0, len).into(), + highlight1: ("this bit here", 9, 4).into(), + highlight2: ("also this bit", 14, 4).into(), + }; + let out = fmt_report(err.into()); + println!("{}", out); + let expected = r#" +oops! + Diagnostic severity: error + +Begin snippet for bad_file.rs starting at line 1, column 1 + +snippet line 1: source +snippet line 2: text text text text text + highlight starting at line 2, column 3: this bit here + highlight starting at line 2, column 8: also this bit +snippet line 3: here + +diagnostic help: try doing it better next time? +diagnostic error code: oops::my::bad +"# + .trim_start() + .to_string(); + 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 { + src: String, + #[snippet(src, "This is the part that broke")] + ctx: SourceSpan, + #[highlight(ctx)] + highlight: SourceSpan, + } + + let src = "source\n text\n here".to_string(); + let len = src.len(); + let err = MyBad { + src, + ctx: ("bad_file.rs", 0, len).into(), + highlight: ("these two lines", 9, 11).into(), + }; + let out = fmt_report(err.into()); + println!("{}", out); + let expected = r#" +oops! + Diagnostic severity: error + +Begin snippet for bad_file.rs starting at line 1, column 1 + +snippet line 1: source +snippet line 2: text + highlight starting at line 2, column 3: these two lines +snippet line 3: here + +diagnostic help: try doing it better next time? +diagnostic error code: oops::my::bad +"# + .trim_start() + .to_string(); + 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 { + src: String, + #[snippet(src, "This is the part that broke")] + ctx: SourceSpan, + #[highlight(ctx)] + highlight1: SourceSpan, + #[highlight(ctx)] + highlight2: SourceSpan, + } + + let src = r#"line1 +line2 +line3 +line4 +line5 +"# + .to_string(); + let len = src.len(); + let err = MyBad { + src, + ctx: ("bad_file.rs", 0, len).into(), + highlight1: ("block 1", 0, len).into(), + highlight2: ("block 2", 10, 9).into(), + }; + let out = fmt_report(err.into()); + println!("{}", out); + let expected = r#" +oops! + Diagnostic severity: error + +Begin snippet for bad_file.rs starting at line 1, column 1 + +snippet line 1: line1 + highlight starting at line 1, column 1: block 1 +snippet line 2: line2 + highlight starting at line 2, column 5: block 2 +snippet line 3: line3 +snippet line 4: line4 +snippet line 6: line5 + +diagnostic help: try doing it better next time? +diagnostic error code: oops::my::bad +"# + .trim_start() + .to_string(); + assert_eq!(expected, out); + Ok(()) +} + +#[test] +fn multiline_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 { + src: String, + #[snippet(src, "This is the part that broke")] + ctx: SourceSpan, + #[highlight(ctx)] + highlight1: SourceSpan, + #[highlight(ctx)] + highlight2: SourceSpan, + } + + let src = r#"line1 +line2 +line3 +line4 +line5 +"# + .to_string(); + let len = src.len(); + let err = MyBad { + src, + ctx: ("bad_file.rs", 0, len).into(), + highlight1: ("block 1", 0, len).into(), + highlight2: (10, 9).into(), + }; + let out = fmt_report(err.into()); + println!("{}", out); + let expected = r#" +oops! + Diagnostic severity: error + +Begin snippet for bad_file.rs starting at line 1, column 1 + +snippet line 1: line1 + highlight starting at line 1, column 1: block 1 +snippet line 2: line2 + highlight starting at line 2, column 5 +snippet line 3: line3 +snippet line 4: line4 +snippet line 6: line5 + +diagnostic help: try doing it better next time? +diagnostic error code: oops::my::bad +"# + .trim_start() + .to_string(); + 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 { + src: String, + #[snippet(src, "This is the part that broke")] + ctx: SourceSpan, + #[highlight(ctx)] + highlight1: SourceSpan, + #[highlight(ctx)] + highlight2: SourceSpan, + } + + let src = "source\n text\n here\nmore here".to_string(); + let len = src.len(); + let err = MyBad { + src, + ctx: ("bad_file.rs", 0, len).into(), + highlight1: ("this bit here", 0, 10).into(), + highlight2: ("also this bit", 20, 6).into(), + }; + let out = fmt_report(err.into()); + println!("{}", out); + let expected = r#" +oops! + Diagnostic severity: error + +Begin snippet for bad_file.rs starting at line 1, column 1 + +snippet line 1: source + highlight starting at line 1, column 1: this bit here +snippet line 2: text +snippet line 3: here + highlight starting at line 3, column 7: also this bit +snippet line 4: more here + +diagnostic help: try doing it better next time? +diagnostic error code: oops::my::bad +"# + .trim_start() + .to_string(); + assert_eq!(expected, out); + Ok(()) +} + +#[test] +/// Lines are overlapping, but the offsets themselves aren't, so they _look_ +/// disjunct if you only look at offsets. +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 { + src: String, + #[snippet(src, "This is the part that broke")] + ctx: SourceSpan, + #[highlight(ctx)] + highlight1: SourceSpan, + #[highlight(ctx)] + highlight2: SourceSpan, + } + + let src = "source\n text\n here".to_string(); + let len = src.len(); + let err = MyBad { + src, + ctx: ("bad_file.rs", 0, len).into(), + highlight1: ("this bit here", 0, 8).into(), + highlight2: ("also this bit", 9, 10).into(), + }; + let out = fmt_report(err.into()); + println!("{}", out); + let expected = r#" +oops! + Diagnostic severity: error + +Begin snippet for bad_file.rs starting at line 1, column 1 + +snippet line 1: source + highlight starting at line 1, column 1: this bit here +snippet line 2: text + highlight starting at line 2, column 3: also this bit +snippet line 3: here + +diagnostic help: try doing it better next time? +diagnostic error code: oops::my::bad +"# + .trim_start() + .to_string(); + assert_eq!(expected, out); + Ok(()) +} + +#[test] +/// Offsets themselves are overlapping, regardless of lines. +#[ignore] +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 { + src: String, + #[snippet(src, "This is the part that broke")] + ctx: SourceSpan, + #[highlight(ctx)] + highlight1: SourceSpan, + #[highlight(ctx)] + highlight2: SourceSpan, + } + + let src = "source\n text\n here".to_string(); + let len = src.len(); + let err = MyBad { + src, + ctx: ("bad_file.rs", 0, len).into(), + highlight1: ("this bit here", 0, 8).into(), + highlight2: ("also this bit", 10, 10).into(), + }; + let out = fmt_report(err.into()); + println!("{}", out); + let expected = r#" +oops! + Diagnostic severity: error + +Begin snippet for bad_file.rs starting at line 1, column 1 + +snippet line 1: source + highlight starting at line 1, column 1: this bit here +snippet line 2: text + highlight starting at line 2, column 4: also this bit +snippet line 3: here + +diagnostic help: try doing it better next time? +diagnostic error code: oops::my::bad +"# + .trim_start() + .to_string(); + assert_eq!(expected, out); + Ok(()) +} diff --git a/tests/printer.rs b/tests/printer.rs index 79102d2..76b028f 100644 --- a/tests/printer.rs +++ b/tests/printer.rs @@ -1,20 +1,25 @@ use miette::{ - DefaultReportPrinter, Diagnostic, DiagnosticReport, MietteError, MietteTheme, SourceSpan, + DefaultReportPrinter, Diagnostic, DiagnosticReport, MietteError, MietteTheme, + NarratableReportPrinter, SourceSpan, }; use thiserror::Error; fn fmt_report(diag: DiagnosticReport) -> String { - // Mostly for dev purposes. - let theme = if std::env::var("STYLE").is_ok() { - MietteTheme::unicode() - } else if std::env::var("BASIC").is_ok() { - MietteTheme::none() - } else { - MietteTheme::unicode_nocolor() - }; - let printer = DefaultReportPrinter::new_themed(theme); let mut out = String::new(); - printer.render_report(&mut out, diag.inner()).unwrap(); + // Mostly for dev purposes. + if std::env::var("STYLE").is_ok() { + DefaultReportPrinter::new_themed(MietteTheme::unicode()) + .render_report(&mut out, diag.inner()) + .unwrap(); + } else if std::env::var("NARRATED").is_ok() { + NarratableReportPrinter + .render_report(&mut out, diag.inner()) + .unwrap(); + } else { + DefaultReportPrinter::new_themed(MietteTheme::unicode_nocolor()) + .render_report(&mut out, diag.inner()) + .unwrap(); + }; out }