From bd59af1df5f36fc697ba782b3b55f7e4a0c91982 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 8 Aug 2024 21:47:27 +0100 Subject: [PATCH] vim: Support ranges in command (#15985) 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)). --- 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 | 573 +++++++++++++++--- crates/vim/src/editor_events.rs | 1 + crates/vim/src/normal/search.rs | 188 +++--- crates/vim/test_data/test_command_ranges.json | 28 + .../test_command_visual_replace.json | 12 + crates/vim/test_data/test_offsets.json | 21 + 10 files changed, 671 insertions(+), 201 deletions(-) create mode 100644 crates/vim/test_data/test_command_ranges.json create mode 100644 crates/vim/test_data/test_command_visual_replace.json create mode 100644 crates/vim/test_data/test_offsets.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 12b6a21730..30b4f588b4 100644 --- a/assets/keymaps/vim.json +++ b/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", diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 0a5cc6dbd6..e6a4332fc9 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/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.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) { + 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, + query: &str, cx: &mut ViewContext, ) -> 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.picker + .update(cx, |picker, cx| picker.set_query(query, cx)) + } } impl EventEmitter for CommandPalette {} diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 7ce3a93012..d904b4b6e7 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -393,7 +393,7 @@ impl<'a> MutableSelectionsCollection<'a> { self.collection.display_map(self.cx) } - fn buffer(&self) -> Ref { + pub fn buffer(&self) -> Ref { self.collection.buffer(self.cx) } diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index ae17718c27..f9adebf633 100644 --- a/crates/vim/Cargo.toml +++ b/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 diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 0776948f40..45c21de2ee 100644 --- a/crates/vim/src/command.rs +++ b/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, } -impl_actions!(vim, [GoToLine]); +#[derive(Debug)] +pub struct WithRange { + is_count: bool, + range: CommandRange, + action: Box, +} + +actions!(vim, [VisualCommand, CountCommand]); +impl_actions!(vim, [GoToLine, WithRange]); + +impl<'de> Deserialize<'de> for WithRange { + fn deserialize(_: D) -> Result + 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 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.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>, action_name: Option<&'static str>, bang_action: Option>, + 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> { 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, 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) -> Option { + 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) -> 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) -> 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 { + 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, +} + +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> { + 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 { @@ -204,9 +534,9 @@ fn generate_commands(_: &AppContext) -> Vec { .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::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::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,21 +619,86 @@ fn commands(cx: &AppContext) -> &Vec { .0 } -pub fn command_interceptor(mut query: &str, cx: &AppContext) -> Option { - // 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 { + // 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..]; + } + + let (range, query) = VimCommand::parse_range(input); + let range_prefix = input[0..(input.len() - query.len())].to_string(); + let query = query.as_str(); + + let action = if range.is_some() && query == "" { + Some( + GoToLine { + range: range.clone().unwrap(), + } + .boxed_clone(), + ) + } 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 { + 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, + }); } 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); + 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; + } + } return Some(CommandInterceptResult { action, @@ -312,46 +707,7 @@ pub fn command_interceptor(mut query: &str, cx: &AppContext) -> Option() { - (query, GoToLine { line }.boxed_clone()) - } else if range_regex().is_match(query) { - ( - query, - ReplaceCommand { - query: query.to_string(), - } - .boxed_clone(), - ) - } else { - return None; - }; - - let string = ":".to_owned() + name; - let positions = generate_positions(&string, query); - - Some(CommandInterceptResult { - action, - string, - positions, - }) + None } fn generate_positions(string: &str, query: &str) -> Vec { @@ -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"); + } } diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index 4af14c69c6..48a59c3e4e 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -49,6 +49,7 @@ fn blurred(editor: View, cx: &mut WindowContext) { .upgrade() .is_some_and(|previous| previous == editor.clone()) { + vim.store_visual_marks(cx); vim.clear_operator(cx); } } diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 4e2535a367..72872f2133 100644 --- a/crates/vim/src/normal/search.rs +++ b/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, + 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>, } actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]); @@ -61,11 +60,6 @@ impl_actions!( [FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext] ); -static RANGE_REGEX: OnceLock = 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.register_action(move_to_next); workspace.register_action(move_to_prev); @@ -354,23 +348,25 @@ fn replace_command( action: &ReplaceCommand, cx: &mut ViewContext, ) { - 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, &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(); - } +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) -> Option { + let Some(delimiter) = chars + .next() + .filter(|c| !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'') + else { + return None; + }; - let Some(delimiter) = chars.next() else { - return Replacement::default(); - }; + let mut search = String::new(); + let mut replacement = String::new(); + let mut flags = String::new(); - let mut search = String::new(); - let mut replacement = String::new(); - let mut flags = String::new(); + let mut buffer = &mut search; - let mut buffer = &mut search; + let mut escaped = false; + // 0 - parsing search + // 1 - parsing replacement + // 2 - parsing flags + let mut phase = 0; - 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; + 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; + // escape unescaped parens + if phase == 0 && c == '(' || c == ')' { + buffer.push('\\') + } + buffer.push(c) } - } else { - // escape unescaped parens - if phase == 0 && c == '(' || c == ')' { - buffer.push('\\') + } + + 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, + _ => {} } - buffer.push(c) } + + Some(replacement) } - - let mut replacement = Replacement { - search, - replacement, - should_replace_all: true, - is_case_sensitive: true, - range, - }; - - for c in flags.chars() { - match c { - 'g' | 'I' => {} - 'c' | 'n' => replacement.should_replace_all = false, - 'i' => replacement.is_case_sensitive = false, - _ => {} - } - } - - replacement } #[cfg(test)] diff --git a/crates/vim/test_data/test_command_ranges.json b/crates/vim/test_data/test_command_ranges.json new file mode 100644 index 0000000000..d0e4928c6a --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_command_visual_replace.json b/crates/vim/test_data/test_command_visual_replace.json new file mode 100644 index 0000000000..69713b19ee --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_offsets.json b/crates/vim/test_data/test_offsets.json new file mode 100644 index 0000000000..065a234817 --- /dev/null +++ b/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"}}