Repository: zkat/miette
Pull request #436: Remove `thiserror` from dependencies
Author: login: TheLostLambda
Created: 2025-04-26T15:57:31Z | Merged: 2025-04-26T18:34:21Z

Closes #435 and should just be a patch-level SemVer change!

Currently blocking the removal of `syn` from `kdl-rs`s dependency tree, but with this PR merged, that should become rather straightforward!

I also found and fixed a bug leading to compilation errors in `ffe374c`, some clippy lints, and reused some code in `53c8330`.

I also added tests wherever I was replacing `Error` or `Diagnostic` derives with something hand-written, just to be sure I wasn't changing any behavior!

Linked issue #435: Remove `thiserror` dependency?
This issue is somewhat related to #433 and #434 — `thiserror` is a non-optional dependency that's dragging in the `syn` crate. It should be possible to manually implement `Error` for the couple of structs currently using it outside of testing code.

Obviously it's useful to keep as a `dev-dependency`, so I'll try to create a PR that just makes the minimal changes needed to get it out of the main dependency list!
Linked issue comments: user: login: TheLostLambda
created_at: 2025-04-26T09:38:40Z
body: Benchmarking compile times before this change:

![Image](https://github.com/user-attachments/assets/10fb3ecb-c5e9-4770-8dd9-2958d81457bb)
author_association: CONTRIBUTOR
user: login: TheLostLambda
created_at: 2025-04-26T15:51:23Z
body: Well, that does make a big difference... About 4.6 times faster compiles on average... Also had the opportunity to fix a build / compilation / feature bug too — I'll create a PR now!

![Image](https://github.com/user-attachments/assets/16b19425-b4ec-4dd1-82b0-3b37bd816518)
author_association: CONTRIBUTOR

Diff:
diff --git a/Cargo.toml b/Cargo.toml
index eba604be..0daf2f72 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,6 @@ rust-version = "1.70.0"
 exclude = ["images/", "tests/", "miette-derive/"]
 
 [dependencies]
-thiserror = "2.0.11"
 miette-derive = { path = "miette-derive", version = "=7.5.0", optional = true }
 unicode-width = "0.1.11"
 cfg-if = "1.0.0"
@@ -30,6 +29,7 @@ serde = { version = "1.0.196", features = ["derive"], optional = true }
 syntect = { version = "5.1.0", optional = true }
 
 [dev-dependencies]
+thiserror = "2.0.11"
 semver = "1.0.21"
 
 # Eyre devdeps
diff --git a/src/error.rs b/src/error.rs
index 4e57a788..f77ce1e1 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,25 +1,51 @@
-use std::{fmt, io};
-
-use thiserror::Error;
+use std::{
+    error::Error,
+    fmt::{self, Display},
+    io,
+};
 
 use crate::Diagnostic;
 
 /**
 Error enum for miette. Used by certain operations in the protocol.
 */
-#[derive(Debug, Error)]
+#[derive(Debug)]
 pub enum MietteError {
     /// Wrapper around [`std::io::Error`]. This is returned when something went
     /// wrong while reading a [`SourceCode`](crate::SourceCode).
-    #[error(transparent)]
-    IoError(#[from] io::Error),
+    IoError(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")]
     OutOfBounds,
 }
 
+impl Display for MietteError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            MietteError::IoError(error) => write!(f, "{error}"),
+            MietteError::OutOfBounds => {
+                write!(f, "The given offset is outside the bounds of its Source")
+            }
+        }
+    }
+}
+
+impl Error for MietteError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        match self {
+            MietteError::IoError(error) => error.source(),
+            MietteError::OutOfBounds => None,
+        }
+    }
+}
+
+impl From<io::Error> for MietteError {
+    fn from(value: io::Error) -> Self {
+        Self::IoError(value)
+    }
+}
+
 impl Diagnostic for MietteError {
     fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
         match self {
@@ -49,3 +75,51 @@ impl Diagnostic for MietteError {
         )))
     }
 }
