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

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>
This commit is contained in:
Hans 2026-01-10 02:04:44 +08:00 committed by GitHub
parent a94157714a
commit 1062e2c5a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 228 additions and 22 deletions

View file

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

View file

@ -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;

View file

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

View file

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