vim: Fix `:g/pattern/norm commands` applying on all matches (#43352)

AidanV and dino created

Closes #36359

Release Notes:

- Fixes bug where `:g/pattern/norm commands` would only apply on the
last match

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

crates/vim/src/command.rs                       | 126 +++++++++++++++---
crates/vim/test_data/test_command_g_normal.json |  22 +++
crates/vim/test_data/test_normal_command.json   |  14 ++
3 files changed, 138 insertions(+), 24 deletions(-)

Detailed changes

crates/vim/src/command.rs 🔗

@@ -243,6 +243,9 @@ struct VimRead {
 struct VimNorm {
     pub range: Option<CommandRange>,
     pub command: String,
+    /// Places cursors at beginning of each given row.
+    /// Overrides given range and current cursor.
+    pub override_rows: Option<Vec<u32>>,
 }
 
 #[derive(Debug)]
@@ -759,9 +762,18 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
             .map(|c| Keystroke::parse(&c.to_string()).unwrap())
             .collect();
         vim.switch_mode(Mode::Normal, true, window, cx);
-        let initial_selections =
-            vim.update_editor(cx, |_, editor, _| editor.selections.disjoint_anchors_arc());
-        if let Some(range) = &action.range {
+        if let Some(override_rows) = &action.override_rows {
+            vim.update_editor(cx, |_, editor, cx| {
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.replace_cursors_with(|map| {
+                        override_rows
+                            .iter()
+                            .map(|row| Point::new(*row, 0).to_display_point(map))
+                            .collect()
+                    });
+                });
+            });
+        } else if let Some(range) = &action.range {
             let result = vim.update_editor(cx, |vim, editor, cx| {
                 let range = range.buffer_range(vim, editor, window, cx)?;
                 editor.change_selections(
@@ -790,37 +802,35 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
             workspace.send_keystrokes_impl(keystrokes, window, cx)
         });
         let had_range = action.range.is_some();
+        let had_override = action.override_rows.is_some();
 
         cx.spawn_in(window, async move |vim, cx| {
             task.await;
             vim.update_in(cx, |vim, window, cx| {
-                vim.update_editor(cx, |_, editor, cx| {
-                    if had_range {
-                        editor.change_selections(SelectionEffects::default(), window, cx, |s| {
-                            s.select_anchor_ranges([s.newest_anchor().range()]);
-                        })
-                    }
-                });
                 if matches!(vim.mode, Mode::Insert | Mode::Replace) {
                     vim.normal_before(&Default::default(), window, cx);
                 } else {
                     vim.switch_mode(Mode::Normal, true, window, cx);
                 }
-                vim.update_editor(cx, |_, editor, cx| {
-                    if let Some(first_sel) = initial_selections
-                        && let Some(tx_id) = editor
+                if had_override || had_range {
+                    vim.update_editor(cx, |_, editor, cx| {
+                        editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+                            s.select_anchor_ranges([s.newest_anchor().range()]);
+                        });
+                        if let Some(tx_id) = editor
                             .buffer()
                             .update(cx, |multi, cx| multi.last_transaction_id(cx))
-                    {
-                        let last_sel = editor.selections.disjoint_anchors_arc();
-                        editor.modify_transaction_selection_history(tx_id, |old| {
-                            old.0 = first_sel;
-                            old.1 = Some(last_sel);
-                        });
-                    }
-                });
+                        {
+                            let last_sel = editor.selections.disjoint_anchors_arc();
+                            editor.modify_transaction_selection_history(tx_id, |old| {
+                                old.0 = old.0.get(..1).unwrap_or(&[]).into();
+                                old.1 = Some(last_sel);
+                            });
+                        }
+                    });
+                }
             })
-            .ok();
+            .log_err();
         })
         .detach();
     });