+
+#[cfg(test)]
+pub(crate) mod tests {
+    use std::{error::Error, io::ErrorKind};
+
+    use super::*;
+
+    #[derive(Debug)]
+    pub(crate) struct TestError(pub(crate) io::Error);
+
+    impl Display for TestError {
+        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+            write!(f, "testing, testing...")
+        }
+    }
+
+    impl Error for TestError {
+        fn source(&self) -> Option<&(dyn Error + 'static)> {
+            Some(&self.0)
+        }
+    }
+
+    #[test]
+    fn io_error() {
+        let inner_error = io::Error::new(ErrorKind::Other, "halt and catch fire");
+        let outer_error = TestError(inner_error);
+        let io_error = io::Error::new(ErrorKind::Other, outer_error);
+
+        let miette_error = MietteError::from(io_error);
+
+        assert_eq!(miette_error.to_string(), "testing, testing...");
+        assert_eq!(
+            miette_error.source().unwrap().to_string(),
+            "halt and catch fire"
+        );
+    }
+
+    #[test]
+    fn out_of_bounds() {
+        let miette_error = MietteError::OutOfBounds;
+
+        assert_eq!(
+            miette_error.to_string(),
+            "The given offset is outside the bounds of its Source"
+        );
+        assert_eq!(miette_error.source().map(ToString::to_string), None);
+    }
+}
diff --git a/src/eyreish/error.rs b/src/eyreish/error.rs
index cbb4aeeb..706470e8 100644
--- a/src/eyreish/error.rs
+++ b/src/eyreish/error.rs
@@ -280,7 +280,7 @@ impl Report {
     /// The root cause is the last error in the iterator produced by
     /// [`chain()`](Report::chain).
     pub fn root_cause(&self) -> &(dyn StdError + 'static) {
-        self.chain().last().unwrap()
+        self.chain().next_back().unwrap()
     }
 
     /// Returns true if `E` is the type held by this error object.
diff --git a/src/eyreish/into_diagnostic.rs b/src/eyreish/into_diagnostic.rs
index f862ac61..1f90c341 100644
--- a/src/eyreish/into_diagnostic.rs
+++ b/src/eyreish/into_diagnostic.rs
@@ -1,12 +1,24 @@
-use thiserror::Error;
+use std::{error::Error, fmt::Display};
 
 use crate::{Diagnostic, Report};
 
 /// Convenience [`Diagnostic`] that can be used as an "anonymous" wrapper for
 /// Errors. This is intended to be paired with [`IntoDiagnostic`].
-#[derive(Debug, Error)]
-#[error(transparent)]
+#[derive(Debug)]
 pub(crate) struct DiagnosticError(pub(crate) Box<dyn std::error::Error + Send + Sync + 'static>);
+
+impl Display for DiagnosticError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let msg = &self.0;
+        write!(f, "{msg}")
+    }
+}
+impl Error for DiagnosticError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        self.0.source()
+    }
+}
+
 impl Diagnostic for DiagnosticError {}
 
 /**
@@ -31,3 +43,26 @@ impl<T, E: std::error::Error + Send + Sync + 'static> IntoDiagnostic<T, E> for R
         self.map_err(|e| DiagnosticError(Box::new(e)).into())
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use std::io::{self, ErrorKind};
+
+    use super::*;
+
+    use crate::error::tests::TestError;
+
+    #[test]
+    fn diagnostic_error() {
+        let inner_error = io::Error::new(ErrorKind::Other, "halt and catch fire");
+        let outer_error: Result<(), _> = Err(TestError(inner_error));
+
+        let diagnostic_error = outer_error.into_diagnostic().unwrap_err();
+
+        assert_eq!(diagnostic_error.to_string(), "testing, testing...");
+        assert_eq!(
+            diagnostic_error.source().unwrap().to_string(),
+            "halt and catch fire"
+        );
+    }
+}
diff --git a/src/handler.rs b/src/handler.rs
index c6ac3484..c993c43f 100644
--- a/src/handler.rs
+++ b/src/handler.rs
@@ -424,7 +424,7 @@ impl HighlighterOption {
         highlighter: Option<MietteHighlighter>,
         supports_color: bool,
     ) -> HighlighterOption {
-        if color == Some(false) || (color == None && !supports_color) {
+        if color == Some(false) || (color.is_none() && !supports_color) {
             return HighlighterOption::Disable;
         }
         highlighter
@@ -433,6 +433,9 @@ impl HighlighterOption {
     }
 }
 
+// NOTE: This is manually implemented so that it's clearer what's going on with
+// the conditional compilation — clippy isn't picking up the `cfg` stuff here
+#[allow(clippy::derivable_impls)]
 impl Default for HighlighterOption {
     fn default() -> Self {
         cfg_if! {
diff --git a/src/highlighters/syntect.rs b/src/highlighters/syntect.rs
index 538124ca..55aa8b64 100644
--- a/src/highlighters/syntect.rs
+++ b/src/highlighters/syntect.rs
@@ -96,12 +96,12 @@ impl SyntectHighlighter {
             }
         }
         // finally, attempt to guess syntax based on first line
-        return self.syntax_set.find_syntax_by_first_line(
+        self.syntax_set.find_syntax_by_first_line(
             std::str::from_utf8(contents.data())
                 .ok()?
                 .split('\n')
                 .next()?,
-        );
+        )
     }
 }
 
@@ -115,7 +115,7 @@ pub(crate) struct SyntectHighlighterState<'h> {
     use_bg_color: bool,
 }
 
-impl<'h> HighlighterState for SyntectHighlighterState<'h> {
+impl HighlighterState for SyntectHighlighterState<'_> {
     fn highlight_line<'s>(&mut self, line: &'s str) -> Vec<Styled<&'s str>> {
         if let Ok(ops) = self.parse_state.parse_line(line, self.syntax_set) {
             let use_bg_color = self.use_bg_color;
diff --git a/src/lib.rs b/src/lib.rs
index 6396c9fa..2e076635 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -51,6 +51,7 @@
 //!   - [... 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)
@@ -690,6 +691,37 @@
 //! [`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,ignore
+//! #[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,ignore
+//! #[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/src/panic.rs b/src/panic.rs
index 123ab689..b1dfd4a8 100644
--- a/src/panic.rs
+++ b/src/panic.rs
@@ -1,7 +1,8 @@
+use std::{error::Error, fmt::Display};
+
 use backtrace::Backtrace;
-use thiserror::Error;
 
-use crate::{self as miette, Context, Diagnostic, Result};
+use crate::{Context, Diagnostic, Result};
 
 /// Tells miette to render panics using its rendering engine.
 pub fn set_panic_hook() {
@@ -25,11 +26,27 @@ pub fn set_panic_hook() {
     }));
 }
 
-#[derive(Debug, Error, Diagnostic)]
-#[error("{0}{panic}", panic = Panic::backtrace())]
-#[diagnostic(help("set the `RUST_BACKTRACE=1` environment variable to display a backtrace."))]
+#[derive(Debug)]
 struct Panic(String);
 
+impl Display for Panic {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let msg = &self.0;
+        let panic = Panic::backtrace();
+        write!(f, "{msg}{panic}")
+    }
+}
+
+impl Error for Panic {}
+
+impl Diagnostic for Panic {
+    fn help<'a>(&'a self) -> Option<Box<dyn Display + 'a>> {
+        Some(Box::new(
+            "set the `RUST_BACKTRACE=1` environment variable to display a backtrace.",
+        ))
+    }
+}
+
 impl Panic {
     fn backtrace() -> String {
         use std::fmt::Write;
@@ -84,3 +101,32 @@ impl Panic {
         "".into()
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use std::error::Error;
+
+    use super::*;
+
+    #[test]
+    fn panic() {
+        let panic = Panic("ruh roh raggy".to_owned());
+
+        assert_eq!(panic.to_string(), "ruh roh raggy");
+        assert!(panic.source().is_none());
+        assert!(panic.code().is_none());
+        assert!(panic.severity().is_none());
+        assert_eq!(
+            panic.help().map(|h| h.to_string()),
+            Some(
+                "set the `RUST_BACKTRACE=1` environment variable to display a backtrace."
+                    .to_owned()
+            )
+        );
+        assert!(panic.url().is_none());
+        assert!(panic.source_code().is_none());
+        assert!(panic.labels().is_none());
+        assert!(panic.related().is_none());
+        assert!(panic.diagnostic_source().is_none());
+    }
+}
diff --git a/src/protocol.rs b/src/protocol.rs
index c2df2945..96ae9a32 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -12,7 +12,7 @@ use std::{
 #[cfg(feature = "serde")]
 use serde::{Deserialize, Serialize};
 
-use crate::MietteError;
+use crate::{DiagnosticError, MietteError};
 
 /// Adds rich metadata to your Error that can be used by
 /// [`Report`](crate::Report) to print really nice and human-friendly error
@@ -174,18 +174,7 @@ impl From<String> for Box<dyn Diagnostic + Send + Sync> {
 
 impl From<Box<dyn std::error::Error + Send + Sync>> for Box<dyn Diagnostic + Send + Sync> {
     fn from(s: Box<dyn std::error::Error + Send + Sync>) -> Self {
-        #[derive(thiserror::Error)]
-        #[error(transparent)]
-        struct BoxedDiagnostic(Box<dyn std::error::Error + Send + Sync>);
-        impl fmt::Debug for BoxedDiagnostic {
-            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-                fmt::Debug::fmt(&self.0, f)
-            }
-        }
-
-        impl Diagnostic for BoxedDiagnostic {}
-
-        Box::new(BoxedDiagnostic(s))
+        Box::new(DiagnosticError(s))
     }
 }
 


Reviews:
user: login: zkat
body: So exciting! Thanks so much for taking the time!
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-04-26T16:57:43Z
user: login: TheLostLambda
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T17:09:23Z
user: login: TheLostLambda
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T17:10:09Z
user: login: TheLostLambda
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T17:46:37Z
user: login: zkat
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-26T18:31:52Z
user: login: zkat
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-26T18:32:07Z

Review comments:
diff_hunk: @@ -1,12 +1,20 @@
-use thiserror::Error;
+use std::{error::Error, fmt::Display};
 
 use crate::{Diagnostic, Report};
 
 /// Convenience [`Diagnostic`] that can be used as an "anonymous" wrapper for
 /// Errors. This is intended to be paired with [`IntoDiagnostic`].
-#[derive(Debug, Error)]
-#[error(transparent)]
+#[derive(Debug)]
 pub(crate) struct DiagnosticError(pub(crate) Box<dyn std::error::Error + Send + Sync + 'static>);
+
+impl Display for DiagnosticError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let msg = &self.0;
+        write!(f, "{msg}")
+    }
+}
+impl Error for DiagnosticError {}
path: src/eyreish/into_diagnostic.rs
user: login: zkat
body: `error(transparent)` forwards the `source` method
created_at: 2025-04-26T16:55:19Z
author_association: OWNER
diff_hunk: @@ -280,7 +280,7 @@ impl Report {
     /// The root cause is the last error in the iterator produced by
     /// [`chain()`](Report::chain).
     pub fn root_cause(&self) -> &(dyn StdError + 'static) {
-        self.chain().last().unwrap()
+        self.chain().next_back().unwrap()
path: src/eyreish/error.rs
user: login: zkat
body: For why?
created_at: 2025-04-26T16:55:28Z
author_association: OWNER
diff_hunk: @@ -280,7 +280,7 @@ impl Report {
     /// The root cause is the last error in the iterator produced by
     /// [`chain()`](Report::chain).
     pub fn root_cause(&self) -> &(dyn StdError + 'static) {
-        self.chain().last().unwrap()
+        self.chain().next_back().unwrap()
path: src/eyreish/error.rs
user: login: TheLostLambda
body: This was one of the clippy lints! Because that iterator is double-ended, you can just pop from the back to get the last element more efficiently!

Apparently calling `.last()` is wasteful in this case because it iterates through the whole iterator before it can return the last value (https://rust-lang.github.io/rust-clippy/stable/index.html#/next_back)

I hadn't thought about that before!
created_at: 2025-04-26T17:09:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,12 +1,20 @@
-use thiserror::Error;
+use std::{error::Error, fmt::Display};
 
 use crate::{Diagnostic, Report};
 
 /// Convenience [`Diagnostic`] that can be used as an "anonymous" wrapper for
 /// Errors. This is intended to be paired with [`IntoDiagnostic`].
-#[derive(Debug, Error)]
-#[error(transparent)]
+#[derive(Debug)]
 pub(crate) struct DiagnosticError(pub(crate) Box<dyn std::error::Error + Send + Sync + 'static>);
+
+impl Display for DiagnosticError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let msg = &self.0;
+        write!(f, "{msg}")
+    }
+}
+impl Error for DiagnosticError {}
path: src/eyreish/into_diagnostic.rs
user: login: TheLostLambda
body: Ach! Good catch! I think my tests must have missed that by not using an error with a source... I'll fix that up now!
created_at: 2025-04-26T17:10:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,12 +1,20 @@
-use thiserror::Error;
+use std::{error::Error, fmt::Display};
 
 use crate::{Diagnostic, Report};
 
 /// Convenience [`Diagnostic`] that can be used as an "anonymous" wrapper for
 /// Errors. This is intended to be paired with [`IntoDiagnostic`].
-#[derive(Debug, Error)]
-#[error(transparent)]
+#[derive(Debug)]
 pub(crate) struct DiagnosticError(pub(crate) Box<dyn std::error::Error + Send + Sync + 'static>);
+
+impl Display for DiagnosticError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let msg = &self.0;
+        write!(f, "{msg}")
+    }
+}
+impl Error for DiagnosticError {}
path: src/eyreish/into_diagnostic.rs
user: login: TheLostLambda
body: Should be fixed now!
created_at: 2025-04-26T17:46:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -280,7 +280,7 @@ impl Report {
     /// The root cause is the last error in the iterator produced by
     /// [`chain()`](Report::chain).
     pub fn root_cause(&self) -> &(dyn StdError + 'static) {
-        self.chain().last().unwrap()
+        self.chain().next_back().unwrap()
path: src/eyreish/error.rs
user: login: zkat
body: Oh! Huh! Good call
created_at: 2025-04-26T18:31:52Z
author_association: OWNER

PR comments:
user: login: TheLostLambda
created_at: 2025-04-26T16:02:24Z
body: Not sure what the doc consistency check is about, @zkat ! It seems to generate this diff, deleting a section of the README if I run the command in the makefile, but I don't know if that's correct...

![image](https://github.com/user-attachments/assets/d4e1b64a-96e2-4fc6-8913-21db082446b7)

EDIT: It looks like this check was already failing on `main`, so I won't touch the README yet...
author_association: CONTRIBUTOR
user: login: TheLostLambda
created_at: 2025-04-26T17:52:52Z
body: I suppose changing MSRV is probably not a SemVer patch change... I'll roll back my fancy-pants `io::Error::other(...)` from 1.74 :sweat_smile: ...
author_association: CONTRIBUTOR
user: login: TheLostLambda
created_at: 2025-04-26T18:03:22Z
body: Trying to fix that README thing now too, since the CI bullied me and I'm looking for revenge (green checkmark...)
author_association: CONTRIBUTOR
user: login: TheLostLambda
created_at: 2025-04-26T18:15:39Z
body: CI successfully slain :crossed_swords: :relieved:

Should be ready for review again!
author_association: CONTRIBUTOR
user: login: zkat
created_at: 2025-04-26T18:32:43Z
body: Thanks a bunch for this! I'll roll a new release later today. Feel free to ping me if I forget :)
author_association: OWNER
user: login: TheLostLambda
created_at: 2025-04-26T18:33:33Z
body: Sounds like a great plan! That'll be great for unblocking the `kdl-rs` changes :relaxed: 
author_association: CONTRIBUTOR