diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 04a911d2df..91dde540ce 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -516,12 +516,14 @@ "'": "vim::Quotes", "`": "vim::BackQuotes", "\"": "vim::DoubleQuotes", - "q": "vim::AnyQuotes", + // "q": "vim::AnyQuotes", + "q": "vim::MiniQuotes", "|": "vim::VerticalBars", "(": "vim::Parentheses", ")": "vim::Parentheses", "b": "vim::Parentheses", // "b": "vim::AnyBrackets", + // "b": "vim::MiniBrackets", "[": "vim::SquareBrackets", "]": "vim::SquareBrackets", "r": "vim::SquareBrackets", diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index c54a3daa45..7ce3dbe4c6 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -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> { - 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> { - 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::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(|"e| { + 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; diff --git a/crates/vim/test_data/test_anybrackets_trailing_space.json b/crates/vim/test_data/test_minibrackets_trailing_space.json similarity index 100% rename from crates/vim/test_data/test_anybrackets_trailing_space.json rename to crates/vim/test_data/test_minibrackets_trailing_space.json diff --git a/docs/src/vim.md b/docs/src/vim.md index 28deee3b0f..f5c2367f7c 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -167,6 +167,79 @@ Zed's vim mode includes some features that are usually provided by very popular - You can use `gR` to do [ReplaceWithRegister](https://github.com/vim-scripts/ReplaceWithRegister). - You can use `cx` for [vim-exchange](https://github.com/tommcdo/vim-exchange) functionality. Note that it does not have a default binding in visual mode, but you can add one to your keymap (refer to the [optional key bindings](#optional-key-bindings) section). - You can navigate to indent depths relative to your cursor with the [indent wise](https://github.com/jeetsukumaran/vim-indentwise) plugin `[-`, `]-`, `[+`, `]+`, `[=`, `]=`. +- You can select quoted text with AnyQuotes and bracketed text with AnyBrackets text objects. Zed also provides MiniQuotes and MiniBrackets which offer alternative selection behavior based on the [mini.ai](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-ai.md) Neovim plugin. See the [Quote and Bracket text objects](#quote-and-bracket-text-objects) section below for details. +- You can configure AnyQuotes, AnyBrackets, MiniQuotes, and MiniBrackets text objects for selecting quoted and bracketed text using different selection strategies. See the [Any Bracket Functionality](#any-bracket-functionality) section below for details. + +### Any Bracket Functionality + +Zed offers two different strategies for selecting text surrounded by any quote, or any bracket. These text objects are **not enabled by default** and must be configured in your keymap to be used. + +#### Included Characters + +Each text object type works with specific characters: + +| Text Object | Characters | +| ------------------------ | -------------------------------------------------------------------------------------- | +| AnyQuotes/MiniQuotes | Single quote (`'`), Double quote (`"`), Backtick (`` ` ``) | +| AnyBrackets/MiniBrackets | Parentheses (`()`), Square brackets (`[]`), Curly braces (`{}`), Angle brackets (`<>`) | + +Both "Any" and "Mini" variants work with the same character sets, but differ in their selection strategy. + +#### AnyQuotes and AnyBrackets (Traditional Vim behavior) + +These text objects implement traditional Vim behavior: + +- **Selection priority**: Finds the innermost (closest) quotes or brackets first +- **Fallback mechanism**: If none are found, falls back to the current line +- **Character-based matching**: Focuses solely on open and close characters without considering syntax +- **Vanilla Vim similarity**: AnyBrackets matches the behavior of commands like `ci<`, `ci(`, etc., in vanilla Vim, including potential edge cases (like considering `>` in `=>` as a closing delimiter) + +#### MiniQuotes and MiniBrackets (mini.ai behavior) + +These text objects implement the behavior of the [mini.ai](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-ai.md) Neovim plugin: + +- **Selection priority**: Searches the current line first before expanding outward +- **Tree-sitter integration**: Uses Tree-sitter queries for more context-aware selections +- **Syntax-aware matching**: Can distinguish between actual brackets and similar characters in other contexts (like `>` in `=>`) + +#### Choosing Between Approaches + +- Use **AnyQuotes/AnyBrackets** if you: + + - Prefer traditional Vim behavior + - Want consistent character-based selection prioritizing innermost delimiters + - Need behavior that closely matches vanilla Vim's text objects + +- Use **MiniQuotes/MiniBrackets** if you: + - Prefer the mini.ai plugin behavior + - Want more context-aware selections using Tree-sitter + - Prefer current-line priority when searching + +#### Example Configuration + +To use these text objects, you need to add bindings to your keymap. Here's an example configuration that makes them available when using text object operators (`i` and `a`) or change-surrounds (`cs`): + +```json +{ + "context": "vim_operator == a || vim_operator == i || vim_operator == cs", + "bindings": { + // Traditional Vim behavior + "q": "vim::AnyQuotes", + "b": "vim::AnyBrackets", + + // mini.ai plugin behavior + "Q": "vim::MiniQuotes", + "B": "vim::MiniBrackets" + } +} +``` + +With this configuration, you can use commands like: + +- `cib` - Change inside brackets using AnyBrackets behavior +- `cim` - Change inside brackets using MiniBrackets behavior +- `ciq` - Change inside quotes using AnyQuotes behavior +- `ciM` - Change inside quotes using MiniQuotes behavior ## Command palette