vim: Change surrounds with Mini{Quotes,Brackets} and AnyQuotes (#51067)

Ian Chamberlain created

Part of #48241 (`dsq` still needs to be implemented, I can try to do in
another PR if+when this is merged)

AnyBrackets was already supported, and these various surrounds were
supported with other vim motions, this just brings parity for "change
surrounds".
	
Also adds MiniBrackets support since it works the same way as MiniQuotes
does.

Most of this change is just test cases for vim edits with `csq` + `csb`,
using the keybinds described in the docs:
https://zed.dev/docs/vim#any-bracket-functionality

Also did a slight refactor to reuse some constants for supported pairs,
for consistency.


Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- Fixed vim `change surrounds` for MiniQuotes, MiniBrackets, and
AnyQuotes

Change summary

crates/vim/src/object.rs    |  23 ++---
crates/vim/src/surrounds.rs | 152 +++++++++++++++++++++++++++++++++++++-
2 files changed, 158 insertions(+), 17 deletions(-)

Detailed changes

crates/vim/src/object.rs 🔗

@@ -4,6 +4,7 @@ use crate::{
     Vim,
     motion::{is_subword_end, is_subword_start, right},
     state::{Mode, Operator},
+    surrounds::{BRACKET_PAIRS, QUOTE_PAIRS, SurroundPair},
 };
 use editor::{
     Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, ToOffset,
@@ -606,7 +607,6 @@ impl Object {
                 surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
             }
             Object::AnyQuotes => {
-                let quote_types = ['\'', '"', '`'];
                 let cursor_offset = relative_to.to_offset(map, Bias::Left);
 
                 // Find innermost range directly without collecting all ranges
@@ -614,14 +614,14 @@ impl Object {
                 let mut min_size = usize::MAX;
 
                 // First pass: find innermost enclosing range
-                for quote in quote_types {
+                for &SurroundPair { open, close } in QUOTE_PAIRS {
                     if let Some(range) = surrounding_markers(
                         map,
                         relative_to,
                         around,
                         self.is_multiline(),
-                        quote,
-                        quote,
+                        open,
+                        close,
                     ) {
                         let start_offset = range.start.to_offset(map, Bias::Left);
                         let end_offset = range.end.to_offset(map, Bias::Right);
@@ -641,16 +641,16 @@ impl Object {
                 }
 
                 // Fallback: find nearest pair if not inside any quotes
-                quote_types
+                QUOTE_PAIRS
                     .iter()
-                    .flat_map(|&quote| {
+                    .flat_map(|&SurroundPair { open, close }| {
                         surrounding_markers(
                             map,
                             relative_to,
                             around,
                             self.is_multiline(),
-                            quote,
-                            quote,
+                            open,
+                            close,
                         )
                     })
                     .min_by_key(|range| {
@@ -681,14 +681,13 @@ impl Object {
                 surrounding_html_tag(map, head, range, 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() {
+                for &SurroundPair { open, close } in BRACKET_PAIRS {
                     if let Some(range) = surrounding_markers(
                         map,
                         relative_to,
@@ -715,9 +714,9 @@ impl Object {
                 }
 
                 // Fallback: find nearest bracket pair if not inside any
-                bracket_pairs
+                BRACKET_PAIRS
                     .iter()
-                    .flat_map(|&(open, close)| {
+                    .flat_map(|&SurroundPair { open, close }| {
                         surrounding_markers(
                             map,
                             relative_to,

crates/vim/src/surrounds.rs 🔗

@@ -61,13 +61,20 @@ pub const SURROUND_PAIRS: &[SurroundPair] = &[
 ];
 
 /// Bracket-only pairs for AnyBrackets matching.
-const BRACKET_PAIRS: &[SurroundPair] = &[
+pub const BRACKET_PAIRS: &[SurroundPair] = &[
     SurroundPair::new('(', ')'),
     SurroundPair::new('[', ']'),
     SurroundPair::new('{', '}'),
     SurroundPair::new('<', '>'),
 ];
 
+/// Quote-only pairs for AnyQuotes matching.
+pub const QUOTE_PAIRS: &[SurroundPair] = &[
+    SurroundPair::new('"', '"'),
+    SurroundPair::new('\'', '\''),
+    SurroundPair::new('`', '`'),
+];
+
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum SurroundsType {
     Motion(Motion),
@@ -472,10 +479,19 @@ impl Vim {
             return Some(pair.to_bracket_pair());
         }
 
-        if object != Object::AnyBrackets {
-            return None;
+        match object {
+            Object::AnyBrackets => self.any_pair(BRACKET_PAIRS, cx),
+            Object::AnyQuotes => self.any_pair(QUOTE_PAIRS, cx),
+            Object::MiniQuotes | Object::MiniBrackets => self.mini_pair(object, cx),
+            _ => None,
         }
+    }
 
+    fn any_pair(
+        &self,
+        allowed_pairs: &[SurroundPair],
+        cx: &mut Context<Self>,
+    ) -> Option<BracketPair> {
         // If we're dealing with `AnyBrackets`, which can map to multiple bracket
         // pairs, we'll need to first determine which `BracketPair` to target.
         // As such, we keep track of the smallest range size, so that in cases
@@ -508,7 +524,7 @@ impl Vim {
                 let relative_to = selection.head();
                 let cursor_offset = relative_to.to_offset(&display_map, Bias::Left);
 
-                for pair in BRACKET_PAIRS {
+                for pair in allowed_pairs {
                     if let Some(range) = surrounding_markers(
                         &display_map,
                         relative_to,
@@ -534,6 +550,25 @@ impl Vim {
 
         best_pair.map(|p| p.to_bracket_pair())
     }
+
+    fn mini_pair(&self, object: Object, cx: &mut Context<Self>) -> Option<BracketPair> {
+        self.editor
+            .update(cx, |editor, cx| {
+                let display_map = editor.display_snapshot(cx);
+                let selections = editor.selections.all_adjusted_display(&display_map);
+                // For now, only primary selection is used to select the bracket/quote pair. It might be weird
+                // if multi-select resulted in different quote kinds being replaced for different selections.
+                // any_pair uses the same logic, so this should be consistent across {Any,Mini}{Quotes,Brackets}
+                let selection = selections.first()?.clone();
+                let range = object.range(&display_map, selection, true, None)?;
+                let start_offset = range.start.to_offset(&display_map, Bias::Left);
+                let (pair_char, _) = display_map.buffer_chars_at(start_offset).next()?;
+                literal_surround_pair(pair_char)
+            })
+            .ok()
+            .flatten()
+            .map(|surround| surround.to_bracket_pair())
+    }
 }
 
 /// Convert an Object to its corresponding SurroundPair.
@@ -628,7 +663,12 @@ mod test {
     use gpui::KeyBinding;
     use indoc::indoc;
 
-    use crate::{PushAddSurrounds, object::AnyBrackets, state::Mode, test::VimTestContext};
+    use crate::{
+        PushAddSurrounds,
+        object::{AnyBrackets, AnyQuotes, MiniBrackets, MiniQuotes},
+        state::Mode,
+        test::VimTestContext,
+    };
 
     #[gpui::test]
     async fn test_add_surrounds(cx: &mut gpui::TestAppContext) {
@@ -1335,6 +1375,108 @@ mod test {
         );
     }
 
+    #[gpui::test]
+    async fn test_change_surrounds_mini_brackets(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        // Update keybindings so that using `csb` triggers Vim's `MiniBrackets` action.
+        cx.update(|_, cx| {
+            cx.bind_keys([KeyBinding::new(
+                "b",
+                MiniBrackets,
+                Some("vim_operator == a || vim_operator == i || vim_operator == cs"),
+            )]);
+        });
+
+        cx.set_state(indoc! {"{braˇcketed}"}, Mode::Normal);
+        cx.simulate_keystrokes("c s b [");
+        cx.assert_state(indoc! {"ˇ[ bracketed ]"}, Mode::Normal);
+
+        cx.set_state(indoc! {"[braˇcketed]"}, Mode::Normal);
+        cx.simulate_keystrokes("c s b {");
+        cx.assert_state(indoc! {"ˇ{ bracketed }"}, Mode::Normal);
+
+        cx.set_state(indoc! {"<braˇcketed>"}, Mode::Normal);
+        cx.simulate_keystrokes("c s b [");
+        cx.assert_state(indoc! {"ˇ[ bracketed ]"}, Mode::Normal);
+
+        cx.set_state(indoc! {"(braˇcketed)"}, Mode::Normal);
+        cx.simulate_keystrokes("c s b [");
+        cx.assert_state(indoc! {"ˇ[ bracketed ]"}, Mode::Normal);
+
+        cx.set_state(indoc! {"(<ˇZed>)"}, Mode::Normal);
+        cx.simulate_keystrokes("c s b )");
+        cx.assert_state(indoc! {"(ˇ(Zed))"}, Mode::Normal);
+
+        cx.set_state(
+            indoc! {"
+                (<ˇZed>)
+                (<ˇDeltaDB>)
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("c s b (");
+        cx.assert_state(
+            indoc! {"
+                (ˇ( Zed ))
+                (ˇ( DeltaDB ))
+            "},
+            Mode::Normal,
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_surrounds_any_quotes(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        // Update keybindings so that using `csq` triggers Vim's `AnyQuotes` action.
+        cx.update(|_, cx| {
+            cx.bind_keys([KeyBinding::new(
+                "q",
+                AnyQuotes,
+                Some("vim_operator == a || vim_operator == i || vim_operator == cs"),
+            )]);
+        });
+
+        cx.set_state(indoc! {"'  ˇstr  '"}, Mode::Normal);
+        cx.simulate_keystrokes("c s q \"");
+        cx.assert_state(indoc! {"ˇ\"  str  \""}, Mode::Normal);
+
+        cx.set_state(indoc! {"`  ˇstr  `"}, Mode::Normal);
+        cx.simulate_keystrokes("c s q '");
+        cx.assert_state(indoc! {"ˇ'  str  '"}, Mode::Normal);
+
+        cx.set_state(indoc! {"\"  ˇstr  \""}, Mode::Normal);
+        cx.simulate_keystrokes("c s q `");
+        cx.assert_state(indoc! {"ˇ`  str  `"}, Mode::Normal);
+    }
+
+    #[gpui::test]
+    async fn test_change_surrounds_mini_quotes(cx: &mut gpui::TestAppContext) {
+        // NOTE: needs TypeScript test cx to recognize single/backquotes
+        let mut cx = VimTestContext::new_typescript(cx).await;
+
+        // Update keybindings so that using `csq` triggers Vim's `MiniQuotes` action.
+        cx.update(|_, cx| {
+            cx.bind_keys([KeyBinding::new(
+                "q",
+                MiniQuotes,
+                Some("vim_operator == a || vim_operator == i || vim_operator == cs"),
+            )]);
+        });
+        cx.set_state(indoc! {"'  ˇstr  '"}, Mode::Normal);
+        cx.simulate_keystrokes("c s q \"");
+        cx.assert_state(indoc! {"ˇ\"  str  \""}, Mode::Normal);
+
+        cx.set_state(indoc! {"`  ˇstr  `"}, Mode::Normal);
+        cx.simulate_keystrokes("c s q '");
+        cx.assert_state(indoc! {"ˇ'  str  '"}, Mode::Normal);
+
+        cx.set_state(indoc! {"\"  ˇstr  \""}, Mode::Normal);
+        cx.simulate_keystrokes("c s q `");
+        cx.assert_state(indoc! {"ˇ`  str  `"}, Mode::Normal);
+    }
+
     // The following test cases all follow tpope/vim-surround's behaviour
     // and are more focused on how whitespace is handled.
     #[gpui::test]