support vim replace command with range (#10709)

Danny Hua created

Release Notes:

- Added support for line ranges in vim replace commands #9428


- not supporting anything other than bare line numbers right now
- ~need to figure out how to show range in question in search bar~
@ConradIrwin implemented showing a highlight of the selected range for a
short direction instead
- ~tests lol~

Change summary

crates/editor/src/editor.rs                       | 14 ++
crates/editor/src/items.rs                        | 45 ++++++-
crates/vim/src/command.rs                         | 10 +
crates/vim/src/normal/search.rs                   | 87 ++++++++++++++++
crates/vim/test_data/test_replace_with_range.json | 12 ++
crates/workspace/src/searchable.rs                |  4 
6 files changed, 160 insertions(+), 12 deletions(-)

Detailed changes

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

@@ -278,6 +278,8 @@ pub fn init(cx: &mut AppContext) {
     });
 }
 
+pub struct SearchWithinRange;
+
 trait InvalidationRegion {
     fn ranges(&self) -> &[Range<Anchor>];
 }
@@ -9264,6 +9266,18 @@ impl Editor {
             )
     }
 
+    pub fn set_search_within_ranges(
+        &mut self,
+        ranges: &[Range<Anchor>],
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.highlight_background::<SearchWithinRange>(
+            ranges,
+            |colors| colors.editor_document_highlight_read_background,
+            cx,
+        )
+    }
+
     pub fn highlight_background<T: 'static>(
         &mut self,
         ranges: &[Range<Anchor>],

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

@@ -1,7 +1,7 @@
 use crate::{
     editor_settings::SeedQuerySetting, persistence::DB, scroll::ScrollAnchor, Anchor, Autoscroll,
     Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot,
-    NavigationData, ToPoint as _,
+    NavigationData, SearchWithinRange, ToPoint as _,
 };
 use anyhow::{anyhow, Context as _, Result};
 use collections::HashSet;
@@ -16,12 +16,14 @@ use language::{
     proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, OffsetRangeExt,
     Point, SelectionGoal,
 };
+use multi_buffer::AnchorRangeExt;
 use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath};
 use rpc::proto::{self, update_view, PeerId};
 use settings::Settings;
 use workspace::item::{ItemSettings, TabContentParams};
 
 use std::{
+    any::TypeId,
     borrow::Cow,
     cmp::{self, Ordering},
     iter,
@@ -999,6 +1001,10 @@ impl SearchableItem for Editor {
         );
     }
 
+    fn has_filtered_search_ranges(&mut self) -> bool {
+        self.has_background_highlights::<SearchWithinRange>()
+    }
+
     fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
         let setting = EditorSettings::get_global(cx).seed_search_query_from_cursor;
         let snapshot = &self.snapshot(cx).buffer_snapshot;
