From 4ed206b37ce2b1713aa20361dfc74bda4a59b11c Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 9 Jul 2025 00:14:04 -0600 Subject: [PATCH] vim: Implement /n and /c in :s (#34102) Closes #23345 Release Notes: - vim: Support /n and /c in :s// --- 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 | 271 +++++++++++++++++------ crates/vim/test_data/test_replace_g.json | 23 ++ crates/vim/test_data/test_replace_n.json | 13 ++ 6 files changed, 263 insertions(+), 81 deletions(-) create mode 100644 crates/vim/test_data/test_replace_g.json create mode 100644 crates/vim/test_data/test_replace_n.json diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index fa6bd93ab8..2e4631a62b 100644 --- a/crates/editor/src/items.rs +++ b/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 = None; for m in matches { - let point = m.start.to_point(&text); let text = text.text_for_range(m.clone()).collect::>(); - // 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 { diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index d3585115f5..44732b23cd 100644 --- a/crates/project/src/search.rs +++ b/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; diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 35c8fcd230..c2590ec9b0 100644 --- a/crates/search/src/buffer_search.rs +++ b/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.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) { + 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) { if let Some(searchable_item) = self.active_searchable_item.as_ref() { if let Some(matches) = self diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 182e60e56c..24f2cf751f 100644 --- a/crates/vim/src/normal/search.rs +++ b/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, + 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::() + }) 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::() 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 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) + } - if !replacement.should_replace_all { - options.set(SearchOptions::ONE_MATCH_PER_LINE, true); + if !replacement.flag_g { + options.set(SearchOptions::ONE_MATCH_PER_LINE, true); + } + + 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(); - 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, - ) - }); - - // 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(()) + // 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::() + .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; diff --git a/crates/vim/test_data/test_replace_g.json b/crates/vim/test_data/test_replace_g.json new file mode 100644 index 0000000000..583d1f89bc --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_replace_n.json b/crates/vim/test_data/test_replace_n.json new file mode 100644 index 0000000000..a03c69e9b2 --- /dev/null +++ b/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"}}