Repository: zkat/miette (rust)

Issue:
Are labels in narratable report handler right?
Here is the snippet from the test:
```rust
    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!
    Diagnostic severity: error
Begin snippet for bad_file.rs starting at line 1, column 1
snippet line 1: source
    label starting at line 1, column 1: this bit here
snippet line 2:   👼🏼text
snippet line 3:     here
diagnostic help: try doing it better next time?
diagnostic code: oops::my::bad
"#
```
The highlight (9, 6) should be on line 2 column 3 I think. No? Actually I've noticed this on my own example which is very similar, the span is on the middle of the 18th line, where snippet starts from 17th. But it prints "label starting at line 17, column 1" (so looks like sticks always to the first column of the first displayed line).

Am I misunderstanding narratable handler?

Pull request description:
fix(handlers): Fix label position
Fixes #106

While fixing the bug I've also extended the handler to print not just
where label starts but also where label ends.

Also implements #97, but for narratabble handler only.

This adds dependency on `unicode-width`, but it was already in the
dependency chain (`textwidth` depends on it).

Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 61793fb..2a692b5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,7 @@ exclude = ["images/", "tests/", "miette-derive/"]
 thiserror = "1.0.26"
 miette-derive = { path = "miette-derive", version = "=3.3.1-alpha.0"}
 once_cell = "1.8.0"
+unicode-width = "0.1.9"
 
 owo-colors = { version = "3.0.0", optional = true }
 atty = { version = "0.2.14", optional = true }
@@ -45,7 +46,7 @@ fancy = [
     "supports-hyperlinks",
     "supports-color",
     "supports-unicode",
-    "backtrace"
+    "backtrace",
 ]
 
 [workspace]
diff --git a/src/handlers/narratable.rs b/src/handlers/narratable.rs
index 0581e0a..31e75cf 100644
--- a/src/handlers/narratable.rs
+++ b/src/handlers/narratable.rs
@@ -1,5 +1,7 @@
 use std::fmt;
 
+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
+
 use crate::chain::Chain;
 use crate::protocol::{Diagnostic, Severity};
 use crate::{LabeledSpan, MietteError, ReportHandler, SourceCode, SourceSpan, SpanContents};
