Merge pull request #1271 from zed-industries/matching-bracket-highlights

Keith Simmons created

Highlight matching bracket when newest selection head is on a bracket

Change summary

assets/keymaps/vim.json                         |   1 
crates/editor/src/editor.rs                     |  15 +
crates/editor/src/highlight_matching_bracket.rs | 142 +++++++++++++++++++
crates/editor/src/multi_buffer.rs               |   2 
crates/editor/src/test.rs                       |  64 +++++--
crates/util/src/test/assertions.rs              |   4 
crates/util/src/test/marked_text.rs             |  17 ++
crates/vim/src/motion.rs                        |  23 ++
8 files changed, 241 insertions(+), 27 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -37,6 +37,7 @@
                     "ignorePunctuation": true
                 }
             ],
+            "shift-%": "vim::Matching",
             "escape": "editor::Cancel"
         }
     },

crates/editor/src/editor.rs 🔗

@@ -1,5 +1,6 @@
 pub mod display_map;
 mod element;
+mod highlight_matching_bracket;
 mod hover_popover;
 pub mod items;
 mod link_go_to_definition;
@@ -32,6 +33,7 @@ use gpui::{
     ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View, ViewContext,
     ViewHandle, WeakViewHandle,
 };
+use highlight_matching_bracket::refresh_matching_bracket_highlights;
 use hover_popover::{hide_hover, HoverState};
 pub use language::{char_kind, CharKind};
 use language::{
@@ -1430,6 +1432,7 @@ impl Editor {
             }
             self.refresh_code_actions(cx);
             self.refresh_document_highlights(cx);
+            refresh_matching_bracket_highlights(self, cx);
         }
 
         self.pause_cursor_blinking(cx);
@@ -5425,7 +5428,7 @@ impl Editor {
             .map(|h| &h.1);
         let write_highlights = self
             .background_highlights
-            .get(&TypeId::of::<DocumentHighlightRead>())
+            .get(&TypeId::of::<DocumentHighlightWrite>())
             .map(|h| &h.1);
         let left_position = position.bias_left(buffer);
         let right_position = position.bias_right(buffer);
@@ -10553,3 +10556,13 @@ impl<T: Ord + Clone> RangeExt<T> for Range<T> {
         self.start.clone()..=self.end.clone()
     }
 }
+
+trait RangeToAnchorExt {
+    fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range<Anchor>;
+}
+
+impl<T: ToOffset> RangeToAnchorExt for Range<T> {
+    fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range<Anchor> {
+        snapshot.anchor_after(self.start)..snapshot.anchor_before(self.end)
+    }
+}

crates/editor/src/highlight_matching_bracket.rs 🔗

