vim: Fix % motion edge case (#39620)

Dino created

Update Vim's `%` motion to first attempt finding the exact matching
bracket/tag under the cursor, then fall back to the previous
nearest-enclosing logic if none is found. This prevents accidentally
jumping to nested pairs in languages like TSX and Svelte where `<>`,
`</>`, and `/>` are also treated as brackets.

Closes #39368 

Release Notes:

- Fixed an edge case with the `%` motion in vim, where the cursor could
end up in a closing HTML tag instead of the matching bracket

Change summary

crates/editor/src/test/editor_lsp_test_context.rs       | 71 +++++++++++
crates/vim/src/motion.rs                                | 36 ++++
crates/vim/src/test/neovim_backed_test_context.rs       | 20 +++
crates/vim/src/test/vim_test_context.rs                 | 22 +++
crates/vim/test_data/test_matching_nested_brackets.json |  5 
5 files changed, 148 insertions(+), 6 deletions(-)

Detailed changes

crates/editor/src/test/editor_lsp_test_context.rs ๐Ÿ”—

@@ -262,6 +262,77 @@ impl EditorLspTestContext {
         Self::new(language, capabilities, cx).await
     }
 
+    pub async fn new_tsx(
+        capabilities: lsp::ServerCapabilities,
+        cx: &mut gpui::TestAppContext,
+    ) -> EditorLspTestContext {
+        let mut word_characters: HashSet<char> = Default::default();
+        word_characters.insert('$');
+        word_characters.insert('#');
+        let language = Language::new(
+            LanguageConfig {
+                name: "TSX".into(),
+                matcher: LanguageMatcher {
+                    path_suffixes: vec!["tsx".to_string()],
+                    ..Default::default()
+                },
+                brackets: language::BracketPairConfig {
+                    pairs: vec![language::BracketPair {
+                        start: "{".to_string(),
+                        end: "}".to_string(),
+                        close: true,
+                        surround: true,
+                        newline: true,
+                    }],
+                    disabled_scopes_by_bracket_ix: Default::default(),
+                },
+                word_characters,
+                ..Default::default()
+            },
+            Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
+        )
+        .with_queries(LanguageQueries {
+            brackets: Some(Cow::from(indoc! {r#"
+                ("(" @open ")" @close)
+                ("[" @open "]" @close)
+                ("{" @open "}" @close)
+                ("<" @open ">" @close)
+                ("<" @open "/>" @close)
+                ("</" @open ">" @close)
+                ("\"" @open "\"" @close)
+                ("'" @open "'" @close)
+                ("`" @open "`" @close)
+                ((jsx_element (jsx_opening_element) @open (jsx_closing_element) @close) (#set! newline.only))"#})),
+            indents: Some(Cow::from(indoc! {r#"
+                [
+                    (call_expression)
+                    (assignment_expression)
+                    (member_expression)
+                    (lexical_declaration)
+                    (variable_declaration)
+                    (assignment_expression)
+                    (if_statement)
+                    (for_statement)
+                ] @indent
+
+                (_ "[" "]" @end) @indent
+                (_ "<" ">" @end) @indent
+                (_ "{" "}" @end) @indent
+                (_ "(" ")" @end) @indent
+
+                (jsx_opening_element ">" @end) @indent
+
+                (jsx_element
+                  (jsx_opening_element) @start
+                  (jsx_closing_element)? @end) @indent
+                "#})),
+            ..Default::default()
+        })
+        .expect("Could not parse queries");
+
+        Self::new(language, capabilities, cx).await
+    }
+
     pub async fn new_html(cx: &mut gpui::TestAppContext) -> Self {
         let language = Language::new(
             LanguageConfig {

crates/vim/src/motion.rs ๐Ÿ”—

@@ -2388,6 +2388,7 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
     let display_point = map.clip_at_line_end(display_point);
     let point = display_point.to_point(map);
     let offset = point.to_offset(&map.buffer_snapshot());
+    let snapshot = map.buffer_snapshot();
 
     // Ensure the range is contained by the current line.
     let mut line_end = map.next_line_boundary(point).0;
@@ -2395,10 +2396,19 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
         line_end = map.max_point().to_point(map);
     }
 
-    if let Some((opening_range, closing_range)) = map
-        .buffer_snapshot()
-        .innermost_enclosing_bracket_ranges(offset..offset, None)
-    {
+    // Attempt to find the smallest enclosing bracket range that also contains
+    // the offset, which only happens if the cursor is currently in a bracket.
+    let range_filter = |_buffer: &language::BufferSnapshot,
+                        opening_range: Range<usize>,
+                        closing_range: Range<usize>| {
+        opening_range.contains(&offset) || closing_range.contains(&offset)
+    };
+
+    let bracket_ranges = snapshot
+        .innermost_enclosing_bracket_ranges(offset..offset, Some(&range_filter))
+        .or_else(|| snapshot.innermost_enclosing_bracket_ranges(offset..offset, None));
+
+    if let Some((opening_range, closing_range)) = bracket_ranges {
         if opening_range.contains(&offset) {
             return closing_range.start.to_display_point(map);
         } else if closing_range.contains(&offset) {
@@ -2440,7 +2450,6 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
                 if distance < closest_distance {
                     closest_pair_destination = Some(close_range.start);
                     closest_distance = distance;
-                    continue;
                 }
             }
 
@@ -2451,7 +2460,6 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
                 if distance < closest_distance {
                     closest_pair_destination = Some(open_range.start);
                     closest_distance = distance;
-                    continue;
                 }
             }
 
@@ -3391,6 +3399,22 @@ mod test {
         }"});
     }
 
+    #[gpui::test]
+    async fn test_matching_nested_brackets(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new_tsx(cx).await;
+
+        cx.set_shared_state(indoc! {r"<Button onClick=ห‡{() => {}}></Button>"})
+            .await;
+        cx.simulate_shared_keystrokes("%").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"<Button onClick={() => {}ห‡}></Button>"});
+        cx.simulate_shared_keystrokes("%").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"<Button onClick=ห‡{() => {}}></Button>"});
+    }
+
     #[gpui::test]
     async fn test_comma_semicolon(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/src/test/neovim_backed_test_context.rs ๐Ÿ”—

@@ -207,6 +207,26 @@ impl NeovimBackedTestContext {
         }
     }
 
+    pub async fn new_tsx(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext {
+        #[cfg(feature = "neovim")]
+        cx.executor().allow_parking();
+        let thread = thread::current();
+        let test_name = thread
+            .name()
+            .expect("thread is not named")
+            .split(':')
+            .next_back()
+            .unwrap()
+            .to_string();
+        Self {
+            cx: VimTestContext::new_tsx(cx).await,
+            neovim: NeovimConnection::new(test_name).await,
+
+            last_set_state: None,
+            recent_keystrokes: Default::default(),
+        }
+    }
+
     pub async fn set_shared_state(&mut self, marked_text: &str) {
         let mode = if marked_text.contains('ยป') {
             Mode::Visual

crates/vim/src/test/vim_test_context.rs ๐Ÿ”—

@@ -66,6 +66,28 @@ impl VimTestContext {
         )
     }
 
+    pub async fn new_tsx(cx: &mut gpui::TestAppContext) -> VimTestContext {
+        Self::init(cx);
+        Self::new_with_lsp(
+            EditorLspTestContext::new_tsx(
+                lsp::ServerCapabilities {
+                    completion_provider: Some(lsp::CompletionOptions {
+                        trigger_characters: Some(vec![".".to_string()]),
+                        ..Default::default()
+                    }),
+                    rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions {
+                        prepare_provider: Some(true),
+                        work_done_progress_options: Default::default(),
+                    })),
+                    ..Default::default()
+                },
+                cx,
+            )
+            .await,
+            true,
+        )
+    }
+
     pub fn init_keybindings(enabled: bool, cx: &mut App) {
         SettingsStore::update_global(cx, |store, cx| {
             store.update_user_settings(cx, |s| s.vim_mode = Some(enabled));

crates/vim/test_data/test_matching_nested_brackets.json ๐Ÿ”—

@@ -0,0 +1,5 @@
+{"Put":{"state":"<Button onClick=ห‡{() => {}}></Button>"}}
+{"Key":"%"}
+{"Get":{"state":"<Button onClick={() => {}ห‡}></Button>","mode":"Normal"}}
+{"Key":"%"}
+{"Get":{"state":"<Button onClick=ห‡{() => {}}></Button>","mode":"Normal"}}