vim: Add `use_match_quotes` setting for % motion, default is `true` (#42615)

Hans and dino created

Add a `match_quotes` parameter to the `vim::Matching` action that
controls whether the `%` motion should treat quote characters (', ", `)
as matching pairs.

In Neovim, `%` only matches bracket pairs (([{}])), not quotes. Zed's
existing behavior includes quote matching, which some users prefer. To
preserve backwards compatibility while allowing users to opt into
Neovim's behavior, this PR:

1. Adds an optional `match_quotes` boolean parameter to the
   `vim::Matching` action
2. Updates the default vim keymap to use ["vim::Matching", {
   "match_quotes": true }], preserving Zed's current behavior
3. Users who prefer Neovim's behavior can rebind `%` in their keymap:

```
{
    "context": "VimControl && !menu",
    "bindings": {
        "%": ["vim::Matching", { "match_quotes": false }]
    }
}
```

When `match_quotes` is `false`, the `%` motion will skip over quote
characters and only match brackets/parentheses, matching Neovim's
default behavior.

Release Notes:

- vim: Added match_quotes parameter to the vim::Matching action to control
whether % matches quote characters

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

assets/keymaps/vim.json                                 |   4 
crates/vim/src/motion.rs                                | 221 ++++++++++
crates/vim/test_data/test_matching_quotes_disabled.json |  18 
crates/vim/test_data/test_matching_tag_with_quotes.json |   7 
4 files changed, 228 insertions(+), 22 deletions(-)

Detailed changes

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",

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>) {
     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<BufferOffset>,
-                        closing_range: Range<BufferOffset>| {
-        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<BufferOffset>,
+              closing_range: Range<BufferOffset>| {
+            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 {
         </html>"#});
     }
 
+    #[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"<div class='teˇst' id='main'>
+            </div>
+            "})
+            .await;
+        cx.simulate_shared_keystrokes("%").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"<div class='test' id='main'>
+            <ˇ/div>
+            "});
+
+        cx.update(|_, cx| {
+            cx.bind_keys([KeyBinding::new("%", Matching { match_quotes: true }, None)]);
+        });
+
+        cx.set_shared_state(indoc! {r"<div class='teˇst' id='main'>
+            </div>
+            "})
+            .await;
+        cx.simulate_shared_keystrokes("%").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"<div class='test' id='main'>
+            <ˇ/div>
+            "});
+    }
     #[gpui::test]
     async fn test_matching_braces_in_tag(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new_typescript(cx).await;

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"}}

crates/vim/test_data/test_matching_tag_with_quotes.json 🔗

@@ -0,0 +1,7 @@
+{"Exec":{"command":"set filetype=html"}}
+{"Put":{"state":"<div class='teˇst' id='main'>\n</div>\n"}}
+{"Key":"%"}
+{"Get":{"state":"<div class='test' id='main'>\n<ˇ/div>\n","mode":"Normal"}}
+{"Put":{"state":"<div class='teˇst' id='main'>\n</div>\n"}}
+{"Key":"%"}
+{"Get":{"state":"<div class='test' id='main'>\n<ˇ/div>\n","mode":"Normal"}}