vim: Support ranges in command (#15985)

Conrad Irwin created

The most requested feature here is "search and replace in visual mode",
but as a happy side effect we can now support things like :2,12j to join
those lines, and much much more.



Release Notes:

- vim: Added support for range syntax in command
([#9428](https://github.com/zed-industries/zed/issues/9428)).
- vim: Prefill command with `:'<,'>` from visual mode
([#13535](https://github.com/zed-industries/zed/issues/13535)).

Change summary

assets/keymaps/vim.json                               |  16 
crates/command_palette/src/command_palette.rs         |  30 
crates/editor/src/selections_collection.rs            |   2 
crates/vim/Cargo.toml                                 |   1 
crates/vim/src/command.rs                             | 569 +++++++++++-
crates/vim/src/editor_events.rs                       |   1 
crates/vim/src/normal/search.rs                       | 190 +--
crates/vim/test_data/test_command_ranges.json         |  28 
crates/vim/test_data/test_command_visual_replace.json |  12 
crates/vim/test_data/test_offsets.json                |  21 
10 files changed, 670 insertions(+), 200 deletions(-)

Detailed changes

assets/keymaps/vim.json šŸ”—

@@ -4,7 +4,6 @@
     "bindings": {
       "i": ["vim::PushOperator", { "Object": { "around": false } }],
       "a": ["vim::PushOperator", { "Object": { "around": true } }],
-      ":": "command_palette::Toggle",
       "h": "vim::Left",
       "left": "vim::Left",
       "backspace": "vim::Backspace",
@@ -199,17 +198,12 @@
       "ctrl-6": "pane::AlternateFile"
     }
   },
-  {
-    "context": "VimControl && VimCount",
-    "bindings": {
-      "0": ["vim::Number", 0]
-    }
-  },
   {
     "context": "vim_mode == normal",
     "bindings": {
       "escape": "editor::Cancel",
       "ctrl-[": "editor::Cancel",
+      ":": "command_palette::Toggle",
       ".": "vim::Repeat",
       "c": ["vim::PushOperator", "Change"],
       "shift-c": "vim::ChangeToEndOfLine",
@@ -257,9 +251,17 @@
       "g c": ["vim::PushOperator", "ToggleComments"]
     }
   },
+  {
+    "context": "VimControl && VimCount",
+    "bindings": {
+      "0": ["vim::Number", 0],
+      ":": "vim::CountCommand"
+    }
+  },
   {
     "context": "vim_mode == visual",
     "bindings": {
+      ":": "vim::VisualCommand",
       "u": "vim::ConvertToLowerCase",
       "U": "vim::ConvertToUpperCase",
       "o": "vim::OtherEnd",

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

@@ -58,20 +58,23 @@ fn trim_consecutive_whitespaces(input: &str) -> String {
 
 impl CommandPalette {
     fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
-        workspace.register_action(|workspace, _: &Toggle, cx| {
-            let Some(previous_focus_handle) = cx.focused() else {
-                return;
-            };
-            let telemetry = workspace.client().telemetry().clone();
-            workspace.toggle_modal(cx, move |cx| {
-                CommandPalette::new(previous_focus_handle, telemetry, cx)
-            });
+        workspace.register_action(|workspace, _: &Toggle, cx| Self::toggle(workspace, "", cx));
+    }
+
+    pub fn toggle(workspace: &mut Workspace, query: &str, cx: &mut ViewContext<Workspace>) {
+        let Some(previous_focus_handle) = cx.focused() else {
+            return;
+        };
+        let telemetry = workspace.client().telemetry().clone();
+        workspace.toggle_modal(cx, move |cx| {
+            CommandPalette::new(previous_focus_handle, telemetry, query, cx)
         });
     }
 
     fn new(
         previous_focus_handle: FocusHandle,
         telemetry: Arc<Telemetry>,
+        query: &str,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let filter = CommandPaletteFilter::try_global(cx);
@@ -98,9 +101,18 @@ impl CommandPalette {
             previous_focus_handle,
         );
 
-        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
+        let picker = cx.new_view(|cx| {
+            let picker = Picker::uniform_list(delegate, cx);
+            picker.set_query(query, cx);
+            picker
+        });
         Self { picker }
     }
+
+    pub fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
+        self.picker
+            .update(cx, |picker, cx| picker.set_query(query, cx))
+    }
 }
 
 impl EventEmitter<DismissEvent> for CommandPalette {}

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

@@ -393,7 +393,7 @@ impl<'a> MutableSelectionsCollection<'a> {
         self.collection.display_map(self.cx)
     }
 
-    fn buffer(&self) -> Ref<MultiBufferSnapshot> {
+    pub fn buffer(&self) -> Ref<MultiBufferSnapshot> {
         self.collection.buffer(self.cx)
     }
 

crates/vim/Cargo.toml šŸ”—

@@ -20,6 +20,7 @@ anyhow.workspace = true
 async-compat = { version = "0.2.1", "optional" = true }
 async-trait = { workspace = true, "optional" = true }
 collections.workspace = true
+command_palette.workspace = true
 command_palette_hooks.workspace = true
 editor.workspace = true
 gpui.workspace = true

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

@@ -1,45 +1,154 @@
-use std::sync::OnceLock;
+use std::{iter::Peekable, ops::Range, str::Chars, sync::OnceLock};
 
+use anyhow::{anyhow, Result};
 use command_palette_hooks::CommandInterceptResult;
-use editor::actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive};
-use gpui::{impl_actions, Action, AppContext, Global, ViewContext};
-use serde_derive::Deserialize;
+use editor::{
+    actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
+    Editor, ToPoint,
+};
+use gpui::{actions, impl_actions, Action, AppContext, Global, ViewContext};
+use language::Point;
+use multi_buffer::MultiBufferRow;
+use serde::Deserialize;
+use ui::WindowContext;
 use util::ResultExt;
-use workspace::{SaveIntent, Workspace};
+use workspace::{notifications::NotifyResultExt, SaveIntent, Workspace};
 
 use crate::{
     motion::{EndOfDocument, Motion, StartOfDocument},
     normal::{
         move_cursor,
-        search::{range_regex, FindCommand, ReplaceCommand},
+        search::{FindCommand, ReplaceCommand, Replacement},
         JoinLines,
     },
     state::Mode,
+    visual::VisualDeleteLine,
     Vim,
 };
 
 #[derive(Debug, Clone, PartialEq, Deserialize)]
 pub struct GoToLine {
-    pub line: u32,
+    range: CommandRange,
+}
+
+#[derive(Debug)]
+pub struct WithRange {
+    is_count: bool,
+    range: CommandRange,
+    action: Box<dyn Action>,
+}
+
+actions!(vim, [VisualCommand, CountCommand]);
+impl_actions!(vim, [GoToLine, WithRange]);
+
+impl<'de> Deserialize<'de> for WithRange {
+    fn deserialize<D>(_: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        Err(serde::de::Error::custom("Cannot deserialize WithRange"))
+    }
+}
+
+impl PartialEq for WithRange {
+    fn eq(&self, other: &Self) -> bool {
+        self.range == other.range && self.action.partial_eq(&*other.action)
+    }
 }
 
-impl_actions!(vim, [GoToLine]);
+impl Clone for WithRange {
+    fn clone(&self) -> Self {
+        Self {
+            is_count: self.is_count,
+            range: self.range.clone(),
+            action: self.action.boxed_clone(),
+        }
+    }
+}
 
 pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
-    workspace.register_action(|_: &mut Workspace, action: &GoToLine, cx| {
+    workspace.register_action(|workspace, _: &VisualCommand, cx| {
+        command_palette::CommandPalette::toggle(workspace, "'<,'>", cx);
+    });
+
+    workspace.register_action(|workspace, _: &CountCommand, cx| {
+        let count = Vim::update(cx, |vim, cx| vim.take_count(cx)).unwrap_or(1);
+        command_palette::CommandPalette::toggle(
+            workspace,
+            &format!(".,.+{}", count.saturating_sub(1)),
+            cx,
+        );
+    });
+
+    workspace.register_action(|workspace: &mut Workspace, action: &GoToLine, cx| {
         Vim::update(cx, |vim, cx| {
             vim.switch_mode(Mode::Normal, false, cx);
-            move_cursor(vim, Motion::StartOfDocument, Some(action.line as usize), cx);
-        });
+            let result = vim.update_active_editor(cx, |vim, editor, cx| {
+                action.range.head().buffer_row(vim, editor, cx)
+            });
+            let Some(buffer_row) = result else {
+                return anyhow::Ok(());
+            };
+            move_cursor(
+                vim,
+                Motion::StartOfDocument,
+                Some(buffer_row?.0 as usize + 1),
+                cx,
+            );
+            Ok(())
+        })
+        .notify_err(workspace, cx);
+    });
+
+    workspace.register_action(|workspace: &mut Workspace, action: &WithRange, cx| {
+        if action.is_count {
+            for _ in 0..action.range.as_count() {
+                cx.dispatch_action(action.action.boxed_clone())
+            }
+        } else {
+            Vim::update(cx, |vim, cx| {
+                let result = vim.update_active_editor(cx, |vim, editor, cx| {
+                    action.range.buffer_range(vim, editor, cx)
+                });
+                let Some(range) = result else {
+                    return anyhow::Ok(());
+                };
+                let range = range?;
+                vim.update_active_editor(cx, |_, editor, cx| {
+                    editor.change_selections(None, cx, |s| {
+                        let end = Point::new(range.end.0, s.buffer().line_len(range.end));
+                        s.select_ranges([end..Point::new(range.start.0, 0)]);
+                    })
+                });
+                cx.dispatch_action(action.action.boxed_clone());
+                cx.defer(move |cx| {
+                    Vim::update(cx, |vim, cx| {
+                        vim.update_active_editor(cx, |_, editor, cx| {
+                            editor.change_selections(None, cx, |s| {
+                                s.select_ranges([
+                                    Point::new(range.start.0, 0)..Point::new(range.start.0, 0)
+                                ]);
+                            })
+                        });
+                    })
+                });
+
+                Ok(())
+            })
+            .notify_err(workspace, cx);
+        }
     });
 }
 
+#[derive(Debug, Default)]
 struct VimCommand {
     prefix: &'static str,
     suffix: &'static str,
     action: Option<Box<dyn Action>>,
     action_name: Option<&'static str>,
     bang_action: Option<Box<dyn Action>>,
+    has_range: bool,
+    has_count: bool,
 }
 
 impl VimCommand {
@@ -48,8 +157,7 @@ impl VimCommand {
             prefix: pattern.0,
             suffix: pattern.1,
             action: Some(action.boxed_clone()),
-            action_name: None,
-            bang_action: None,
+            ..Default::default()
         }
     }
 
@@ -58,9 +166,8 @@ impl VimCommand {
         Self {
             prefix: pattern.0,
             suffix: pattern.1,
-            action: None,
             action_name: Some(action_name),
-            bang_action: None,
+            ..Default::default()
         }
     }
 
@@ -69,6 +176,15 @@ impl VimCommand {
         self
     }
 
+    fn range(mut self) -> Self {
+        self.has_range = true;
+        self
+    }
+    fn count(mut self) -> Self {
+        self.has_count = true;
+        self
+    }
+
     fn parse(&self, mut query: &str, cx: &AppContext) -> Option<Box<dyn Action>> {
         let has_bang = query.ends_with('!');
         if has_bang {
@@ -92,6 +208,220 @@ impl VimCommand {
             None
         }
     }
+
+    // TODO: ranges with search queries
+    fn parse_range(query: &str) -> (Option<CommandRange>, String) {
+        let mut chars = query.chars().peekable();
+
+        match chars.peek() {
+            Some('%') => {
+                chars.next();
+                return (
+                    Some(CommandRange {
+                        start: Position::Line { row: 1, offset: 0 },
+                        end: Some(Position::LastLine { offset: 0 }),
+                    }),
+                    chars.collect(),
+                );
+            }
+            Some('*') => {
+                chars.next();
+                return (
+                    Some(CommandRange {
+                        start: Position::Mark {
+                            name: '<',
+                            offset: 0,
+                        },
+                        end: Some(Position::Mark {
+                            name: '>',
+                            offset: 0,
+                        }),
+                    }),
+                    chars.collect(),
+                );
+            }
+            _ => {}
+        }
+
+        let start = Self::parse_position(&mut chars);
+
+        match chars.peek() {
+            Some(',' | ';') => {
+                chars.next();
+                (
+                    Some(CommandRange {
+                        start: start.unwrap_or(Position::CurrentLine { offset: 0 }),
+                        end: Self::parse_position(&mut chars),
+                    }),
+                    chars.collect(),
+                )
+            }
+            _ => (
+                start.map(|start| CommandRange { start, end: None }),
+                chars.collect(),
+            ),
+        }
+    }
+
+    fn parse_position(chars: &mut Peekable<Chars>) -> Option<Position> {
+        match chars.peek()? {
+            '0'..='9' => {
+                let row = Self::parse_u32(chars);
+                Some(Position::Line {
+                    row,
+                    offset: Self::parse_offset(chars),
+                })
+            }
+            '\'' => {
+                chars.next();
+                let name = chars.next()?;
+                Some(Position::Mark {
+                    name,
+                    offset: Self::parse_offset(chars),
+                })
+            }
+            '.' => {
+                chars.next();
+                Some(Position::CurrentLine {
+                    offset: Self::parse_offset(chars),
+                })
+            }
+            '+' | '-' => Some(Position::CurrentLine {
+                offset: Self::parse_offset(chars),
+            }),
+            '$' => {
+                chars.next();
+                Some(Position::LastLine {
+                    offset: Self::parse_offset(chars),
+                })
+            }
+            _ => None,
+        }
+    }
+
+    fn parse_offset(chars: &mut Peekable<Chars>) -> i32 {
+        let mut res: i32 = 0;
+        while matches!(chars.peek(), Some('+' | '-')) {
+            let sign = if chars.next().unwrap() == '+' { 1 } else { -1 };
+            let amount = if matches!(chars.peek(), Some('0'..='9')) {
+                (Self::parse_u32(chars) as i32).saturating_mul(sign)
+            } else {
+                sign
+            };
+            res = res.saturating_add(amount)
+        }
+        res
+    }
+
+    fn parse_u32(chars: &mut Peekable<Chars>) -> u32 {
+        let mut res: u32 = 0;
+        while matches!(chars.peek(), Some('0'..='9')) {
+            res = res
+                .saturating_mul(10)
+                .saturating_add(chars.next().unwrap() as u32 - '0' as u32);
+        }
+        res
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Deserialize)]
+enum Position {
+    Line { row: u32, offset: i32 },
+    Mark { name: char, offset: i32 },
+    LastLine { offset: i32 },
+    CurrentLine { offset: i32 },
+}
+
+impl Position {
+    fn buffer_row(
+        &self,
+        vim: &Vim,
+        editor: &mut Editor,
+        cx: &mut WindowContext,
+    ) -> Result<MultiBufferRow> {
+        let snapshot = editor.snapshot(cx);
+        let target = match self {
+            Position::Line { row, offset } => row.saturating_add_signed(offset.saturating_sub(1)),
+            Position::Mark { name, offset } => {
+                let Some(mark) = vim
+                    .state()
+                    .marks
+                    .get(&name.to_string())
+                    .and_then(|vec| vec.last())
+                else {
+                    return Err(anyhow!("mark {} not set", name));
+                };
+                mark.to_point(&snapshot.buffer_snapshot)
+                    .row
+                    .saturating_add_signed(*offset)
+            }
+            Position::LastLine { offset } => {
+                snapshot.max_buffer_row().0.saturating_add_signed(*offset)
+            }
+            Position::CurrentLine { offset } => editor
+                .selections
+                .newest_anchor()
+                .head()
+                .to_point(&snapshot.buffer_snapshot)
+                .row
+                .saturating_add_signed(*offset),
+        };
+
+        Ok(MultiBufferRow(target).min(snapshot.max_buffer_row()))
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Deserialize)]
+pub(crate) struct CommandRange {
+    start: Position,
+    end: Option<Position>,
+}
+
+impl CommandRange {
+    fn head(&self) -> &Position {
+        self.end.as_ref().unwrap_or(&self.start)
+    }
+
+    pub(crate) fn buffer_range(
+        &self,
+        vim: &Vim,
+        editor: &mut Editor,
+        cx: &mut WindowContext,
+    ) -> Result<Range<MultiBufferRow>> {
+        let start = self.start.buffer_row(vim, editor, cx)?;
+        let end = if let Some(end) = self.end.as_ref() {
+            end.buffer_row(vim, editor, cx)?
+        } else {
+            start
+        };
+        if end < start {
+            anyhow::Ok(end..start)
+        } else {
+            anyhow::Ok(start..end)
+        }
+    }
+
+    pub fn as_count(&self) -> u32 {
+        if let CommandRange {
+            start: Position::Line { row, offset: 0 },
+            end: None,
+        } = &self
+        {
+            *row
+        } else {
+            0
+        }
+    }
+
+    pub fn is_count(&self) -> bool {
+        matches!(
+            &self,
+            CommandRange {
+                start: Position::Line { row: _, offset: 0 },
+                end: None
+            }
+        )
+    }
 }
 
 fn generate_commands(_: &AppContext) -> Vec<VimCommand> {
@@ -204,9 +534,9 @@ fn generate_commands(_: &AppContext) -> Vec<VimCommand> {
         .bang(workspace::CloseActiveItem {
             save_intent: Some(SaveIntent::Skip),
         }),
-        VimCommand::new(("bn", "ext"), workspace::ActivateNextItem),
-        VimCommand::new(("bN", "ext"), workspace::ActivatePrevItem),
-        VimCommand::new(("bp", "revious"), workspace::ActivatePrevItem),
+        VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
+        VimCommand::new(("bN", "ext"), workspace::ActivatePrevItem).count(),
+        VimCommand::new(("bp", "revious"), workspace::ActivatePrevItem).count(),
         VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
         VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
         VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
@@ -220,9 +550,9 @@ fn generate_commands(_: &AppContext) -> Vec<VimCommand> {
         ),
         VimCommand::new(("tabe", "dit"), workspace::NewFile),
         VimCommand::new(("tabnew", ""), workspace::NewFile),
-        VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem),
-        VimCommand::new(("tabp", "revious"), workspace::ActivatePrevItem),
-        VimCommand::new(("tabN", "ext"), workspace::ActivatePrevItem),
+        VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
+        VimCommand::new(("tabp", "revious"), workspace::ActivatePrevItem).count(),
+        VimCommand::new(("tabN", "ext"), workspace::ActivatePrevItem).count(),
         VimCommand::new(
             ("tabc", "lose"),
             workspace::CloseActiveItem {
@@ -250,15 +580,15 @@ fn generate_commands(_: &AppContext) -> Vec<VimCommand> {
         VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
         VimCommand::new(("cc", ""), editor::actions::Hover),
         VimCommand::new(("ll", ""), editor::actions::Hover),
-        VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic),
-        VimCommand::new(("cp", "revious"), editor::actions::GoToPrevDiagnostic),
-        VimCommand::new(("cN", "ext"), editor::actions::GoToPrevDiagnostic),
-        VimCommand::new(("lp", "revious"), editor::actions::GoToPrevDiagnostic),
-        VimCommand::new(("lN", "ext"), editor::actions::GoToPrevDiagnostic),
-        VimCommand::new(("j", "oin"), JoinLines),
-        VimCommand::new(("d", "elete"), editor::actions::DeleteLine),
-        VimCommand::new(("sor", "t"), SortLinesCaseSensitive),
-        VimCommand::new(("sort i", ""), SortLinesCaseInsensitive),
+        VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic).count(),
+        VimCommand::new(("cp", "revious"), editor::actions::GoToPrevDiagnostic).count(),
+        VimCommand::new(("cN", "ext"), editor::actions::GoToPrevDiagnostic).count(),
+        VimCommand::new(("lp", "revious"), editor::actions::GoToPrevDiagnostic).count(),
+        VimCommand::new(("lN", "ext"), editor::actions::GoToPrevDiagnostic).count(),
+        VimCommand::new(("j", "oin"), JoinLines).range(),
+        VimCommand::new(("d", "elete"), VisualDeleteLine).range(),
+        VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(),
+        VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(),
         VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
         VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
         VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
@@ -289,69 +619,95 @@ fn commands(cx: &AppContext) -> &Vec<VimCommand> {
         .0
 }
 
-pub fn command_interceptor(mut query: &str, cx: &AppContext) -> Option<CommandInterceptResult> {
-    // Note: this is a very poor simulation of vim's command palette.
-    // In the future we should adjust it to handle parsing range syntax,
-    // and then calling the appropriate commands with/without ranges.
-    //
-    // We also need to support passing arguments to commands like :w
+pub fn command_interceptor(mut input: &str, cx: &AppContext) -> Option<CommandInterceptResult> {
+    // NOTE: We also need to support passing arguments to commands like :w
     // (ideally with filename autocompletion).
-    while query.starts_with(':') {
-        query = &query[1..];
+    while input.starts_with(':') {
+        input = &input[1..];
     }
 
-    for command in commands(cx).iter() {
-        if let Some(action) = command.parse(query, cx) {
-            let string = ":".to_owned() + command.prefix + command.suffix;
-            let positions = generate_positions(&string, query);
-
-            return Some(CommandInterceptResult {
-                action,
-                string,
-                positions,
-            });
-        }
-    }
+    let (range, query) = VimCommand::parse_range(input);
+    let range_prefix = input[0..(input.len() - query.len())].to_string();
+    let query = query.as_str();
 
-    let (name, action) = if query.starts_with('/') || query.starts_with('?') {
-        (
-            query,
-            FindCommand {
-                query: query[1..].to_string(),
-                backwards: query.starts_with('?'),
+    let action = if range.is_some() && query == "" {
+        Some(
+            GoToLine {
+                range: range.clone().unwrap(),
             }
             .boxed_clone(),
         )
-    } else if query.starts_with('%') {
-        (
-            query,
-            ReplaceCommand {
-                query: query.to_string(),
-            }
-            .boxed_clone(),
-        )
-    } 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(),
+    } else if query.starts_with('/') || query.starts_with('?') {
+        Some(
+            FindCommand {
+                query: query[1..].to_string(),
+                backwards: query.starts_with('?'),
             }
             .boxed_clone(),
         )
+    } else if query.starts_with('s') {
+        let mut substitute = "substitute".chars().peekable();
+        let mut query = query.chars().peekable();
+        while substitute
+            .peek()
+            .is_some_and(|char| Some(char) == query.peek())
+        {
+            substitute.next();
+            query.next();
+        }
+        if let Some(replacement) = Replacement::parse(query) {
+            Some(
+                ReplaceCommand {
+                    replacement,
+                    range: range.clone(),
+                }
+                .boxed_clone(),
+            )
+        } else {
+            None
+        }
     } else {
-        return None;
+        None
     };
+    if let Some(action) = action {
+        let string = input.to_string();
+        let positions = generate_positions(&string, &(range_prefix + query));
+        return Some(CommandInterceptResult {
+            action,
+            string,
+            positions,
+        });
+    }
 
-    let string = ":".to_owned() + name;
-    let positions = generate_positions(&string, query);
+    for command in commands(cx).iter() {
+        if let Some(action) = command.parse(&query, cx) {
+            let string = ":".to_owned() + &range_prefix + command.prefix + command.suffix;
+            let positions = generate_positions(&string, &(range_prefix + query));
+
+            if let Some(range) = &range {
+                if command.has_range || (range.is_count() && command.has_count) {
+                    return Some(CommandInterceptResult {
+                        action: Box::new(WithRange {
+                            is_count: command.has_count,
+                            range: range.clone(),
+                            action,
+                        }),
+                        string,
+                        positions,
+                    });
+                } else {
+                    return None;
+                }
+            }
 
-    Some(CommandInterceptResult {
-        action,
-        string,
-        positions,
-    })
+            return Some(CommandInterceptResult {
+                action,
+                string,
+                positions,
+            });
+        }
+    }
+    None
 }
 
 fn generate_positions(string: &str, query: &str) -> Vec<usize> {
@@ -506,4 +862,59 @@ mod test {
         cx.simulate_keystrokes(": q a enter");
         cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 0));
     }
+
+    #[gpui::test]
+    async fn test_offsets(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
+            .await;
+
+        cx.simulate_shared_keystrokes(": + enter").await;
+        cx.shared_state()
+            .await
+            .assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
+
+        cx.simulate_shared_keystrokes(": 1 0 - enter").await;
+        cx.shared_state()
+            .await
+            .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
+
+        cx.simulate_shared_keystrokes(": . - 2 enter").await;
+        cx.shared_state()
+            .await
+            .assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
+
+        cx.simulate_shared_keystrokes(": % enter").await;
+        cx.shared_state()
+            .await
+            .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
+    }
+
+    #[gpui::test]
+    async fn test_command_ranges(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
+
+        cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
+        cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
+
+        cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
+        cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
+
+        cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
+        cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
+    }
+
+    #[gpui::test]
+    async fn test_command_visual_replace(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
+
+        cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
+            .await;
+        cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
+    }
 }

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

@@ -49,6 +49,7 @@ fn blurred(editor: View<Editor>, cx: &mut WindowContext) {
                 .upgrade()
                 .is_some_and(|previous| previous == editor.clone())
             {
+                vim.store_visual_marks(cx);
                 vim.clear_operator(cx);
             }
         }

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

@@ -1,14 +1,13 @@
-use std::{ops::Range, sync::OnceLock, time::Duration};
+use std::{iter::Peekable, str::Chars, time::Duration};
 
 use gpui::{actions, impl_actions, ViewContext};
 use language::Point;
-use multi_buffer::MultiBufferRow;
-use regex::Regex;
 use search::{buffer_search, BufferSearchBar, SearchOptions};
 use serde_derive::Deserialize;
-use workspace::{searchable::Direction, Workspace};
+use workspace::{notifications::NotifyResultExt, searchable::Direction, Workspace};
 
 use crate::{
+    command::CommandRange,
     motion::{search_motion, Motion},
     normal::move_cursor,
     state::{Mode, SearchState},
@@ -43,16 +42,16 @@ pub struct FindCommand {
 
 #[derive(Debug, Clone, PartialEq, Deserialize)]
 pub struct ReplaceCommand {
-    pub query: String,
+    pub(crate) range: Option<CommandRange>,
+    pub(crate) replacement: Replacement,
 }
 
-#[derive(Debug, Default)]
-struct Replacement {
+#[derive(Debug, Default, PartialEq, Deserialize, Clone)]
+pub(crate) struct Replacement {
     search: String,
     replacement: String,
     should_replace_all: bool,
     is_case_sensitive: bool,
-    range: Option<Range<usize>>,
 }
 
 actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]);
@@ -61,11 +60,6 @@ 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);
@@ -354,23 +348,25 @@ fn replace_command(
     action: &ReplaceCommand,
     cx: &mut ViewContext<Workspace>,
 ) {
-    let replacement = parse_replace_all(&action.query);
+    let replacement = action.replacement.clone();
     let pane = workspace.active_pane().clone();
-    let mut editor = Vim::read(cx)
+    let 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| {
+    if let Some(range) = &action.range {
+        if let Some(result) = Vim::update(cx, |vim, cx| {
+            vim.update_active_editor(cx, |vim, editor, cx| {
+                let range = range.buffer_range(vim, editor, cx)?;
                 let snapshot = &editor.snapshot(cx).buffer_snapshot;
-                let end_row = MultiBufferRow(range.end.saturating_sub(1) as u32);
-                let end_point = Point::new(end_row.0, snapshot.line_len(end_row));
-                let range = snapshot
-                    .anchor_before(Point::new(range.start.saturating_sub(1) as u32, 0))
+                let end_point = Point::new(range.end.0, snapshot.line_len(range.end));
+                let range = snapshot.anchor_before(Point::new(range.start.0, 0))
                     ..snapshot.anchor_after(end_point);
-                editor.set_search_within_ranges(&[range], cx)
+                editor.set_search_within_ranges(&[range], cx);
+                anyhow::Ok(())
             })
+        }) {
+            result.notify_err(workspace, cx);
         }
     }
     pane.update(cx, |pane, cx| {
@@ -432,95 +428,81 @@ fn replace_command(
     })
 }
 
-// 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.
-fn parse_replace_all(query: &str) -> Replacement {
-    let mut chars = query.chars();
-    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();
-    }
-
-    let Some(delimiter) = chars.next() else {
-        return Replacement::default();
-    };
+impl Replacement {
+    // 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>) -> Option<Replacement> {
+        let Some(delimiter) = chars
+            .next()
+            .filter(|c| !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'')
+        else {
+            return None;
+        };
 
-    let mut search = String::new();
-    let mut replacement = String::new();
-    let mut flags = String::new();
-
-    let mut buffer = &mut search;
-
-    let mut escaped = false;
-    // 0 - parsing search
-    // 1 - parsing replacement
-    // 2 - parsing flags
-    let mut phase = 0;
-
-    for c in chars {
-        if escaped {
-            escaped = false;
-            if phase == 1 && c.is_digit(10) {
-                buffer.push('$')
-            // unescape escaped parens
-            } else if phase == 0 && c == '(' || c == ')' {
-            } else if c != delimiter {
-                buffer.push('\\')
-            }
-            buffer.push(c)
-        } else if c == '\\' {
-            escaped = true;
-        } else if c == delimiter {
-            if phase == 0 {
-                buffer = &mut replacement;
-                phase = 1;
-            } else if phase == 1 {
-                buffer = &mut flags;
-                phase = 2;
+        let mut search = String::new();
+        let mut replacement = String::new();
+        let mut flags = String::new();
+
+        let mut buffer = &mut search;
+
+        let mut escaped = false;
+        // 0 - parsing search
+        // 1 - parsing replacement
+        // 2 - parsing flags
+        let mut phase = 0;
+
+        for c in chars {
+            if escaped {
+                escaped = false;
+                if phase == 1 && c.is_digit(10) {
+                    buffer.push('$')
+                // unescape escaped parens
+                } else if phase == 0 && c == '(' || c == ')' {
+                } else if c != delimiter {
+                    buffer.push('\\')
+                }
+                buffer.push(c)
+            } else if c == '\\' {
+                escaped = true;
+            } else if c == delimiter {
+                if phase == 0 {
+                    buffer = &mut replacement;
+                    phase = 1;
+                } else if phase == 1 {
+                    buffer = &mut flags;
+                    phase = 2;
+                } else {
+                    break;
+                }
             } else {
-                break;
-            }
-        } else {
-            // escape unescaped parens
-            if phase == 0 && c == '(' || c == ')' {
-                buffer.push('\\')
+                // escape unescaped parens
+                if phase == 0 && c == '(' || c == ')' {
+                    buffer.push('\\')
+                }
+                buffer.push(c)
             }
-            buffer.push(c)
         }
-    }
 
-    let mut replacement = Replacement {
-        search,
-        replacement,
-        should_replace_all: true,
-        is_case_sensitive: true,
-        range,
-    };
+        let mut replacement = Replacement {
+            search,
+            replacement,
+            should_replace_all: true,
+            is_case_sensitive: true,
+        };
 
-    for c in flags.chars() {
-        match c {
-            'g' | 'I' => {}
-            'c' | 'n' => replacement.should_replace_all = false,
-            'i' => replacement.is_case_sensitive = false,
-            _ => {}
+        for c in flags.chars() {
+            match c {
+                'g' | 'I' => {}
+                'c' | 'n' => replacement.should_replace_all = false,
+                'i' => replacement.is_case_sensitive = false,
+                _ => {}
+            }
         }
-    }
 
-    replacement
+        Some(replacement)
+    }
 }
 
 #[cfg(test)]

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

@@ -0,0 +1,28 @@
+{"Put":{"state":"ˇ1\n2\n3\n4\n4\n3\n2\n1"}}
+{"Key":":"}
+{"Key":"2"}
+{"Key":","}
+{"Key":"4"}
+{"Key":"d"}
+{"Key":"enter"}
+{"Get":{"state":"1\nˇ4\n3\n2\n1","mode":"Normal"}}
+{"Key":":"}
+{"Key":"2"}
+{"Key":","}
+{"Key":"4"}
+{"Key":"s"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"t"}
+{"Key":"enter"}
+{"Get":{"state":"1\nˇ2\n3\n4\n1","mode":"Normal"}}
+{"Key":":"}
+{"Key":"2"}
+{"Key":","}
+{"Key":"4"}
+{"Key":"j"}
+{"Key":"o"}
+{"Key":"i"}
+{"Key":"n"}
+{"Key":"enter"}
+{"Get":{"state":"1\nˇ2 3 4\n1","mode":"Normal"}}

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

@@ -0,0 +1,12 @@
+{"Put":{"state":"ˇ1\n2\n3\n4\n4\n3\n2\n1"}}
+{"Key":"v"}
+{"Key":"2"}
+{"Key":"j"}
+{"Key":":"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"."}
+{"Key":"/"}
+{"Key":"k"}
+{"Key":"enter"}
+{"Get":{"state":"k\nk\nˇk\n4\n4\n3\n2\n1","mode":"Normal"}}

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

@@ -0,0 +1,21 @@
+{"Put":{"state":"ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n"}}
+{"Key":":"}
+{"Key":"+"}
+{"Key":"enter"}
+{"Get":{"state":"1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n","mode":"Normal"}}
+{"Key":":"}
+{"Key":"1"}
+{"Key":"0"}
+{"Key":"-"}
+{"Key":"enter"}
+{"Get":{"state":"1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n","mode":"Normal"}}
+{"Key":":"}
+{"Key":"."}
+{"Key":"-"}
+{"Key":"2"}
+{"Key":"enter"}
+{"Get":{"state":"1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n","mode":"Normal"}}
+{"Key":":"}
+{"Key":"%"}
+{"Key":"enter"}
+{"Get":{"state":"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ","mode":"Normal"}}