vim: Implement /n and /c in :s (#34102)

Conrad Irwin created

Closes #23345

Release Notes:

- vim: Support /n and /c in :s//

Change summary

crates/editor/src/items.rs               |  14 -
crates/project/src/search.rs             |   3 
crates/search/src/buffer_search.rs       |  20 +
crates/vim/src/normal/search.rs          | 273 +++++++++++++++++++------
crates/vim/test_data/test_replace_g.json |  23 ++
crates/vim/test_data/test_replace_n.json |  13 +
6 files changed, 264 insertions(+), 82 deletions(-)

Detailed changes

crates/editor/src/items.rs 🔗

@@ -1607,24 +1607,10 @@ impl SearchableItem for Editor {
         let text = self.buffer.read(cx);
         let text = text.snapshot(cx);
         let mut edits = vec![];
-        let mut last_point: Option<Point> = None;
 
         for m in matches {
-            let point = m.start.to_point(&text);
             let text = text.text_for_range(m.clone()).collect::<Vec<_>>();
 
-            // Check if the row for the current match is different from the last
-            // match. If that's not the case and we're still replacing matches
-            // in the same row/line, skip this match if the `one_match_per_line`
-            // option is enabled.
-            if last_point.is_none() {
-                last_point = Some(point);
-            } else if last_point.is_some() && point.row != last_point.unwrap().row {
-                last_point = Some(point);
-            } else if query.one_match_per_line().is_some_and(|enabled| enabled) {
-                continue;
-            }
-
             let text: Cow<_> = if text.len() == 1 {
                 text.first().cloned().unwrap().into()
             } else {

crates/project/src/search.rs 🔗

@@ -404,6 +404,9 @@ impl SearchQuery {
                                     let start = line_offset + mat.start();
                                     let end = line_offset + mat.end();
                                     matches.push(start..end);
+                                    if self.one_match_per_line() == Some(true) {
+                                        break;
+                                    }
                                 }
 
                                 line_offset += line.len() + 1;

crates/search/src/buffer_search.rs 🔗

@@ -939,6 +939,11 @@ impl BufferSearchBar {
             });
     }
 
+    pub fn focus_replace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.focus(&self.replacement_editor.focus_handle(cx), window, cx);
+        cx.notify();
+    }
+
     pub fn search(
         &mut self,
         query: &str,
@@ -1092,6 +1097,21 @@ impl BufferSearchBar {
         }
     }
 
+    pub fn select_first_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(searchable_item) = self.active_searchable_item.as_ref() {
+            if let Some(matches) = self
+                .searchable_items_with_matches
+                .get(&searchable_item.downgrade())
+            {
+                if matches.is_empty() {
+                    return;
+                }
+                searchable_item.update_matches(matches, window, cx);
+                searchable_item.activate_match(0, matches, window, cx);
+            }
+        }
+    }
+
     pub fn select_last_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(searchable_item) = self.active_searchable_item.as_ref() {
             if let Some(matches) = self

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

@@ -71,11 +71,13 @@ pub struct ReplaceCommand {
 }
 
 #[derive(Clone, Debug, PartialEq)]
-pub(crate) struct Replacement {
+pub struct Replacement {
     search: String,
     replacement: String,
-    should_replace_all: bool,
-    is_case_sensitive: bool,
+    case_sensitive: Option<bool>,
+    flag_n: bool,
+    flag_g: bool,
+    flag_c: bool,
 }
 
 actions!(
@@ -468,71 +470,89 @@ impl Vim {
                 result.notify_err(workspace, cx);
             })
         }
-        let vim = cx.entity().clone();
-        pane.update(cx, |pane, cx| {
-            let mut options = SearchOptions::REGEX;
+        let Some(search_bar) = pane.update(cx, |pane, cx| {
+            pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
+        }) else {
+            return;
+        };
+        let mut options = SearchOptions::REGEX;
+        let search = search_bar.update(cx, |search_bar, cx| {
+            if !search_bar.show(window, cx) {
+                return None;
+            }
 
-            let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
-                return;
+            let search = if replacement.search.is_empty() {
+                search_bar.query(cx)
+            } else {
+                replacement.search
             };
-            let search = search_bar.update(cx, |search_bar, cx| {
-                if !search_bar.show(window, cx) {
-                    return None;
-                }
-
-                if replacement.is_case_sensitive {
-                    options.set(SearchOptions::CASE_SENSITIVE, true)
-                }
-                let search = if replacement.search.is_empty() {
-                    search_bar.query(cx)
-                } else {
-                    replacement.search
-                };
-                if search_bar.should_use_smartcase_search(cx) {
-                    options.set(
-                        SearchOptions::CASE_SENSITIVE,
-                        search_bar.is_contains_uppercase(&search),
-                    );
-                }
 
-                if !replacement.should_replace_all {
-                    options.set(SearchOptions::ONE_MATCH_PER_LINE, true);
-                }
+            if let Some(case) = replacement.case_sensitive {
+                options.set(SearchOptions::CASE_SENSITIVE, case)
+            } else if search_bar.should_use_smartcase_search(cx) {
+                options.set(
+                    SearchOptions::CASE_SENSITIVE,
+                    search_bar.is_contains_uppercase(&search),
+                );
+            } else {
+                options.set(SearchOptions::CASE_SENSITIVE, false)
+            }
 
-                search_bar.set_replacement(Some(&replacement.replacement), cx);
-                Some(search_bar.search(&search, Some(options), window, cx))
-            });
-            let Some(search) = search else { return };
-            let search_bar = search_bar.downgrade();
-            cx.spawn_in(window, async move |_, cx| {
-                search.await?;
-                search_bar.update_in(cx, |search_bar, window, cx| {
-                    search_bar.select_last_match(window, cx);
-                    search_bar.replace_all(&Default::default(), window, cx);
-                    editor.update(cx, |editor, cx| editor.clear_search_within_ranges(cx));
-                    let _ = search_bar.search(&search_bar.query(cx), None, window, cx);
-                    vim.update(cx, |vim, cx| {
-                        vim.move_cursor(
-                            Motion::StartOfLine {
-                                display_lines: false,
-                            },
-                            None,
-                            window,
-                            cx,
-                        )
-                    });
+            if !replacement.flag_g {
+                options.set(SearchOptions::ONE_MATCH_PER_LINE, true);
+            }
 
-                    // Disable the `ONE_MATCH_PER_LINE` search option when finished, as
-                    // this is not properly supported outside of vim mode, and
-                    // not disabling it makes the "Replace All Matches" button
-                    // actually replace only the first match on each line.
-                    options.set(SearchOptions::ONE_MATCH_PER_LINE, false);
-                    search_bar.set_search_options(options, cx);
-                })?;
-                anyhow::Ok(())
+            search_bar.set_replacement(Some(&replacement.replacement), cx);
+            if replacement.flag_c {
+                search_bar.focus_replace(window, cx);
+            }
+            Some(search_bar.search(&search, Some(options), window, cx))
+        });
+        if replacement.flag_n {
+            self.move_cursor(
+                Motion::StartOfLine {
+                    display_lines: false,
+                },
+                None,
+                window,
+                cx,
+            );
+            return;
+        }
+        let Some(search) = search else { return };
+        let search_bar = search_bar.downgrade();
+        cx.spawn_in(window, async move |vim, cx| {
+            search.await?;
+            search_bar.update_in(cx, |search_bar, window, cx| {
+                if replacement.flag_c {
+                    search_bar.select_first_match(window, cx);
+                    return;
+                }
+                search_bar.select_last_match(window, cx);
+                search_bar.replace_all(&Default::default(), window, cx);
+                editor.update(cx, |editor, cx| editor.clear_search_within_ranges(cx));
+                let _ = search_bar.search(&search_bar.query(cx), None, window, cx);
+                vim.update(cx, |vim, cx| {
+                    vim.move_cursor(
+                        Motion::StartOfLine {
+                            display_lines: false,
+                        },
+                        None,
+                        window,
+                        cx,
+                    )
+                })
+                .ok();
+
+                // Disable the `ONE_MATCH_PER_LINE` search option when finished, as
+                // this is not properly supported outside of vim mode, and
+                // not disabling it makes the "Replace All Matches" button
+                // actually replace only the first match on each line.
+                options.set(SearchOptions::ONE_MATCH_PER_LINE, false);
+                search_bar.set_search_options(options, cx);
             })
-            .detach_and_log_err(cx);
         })
+        .detach_and_log_err(cx);
     }
 }
 
@@ -593,16 +613,19 @@ impl Replacement {
         let mut replacement = Replacement {
             search,
             replacement,
-            should_replace_all: false,
-            is_case_sensitive: true,
+            case_sensitive: None,
+            flag_g: false,
+            flag_n: false,
+            flag_c: false,
         };
 
         for c in flags.chars() {
             match c {
-                'g' => replacement.should_replace_all = true,
-                'c' | 'n' => replacement.should_replace_all = false,
-                'i' => replacement.is_case_sensitive = false,
-                'I' => replacement.is_case_sensitive = true,
+                'g' => replacement.flag_g = true,
+                'n' => replacement.flag_n = true,
+                'c' => replacement.flag_c = true,
+                'i' => replacement.case_sensitive = Some(false),
+                'I' => replacement.case_sensitive = Some(true),
                 _ => {}
             }
         }
@@ -913,7 +936,6 @@ mod test {
         });
     }
 
-    // cargo test -p vim --features neovim test_replace_with_range_at_start
     #[gpui::test]
     async fn test_replace_with_range_at_start(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;
@@ -979,6 +1001,121 @@ mod test {
         });
     }
 
+    #[gpui::test]
+    async fn test_replace_n(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {
+            "ˇaa
+            bb
+            aa"
+        })
+        .await;
+
+        cx.simulate_shared_keystrokes(": s / b b / d d / n").await;
+        cx.simulate_shared_keystrokes("enter").await;
+
+        cx.shared_state().await.assert_eq(indoc! {
+            "ˇaa
+            bb
+            aa"
+        });
+
+        let search_bar = cx.update_workspace(|workspace, _, cx| {
+            workspace.active_pane().update(cx, |pane, cx| {
+                pane.toolbar()
+                    .read(cx)
+                    .item_of_type::<BufferSearchBar>()
+                    .unwrap()
+            })
+        });
+        cx.update_entity(search_bar, |search_bar, _, cx| {
+            assert!(!search_bar.is_dismissed());
+            assert_eq!(search_bar.query(cx), "bb".to_string());
+            assert_eq!(search_bar.replacement(cx), "dd".to_string());
+        })
+    }
+
+    #[gpui::test]
+    async fn test_replace_g(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {
+            "ˇaa aa aa aa
+            aa
+            aa"
+        })
+        .await;
+
+        cx.simulate_shared_keystrokes(": s / a a / b b").await;
+        cx.simulate_shared_keystrokes("enter").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "ˇbb aa aa aa
+            aa
+            aa"
+        });
+        cx.simulate_shared_keystrokes(": s / a a / b b / g").await;
+        cx.simulate_shared_keystrokes("enter").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "ˇbb bb bb bb
+            aa
+            aa"
+        });
+    }
+
+    #[gpui::test]
+    async fn test_replace_c(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.set_state(
+            indoc! {
+                "ˇaa
+            aa
+            aa"
+            },
+            Mode::Normal,
+        );
+
+        cx.simulate_keystrokes("v j : s / a a / d d / c");
+        cx.simulate_keystrokes("enter");
+
+        cx.assert_state(
+            indoc! {
+                "ˇaa
+            aa
+            aa"
+            },
+            Mode::Normal,
+        );
+
+        cx.simulate_keystrokes("enter");
+
+        cx.assert_state(
+            indoc! {
+                "dd
+            ˇaa
+            aa"
+            },
+            Mode::Normal,
+        );
+
+        cx.simulate_keystrokes("enter");
+        cx.assert_state(
+            indoc! {
+                "dd
+            ddˇ
+            aa"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("enter");
+        cx.assert_state(
+            indoc! {
+                "dd
+            ddˇ
+            aa"
+            },
+            Mode::Normal,
+        );
+    }
+
     #[gpui::test]
     async fn test_replace_with_range(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/test_data/test_replace_g.json 🔗

@@ -0,0 +1,23 @@
+{"Put":{"state":"ˇaa aa aa aa\naa\naa"}}
+{"Key":":"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"a"}
+{"Key":"/"}
+{"Key":"b"}
+{"Key":"b"}
+{"Key":"enter"}
+{"Get":{"state":"ˇbb aa aa aa\naa\naa","mode":"Normal"}}
+{"Key":":"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"a"}
+{"Key":"/"}
+{"Key":"b"}
+{"Key":"b"}
+{"Key":"/"}
+{"Key":"g"}
+{"Key":"enter"}
+{"Get":{"state":"ˇbb bb bb bb\naa\naa","mode":"Normal"}}

crates/vim/test_data/test_replace_n.json 🔗

@@ -0,0 +1,13 @@
+{"Put":{"state":"ˇaa\nbb\naa"}}
+{"Key":":"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"b"}
+{"Key":"b"}
+{"Key":"/"}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"/"}
+{"Key":"n"}
+{"Key":"enter"}
+{"Get":{"state":"ˇaa\nbb\naa","mode":"Normal"}}