@@ -208,23 +210,44 @@ impl NarratableReportHandler {
         writeln!(f)?;
         for line in &lines {
             writeln!(f, "snippet line {}: {}", line.line_number, line.text)?;
-            let relevant = labels.iter().filter(|l| line.span_starts(l.inner()));
-            for label in relevant {
-                let contents = source
-                    .read_span(label.inner(), self.context_lines, self.context_lines)
-                    .map_err(|_| fmt::Error)?;
-                if contents.line() + 1 == line.line_number {
-                    write!(
-                        f,
-                        "    label starting at line {}, column {}",
-                        contents.line() + 1,
-                        contents.column() + 1
-                    )?;
-                    if let Some(label) = label.label() {
-                        write!(f, ": {}", label)?;
+            let relevant = labels
+                .iter()
+                .filter_map(|l| line.span_attach(l.inner()).map(|a| (a, l)));
+            for (attach, label) in relevant {
+                match attach {
+                    SpanAttach::Contained { col_start, col_end } if col_start == col_end => {
+                        write!(
+                            f,
+                            "    label at line {}, column {}",
+                            line.line_number, col_start,
+                        )?;
+                    }
+                    SpanAttach::Contained { col_start, col_end } => {
+                        write!(
+                            f,
+                            "    label at line {}, columns {} to {}",
+                            line.line_number, col_start, col_end,
+                        )?;
+                    }
+                    SpanAttach::Starts { col_start } => {
+                        write!(
+                            f,
+                            "    label starting at line {}, column {}",
+                            line.line_number, col_start,
+                        )?;
                     }
-                    writeln!(f)?;
+                    SpanAttach::Ends { col_end } => {
+                        write!(
+                            f,
+                            "    label ending at line {}, column {}",
+                            line.line_number, col_end,
+                        )?;
+                    }
+                }
+                if let Some(label) = label.label() {
+                    write!(f, ": {}", label)?;
                 }
+                writeln!(f)?;
             }
         }
         Ok(())
@@ -281,6 +304,7 @@ impl NarratableReportHandler {
                     line_number: line,
                     offset: line_offset,
                     text: line_str.clone(),
+                    at_end_of_file,
                 });
                 line_str.clear();
                 line_offset = offset;
@@ -308,12 +332,62 @@ struct Line {
     line_number: usize,
     offset: usize,
     text: String,
+    at_end_of_file: bool,
+}
+
+enum SpanAttach {
+    Contained { col_start: usize, col_end: usize },
+    Starts { col_start: usize },
+    Ends { col_end: usize },
+}
+
+/// Returns column at offset, and nearest boundary if offset is in the middle of the character
+fn safe_get_column(text: &str, offset: usize, start: bool) -> usize {
+    let mut column = text.get(0..offset).map(|s| s.width()).unwrap_or_else(|| {
+        let mut column = 0;
+        for (idx, c) in text.char_indices() {
+            if offset <= idx {
+                break;
+            }
+            column += c.width().unwrap_or(0);
+        }
+        column
+    });
+    if start {
+        // Offset are zero-based, so plus one
+        column += 1;
+    } // On the other hand for end span, offset refers for the next column
+      // So we should do -1. column+1-1 == column
+    column
 }
 
 impl Line {
-    // Does this line contain the *beginning* of this multiline span?
-    // This assumes self.span_applies() is true already.
-    fn span_starts(&self, span: &SourceSpan) -> bool {
-        span.offset() >= self.offset
+    fn span_attach(&self, span: &SourceSpan) -> Option<SpanAttach> {
+        let span_end = span.offset() + span.len();
+        let line_end = self.offset + self.text.len();
+
+        let start_after = span.offset() >= self.offset;
+        let end_before = self.at_end_of_file || span_end <= line_end;
+
+        if start_after && end_before {
+            let col_start = safe_get_column(&self.text, span.offset() - self.offset, true);
+            let col_end = if span.is_empty() {
+                col_start
+            } else {
+                // span_end refers to the next character after token
+                // while col_end refers to the exact character, so -1
+                safe_get_column(&self.text, span_end - self.offset, false)
+            };
+            return Some(SpanAttach::Contained { col_start, col_end });
+        }
+        if start_after && span.offset() <= line_end {
+            let col_start = safe_get_column(&self.text, span.offset() - self.offset, true);
+            return Some(SpanAttach::Starts { col_start });
+        }
+        if end_before && span_end >= self.offset {
+            let col_end = safe_get_column(&self.text, span_end - self.offset, false);
+            return Some(SpanAttach::Ends { col_end });
+        }
+        None
     }
 }


Test patch:
diff --git a/tests/narrated.rs b/tests/narrated.rs
index 6e906c6..5d2eff1 100644
--- a/tests/narrated.rs
+++ b/tests/narrated.rs
@@ -46,8 +46,8 @@ fn single_line_with_wide_char() -> Result<(), MietteError> {
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1: this bit here
 snippet line 2:   👼🏼text
+    label at line 2, columns 3 to 6: this bit here
 snippet line 3:     here
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
@@ -82,8 +82,8 @@ fn single_line_highlight() -> Result<(), MietteError> {
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1: this bit here
 snippet line 2:   text
+    label at line 2, columns 3 to 6: this bit here
 snippet line 3:     here
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
@@ -118,7 +118,7 @@ fn single_line_highlight_offset_zero() -> Result<(), MietteError> {
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1: this bit here
+    label at line 1, column 1: this bit here
 snippet line 2:   text
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
@@ -153,8 +153,8 @@ fn single_line_highlight_with_empty_span() -> Result<(), MietteError> {
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1: this bit here
 snippet line 2:   text
+    label at line 2, column 3: this bit here
 snippet line 3:     here
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
@@ -189,8 +189,8 @@ fn single_line_highlight_no_label() -> Result<(), MietteError> {
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1
 snippet line 2:   text
+    label at line 2, columns 3 to 6
 snippet line 3:     here
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
@@ -225,8 +225,8 @@ fn single_line_highlight_at_line_start() -> Result<(), MietteError> {
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1: this bit here
 snippet line 2: text
+    label at line 2, columns 1 to 4: this bit here
 snippet line 3:   here
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
@@ -267,10 +267,10 @@ fn multiple_same_line_highlights() -> Result<(), MietteError> {
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1: x
-    label starting at line 1, column 1: y
-    label starting at line 1, column 1: z
 snippet line 2:   text text text text text
+    label at line 2, columns 3 to 6: x
+    label at line 2, columns 8 to 11: y
+    label at line 2, columns 18 to 21: z
 snippet line 3:     here
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
@@ -305,9 +305,10 @@ fn multiline_highlight_adjacent() -> Result<(), MietteError> {
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1: these two lines
 snippet line 2:   text
+    label starting at line 2, column 3: these two lines
 snippet line 3:     here
+    label ending at line 3, column 6: these two lines
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
 "#
@@ -352,11 +353,13 @@ Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: line1
     label starting at line 1, column 1: block 1
-    label starting at line 1, column 1: block 2
 snippet line 2: line2
+    label starting at line 2, column 5: block 2
 snippet line 3: line3
 snippet line 4: line4
+    label ending at line 4, column 1: block 2
 snippet line 5: line5
+    label ending at line 5, column 5: block 1
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
 "#
@@ -418,11 +421,13 @@ Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: line1
     label starting at line 1, column 1: block 1
-    label starting at line 1, column 1
 snippet line 2: line2
+    label starting at line 2, column 5
 snippet line 3: line3
 snippet line 4: line4
+    label ending at line 4, column 1
 snippet line 5: line5
+    label ending at line 5, column 5: block 1
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
 "
@@ -461,9 +466,11 @@ Begin snippet for bad_file.rs starting at line 1, column 1
 snippet line 1: source
     label starting at line 1, column 1: this bit here
 snippet line 2:   text
-    label starting at line 2, column 1: also this bit
+    label ending at line 2, column 3: this bit here
 snippet line 3:     here
+    label starting at line 3, column 7: also this bit
 snippet line 4: more here
+    label ending at line 4, column 3: also this bit
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
 "
@@ -575,8 +582,8 @@ fn related() -> Result<(), MietteError> {
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1: this bit here
 snippet line 2:   text
+    label at line 2, columns 3 to 6: this bit here
 snippet line 3:     here
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad
@@ -587,7 +594,7 @@ Error: oops!
 Begin snippet for bad_file.rs starting at line 1, column 1
 
 snippet line 1: source
-    label starting at line 1, column 1: this bit here
+    label at line 1, columns 1 to 6: this bit here
 snippet line 2:   text
 diagnostic help: try doing it better next time?
 diagnostic code: oops::my::bad