vim: Fix change surround with any brackets text object (#37386)

Dino created

This commit fixes an issue with how the `AnyBrackets` object was handled
with change surrounds (`cs`). With the keymap below, if one was to use
`csb{` with the text `(bracketed)` and the cursor inside the
parentheses, the text would not change.

```json
{
  "context": "vim_operator == a || vim_operator == i || vim_operator == cs",
  "bindings": {
    "b": "vim::AnyBrackets"
  }
}
```

Unfortunately there was no implementation for finding a corresponding
`BracketPair` for the `AnyBrackets` object, meaning that, when using
`cs` (change surrounds) the code would simply do nothing.

This commit updates this logic so as to try and find the nearest
surrounding bracket (parentheses, curly brackets, square brackets or
angle brackets), ensuring that `cs` also works with `AnyBrackets`.

Closes #24439

Release Notes:

- Fixed handling of `AnyBrackets` in vim's change surrounds (`cs`)

Change summary

crates/vim/src/object.rs    |   2 
crates/vim/src/surrounds.rs | 255 ++++++++++++++++++++++++++++----------
2 files changed, 190 insertions(+), 67 deletions(-)

Detailed changes

crates/vim/src/object.rs 🔗

@@ -1510,7 +1510,7 @@ pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> D
     map.max_point()
 }
 
