vim: Create anyquotes, anybrackets, miniquotes, and minibrackets text objects (#26748)

## Why?
Some users expressed a preference for the AnyQuotes and AnyBrackets text
objects to align more closely with traditional Vim behavior, rather than
the mini.ai plugin's approach. To address this, I’ve introduced two new
text objects: MiniQuotes and MiniBrackets. These retain the mini.ai
plugin behavior, while the updated AnyQuotes and AnyBrackets now follow
the logic described in [this bug
report](https://github.com/zed-industries/zed/issues/25563) and [this
bug report](https://github.com/zed-industries/zed/issues/25562).

## Behavior Overview:
### AnyQuotes and AnyBrackets:
These now prioritize the innermost range first (e.g., the closest quotes
or brackets). If none are found, they fall back to searching the current
line. This aligns with the behavior requested in the issue.

### MiniQuotes and MiniBrackets:
These maintain the mini.ai plugin behavior, prioritizing the current
line before expanding the search outward.

### Usage Examples:
AnyQuotes: Works like ```ci', ci", ci` , ca', ca", ca` , etc.```

AnyBrackets: Works like ```ci(, ci[, ci{, ci<, ca(, ca[, ca{, ca<,
etc.```

Please give these changes a try and let me know your thoughts!

### Release Notes:

- vim: Add AnyQuotes, AnyBrackets, MiniQuotes and MiniBrackets text
objects

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
Osvaldo 2025-04-29 16:09:27 -06:00 committed by GitHub
parent 33abf1ee7c
commit a09e5d255b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 536 additions and 11 deletions

View file

@ -28,9 +28,11 @@ pub enum Object {
Quotes,
BackQuotes,
AnyQuotes,
MiniQuotes,
DoubleQuotes,
VerticalBars,
AnyBrackets,
MiniBrackets,
Parentheses,
SquareBrackets,
CurlyBrackets,
@ -158,7 +160,7 @@ impl DelimiterRange {
}
}
fn find_any_delimiters(
fn find_mini_delimiters(
map: &DisplaySnapshot,
display_point: DisplayPoint,
around: bool,
@ -234,20 +236,20 @@ fn is_bracket_delimiter(buffer: &BufferSnapshot, start: usize, _end: usize) -> b
)
}
fn find_any_quotes(
fn find_mini_quotes(
map: &DisplaySnapshot,
display_point: DisplayPoint,
around: bool,
) -> Option<Range<DisplayPoint>> {
find_any_delimiters(map, display_point, around, &is_quote_delimiter)
find_mini_delimiters(map, display_point, around, &is_quote_delimiter)
}
fn find_any_brackets(
fn find_mini_brackets(
map: &DisplaySnapshot,
display_point: DisplayPoint,
around: bool,
) -> Option<Range<DisplayPoint>> {
find_any_delimiters(map, display_point, around, &is_bracket_delimiter)
find_mini_delimiters(map, display_point, around, &is_bracket_delimiter)
}
impl_actions!(vim, [Word, Subword, IndentObj]);
@ -259,10 +261,12 @@ actions!(
Paragraph,
Quotes,
BackQuotes,
MiniQuotes,
AnyQuotes,
DoubleQuotes,
VerticalBars,
Parentheses,
MiniBrackets,
AnyBrackets,
SquareBrackets,
CurlyBrackets,
@ -306,6 +310,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, |vim, _: &BackQuotes, window, cx| {
vim.object(Object::BackQuotes, window, cx)
});
Vim::action(editor, cx, |vim, _: &MiniQuotes, window, cx| {
vim.object(Object::MiniQuotes, window, cx)
});
Vim::action(editor, cx, |vim, _: &MiniBrackets, window, cx| {
vim.object(Object::MiniBrackets, window, cx)
});
Vim::action(editor, cx, |vim, _: &AnyQuotes, window, cx| {
vim.object(Object::AnyQuotes, window, cx)
});
@ -382,11 +392,13 @@ impl Object {
| Object::Quotes
| Object::BackQuotes
| Object::AnyQuotes
| Object::MiniQuotes
| Object::VerticalBars
| Object::DoubleQuotes => false,
Object::Sentence
| Object::Paragraph
| Object::AnyBrackets
| Object::MiniBrackets
| Object::Parentheses
| Object::Tag
| Object::AngleBrackets
@ -412,9 +424,11 @@ impl Object {
Object::Quotes
| Object::BackQuotes
| Object::AnyQuotes
| Object::MiniQuotes
| Object::DoubleQuotes
| Object::VerticalBars
| Object::AnyBrackets
| Object::MiniBrackets
| Object::Parentheses
| Object::SquareBrackets
| Object::Tag
@ -434,6 +448,7 @@ impl Object {
| Object::Sentence
| Object::Quotes
| Object::AnyQuotes
| Object::MiniQuotes
| Object::BackQuotes
| Object::DoubleQuotes => {
if current_mode == Mode::VisualBlock {
@ -444,6 +459,7 @@ impl Object {
}
Object::Parentheses
| Object::AnyBrackets
| Object::MiniBrackets
| Object::SquareBrackets
| Object::CurlyBrackets
| Object::AngleBrackets
@ -493,7 +509,67 @@ impl Object {
Object::BackQuotes => {
surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
}
Object::AnyQuotes => find_any_quotes(map, relative_to, around),
Object::AnyQuotes => {
let quote_types = ['\'', '"', '`'];
let cursor_offset = relative_to.to_offset(map, Bias::Left);
// Find innermost range directly without collecting all ranges
let mut innermost = None;
let mut min_size = usize::MAX;
// First pass: find innermost enclosing range
for quote in quote_types {
if let Some(range) = surrounding_markers(
map,
relative_to,
around,
self.is_multiline(),
quote,
quote,
) {
let start_offset = range.start.to_offset(map, Bias::Left);
let end_offset = range.end.to_offset(map, Bias::Right);
if cursor_offset >= start_offset && cursor_offset <= end_offset {
let size = end_offset - start_offset;
if size < min_size {
min_size = size;
innermost = Some(range);
}
}
}
}
if let Some(range) = innermost {
return Some(range);
}
// Fallback: find nearest pair if not inside any quotes
quote_types
.iter()
.flat_map(|&quote| {
surrounding_markers(
map,
relative_to,
around,
self.is_multiline(),
quote,
quote,
)
})
.min_by_key(|range| {
let start_offset = range.start.to_offset(map, Bias::Left);
let end_offset = range.end.to_offset(map, Bias::Right);
if cursor_offset < start_offset {
(start_offset - cursor_offset) as isize
} else if cursor_offset > end_offset {
(cursor_offset - end_offset) as isize
} else {
0
}
})
}
Object::MiniQuotes => find_mini_quotes(map, relative_to, around),
Object::DoubleQuotes => {
surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"')
}
@ -508,7 +584,66 @@ impl Object {
let range = selection.range();
surrounding_html_tag(map, head, range, around)
}
Object::AnyBrackets => find_any_brackets(map, relative_to, around),
Object::AnyBrackets => {
let bracket_pairs = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')];
let cursor_offset = relative_to.to_offset(map, Bias::Left);
// Find innermost enclosing bracket range
let mut innermost = None;
let mut min_size = usize::MAX;
for &(open, close) in bracket_pairs.iter() {
if let Some(range) = surrounding_markers(
map,
relative_to,
around,
self.is_multiline(),
open,
close,
) {
let start_offset = range.start.to_offset(map, Bias::Left);
let end_offset = range.end.to_offset(map, Bias::Right);
if cursor_offset >= start_offset && cursor_offset <= end_offset {
let size = end_offset - start_offset;
if size < min_size {
min_size = size;
innermost = Some(range);
}
}
}
}
if let Some(range) = innermost {
return Some(range);
}
// Fallback: find nearest bracket pair if not inside any
bracket_pairs
.iter()
.flat_map(|&(open, close)| {
surrounding_markers(
map,
relative_to,
around,
self.is_multiline(),
open,
close,
)
})
.min_by_key(|range| {
let start_offset = range.start.to_offset(map, Bias::Left);
let end_offset = range.end.to_offset(map, Bias::Right);
if cursor_offset < start_offset {
(start_offset - cursor_offset) as isize
} else if cursor_offset > end_offset {
(cursor_offset - end_offset) as isize
} else {
0
}
})
}
Object::MiniBrackets => find_mini_brackets(map, relative_to, around),
Object::SquareBrackets => {
surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
}
@ -1525,7 +1660,7 @@ mod test {
use indoc::indoc;
use crate::{
object::AnyBrackets,
object::{AnyBrackets, AnyQuotes, MiniBrackets},
state::Mode,
test::{NeovimBackedTestContext, VimTestContext},
};
@ -2193,6 +2328,158 @@ mod test {
#[gpui::test]
async fn test_anyquotes_object(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.update(|_, cx| {
cx.bind_keys([KeyBinding::new(
"q",
AnyQuotes,
Some("vim_operator == a || vim_operator == i || vim_operator == cs"),
)]);
});
const TEST_CASES: &[(&str, &str, &str, Mode)] = &[
// the false string in the middle should be considered
(
"c i q",
"'first' false ˇstring 'second'",
"'first'ˇ'second'",
Mode::Insert,
),
// Single quotes
(
"c i q",
"Thisˇ is a 'quote' example.",
"This is a 'ˇ' example.",
Mode::Insert,
),
(
"c a q",
"Thisˇ is a 'quote' example.",
"This is a ˇexample.",
Mode::Insert,
),
(
"c i q",
"This is a \"simple 'qˇuote'\" example.",
"This is a \"simple 'ˇ'\" example.",
Mode::Insert,
),
(
"c a q",
"This is a \"simple 'qˇuote'\" example.",
"This is a \"simpleˇ\" example.",
Mode::Insert,
),
(
"c i q",
"This is a 'qˇuote' example.",
"This is a 'ˇ' example.",
Mode::Insert,
),
(
"c a q",
"This is a 'qˇuote' example.",
"This is a ˇexample.",
Mode::Insert,
),
(
"d i q",
"This is a 'qˇuote' example.",
"This is a 'ˇ' example.",
Mode::Normal,
),
(
"d a q",
"This is a 'qˇuote' example.",
"This is a ˇexample.",
Mode::Normal,
),
// Double quotes
(
"c i q",
"This is a \"qˇuote\" example.",
"This is a \"ˇ\" example.",
Mode::Insert,
),
(
"c a q",
"This is a \"qˇuote\" example.",
"This is a ˇexample.",
Mode::Insert,
),
(
"d i q",
"This is a \"qˇuote\" example.",
"This is a \"ˇ\" example.",
Mode::Normal,
),
(
"d a q",
"This is a \"qˇuote\" example.",
"This is a ˇexample.",
Mode::Normal,
),
// Back quotes
(
"c i q",
"This is a `qˇuote` example.",
"This is a `ˇ` example.",
Mode::Insert,
),
(
"c a q",
"This is a `qˇuote` example.",
"This is a ˇexample.",
Mode::Insert,
),
(
"d i q",
"This is a `qˇuote` example.",
"This is a `ˇ` example.",
Mode::Normal,
),
(
"d a q",
"This is a `qˇuote` example.",
"This is a ˇexample.",
Mode::Normal,
),
];
for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES {
cx.set_state(initial_state, Mode::Normal);
cx.simulate_keystrokes(keystrokes);
cx.assert_state(expected_state, *expected_mode);
}
const INVALID_CASES: &[(&str, &str, Mode)] = &[
("c i q", "this is a 'qˇuote example.", Mode::Normal), // Missing closing simple quote
("c a q", "this is a 'qˇuote example.", Mode::Normal), // Missing closing simple quote
("d i q", "this is a 'qˇuote example.", Mode::Normal), // Missing closing simple quote
("d a q", "this is a 'qˇuote example.", Mode::Normal), // Missing closing simple quote
("c i q", "this is a \"qˇuote example.", Mode::Normal), // Missing closing double quote
("c a q", "this is a \"qˇuote example.", Mode::Normal), // Missing closing double quote
("d i q", "this is a \"qˇuote example.", Mode::Normal), // Missing closing double quote
("d a q", "this is a \"qˇuote example.", Mode::Normal), // Missing closing back quote
("c i q", "this is a `qˇuote example.", Mode::Normal), // Missing closing back quote
("c a q", "this is a `qˇuote example.", Mode::Normal), // Missing closing back quote
("d i q", "this is a `qˇuote example.", Mode::Normal), // Missing closing back quote
("d a q", "this is a `qˇuote example.", Mode::Normal), // Missing closing back quote
];
for (keystrokes, initial_state, mode) in INVALID_CASES {
cx.set_state(initial_state, Mode::Normal);
cx.simulate_keystrokes(keystrokes);
cx.assert_state(initial_state, *mode);
}
}
#[gpui::test]
async fn test_miniquotes_object(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new_typescript(cx).await;
const TEST_CASES: &[(&str, &str, &str, Mode)] = &[
@ -2220,7 +2507,7 @@ mod test {
Mode::Insert,
),
// If you are in the close quote and it is the only quote in the buffer, it should replace inside the quote
// This is not working with the core motion ci' for this special edge case, so I am happy to fix it in AnyQuotes :)
// This is not working with the core motion ci' for this special edge case, so I am happy to fix it in MiniQuotes :)
// Bug reference: https://github.com/zed-industries/zed/issues/23889
("c i q", "'quote«'ˇ»", "'ˇ'", Mode::Insert),
// Single quotes
@ -2367,6 +2654,169 @@ mod test {
)]);
});
const TEST_CASES: &[(&str, &str, &str, Mode)] = &[
(
"c i b",
indoc! {"
{
{
ˇprint('hello')
}
}
"},
indoc! {"
{
{
ˇ
}
}
"},
Mode::Insert,
),
// Bracket (Parentheses)
(
"c i b",
"Thisˇ is a (simple [quote]) example.",
"This is a (ˇ) example.",
Mode::Insert,
),
(
"c i b",
"This is a [simple (qˇuote)] example.",
"This is a [simple (ˇ)] example.",
Mode::Insert,
),
(
"c a b",
"This is a [simple (qˇuote)] example.",
"This is a [simple ˇ] example.",
Mode::Insert,
),
(
"c a b",
"Thisˇ is a (simple [quote]) example.",
"This is a ˇ example.",
Mode::Insert,
),
(
"c i b",
"This is a (qˇuote) example.",
"This is a (ˇ) example.",
Mode::Insert,
),
(
"c a b",
"This is a (qˇuote) example.",
"This is a ˇ example.",
Mode::Insert,
),
(
"d i b",
"This is a (qˇuote) example.",
"This is a (ˇ) example.",
Mode::Normal,
),
(
"d a b",
"This is a (qˇuote) example.",
"This is a ˇ example.",
Mode::Normal,
),
// Square brackets
(
"c i b",
"This is a [qˇuote] example.",
"This is a [ˇ] example.",
Mode::Insert,
),
(
"c a b",
"This is a [qˇuote] example.",
"This is a ˇ example.",
Mode::Insert,
),
(
"d i b",
"This is a [qˇuote] example.",
"This is a [ˇ] example.",
Mode::Normal,
),
(
"d a b",
"This is a [qˇuote] example.",
"This is a ˇ example.",
Mode::Normal,
),
// Curly brackets
(
"c i b",
"This is a {qˇuote} example.",
"This is a {ˇ} example.",
Mode::Insert,
),
(
"c a b",
"This is a {qˇuote} example.",
"This is a ˇ example.",
Mode::Insert,
),
(
"d i b",
"This is a {qˇuote} example.",
"This is a {ˇ} example.",
Mode::Normal,
),
(
"d a b",
"This is a {qˇuote} example.",
"This is a ˇ example.",
Mode::Normal,
),
];
for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES {
cx.set_state(initial_state, Mode::Normal);
cx.simulate_keystrokes(keystrokes);
cx.assert_state(expected_state, *expected_mode);
}
const INVALID_CASES: &[(&str, &str, Mode)] = &[
("c i b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket
("c a b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket
("d i b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket
("d a b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket
("c i b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket
("c a b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket
("d i b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket
("d a b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket
("c i b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket
("c a b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket
("d i b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket
("d a b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket
];
for (keystrokes, initial_state, mode) in INVALID_CASES {
cx.set_state(initial_state, Mode::Normal);
cx.simulate_keystrokes(keystrokes);
cx.assert_state(initial_state, *mode);
}
}
#[gpui::test]
async fn test_minibrackets_object(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.update(|_, cx| {
cx.bind_keys([KeyBinding::new(
"b",
MiniBrackets,
Some("vim_operator == a || vim_operator == i || vim_operator == cs"),
)]);
});
const TEST_CASES: &[(&str, &str, &str, Mode)] = &[
// Special cases from mini.ai plugin
// Current line has more priority for the cover or next algorithm, to avoid changing curly brackets which is supper anoying
@ -2572,7 +3022,7 @@ mod test {
}
#[gpui::test]
async fn test_anybrackets_trailing_space(cx: &mut gpui::TestAppContext) {
async fn test_minibrackets_trailing_space(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("(trailingˇ whitespace )")
.await;