Repository: zkat/miette
Pull request #417 (2024-12-22 21:55:00)

Add show_related_errors_as_nested-option
This PR adds a new option to the `MietteHandlerOpts` that allows rendering related errors as nested, like this:

![image](https://github.com/user-attachments/assets/f51ad91f-2c7b-44d3-b8b7-f4cf9f296ce2)

implements #416 


Patch:
diff --git a/src/handler.rs b/src/handler.rs
index 10d8f65..4af5999 100644
--- a/src/handler.rs
+++ b/src/handler.rs
@@ -57,6 +57,7 @@ pub struct MietteHandlerOpts {
     pub(crate) word_separator: Option<textwrap::WordSeparator>,
     pub(crate) word_splitter: Option<textwrap::WordSplitter>,
     pub(crate) highlighter: Option<MietteHighlighter>,
+    pub(crate) show_related_as_nested: Option<bool>,
 }
 
 impl MietteHandlerOpts {
@@ -167,6 +168,18 @@ impl MietteHandlerOpts {
         self
     }
 
+    /// Show related errors as siblings.
+    pub fn show_related_errors_as_siblings(mut self) -> Self {
+        self.show_related_as_nested = Some(false);
+        self
+    }
+
+    /// Show related errors as nested errors.
+    pub fn show_related_errors_as_nested(mut self) -> Self {
+        self.show_related_as_nested = Some(true);
+        self
+    }
+
     /// If true, colors will be used during graphical rendering, regardless
     /// of whether or not the terminal supports them.
     ///
@@ -332,6 +345,9 @@ impl MietteHandlerOpts {
             if let Some(s) = self.word_splitter {
                 handler = handler.with_word_splitter(s)
             }
+            if let Some(b) = self.show_related_as_nested {
+                handler = handler.with_show_related_as_nested(b)
+            }
 
             MietteHandler {
                 inner: Box::new(handler),
diff --git a/src/handlers/graphical.rs b/src/handlers/graphical.rs
index 9e0948c..fcbec05 100644
--- a/src/handlers/graphical.rs
+++ b/src/handlers/graphical.rs
@@ -37,6 +37,7 @@ pub struct GraphicalReportHandler {
     pub(crate) word_splitter: Option<textwrap::WordSplitter>,
     pub(crate) highlighter: MietteHighlighter,
     pub(crate) link_display_text: Option<String>,
+    pub(crate) show_related_as_nested: bool,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -64,6 +65,7 @@ impl GraphicalReportHandler {
             word_splitter: None,
             highlighter: MietteHighlighter::default(),
             link_display_text: None,
+            show_related_as_nested: false,
         }
     }
 
@@ -83,6 +85,7 @@ impl GraphicalReportHandler {
             word_splitter: None,
             highlighter: MietteHighlighter::default(),
             link_display_text: None,
+            show_related_as_nested: false,
         }
     }
 
@@ -177,6 +180,12 @@ impl GraphicalReportHandler {
         self
     }
 
+    /// Sets whether to render related errors as nested errors.
+    pub fn with_show_related_as_nested(mut self, show_related_as_nested: bool) -> Self {
+        self.show_related_as_nested = show_related_as_nested;
+        self
+    }
+
     /// Enable syntax highlighting for source code snippets, using the given
     /// [`Highlighter`]. See the [highlighters](crate::highlighters) crate
     /// for more details.
@@ -414,23 +423,83 @@ impl GraphicalReportHandler {
         diagnostic: &(dyn Diagnostic),
         parent_src: Option<&dyn SourceCode>,
     ) -> fmt::Result {
+        let src = diagnostic.source_code().or(parent_src);
+
         if let Some(related) = diagnostic.related() {
+            let severity_style = match diagnostic.severity() {
+                Some(Severity::Error) | None => self.theme.styles.error,
+                Some(Severity::Warning) => self.theme.styles.warning,
+                Some(Severity::Advice) => self.theme.styles.advice,
+            };
+
             let mut inner_renderer = self.clone();
             // Re-enable the printing of nested cause chains for related errors
             inner_renderer.with_cause_chain = true;
-            for rel in related {
-                writeln!(f)?;
-                match rel.severity() {
-                    Some(Severity::Error) | None => write!(f, "Error: ")?,
-                    Some(Severity::Warning) => write!(f, "Warning: ")?,
-                    Some(Severity::Advice) => write!(f, "Advice: ")?,
-                };
-                inner_renderer.render_header(f, rel)?;
-                let src = rel.source_code().or(parent_src);
-                inner_renderer.render_causes(f, rel, src)?;
-                inner_renderer.render_snippets(f, rel, src)?;
-                inner_renderer.render_footer(f, rel)?;
-                inner_renderer.render_related(f, rel, src)?;
+            if self.show_related_as_nested {
+                let width = self.termwidth.saturating_sub(2);
+                let mut related = related.peekable();
+                while let Some(rel) = related.next() {
+                    let is_last = related.peek().is_none();
+                    let char = if !is_last {
+                        self.theme.characters.lcross
+                    } else {
+                        self.theme.characters.lbot
+                    };
+                    let initial_indent = format!(
+                        "  {}{}{} ",
+                        char, self.theme.characters.hbar, self.theme.characters.rarrow
+                    )
+                    .style(severity_style)
+                    .to_string();
+                    let rest_indent = format!(
+                        "  {}   ",
+                        if is_last {
+                            ' '
+                        } else {
+                            self.theme.characters.vbar
+                        }
+                    )
+                    .style(severity_style)
+                    .to_string();
+
+                    let mut opts = textwrap::Options::new(width)
+                        .initial_indent(&initial_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);
+                    }
+
+                    let mut inner = String::new();
+
+                    let mut inner_renderer = self.clone();
+                    inner_renderer.footer = None;
+                    inner_renderer.with_cause_chain = false;
+                    inner_renderer.termwidth -= rest_indent.width();
+                    inner_renderer.render_report_inner(&mut inner, rel, src)?;
+
+                    // If there was no header, remove the leading newline
+                    let inner = inner.trim_matches('\n');
+                    writeln!(f, "{}", self.wrap(inner, opts))?;
+                }
+            } else {
+                for rel in related {
+                    writeln!(f)?;
+                    match rel.severity() {
+                        Some(Severity::Error) | None => write!(f, "Error: ")?,
+                        Some(Severity::Warning) => write!(f, "Warning: ")?,
+                        Some(Severity::Advice) => write!(f, "Advice: ")?,
+                    };
+                    inner_renderer.render_header(f, rel)?;
+                    let src = rel.source_code().or(parent_src);
+                    inner_renderer.render_causes(f, rel, src)?;
+                    inner_renderer.render_snippets(f, rel, src)?;
+                    inner_renderer.render_footer(f, rel)?;
+                    inner_renderer.render_related(f, rel, src)?;
+                }
             }
         }
         Ok(())


Test patch:
diff --git a/tests/test_diagnostic_source_macro.rs b/tests/test_diagnostic_source_macro.rs
index b21e091..71429ef 100644
--- a/tests/test_diagnostic_source_macro.rs
+++ b/tests/test_diagnostic_source_macro.rs
@@ -300,6 +300,64 @@ fn test_nested_cause_chains_for_related_errors_are_output() {
     assert_eq!(expected, out);
 }
 
+#[cfg(feature = "fancy-no-backtrace")]
+#[test]
+fn test_display_related_errors_as_nested() {
+    let inner_error = TestStructError {
+        asdf_inner_foo: SourceError {
+            code: String::from("This is another error"),
+            help: String::from("You should fix this"),
+            label: (3, 4),
+        },
+    };
+    let first_error = NestedError {
+        code: String::from("right here"),
+        label: (6, 4),
+        the_other_err: Box::new(inner_error),
+    };
+    let second_error = SourceError {
+        code: String::from("You're actually a mess"),
+        help: String::from("Get a grip..."),
+        label: (3, 4),
+    };
+    let diag = MultiError {
+        related_errs: vec![
+            Box::new(MultiError {
+                related_errs: vec![Box::new(first_error), Box::new(AnErr)],
+            }),
+            Box::new(second_error),
+        ],
+    };
+
+    let mut out = String::new();
+    miette::GraphicalReportHandler::new_themed(miette::GraphicalTheme::unicode_nocolor())
+        .with_width(80)
+        .with_show_related_as_nested(true)
+        .render_report(&mut out, &diag)
+        .unwrap();
+    println!("{}", out);
+
+    let expected = r#"
+  × A multi-error happened
+  ├─▶   × A multi-error happened
+  │     ├─▶   × A nested error happened
+  │     │      ╭────
+  │     │    1 │ right here
+  │     │      ·       ──┬─
+  │     │      ·         ╰── here
+  │     │      ╰────
+  │     ╰─▶   × AnErr
+  ╰─▶   × A complex error happened
+         ╭────
+       1 │ You're actually a mess
+         ·    ──┬─
+         ·      ╰── here
+         ╰────
+        help: Get a grip...
+"#;
+    assert_eq!(expected, out);
+}
+
 #[cfg(feature = "fancy-no-backtrace")]
 #[derive(Debug, miette::Diagnostic, thiserror::Error)]
 #[error("A case1 error happened")]