-fn surrounding_markers(
+pub fn surrounding_markers(
     map: &DisplaySnapshot,
     relative_to: DisplayPoint,
     around: bool,

crates/vim/src/surrounds.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     Vim,
     motion::{self, Motion},
-    object::Object,
+    object::{Object, surrounding_markers},
     state::Mode,
 };
 use editor::{Bias, movement};
@@ -224,7 +224,7 @@ impl Vim {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if let Some(will_replace_pair) = object_to_bracket_pair(target) {
+        if let Some(will_replace_pair) = self.object_to_bracket_pair(target, cx) {
             self.stop_recording(cx);
             self.update_editor(cx, |_, editor, cx| {
                 editor.transact(window, cx, |editor, window, cx| {
@@ -358,7 +358,7 @@ impl Vim {
         cx: &mut Context<Self>,
     ) -> bool {
         let mut valid = false;
-        if let Some(pair) = object_to_bracket_pair(object) {
+        if let Some(pair) = self.object_to_bracket_pair(object, cx) {
             self.update_editor(cx, |_, editor, cx| {
                 editor.transact(window, cx, |editor, window, cx| {
                     editor.set_clip_at_line_ends(false, cx);
@@ -405,6 +405,140 @@ impl Vim {
         }
         valid
     }
+
+    fn object_to_bracket_pair(
+        &self,
+        object: Object,
+        cx: &mut Context<Self>,
+    ) -> Option<BracketPair> {
+        match object {
+            Object::Quotes => Some(BracketPair {
+                start: "'".to_string(),
+                end: "'".to_string(),
+                close: true,
+                surround: true,
+                newline: false,
+            }),
+            Object::BackQuotes => Some(BracketPair {
+                start: "`".to_string(),
+                end: "`".to_string(),
+                close: true,
+                surround: true,
+                newline: false,
+            }),
+            Object::DoubleQuotes => Some(BracketPair {
+                start: "\"".to_string(),
+                end: "\"".to_string(),
+                close: true,
+                surround: true,
+                newline: false,
+            }),
+            Object::VerticalBars => Some(BracketPair {
+                start: "|".to_string(),
+                end: "|".to_string(),
+                close: true,
+                surround: true,
+                newline: false,
+            }),
+            Object::Parentheses => Some(BracketPair {
+                start: "(".to_string(),
+                end: ")".to_string(),
+                close: true,
+                surround: true,
+                newline: false,
+            }),
+            Object::SquareBrackets => Some(BracketPair {
+                start: "[".to_string(),
+                end: "]".to_string(),
+                close: true,
+                surround: true,
+                newline: false,
+            }),
+            Object::CurlyBrackets => Some(BracketPair {
+                start: "{".to_string(),
+                end: "}".to_string(),
+                close: true,
+                surround: true,
+                newline: false,
+            }),
+            Object::AngleBrackets => Some(BracketPair {
+                start: "<".to_string(),
+                end: ">".to_string(),
+                close: true,
+                surround: true,
+                newline: false,
+            }),
+            Object::AnyBrackets => {
+                // 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 like `({ name: "John" })` if the cursor is
+                // inside the curly brackets, we target the curly brackets
+                // instead of the parentheses.
+                let mut bracket_pair = None;
+                let mut min_range_size = usize::MAX;
+
+                let _ = self.editor.update(cx, |editor, cx| {
+                    let (display_map, selections) = editor.selections.all_adjusted_display(cx);
+                    // Even if there's multiple cursors, we'll simply rely on
+                    // the first one to understand what bracket pair to map to.
+                    // I believe we could, if worth it, go one step above and
+                    // have a `BracketPair` per selection, so that `AnyBracket`
+                    // could work in situations where the transformation below
+                    // could be done.
+                    //
+                    // ```
+                    // (< name:ˇ'Zed' >)
+                    // <[ name:ˇ'DeltaDB' ]>
+                    // ```
+                    //
+                    // After using `csb{`:
+                    //
+                    // ```
+                    // (ˇ{ name:'Zed' })
+                    // <ˇ{ name:'DeltaDB' }>
+                    // ```
+                    if let Some(selection) = selections.first() {
+                        let relative_to = selection.head();
+                        let bracket_pairs = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')];
+                        let cursor_offset = relative_to.to_offset(&display_map, Bias::Left);
+
+                        for &(open, close) in bracket_pairs.iter() {
+                            if let Some(range) = surrounding_markers(
+                                &display_map,
+                                relative_to,
+                                true,
+                                false,
+                                open,
+                                close,
+                            ) {
+                                let start_offset = range.start.to_offset(&display_map, Bias::Left);
+                                let end_offset = range.end.to_offset(&display_map, Bias::Right);
+
+                                if cursor_offset >= start_offset && cursor_offset <= end_offset {
+                                    let size = end_offset - start_offset;
+                                    if size < min_range_size {
+                                        min_range_size = size;
+                                        bracket_pair = Some(BracketPair {
+                                            start: open.to_string(),
+                                            end: close.to_string(),
+                                            close: true,
+                                            surround: true,
+                                            newline: false,
+                                        })
+                                    }
+                                }
+                            }
+                        }
+                    }
+                });
+
+                bracket_pair
+            }
+            _ => None,
+        }
+    }
 }
 
 fn find_surround_pair<'a>(pairs: &'a [BracketPair], ch: &str) -> Option<&'a BracketPair> {
@@ -505,74 +639,12 @@ fn pair_to_object(pair: &BracketPair) -> Option<Object> {
     }
 }
 
-fn object_to_bracket_pair(object: Object) -> Option<BracketPair> {
-    match object {
-        Object::Quotes => Some(BracketPair {
-            start: "'".to_string(),
-            end: "'".to_string(),
-            close: true,
-            surround: true,
-            newline: false,
-        }),
-        Object::BackQuotes => Some(BracketPair {
-            start: "`".to_string(),
-            end: "`".to_string(),
-            close: true,
-            surround: true,
-            newline: false,
-        }),
-        Object::DoubleQuotes => Some(BracketPair {
-            start: "\"".to_string(),
-            end: "\"".to_string(),
-            close: true,
-            surround: true,
-            newline: false,
-        }),
-        Object::VerticalBars => Some(BracketPair {
-            start: "|".to_string(),
-            end: "|".to_string(),
-            close: true,
-            surround: true,
-            newline: false,
-        }),
-        Object::Parentheses => Some(BracketPair {
-            start: "(".to_string(),
-            end: ")".to_string(),
-            close: true,
-            surround: true,
-            newline: false,
-        }),
-        Object::SquareBrackets => Some(BracketPair {
-            start: "[".to_string(),
-            end: "]".to_string(),
-            close: true,
-            surround: true,
-            newline: false,
-        }),
-        Object::CurlyBrackets => Some(BracketPair {
-            start: "{".to_string(),
-            end: "}".to_string(),
-            close: true,
-            surround: true,
-            newline: false,
-        }),
-        Object::AngleBrackets => Some(BracketPair {
-            start: "<".to_string(),
-            end: ">".to_string(),
-            close: true,
-            surround: true,
-            newline: false,
-        }),
-        _ => None,
-    }
-}
-
 #[cfg(test)]
 mod test {
     use gpui::KeyBinding;
     use indoc::indoc;
 
-    use crate::{PushAddSurrounds, state::Mode, test::VimTestContext};
+    use crate::{PushAddSurrounds, object::AnyBrackets, state::Mode, test::VimTestContext};
 
     #[gpui::test]
     async fn test_add_surrounds(cx: &mut gpui::TestAppContext) {
@@ -1171,6 +1243,57 @@ mod test {
         );
     }
 
+    #[gpui::test]
+    async fn test_change_surrounds_any_brackets(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        // Update keybindings so that using `csb` triggers Vim's `AnyBrackets`
+        // action.
+        cx.update(|_, cx| {
+            cx.bind_keys([KeyBinding::new(
+                "b",
+                AnyBrackets,
+                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! {"(< name: ˇ'Zed' >)"}, Mode::Normal);
+        cx.simulate_keystrokes("c s b {");
+        cx.assert_state(indoc! {"(ˇ{ name: 'Zed' })"}, Mode::Normal);
+
+        cx.set_state(
+            indoc! {"
+            (< name: ˇ'Zed' >)
+            (< nˇame: 'DeltaDB' >)
+        "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("c s b {");
+        cx.set_state(
+            indoc! {"
+            (ˇ{ name: 'Zed' })
+            (ˇ{ name: 'DeltaDB' })
+        "},
+            Mode::Normal,
+        );
+    }
+
     #[gpui::test]
     async fn test_surrounds(cx: &mut gpui::TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;