Repository: zkat/miette
Pull request #312 (2023-11-02 02:25:54)

Extend error text span to whole code points
This fixes a panic when an error starts inside a Unicode code point. The range is extended to start (or end) at the beginning (or end) of the character inside which the byte offset is located.

Fixes zkat/miette#223. Unit tests provided in that issue by @Benjamin-L (lightly modified).

Benjamin-L: There's one more edge case here that might be worth fixing now or might be worth handling in a later PR:

```rust
    let src = "source\n  👼🏼text\n    here".to_string();
    let err = MyBad {
        src: NamedSource::new("bad_file.rs", src),
        highlight: (10, 0).into(),
    };
```

Produces

```
---- single_line_with_wide_char_unaligned_span_empty stdout ----
Error: 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?
```

I would probably expect either

```
---- single_line_with_wide_char_unaligned_span_empty stdout ----
Error: 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?
```

or maybe

```
---- single_line_with_wide_char_unaligned_span_empty stdout ----
Error: 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?
```

Patch:
diff --git a/src/handlers/graphical.rs b/src/handlers/graphical.rs
index 770944d..44d92b7 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,8 +717,8 @@ 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 = self.visual_offset(line, byte_end, false).max(start + 1);
 
                 let vbar_offset = (start + end) / 2;
                 let num_left = vbar_offset - start;


Test patch:
diff --git a/tests/graphical.rs b/tests/graphical.rs
index 887e454..31db4dc 100644
--- a/tests/graphical.rs
+++ b/tests/graphical.rs
@@ -1247,3 +1247,77 @@ 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(())
+}