Repository: zkat/miette
Pull request #304 (2023-10-16 20:54:01)

Make `miette-derive` be able to be turned off
Implements #303


Patch:
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/README.md b/README.md
index 7d73ba3..a29b428 100644
--- a/README.md
+++ b/README.md
@@ -96,7 +96,7 @@ You can derive a `Diagnostic` from any `std::error::Error` type.
 
 `thiserror` is a great way to define them, and plays nicely with `miette`!
 */
-use miette::{Diagnostic, SourceSpan};
+use miette::{Diagnostic, NamedSource, SourceSpan};
 use thiserror::Error;
 
 #[derive(Error, Debug, Diagnostic)]
@@ -123,7 +123,7 @@ Use this `Result` type (or its expanded version) as the return type
 throughout your app (but NOT your libraries! Those should always return
 concrete types!).
 */
-use miette::{NamedSource, Result};
+use miette::Result;
 fn this_fails() -> Result<()> {
     // You can use plain strings as a `Source`, or anything that implements
     // the one-method `Source` trait.
diff --git a/miette-derive/src/source_code.rs b/miette-derive/src/source_code.rs
index 62f28e7..e1b85ab 100644
--- a/miette-derive/src/source_code.rs
+++ b/miette-derive/src/source_code.rs
@@ -10,6 +10,7 @@ use crate::{
 
 pub struct SourceCode {
     source_code: syn::Member,
+    is_option: bool,
 }
 
 impl SourceCode {
@@ -27,6 +28,19 @@ impl SourceCode {
         for (i, field) in fields.iter().enumerate() {
             for attr in &field.attrs {
                 if attr.path().is_ident("source_code") {
+                    let is_option = if let syn::Type::Path(syn::TypePath {
+                        path: syn::Path { segments, .. },
+                        ..
+                    }) = &field.ty
+                    {
+                        segments
+                            .last()
+                            .map(|seg| seg.ident == "Option")
+                            .unwrap_or(false)
+                    } else {
+                        false
+                    };
+
                     let source_code = if let Some(ident) = field.ident.clone() {
                         syn::Member::Named(ident)
                     } else {
@@ -35,7 +49,10 @@ impl SourceCode {
                             span: field.span(),
                         })
                     };
-                    return Ok(Some(SourceCode { source_code }));
+                    return Ok(Some(SourceCode {
+                        source_code,
+                        is_option,
+                    }));
                 }
             }
         }
@@ -45,11 +62,21 @@ impl SourceCode {
     pub(crate) fn gen_struct(&self, fields: &syn::Fields) -> Option<TokenStream> {
         let (display_pat, _display_members) = display_pat_members(fields);
         let src = &self.source_code;
+        let ret = if self.is_option {
+            quote! {
+                self.#src.as_ref().map(|s| s as _)
+            }
+        } else {
+            quote! {
+                Some(&self.#src)
+            }
+        };
+
         Some(quote! {
             #[allow(unused_variables)]
             fn source_code(&self) -> std::option::Option<&dyn miette::SourceCode> {
                 let Self #display_pat = self;
-                Some(&self.#src)
+                #ret
             }
         })
     }
@@ -68,10 +95,19 @@ impl SourceCode {
                         }
                     };
                     let variant_name = ident.clone();
+                    let ret = if source_code.is_option {
+                        quote! {
+                            #field.as_ref().map(|s| s as _)
+                        }
+                    } else {
+                        quote! {
+                            std::option::Option::Some(#field)
+                        }
+                    };
                     match &fields {
                         syn::Fields::Unit => None,
                         _ => Some(quote! {
-                            Self::#variant_name #display_pat => std::option::Option::Some(#field),
+                            Self::#variant_name #display_pat => #ret,
                         }),
                     }
                 })
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<Box<dyn fmt::Display + 'a>> {
+        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<Box<dyn fmt::Display + 'a>> {
+        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<Box<dyn fmt::Display + 'a>> {
+        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/handlers/graphical.rs b/src/handlers/graphical.rs
index 770944d..8cec88b 100644
--- a/src/handlers/graphical.rs
+++ b/src/handlers/graphical.rs
@@ -651,11 +651,22 @@ impl GraphicalReportHandler {
     }
 
     /// Returns the visual column position of a byte offset on a specific line.
-    fn visual_offset(&self, line: &Line, offset: usize) -> usize {
+    ///
+    /// If the offset occurs in the middle of a character, the returned column
+    /// corresponds to that character's first column in `start` is true, or its
+    /// last column if `start` is false.
+    fn visual_offset(&self, line: &Line, offset: usize, start: bool) -> usize {
         let line_range = line.offset..=(line.offset + line.length);
         assert!(line_range.contains(&offset));
 
-        let text_index = offset - line.offset;
+        let mut text_index = offset - line.offset;
+        while text_index <= line.text.len() && !line.text.is_char_boundary(text_index) {
+            if start {
+                text_index -= 1;
+            } else {
+                text_index += 1;
+            }
+        }
         let text = &line.text[..text_index.min(line.text.len())];
         let text_width = self.line_visual_char_width(text).sum();
         if text_index > line.text.len() {
@@ -706,32 +717,34 @@ impl GraphicalReportHandler {
             .map(|hl| {
                 let byte_start = hl.offset();
                 let byte_end = hl.offset() + hl.len();
-                let start = self.visual_offset(line, byte_start).max(highest);
-                let end = self.visual_offset(line, byte_end).max(start + 1);
+                let start = self.visual_offset(line, byte_start, true).max(highest);
+                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/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::*;


Test patch:
diff --git a/tests/derive.rs b/tests/derive.rs
index f4739a7..d1da1bb 100644
--- a/tests/derive.rs
+++ b/tests/derive.rs
@@ -584,3 +584,45 @@ fn test_unit_enum_display() {
         "hello from unit help"
     );
 }
+
+#[test]
+fn test_optional_source_code() {
+    #[derive(Debug, Diagnostic, Error)]
+    #[error("struct with optional source")]
+    struct Struct {
+        #[source_code]
+        src: Option<String>,
+    }
+    assert!(Struct { src: None }.source_code().is_none());
+    assert!(Struct {
+        src: Some("".to_string())
+    }
+    .source_code()
+    .is_some());
+
+    #[derive(Debug, Diagnostic, Error)]
+    enum Enum {
+        #[error("variant1 with optional source")]
+        Variant1 {
+            #[source_code]
+            src: Option<String>,
+        },
+        #[error("variant2 with optional source")]
+        Variant2 {
+            #[source_code]
+            src: Option<String>,
+        },
+    }
+    assert!(Enum::Variant1 { src: None }.source_code().is_none());
+    assert!(Enum::Variant1 {
+        src: Some("".to_string())
+    }
+    .source_code()
+    .is_some());
+    assert!(Enum::Variant2 { src: None }.source_code().is_none());
+    assert!(Enum::Variant2 {
+        src: Some("".to_string())
+    }
+    .source_code()
+    .is_some());
+}
diff --git a/tests/graphical.rs b/tests/graphical.rs
index 887e454..d24879a 100644
--- a/tests/graphical.rs
+++ b/tests/graphical.rs
@@ -1247,3 +1247,114 @@ fn primary_label() {
 
     assert_eq!(expected, out);
 }
+
+#[test]
+fn single_line_with_wide_char_unaligned_span_start() -> 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, 5).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(())
+}
+
+#[test]
+fn single_line_with_wide_char_unaligned_span_end() -> 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: (9, 6).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 👼🏼
+   ·   ───┬───
+   ·      ╰── this bit here
+ 3 │     here
+   ╰────
+  help: try doing it better next time?
+"#
+    .trim_start()
+    .to_string();
+    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(())
+}