editor: Do not include inlays in word diff highlights (#49007)

Lukas Wirth and Zed Zippy created

Release Notes:

- Fixed inlay hints being rendered as new inserted words in word based
diff highlighting

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>

Change summary

crates/editor/src/display_map.rs           | 117 ++++++++++++++++++++++++
crates/editor/src/display_map/inlay_map.rs |  45 +++++++++
crates/editor/src/element.rs               |  44 +++++---
3 files changed, 187 insertions(+), 19 deletions(-)

Detailed changes

crates/editor/src/display_map.rs 🔗

@@ -105,6 +105,7 @@ use multi_buffer::{
 use project::project_settings::DiagnosticSeverity;
 use project::{InlayId, lsp_store::LspFoldingRange, lsp_store::TokenType};
 use serde::Deserialize;
+use smallvec::SmallVec;
 use sum_tree::{Bias, TreeMap};
 use text::{BufferId, LineIndent, Patch, ToOffset as _};
 use ui::{SharedString, px};
@@ -1694,6 +1695,38 @@ impl DisplaySnapshot {
         DisplayPoint(block_point)
     }
 
+    /// Converts a buffer offset range into one or more `DisplayPoint` ranges
+    /// that cover only actual buffer text, excluding any inlay hint text that
+    /// falls within the range.
+    pub fn isomorphic_display_point_ranges_for_buffer_range(
+        &self,
+        range: Range<MultiBufferOffset>,
+    ) -> SmallVec<[Range<DisplayPoint>; 1]> {
+        let inlay_snapshot = self.inlay_snapshot();
+        inlay_snapshot
+            .buffer_offset_to_inlay_ranges(range)
+            .map(|inlay_range| {
+                let inlay_point_to_display_point = |inlay_point: InlayPoint, bias: Bias| {
+                    let fold_point = self.fold_snapshot().to_fold_point(inlay_point, bias);
+                    let tab_point = self.tab_snapshot().fold_point_to_tab_point(fold_point);
+                    let wrap_point = self.wrap_snapshot().tab_point_to_wrap_point(tab_point);
+                    let block_point = self.block_snapshot.to_block_point(wrap_point);
+                    DisplayPoint(block_point)
+                };
+
+                let start = inlay_point_to_display_point(
+                    inlay_snapshot.to_point(inlay_range.start),
+                    Bias::Left,
+                );
+                let end = inlay_point_to_display_point(
+                    inlay_snapshot.to_point(inlay_range.end),
+                    Bias::Left,
+                );
+                start..end
+            })
+            .collect()
+    }
+
     pub fn display_point_to_point(&self, point: DisplayPoint, bias: Bias) -> Point {
         self.inlay_snapshot()
             .to_buffer_point(self.display_point_to_inlay_point(point, bias))
@@ -3956,4 +3989,88 @@ pub mod tests {
             store.update_user_settings(cx, f);
         });
     }
+
+    #[gpui::test]
+    fn test_isomorphic_display_point_ranges_for_buffer_range(cx: &mut gpui::TestAppContext) {
+        cx.update(|cx| init_test(cx, |_| {}));
+
+        let buffer = cx.new(|cx| Buffer::local("let x = 5;\n", cx));
+        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+        let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
+
+        let font_size = px(14.0);
+        let map = cx.new(|cx| {
+            DisplayMap::new(
+                buffer.clone(),
+                font("Helvetica"),
+                font_size,
+                None,
+                1,
+                1,
+                FoldPlaceholder::test(),
+                DiagnosticSeverity::Warning,
+                cx,
+            )
+        });
+
+        // Without inlays, a buffer range maps to a single display range.
+        let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
+        let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range(
+            MultiBufferOffset(4)..MultiBufferOffset(9),
+        );
+        assert_eq!(ranges.len(), 1);
+        // "x = 5" is columns 4..9 with no inlays shifting anything.
+        assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 4));
+        assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 9));
+
+        // Insert a 4-char inlay hint ": i32" at buffer offset 5 (after "x").
+        map.update(cx, |map, cx| {
+            map.splice_inlays(
+                &[],
+                vec![Inlay::mock_hint(
+                    0,
+                    buffer_snapshot.anchor_after(MultiBufferOffset(5)),
+                    ": i32",
+                )],
+                cx,
+            );
+        });
+        let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
+        assert_eq!(snapshot.text(), "let x: i32 = 5;\n");
+
+        // A buffer range [4..9] ("x = 5") now spans across the inlay.
+        // It should be split into two display ranges that skip the inlay text.
+        let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range(
+            MultiBufferOffset(4)..MultiBufferOffset(9),
+        );
+        assert_eq!(
+            ranges.len(),
+            2,
+            "expected the range to be split around the inlay, got: {:?}",
+            ranges,
+        );
+        // First sub-range: buffer [4, 5) → "x" at display columns 4..5
+        assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 4));
+        assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 5));
+        // Second sub-range: buffer [5, 9) → " = 5" at display columns 10..14
+        // (shifted right by the 5-char ": i32" inlay)
+        assert_eq!(ranges[1].start, DisplayPoint::new(DisplayRow(0), 10));
+        assert_eq!(ranges[1].end, DisplayPoint::new(DisplayRow(0), 14));
+
+        // A range entirely before the inlay is not split.
+        let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range(
+            MultiBufferOffset(0)..MultiBufferOffset(5),
+        );
+        assert_eq!(ranges.len(), 1);
+        assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 0));
+        assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 5));
+
+        // A range entirely after the inlay is not split.
+        let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range(
+            MultiBufferOffset(5)..MultiBufferOffset(9),
+        );
+        assert_eq!(ranges.len(), 1);
+        assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 10));
+        assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 14));
+    }
 }

