vim: Add sneak motion (#22793)

Nico Lehmann , Kajetan Puchalski , Aidan Grant , and Conrad Irwin created

A (re)continuation of https://github.com/zed-industries/zed/pull/21067. 

This takes the original implementation in
https://github.com/zed-industries/zed/pull/15572 and adds the test in
https://github.com/zed-industries/zed/pull/21067. Then, as requested in
https://github.com/zed-industries/zed/pull/21067#issuecomment-2515469185,
it documents how to map a keybinding instead of having a setting.

Closes #13858

Release Notes:

- Added support for the popular
[vim_sneak](https://github.com/justinmk/vim-sneak) plugin. This is
disabled by default and can be enabled by binding a key to the `Sneak`
and `SneakBackward` operators.

Reference:
https://github.com/justinmk/vim-sneak

---------

Co-authored-by: Kajetan Puchalski <kajetan.puchalski@tuta.io>
Co-authored-by: Aidan Grant <mraidangrant@gmail.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/vim/src/motion.rs | 179 +++++++++++++++++++++++++++++++++++++++++
crates/vim/src/state.rs  |   6 +
crates/vim/src/test.rs   |  69 +++++++++++++++
crates/vim/src/vim.rs    |  38 ++++++++
docs/src/vim.md          |  14 +++
5 files changed, 301 insertions(+), 5 deletions(-)

Detailed changes

crates/vim/src/motion.rs 🔗

@@ -91,6 +91,16 @@ pub enum Motion {
         mode: FindRange,
         smartcase: bool,
     },
+    Sneak {
+        first_char: char,
+        second_char: char,
+        smartcase: bool,
+    },
+    SneakBackward {
+        first_char: char,
+        second_char: char,
+        smartcase: bool,
+    },
     RepeatFind {
         last_find: Box<Motion>,
     },
@@ -538,8 +548,10 @@ impl Vim {
     }
 
     pub(crate) fn motion(&mut self, motion: Motion, cx: &mut ViewContext<Self>) {
-        if let Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) =
-            self.active_operator()
+        if let Some(Operator::FindForward { .. })
+        | Some(Operator::Sneak { .. })
+        | Some(Operator::SneakBackward { .. })
+        | Some(Operator::FindBackward { .. }) = self.active_operator()
         {
             self.pop_operator(cx);
         }
@@ -625,6 +637,8 @@ impl Motion {
             | PreviousSubwordEnd { .. }
             | FirstNonWhitespace { .. }
             | FindBackward { .. }
+            | Sneak { .. }
+            | SneakBackward { .. }
             | RepeatFind { .. }
             | RepeatFindReversed { .. }
             | Jump { line: false, .. }
@@ -666,6 +680,8 @@ impl Motion {
             | PreviousSubwordEnd { .. }
             | FirstNonWhitespace { .. }
             | FindBackward { .. }
+            | Sneak { .. }
+            | SneakBackward { .. }
             | RepeatFindReversed { .. }
             | WindowTop
             | WindowMiddle
@@ -727,6 +743,8 @@ impl Motion {
             | PreviousSubwordStart { .. }
             | FirstNonWhitespace { .. }
             | FindBackward { .. }
+            | Sneak { .. }
+            | SneakBackward { .. }
             | Jump { .. }
             | NextSectionStart
             | NextSectionEnd
@@ -862,6 +880,22 @@ impl Motion {
                 find_backward(map, point, *after, *char, times, *mode, *smartcase),
                 SelectionGoal::None,
             ),
+            Sneak {
+                first_char,
+                second_char,
+                smartcase,
+            } => {
+                return sneak(map, point, *first_char, *second_char, times, *smartcase)
+                    .map(|new_point| (new_point, SelectionGoal::None));
+            }
+            SneakBackward {
+                first_char,
+                second_char,
+                smartcase,
+            } => {
+                return sneak_backward(map, point, *first_char, *second_char, times, *smartcase)
+                    .map(|new_point| (new_point, SelectionGoal::None));
+            }
             // ; -- repeat the last find done with t, f, T, F
             RepeatFind { last_find } => match **last_find {
                 Motion::FindForward {
@@ -895,9 +929,44 @@ impl Motion {
 
                     (new_point, SelectionGoal::None)
                 }
+                Motion::Sneak {
+                    first_char,
+                    second_char,
+                    smartcase,
+                } => {
+                    let mut new_point =
+                        sneak(map, point, first_char, second_char, times, smartcase);
+                    if new_point == Some(point) {
+                        new_point =
+                            sneak(map, point, first_char, second_char, times + 1, smartcase);
+                    }
+
+                    return new_point.map(|new_point| (new_point, SelectionGoal::None));
+                }
+
+                Motion::SneakBackward {
+                    first_char,
+                    second_char,
+                    smartcase,
+                } => {
+                    let mut new_point =
+                        sneak_backward(map, point, first_char, second_char, times, smartcase);
+                    if new_point == Some(point) {
+                        new_point = sneak_backward(
+                            map,
+                            point,
+                            first_char,
+                            second_char,
+                            times + 1,
+                            smartcase,
+                        );
+                    }
+
+                    return new_point.map(|new_point| (new_point, SelectionGoal::None));
+                }
                 _ => return None,
             },
-            // , -- repeat the last find done with t, f, T, F, in opposite direction
+            // , -- repeat the last find done with t, f, T, F, s, S, in opposite direction
             RepeatFindReversed { last_find } => match **last_find {
                 Motion::FindForward {
                     before,
@@ -930,6 +999,42 @@ impl Motion {
 
                     return new_point.map(|new_point| (new_point, SelectionGoal::None));
                 }
+
+                Motion::Sneak {
+                    first_char,
+                    second_char,
+                    smartcase,
+                } => {
+                    let mut new_point =
+                        sneak_backward(map, point, first_char, second_char, times, smartcase);
+                    if new_point == Some(point) {
+                        new_point = sneak_backward(
+                            map,
+                            point,
+                            first_char,
+                            second_char,
+                            times + 1,
+                            smartcase,
+                        );
+                    }
+
+                    return new_point.map(|new_point| (new_point, SelectionGoal::None));
+                }
+
+                Motion::SneakBackward {
+                    first_char,
+                    second_char,
+                    smartcase,
+                } => {
+                    let mut new_point =
+                        sneak(map, point, first_char, second_char, times, smartcase);
+                    if new_point == Some(point) {
+                        new_point =
+                            sneak(map, point, first_char, second_char, times + 1, smartcase);
+                    }
+
+                    return new_point.map(|new_point| (new_point, SelectionGoal::None));
+                }
                 _ => return None,
             },
             NextLineStart => (next_line_start(map, point, times), SelectionGoal::None),
@@ -2134,6 +2239,74 @@ fn is_character_match(target: char, other: char, smartcase: bool) -> bool {
     }
 }
 
+fn sneak(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    first_target: char,
+    second_target: char,
+    times: usize,
+    smartcase: bool,
+) -> Option<DisplayPoint> {
+    let mut to = from;
+    let mut found = false;
+
+    for _ in 0..times {
+        found = false;
+        let new_to = find_boundary(
+            map,
+            movement::right(map, to),
+            FindRange::MultiLine,
+            |left, right| {
+                found = is_character_match(first_target, left, smartcase)
+                    && is_character_match(second_target, right, smartcase);
+                found
+            },
+        );
+        if to == new_to {
+            break;
+        }
+        to = new_to;
+    }
+
+    if found {
+        Some(movement::left(map, to))
+    } else {
+        None
+    }
+}
+
+fn sneak_backward(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    first_target: char,
+    second_target: char,
+    times: usize,
+    smartcase: bool,
+) -> Option<DisplayPoint> {
+    let mut to = from;
+    let mut found = false;
+
+    for _ in 0..times {
+        found = false;
+        let new_to =
+            find_preceding_boundary_display_point(map, to, FindRange::MultiLine, |left, right| {
+                found = is_character_match(first_target, left, smartcase)
+                    && is_character_match(second_target, right, smartcase);
+                found
+            });
+        if to == new_to {
+            break;
+        }
+        to = new_to;
+    }
+
+    if found {
+        Some(movement::left(map, to))
+    } else {
+        None
+    }
+}
+
 fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
     let correct_line = start_of_relative_buffer_row(map, point, times as isize);
     first_non_whitespace(map, false, correct_line)

crates/vim/src/state.rs 🔗

@@ -68,6 +68,8 @@ pub enum Operator {
     Object { around: bool },
     FindForward { before: bool },
     FindBackward { after: bool },
+    Sneak { first_char: Option<char> },
+    SneakBackward { first_char: Option<char> },
     AddSurrounds { target: Option<SurroundsType> },
     ChangeSurrounds { target: Option<Object> },
     DeleteSurrounds,
@@ -460,6 +462,8 @@ impl Operator {
             Operator::Literal { .. } => "^V",
             Operator::FindForward { before: false } => "f",
             Operator::FindForward { before: true } => "t",
+            Operator::Sneak { .. } => "s",
+            Operator::SneakBackward { .. } => "S",
             Operator::FindBackward { after: false } => "F",
             Operator::FindBackward { after: true } => "T",
             Operator::AddSurrounds { .. } => "ys",
@@ -502,6 +506,8 @@ impl Operator {
             | Operator::Mark
             | Operator::Jump { .. }
             | Operator::FindBackward { .. }
+            | Operator::Sneak { .. }
+            | Operator::SneakBackward { .. }
             | Operator::Register
             | Operator::RecordRegister
             | Operator::ReplayRegister

crates/vim/src/test.rs 🔗

@@ -17,7 +17,12 @@ use indoc::indoc;
 use search::BufferSearchBar;
 use workspace::WorkspaceSettings;
 
-use crate::{insert::NormalBefore, motion, state::Mode};
+use crate::{
+    insert::NormalBefore,
+    motion,
+    state::{Mode, Operator},
+    PushOperator,
+};
 
 #[gpui::test]
 async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
@@ -1332,6 +1337,68 @@ async fn test_find_multibyte(cx: &mut gpui::TestAppContext) {
         .assert_eq(r#"<label for="guests">ˇo</label>"#);
 }
 
+#[gpui::test]
+async fn test_sneak(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, true).await;
+
+    cx.update(|cx| {
+        cx.bind_keys([
+            KeyBinding::new(
+                "s",
+                PushOperator(Operator::Sneak { first_char: None }),
+                Some("vim_mode == normal"),
+            ),
+            KeyBinding::new(
+                "S",
+                PushOperator(Operator::SneakBackward { first_char: None }),
+                Some("vim_mode == normal"),
+            ),
+            KeyBinding::new(
+                "S",
+                PushOperator(Operator::SneakBackward { first_char: None }),
+                Some("vim_mode == visual"),
+            ),
+        ])
+    });
+
+    // Sneak forwards multibyte & multiline
+    cx.set_state(
+        indoc! {
+            r#"<labelˇ for="guests">
+                    Počet hostů
+                </label>"#
+        },
+        Mode::Normal,
+    );
+    cx.simulate_keystrokes("s t ů");
+    cx.assert_state(
+        indoc! {
+            r#"<label for="guests">
+                Počet hosˇtů
+            </label>"#
+        },
+        Mode::Normal,
+    );
+
+    // Visual sneak backwards multibyte & multiline
+    cx.simulate_keystrokes("v S < l");
+    cx.assert_state(
+        indoc! {
+            r#"«ˇ<label for="guests">
+                Počet host»ů
+            </label>"#
+        },
+        Mode::Visual,
+    );
+
+    // Sneak backwards repeated
+    cx.set_state(r#"11 12 13 ˇ14"#, Mode::Normal);
+    cx.simulate_keystrokes("S space 1");
+    cx.assert_state(r#"11 12ˇ 13 14"#, Mode::Normal);
+    cx.simulate_keystrokes(";");
+    cx.assert_state(r#"11ˇ 12 13 14"#, Mode::Normal);
+}
+
 #[gpui::test]
 async fn test_plus_minus(cx: &mut gpui::TestAppContext) {
     let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/src/vim.rs 🔗

@@ -80,9 +80,11 @@ actions!(
         InnerObject,
         FindForward,
         FindBackward,
-        OpenDefaultKeymap,
         MaximizePane,
+        OpenDefaultKeymap,
         ResetPaneSizes,
+        Sneak,
+        SneakBackward,
     ]
 );
 
@@ -1093,6 +1095,40 @@ impl Vim {
                 Vim::globals(cx).last_find = Some(find.clone());
                 self.motion(find, cx)
             }
+            Some(Operator::Sneak { first_char }) => {
+                if let Some(first_char) = first_char {
+                    if let Some(second_char) = text.chars().next() {
+                        let sneak = Motion::Sneak {
+                            first_char,
+                            second_char,
+                            smartcase: VimSettings::get_global(cx).use_smartcase_find,
+                        };
+                        Vim::globals(cx).last_find = Some((&sneak).clone());
+                        self.motion(sneak, cx)
+                    }
+                } else {
+                    let first_char = text.chars().next();
+                    self.pop_operator(cx);
+                    self.push_operator(Operator::Sneak { first_char }, cx);
+                }
+            }
+            Some(Operator::SneakBackward { first_char }) => {
+                if let Some(first_char) = first_char {
+                    if let Some(second_char) = text.chars().next() {
+                        let sneak = Motion::SneakBackward {
+                            first_char,
+                            second_char,
+                            smartcase: VimSettings::get_global(cx).use_smartcase_find,
+                        };
+                        Vim::globals(cx).last_find = Some((&sneak).clone());
+                        self.motion(sneak, cx)
+                    }
+                } else {
+                    let first_char = text.chars().next();
+                    self.pop_operator(cx);
+                    self.push_operator(Operator::SneakBackward { first_char }, cx);
+                }
+            }
             Some(Operator::Replace) => match self.mode {
                 Mode::Normal => self.normal_replace(text, cx),
                 Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {

docs/src/vim.md 🔗

@@ -408,6 +408,20 @@ Vim mode comes with shortcuts to surround the selection in normal mode (`ys`), b
 }
 ```
 
+The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for quick navigation to any two-character sequence in your text. You can enable it by adding the following keybindings to your keymap. By default, the `s` key is mapped to `vim::Substitute`. Adding these bindings will override that behavior, so ensure this change aligns with your workflow preferences.
+
+```json
+[
+  {
+    "context": "vim_mode == normal || vim_mode == visual",
+    "bindings": {
+      "s": ["vim::PushOperator", { "Sneak": {} }],
+      "S": ["vim::PushOperator", { "SneakBackward": {} }]
+    }
+  }
+]
+```
+
 ### Restoring common text editing keybindings
 
 If you're using vim mode on Linux or Windows, you may find it overrides keybindings you can't live without: `ctrl+v` to copy, `ctrl+f` to search, etc. You can restore them by copying this data into your keymap: