vim: Add AnyQuotes support for unified quote handling similar to mini.ai nvim (#22263)

Osvaldo created

### Edit 1:
I tested it locally and it works!

### IMPORTANT: 
**Feedback and suggestions for improvement are greatly appreciated!**

This commit introduces a new AnyQuotes text object to handle text
surrounded by single quotes ('), double quotes ("), or back quotes (`)
seamlessly. The following changes are included:

- Added AnyQuotes to the Object enum to represent the new feature.
- Registered AnyQuotes as an action in the actions! macro and register
function to ensure proper integration with Vim actions like ci, ca, di,
and da.
- Extended Object::range to check for surrounding single, double, or
back quotes sequentially.
- Updated methods like is_multiline and always_expands_both_ways to
ensure consistent behavior with other text objects.
- Added support in surrounding_markers to evaluate any of the quote
types when AnyQuotes is invoked.
- This enhancement provides users with a flexible and unified way to
interact with text objects enclosed by different types of quotes.

Release Notes:

- vim: Add `aq`/`iq` "any quote" text objects that are the smallest of
`a"`, `a'` or <code>a`</code>

Change summary

assets/keymaps/vim.json  |   1 
crates/vim/src/object.rs | 151 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 152 insertions(+)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -397,6 +397,7 @@
       "'": "vim::Quotes",
       "`": "vim::BackQuotes",
       "\"": "vim::DoubleQuotes",
+      "q": "vim::AnyQuotes",
       "|": "vim::VerticalBars",
       "(": "vim::Parentheses",
       ")": "vim::Parentheses",

crates/vim/src/object.rs 🔗

@@ -25,6 +25,7 @@ pub enum Object {
     Paragraph,
     Quotes,
     BackQuotes,
+    AnyQuotes,
     DoubleQuotes,
     VerticalBars,
     Parentheses,
@@ -61,6 +62,7 @@ actions!(
         Paragraph,
         Quotes,
         BackQuotes,
+        AnyQuotes,
         DoubleQuotes,
         VerticalBars,
         Parentheses,
@@ -96,6 +98,9 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
     Vim::action(editor, cx, |vim, _: &BackQuotes, cx| {
         vim.object(Object::BackQuotes, cx)
     });
+    Vim::action(editor, cx, |vim, _: &AnyQuotes, cx| {
+        vim.object(Object::AnyQuotes, cx)
+    });
     Vim::action(editor, cx, |vim, _: &DoubleQuotes, cx| {
         vim.object(Object::DoubleQuotes, cx)
     });
@@ -156,6 +161,7 @@ impl Object {
             Object::Word { .. }
             | Object::Quotes
             | Object::BackQuotes
+            | Object::AnyQuotes
             | Object::VerticalBars
             | Object::DoubleQuotes => false,
             Object::Sentence
@@ -182,6 +188,7 @@ impl Object {
             | Object::IndentObj { .. } => false,
             Object::Quotes
             | Object::BackQuotes
+            | Object::AnyQuotes
             | Object::DoubleQuotes
             | Object::VerticalBars
             | Object::Parentheses
@@ -200,6 +207,7 @@ impl Object {
             Object::Word { .. }
             | Object::Sentence
             | Object::Quotes
+            | Object::AnyQuotes
             | Object::BackQuotes
             | Object::DoubleQuotes => {
                 if current_mode == Mode::VisualBlock {
@@ -251,6 +259,35 @@ impl Object {
             Object::BackQuotes => {
                 surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
             }
+            Object::AnyQuotes => {
+                let quote_types = ['\'', '"', '`']; // Types of quotes to handle
+                let relative_offset = relative_to.to_offset(map, Bias::Left) as isize;
+
+                // Find the closest matching quote range
+                quote_types
+                    .iter()
+                    .flat_map(|&quote| {
+                        // Get ranges for each quote type
+                        surrounding_markers(
+                            map,
+                            relative_to,
+                            around,
+                            self.is_multiline(),
+                            quote,
+                            quote,
+                        )
+                    })
+                    .min_by_key(|range| {
+                        // Calculate proximity of ranges to the cursor
+                        let start_distance = (relative_offset
+                            - range.start.to_offset(map, Bias::Left) as isize)
+                            .abs();
+                        let end_distance = (relative_offset
+                            - range.end.to_offset(map, Bias::Right) as isize)
+                            .abs();
+                        start_distance + end_distance
+                    })
+            }
             Object::DoubleQuotes => {
                 surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"')
             }
@@ -1751,6 +1788,120 @@ mod test {
         }
     }
 
+    #[gpui::test]
+    async fn test_anyquotes_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        const TEST_CASES: &[(&str, &str, &str, Mode)] = &[
+            // Single 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,
+            ),
+            // 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_tags(cx: &mut gpui::TestAppContext) {
         let mut cx = VimTestContext::new_html(cx).await;