crates/editor/src/display_map/inlay_map.rs 🔗

@@ -938,6 +938,51 @@ impl InlaySnapshot {
         self.inlay_point_cursor().map(point)
     }
 
+    /// Converts a buffer offset range into one or more `InlayOffset` ranges that
+    /// cover only the actual buffer text, skipping any inlay hint text that falls
+    /// within the range. When there are no inlays the returned vec contains a
+    /// single element identical to the input mapped into inlay-offset space.
+    pub fn buffer_offset_to_inlay_ranges(
+        &self,
+        range: Range<MultiBufferOffset>,
+    ) -> impl Iterator<Item = Range<InlayOffset>> {
+        let mut cursor = self
+            .transforms
+            .cursor::<Dimensions<MultiBufferOffset, InlayOffset>>(());
+        cursor.seek(&range.start, Bias::Right);
+
+        std::iter::from_fn(move || {
+            loop {
+                match cursor.item()? {
+                    Transform::Isomorphic(_) => {
+                        let seg_buffer_start = cursor.start().0;
+                        let seg_buffer_end = cursor.end().0;
+                        let seg_inlay_start = cursor.start().1;
+
+                        let overlap_start = cmp::max(range.start, seg_buffer_start);
+                        let overlap_end = cmp::min(range.end, seg_buffer_end);
+
+                        let past_end = seg_buffer_end >= range.end;
+                        cursor.next();
+
+                        if overlap_start < overlap_end {
+                            let inlay_start =
+                                InlayOffset(seg_inlay_start.0 + (overlap_start - seg_buffer_start));
+                            let inlay_end =
+                                InlayOffset(seg_inlay_start.0 + (overlap_end - seg_buffer_start));
+                            return Some(inlay_start..inlay_end);
+                        }
+
+                        if past_end {
+                            return None;
+                        }
+                    }
+                    Transform::Inlay(_) => cursor.next(),
+                }
+            }
+        })
+    }
+
     #[ztracing::instrument(skip_all)]
     pub fn inlay_point_cursor(&self) -> InlayPointCursor<'_> {
         let cursor = self.transforms.cursor::<Dimensions<Point, InlayPoint>>(());

crates/editor/src/element.rs 🔗

@@ -5504,25 +5504,31 @@ impl EditorElement {
             })
             .filter(|(_, status)| status.is_modified())
             .flat_map(|(word_diffs, _)| word_diffs)
-            .filter_map(|word_diff| {
-                let start_point = word_diff.start.to_display_point(&snapshot.display_snapshot);
-                let end_point = word_diff.end.to_display_point(&snapshot.display_snapshot);
-                let start_row_offset = start_point.row().0.saturating_sub(start_row.0) as usize;
-
-                row_infos
-                    .get(start_row_offset)
-                    .and_then(|row_info| row_info.diff_status)
-                    .and_then(|diff_status| {
-                        let background_color = match diff_status.kind {
-                            DiffHunkStatusKind::Added => colors.version_control_word_added,
-                            DiffHunkStatusKind::Deleted => colors.version_control_word_deleted,
-                            DiffHunkStatusKind::Modified => {
-                                debug_panic!("modified diff status for row info");
-                                return None;
-                            }
-                        };
-                        Some((start_point..end_point, background_color))
-                    })
+            .flat_map(|word_diff| {
+                let display_ranges = snapshot
+                    .display_snapshot
+                    .isomorphic_display_point_ranges_for_buffer_range(
+                        word_diff.start..word_diff.end,
+                    );
+
+                display_ranges.into_iter().filter_map(|range| {
+                    let start_row_offset = range.start.row().0.saturating_sub(start_row.0) as usize;
+
+                    let diff_status = row_infos
+                        .get(start_row_offset)
+                        .and_then(|row_info| row_info.diff_status)?;
+
+                    let background_color = match diff_status.kind {
+                        DiffHunkStatusKind::Added => colors.version_control_word_added,
+                        DiffHunkStatusKind::Deleted => colors.version_control_word_deleted,
+                        DiffHunkStatusKind::Modified => {
+                            debug_panic!("modified diff status for row info");
+                            return None;
+                        }
+                    };
+
+                    Some((range, background_color))
+                })
             });
 
         highlighted_ranges.extend(word_highlights);