@@ -0,0 +1,142 @@
+use gpui::ViewContext;
+
+use crate::{Editor, RangeToAnchorExt};
+
+enum MatchingBracketHighlight {}
+
+pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
+    editor.clear_background_highlights::<MatchingBracketHighlight>(cx);
+
+    let newest_selection = editor.selections.newest::<usize>(cx);
+    // Don't highlight brackets if the selection isn't empty
+    if !newest_selection.is_empty() {
+        return;
+    }
+
+    let head = newest_selection.head();
+    let snapshot = editor.snapshot(cx);
+    if let Some((opening_range, closing_range)) = snapshot
+        .buffer_snapshot
+        .enclosing_bracket_ranges(head..head)
+    {
+        editor.highlight_background::<MatchingBracketHighlight>(
+            vec![
+                opening_range.to_anchors(&snapshot.buffer_snapshot),
+                closing_range.to_anchors(&snapshot.buffer_snapshot),
+            ],
+            |theme| theme.editor.document_highlight_read_background,
+            cx,
+        )
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use indoc::indoc;
+
+    use language::{BracketPair, Language, LanguageConfig};
+
+    use crate::test::EditorLspTestContext;
+
+    use super::*;
+
+    #[gpui::test]
+    async fn test_matching_bracket_highlights(cx: &mut gpui::TestAppContext) {
+        let mut cx = EditorLspTestContext::new(
+            Language::new(
+                LanguageConfig {
+                    name: "Rust".into(),
+                    path_suffixes: vec!["rs".to_string()],
+                    brackets: vec![
+                        BracketPair {
+                            start: "{".to_string(),
+                            end: "}".to_string(),
+                            close: false,
+                            newline: true,
+                        },
+                        BracketPair {
+                            start: "(".to_string(),
+                            end: ")".to_string(),
+                            close: false,
+                            newline: true,
+                        },
+                    ],
+                    ..Default::default()
+                },
+                Some(tree_sitter_rust::language()),
+            )
+            .with_brackets_query(indoc! {r#"
+                ("{" @open "}" @close)
+                ("(" @open ")" @close)
+                "#})
+            .unwrap(),
+            Default::default(),
+            cx,
+        )
+        .await;
+
+        // positioning cursor inside bracket highlights both
+        cx.set_state_by(
+            vec!['|'.into()],
+            indoc! {r#"
+                pub fn test("Test |argument") {
+                    another_test(1, 2, 3);
+                }"#},
+        );
+        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
+                pub fn test[(]"Test argument"[)] {
+                    another_test(1, 2, 3);
+                }"#});
+
+        cx.set_state_by(
+            vec!['|'.into()],
+            indoc! {r#"
+                pub fn test("Test argument") {
+                    another_test(1, |2, 3);
+                }"#},
+        );
+        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
+            pub fn test("Test argument") {
+                another_test[(]1, 2, 3[)];
+            }"#});
+
+        cx.set_state_by(
+            vec!['|'.into()],
+            indoc! {r#"
+                pub fn test("Test argument") {
+                    another|_test(1, 2, 3);
+                }"#},
+        );
+        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
+            pub fn test("Test argument") [{]
+                another_test(1, 2, 3);
+            [}]"#});
+
+        // positioning outside of brackets removes highlight
+        cx.set_state_by(
+            vec!['|'.into()],
+            indoc! {r#"
+                pub f|n test("Test argument") {
+                    another_test(1, 2, 3);
+                }"#},
+        );
+        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
+            pub fn test("Test argument") {
+                another_test(1, 2, 3);
+            }"#});
+
+        // non empty selection dismisses highlight
+        // positioning outside of brackets removes highlight
+        cx.set_state_by(
+            vec![('<', '>').into()],
+            indoc! {r#"
+                pub fn test("Te<st arg>ument") {
+                    another_test(1, 2, 3);
+                }"#},
+        );
+        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
+            pub fn test("Test argument") {
+                another_test(1, 2, 3);
+            }"#});
+    }
+}

crates/editor/src/multi_buffer.rs 🔗

@@ -2321,7 +2321,7 @@ impl MultiBufferSnapshot {
                     .enclosing_bracket_ranges(start_in_buffer..end_in_buffer)?;
 
                 if start_bracket_range.start >= excerpt_buffer_start
-                    && end_bracket_range.end < excerpt_buffer_end
+                    && end_bracket_range.end <= excerpt_buffer_end
                 {
                     start_bracket_range.start =
                         cursor.start() + (start_bracket_range.start - excerpt_buffer_start);

crates/editor/src/test.rs 🔗

@@ -1,4 +1,5 @@
 use std::{
+    any::TypeId,
     ops::{Deref, DerefMut, Range},
     sync::Arc,
 };
@@ -13,7 +14,7 @@ use project::Project;
 use settings::Settings;
 use util::{
     assert_set_eq, set_eq,
-    test::{marked_text, marked_text_ranges, marked_text_ranges_by, SetEqError},
+    test::{marked_text, marked_text_ranges, marked_text_ranges_by, SetEqError, TextRangeMarker},
 };
 use workspace::{pane, AppState, Workspace, WorkspaceHandle};
 
@@ -159,29 +160,30 @@ impl<'a> EditorTestContext<'a> {
     // `[` to `}` represents a non empty selection with the head at `}`
     // `{` to `]` represents a non empty selection with the head at `{`
     pub fn set_state(&mut self, text: &str) {
+        self.set_state_by(
+            vec![
+                '|'.into(),
+                ('[', '}').into(),
+                TextRangeMarker::ReverseRange('{', ']'),
+            ],
+            text,
+        );
+    }
+
+    pub fn set_state_by(&mut self, range_markers: Vec<TextRangeMarker>, text: &str) {
         self.editor.update(self.cx, |editor, cx| {
-            let (unmarked_text, mut selection_ranges) = marked_text_ranges_by(
-                &text,
-                vec!['|'.into(), ('[', '}').into(), ('{', ']').into()],
-            );
+            let (unmarked_text, selection_ranges) = marked_text_ranges_by(&text, range_markers);
             editor.set_text(unmarked_text, cx);
 
-            let mut selections: Vec<Range<usize>> =
-                selection_ranges.remove(&'|'.into()).unwrap_or_default();
-            selections.extend(
-                selection_ranges
-                    .remove(&('{', ']').into())
-                    .unwrap_or_default()
-                    .into_iter()
-                    .map(|range| range.end..range.start),
-            );
-            selections.extend(
-                selection_ranges
-                    .remove(&('[', '}').into())
-                    .unwrap_or_default(),
-            );
-
-            editor.change_selections(Some(Autoscroll::Fit), cx, |s| s.select_ranges(selections));
+            let selection_ranges: Vec<Range<usize>> = selection_ranges
+                .values()
+                .into_iter()
+                .flatten()
+                .cloned()
+                .collect();
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.select_ranges(selection_ranges)
+            })
         })
     }
 
@@ -216,6 +218,26 @@ impl<'a> EditorTestContext<'a> {
         )
     }
 
+    pub fn assert_editor_background_highlights<Tag: 'static>(&mut self, marked_text: &str) {
+        let (unmarked, mut ranges) = marked_text_ranges_by(marked_text, vec![('[', ']').into()]);
+        assert_eq!(unmarked, self.buffer_text());
+
+        let asserted_ranges = ranges.remove(&('[', ']').into()).unwrap();
+        let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
+            let snapshot = editor.snapshot(cx);
+            editor
+                .background_highlights
+                .get(&TypeId::of::<Tag>())
+                .map(|h| h.1.clone())
+                .unwrap_or_default()
+                .into_iter()
+                .map(|range| range.to_offset(&snapshot.buffer_snapshot))
+                .collect()
+        });
+
+        assert_set_eq!(asserted_ranges, actual_ranges);
+    }
+
     pub fn assert_editor_text_highlights<Tag: ?Sized + 'static>(&mut self, marked_text: &str) {
         let (unmarked, mut ranges) = marked_text_ranges_by(marked_text, vec![('[', ']').into()]);
         assert_eq!(unmarked, self.buffer_text());

crates/util/src/test/assertions.rs 🔗

@@ -51,10 +51,10 @@ macro_rules! assert_set_eq {
 
         match set_eq!(&left, &right) {
             Err(SetEqError::LeftMissing(missing)) => {
-                panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nright does not contain {:?}", &left, &right, &missing);
+                panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nleft does not contain {:?}", &left, &right, &missing);
             },
             Err(SetEqError::RightMissing(missing)) => {
-                panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nleft does not contain {:?}", &left, &right, &missing);
+                panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nright does not contain {:?}", &left, &right, &missing);
             },
             _ => {}
         }

crates/util/src/test/marked_text.rs 🔗

@@ -28,6 +28,7 @@ pub fn marked_text(marked_text: &str) -> (String, Vec<usize>) {
 pub enum TextRangeMarker {
     Empty(char),
     Range(char, char),
+    ReverseRange(char, char),
 }
 
 impl TextRangeMarker {
@@ -35,6 +36,7 @@ impl TextRangeMarker {
         match self {
             Self::Empty(m) => vec![*m],
             Self::Range(l, r) => vec![*l, *r],
+            Self::ReverseRange(l, r) => vec![*l, *r],
         }
     }
 }
@@ -85,6 +87,21 @@ pub fn marked_text_ranges_by(
                     .collect::<Vec<Range<usize>>>();
                 (marker, ranges)
             }
+            TextRangeMarker::ReverseRange(start_marker, end_marker) => {
+                let starts = marker_offsets.remove(&start_marker).unwrap_or_default();
+                let ends = marker_offsets.remove(&end_marker).unwrap_or_default();
+                assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced");
+
+                let ranges = starts
+                    .into_iter()
+                    .zip(ends)
+                    .map(|(start, end)| {
+                        assert!(end >= start, "marked ranges must be disjoint");
+                        end..start
+                    })
+                    .collect::<Vec<Range<usize>>>();
+                (marker, ranges)
+            }
         })
         .collect();
 

crates/vim/src/motion.rs 🔗

@@ -30,6 +30,7 @@ pub enum Motion {
     EndOfLine,
     StartOfDocument,
     EndOfDocument,
+    Matching,
 }
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -65,7 +66,8 @@ actions!(
         EndOfLine,
         CurrentLine,
         StartOfDocument,
-        EndOfDocument
+        EndOfDocument,
+        Matching,
     ]
 );
 impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]);
@@ -85,6 +87,7 @@ pub fn init(cx: &mut MutableAppContext) {
         motion(Motion::StartOfDocument, cx)
     });
     cx.add_action(|_: &mut Workspace, _: &EndOfDocument, cx: _| motion(Motion::EndOfDocument, cx));
