diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 3eddcc3dd32275d6286b0070e5dfbbac445e17e4..8fb9528a5f71cb62bda6811a9ddb862bfd6ccc81 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -54,7 +54,7 @@ "#": "vim::MoveToPrevious", "n": "vim::MoveToNextMatch", "shift-n": "vim::MoveToPreviousMatch", - "%": "vim::Matching", + "%": ["vim::Matching", { "match_quotes": true }], "f": ["vim::PushFindForward", { "before": false, "multiline": false }], "t": ["vim::PushFindForward", { "before": true, "multiline": false }], "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": false }], @@ -642,7 +642,7 @@ { "context": "vim_operator == helix_m", "bindings": { - "m": "vim::Matching", + "m": ["vim::Matching", { "match_quotes": true }], "s": "vim::PushHelixSurroundAdd", "r": "vim::PushHelixSurroundReplace", "d": "vim::PushHelixSurroundDelete", diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index bc15eb3b9582cefc6f4459a4ba01ec53e4b61865..9f871b6795291f2d6f3b5a831d8804fb0bad6686 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -95,7 +95,9 @@ pub enum Motion { EndOfParagraph, StartOfDocument, EndOfDocument, - Matching, + Matching { + match_quotes: bool, + }, GoToPercentage, UnmatchedForward { char: char, @@ -275,6 +277,18 @@ struct FirstNonWhitespace { display_lines: bool, } +/// Moves to the matching bracket or delimiter. +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)] +#[action(namespace = vim)] +#[serde(deny_unknown_fields)] +struct Matching { + #[serde(default)] + /// Whether to include quote characters (`'`, `"`, `` ` ``) when searching + /// for matching pairs. When `false`, only brackets and parentheses are + /// matched, which aligns with Neovim's default `%` behavior. + match_quotes: bool, +} + /// Moves to the end of the current line. #[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)] #[action(namespace = vim)] @@ -347,8 +361,6 @@ actions!( StartOfDocument, /// Moves to the end of the document. EndOfDocument, - /// Moves to the matching bracket or delimiter. - Matching, /// Goes to a percentage position in the file. GoToPercentage, /// Moves to the start of the next line. @@ -499,9 +511,14 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &EndOfDocument, window, cx| { vim.motion(Motion::EndOfDocument, window, cx) }); - Vim::action(editor, cx, |vim, _: &Matching, window, cx| { - vim.motion(Motion::Matching, window, cx) - }); + Vim::action( + editor, + cx, + |vim, &Matching { match_quotes }: &Matching, window, cx| { + vim.motion(Motion::Matching { match_quotes }, window, cx) + }, + ); + Vim::action(editor, cx, |vim, _: &GoToPercentage, window, cx| { vim.motion(Motion::GoToPercentage, window, cx) }); @@ -773,7 +790,7 @@ impl Motion { | Jump { line: true, .. } => MotionKind::Linewise, EndOfLine { .. } | EndOfLineDownward - | Matching + | Matching { .. } | FindForward { .. } | NextWordEnd { .. } | PreviousWordEnd { .. } @@ -847,7 +864,7 @@ impl Motion { | EndOfParagraph | GoToPercentage | Jump { .. } - | Matching + | Matching { .. } | NextComment | NextGreaterIndent | NextLesserIndent @@ -887,7 +904,7 @@ impl Motion { | Up { .. } | EndOfLine { .. } | MiddleOfLine { .. } - | Matching + | Matching { .. } | UnmatchedForward { .. } | UnmatchedBackward { .. } | FindForward { .. } @@ -1039,7 +1056,7 @@ impl Motion { end_of_document(map, point, maybe_times), SelectionGoal::None, ), - Matching => (matching(map, point), SelectionGoal::None), + Matching { match_quotes } => (matching(map, point, *match_quotes), SelectionGoal::None), GoToPercentage => (go_to_percentage(map, point, times), SelectionGoal::None), UnmatchedForward { char } => ( unmatched_forward(map, point, *char, times), @@ -2407,7 +2424,11 @@ fn find_matching_bracket_text_based( None } -fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint { +fn matching( + map: &DisplaySnapshot, + display_point: DisplayPoint, + match_quotes: bool, +) -> DisplayPoint { if !map.is_singleton() { return display_point; } @@ -2423,18 +2444,38 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint line_end = map.max_point().to_point(map); } - // 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, - closing_range: Range| { - opening_range.contains(&BufferOffset(offset.0)) - || closing_range.contains(&BufferOffset(offset.0)) + let is_quote_char = |ch: char| matches!(ch, '\'' | '"' | '`'); + + let make_range_filter = |require_on_bracket: bool| { + move |buffer: &language::BufferSnapshot, + opening_range: Range, + closing_range: Range| { + if !match_quotes + && buffer + .chars_at(opening_range.start) + .next() + .is_some_and(is_quote_char) + { + return false; + } + + if require_on_bracket { + // Attempt to find the smallest enclosing bracket range that also contains + // the offset, which only happens if the cursor is currently in a bracket. + opening_range.contains(&BufferOffset(offset.0)) + || closing_range.contains(&BufferOffset(offset.0)) + } else { + true + } + } }; let bracket_ranges = snapshot - .innermost_enclosing_bracket_ranges(offset..offset, Some(&range_filter)) - .or_else(|| snapshot.innermost_enclosing_bracket_ranges(offset..offset, None)); + .innermost_enclosing_bracket_ranges(offset..offset, Some(&make_range_filter(true))) + .or_else(|| { + snapshot + .innermost_enclosing_bracket_ranges(offset..offset, Some(&make_range_filter(false))) + }); if let Some((opening_range, closing_range)) = bracket_ranges { let mut chars = map.buffer_snapshot().chars_at(offset); @@ -2461,6 +2502,16 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint let mut closest_distance = usize::MAX; for (open_range, close_range) in ranges { + if !match_quotes + && map + .buffer_snapshot() + .chars_at(open_range.start) + .next() + .is_some_and(is_quote_char) + { + continue; + } + if map.buffer_snapshot().chars_at(open_range.start).next() == Some('<') { if offset > open_range.start && offset < close_range.start { let mut chars = map.buffer_snapshot().chars_at(close_range.start); @@ -3143,10 +3194,12 @@ fn indent_motion( mod test { use crate::{ + motion::Matching, state::Mode, test::{NeovimBackedTestContext, VimTestContext}, }; use editor::Inlay; + use gpui::KeyBinding; use indoc::indoc; use language::Point; use multi_buffer::MultiBufferRow; @@ -3269,6 +3322,94 @@ mod test { cx.shared_state().await.assert_eq("func boop(ˇ) {\n}"); } + #[gpui::test] + async fn test_matching_quotes_disabled(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // Bind % to Matching with match_quotes: false to match Neovim's behavior + // (Neovim's % doesn't match quotes by default) + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new( + "%", + Matching { + match_quotes: false, + }, + None, + )]); + }); + + cx.set_shared_state("one {two 'thˇree' four}").await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq("one ˇ{two 'three' four}"); + + cx.set_shared_state("'hello wˇorld'").await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq("'hello wˇorld'"); + + cx.set_shared_state(r#"func ("teˇst") {}"#).await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(r#"func ˇ("test") {}"#); + + cx.set_shared_state("ˇ'hello'").await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq("ˇ'hello'"); + + cx.set_shared_state("'helloˇ'").await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq("'helloˇ'"); + + cx.set_shared_state(indoc! {r"func (a string) { + do('somethiˇng')) + }"}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) { + doˇ('something')) + }"}); + } + + #[gpui::test] + async fn test_matching_quotes_enabled(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new_markdown_with_rust(cx).await; + + // Test default behavior (match_quotes: true as configured in keymap/vim.json) + cx.set_state("one {two 'thˇree' four}", Mode::Normal); + cx.simulate_keystrokes("%"); + cx.assert_state("one {two ˇ'three' four}", Mode::Normal); + + cx.set_state("'hello wˇorld'", Mode::Normal); + cx.simulate_keystrokes("%"); + cx.assert_state("ˇ'hello world'", Mode::Normal); + + cx.set_state(r#"func ('teˇst') {}"#, Mode::Normal); + cx.simulate_keystrokes("%"); + cx.assert_state(r#"func (ˇ'test') {}"#, Mode::Normal); + + cx.set_state("ˇ'hello'", Mode::Normal); + cx.simulate_keystrokes("%"); + cx.assert_state("'helloˇ'", Mode::Normal); + + cx.set_state("'helloˇ'", Mode::Normal); + cx.simulate_keystrokes("%"); + cx.assert_state("ˇ'hello'", Mode::Normal); + + cx.set_state( + indoc! {r"func (a string) { + do('somethiˇng')) + }"}, + Mode::Normal, + ); + cx.simulate_keystrokes("%"); + cx.assert_state( + indoc! {r"func (a string) { + do(ˇ'something')) + }"}, + Mode::Normal, + ); + } + #[gpui::test] async fn test_unmatched_forward(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; @@ -3523,6 +3664,46 @@ mod test { "#}); } + #[gpui::test] + async fn test_matching_tag_with_quotes(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new_html(cx).await; + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new( + "%", + Matching { + match_quotes: false, + }, + None, + )]); + }); + + cx.neovim.exec("set filetype=html").await; + cx.set_shared_state(indoc! {r"
+
+ "}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"
+ <ˇ/div> + "}); + + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new("%", Matching { match_quotes: true }, None)]); + }); + + cx.set_shared_state(indoc! {r"
+
+ "}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"
+ <ˇ/div> + "}); + } #[gpui::test] async fn test_matching_braces_in_tag(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new_typescript(cx).await; diff --git a/crates/vim/test_data/test_matching_quotes_disabled.json b/crates/vim/test_data/test_matching_quotes_disabled.json new file mode 100644 index 0000000000000000000000000000000000000000..0ac20d3ab58f24dad8ea5fdaace5895e42efba69 --- /dev/null +++ b/crates/vim/test_data/test_matching_quotes_disabled.json @@ -0,0 +1,18 @@ +{"Put":{"state":"one {two 'thˇree' four}"}} +{"Key":"%"} +{"Get":{"state":"one ˇ{two 'three' four}","mode":"Normal"}} +{"Put":{"state":"'hello wˇorld'"}} +{"Key":"%"} +{"Get":{"state":"'hello wˇorld'","mode":"Normal"}} +{"Put":{"state":"func (\"teˇst\") {}"}} +{"Key":"%"} +{"Get":{"state":"func ˇ(\"test\") {}","mode":"Normal"}} +{"Put":{"state":"ˇ'hello'"}} +{"Key":"%"} +{"Get":{"state":"ˇ'hello'","mode":"Normal"}} +{"Put":{"state":"'helloˇ'"}} +{"Key":"%"} +{"Get":{"state":"'helloˇ'","mode":"Normal"}} +{"Put":{"state":"func (a string) {\n do('somethiˇng'))\n}"}} +{"Key":"%"} +{"Get":{"state":"func (a string) {\n doˇ('something'))\n}","mode":"Normal"}} diff --git a/crates/vim/test_data/test_matching_tag_with_quotes.json b/crates/vim/test_data/test_matching_tag_with_quotes.json new file mode 100644 index 0000000000000000000000000000000000000000..eae788f53aa9721bf01a09beeb09ebe824b3866d --- /dev/null +++ b/crates/vim/test_data/test_matching_tag_with_quotes.json @@ -0,0 +1,7 @@ +{"Exec":{"command":"set filetype=html"}} +{"Put":{"state":"
\n
\n"}} +{"Key":"%"} +{"Get":{"state":"
\n<ˇ/div>\n","mode":"Normal"}} +{"Put":{"state":"
\n
\n"}} +{"Key":"%"} +{"Get":{"state":"
\n<ˇ/div>\n","mode":"Normal"}}