Fix inlay hint hover highlight for multi-byte UTF-8 characters (#44872)

rari404 created

## Summary

Fixes #44812

The hover highlight for clickable inlay hints was not covering the last
character when the label contained multi-byte UTF-8 characters (e.g.,
`→`).

## Problem

When hovering over an inlay hint like `→ app/Livewire/UserProfile.php`,
the highlight/underline stopped one character short, leaving the final
character unhighlighted.

**Root cause:** `find_hovered_hint_part()` in `hover_popover.rs` used
`part.value.chars().count()` (character count) but `InlayOffset` wraps
`MultiBufferOffset(pub usize)` which is byte-based.

For a label like `→ app/Livewire/UserProfile.php`:
- Byte length: 32 (the `→` character is 3 bytes in UTF-8)
- Character count: 30

This mismatch caused the calculated highlight range to end 2 bytes
short.

## Changes

1. **Use byte length instead of character count** (line 112):
   ```rust
   // Before (buggy)
   let part_len = part.value.chars().count();
   
   // After (correct)
   let part_len = part.value.len();
   ```

2. **Fix boundary condition** (line 113): Changed `>` to `>=` for
correct `[start, end)` range semantics when hovering at part boundaries.

3. **Rename variable** for clarity: `hovered_character` →
`offset_in_hint` since it's a byte offset, not a character position.

4. **Add unit test** reproducing the exact scenario from the issue with
multi-byte UTF-8 characters.

## Testing

Added `test_find_hovered_hint_part_with_multibyte_characters` which:
- Verifies the label `→ app/Livewire/UserProfile.php` has 32 bytes but
30 characters
- Tests hovering at the last byte correctly returns the full range
- Tests boundary behavior with multiple label parts containing
multi-byte characters

Release Notes:

- Fixed inlay hint hover highlight not covering the last character when
the label contains multi-byte UTF-8 characters

Change summary

crates/editor/src/hover_popover.rs | 102 ++++++++++++++++++++++++++++++-
1 file changed, 98 insertions(+), 4 deletions(-)

Detailed changes

crates/editor/src/hover_popover.rs 🔗

@@ -106,12 +106,12 @@ pub fn find_hovered_hint_part(
     hovered_offset: InlayOffset,
 ) -> Option<(InlayHintLabelPart, Range<InlayOffset>)> {
     if hovered_offset >= hint_start {
-        let mut hovered_character = hovered_offset - hint_start;
+        let mut offset_in_hint = hovered_offset - hint_start;
         let mut part_start = hint_start;
         for part in label_parts {
-            let part_len = part.value.chars().count();
-            if hovered_character > part_len {
-                hovered_character -= part_len;
+            let part_len = part.value.len();
+            if offset_in_hint >= part_len {
+                offset_in_hint -= part_len;
                 part_start.0 += part_len;
             } else {
                 let part_end = InlayOffset(part_start.0 + part_len);
@@ -1907,4 +1907,98 @@ mod tests {
             );
         });
     }
+
+    #[test]
+    fn test_find_hovered_hint_part_with_multibyte_characters() {
+        use crate::display_map::InlayOffset;
+        use multi_buffer::MultiBufferOffset;
+        use project::InlayHintLabelPart;
+
+        // Test with multi-byte UTF-8 character "→" (3 bytes, 1 character)
+        let label = "→ app/Livewire/UserProfile.php";
+        let label_parts = vec![InlayHintLabelPart {
+            value: label.to_string(),
+            tooltip: None,
+            location: None,
+        }];
+
+        let hint_start = InlayOffset(MultiBufferOffset(100));
+
+        // Verify the label has more bytes than characters (due to "→")
+        assert_eq!(label.len(), 32); // bytes
+        assert_eq!(label.chars().count(), 30); // characters
+
+        // Test hovering at the last byte (should find the part)
+        let last_byte_offset = InlayOffset(MultiBufferOffset(100 + label.len() - 1));
+        let result = find_hovered_hint_part(label_parts.clone(), hint_start, last_byte_offset);
+        assert!(
+            result.is_some(),
+            "Should find part when hovering at last byte"
+        );
+        let (part, range) = result.unwrap();
+        assert_eq!(part.value, label);
+        assert_eq!(range.start, hint_start);
+        assert_eq!(range.end, InlayOffset(MultiBufferOffset(100 + label.len())));
+
+        // Test hovering at the first byte of "→" (byte 0)
+        let first_byte_offset = InlayOffset(MultiBufferOffset(100));
+        let result = find_hovered_hint_part(label_parts.clone(), hint_start, first_byte_offset);
+        assert!(
+            result.is_some(),
+            "Should find part when hovering at first byte"
+        );
+
+        // Test hovering in the middle of "→" (byte 1, still part of the arrow character)
+        let mid_arrow_offset = InlayOffset(MultiBufferOffset(101));
+        let result = find_hovered_hint_part(label_parts, hint_start, mid_arrow_offset);
+        assert!(
+            result.is_some(),
+            "Should find part when hovering in middle of multi-byte char"
+        );
+
+        // Test with multiple parts containing multi-byte characters
+        // Part ranges are [start, end) - start inclusive, end exclusive
+        // "→ " occupies bytes [0, 4), "path" occupies bytes [4, 8)
+        let parts = vec![
+            InlayHintLabelPart {
+                value: "→ ".to_string(), // 4 bytes (3 + 1)
+                tooltip: None,
+                location: None,
+            },
+            InlayHintLabelPart {
+                value: "path".to_string(), // 4 bytes
+                tooltip: None,
+                location: None,
+            },
+        ];
+
+        // Hover at byte 3 (last byte of "→ ", the space character)
+        let arrow_last_byte = InlayOffset(MultiBufferOffset(100 + 3));
+        let result = find_hovered_hint_part(parts.clone(), hint_start, arrow_last_byte);
+        assert!(result.is_some(), "Should find first part at its last byte");
+        let (part, range) = result.unwrap();
+        assert_eq!(part.value, "→ ");
+        assert_eq!(
+            range,
+            InlayOffset(MultiBufferOffset(100))..InlayOffset(MultiBufferOffset(104))
+        );
+
+        // Hover at byte 4 (first byte of "path", at the boundary)
+        let path_start_offset = InlayOffset(MultiBufferOffset(100 + 4));
+        let result = find_hovered_hint_part(parts.clone(), hint_start, path_start_offset);
+        assert!(result.is_some(), "Should find second part at boundary");
+        let (part, _) = result.unwrap();
+        assert_eq!(part.value, "path");
+
+        // Hover at byte 7 (last byte of "path")
+        let path_end_offset = InlayOffset(MultiBufferOffset(100 + 7));
+        let result = find_hovered_hint_part(parts, hint_start, path_end_offset);
+        assert!(result.is_some(), "Should find second part at last byte");
+        let (part, range) = result.unwrap();
+        assert_eq!(part.value, "path");
+        assert_eq!(
+            range,
+            InlayOffset(MultiBufferOffset(104))..InlayOffset(MultiBufferOffset(108))
+        );
+    }
 }