Repository: zkat/miette
Pull request #419 (2025-01-11 18:07:39)

feature: add support for disabling the primary label line/col information
This is an attempt to tackle #91 by using the `with_primary_span_start`  (default) and `without_primary_span_start` options.

This keeps the current behavior unchanged, but allows the user to turn off the line:col information displayed for a report whose primary span extends across multiple lines. A short example:

```
// This
  × decoding error
   ╭─[2:5]
 1 │ malformed
 2 │ h�XYZ
   ·   ┬
   ·   ╰── valid data here
   ╰────

// Becomes
  × decoding error
   ╭────
 1 │ malformed
 2 │ h�XYZ
   ·   ┬
   ·   ╰── valid data here
   ╰────

```



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 277215d..3ce20c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
 # `miette` Release Changelog
 
+<a name="7.5.0"></a>
+## 7.5.0 (2025-02-01)
+
+### Features
+
+* **graphical:** support rendering related diagnostics as nested (#417) ([771a0751](https://github.com/zkat/miette/commit/771a07519f078b94aceb1a2d2532d786f09f350b))
+
+### Bug Fixes
+
+* **graphical:** prevent leading newline when no link/code (#418) ([1e1938a0](https://github.com/zkat/miette/commit/1e1938a099409969c69b9f070e0fb0d13d564527))
+
 <a name="7.4.0"></a>
 ## 7.4.0 (2024-11-27)
 
diff --git a/Cargo.toml b/Cargo.toml
index 2427667..896e940 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "miette"
-version = "7.4.0"
+version = "7.5.0"
 authors = ["Kat Marchán <kzm@zkat.tech>"]
 description = "Fancy diagnostic reporting library and protocol for us mere mortals who aren't compiler hackers."
 categories = ["rust-patterns"]
@@ -14,7 +14,7 @@ exclude = ["images/", "tests/", "miette-derive/"]
 
 [dependencies]
 thiserror = "1.0.56"
-miette-derive = { path = "miette-derive", version = "=7.4.0", optional = true }
+miette-derive = { path = "miette-derive", version = "=7.5.0", optional = true }
 unicode-width = "0.1.11"
 cfg-if = "1.0.0"
 
diff --git a/README.md b/README.md
index bd2e887..4829d03 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,7 @@ diagnostic error code: ruget::api::bad_json
   - [... handler options](#-handler-options)
   - [... dynamic diagnostics](#-dynamic-diagnostics)
   - [... syntax highlighting](#-syntax-highlighting)
+  - [... primary label](#-primary-label)
   - [... collection of labels](#-collection-of-labels)
 - [Acknowledgements](#acknowledgements)
 - [License](#license)
@@ -689,6 +690,37 @@ trait to [`MietteHandlerOpts`] by calling the
 [`with_syntax_highlighting`](MietteHandlerOpts::with_syntax_highlighting)
 method. See the [`highlighters`] module docs for more details.
 
+#### ... primary label
+
+You can use the `primary` parameter to `label` to indicate that the label
+is the primary label.
+
+```rust
+#[derive(Debug, Diagnostic, Error)]
+#[error("oops!")]
+struct MyError {
+    #[label(primary, "main issue")]
+    primary_span: SourceSpan,
+
+    #[label("other label")]
+    other_span: SourceSpan,
+}
+```
+
+The `primary` parameter can be used at most once:
+
+```rust
+#[derive(Debug, Diagnostic, Error)]
+#[error("oops!")]
+struct MyError {
+    #[label(primary, "main issue")]
+    primary_span: SourceSpan,
+
+    #[label(primary, "other label")] // Error: Cannot have more than one primary label.
+    other_span: SourceSpan,
+}
+```
+
 #### ... collection of labels
 
 When the number of labels is unknown, you can use a collection of `SourceSpan`
diff --git a/miette-derive/Cargo.toml b/miette-derive/Cargo.toml
index c055df7..9a27562 100644
--- a/miette-derive/Cargo.toml
+++ b/miette-derive/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "miette-derive"
-version = "7.4.0"
+version = "7.5.0"
 authors = ["Kat Marchán <kzm@zkat.tech>"]
 edition = "2018"
 license = "Apache-2.0"
diff --git a/src/diagnostic_chain.rs b/src/diagnostic_chain.rs
index 1e5e0c2..996fdba 100644
--- a/src/diagnostic_chain.rs
+++ b/src/diagnostic_chain.rs
@@ -74,7 +74,7 @@ impl<'a> ErrorKind<'a> {
     }
 }
 
-impl<'a> std::fmt::Debug for ErrorKind<'a> {
+impl std::fmt::Debug for ErrorKind<'_> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
             ErrorKind::Diagnostic(d) => d.fmt(f),
@@ -83,7 +83,7 @@ impl<'a> std::fmt::Debug for ErrorKind<'a> {
     }
 }
 
-impl<'a> std::fmt::Display for ErrorKind<'a> {
+impl std::fmt::Display for ErrorKind<'_> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
             ErrorKind::Diagnostic(d) => d.fmt(f),
diff --git a/src/eyreish/ptr.rs b/src/eyreish/ptr.rs
index fa954d1..40c9c7b 100644
--- a/src/eyreish/ptr.rs
+++ b/src/eyreish/ptr.rs
@@ -69,9 +69,9 @@ where
     lifetime: PhantomData<&'a T>,
 }
 
-impl<'a, T> Copy for Ref<'a, T> where T: ?Sized {}
+impl<T> Copy for Ref<'_, T> where T: ?Sized {}
 
-impl<'a, T> Clone for Ref<'a, T>
+impl<T> Clone for Ref<'_, T>
 where
     T: ?Sized,
 {
@@ -132,9 +132,9 @@ where
     lifetime: PhantomData<&'a mut T>,
 }
 
-impl<'a, T> Copy for Mut<'a, T> where T: ?Sized {}
+impl<T> Copy for Mut<'_, T> where T: ?Sized {}
 
-impl<'a, T> Clone for Mut<'a, T>
+impl<T> Clone for Mut<'_, T>
 where
     T: ?Sized,
 {
@@ -173,7 +173,7 @@ where
     }
 }
 
-impl<'a, T> Mut<'a, T> {
+impl<T> Mut<'_, T> {
     pub(crate) unsafe fn read(self) -> T {
         self.ptr.as_ptr().read()
     }
diff --git a/src/handlers/graphical.rs b/src/handlers/graphical.rs
index fcbec05..faac695 100644
--- a/src/handlers/graphical.rs
+++ b/src/handlers/graphical.rs
@@ -33,6 +33,7 @@ pub struct GraphicalReportHandler {
     pub(crate) with_cause_chain: bool,
     pub(crate) wrap_lines: bool,
     pub(crate) break_words: bool,
+    pub(crate) with_primary_span_start: bool,
     pub(crate) word_separator: Option<textwrap::WordSeparator>,
     pub(crate) word_splitter: Option<textwrap::WordSplitter>,
     pub(crate) highlighter: MietteHighlighter,
@@ -61,6 +62,7 @@ impl GraphicalReportHandler {
             with_cause_chain: true,
             wrap_lines: true,
             break_words: true,
+            with_primary_span_start: true,
             word_separator: None,
             word_splitter: None,
             highlighter: MietteHighlighter::default(),
@@ -80,6 +82,7 @@ impl GraphicalReportHandler {
             tab_width: 4,
             wrap_lines: true,
             with_cause_chain: true,
+            with_primary_span_start: true,
             break_words: true,
             word_separator: None,
             word_splitter: None,
@@ -119,6 +122,20 @@ impl GraphicalReportHandler {
         self
     }
 
+    /// Include the line and column for the the start of the primary span when the
+    /// snippet extends multiple lines
+    pub fn with_primary_span_start(mut self) -> Self {
+        self.with_primary_span_start = true;
+        self
+    }
+
+    /// Do not include the line and column for the the start of the primary span
+    /// when the snippet extends multiple lines
+    pub fn without_primary_span_start(mut self) -> Self {
+        self.with_primary_span_start = false;
+        self
+    }
+
     /// Whether to include [`Diagnostic::url()`] in the output.
     ///
     /// Disabling this is not recommended, but can be useful for more easily
@@ -237,7 +254,7 @@ impl GraphicalReportHandler {
         parent_src: Option<&dyn SourceCode>,
     ) -> fmt::Result {
         let src = diagnostic.source_code().or(parent_src);
-        self.render_header(f, diagnostic)?;
+        self.render_header(f, diagnostic, false)?;
         self.render_causes(f, diagnostic, src)?;
         self.render_snippets(f, diagnostic, src)?;
         self.render_footer(f, diagnostic)?;
@@ -261,13 +278,19 @@ impl GraphicalReportHandler {
         Ok(())
     }
 
-    fn render_header(&self, f: &mut impl fmt::Write, diagnostic: &(dyn Diagnostic)) -> fmt::Result {
+    fn render_header(
+        &self,
+        f: &mut impl fmt::Write,
+        diagnostic: &(dyn Diagnostic),
+        is_nested: bool,
+    ) -> fmt::Result {
         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 header = String::new();
+        let mut need_newline = is_nested;
         if self.links == LinkStyle::Link && diagnostic.url().is_some() {
             let url = diagnostic.url().unwrap(); // safe
             let code = if let Some(code) = diagnostic.code() {
@@ -284,6 +307,7 @@ impl GraphicalReportHandler {
             );
             write!(header, "{}", link)?;
             writeln!(f, "{}", header)?;
+            need_newline = true;
         } else if let Some(code) = diagnostic.code() {
             write!(header, "{}", code.style(severity_style),)?;
             if self.links == LinkStyle::Text && diagnostic.url().is_some() {
@@ -291,8 +315,11 @@ impl GraphicalReportHandler {
                 write!(header, " ({})", url.style(self.theme.styles.link))?;
             }
             writeln!(f, "{}", header)?;
+            need_newline = true;
+        }
+        if need_newline {
+            writeln!(f)?;
         }
-        writeln!(f)?;
         Ok(())
     }
 
@@ -493,7 +520,7 @@ impl GraphicalReportHandler {
                         Some(Severity::Warning) => write!(f, "Warning: ")?,
                         Some(Severity::Advice) => write!(f, "Advice: ")?,
                     };
-                    inner_renderer.render_header(f, rel)?;
+                    inner_renderer.render_header(f, rel, true)?;
                     let src = rel.source_code().or(parent_src);
                     inner_renderer.render_causes(f, rel, src)?;
                     inner_renderer.render_snippets(f, rel, src)?;
@@ -654,26 +681,34 @@ impl GraphicalReportHandler {
         };
 
         if let Some(source_name) = primary_contents.name() {
-            writeln!(
-                f,
-                "[{}]",
-                format_args!(
-                    "{}:{}:{}",
-                    source_name,
-                    primary_contents.line() + 1,
-                    primary_contents.column() + 1
-                )
-                .style(self.theme.styles.link)
-            )?;
-        } else if lines.len() <= 1 {
-            writeln!(f, "{}", self.theme.characters.hbar.to_string().repeat(3))?;
-        } else {
+            if self.with_primary_span_start {
+                writeln!(
+                    f,
+                    "[{}]",
+                    format_args!(
+                        "{}:{}:{}",
+                        source_name,
+                        primary_contents.line() + 1,
+                        primary_contents.column() + 1
+                    )
+                    .style(self.theme.styles.link)
+                )?;
+            } else {
+                writeln!(
+                    f,
+                    "[{}]",
+                    format_args!("{}", source_name,).style(self.theme.styles.link)
+                )?;
+            }
+        } else if self.with_primary_span_start && lines.len() > 1 {
             writeln!(
                 f,
                 "[{}:{}]",
                 primary_contents.line() + 1,
                 primary_contents.column() + 1
             )?;
+        } else {
+            writeln!(f, "{}", self.theme.characters.hbar.to_string().repeat(3))?;
         }
 
         // Now it's time for the fun part--actually rendering everything!
diff --git a/src/protocol.rs b/src/protocol.rs
index 7f09d4d..c2df294 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -134,7 +134,7 @@ impl From<&str> for Box<dyn Diagnostic> {
     }
 }
 
-impl<'a> From<&str> for Box<dyn Diagnostic + Send + Sync + 'a> {
+impl From<&str> for Box<dyn Diagnostic + Send + Sync + '_> {
     fn from(s: &str) -> Self {
         From::from(String::from(s))
     }
diff --git a/src/source_impls.rs b/src/source_impls.rs
index e362b4a..9c26b71 100644
--- a/src/source_impls.rs
+++ b/src/source_impls.rs
@@ -104,7 +104,7 @@ impl SourceCode for [u8] {
     }
 }
 
-impl<'src> SourceCode for &'src [u8] {
+impl SourceCode for &[u8] {
     fn read_span<'a>(
         &'a self,
         span: &SourceSpan,
@@ -143,7 +143,7 @@ impl SourceCode for str {
 }
 
 /// Makes `src: &'static str` or `struct S<'a> { src: &'a str }` usable.
-impl<'s> SourceCode for &'s str {
+impl SourceCode for &str {
     fn read_span<'a>(
         &'a self,
         span: &SourceSpan,


Test patch:
diff --git a/tests/graphical.rs b/tests/graphical.rs
index 3176812..93c9bce 100644
--- a/tests/graphical.rs
+++ b/tests/graphical.rs
@@ -59,7 +59,7 @@ fn word_wrap_options() -> Result<(), MietteError> {
     let out =
         fmt_report_with_settings(Report::msg("abcdefghijklmnopqrstuvwxyz"), |handler| handler);
 
-    let expected = "\n  × abcdefghijklmnopqrstuvwxyz\n".to_string();
+    let expected = "  × abcdefghijklmnopqrstuvwxyz\n".to_string();
     assert_eq!(expected, out);
 
     // A long word can break with a smaller width
@@ -75,14 +75,14 @@ fn word_wrap_options() -> Result<(), MietteError> {
   │ uvwx
   │ yz
 "#
-    .to_string();
+    .trim_start_matches('\n');
     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 = "\n  × abcdefghijklmnopqrstuvwxyz\n".to_string();
+    let expected = "  × abcdefghijklmnopqrstuvwxyz\n".to_string();
     assert_eq!(expected, out);
 
     // Breaks should start at the boundary of each word if possible
@@ -104,7 +104,7 @@ fn word_wrap_options() -> Result<(), MietteError> {
   │ 5678
   │ 90
 "#
-    .to_string();
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 
     // But long words should not break if word breaking is disabled
@@ -121,7 +121,7 @@ fn word_wrap_options() -> Result<(), MietteError> {
   │ 1234567
   │ 1234567890
 "#
-    .to_string();
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 
     // Unless, of course, there are hyphens
@@ -149,7 +149,7 @@ fn word_wrap_options() -> Result<(), MietteError> {
   │ f-g-
   │ h
 "#
-    .to_string();
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 
     // Which requires an additional opt-out
@@ -171,7 +171,7 @@ fn word_wrap_options() -> Result<(), MietteError> {
   │ a-b-c-d-e-f-g
   │ a-b-c-d-e-f-g-h
 "#
-    .to_string();
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 
     // Or if there are _other_ unicode word boundaries
@@ -199,7 +199,7 @@ fn word_wrap_options() -> Result<(), MietteError> {
   │ f/g/
   │ h
 "#
-    .to_string();
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 
     // Such things require you to opt-in to only breaking on ASCII whitespace
@@ -221,7 +221,7 @@ fn word_wrap_options() -> Result<(), MietteError> {
   │ a/b/c/d/e/f/g
   │ a/b/c/d/e/f/g/h
 "#
-    .to_string();
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 
     Ok(())
@@ -245,7 +245,7 @@ fn wrap_option() -> Result<(), MietteError> {
   │ pqr stu
   │ vwx yz
 "#
-    .to_string();
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 
     // Unless, wrapping is disabled
@@ -254,7 +254,7 @@ fn wrap_option() -> Result<(), MietteError> {
         |handler| handler.with_width(15).with_wrap_lines(false),
     );
     let expected =
-        "\n  × abc def ghi jkl mno pqr stu vwx yz abc def ghi jkl mno pqr stu vwx yz\n".to_string();
+        "  × 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
@@ -267,7 +267,7 @@ fn wrap_option() -> Result<(), MietteError> {
   │ abc def ghi jkl mno pqr stu vwx yz
   │ abc def ghi jkl mno pqr stu vwx yz
 "#
-    .to_string();
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 
     Ok(())
@@ -485,8 +485,7 @@ if true {
    · ╰──── big
    ╰────
 "#
-    .to_string();
-
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 }
 
@@ -517,8 +516,7 @@ fn single_line_highlight_span_full_line() {
    ·   ╰── This bit here
    ╰────
 "#
-    .to_string();
-
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 }
 
@@ -1781,8 +1779,7 @@ fn zero_length_eol_span() {
    · ╰── This bit here
    ╰────
 "#
-    .to_string();
-
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 }
 
@@ -1817,8 +1814,7 @@ fn primary_label() {
    ·    ╰── nope
    ╰────
 "#
-    .to_string();
-
+    .trim_start_matches('\n');
     assert_eq!(expected, out);
 }
 
@@ -1968,7 +1964,8 @@ fn syntax_highlighter() {
    ·                  ╰── this is a label
  3 │ }
    ╰────
-"#;
+"#
+    .trim_start_matches('\n');
     assert!(out.contains("\u{1b}[38;2;180;142;173m"));
     assert_eq!(expected, strip_ansi_escapes::strip_str(out))
 }
@@ -2028,6 +2025,7 @@ fn syntax_highlighter_on_real_file() {
         l2 = line,
         l3 = line + 1
     );
+    let expected = expected.trim_start_matches('\n');
     assert!(out.contains("\u{1b}[38;2;180;142;173m"));
     assert_eq!(expected, strip_ansi_escapes::strip_str(out));
 }
@@ -2510,3 +2508,63 @@ fn after_invalid_unicode() -> Result<(), MietteError> {
 
     Ok(())
 }
+
+#[test]
+fn option_include_primary_span_start() {
+    #[derive(Debug, Clone, Diagnostic, Error)]
+    #[error("decoding error")]
+    #[diagnostic(code(decode_err))]
+    struct E {
+        #[label("valid data here")]
+        src: SourceSpan,
+    }
+
+    let invalid_source: &[u8] = b"malformed\nh\xf0\x93\x8aXYZ";
+
+    let (x_index, _) = invalid_source
+        .iter()
+        .enumerate()
+        .find(|&(_, &x)| x == b'X')
+        .unwrap();
+
+    // make err pointing at the X
+    let err = E {
+        src: SourceSpan::from((x_index, 1)),
+    };
+
+    let result = fmt_report_with_settings(
+        Report::new(err.clone()).with_source_code(invalid_source),
+        |handler| handler,
+    );
+
+    let expected = "decode_err
+
+  × decoding error
+   ╭─[2:5]
+ 1 │ malformed
+ 2 │ h�XYZ
+   ·   ┬
+   ·   ╰── valid data here
+   ╰────
+";
+
+    assert_eq!(expected, result);
+
+    let result = fmt_report_with_settings(
+        Report::new(err).with_source_code(invalid_source),
+        |handler| handler.without_primary_span_start(),
+    );
+
+    let expected = "decode_err
+
+  × decoding error
+   ╭────
+ 1 │ malformed
+ 2 │ h�XYZ
+   ·   ┬
+   ·   ╰── valid data here
+   ╰────
+";
+
+    assert_eq!(expected, result);
+}
diff --git a/tests/test_diagnostic_source_macro.rs b/tests/test_diagnostic_source_macro.rs
index 71429ef..e038830 100644
--- a/tests/test_diagnostic_source_macro.rs
+++ b/tests/test_diagnostic_source_macro.rs
@@ -118,7 +118,8 @@ fn test_diagnostic_source_pass_extra_info() {
 
   this is a footer
 "#
-    .to_string();
+    .trim_start_matches('\n');
+
     assert_eq!(expected, out);
 }
 
@@ -149,7 +150,8 @@ fn test_diagnostic_source_is_output() {
          ╰────
         help: That's where the error is!
       
-"#;
+"#
+    .trim_start_matches('\n');
 
     assert_eq!(expected, out);
 }
@@ -208,7 +210,8 @@ fn test_nested_diagnostic_source_is_output() {
    ╰────
 
   Yooo, a footer
-"#;
+"#
+    .trim_start_matches('\n');
 
     assert_eq!(expected, out);
 }
@@ -295,7 +298,8 @@ fn test_nested_cause_chains_for_related_errors_are_output() {
    ╰────
 
   Yooo, a footer
-"#;
+"#
+    .trim_start_matches('\n');
 
     assert_eq!(expected, out);
 }
@@ -354,7 +358,9 @@ fn test_display_related_errors_as_nested() {
          ·      ╰── here
          ╰────
         help: Get a grip...
-"#;
+"#
+    .trim_start_matches('\n');
+
     assert_eq!(expected, out);
 }
 
@@ -403,7 +409,8 @@ fn source_is_inherited_to_causes() {
       
 
   Yooo, a footer
-"#;
+"#
+    .trim_start_matches('\n');
 
     assert_eq!(expected, out);
 }