From 4a6f071fde1016257f21f4ddff3d830a06407418 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 18 Dec 2024 08:28:42 -0700 Subject: [PATCH] vim: Add support for :g/ and :v/ (#22177) Closes #ISSUE Still TODO to make this feature good is better command history Release Notes: - vim: Add support for `:g//` and `:v//` --- crates/editor/src/editor.rs | 4 +- crates/editor/src/selections_collection.rs | 4 +- crates/vim/src/command.rs | 269 +++++++++++++++++- crates/vim/src/visual.rs | 12 +- .../test_command_matching_lines.json | 19 ++ 5 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 crates/vim/test_data/test_command_matching_lines.json diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8eb18f6c3b..b5448b12d9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10413,7 +10413,7 @@ impl Editor { self.end_transaction_at(Instant::now(), cx) } - fn start_transaction_at(&mut self, now: Instant, cx: &mut ViewContext) { + pub fn start_transaction_at(&mut self, now: Instant, cx: &mut ViewContext) { self.end_selection(cx); if let Some(tx_id) = self .buffer @@ -10427,7 +10427,7 @@ impl Editor { } } - fn end_transaction_at( + pub fn end_transaction_at( &mut self, now: Instant, cx: &mut ViewContext, diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 1edfc6a4fb..b79f9d44ff 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -391,7 +391,7 @@ impl SelectionsCollection { } } - pub(crate) fn change_with( + pub fn change_with( &mut self, cx: &mut AppContext, change: impl FnOnce(&mut MutableSelectionsCollection) -> R, @@ -764,7 +764,7 @@ impl<'a> MutableSelectionsCollection<'a> { pub fn replace_cursors_with( &mut self, - mut find_replacement_cursors: impl FnMut(&DisplaySnapshot) -> Vec, + find_replacement_cursors: impl FnOnce(&DisplaySnapshot) -> Vec, ) { let display_map = self.display_map(); let new_selections = find_replacement_cursors(&display_map) diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 68aefc8cd7..660a2a161f 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -3,17 +3,21 @@ use std::{ ops::{Deref, Range}, str::Chars, sync::OnceLock, + time::Instant, }; use anyhow::{anyhow, Result}; use command_palette_hooks::CommandInterceptResult; use editor::{ actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive}, - Editor, ToPoint, + display_map::ToDisplayPoint, + Bias, Editor, ToPoint, }; use gpui::{actions, impl_actions, Action, AppContext, Global, ViewContext}; use language::Point; use multi_buffer::MultiBufferRow; +use regex::Regex; +use search::{BufferSearchBar, SearchOptions}; use serde::Deserialize; use ui::WindowContext; use util::ResultExt; @@ -57,7 +61,10 @@ pub struct WithCount { struct WrappedAction(Box); actions!(vim, [VisualCommand, CountCommand]); -impl_actions!(vim, [GoToLine, YankCommand, WithRange, WithCount]); +impl_actions!( + vim, + [GoToLine, YankCommand, WithRange, WithCount, OnMatchingLines] +); impl<'de> Deserialize<'de> for WrappedAction { fn deserialize(_: D) -> Result @@ -204,6 +211,10 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { }); }); }); + + Vim::action(editor, cx, |vim, action: &OnMatchingLines, cx| { + action.run(vim, cx) + }) } #[derive(Default)] @@ -786,6 +797,31 @@ pub fn command_interceptor(mut input: &str, cx: &AppContext) -> Option Vec { positions } +#[derive(Debug, PartialEq, Deserialize, Clone)] +pub(crate) struct OnMatchingLines { + range: CommandRange, + search: String, + action: WrappedAction, + invert: bool, +} + +impl OnMatchingLines { + // convert a vim query into something more usable by zed. + // we don't attempt to fully convert between the two regex syntaxes, + // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern, + // and convert \0..\9 to $0..$9 in the replacement so that common idioms work. + pub(crate) fn parse( + mut chars: Peekable, + invert: bool, + range: CommandRange, + cx: &AppContext, + ) -> Option { + let delimiter = chars.next().filter(|c| { + !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!' + })?; + + let mut search = String::new(); + let mut escaped = false; + + while let Some(c) = chars.next() { + if escaped { + escaped = false; + // unescape escaped parens + if c != '(' && c != ')' && c != delimiter { + search.push('\\') + } + search.push(c) + } else if c == '\\' { + escaped = true; + } else if c == delimiter { + break; + } else { + // escape unescaped parens + if c == '(' || c == ')' { + search.push('\\') + } + search.push(c) + } + } + + let command: String = chars.collect(); + + let action = WrappedAction(command_interceptor(&command, cx)?.action); + + Some(Self { + range, + search, + invert, + action, + }) + } + + pub fn run(&self, vim: &mut Vim, cx: &mut ViewContext) { + let result = vim.update_editor(cx, |vim, editor, cx| { + self.range.buffer_range(vim, editor, cx) + }); + + let range = match result { + None => return, + Some(e @ Err(_)) => { + let Some(workspace) = vim.workspace(cx) else { + return; + }; + workspace.update(cx, |workspace, cx| { + e.notify_err(workspace, cx); + }); + return; + } + Some(Ok(result)) => result, + }; + + let mut action = self.action.boxed_clone(); + let mut last_pattern = self.search.clone(); + + let mut regexes = match Regex::new(&self.search) { + Ok(regex) => vec![(regex, !self.invert)], + e @ Err(_) => { + let Some(workspace) = vim.workspace(cx) else { + return; + }; + workspace.update(cx, |workspace, cx| { + e.notify_err(workspace, cx); + }); + return; + } + }; + while let Some(inner) = action + .boxed_clone() + .as_any() + .downcast_ref::() + { + let Some(regex) = Regex::new(&inner.search).ok() else { + break; + }; + last_pattern = inner.search.clone(); + action = inner.action.boxed_clone(); + regexes.push((regex, !inner.invert)) + } + + if let Some(pane) = vim.pane(cx) { + pane.update(cx, |pane, cx| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() + { + search_bar.update(cx, |search_bar, cx| { + if search_bar.show(cx) { + let _ = search_bar.search( + &last_pattern, + Some(SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE), + cx, + ); + } + }); + } + }); + }; + + vim.update_editor(cx, |_, editor, cx| { + let snapshot = editor.snapshot(cx); + let mut row = range.start.0; + + let point_range = Point::new(range.start.0, 0) + ..snapshot + .buffer_snapshot + .clip_point(Point::new(range.end.0 + 1, 0), Bias::Left); + cx.spawn(|editor, mut cx| async move { + let new_selections = cx + .background_executor() + .spawn(async move { + let mut line = String::new(); + let mut new_selections = Vec::new(); + let chunks = snapshot + .buffer_snapshot + .text_for_range(point_range) + .chain(["\n"]); + + for chunk in chunks { + for (newline_ix, text) in chunk.split('\n').enumerate() { + if newline_ix > 0 { + if regexes.iter().all(|(regex, should_match)| { + regex.is_match(&line) == *should_match + }) { + new_selections + .push(Point::new(row, 0).to_display_point(&snapshot)) + } + row += 1; + line.clear(); + } + line.push_str(text) + } + } + + new_selections + }) + .await; + + if new_selections.is_empty() { + return; + } + editor + .update(&mut cx, |editor, cx| { + editor.start_transaction_at(Instant::now(), cx); + editor.change_selections(None, cx, |s| { + s.replace_cursors_with(|_| new_selections); + }); + cx.dispatch_action(action); + cx.defer(move |editor, cx| { + let newest = editor.selections.newest::(cx).clone(); + editor.change_selections(None, cx, |s| { + s.select(vec![newest]); + }); + editor.end_transaction_at(Instant::now(), cx); + }) + }) + .ok(); + }) + .detach(); + }); + } +} + #[cfg(test)] mod test { use std::path::Path; @@ -1109,4 +1332,46 @@ mod test { assert_active_item(workspace, "/root/dir/file3.rs", "go to file3", cx); }); } + + #[gpui::test] + async fn test_command_matching_lines(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇa + b + a + b + a + "}) + .await; + + cx.simulate_shared_keystrokes(":").await; + cx.simulate_shared_keystrokes("g / a / d").await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + b + b + ˇ"}); + + cx.simulate_shared_keystrokes("u").await; + + cx.shared_state().await.assert_eq(indoc! {" + ˇa + b + a + b + a + "}); + + cx.simulate_shared_keystrokes(":").await; + cx.simulate_shared_keystrokes("v / a / d").await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + a + a + ˇa"}); + } } diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index b062d07ad9..d7d34febf8 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use collections::HashMap; use editor::{ - display_map::{DisplaySnapshot, ToDisplayPoint}, + display_map::{DisplayRow, DisplaySnapshot, ToDisplayPoint}, movement, scroll::Autoscroll, Bias, DisplayPoint, Editor, ToOffset, @@ -463,8 +463,16 @@ impl Vim { *selection.end.column_mut() = map.line_len(selection.end.row()) } else if vim.mode != Mode::VisualLine { selection.start = DisplayPoint::new(selection.start.row(), 0); + selection.end = + map.next_line_boundary(selection.end.to_point(map)).1; if selection.end.row() == map.max_point().row() { - selection.end = map.max_point() + selection.end = map.max_point(); + if selection.start == selection.end { + let prev_row = + DisplayRow(selection.start.row().0.saturating_sub(1)); + selection.start = + DisplayPoint::new(prev_row, map.line_len(prev_row)); + } } else { *selection.end.row_mut() += 1; *selection.end.column_mut() = 0; diff --git a/crates/vim/test_data/test_command_matching_lines.json b/crates/vim/test_data/test_command_matching_lines.json new file mode 100644 index 0000000000..450aae0de0 --- /dev/null +++ b/crates/vim/test_data/test_command_matching_lines.json @@ -0,0 +1,19 @@ +{"Put":{"state":"ˇa\nb\na\nb\na\n"}} +{"Key":":"} +{"Key":"g"} +{"Key":"/"} +{"Key":"a"} +{"Key":"/"} +{"Key":"d"} +{"Key":"enter"} +{"Get":{"state":"b\nb\nˇ","mode":"Normal"}} +{"Key":"u"} +{"Get":{"state":"ˇa\nb\na\nb\na\n","mode":"Normal"}} +{"Key":":"} +{"Key":"v"} +{"Key":"/"} +{"Key":"a"} +{"Key":"/"} +{"Key":"d"} +{"Key":"enter"} +{"Get":{"state":"a\na\nˇa","mode":"Normal"}}