Add a helix-specific substitute method (#38735)

jneem created

`vim::Substitute` is a little different from the helix behavior, so this
PR adds helix versions. The most important difference (for my usage, at
least) is that if you're selecting whole lines then helix drops the `\n`
from the selection (much like vim's lines mode, except that helix bases
this behavior on the selection instead of having a different mode).

Release Notes:

- N/A

Change summary

assets/keymaps/vim.json |   3 
crates/vim/src/helix.rs | 119 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 120 insertions(+), 2 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -497,7 +497,8 @@
       "shift-u": "editor::Redo",
       "ctrl-c": "editor::ToggleComments",
       "d": "vim::HelixDelete",
-      "c": "vim::Substitute",
+      "c": "vim::HelixSubstitute",
+      "alt-c": "vim::HelixSubstituteNoYank",
       "shift-c": "vim::HelixDuplicateBelow",
       "alt-shift-c": "vim::HelixDuplicateAbove",
       ",": "vim::HelixKeepNewestSelection"

crates/vim/src/helix.rs 🔗

@@ -18,7 +18,7 @@ use text::{Bias, SelectionGoal};
 use workspace::searchable;
 use workspace::searchable::FilteredSearchRange;
 
-use crate::motion;
+use crate::motion::{self, MotionKind};
 use crate::state::SearchState;
 use crate::{
     Vim,
@@ -48,6 +48,10 @@ actions!(
         HelixDuplicateBelow,
         /// Copies all selections above.
         HelixDuplicateAbove,
+        /// Delete the selection and enter edit mode.
+        HelixSubstitute,
+        /// Delete the selection and enter edit mode, without yanking the selection.
+        HelixSubstituteNoYank,
     ]
 );
 
@@ -68,6 +72,8 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
         let times = Vim::take_count(cx);
         vim.helix_duplicate_selections_above(times, window, cx);
     });
+    Vim::action(editor, cx, Vim::helix_substitute);
+    Vim::action(editor, cx, Vim::helix_substitute_no_yank);
 }
 
 impl Vim {
@@ -604,6 +610,54 @@ impl Vim {
             editor.change_selections(Default::default(), window, cx, |s| s.select(vec![newest]));
         });
     }
+
+    fn do_helix_substitute(&mut self, yank: bool, window: &mut Window, cx: &mut Context<Self>) {
+        self.update_editor(cx, |vim, editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            editor.transact(window, cx, |editor, window, cx| {
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.move_with(|map, selection| {
+                        if selection.start == selection.end {
+                            selection.end = movement::right(map, selection.end);
+                        }
+
+                        // If the selection starts and ends on a newline, we exclude the last one.
+                        if !selection.is_empty()
+                            && selection.start.column() == 0
+                            && selection.end.column() == 0
+                        {
+                            selection.end = movement::left(map, selection.end);
+                        }
+                    })
+                });
+                if yank {
+                    vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
+                }
+                let selections = editor.selections.all::<Point>(cx).into_iter();
+                let edits = selections.map(|selection| (selection.start..selection.end, ""));
+                editor.edit(edits, cx);
+            });
+        });
+        self.switch_mode(Mode::Insert, true, window, cx);
+    }
+
+    fn helix_substitute(
+        &mut self,
+        _: &HelixSubstitute,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.do_helix_substitute(true, window, cx);
+    }
+
+    fn helix_substitute_no_yank(
+        &mut self,
+        _: &HelixSubstituteNoYank,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.do_helix_substitute(false, window, cx);
+    }
 }
 
 #[cfg(test)]
@@ -1241,4 +1295,67 @@ mod test {
         cx.simulate_keystrokes("s o n e enter");
         cx.assert_state("ˇone two one", Mode::HelixNormal);
     }
+
+    #[gpui::test]
+    async fn test_helix_substitute(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state("ˇone two", Mode::HelixNormal);
+        cx.simulate_keystrokes("c");
+        cx.assert_state("ˇne two", Mode::Insert);
+
+        cx.set_state("«oneˇ» two", Mode::HelixNormal);
+        cx.simulate_keystrokes("c");
+        cx.assert_state("ˇ two", Mode::Insert);
+
+        cx.set_state(
+            indoc! {"
+            oneˇ two
+            three
+            "},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x c");
+        cx.assert_state(
+            indoc! {"
+            ˇ
+            three
+            "},
+            Mode::Insert,
+        );
+
+        cx.set_state(
+            indoc! {"
+            one twoˇ
+            three
+            "},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("c");
+        cx.assert_state(
+            indoc! {"
+            one twoˇthree
+            "},
+            Mode::Insert,
+        );
+
+        // Helix doesn't set the cursor to the first non-blank one when
+        // replacing lines: it uses language-dependent indent queries instead.
+        cx.set_state(
+            indoc! {"
+            one two
+            «    indented
+            three not indentedˇ»
+            "},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("c");
+        cx.set_state(
+            indoc! {"
+            one two
+            ˇ
+            "},
+            Mode::Insert,
+        );
+    }
 }