vim: Fix % motion edge case (#39620)

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
This commit is contained in:
Dino
2025-10-08 13:49:55 +01:00
committed by GitHub
parent a9455eb947
commit 057b7b1543
5 changed files with 148 additions and 6 deletions
@@ -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 {
+30 -6
View File
@@ -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;
@@ -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
+22
View File
@@ -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));
@@ -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"}}