vim: Add indent text object (#21121)

vultix created

Added support for the popular vim
[indent-text-object](https://github.com/michaeljsmith/vim-indent-object).
This is especially useful in indentation-sensitive languages like
python.

Release Notes:

- vim: Added `vii`, `vai` and `vaI` for selecting
[indent-text-object](https://github.com/michaeljsmith/vim-indent-object)s.

Change summary

assets/keymaps/vim.json  |   4 
crates/vim/src/object.rs | 169 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 168 insertions(+), 5 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -381,7 +381,9 @@
       "shift-b": "vim::CurlyBrackets",
       "<": "vim::AngleBrackets",
       ">": "vim::AngleBrackets",
-      "a": "vim::Argument"
+      "a": "vim::Argument",
+      "i": "vim::IndentObj",
+      "shift-i": ["vim::IndentObj", { "includeBelow": true }]
     }
   },
   {

crates/vim/src/object.rs 🔗

@@ -28,6 +28,7 @@ pub enum Object {
     CurlyBrackets,
     AngleBrackets,
     Argument,
+    IndentObj { include_below: bool },
     Tag,
 }
 
@@ -37,8 +38,14 @@ struct Word {
     #[serde(default)]
     ignore_punctuation: bool,
 }
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct IndentObj {
+    #[serde(default)]
+    include_below: bool,
+}
 
-impl_actions!(vim, [Word]);
+impl_actions!(vim, [Word, IndentObj]);
 
 actions!(
     vim,
@@ -100,6 +107,13 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
     Vim::action(editor, cx, |vim, _: &Argument, cx| {
         vim.object(Object::Argument, cx)
     });
+    Vim::action(
+        editor,
+        cx,
+        |vim, &IndentObj { include_below }: &IndentObj, cx| {
+            vim.object(Object::IndentObj { include_below }, cx)
+        },
+    );
 }
 
 impl Vim {
@@ -129,13 +143,18 @@ impl Object {
             | Object::AngleBrackets
             | Object::CurlyBrackets
             | Object::SquareBrackets
-            | Object::Argument => true,
+            | Object::Argument
+            | Object::IndentObj { .. } => true,
         }
     }
 
     pub fn always_expands_both_ways(self) -> bool {
         match self {
-            Object::Word { .. } | Object::Sentence | Object::Paragraph | Object::Argument => false,
+            Object::Word { .. }
+            | Object::Sentence
+            | Object::Paragraph
+            | Object::Argument
+            | Object::IndentObj { .. } => false,
             Object::Quotes
             | Object::BackQuotes
             | Object::DoubleQuotes
@@ -167,7 +186,8 @@ impl Object {
             | Object::AngleBrackets
             | Object::VerticalBars
             | Object::Tag
-            | Object::Argument => Mode::Visual,
+            | Object::Argument
+            | Object::IndentObj { .. } => Mode::Visual,
             Object::Paragraph => Mode::VisualLine,
         }
     }
@@ -219,6 +239,7 @@ impl Object {
                 surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>')
             }
             Object::Argument => argument(map, relative_to, around),
+            Object::IndentObj { include_below } => indent(map, relative_to, around, include_below),
         }
     }
 
@@ -569,6 +590,58 @@ fn argument(
     }
 }
 
+fn indent(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    around: bool,
+    include_below: bool,
+) -> Option<Range<DisplayPoint>> {
+    let point = relative_to.to_point(map);
+    let row = point.row;
+
+    let desired_indent = map.line_indent_for_buffer_row(MultiBufferRow(row));
+
+    // Loop backwards until we find a non-blank line with less indent
+    let mut start_row = row;
+    for prev_row in (0..row).rev() {
+        let indent = map.line_indent_for_buffer_row(MultiBufferRow(prev_row));
+        if indent.is_line_empty() {
+            continue;
+        }
+        if indent.spaces < desired_indent.spaces || indent.tabs < desired_indent.tabs {
+            if around {
+                // When around is true, include the first line with less indent
+                start_row = prev_row;
+            }
+            break;
+        }
+        start_row = prev_row;
+    }
+
+    // Loop forwards until we find a non-blank line with less indent
+    let mut end_row = row;
+    let max_rows = map.max_buffer_row().0;
+    for next_row in (row + 1)..=max_rows {
+        let indent = map.line_indent_for_buffer_row(MultiBufferRow(next_row));
+        if indent.is_line_empty() {
+            continue;
+        }
+        if indent.spaces < desired_indent.spaces || indent.tabs < desired_indent.tabs {
+            if around && include_below {
+                // When around is true and including below, include this line
+                end_row = next_row;
+            }
+            break;
+        }
+        end_row = next_row;
+    }
+
+    let end_len = map.buffer_snapshot.line_len(MultiBufferRow(end_row));
+    let start = map.point_to_display_point(Point::new(start_row, 0), Bias::Right);
+    let end = map.point_to_display_point(Point::new(end_row, end_len), Bias::Left);
+    Some(start..end)
+}
+
 fn sentence(
     map: &DisplaySnapshot,
     relative_to: DisplayPoint,
@@ -1458,6 +1531,94 @@ mod test {
         cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual);
     }
 
+    #[gpui::test]
+    async fn test_indent_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        // Base use case
+        cx.set_state(
+            indoc! {"
+                fn boop() {
+                    // Comment
+                    baz();ˇ
+
+                    loop {
+                        bar(1);
+                        bar(2);
+                    }
+
+                    result
+                }
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v i i");
+        cx.assert_state(
+            indoc! {"
+                fn boop() {
+                «    // Comment
+                    baz();
+
+                    loop {
+                        bar(1);
+                        bar(2);
+                    }
+
+                    resultˇ»
+                }
+            "},
+            Mode::Visual,
+        );
+
+        // Around indent (include line above)
+        cx.set_state(
+            indoc! {"
+                const ABOVE: str = true;
+                fn boop() {
+
+                    hello();
+                    worˇld()
+                }
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a i");
+        cx.assert_state(
+            indoc! {"
+                const ABOVE: str = true;
+                «fn boop() {
+
+                    hello();
+                    world()ˇ»
+                }
+            "},
+            Mode::Visual,
+        );
+
+        // Around indent (include line above & below)
+        cx.set_state(
+            indoc! {"
+                const ABOVE: str = true;
+                fn boop() {
+                    hellˇo();
+                    world()
+
+                }
+                const BELOW: str = true;
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("c a shift-i");
+        cx.assert_state(
+            indoc! {"
+                const ABOVE: str = true;
+                ˇ
+                const BELOW: str = true;
+            "},
+            Mode::Insert,
+        );
+    }
+
     #[gpui::test]
     async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;