mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
a94157714a
commit
1062e2c5a9
4 changed files with 228 additions and 22 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
18
crates/vim/test_data/test_matching_quotes_disabled.json
Normal file
18
crates/vim/test_data/test_matching_quotes_disabled.json
Normal 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"}}
|
||||
7
crates/vim/test_data/test_matching_tag_with_quotes.json
Normal file
7
crates/vim/test_data/test_matching_tag_with_quotes.json
Normal 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"}}
|
||||
Loading…
Reference in a new issue