Repository: zkat/miette (rust)

Issue:
Potentially broken word wrapping on `main`
Just a quick report, and I might have time to dig a bit deeper later on, but I've noticed a few times that I'll get some strange wrapping where the `GraphicalReportHandler` will move from the first to the second line, print exactly one word, then move to a third line.

I'm not particularly sure why yet, but it looks something like this: 
![image](https://github.com/zkat/miette/assets/6251883/e1b45329-3744-4f37-8f48-0629ba2959ce)
Here, `the` is the lonely word that gets banished to that middle line, though I think I've seen this with `a` as well, so it seems like one-char words are enough to trigger this issue. Resizing the terminal a bit causes the problem to disappear or reappear, so perhaps there is some sort of off-by-one, or several levels of wrapping that don't communicate indentation properly?

Hard to say, but I've left it here in case anyone else has more time to look into it than I do!

Pull request description:
fix(graphical): fix nested error wrapping
Fixes #333

Here is an error now nicely wrapping (with the 2 char padding on either side) of a 50-char wide terminal:
![image](https://github.com/zkat/miette/assets/6251883/e78cb3da-8ce6-4e16-94e1-2afe6855445d)


Patch:
diff --git a/src/eyreish/wrapper.rs b/src/eyreish/wrapper.rs
index 6e65eb7..d940fed 100644
--- a/src/eyreish/wrapper.rs
+++ b/src/eyreish/wrapper.rs
@@ -6,35 +6,9 @@ use crate::{Diagnostic, LabeledSpan, Report, SourceCode};
 
 use crate as miette;
 
-#[repr(transparent)]
-pub(crate) struct DisplayError<M>(pub(crate) M);
-
 #[repr(transparent)]
 pub(crate) struct MessageError<M>(pub(crate) M);
 
-pub(crate) struct NoneError;
-
-impl<M> Debug for DisplayError<M>
-where
-    M: Display,
-{
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        Display::fmt(&self.0, f)
-    }
-}
-
-impl<M> Display for DisplayError<M>
-where
-    M: Display,
-{
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        Display::fmt(&self.0, f)
-    }
-}
-
-impl<M> StdError for DisplayError<M> where M: Display + 'static {}
-impl<M> Diagnostic for DisplayError<M> where M: Display + 'static {}
-
 impl<M> Debug for MessageError<M>
 where
     M: Display + Debug,
@@ -56,21 +30,6 @@ where
 impl<M> StdError for MessageError<M> where M: Display + Debug + 'static {}
 impl<M> Diagnostic for MessageError<M> where M: Display + Debug + 'static {}
 
-impl Debug for NoneError {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        Debug::fmt("Option was None", f)
-    }
-}
-
-impl Display for NoneError {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        Display::fmt("Option was None", f)
-    }
-}
-
-impl StdError for NoneError {}
-impl Diagnostic for NoneError {}
-
 #[repr(transparent)]
 pub(crate) struct BoxedError(pub(crate) Box<dyn Diagnostic + Send + Sync>);
 
diff --git a/src/handlers/graphical.rs b/src/handlers/graphical.rs
index a4474f9..9c1d7ab 100644
--- a/src/handlers/graphical.rs
+++ b/src/handlers/graphical.rs
@@ -1,7 +1,7 @@
 use std::fmt::{self, Write};
 
 use owo_colors::{OwoColorize, Style, StyledList};
-use unicode_width::UnicodeWidthChar;
+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
 
 use crate::diagnostic_chain::{DiagnosticChain, ErrorKind};
 use crate::handlers::theme::*;
