From 3d6f903df0e7c9d0eb9a1fdbbf0028bab5496429 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 9 Nov 2023 13:21:32 -0800 Subject: [PATCH 1/5] fix(formatting): Fix formatting bug when an empty span is not aligned to a char boundary (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous output looked like this: ---- single_line_with_wide_char_unaligned_span_empty stdout ---- Error: oops::my::bad × oops! ╭─[bad_file.rs:2:4] 1 │ source 2 │ 👼🏼text · ─▲ · ╰── this bit here 3 │ here ╰──── help: try doing it better next time? Note that the .max(start + 1) term is still necessary in the nonempty branch, since it's possible to have a nonempty span covering zero-width text. * remove uncessary if statement start > end in all cases. --- src/handlers/graphical.rs | 44 ++++++++++++++++++++------------------- tests/graphical.rs | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/handlers/graphical.rs b/src/handlers/graphical.rs index 44d92b7..8cec88b 100644 --- a/src/handlers/graphical.rs +++ b/src/handlers/graphical.rs @@ -718,31 +718,33 @@ impl GraphicalReportHandler { let byte_start = hl.offset(); let byte_end = hl.offset() + hl.len(); let start = self.visual_offset(line, byte_start, true).max(highest); - let end = self.visual_offset(line, byte_end, false).max(start + 1); + let end = if hl.len() == 0 { + start + 1 + } else { + self.visual_offset(line, byte_end, false).max(start + 1) + }; let vbar_offset = (start + end) / 2; let num_left = vbar_offset - start; let num_right = end - vbar_offset - 1; - if start < end { - underlines.push_str( - &format!( - "{:width$}{}{}{}", - "", - chars.underline.to_string().repeat(num_left), - if hl.len() == 0 { - chars.uarrow - } else if hl.label().is_some() { - chars.underbar - } else { - chars.underline - }, - chars.underline.to_string().repeat(num_right), - width = start.saturating_sub(highest), - ) - .style(hl.style) - .to_string(), - ); - } + underlines.push_str( + &format!( + "{:width$}{}{}{}", + "", + chars.underline.to_string().repeat(num_left), + if hl.len() == 0 { + chars.uarrow + } else if hl.label().is_some() { + chars.underbar + } else { + chars.underline + }, + chars.underline.to_string().repeat(num_right), + width = start.saturating_sub(highest), + ) + .style(hl.style) + .to_string(), + ); highest = std::cmp::max(highest, end); (hl, vbar_offset) diff --git a/tests/graphical.rs b/tests/graphical.rs index 31db4dc..d24879a 100644 --- a/tests/graphical.rs +++ b/tests/graphical.rs @@ -1321,3 +1321,40 @@ fn single_line_with_wide_char_unaligned_span_end() -> Result<(), MietteError> { assert_eq!(expected, out); Ok(()) } + +#[test] +fn single_line_with_wide_char_unaligned_span_empty() -> 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: (10, 0).into(), + }; + let out = fmt_report(err.into()); + println!("Error: {}", out); + let expected = r#"oops::my::bad + + × oops! + ╭─[bad_file.rs:2:4] + 1 │ source + 2 │ 👼🏼text + · ▲ + · ╰── this bit here + 3 │ here + ╰──── + help: try doing it better next time? +"# + .trim_start() + .to_string(); + assert_eq!(expected, out); + Ok(()) +} From c7ba5b7e52e05991cecd3ca925c710bbe49850b9 Mon Sep 17 00:00:00 2001 From: ManicMarrc <122530518+ManicMarrc@users.noreply.github.com> Date: Fri, 10 Nov 2023 04:22:47 +0700 Subject: [PATCH 2/5] feat(derive): Make `miette-derive` be able to be turned off (#304) --- Cargo.toml | 5 +++-- src/error.rs | 42 +++++++++++++++++++++++++++++++++--------- src/lib.rs | 1 + 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dd6e584..f8c2cb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ exclude = ["images/", "tests/", "miette-derive/"] [dependencies] thiserror = "1.0.40" -miette-derive = { path = "miette-derive", version = "=5.10.0" } +miette-derive = { path = "miette-derive", version = "=5.10.0", optional = true } once_cell = "1.8.0" unicode-width = "0.1.9" @@ -44,7 +44,8 @@ lazy_static = "1.4" serde_json = "1.0.64" [features] -default = [] +default = ["derive"] +derive = ["miette-derive"] no-format-args-capture = [] fancy-no-backtrace = [ "owo-colors", diff --git a/src/error.rs b/src/error.rs index 56041ca..4e57a78 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,27 +1,51 @@ -use std::io; +use std::{fmt, io}; use thiserror::Error; -use crate::{self as miette, Diagnostic}; +use crate::Diagnostic; /** Error enum for miette. Used by certain operations in the protocol. */ -#[derive(Debug, Diagnostic, Error)] +#[derive(Debug, Error)] pub enum MietteError { /// Wrapper around [`std::io::Error`]. This is returned when something went /// wrong while reading a [`SourceCode`](crate::SourceCode). #[error(transparent)] - #[diagnostic(code(miette::io_error), url(docsrs))] IoError(#[from] io::Error), /// Returned when a [`SourceSpan`](crate::SourceSpan) extends beyond the /// bounds of a given [`SourceCode`](crate::SourceCode). #[error("The given offset is outside the bounds of its Source")] - #[diagnostic( - code(miette::span_out_of_bounds), - help("Double-check your spans. Do you have an off-by-one error?"), - url(docsrs) - )] OutOfBounds, } + +impl Diagnostic for MietteError { + fn code<'a>(&'a self) -> Option> { + match self { + MietteError::IoError(_) => Some(Box::new("miette::io_error")), + MietteError::OutOfBounds => Some(Box::new("miette::span_out_of_bounds")), + } + } + + fn help<'a>(&'a self) -> Option> { + match self { + MietteError::IoError(_) => None, + MietteError::OutOfBounds => Some(Box::new( + "Double-check your spans. Do you have an off-by-one error?", + )), + } + } + + fn url<'a>(&'a self) -> Option> { + let crate_version = env!("CARGO_PKG_VERSION"); + let variant = match self { + MietteError::IoError(_) => "#variant.IoError", + MietteError::OutOfBounds => "#variant.OutOfBounds", + }; + Some(Box::new(format!( + "https://docs.rs/miette/{}/miette/enum.MietteError.html{}", + crate_version, variant, + ))) + } +} diff --git a/src/lib.rs b/src/lib.rs index 20589ef..3cb021b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -652,6 +652,7 @@ //! and some from [`thiserror`](https://github.com/dtolnay/thiserror), also //! under the Apache License. Some code is taken from //! [`ariadne`](https://github.com/zesterer/ariadne), which is MIT licensed. +#[cfg(feature = "derive")] pub use miette_derive::*; pub use error::*; From fd77257cee0f5d03aa7dccb4ba8cbaa40c1a88c6 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 15 Nov 2023 12:34:24 -0600 Subject: [PATCH 3/5] feat(graphical): Expose additional `textwrap` options (#321) --- src/handler.rs | 34 +++++++ src/handlers/graphical.rs | 74 +++++++++++++-- src/lib.rs | 1 + tests/graphical.rs | 184 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 8 deletions(-) diff --git a/src/handler.rs b/src/handler.rs index e983a55..e32f3ef 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -55,6 +55,9 @@ pub struct MietteHandlerOpts { pub(crate) context_lines: Option, pub(crate) tab_width: Option, pub(crate) with_cause_chain: Option, + pub(crate) break_words: Option, + pub(crate) word_separator: Option, + pub(crate) word_splitter: Option, } impl MietteHandlerOpts { @@ -86,6 +89,27 @@ impl MietteHandlerOpts { self } + /// If true, long words can be broken when wrapping. + /// + /// If false, long words will not be broken when they exceed the width. + /// + /// Defaults to true. + pub fn break_words(mut self, break_words: bool) -> Self { + self.break_words = Some(break_words); + self + } + + /// Sets the `textwrap::WordSeparator` to use when determining wrap points. + pub fn word_separator(mut self, word_separator: textwrap::WordSeparator) -> Self { + self.word_separator = Some(word_separator); + self + } + + /// Sets the `textwrap::WordSplitter` to use when determining wrap points. + pub fn word_splitter(mut self, word_splitter: textwrap::WordSplitter) -> Self { + self.word_splitter = Some(word_splitter); + self + } /// Include the cause chain of the top-level error in the report. pub fn with_cause_chain(mut self) -> Self { self.with_cause_chain = Some(true); @@ -233,6 +257,16 @@ impl MietteHandlerOpts { if let Some(w) = self.tab_width { handler = handler.tab_width(w); } + if let Some(b) = self.break_words { + handler = handler.with_break_words(b) + } + if let Some(s) = self.word_separator { + handler = handler.with_word_separator(s) + } + if let Some(s) = self.word_splitter { + handler = handler.with_word_splitter(s) + } + MietteHandler { inner: Box::new(handler), } diff --git a/src/handlers/graphical.rs b/src/handlers/graphical.rs index 8cec88b..35e9c79 100644 --- a/src/handlers/graphical.rs +++ b/src/handlers/graphical.rs @@ -30,6 +30,9 @@ pub struct GraphicalReportHandler { pub(crate) context_lines: usize, pub(crate) tab_width: usize, pub(crate) with_cause_chain: bool, + pub(crate) break_words: bool, + pub(crate) word_separator: Option, + pub(crate) word_splitter: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -51,6 +54,9 @@ impl GraphicalReportHandler { context_lines: 1, tab_width: 4, with_cause_chain: true, + break_words: true, + word_separator: None, + word_splitter: None, } } @@ -64,6 +70,9 @@ impl GraphicalReportHandler { context_lines: 1, tab_width: 4, with_cause_chain: true, + break_words: true, + word_separator: None, + word_splitter: None, } } @@ -122,6 +131,24 @@ impl GraphicalReportHandler { self } + /// Enables or disables breaking of words during wrapping. + pub fn with_break_words(mut self, break_words: bool) -> Self { + self.break_words = break_words; + self + } + + /// Sets the word separator to use when wrapping. + pub fn with_word_separator(mut self, word_separator: textwrap::WordSeparator) -> Self { + self.word_separator = Some(word_separator); + self + } + + /// Sets the word splitter to usewhen wrapping. + pub fn with_word_splitter(mut self, word_splitter: textwrap::WordSplitter) -> Self { + self.word_splitter = Some(word_splitter); + self + } + /// Sets the 'global' footer for this handler. pub fn with_footer(mut self, footer: String) -> Self { self.footer = Some(footer); @@ -159,9 +186,17 @@ impl GraphicalReportHandler { if let Some(footer) = &self.footer { writeln!(f)?; let width = self.termwidth.saturating_sub(4); - let opts = textwrap::Options::new(width) + let mut opts = textwrap::Options::new(width) .initial_indent(" ") - .subsequent_indent(" "); + .subsequent_indent(" ") + .break_words(self.break_words); + if let Some(word_separator) = self.word_separator { + opts = opts.word_separator(word_separator); + } + if let Some(word_splitter) = self.word_splitter.clone() { + opts = opts.word_splitter(word_splitter); + } + writeln!(f, "{}", textwrap::fill(footer, opts))?; } Ok(()) @@ -212,9 +247,16 @@ impl GraphicalReportHandler { let initial_indent = format!(" {} ", severity_icon.style(severity_style)); let rest_indent = format!(" {} ", self.theme.characters.vbar.style(severity_style)); let width = self.termwidth.saturating_sub(2); - let opts = textwrap::Options::new(width) + let mut opts = textwrap::Options::new(width) .initial_indent(&initial_indent) - .subsequent_indent(&rest_indent); + .subsequent_indent(&rest_indent) + .break_words(self.break_words); + if let Some(word_separator) = self.word_separator { + opts = opts.word_separator(word_separator); + } + if let Some(word_splitter) = self.word_splitter.clone() { + opts = opts.word_splitter(word_splitter); + } writeln!(f, "{}", textwrap::fill(&diagnostic.to_string(), opts))?; @@ -251,9 +293,17 @@ impl GraphicalReportHandler { ) .style(severity_style) .to_string(); - let opts = textwrap::Options::new(width) + let mut opts = textwrap::Options::new(width) .initial_indent(&initial_indent) - .subsequent_indent(&rest_indent); + .subsequent_indent(&rest_indent) + .break_words(self.break_words); + if let Some(word_separator) = self.word_separator { + opts = opts.word_separator(word_separator); + } + if let Some(word_splitter) = self.word_splitter.clone() { + opts = opts.word_splitter(word_splitter); + } + match error { ErrorKind::Diagnostic(diag) => { let mut inner = String::new(); @@ -280,9 +330,17 @@ impl GraphicalReportHandler { if let Some(help) = diagnostic.help() { let width = self.termwidth.saturating_sub(4); let initial_indent = " help: ".style(self.theme.styles.help).to_string(); - let opts = textwrap::Options::new(width) + let mut opts = textwrap::Options::new(width) .initial_indent(&initial_indent) - .subsequent_indent(" "); + .subsequent_indent(" ") + .break_words(self.break_words); + if let Some(word_separator) = self.word_separator { + opts = opts.word_separator(word_separator); + } + if let Some(word_splitter) = self.word_splitter.clone() { + opts = opts.word_splitter(word_splitter); + } + writeln!(f, "{}", textwrap::fill(&help.to_string(), opts))?; } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 3cb021b..08de732 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -593,6 +593,7 @@ //! .unicode(false) //! .context_lines(3) //! .tab_width(4) +//! .break_words(true) //! .build(), //! ) //! })) diff --git a/tests/graphical.rs b/tests/graphical.rs index d24879a..536efcd 100644 --- a/tests/graphical.rs +++ b/tests/graphical.rs @@ -34,6 +34,190 @@ fn fmt_report(diag: Report) -> String { out } +fn fmt_report_with_settings( + diag: Report, + with_settings: fn(GraphicalReportHandler) -> GraphicalReportHandler, +) -> String { + let mut out = String::new(); + + let handler = with_settings(GraphicalReportHandler::new_themed( + GraphicalTheme::unicode_nocolor(), + )); + + handler.render_report(&mut out, diag.as_ref()).unwrap(); + + println!("Error:\n```\n{}\n```", out); + + out +} + +#[test] +fn word_wrap_options() -> Result<(), MietteError> { + // By default, a long word should not break + let out = + fmt_report_with_settings(Report::msg("abcdefghijklmnopqrstuvwxyz"), |handler| handler); + + let expected = " × abcdefghijklmnopqrstuvwxyz\n".to_string(); + assert_eq!(expected, out); + + // A long word can break with a smaller width + let out = fmt_report_with_settings(Report::msg("abcdefghijklmnopqrstuvwxyz"), |handler| { + handler.with_width(10) + }); + let expected = r#" × abcd + │ efgh + │ ijkl + │ mnop + │ qrst + │ uvwx + │ yz +"# + .to_string(); + assert_eq!(expected, out); + + // Unless, word breaking is disabled + let out = fmt_report_with_settings(Report::msg("abcdefghijklmnopqrstuvwxyz"), |handler| { + handler.with_width(10).with_break_words(false) + }); + let expected = " × abcdefghijklmnopqrstuvwxyz\n".to_string(); + assert_eq!(expected, out); + + // Breaks should start at the boundary of each word if possible + let out = fmt_report_with_settings( + Report::msg("12 123 1234 12345 123456 1234567 1234567890"), + |handler| handler.with_width(10), + ); + let expected = r#" × 12 + │ 123 + │ 1234 + │ 1234 + │ 5 + │ 1234 + │ 56 + │ 1234 + │ 567 + │ 1234 + │ 5678 + │ 90 +"# + .to_string(); + assert_eq!(expected, out); + + // But long words should not break if word breaking is disabled + let out = fmt_report_with_settings( + Report::msg("12 123 1234 12345 123456 1234567 1234567890"), + |handler| handler.with_width(10).with_break_words(false), + ); + let expected = r#" × 12 + │ 123 + │ 1234 + │ 12345 + │ 123456 + │ 1234567 + │ 1234567890 +"# + .to_string(); + assert_eq!(expected, out); + + // Unless, of course, there are hyphens + let out = fmt_report_with_settings( + Report::msg("a-b a-b-c a-b-c-d a-b-c-d-e a-b-c-d-e-f a-b-c-d-e-f-g a-b-c-d-e-f-g-h"), + |handler| handler.with_width(10).with_break_words(false), + ); + let expected = r#" × a-b + │ a-b- + │ c a- + │ b-c- + │ d a- + │ b-c- + │ d-e + │ a-b- + │ c-d- + │ e-f + │ a-b- + │ c-d- + │ e-f- + │ g a- + │ b-c- + │ d-e- + │ f-g- + │ h +"# + .to_string(); + assert_eq!(expected, out); + + // Which requires an additional opt-out + let out = fmt_report_with_settings( + Report::msg("a-b a-b-c a-b-c-d a-b-c-d-e a-b-c-d-e-f a-b-c-d-e-f-g a-b-c-d-e-f-g-h"), + |handler| { + handler + .with_width(10) + .with_break_words(false) + .with_word_splitter(textwrap::WordSplitter::NoHyphenation) + }, + ); + let expected = r#" × a-b + │ a-b-c + │ a-b-c-d + │ a-b-c-d-e + │ a-b-c-d-e-f + │ a-b-c-d-e-f-g + │ a-b-c-d-e-f-g-h +"# + .to_string(); + assert_eq!(expected, out); + + // Or if there are _other_ unicode word boundaries + let out = fmt_report_with_settings( + Report::msg("a/b a/b/c a/b/c/d a/b/c/d/e a/b/c/d/e/f a/b/c/d/e/f/g a/b/c/d/e/f/g/h"), + |handler| handler.with_width(10).with_break_words(false), + ); + let expected = r#" × a/b + │ a/b/ + │ c a/ + │ b/c/ + │ d a/ + │ b/c/ + │ d/e + │ a/b/ + │ c/d/ + │ e/f + │ a/b/ + │ c/d/ + │ e/f/ + │ g a/ + │ b/c/ + │ d/e/ + │ f/g/ + │ h +"# + .to_string(); + assert_eq!(expected, out); + + // Such things require you to opt-in to only breaking on ASCII whitespace + let out = fmt_report_with_settings( + Report::msg("a/b a/b/c a/b/c/d a/b/c/d/e a/b/c/d/e/f a/b/c/d/e/f/g a/b/c/d/e/f/g/h"), + |handler| { + handler + .with_width(10) + .with_break_words(false) + .with_word_separator(textwrap::WordSeparator::AsciiSpace) + }, + ); + let expected = r#" × a/b + │ a/b/c + │ a/b/c/d + │ a/b/c/d/e + │ a/b/c/d/e/f + │ a/b/c/d/e/f/g + │ a/b/c/d/e/f/g/h +"# + .to_string(); + assert_eq!(expected, out); + + Ok(()) +} + #[test] fn empty_source() -> Result<(), MietteError> { #[derive(Debug, Diagnostic, Error)] From 251d6d59292397458328ef57fb7957faedafd019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20D=C3=B6nszelmann?= Date: Wed, 15 Nov 2023 19:35:46 +0100 Subject: [PATCH 4/5] fix(docs): add example to README and docs fixing #96 (#319) --- README.md | 17 +++++++++++++++++ src/lib.rs | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/README.md b/README.md index a29b428..5f79c52 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,23 @@ enabled: miette = { version = "X.Y.Z", features = ["fancy"] } ``` +Another way to display a diagnostic is by printing them using the debug formatter. +This is, in fact, what returning diagnostics from main ends up doing. +To do it yourself, you can write the following: + +```rust +use miette::{IntoDiagnostic, Result}; +use semver::Version; + +fn just_a_random_function() { + let version_result: Result = "1.2.x".parse().into_diagnostic(); + match version_result { + Err(e) => println!("{:?}", e), + Ok(version) => println!("{}", version), + } +} +``` + #### ... diagnostic code URLs `miette` supports providing a URL for individual diagnostics. This URL will diff --git a/src/lib.rs b/src/lib.rs index 08de732..d80efa7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -304,6 +304,23 @@ //! miette = { version = "X.Y.Z", features = ["fancy"] } //! ``` //! +//! Another way to display a diagnostic is by printing them using the debug formatter. +//! This is, in fact, what returning diagnostics from main ends up doing. +//! To do it yourself, you can write the following: +//! +//! ```rust +//! use miette::{IntoDiagnostic, Result}; +//! use semver::Version; +//! +//! fn just_a_random_function() { +//! let version_result: Result = "1.2.x".parse().into_diagnostic(); +//! match version_result { +//! Err(e) => println!("{:?}", e), +//! Ok(version) => println!("{}", version), +//! } +//! } +//! ``` +//! //! ### ... diagnostic code URLs //! //! `miette` supports providing a URL for individual diagnostics. This URL will From 865d67c8dda119ddd03ac43be22f4fa272a9f433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20D=C3=B6nszelmann?= Date: Wed, 15 Nov 2023 19:39:29 +0100 Subject: [PATCH 5/5] feat(graphical): support rendering labels that contain newlines (#318) Fixes: https://github.com/zkat/miette/issues/85 --- src/handlers/graphical.rs | 313 ++++++++++++++++++++++++++++++++------ tests/graphical.rs | 171 +++++++++++++++++++++ 2 files changed, 435 insertions(+), 49 deletions(-) diff --git a/src/handlers/graphical.rs b/src/handlers/graphical.rs index 35e9c79..baadfb3 100644 --- a/src/handlers/graphical.rs +++ b/src/handlers/graphical.rs @@ -20,7 +20,7 @@ This printer can be customized by using [`new_themed()`](GraphicalReportHandler: See [`set_hook()`](crate::set_hook) for more details on customizing your global printer. -*/ + */ #[derive(Debug, Clone)] pub struct GraphicalReportHandler { pub(crate) links: LinkStyle, @@ -545,7 +545,13 @@ impl GraphicalReportHandler { // no line number! self.write_no_linum(f, linum_width)?; // gutter _again_ - self.render_highlight_gutter(f, max_gutter, line, &labels)?; + self.render_highlight_gutter( + f, + max_gutter, + line, + &labels, + LabelRenderMode::SingleLine, + )?; self.render_single_line_highlights( f, line, @@ -557,11 +563,7 @@ impl GraphicalReportHandler { } for hl in multi_line { if hl.label().is_some() && line.span_ends(hl) && !line.span_starts(hl) { - // no line number! - self.write_no_linum(f, linum_width)?; - // gutter _again_ - self.render_highlight_gutter(f, max_gutter, line, &labels)?; - self.render_multi_line_end(f, hl)?; + self.render_multi_line_end(f, &labels, max_gutter, linum_width, line, hl)?; } } } @@ -575,6 +577,91 @@ impl GraphicalReportHandler { Ok(()) } + fn render_multi_line_end( + &self, + f: &mut impl fmt::Write, + labels: &[FancySpan], + max_gutter: usize, + linum_width: usize, + line: &Line, + label: &FancySpan, + ) -> fmt::Result { + // no line number! + self.write_no_linum(f, linum_width)?; + + if let Some(label_parts) = label.label_parts() { + // if it has a label, how long is it? + let (first, rest) = label_parts + .split_first() + .expect("cannot crash because rest would have been None, see docs on the `label` field of FancySpan"); + + if rest.is_empty() { + // gutter _again_ + self.render_highlight_gutter( + f, + max_gutter, + line, + &labels, + LabelRenderMode::SingleLine, + )?; + + self.render_multi_line_end_single( + f, + first, + label.style, + LabelRenderMode::SingleLine, + )?; + } else { + // gutter _again_ + self.render_highlight_gutter( + f, + max_gutter, + line, + &labels, + LabelRenderMode::MultiLineFirst, + )?; + + self.render_multi_line_end_single( + f, + first, + label.style, + LabelRenderMode::MultiLineFirst, + )?; + for label_line in rest { + // no line number! + self.write_no_linum(f, linum_width)?; + // gutter _again_ + self.render_highlight_gutter( + f, + max_gutter, + line, + &labels, + LabelRenderMode::MultiLineRest, + )?; + self.render_multi_line_end_single( + f, + label_line, + label.style, + LabelRenderMode::MultiLineRest, + )?; + } + } + } else { + // gutter _again_ + self.render_highlight_gutter( + f, + max_gutter, + line, + &labels, + LabelRenderMode::SingleLine, + )?; + // has no label + writeln!(f, "{}", self.theme.characters.hbar.style(label.style))?; + } + + Ok(()) + } + fn render_line_gutter( &self, f: &mut impl fmt::Write, @@ -643,6 +730,7 @@ impl GraphicalReportHandler { max_gutter: usize, line: &Line, highlights: &[FancySpan], + render_mode: LabelRenderMode, ) -> fmt::Result { if max_gutter == 0 { return Ok(()); @@ -652,15 +740,33 @@ impl GraphicalReportHandler { let applicable = highlights.iter().filter(|hl| line.span_applies(hl)); for (i, hl) in applicable.enumerate() { if !line.span_line_only(hl) && line.span_ends(hl) { - gutter.push_str(&chars.lbot.style(hl.style).to_string()); - gutter.push_str( - &chars - .hbar - .to_string() - .repeat(max_gutter.saturating_sub(i) + 2) - .style(hl.style) - .to_string(), - ); + if render_mode == LabelRenderMode::MultiLineRest { + // this is to make multiline labels work. We want to make the right amount + // of horizontal space for them, but not actually draw the lines + for _ in 0..max_gutter.saturating_sub(i) + 2 { + gutter.push(' '); + } + } else { + gutter.push_str(&chars.lbot.style(hl.style).to_string()); + + gutter.push_str( + &chars + .hbar + .to_string() + .repeat( + max_gutter.saturating_sub(i) + // if we are rendering a multiline label, then leave a bit of space for the + // rcross character + + if render_mode == LabelRenderMode::MultiLineFirst { + 1 + } else { + 2 + }, + ) + .style(hl.style) + .to_string(), + ); + } break; } else { gutter.push_str(&chars.vbar.style(hl.style).to_string()); @@ -811,27 +917,40 @@ impl GraphicalReportHandler { writeln!(f, "{}", underlines)?; for hl in single_liners.iter().rev() { - if let Some(label) = hl.label() { - self.write_no_linum(f, linum_width)?; - self.render_highlight_gutter(f, max_gutter, line, all_highlights)?; - let mut curr_offset = 1usize; - for (offset_hl, vbar_offset) in &vbar_offsets { - while curr_offset < *vbar_offset + 1 { - write!(f, " ")?; - curr_offset += 1; - } - if *offset_hl != hl { - write!(f, "{}", chars.vbar.to_string().style(offset_hl.style))?; - curr_offset += 1; - } else { - let lines = format!( - "{}{} {}", - chars.lbot, - chars.hbar.to_string().repeat(2), - label, - ); - writeln!(f, "{}", lines.style(hl.style))?; - break; + if let Some(label) = hl.label_parts() { + if label.len() == 1 { + self.write_label_text( + f, + line, + linum_width, + max_gutter, + all_highlights, + chars, + &vbar_offsets, + hl, + &label[0], + LabelRenderMode::SingleLine, + )?; + } else { + let mut first = true; + for label_line in &label { + self.write_label_text( + f, + line, + linum_width, + max_gutter, + all_highlights, + chars, + &vbar_offsets, + hl, + label_line, + if first { + LabelRenderMode::MultiLineFirst + } else { + LabelRenderMode::MultiLineRest + }, + )?; + first = false; } } } @@ -839,13 +958,80 @@ impl GraphicalReportHandler { Ok(()) } - fn render_multi_line_end(&self, f: &mut impl fmt::Write, hl: &FancySpan) -> fmt::Result { - writeln!( + // I know it's not good practice, but making this a function makes a lot of sense + // and making a struct for this does not... + #[allow(clippy::too_many_arguments)] + fn write_label_text( + &self, + f: &mut impl fmt::Write, + line: &Line, + linum_width: usize, + max_gutter: usize, + all_highlights: &[FancySpan], + chars: &ThemeCharacters, + vbar_offsets: &[(&&FancySpan, usize)], + hl: &&FancySpan, + label: &str, + render_mode: LabelRenderMode, + ) -> fmt::Result { + self.write_no_linum(f, linum_width)?; + self.render_highlight_gutter( f, - "{} {}", - self.theme.characters.hbar.style(hl.style), - hl.label().unwrap_or_else(|| "".into()), + max_gutter, + line, + all_highlights, + LabelRenderMode::SingleLine, )?; + let mut curr_offset = 1usize; + for (offset_hl, vbar_offset) in vbar_offsets { + while curr_offset < *vbar_offset + 1 { + write!(f, " ")?; + curr_offset += 1; + } + if *offset_hl != hl { + write!(f, "{}", chars.vbar.to_string().style(offset_hl.style))?; + curr_offset += 1; + } else { + let lines = match render_mode { + LabelRenderMode::SingleLine => format!( + "{}{} {}", + chars.lbot, + chars.hbar.to_string().repeat(2), + label, + ), + LabelRenderMode::MultiLineFirst => { + format!("{}{}{} {}", chars.lbot, chars.hbar, chars.rcross, label,) + } + LabelRenderMode::MultiLineRest => { + format!(" {} {}", chars.vbar, label,) + } + }; + writeln!(f, "{}", lines.style(hl.style))?; + break; + } + } + Ok(()) + } + + fn render_multi_line_end_single( + &self, + f: &mut impl fmt::Write, + label: &str, + style: Style, + render_mode: LabelRenderMode, + ) -> fmt::Result { + match render_mode { + LabelRenderMode::SingleLine => { + writeln!(f, "{} {}", self.theme.characters.hbar.style(style), label)?; + } + LabelRenderMode::MultiLineFirst => { + writeln!(f, "{} {}", self.theme.characters.rcross.style(style), label)?; + } + LabelRenderMode::MultiLineRest => { + writeln!(f, "{} {}", self.theme.characters.vbar.style(style), label)?; + } + } + Ok(()) } @@ -924,6 +1110,16 @@ impl ReportHandler for GraphicalReportHandler { Support types */ +#[derive(PartialEq, Debug)] +enum LabelRenderMode { + /// we're rendering a single line label (or not rendering in any special way) + SingleLine, + /// we're rendering a multiline label + MultiLineFirst, + /// we're rendering the rest of a multiline label + MultiLineRest, +} + #[derive(Debug)] struct Line { line_number: usize, @@ -941,10 +1137,10 @@ impl Line { let spanlen = if span.len() == 0 { 1 } else { span.len() }; // Span starts in this line (span.offset() >= self.offset && span.offset() < self.offset + self.length) - // Span passes through this line - || (span.offset() < self.offset && span.offset() + spanlen > self.offset + self.length) //todo - // Span ends on this line - || (span.offset() + spanlen > self.offset && span.offset() + spanlen <= self.offset + self.length) + // Span passes through this line + || (span.offset() < self.offset && span.offset() + spanlen > self.offset + self.length) //todo + // Span ends on this line + || (span.offset() + spanlen > self.offset && span.offset() + spanlen <= self.offset + self.length) } // A 'flyby' is a multi-line span that technically covers this line, but @@ -974,7 +1170,10 @@ impl Line { #[derive(Debug, Clone)] struct FancySpan { - label: Option, + /// this is deliberately an option of a vec because I wanted to be very explicit + /// that there can also be *no* label. If there is a label, it can have multiple + /// lines which is what the vec is for. + label: Option>, span: SourceSpan, style: Style, } @@ -985,9 +1184,17 @@ impl PartialEq for FancySpan { } } +fn split_label(v: String) -> Vec { + v.split('\n').map(|i| i.to_string()).collect() +} + impl FancySpan { fn new(label: Option, span: SourceSpan, style: Style) -> Self { - FancySpan { label, span, style } + FancySpan { + label: label.map(split_label), + span, + style, + } } fn style(&self) -> Style { @@ -997,7 +1204,15 @@ impl FancySpan { fn label(&self) -> Option { self.label .as_ref() - .map(|l| l.style(self.style()).to_string()) + .map(|l| l.join("\n").style(self.style()).to_string()) + } + + fn label_parts(&self) -> Option> { + self.label.as_ref().map(|l| { + l.iter() + .map(|i| i.style(self.style()).to_string()) + .collect() + }) } fn offset(&self) -> usize { diff --git a/tests/graphical.rs b/tests/graphical.rs index 536efcd..f59f849 100644 --- a/tests/graphical.rs +++ b/tests/graphical.rs @@ -251,6 +251,52 @@ fn empty_source() -> Result<(), MietteError> { Ok(()) } +#[test] +fn multiple_spans_multiline() { + #[derive(Error, Debug, Diagnostic)] + #[error("oops!")] + #[diagnostic(severity(Error))] + struct MyBad { + #[source_code] + src: NamedSource, + #[label("big")] + big: SourceSpan, + #[label("small")] + small: SourceSpan, + } + let err = MyBad { + src: NamedSource::new( + "issue", + "\ +if true { + a +} else { + b +}", + ), + big: (0, 32).into(), + small: (14, 1).into(), + }; + let out = fmt_report(err.into()); + println!("Error: {}", out); + + let expected = r#" × oops! + ╭─[issue:1:1] + 1 │ ╭─▶ if true { + 2 │ │╭▶ a + · ││ ┬ + · ││ ╰── small + 3 │ │ } else { + 4 │ │ b + 5 │ ├─▶ } + · ╰──── big + ╰──── +"# + .to_string(); + + assert_eq!(expected, out); +} + #[test] fn single_line_highlight_span_full_line() { #[derive(Error, Debug, Diagnostic)] @@ -725,6 +771,94 @@ fn single_line_highlight_at_line_start() -> Result<(), MietteError> { Ok(()) } +#[test] +fn multiline_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("this bit here\nand\nthis\ntoo")] + 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 = r#"oops::my::bad + + × oops! + ╭─[bad_file.rs:2:1] + 1 │ source + 2 │ text + · ──┬─ + · ╰─┤ this bit here + · │ and + · │ this + · │ too + 3 │ here + ╰──── + help: try doing it better next time? +"# + .trim_start() + .to_string(); + assert_eq!(expected, out); + Ok(()) +} + +#[test] +fn multiple_multi_line_labels() -> 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\ny"] + highlight1: SourceSpan, + #[label = "z\nw"] + highlight2: SourceSpan, + #[label = "a\nb"] + 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 = r#"oops::my::bad + + × oops! + ╭─[bad_file.rs:2:3] + 1 │ source + 2 │ text text text text text + · ──┬─ ──┬─ ──┬─ + · │ │ ╰─┤ a + · │ │ │ b + · │ ╰─┤ z + · │ │ w + · ╰─┤ x + · │ y + 3 │ here + ╰──── + help: try doing it better next time? +"# + .trim_start() + .to_string(); + assert_eq!(expected, out); + Ok(()) +} + #[test] fn multiple_same_line_highlights() -> Result<(), MietteError> { #[derive(Debug, Diagnostic, Error)] @@ -853,6 +987,43 @@ fn multiline_highlight_adjacent() -> Result<(), MietteError> { Ok(()) } +#[test] +fn multiline_highlight_multiline_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 = "these two lines\nare the problem"] + 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 = r#"oops::my::bad + + × oops! + ╭─[bad_file.rs:2:3] + 1 │ source + 2 │ ╭─▶ text + 3 │ ├─▶ here + · ╰──┤ these two lines + · │ are the problem + ╰──── + help: try doing it better next time? +"# + .trim_start() + .to_string(); + assert_eq!(expected, out); + Ok(()) +} + #[test] fn multiline_highlight_flyby() -> Result<(), MietteError> { #[derive(Debug, Diagnostic, Error)]