vim: Implement text-based matching bracket logic for Vim '%' motion to correctly find pairs within comments (#45559)

Rocky Shi created

Closes #25435

Release Notes:

- Improved vim's '%' motion to always fall back to text-based bracket
matching when language-aware matching fails

Change summary

crates/vim/src/motion.rs                          | 69 ++++++++++++++++
crates/vim/src/normal.rs                          | 18 ++++
crates/vim/test_data/test_percent_in_comment.json | 62 +++++++++++++++
3 files changed, 145 insertions(+), 4 deletions(-)

Detailed changes

crates/vim/src/motion.rs 🔗

@@ -2352,6 +2352,61 @@ fn matching_tag(map: &DisplaySnapshot, head: DisplayPoint) -> Option<DisplayPoin
     None
 }
 
+const BRACKET_PAIRS: [(char, char); 3] = [('(', ')'), ('[', ']'), ('{', '}')];
+
+fn get_bracket_pair(ch: char) -> Option<(char, char, bool)> {
+    for (open, close) in BRACKET_PAIRS {
+        if ch == open {
+            return Some((open, close, true));
+        }
+        if ch == close {
+            return Some((open, close, false));
+        }
+    }
+    None
+}
+
+fn find_matching_bracket_text_based(
+    map: &DisplaySnapshot,
+    offset: MultiBufferOffset,
+    line_range: Range<MultiBufferOffset>,
+) -> Option<MultiBufferOffset> {
+    let bracket_info = map
+        .buffer_chars_at(offset)
+        .take_while(|(_, char_offset)| *char_offset < line_range.end)
+        .find_map(|(ch, char_offset)| get_bracket_pair(ch).map(|info| (info, char_offset)));
+
+    let (open, close, is_opening) = bracket_info?.0;
+    let bracket_offset = bracket_info?.1;
+
+    let mut depth = 0i32;
+    if is_opening {
+        for (ch, char_offset) in map.buffer_chars_at(bracket_offset) {
+            if ch == open {
+                depth += 1;
+            } else if ch == close {
+                depth -= 1;
+                if depth == 0 {
+                    return Some(char_offset);
+                }
+            }
+        }
+    } else {
+        for (ch, char_offset) in map.reverse_buffer_chars_at(bracket_offset + close.len_utf8()) {
+            if ch == close {
+                depth += 1;
+            } else if ch == open {
+                depth -= 1;
+                if depth == 0 {
+                    return Some(char_offset);
+                }
+            }
+        }
+    }
+
+    None
+}
+
 fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
     if !map.is_singleton() {
         return display_point;
@@ -2398,10 +2453,10 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
     let line_range = map.prev_line_boundary(point).0..line_end;
     let visible_line_range =
         line_range.start..Point::new(line_range.end.row, line_range.end.column.saturating_sub(1));
+    let line_range = line_range.start.to_offset(&map.buffer_snapshot())
+        ..line_range.end.to_offset(&map.buffer_snapshot());
     let ranges = map.buffer_snapshot().bracket_ranges(visible_line_range);
     if let Some(ranges) = ranges {
-        let line_range = line_range.start.to_offset(&map.buffer_snapshot())
-            ..line_range.end.to_offset(&map.buffer_snapshot());
         let mut closest_pair_destination = None;
         let mut closest_distance = usize::MAX;
 
@@ -2447,9 +2502,15 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
 
         closest_pair_destination
             .map(|destination| destination.to_display_point(map))
-            .unwrap_or(display_point)
+            .unwrap_or_else(|| {
+                find_matching_bracket_text_based(map, offset, line_range.clone())
+                    .map(|o| o.to_display_point(map))
+                    .unwrap_or(display_point)
+            })
     } else {
-        display_point
+        find_matching_bracket_text_based(map, offset, line_range)
+            .map(|o| o.to_display_point(map))
+            .unwrap_or(display_point)
     }
 }
 

crates/vim/src/normal.rs 🔗

@@ -1866,6 +1866,24 @@ mod test {
             .assert_matches();
     }
 