+    cx.add_action(|_: &mut Workspace, _: &Matching, cx: _| motion(Motion::Matching, cx));
 
     cx.add_action(
         |_: &mut Workspace, &NextWordStart { ignore_punctuation }: &NextWordStart, cx: _| {
@@ -136,7 +139,7 @@ impl Motion {
         }
 
         match self {
-            EndOfLine | NextWordEnd { .. } => true,
+            EndOfLine | NextWordEnd { .. } | Matching => true,
             Left | Right | StartOfLine | NextWordStart { .. } | PreviousWordStart { .. } => false,
             _ => panic!("Exclusivity not defined for {self:?}"),
         }
@@ -172,6 +175,7 @@ impl Motion {
             CurrentLine => (end_of_line(map, point), SelectionGoal::None),
             StartOfDocument => (start_of_document(map, point), SelectionGoal::None),
             EndOfDocument => (end_of_document(map, point), SelectionGoal::None),
+            Matching => (matching(map, point), SelectionGoal::None),
         }
     }
 
@@ -341,3 +345,18 @@ fn end_of_document(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
     *new_point.column_mut() = point.column();
     map.clip_point(new_point, Bias::Left)
 }
+
+fn matching(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
+    let offset = point.to_offset(map, Bias::Left);
+    if let Some((open_range, close_range)) =
+        map.buffer_snapshot.enclosing_bracket_ranges(offset..offset)
+    {
+        if open_range.contains(&offset) {
+            close_range.start.to_display_point(map)
+        } else {
+            open_range.start.to_display_point(map)
+        }
+    } else {
+        point
+    }
+}