From 648d1de26b481d8b4c81a65d60648bb1c909f926 Mon Sep 17 00:00:00 2001 From: Rocky Shi Date: Fri, 9 Jan 2026 05:35:30 +1300 Subject: [PATCH] vim: Implement text-based matching bracket logic for Vim '%' motion to correctly find pairs within comments (#45559) Closes #25435 Release Notes: - Improved vim's '%' motion to always fall back to text-based bracket matching when language-aware matching fails --- crates/vim/src/motion.rs | 69 +++++++++++++++++-- crates/vim/src/normal.rs | 18 +++++ .../test_data/test_percent_in_comment.json | 62 +++++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 crates/vim/test_data/test_percent_in_comment.json diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 67cf2b91dc9e0c5561a9c4f5d4d13179f64e24bd..bc15eb3b9582cefc6f4459a4ba01ec53e4b61865 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -2352,6 +2352,61 @@ fn matching_tag(map: &DisplaySnapshot, head: DisplayPoint) -> Option 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, +) -> Option { + 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) } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index aee0b424f04d49cc634048bb64f95805beef8455..699a920704a97a444380ce21a09f8fbff31f7029 100644 --- a/crates/vim/src/normal.rs +++ b/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; diff --git a/crates/vim/test_data/test_percent_in_comment.json b/crates/vim/test_data/test_percent_in_comment.json new file mode 100644 index 0000000000000000000000000000000000000000..bd5eaa3d0dc86503a22b0c722badc979b28d15d1 --- /dev/null +++ b/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"}}