vim: Add indent-wise motions (#28044)

5brian created

Taken from:
https://github.com/jeetsukumaran/vim-indentwise?tab=readme-ov-file#movements-by-relative-indent-depth



> [- : Move to previous line of lesser indent than the current line.
> [+ : Move to previous line of greater indent than the current line.
> [= : Move to previous line of same indent as the current line that is
separated from the current line by lines of different indents.
> ]- : Move to next line of lesser indent than the current line.
> ]+ : Move to next line of greater indent than the current line.
> ]= : Move to next line of same indent as the current line that is
separated from the current line by lines of different indents.



Release Notes:

- vim: Added indent-wise motions `] -/+/=`

Change summary

assets/keymaps/vim.json  |   6 +
crates/vim/src/motion.rs | 222 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 228 insertions(+)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -44,6 +44,12 @@
       "[ /": "vim::PreviousComment",
       "] *": "vim::NextComment",
       "] /": "vim::NextComment",
+      "[ -": "vim::PreviousLesserIndent",
+      "[ +": "vim::PreviousGreaterIndent",
+      "[ =": "vim::PreviousSameIndent",
+      "] -": "vim::NextLesserIndent",
+      "] +": "vim::NextGreaterIndent",
+      "] =": "vim::NextSameIndent",
       // Word motions
       "w": "vim::NextWordStart",
       "e": "vim::NextWordEnd",

crates/vim/src/motion.rs 🔗

@@ -147,6 +147,12 @@ pub enum Motion {
     PreviousMethodEnd,
     NextComment,
     PreviousComment,
+    PreviousLesserIndent,
+    PreviousGreaterIndent,
+    PreviousSameIndent,
+    NextLesserIndent,
+    NextGreaterIndent,
+    NextSameIndent,
 
     // we don't have a good way to run a search synchronously, so
     // we handle search motions by running the search async and then
@@ -161,6 +167,13 @@ pub enum Motion {
     },
 }
 
+#[derive(Clone, Copy)]
+enum IndentType {
+    Lesser,
+    Greater,
+    Same,
+}
+
 #[derive(Clone, Deserialize, JsonSchema, PartialEq)]
 #[serde(deny_unknown_fields)]
 struct NextWordStart {
@@ -323,6 +336,12 @@ actions!(
         PreviousMethodEnd,
         NextComment,
         PreviousComment,
+        PreviousLesserIndent,
+        PreviousGreaterIndent,
+        PreviousSameIndent,
+        NextLesserIndent,
+        NextGreaterIndent,
+        NextSameIndent,
     ]
 );
 
@@ -572,6 +591,24 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, |vim, &PreviousComment, window, cx| {
         vim.motion(Motion::PreviousComment, window, cx)
     });
+    Vim::action(editor, cx, |vim, &PreviousLesserIndent, window, cx| {
+        vim.motion(Motion::PreviousLesserIndent, window, cx)
+    });
+    Vim::action(editor, cx, |vim, &PreviousGreaterIndent, window, cx| {
+        vim.motion(Motion::PreviousGreaterIndent, window, cx)
+    });
+    Vim::action(editor, cx, |vim, &PreviousSameIndent, window, cx| {
+        vim.motion(Motion::PreviousSameIndent, window, cx)
+    });
+    Vim::action(editor, cx, |vim, &NextLesserIndent, window, cx| {
+        vim.motion(Motion::NextLesserIndent, window, cx)
+    });
+    Vim::action(editor, cx, |vim, &NextGreaterIndent, window, cx| {
+        vim.motion(Motion::NextGreaterIndent, window, cx)
+    });
+    Vim::action(editor, cx, |vim, &NextSameIndent, window, cx| {
+        vim.motion(Motion::NextSameIndent, window, cx)
+    });
 }
 
 impl Vim {
@@ -666,6 +703,12 @@ impl Motion {
             | PreviousMethodEnd
             | NextComment
             | PreviousComment
+            | PreviousLesserIndent
+            | PreviousGreaterIndent
+            | PreviousSameIndent
+            | NextLesserIndent
+            | NextGreaterIndent
+            | NextSameIndent
             | GoToPercentage
             | Jump { line: true, .. } => MotionKind::Linewise,
             EndOfLine { .. }
@@ -765,6 +808,12 @@ impl Motion {
             | PreviousMethodEnd
             | NextComment
             | PreviousComment
+            | PreviousLesserIndent
+            | PreviousGreaterIndent
+            | PreviousSameIndent
+            | NextLesserIndent
+            | NextGreaterIndent
+            | NextSameIndent
             | Jump { .. } => false,
         }
     }
@@ -1109,6 +1158,30 @@ impl Motion {
                 comment_motion(map, point, times, Direction::Prev),
                 SelectionGoal::None,
             ),
+            PreviousLesserIndent => (
+                indent_motion(map, point, times, Direction::Prev, IndentType::Lesser),
+                SelectionGoal::None,
+            ),
+            PreviousGreaterIndent => (
+                indent_motion(map, point, times, Direction::Prev, IndentType::Greater),
+                SelectionGoal::None,
+            ),
+            PreviousSameIndent => (
+                indent_motion(map, point, times, Direction::Prev, IndentType::Same),
+                SelectionGoal::None,
+            ),
+            NextLesserIndent => (
+                indent_motion(map, point, times, Direction::Next, IndentType::Lesser),
+                SelectionGoal::None,
+            ),
+            NextGreaterIndent => (
+                indent_motion(map, point, times, Direction::Next, IndentType::Greater),
+                SelectionGoal::None,
+            ),
+            NextSameIndent => (
+                indent_motion(map, point, times, Direction::Next, IndentType::Same),
+                SelectionGoal::None,
+            ),
         };
 
         (new_point != point || infallible).then_some((new_point, goal))
