From 848d3cc34e64658463b112397e8ae656ac4d7100 Mon Sep 17 00:00:00 2001 From: rari404 <138394996+edlsh@users.noreply.github.com> Date: Sun, 4 Jan 2026 07:59:34 -0500 Subject: [PATCH] Fix inlay hint hover highlight for multi-byte UTF-8 characters (#44872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- crates/editor/src/hover_popover.rs | 102 +++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 4 deletions(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index db3b68ec6fe1be7c6a4c080366cd130c2d83fb96..9c718cff4e0eeebd08fea9c20404ddb8f599988a 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -106,12 +106,12 @@ pub fn find_hovered_hint_part( hovered_offset: InlayOffset, ) -> Option<(InlayHintLabelPart, Range)> { 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)) + ); + } }