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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}}
|
||||
Reference in New Issue
Block a user