+    #[gpui::test]
+    async fn test_percent_in_comment(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.simulate_at_each_offset("%", "// ˇconsole.logˇ(ˇvaˇrˇ)ˇ;")
+            .await
+            .assert_matches();
+        cx.simulate_at_each_offset("%", "// ˇ{ ˇ{ˇ}ˇ }ˇ")
+            .await
+            .assert_matches();
+        // Template-style brackets (like Liquid {% %} and {{ }})
+        cx.simulate_at_each_offset("%", "ˇ{ˇ% block %ˇ}ˇ")
+            .await
+            .assert_matches();
+        cx.simulate_at_each_offset("%", "ˇ{ˇ{ˇ var ˇ}ˇ}ˇ")
+            .await
+            .assert_matches();
+    }
+
     #[gpui::test]
     async fn test_end_of_line_with_neovim(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/test_data/test_percent_in_comment.json 🔗

@@ -0,0 +1,62 @@
+{"Put":{"state":"// ˇconsole.log(var);"}}
+{"Key":"%"}
+{"Get":{"state":"// console.log(varˇ);","mode":"Normal"}}
+{"Put":{"state":"// console.logˇ(var);"}}
+{"Key":"%"}
+{"Get":{"state":"// console.log(varˇ);","mode":"Normal"}}
+{"Put":{"state":"// console.log(ˇvar);"}}
+{"Key":"%"}
+{"Get":{"state":"// console.logˇ(var);","mode":"Normal"}}
+{"Put":{"state":"// console.log(vaˇr);"}}
+{"Key":"%"}
+{"Get":{"state":"// console.logˇ(var);","mode":"Normal"}}
+{"Put":{"state":"// console.log(varˇ);"}}
+{"Key":"%"}
+{"Get":{"state":"// console.logˇ(var);","mode":"Normal"}}
+{"Put":{"state":"// console.log(var)ˇ;"}}
+{"Key":"%"}
+{"Get":{"state":"// console.log(var)ˇ;","mode":"Normal"}}
+{"Put":{"state":"// ˇ{ {} }"}}
+{"Key":"%"}
+{"Get":{"state":"// { {} ˇ}","mode":"Normal"}}
+{"Put":{"state":"// { ˇ{} }"}}
+{"Key":"%"}
+{"Get":{"state":"// { {ˇ} }","mode":"Normal"}}
+{"Key":"%"}
+{"Get":{"state":"// { ˇ{} }","mode":"Normal"}}
+{"Put":{"state":"// { {}ˇ }"}}
+{"Key":"%"}
+{"Get":{"state":"// ˇ{ {} }","mode":"Normal"}}
+{"Put":{"state":"// { {} }ˇ"}}
+{"Key":"%"}
+{"Get":{"state":"// ˇ{ {} }","mode":"Normal"}}
+{"Put":{"state":"ˇ{% block %}"}}
+{"Key":"%"}
+{"Get":{"state":"{% block %ˇ}","mode":"Normal"}}
+{"Put":{"state":"{ˇ% block %}"}}
+{"Key":"%"}
+{"Get":{"state":"ˇ{% block %}","mode":"Normal"}}
+{"Put":{"state":"{% block %ˇ}"}}
+{"Key":"%"}
+{"Get":{"state":"ˇ{% block %}","mode":"Normal"}}
+{"Put":{"state":"{% block %}ˇ"}}
+{"Key":"%"}
+{"Get":{"state":"ˇ{% block %}","mode":"Normal"}}
+{"Put":{"state":"ˇ{{ var }}"}}
+{"Key":"%"}
+{"Get":{"state":"{{ var }ˇ}","mode":"Normal"}}
+{"Put":{"state":"{ˇ{ var }}"}}
+{"Key":"%"}
+{"Get":{"state":"{{ var ˇ}}","mode":"Normal"}}
+{"Put":{"state":"{{ˇ var }}"}}
+{"Key":"%"}
+{"Get":{"state":"{ˇ{ var }}","mode":"Normal"}}
+{"Put":{"state":"{{ var ˇ}}"}}
+{"Key":"%"}
+{"Get":{"state":"{ˇ{ var }}","mode":"Normal"}}
+{"Put":{"state":"{{ var }ˇ}"}}
+{"Key":"%"}
+{"Get":{"state":"ˇ{{ var }}","mode":"Normal"}}
+{"Put":{"state":"{{ var }}ˇ"}}
+{"Key":"%"}
+{"Get":{"state":"ˇ{{ var }}","mode":"Normal"}}