vim S (#2929)

Conrad Irwin created

Release Notes:
- vim: Add `S` to substitute line ([#1897](https://github.com/zed-industries/community/issues/1897)).

Change summary

assets/keymaps/vim.json                        |  2 
crates/vim/src/normal.rs                       |  9 -
crates/vim/src/normal/substitute.rs            | 97 +++++++++++++++++++
crates/vim/src/test/neovim_connection.rs       |  3 
crates/vim/test_data/test_substitute_line.json | 29 +++++
5 files changed, 129 insertions(+), 11 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -371,6 +371,7 @@
         "Replace"
       ],
       "s": "vim::Substitute",
+      "shift-s": "vim::SubstituteLine",
       "> >": "editor::Indent",
       "< <": "editor::Outdent",
       "ctrl-pagedown": "pane::ActivateNextItem",
@@ -446,6 +447,7 @@
         }
       ],
       "s": "vim::Substitute",
+      "shift-s": "vim::SubstituteLine",
       "c": "vim::Substitute",
       "~": "vim::ChangeCase",
       "shift-i": [

crates/vim/src/normal.rs 🔗

@@ -27,7 +27,6 @@ use self::{
     case::change_case,
     change::{change_motion, change_object},
     delete::{delete_motion, delete_object},
-    substitute::substitute,
     yank::{yank_motion, yank_object},
 };
 
@@ -44,7 +43,6 @@ actions!(
         ChangeToEndOfLine,
         DeleteToEndOfLine,
         Yank,
-        Substitute,
         ChangeCase,
     ]
 );
@@ -56,13 +54,8 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(insert_line_above);
     cx.add_action(insert_line_below);
     cx.add_action(change_case);
+    substitute::init(cx);
     search::init(cx);
-    cx.add_action(|_: &mut Workspace, _: &Substitute, cx| {
-        Vim::update(cx, |vim, cx| {
-            let times = vim.pop_number_operator(cx);
-            substitute(vim, times, cx);
-        })
-    });
     cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| {
         Vim::update(cx, |vim, cx| {
             let times = vim.pop_number_operator(cx);

crates/vim/src/normal/substitute.rs 🔗

@@ -1,10 +1,32 @@
-use gpui::WindowContext;
+use editor::movement;
+use gpui::{actions, AppContext, WindowContext};
 use language::Point;
+use workspace::Workspace;
 
 use crate::{motion::Motion, utils::copy_selections_content, Mode, Vim};
 
-pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) {
-    let line_mode = vim.state().mode == Mode::VisualLine;
+actions!(vim, [Substitute, SubstituteLine]);
+
+pub(crate) fn init(cx: &mut AppContext) {
+    cx.add_action(|_: &mut Workspace, _: &Substitute, cx| {
+        Vim::update(cx, |vim, cx| {
+            let count = vim.pop_number_operator(cx);
+            substitute(vim, count, vim.state().mode == Mode::VisualLine, cx);
+        })
+    });
+
+    cx.add_action(|_: &mut Workspace, _: &SubstituteLine, cx| {
+        Vim::update(cx, |vim, cx| {
+            if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) {
+                vim.switch_mode(Mode::VisualLine, false, cx)
+            }
+            let count = vim.pop_number_operator(cx);
+            substitute(vim, count, true, cx)
+        })
+    });
+}
+
+pub fn substitute(vim: &mut Vim, count: Option<usize>, line_mode: bool, cx: &mut WindowContext) {
     vim.update_active_editor(cx, |editor, cx| {
         editor.set_clip_at_line_ends(false, cx);
         editor.transact(cx, |editor, cx| {
@@ -14,6 +36,11 @@ pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) {
                         Motion::Right.expand_selection(map, selection, count, true);
                     }
                     if line_mode {
+                        // in Visual mode when the selection contains the newline at the end
+                        // of the line, we should exclude it.
+                        if !selection.is_empty() && selection.end.column() == 0 {
+                            selection.end = movement::left(map, selection.end);
+                        }
                         Motion::CurrentLine.expand_selection(map, selection, None, false);
                         if let Some((point, _)) = (Motion::FirstNonWhitespace {
                             display_lines: false,
@@ -166,4 +193,68 @@ mod test {
             the laˇzy dog"})
             .await;
     }
+
+    #[gpui::test]
+    async fn test_substitute_line(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        let initial_state = indoc! {"
+                    The quick brown
+                    fox juˇmps over
+                    the lazy dog
+                    "};
+
+        // normal mode
+        cx.set_shared_state(initial_state).await;
+        cx.simulate_shared_keystrokes(["shift-s", "o"]).await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            oˇ
+            the lazy dog
+            "})
+            .await;
+
+        // visual mode
+        cx.set_shared_state(initial_state).await;
+        cx.simulate_shared_keystrokes(["v", "k", "shift-s", "o"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            oˇ
+            the lazy dog
+            "})
+            .await;
+
+        // visual block mode
+        cx.set_shared_state(initial_state).await;
+        cx.simulate_shared_keystrokes(["ctrl-v", "j", "shift-s", "o"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            oˇ
+            "})
+            .await;
+
+        // visual mode including newline
+        cx.set_shared_state(initial_state).await;
+        cx.simulate_shared_keystrokes(["v", "$", "shift-s", "o"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            oˇ
+            the lazy dog
+            "})
+            .await;
+
+        // indentation
+        cx.set_neovim_option("shiftwidth=4").await;
+        cx.set_shared_state(initial_state).await;
+        cx.simulate_shared_keystrokes([">", ">", "shift-s", "o"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+                oˇ
+            the lazy dog
+            "})
+            .await;
+    }
 }

crates/vim/src/test/neovim_connection.rs 🔗

@@ -237,6 +237,9 @@ impl NeovimConnection {
 
     #[cfg(not(feature = "neovim"))]
     pub async fn set_option(&mut self, value: &str) {
+        if let Some(NeovimData::Get { .. }) = self.data.front() {
+            self.data.pop_front();
+        };
         assert_eq!(
             self.data.pop_front(),
             Some(NeovimData::SetOption {

crates/vim/test_data/test_substitute_line.json 🔗

@@ -0,0 +1,29 @@
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog\n"}}
+{"Key":"shift-s"}
+{"Key":"o"}
+{"Get":{"state":"The quick brown\noˇ\nthe lazy dog\n","mode":"Insert"}}
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog\n"}}
+{"Key":"v"}
+{"Key":"k"}
+{"Key":"shift-s"}
+{"Key":"o"}
+{"Get":{"state":"oˇ\nthe lazy dog\n","mode":"Insert"}}
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog\n"}}
+{"Key":"ctrl-v"}
+{"Key":"j"}
+{"Key":"shift-s"}
+{"Key":"o"}
+{"Get":{"state":"The quick brown\noˇ\n","mode":"Insert"}}
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog\n"}}
+{"Key":"v"}
+{"Key":"$"}
+{"Key":"shift-s"}
+{"Key":"o"}
+{"Get":{"state":"The quick brown\noˇ\nthe lazy dog\n","mode":"Insert"}}
+{"SetOption":{"value":"shiftwidth=4"}}
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog\n"}}
+{"Key":">"}
+{"Key":">"}
+{"Key":"shift-s"}
+{"Key":"o"}
+{"Get":{"state":"The quick brown\n    oˇ\nthe lazy dog\n","mode":"Insert"}}