vim: Add support for :g/ and :v/ (#22177)

Conrad Irwin created

Closes #ISSUE

Still TODO to make this feature good is better command history

Release Notes:

- vim: Add support for `:g/<pattern>/<cmd>` and `:v/<pattern>/<cmd>`

Change summary

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 
crates/vim/test_data/test_command_matching_lines.json |  19 
5 files changed, 300 insertions(+), 8 deletions(-)

Detailed changes

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<Self>) {
+    pub fn start_transaction_at(&mut self, now: Instant, cx: &mut ViewContext<Self>) {
         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<Self>,

crates/editor/src/selections_collection.rs šŸ”—

@@ -391,7 +391,7 @@ impl SelectionsCollection {
         }
     }
 
-    pub(crate) fn change_with<R>(
+    pub fn change_with<R>(
         &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<DisplayPoint>,
+        find_replacement_cursors: impl FnOnce(&DisplaySnapshot) -> Vec<DisplayPoint>,
     ) {
         let display_map = self.display_map();
         let new_selections = find_replacement_cursors(&display_map)

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<dyn Action>);
 
 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>(_: D) -> Result<Self, D::Error>
@@ -204,6 +211,10 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
             });
         });
     });
+
+    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<CommandIn
         } else {
             None
         }
+    } else if query.starts_with('g') || query.starts_with('v') {
+        let mut global = "global".chars().peekable();
+        let mut query = query.chars().peekable();
+        let mut invert = false;
+        if query.peek() == Some(&'v') {
+            invert = true;
+            query.next();
+        }
+        while global.peek().is_some_and(|char| Some(char) == query.peek()) {
+            global.next();
+            query.next();
+        }
+        if !invert && query.peek() == Some(&'!') {
+            invert = true;
+            query.next();
+        }
+        let range = range.clone().unwrap_or(CommandRange {
+            start: Position::Line { row: 0, offset: 0 },
+            end: Some(Position::LastLine { offset: 0 }),
+        });
+        if let Some(action) = OnMatchingLines::parse(query, invert, range, cx) {
+            Some(action.boxed_clone())
+        } else {
+            None
+        }
     } else {
         None
     };
@@ -839,6 +875,193 @@ fn generate_positions(string: &str, query: &str) -> Vec<usize> {
     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<Chars>,
+        invert: bool,
+        range: CommandRange,
+        cx: &AppContext,
+    ) -> Option<Self> {
+        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<Vim>) {
+        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::<OnMatchingLines>()
+        {
+            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::<BufferSearchBar>()
+                {
+                    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::<Point>(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"});
+    }
 }

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;

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"}}