@@ -2725,6 +2798,67 @@ fn section_motion(
     display_point
 }
 
+fn matches_indent_type(
+    target_indent: &text::LineIndent,
+    current_indent: &text::LineIndent,
+    indent_type: IndentType,
+) -> bool {
+    match indent_type {
+        IndentType::Lesser => {
+            target_indent.spaces < current_indent.spaces || target_indent.tabs < current_indent.tabs
+        }
+        IndentType::Greater => {
+            target_indent.spaces > current_indent.spaces || target_indent.tabs > current_indent.tabs
+        }
+        IndentType::Same => {
+            target_indent.spaces == current_indent.spaces
+                && target_indent.tabs == current_indent.tabs
+        }
+    }
+}
+
+fn indent_motion(
+    map: &DisplaySnapshot,
+    mut display_point: DisplayPoint,
+    times: usize,
+    direction: Direction,
+    indent_type: IndentType,
+) -> DisplayPoint {
+    let buffer_point = map.display_point_to_point(display_point, Bias::Left);
+    let current_row = MultiBufferRow(buffer_point.row);
+    let current_indent = map.line_indent_for_buffer_row(current_row);
+    if current_indent.is_line_empty() {
+        return display_point;
+    }
+    let max_row = map.max_point().to_point(map).row;
+
+    for _ in 0..times {
+        let current_buffer_row = map.display_point_to_point(display_point, Bias::Left).row;
+
+        let target_row = match direction {
+            Direction::Next => (current_buffer_row + 1..=max_row).find(|&row| {
+                let indent = map.line_indent_for_buffer_row(MultiBufferRow(row));
+                !indent.is_line_empty()
+                    && matches_indent_type(&indent, &current_indent, indent_type)
+            }),
+            Direction::Prev => (0..current_buffer_row).rev().find(|&row| {
+                let indent = map.line_indent_for_buffer_row(MultiBufferRow(row));
+                !indent.is_line_empty()
+                    && matches_indent_type(&indent, &current_indent, indent_type)
+            }),
+        }
+        .unwrap_or(current_buffer_row);
+
+        let new_point = map.point_to_display_point(Point::new(target_row, 0), Bias::Right);
+        let new_point = first_non_whitespace(map, false, new_point);
+        if new_point == display_point {
+            break;
+        }
+        display_point = new_point;
+    }
+    display_point
+}
+
 #[cfg(test)]
 mod test {
 
@@ -3594,4 +3728,92 @@ mod test {
                         πππˇπ
                         πanotherline"});
     }
+
+    #[gpui::test]
+    async fn test_go_to_indent(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.set_state(
+            indoc! {
+                "func empty(a string) bool {
+                     ˇif a == \"\" {
+                         return true
+                     }
+                     return false
+                }"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("[ -");
+        cx.assert_state(
+            indoc! {
+                "ˇfunc empty(a string) bool {
+                     if a == \"\" {
+                         return true
+                     }
+                     return false
+                }"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("] =");
+        cx.assert_state(
+            indoc! {
+                "func empty(a string) bool {
+                     if a == \"\" {
+                         return true
+                     }
+                     return false
+                ˇ}"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("[ +");
+        cx.assert_state(
+            indoc! {
+                "func empty(a string) bool {
+                     if a == \"\" {
+                         return true
+                     }
+                     ˇreturn false
+                }"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("2 [ =");
+        cx.assert_state(
+            indoc! {
+                "func empty(a string) bool {
+                     ˇif a == \"\" {
+                         return true
+                     }
+                     return false
+                }"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("] +");
+        cx.assert_state(
+            indoc! {
+                "func empty(a string) bool {
+                     if a == \"\" {
+                         ˇreturn true
+                     }
+                     return false
+                }"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("] -");
+        cx.assert_state(
+            indoc! {
+                "func empty(a string) bool {
+                     if a == \"\" {
+                         return true
+                     ˇ}
+                     return false
+                }"
+            },
+            Mode::Normal,
+        );
+    }
 }