@@ -1123,18 +1129,37 @@ impl SearchableItem for Editor {
         cx: &mut ViewContext<Self>,
     ) -> Task<Vec<Range<Anchor>>> {
         let buffer = self.buffer().read(cx).snapshot(cx);
+        let search_within_ranges = self
+            .background_highlights
+            .get(&TypeId::of::<SearchWithinRange>())
+            .map(|(_color, ranges)| {
+                ranges
+                    .iter()
+                    .map(|range| range.to_offset(&buffer))
+                    .collect::<Vec<_>>()
+            });
         cx.background_executor().spawn(async move {
             let mut ranges = Vec::new();
             if let Some((_, _, excerpt_buffer)) = buffer.as_singleton() {
-                ranges.extend(
-                    query
-                        .search(excerpt_buffer, None)
-                        .await
-                        .into_iter()
-                        .map(|range| {
-                            buffer.anchor_after(range.start)..buffer.anchor_before(range.end)
-                        }),
-                );
+                if let Some(search_within_ranges) = search_within_ranges {
+                    for range in search_within_ranges {
+                        let offset = range.start;
+                        ranges.extend(
+                            query
+                                .search(excerpt_buffer, Some(range))
+                                .await
+                                .into_iter()
+                                .map(|range| {
+                                    buffer.anchor_after(range.start + offset)
+                                        ..buffer.anchor_before(range.end + offset)
+                                }),
+                        );
+                    }
+                } else {
+                    ranges.extend(query.search(excerpt_buffer, None).await.into_iter().map(
+                        |range| buffer.anchor_after(range.start)..buffer.anchor_before(range.end),
+                    ));
+                }
             } else {
                 for excerpt in buffer.excerpt_boundaries_in_range(0..buffer.len()) {
                     let excerpt_range = excerpt.range.context.to_offset(&excerpt.buffer);

crates/vim/src/command.rs šŸ”—

@@ -8,7 +8,7 @@ use crate::{
     motion::{EndOfDocument, Motion, StartOfDocument},
     normal::{
         move_cursor,
-        search::{FindCommand, ReplaceCommand},
+        search::{range_regex, FindCommand, ReplaceCommand},
         JoinLines,
     },
     state::Mode,
@@ -340,6 +340,14 @@ pub fn command_interceptor(mut query: &str, cx: &AppContext) -> Option<CommandIn
                 )
             } else if let Ok(line) = query.parse::<u32>() {
                 (query, GoToLine { line }.boxed_clone())
+            } else if range_regex().is_match(query) {
+                (
+                    query,
+                    ReplaceCommand {
+                        query: query.to_string(),
+                    }
+                    .boxed_clone(),
+                )
             } else {
                 return None;
             }

crates/vim/src/normal/search.rs šŸ”—

@@ -1,4 +1,8 @@
+use std::{ops::Range, sync::OnceLock, time::Duration};
+
 use gpui::{actions, impl_actions, ViewContext};
+use language::Point;
+use regex::Regex;
 use search::{buffer_search, BufferSearchBar, SearchOptions};
 use serde_derive::Deserialize;
 use workspace::{searchable::Direction, Workspace};
@@ -47,6 +51,7 @@ struct Replacement {
     replacement: String,
     should_replace_all: bool,
     is_case_sensitive: bool,
+    range: Option<Range<usize>>,
 }
 
 actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]);
@@ -55,6 +60,11 @@ impl_actions!(
     [FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext]
 );
 
+static RANGE_REGEX: OnceLock<Regex> = OnceLock::new();
+pub(crate) fn range_regex() -> &'static Regex {
+    RANGE_REGEX.get_or_init(|| Regex::new(r"^(\d+),(\d+)s(.*)").unwrap())
+}
+
 pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
     workspace.register_action(move_to_next);
     workspace.register_action(move_to_prev);
@@ -329,6 +339,22 @@ fn replace_command(
 ) {
     let replacement = parse_replace_all(&action.query);
     let pane = workspace.active_pane().clone();
+    let mut editor = Vim::read(cx)
+        .active_editor
+        .as_ref()
+        .and_then(|editor| editor.upgrade());
+    if let Some(range) = &replacement.range {
+        if let Some(editor) = editor.as_mut() {
+            editor.update(cx, |editor, cx| {
+                let snapshot = &editor.snapshot(cx).buffer_snapshot;
+                let range = snapshot
+                    .anchor_before(Point::new(range.start.saturating_sub(1) as u32, 0))
+                    ..snapshot.anchor_before(Point::new(range.end as u32, 0));
+
+                editor.set_search_within_ranges(&[range], cx)
+            })
+        }
+    }
     pane.update(cx, |pane, cx| {
         let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
             return;
@@ -359,6 +385,19 @@ fn replace_command(
                 if replacement.should_replace_all {
                     search_bar.select_last_match(cx);
                     search_bar.replace_all(&Default::default(), cx);
+                    if let Some(editor) = editor {
+                        cx.spawn(|_, mut cx| async move {
+                            cx.background_executor()
+                                .timer(Duration::from_millis(200))
+                                .await;
+                            editor
+                                .update(&mut cx, |editor, cx| {
+                                    editor.set_search_within_ranges(&[], cx)
+                                })
+                                .ok();
+                        })
+                        .detach();
+                    }
                     Vim::update(cx, |vim, cx| {
                         move_cursor(
                             vim,
@@ -383,7 +422,20 @@ fn replace_command(
 // and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
 fn parse_replace_all(query: &str) -> Replacement {
     let mut chars = query.chars();
-    if Some('%') != chars.next() || Some('s') != chars.next() {
+    let mut range = None;
+    let maybe_line_range_and_rest: Option<(Range<usize>, &str)> =
+        range_regex().captures(query).map(|captures| {
+            (
+                captures.get(1).unwrap().as_str().parse().unwrap()
+                    ..captures.get(2).unwrap().as_str().parse().unwrap(),
+                captures.get(3).unwrap().as_str(),
+            )
+        });
+    if maybe_line_range_and_rest.is_some() {
+        let (line_range, rest) = maybe_line_range_and_rest.unwrap();
+        range = Some(line_range);
+        chars = rest.chars();
+    } else if Some('%') != chars.next() || Some('s') != chars.next() {
         return Replacement::default();
     }
 
@@ -440,6 +492,7 @@ fn parse_replace_all(query: &str) -> Replacement {
         replacement,
         should_replace_all: true,
         is_case_sensitive: true,
+        range,
     };
 
     for c in flags.chars() {
@@ -662,4 +715,36 @@ mod test {
         })
         .await;
     }
+
+    // cargo test -p vim --features neovim test_replace_with_range
+    #[gpui::test]
+    async fn test_replace_with_range(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {
+            "ˇa
+            a
+            a
+            a
+            a
+            a
+            a
+             "
+        })
+        .await;
+        cx.simulate_shared_keystrokes([":", "2", ",", "5", "s", "/", "a", "/", "b"])
+            .await;
+        cx.simulate_shared_keystrokes(["enter"]).await;
+        cx.assert_shared_state(indoc! {
+            "a
+            b
+            b
+            b
+            ˇb
+            a
+            a
+             "
+        })
+        .await;
+    }
 }

crates/vim/test_data/test_replace_with_range.json šŸ”—

@@ -0,0 +1,12 @@
+{"Put":{"state":"ˇa\na\na\na\na\na\na\n "}}
+{"Key":":"}
+{"Key":"2"}
+{"Key":","}
+{"Key":"5"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"/"}
+{"Key":"b"}
+{"Key":"enter"}
+{"Get":{"state":"a\nb\nb\nb\nˇb\na\na\n ","mode":"Normal"}}

crates/workspace/src/searchable.rs šŸ”—

@@ -57,6 +57,10 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
 
     fn search_bar_visibility_changed(&mut self, _visible: bool, _cx: &mut ViewContext<Self>) {}
 
+    fn has_filtered_search_ranges(&mut self) -> bool {
+        false
+    }
+
     fn clear_matches(&mut self, cx: &mut ViewContext<Self>);
     fn update_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext<Self>);
     fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String;