@@ -225,7 +225,7 @@ impl GraphicalReportHandler {
         self.render_related(f, diagnostic, src)?;
         if let Some(footer) = &self.footer {
             writeln!(f)?;
-            let width = self.termwidth.saturating_sub(4);
+            let width = self.termwidth.saturating_sub(2);
             let mut opts = textwrap::Options::new(width)
                 .initial_indent("  ")
                 .subsequent_indent("  ")
@@ -265,7 +265,6 @@ impl GraphicalReportHandler {
             );
             write!(header, "{}", link)?;
             writeln!(f, "{}", header)?;
-            writeln!(f)?;
         } else if let Some(code) = diagnostic.code() {
             write!(header, "{}", code.style(severity_style),)?;
             if self.links == LinkStyle::Text && diagnostic.url().is_some() {
@@ -273,8 +272,8 @@ impl GraphicalReportHandler {
                 write!(header, " ({})", url.style(self.theme.styles.link))?;
             }
             writeln!(f, "{}", header)?;
-            writeln!(f)?;
         }
+        writeln!(f)?;
         Ok(())
     }
 
@@ -354,8 +353,12 @@ impl GraphicalReportHandler {
                         inner_renderer.footer = None;
                         // Cause chains are already flattened, so don't double-print the nested error
                         inner_renderer.with_cause_chain = false;
+                        // Since everything from here on is indented, shrink the virtual terminal
+                        inner_renderer.termwidth -= rest_indent.width();
                         inner_renderer.render_report(&mut inner, diag)?;
 
+                        // If there was no header, remove the leading newline
+                        let inner = inner.trim_start_matches('\n');
                         writeln!(f, "{}", self.wrap(&inner, opts))?;
                     }
                     ErrorKind::StdError(err) => {
@@ -370,7 +373,7 @@ impl GraphicalReportHandler {
 
     fn render_footer(&self, f: &mut impl fmt::Write, diagnostic: &(dyn Diagnostic)) -> fmt::Result {
         if let Some(help) = diagnostic.help() {
-            let width = self.termwidth.saturating_sub(4);
+            let width = self.termwidth.saturating_sub(2);
             let initial_indent = "  help: ".style(self.theme.styles.help).to_string();
             let mut opts = textwrap::Options::new(width)
                 .initial_indent(&initial_indent)
@@ -398,8 +401,8 @@ impl GraphicalReportHandler {
             let mut inner_renderer = self.clone();
             // Re-enable the printing of nested cause chains for related errors
             inner_renderer.with_cause_chain = true;
-            writeln!(f)?;
             for rel in related {
+                writeln!(f)?;
                 match rel.severity() {
                     Some(Severity::Error) | None => write!(f, "Error: ")?,
                     Some(Severity::Warning) => write!(f, "Warning: ")?,


Test patch:
diff --git a/tests/derive.rs b/tests/derive.rs
index ac29eee..aa631dc 100644
--- a/tests/derive.rs
+++ b/tests/derive.rs
@@ -6,12 +6,14 @@ fn related() {
     #[derive(Error, Debug, Diagnostic)]
     #[error("welp")]
     #[diagnostic(code(foo::bar::baz))]
+    #[allow(dead_code)]
     struct Foo {
         #[related]
         related: Vec<Baz>,
     }
 
     #[derive(Error, Debug, Diagnostic)]
+    #[allow(dead_code)]
     enum Bar {
         #[error("variant1")]
         #[diagnostic(code(foo::bar::baz))]
@@ -29,6 +31,7 @@ fn related() {
 
     #[derive(Error, Debug, Diagnostic)]
     #[error("welp2")]
+    #[allow(dead_code)]
     struct Baz;
 }
 
@@ -37,6 +40,7 @@ fn related_report() {
     #[derive(Error, Debug, Diagnostic)]
     #[error("welp")]
     #[diagnostic(code(foo::bar::baz))]
+    #[allow(dead_code)]
     struct Foo {
         #[related]
         related: Vec<Report>,
@@ -288,6 +292,7 @@ fn test_snippet_named_struct() {
     #[derive(Debug, Diagnostic, Error)]
     #[error("welp")]
     #[diagnostic(code(foo::bar::baz))]
+    #[allow(dead_code)]
     struct Foo<'a> {
         #[source_code]
         src: &'a str,
@@ -310,6 +315,7 @@ fn test_snippet_unnamed_struct() {
     #[derive(Debug, Diagnostic, Error)]
     #[error("welp")]
     #[diagnostic(code(foo::bar::baz))]
+    #[allow(dead_code)]
     struct Foo<'a>(
         #[source_code] &'a str,
         #[label("{0}")] SourceSpan,
diff --git a/tests/graphical.rs b/tests/graphical.rs
index 4763117..c6b5e1a 100644
--- a/tests/graphical.rs
+++ b/tests/graphical.rs
@@ -59,14 +59,15 @@ fn word_wrap_options() -> Result<(), MietteError> {
     let out =
         fmt_report_with_settings(Report::msg("abcdefghijklmnopqrstuvwxyz"), |handler| handler);
 
-    let expected = "  × abcdefghijklmnopqrstuvwxyz\n".to_string();
+    let expected = "\n  × 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
+    let expected = r#"
+  × abcd
   │ efgh
   │ ijkl
   │ mnop
@@ -81,7 +82,7 @@ fn word_wrap_options() -> Result<(), MietteError> {
     let out = fmt_report_with_settings(Report::msg("abcdefghijklmnopqrstuvwxyz"), |handler| {
         handler.with_width(10).with_break_words(false)
     });
-    let expected = "  × abcdefghijklmnopqrstuvwxyz\n".to_string();
+    let expected = "\n  × abcdefghijklmnopqrstuvwxyz\n".to_string();
     assert_eq!(expected, out);
 
     // Breaks should start at the boundary of each word if possible
@@ -89,7 +90,8 @@ fn word_wrap_options() -> Result<(), MietteError> {
         Report::msg("12 123 1234 12345 123456 1234567 1234567890"),
         |handler| handler.with_width(10),
     );
-    let expected = r#"  × 12
+    let expected = r#"
+  × 12
   │ 123
   │ 1234
   │ 1234
@@ -110,7 +112,8 @@ fn word_wrap_options() -> Result<(), MietteError> {
         Report::msg("12 123 1234 12345 123456 1234567 1234567890"),
         |handler| handler.with_width(10).with_break_words(false),
     );
-    let expected = r#"  × 12
+    let expected = r#"
+  × 12
   │ 123
   │ 1234
   │ 12345
@@ -126,7 +129,8 @@ fn word_wrap_options() -> Result<(), MietteError> {
         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
+    let expected = r#"
+  × a-b
   │ a-b-
   │ c a-
   │ b-c-
@@ -158,7 +162,8 @@ fn word_wrap_options() -> Result<(), MietteError> {
                 .with_word_splitter(textwrap::WordSplitter::NoHyphenation)
         },
     );
-    let expected = r#"  × a-b
+    let expected = r#"
+  × a-b
   │ a-b-c
   │ a-b-c-d
   │ a-b-c-d-e
@@ -174,7 +179,8 @@ fn word_wrap_options() -> Result<(), MietteError> {
         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
+    let expected = r#"
+  × a/b
   │ a/b/
   │ c a/
   │ b/c/
@@ -206,7 +212,8 @@ fn word_wrap_options() -> Result<(), MietteError> {
                 .with_word_separator(textwrap::WordSeparator::AsciiSpace)
         },
     );
-    let expected = r#"  × a/b
+    let expected = r#"
+  × a/b
   │ a/b/c
   │ a/b/c/d
   │ a/b/c/d/e
@@ -227,7 +234,8 @@ fn wrap_option() -> Result<(), MietteError> {
         Report::msg("abc def ghi jkl mno pqr stu vwx yz abc def ghi jkl mno pqr stu vwx yz"),
         |handler| handler.with_width(15),
     );
-    let expected = r#"  × abc def
+    let expected = r#"
+  × abc def
   │ ghi jkl
   │ mno pqr
   │ stu vwx
@@ -246,7 +254,7 @@ fn wrap_option() -> Result<(), MietteError> {
         |handler| handler.with_width(15).with_wrap_lines(false),
     );
     let expected =
-        "  × abc def ghi jkl mno pqr stu vwx yz abc def ghi jkl mno pqr stu vwx yz\n".to_string();
+        "\n  × abc def ghi jkl mno pqr stu vwx yz abc def ghi jkl mno pqr stu vwx yz\n".to_string();
     assert_eq!(expected, out);
 
     // Then, user-defined new lines should be preserved wrapping is disabled
@@ -254,7 +262,8 @@ fn wrap_option() -> Result<(), MietteError> {
       Report::msg("abc def ghi jkl mno pqr stu vwx yz\nabc def ghi jkl mno pqr stu vwx yz\nabc def ghi jkl mno pqr stu vwx yz"),
       |handler| handler.with_width(15).with_wrap_lines(false),
   );
-    let expected = r#"  × abc def ghi jkl mno pqr stu vwx yz
+    let expected = r#"
+  × abc def ghi jkl mno pqr stu vwx yz
   │ abc def ghi jkl mno pqr stu vwx yz
   │ abc def ghi jkl mno pqr stu vwx yz
 "#
@@ -264,6 +273,143 @@ fn wrap_option() -> Result<(), MietteError> {
     Ok(())
 }
 
+#[test]
+fn wrapping_nested_errors() -> Result<(), MietteError> {
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("This is the parent error, the error withhhhh the children, kiddos, pups, as it were, and so on...")]
+    #[diagnostic(
+        code(mama::error),
+        help(
+            "try doing it better next time? I mean, you could have also done better thisssss time, but no?"
+        )
+    )]
+    struct MamaError {
+        #[diagnostic_source]
+        baby: BabyError,
+    }
+
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("Wah wah: I may be small, but I'll cause a proper bout of trouble — justt try wrapping this mess of a line, buddo!")]
+    #[diagnostic(
+        code(baby::error),
+        help(
+            "it cannot be helped... woulddddddd you really want to get rid of an error that's so cute?"
+        )
+    )]
+    struct BabyError;
+
+    let err = MamaError { baby: BabyError };
+    let out = fmt_report_with_settings(err.into(), |handler| handler.with_width(50));
+    let expected = r#"mama::error
+
+  × This is the parent error, the error withhhhh
+  │ the children, kiddos, pups, as it were, and
+  │ so on...
+  ╰─▶ baby::error
+      
+        × Wah wah: I may be small, but I'll
+        │ cause a proper bout of trouble — justt
+        │ try wrapping this mess of a line,
+        │ buddo!
+        help: it cannot be helped... woulddddddd
+              you really want to get rid of an
+              error that's so cute?
+      
+  help: try doing it better next time? I mean,
+        you could have also done better thisssss
+        time, but no?
+"#;
+    assert_eq!(expected, out);
+    Ok(())
+}
+
+#[test]
+fn wrapping_related_errors() -> Result<(), MietteError> {
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("This is the parent error, the error withhhhh the children, kiddos, pups, as it were, and so on...")]
+    #[diagnostic(
+        code(mama::error),
+        help(
+            "try doing it better next time? I mean, you could have also done better thisssss time, but no?"
+        )
+    )]
+    struct MamaError {
+        #[diagnostic_source]
+        baby: BrotherError,
+    }
+
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("Welcome to the brother-error brotherhood — where all of the wee baby errors join into a formidable force")]
+    #[diagnostic(code(brother::error))]
+    struct BrotherError {
+        #[related]
+        brethren: Vec<Box<dyn Diagnostic + Send + Sync>>,
+    }
+
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("Wah wah: I may be small, but I'll cause a proper bout of trouble — justt try wrapping this mess of a line, buddo!")]
+    #[diagnostic(help(
+        "it cannot be helped... woulddddddd you really want to get rid of an error that's so cute?"
+    ))]
+    struct BabyError;
+
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("Wah wah: I may be small, but I'll cause a proper bout of trouble — justt try wrapping this mess of a line, buddo!")]
+    #[diagnostic(severity(Warning))]
+    struct BabyWarning;
+
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("Wah wah: I may be small, but I'll cause a proper bout of trouble — justt try wrapping this mess of a line, buddo!")]
+    #[diagnostic(severity(Advice))]
+    struct BabyAdvice;
+
+    let err = MamaError {
+        baby: BrotherError {
+            brethren: vec![BabyError.into(), BabyWarning.into(), BabyAdvice.into()],
+        },
+    };
+    let out = fmt_report_with_settings(err.into(), |handler| handler.with_width(50));
+    let expected = r#"mama::error
+
+  × This is the parent error, the error withhhhh
+  │ the children, kiddos, pups, as it were, and
+  │ so on...
+  ╰─▶ brother::error
+      
+        × Welcome to the brother-error
+        │ brotherhood — where all of the wee
+        │ baby errors join into a formidable
+        │ force
+      
+      Error:
+        × Wah wah: I may be small, but I'll
+        │ cause a proper bout of trouble — justt
+        │ try wrapping this mess of a line,
+        │ buddo!
+        help: it cannot be helped... woulddddddd
+              you really want to get rid of an
+              error that's so cute?
+      
+      Warning:
+        ⚠ Wah wah: I may be small, but I'll
+        │ cause a proper bout of trouble — justt
+        │ try wrapping this mess of a line,
+        │ buddo!
+      
+      Advice:
+        ☞ Wah wah: I may be small, but I'll
+        │ cause a proper bout of trouble — justt
+        │ try wrapping this mess of a line,
+        │ buddo!
+      
+  help: try doing it better next time? I mean,
+        you could have also done better thisssss
+        time, but no?
+"#;
+    assert_eq!(expected, out);
+    Ok(())
+}
+
 #[test]
 fn empty_source() -> Result<(), MietteError> {
     #[derive(Debug, Diagnostic, Error)]
@@ -326,7 +472,8 @@ if true {
     let out = fmt_report(err.into());
     println!("Error: {}", out);
 
-    let expected = r#"  × oops!
+    let expected = r#"
+  × oops!
    ╭─[issue:1:1]
  1 │ ╭─▶ if true {
  2 │ │       a
@@ -361,7 +508,8 @@ fn single_line_highlight_span_full_line() {
     let out = fmt_report(err.into());
     println!("Error: {}", out);
 
-    let expected = r#"  × oops!
+    let expected = r#"
+  × oops!
    ╭─[issue:2:1]
  1 │ source
  2 │ text
@@ -1411,7 +1559,6 @@ Error: oops::my::bad
  2 │   text
    ╰────
   help: try doing it better next time?
-
 "#
     .trim_start()
     .to_string();
@@ -1578,6 +1725,7 @@ Error: oops::my::related::error
  2 │   text
    ╰────
   help: try doing it better next time?
+
 Warning: oops::my::related::warning
 
   ⚠ oops!
@@ -1588,6 +1736,7 @@ Warning: oops::my::related::warning
  2 │   text
    ╰────
   help: try doing it better next time?
+
 Advice: oops::my::related::advice
 
   ☞ oops!
@@ -1623,7 +1772,8 @@ fn zero_length_eol_span() {
     let out = fmt_report(err.into());
     println!("Error: {}", out);
 
-    let expected = r#"  × oops!
+    let expected = r#"
+  × oops!
    ╭─[issue:2:1]
  1 │ this is the first line
  2 │ this is the second line
@@ -1657,7 +1807,8 @@ fn primary_label() {
     println!("Error: {}", out);
 
     // line 2 should be the primary, not line 1
-    let expected = r#"  × oops!
+    let expected = r#"
+  × oops!
    ╭─[issue:2:2]
  1 │ this is the first line
    ·   ────
@@ -1808,7 +1959,8 @@ fn syntax_highlighter() {
     GraphicalReportHandler::new_themed(GraphicalTheme::unicode())
         .render_report(&mut out, &err)
         .unwrap();
-    let expected = r#"  × This is an error
+    let expected = r#"
+  × This is an error
    ╭─[hello_world:2:5]
  1 │ fn main() {
  2 │     println!("Hello, World!");
@@ -1862,7 +2014,8 @@ fn syntax_highlighter_on_real_file() {
         .unwrap();
 
     let expected = format!(
-        r#"  × This is an error
+        r#"
+  × This is an error
       ╭─[{filename}:{l2}:{CO}]
  {l1} │ 
  {l2} │     let (filename, line) = (file!(), line!() as usize);
diff --git a/tests/test_diagnostic_source_macro.rs b/tests/test_diagnostic_source_macro.rs
index 939812c..4f15ea0 100644
--- a/tests/test_diagnostic_source_macro.rs
+++ b/tests/test_diagnostic_source_macro.rs
@@ -104,7 +104,8 @@ fn test_diagnostic_source_pass_extra_info() {
         .render_report(&mut out, &diag)
         .unwrap();
     println!("Error: {}", out);
-    let expected = r#"  × TestError
+    let expected = r#"
+  × TestError
   ╰─▶   × A complex error happened
          ╭─[1:2]
        1 │ Hello
@@ -138,7 +139,8 @@ fn test_diagnostic_source_is_output() {
         .unwrap();
     println!("{}", out);
 
-    let expected = r#"  × TestError
+    let expected = r#"
+  × TestError
   ╰─▶   × A complex error happened
          ╭────
        1 │ right here
@@ -186,7 +188,8 @@ fn test_nested_diagnostic_source_is_output() {
         .unwrap();
     println!("{}", out);
 
-    let expected = r#"  × A nested error happened
+    let expected = r#"
+  × A nested error happened
   ├─▶   × TestError
   │   
   ╰─▶   × A complex error happened
@@ -252,10 +255,12 @@ fn test_nested_cause_chains_for_related_errors_are_output() {
         .unwrap();
     println!("{}", out);
 
-    let expected = r#"  × A nested error happened
+    let expected = r#"
+  × A nested error happened
   ╰─▶   × A multi-error happened
       
-      Error:   × A nested error happened
+      Error:
+        × A nested error happened
         ├─▶   × TestError
         │
         ╰─▶   × A complex error happened
@@ -271,7 +276,9 @@ fn test_nested_cause_chains_for_related_errors_are_output() {
          ·       ──┬─
          ·         ╰── here
          ╰────
-      Error:   × A complex error happened
+      
+      Error:
+        × A complex error happened
          ╭────
        1 │ You're actually a mess
          ·    ──┬─