@@ -1606,6 +1616,7 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
             VimNorm {
                 command: "".into(),
                 range: None,
+                override_rows: None,
             },
         )
         .args(|_, args| {
@@ -1613,6 +1624,7 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
                 VimNorm {
                     command: args,
                     range: None,
+                    override_rows: None,
                 }
                 .boxed_clone(),
             )
@@ -1863,7 +1875,7 @@ pub fn command_interceptor(
     } else if on_matching_lines.is_some() {
         commands(cx)
             .iter()
-            .find_map(|command| command.parse(query, &range, cx))
+            .find_map(|command| command.parse(query, &None, cx))
     } else {
         None
     };
@@ -2194,6 +2206,19 @@ impl OnMatchingLines {
                 if new_selections.is_empty() {
                     return;
                 }
+
+                if let Some(vim_norm) = action.as_any().downcast_ref::<VimNorm>() {
+                    let mut vim_norm = vim_norm.clone();
+                    vim_norm.override_rows =
+                        Some(new_selections.iter().map(|point| point.row().0).collect());
+                    editor
+                        .update_in(cx, |_, window, cx| {
+                            window.dispatch_action(vim_norm.boxed_clone(), cx);
+                        })
+                        .log_err();
+                    return;
+                }
+
                 editor
                     .update_in(cx, |editor, window, cx| {
                         editor.start_transaction_at(Instant::now(), window, cx);
@@ -2201,6 +2226,7 @@ impl OnMatchingLines {
                             s.replace_cursors_with(|_| new_selections);
                         });
                         window.dispatch_action(action, cx);
+
                         cx.defer_in(window, move |editor, window, cx| {
                             let newest = editor
                                 .selections
@@ -2216,7 +2242,7 @@ impl OnMatchingLines {
                             editor.end_transaction_at(Instant::now(), cx);
                         })
                     })
-                    .ok();
+                    .log_err();
             })
             .detach();
         });
@@ -3150,9 +3176,61 @@ mod test {
             jumps over
             the lazy dog
         "});
+
+        cx.set_shared_state(indoc! {"
+            The« quick
+            brownˇ» fox
+            jumps over
+            the lazy dog
+        "})
+            .await;
+
+        cx.simulate_shared_keystrokes(": n o r m space I 1 2 3")
+            .await;
+        cx.simulate_shared_keystrokes("enter").await;
+        cx.simulate_shared_keystrokes("u").await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            ˇThe quick
+            brown fox
+            jumps over
+            the lazy dog
+        "});
+
         // Once ctrl-v to input character literals is added there should be a test for redo
     }
 
+    #[gpui::test]
+    async fn test_command_g_normal(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {"
+            ˇfoo
+
+            foo
+        "})
+            .await;
+
+        cx.simulate_shared_keystrokes(": % g / f o o / n o r m space A b a r")
+            .await;
+        cx.simulate_shared_keystrokes("enter").await;
+        cx.run_until_parked();
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            foobar
+
+            foobaˇr
+        "});
+
+        cx.simulate_shared_keystrokes("u").await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            foˇo
+
+            foo
+        "});
+    }
+
     #[gpui::test]
     async fn test_command_tabnew(cx: &mut TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;

crates/vim/test_data/test_command_g_normal.json 🔗

@@ -0,0 +1,22 @@
+{"Put":{"state":"ˇfoo\n\nfoo\n"}}
+{"Key":":"}
+{"Key":"%"}
+{"Key":"g"}
+{"Key":"/"}
+{"Key":"f"}
+{"Key":"o"}
+{"Key":"o"}
+{"Key":"/"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"A"}
+{"Key":"b"}
+{"Key":"a"}
+{"Key":"r"}
+{"Key":"enter"}
+{"Get":{"state":"foobar\n\nfoobaˇr\n","mode":"Normal"}}
+{"Key":"u"}
+{"Get":{"state":"foˇo\n\nfoo\n","mode":"Normal"}}

crates/vim/test_data/test_normal_command.json 🔗

@@ -62,3 +62,17 @@
 {"Key":"u"}
 {"Key":"enter"}
 {"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}
+{"Put":{"state":"The« quick\nbrownˇ» fox\njumps over\nthe lazy dog\n"}}
+{"Key":":"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"I"}
+{"Key":"1"}
+{"Key":"2"}
+{"Key":"3"}
+{"Key":"enter"}
+{"Key":"u"}
+{"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}