diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 2af5df13b8..cc66bc13f7 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -384,18 +384,46 @@ "d": "editor::Rename" // zed specific } }, + { + "context": "Editor && vim_mode == normal && vim_operator == c", + "bindings": { + "s": [ + "vim::PushOperator", + { + "ChangeSurrounds": {} + } + ] + } + }, { "context": "Editor && vim_operator == d", "bindings": { "d": "vim::CurrentLine" } }, + { + "context": "Editor && vim_mode == normal && vim_operator == d", + "bindings": { + "s": ["vim::PushOperator", "DeleteSurrounds"] + } + }, { "context": "Editor && vim_operator == y", "bindings": { "y": "vim::CurrentLine" } }, + { + "context": "Editor && vim_mode == normal && vim_operator == y", + "bindings": { + "s": [ + "vim::PushOperator", + { + "AddSurrounds": {} + } + ] + } + }, { "context": "Editor && VimObject", "bindings": { diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 34299ffad6..08aa6604c4 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -6,13 +6,14 @@ use crate::{char_kind, scroll::ScrollAnchor, CharKind, EditorStyle, ToOffset, To use gpui::{px, Pixels, WindowTextSystem}; use language::Point; use multi_buffer::MultiBufferSnapshot; +use serde::Deserialize; use std::{ops::Range, sync::Arc}; /// Defines search strategy for items in `movement` module. /// `FindRange::SingeLine` only looks for a match on a single line at a time, whereas /// `FindRange::MultiLine` keeps going until the end of a string. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] pub enum FindRange { SingleLine, MultiLine, diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 500eef28f3..c566baaa10 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -14,12 +14,13 @@ use workspace::Workspace; use crate::{ normal::normal_motion, state::{Mode, Operator}, + surrounds::SurroundsType, utils::coerce_punctuation, visual::visual_motion, Vim, }; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub enum Motion { Left, Backspace, @@ -386,15 +387,31 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) { } let count = Vim::update(cx, |vim, cx| vim.take_count(cx)); - let operator = Vim::read(cx).active_operator(); + let active_operator = Vim::read(cx).active_operator(); + let mut waiting_operator: Option = None; match Vim::read(cx).state().mode { - Mode::Normal | Mode::Replace => normal_motion(motion, operator, count, cx), - Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_motion(motion, count, cx), + Mode::Normal | Mode::Replace => { + if active_operator == Some(Operator::AddSurrounds { target: None }) { + waiting_operator = Some(Operator::AddSurrounds { + target: Some(SurroundsType::Motion(motion)), + }); + } else { + normal_motion(motion.clone(), active_operator.clone(), count, cx) + } + } + Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { + visual_motion(motion.clone(), count, cx) + } Mode::Insert => { // Shouldn't execute a motion in insert mode. Ignoring } } - Vim::update(cx, |vim, cx| vim.clear_operator(cx)); + Vim::update(cx, |vim, cx| { + vim.clear_operator(cx); + if let Some(operator) = waiting_operator { + vim.push_operator(operator, cx); + } + }); } // Motion handling is specified here: diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index c73021885f..e0eb1e46d1 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -15,6 +15,7 @@ use crate::{ motion::{self, first_non_whitespace, next_line_end, right, Motion}, object::Object, state::{Mode, Operator}, + surrounds::{check_and_move_to_valid_bracket_pair, SurroundsType}, Vim, }; use collections::BTreeSet; @@ -178,6 +179,7 @@ pub fn normal_motion( Some(Operator::Change) => change_motion(vim, motion, times, cx), Some(Operator::Delete) => delete_motion(vim, motion, times, cx), Some(Operator::Yank) => yank_motion(vim, motion, times, cx), + Some(Operator::AddSurrounds { target: None }) => {} Some(operator) => { // Can't do anything for text objects, Ignoring error!("Unexpected normal mode motion operator: {:?}", operator) @@ -188,21 +190,40 @@ pub fn normal_motion( pub fn normal_object(object: Object, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { + let mut waiting_operator: Option = None; match vim.maybe_pop_operator() { Some(Operator::Object { around }) => match vim.maybe_pop_operator() { Some(Operator::Change) => change_object(vim, object, around, cx), Some(Operator::Delete) => delete_object(vim, object, around, cx), Some(Operator::Yank) => yank_object(vim, object, around, cx), + Some(Operator::AddSurrounds { target: None }) => { + waiting_operator = Some(Operator::AddSurrounds { + target: Some(SurroundsType::Object(object)), + }); + } _ => { // Can't do anything for namespace operators. Ignoring } }, + Some(Operator::DeleteSurrounds) => { + waiting_operator = Some(Operator::DeleteSurrounds); + } + Some(Operator::ChangeSurrounds { target: None }) => { + if check_and_move_to_valid_bracket_pair(vim, object, cx) { + waiting_operator = Some(Operator::ChangeSurrounds { + target: Some(object), + }); + } + } _ => { - // Can't do anything with change/delete/yank and text objects. Ignoring + // Can't do anything with change/delete/yank/surrounds and text objects. Ignoring } } vim.clear_operator(cx); - }) + if let Some(operator) = waiting_operator { + vim.push_operator(operator, cx); + } + }); } pub(crate) fn move_cursor( diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index f6d5ff1499..cd08c052ed 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -14,7 +14,7 @@ use language::{char_kind, BufferSnapshot, CharKind, Point, Selection}; use serde::Deserialize; use workspace::Workspace; -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] pub enum Object { Word { ignore_punctuation: bool }, Sentence, diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index a83f597023..c78304610e 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,6 +1,7 @@ use std::{fmt::Display, ops::Range, sync::Arc}; -use crate::motion::Motion; +use crate::surrounds::SurroundsType; +use crate::{motion::Motion, object::Object}; use collections::HashMap; use editor::Anchor; use gpui::{Action, KeyContext}; @@ -55,6 +56,9 @@ pub enum Operator { Object { around: bool }, FindForward { before: bool }, FindBackward { after: bool }, + AddSurrounds { target: Option }, + ChangeSurrounds { target: Option }, + DeleteSurrounds, } #[derive(Default, Clone)] @@ -253,15 +257,21 @@ impl Operator { Operator::FindForward { before: true } => "t", Operator::FindBackward { after: false } => "F", Operator::FindBackward { after: true } => "T", + Operator::AddSurrounds { .. } => "ys", + Operator::ChangeSurrounds { .. } => "cs", + Operator::DeleteSurrounds => "ds", } } pub fn context_flags(&self) -> &'static [&'static str] { match self { - Operator::Object { .. } => &["VimObject"], - Operator::FindForward { .. } | Operator::FindBackward { .. } | Operator::Replace => { - &["VimWaiting"] - } + Operator::Object { .. } | Operator::ChangeSurrounds { target: None } => &["VimObject"], + Operator::FindForward { .. } + | Operator::FindBackward { .. } + | Operator::Replace + | Operator::AddSurrounds { target: Some(_) } + | Operator::ChangeSurrounds { .. } + | Operator::DeleteSurrounds => &["VimWaiting"], _ => &[], } } diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs new file mode 100644 index 0000000000..c6251b6b9b --- /dev/null +++ b/crates/vim/src/surrounds.rs @@ -0,0 +1,907 @@ +use crate::{motion::Motion, object::Object, state::Mode, Vim}; +use editor::{scroll::Autoscroll, Bias}; +use gpui::WindowContext; +use language::BracketPair; +use serde::Deserialize; +use std::sync::Arc; +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub enum SurroundsType { + Motion(Motion), + Object(Object), +} + +pub fn add_surrounds(text: Arc, target: SurroundsType, cx: &mut WindowContext) { + Vim::update(cx, |vim, cx| { + vim.stop_recording(); + vim.update_active_editor(cx, |_, editor, cx| { + let text_layout_details = editor.text_layout_details(cx); + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + + let pair = match find_surround_pair(&all_support_surround_pair(), &text) { + Some(pair) => pair.clone(), + None => BracketPair { + start: text.to_string(), + end: text.to_string(), + close: true, + newline: false, + }, + }; + let surround = pair.end != *text; + let (display_map, display_selections) = editor.selections.all_adjusted_display(cx); + let mut edits = Vec::new(); + let mut anchors = Vec::new(); + + for selection in &display_selections { + let range = match &target { + SurroundsType::Object(object) => { + object.range(&display_map, selection.clone(), false) + } + SurroundsType::Motion(motion) => motion.range( + &display_map, + selection.clone(), + Some(1), + true, + &text_layout_details, + ), + }; + + if let Some(range) = range { + let start = range.start.to_offset(&display_map, Bias::Right); + let end = range.end.to_offset(&display_map, Bias::Left); + let start_cursor_str = + format!("{}{}", pair.start, if surround { " " } else { "" }); + let close_cursor_str = + format!("{}{}", if surround { " " } else { "" }, pair.end); + let start_anchor = display_map.buffer_snapshot.anchor_before(start); + + edits.push((start..start, start_cursor_str)); + edits.push((end..end, close_cursor_str)); + anchors.push(start_anchor..start_anchor); + } else { + let start_anchor = display_map + .buffer_snapshot + .anchor_before(selection.head().to_offset(&display_map, Bias::Left)); + anchors.push(start_anchor..start_anchor); + } + } + + editor.buffer().update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + editor.set_clip_at_line_ends(true, cx); + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_anchor_ranges(anchors) + }); + }); + }); + vim.switch_mode(Mode::Normal, false, cx); + }); +} + +pub fn delete_surrounds(text: Arc, cx: &mut WindowContext) { + Vim::update(cx, |vim, cx| { + vim.stop_recording(); + + // only legitimate surrounds can be removed + let pair = match find_surround_pair(&all_support_surround_pair(), &text) { + Some(pair) => pair.clone(), + None => return, + }; + let pair_object = match pair_to_object(&pair) { + Some(pair_object) => pair_object, + None => return, + }; + let surround = pair.end != *text; + + vim.update_active_editor(cx, |_, editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + + let (display_map, display_selections) = editor.selections.all_display(cx); + let mut edits = Vec::new(); + let mut anchors = Vec::new(); + + for selection in &display_selections { + let start = selection.start.to_offset(&display_map, Bias::Left); + if let Some(range) = pair_object.range(&display_map, selection.clone(), true) { + // If the current parenthesis object is single-line, + // then we need to filter whether it is the current line or not + if !pair_object.is_multiline() { + let is_same_row = selection.start.row() == range.start.row() + && selection.end.row() == range.end.row(); + if !is_same_row { + anchors.push(start..start); + continue; + } + } + // This is a bit cumbersome, and it is written to deal with some special cases, as shown below + // hello«ˇ "hello in a word" »again. + // Sometimes the expand_selection will not be matched at both ends, and there will be extra spaces + // In order to be able to accurately match and replace in this case, some cumbersome methods are used + let mut chars_and_offset = display_map + .buffer_chars_at(range.start.to_offset(&display_map, Bias::Left)) + .peekable(); + while let Some((ch, offset)) = chars_and_offset.next() { + if ch.to_string() == pair.start { + let start = offset; + let mut end = start + 1; + if surround { + if let Some((next_ch, _)) = chars_and_offset.peek() { + if next_ch.eq(&' ') { + end += 1; + } + } + } + edits.push((start..end, "")); + anchors.push(start..start); + break; + } + } + let mut reverse_chars_and_offsets = display_map + .reverse_buffer_chars_at(range.end.to_offset(&display_map, Bias::Left)) + .peekable(); + while let Some((ch, offset)) = reverse_chars_and_offsets.next() { + if ch.to_string() == pair.end { + let mut start = offset; + let end = start + 1; + if surround { + if let Some((next_ch, _)) = reverse_chars_and_offsets.peek() { + if next_ch.eq(&' ') { + start -= 1; + } + } + } + edits.push((start..end, "")); + break; + } + } + } else { + anchors.push(start..start); + } + } + + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_ranges(anchors); + }); + edits.sort_by_key(|(range, _)| range.start); + editor.buffer().update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + editor.set_clip_at_line_ends(true, cx); + }); + }); + }); +} + +pub fn change_surrounds(text: Arc, target: Object, cx: &mut WindowContext) { + if let Some(will_replace_pair) = object_to_bracket_pair(target) { + Vim::update(cx, |vim, cx| { + vim.stop_recording(); + vim.update_active_editor(cx, |_, editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + + let pair = match find_surround_pair(&all_support_surround_pair(), &text) { + Some(pair) => pair.clone(), + None => BracketPair { + start: text.to_string(), + end: text.to_string(), + close: true, + newline: false, + }, + }; + let surround = pair.end != *text; + let (display_map, selections) = editor.selections.all_adjusted_display(cx); + let mut edits = Vec::new(); + let mut anchors = Vec::new(); + + for selection in &selections { + let start = selection.start.to_offset(&display_map, Bias::Left); + if let Some(range) = target.range(&display_map, selection.clone(), true) { + if !target.is_multiline() { + let is_same_row = selection.start.row() == range.start.row() + && selection.end.row() == range.end.row(); + if !is_same_row { + anchors.push(start..start); + continue; + } + } + let mut chars_and_offset = display_map + .buffer_chars_at(range.start.to_offset(&display_map, Bias::Left)) + .peekable(); + while let Some((ch, offset)) = chars_and_offset.next() { + if ch.to_string() == will_replace_pair.start { + let mut open_str = pair.start.clone(); + let start = offset; + let mut end = start + 1; + match chars_and_offset.peek() { + Some((next_ch, _)) => { + // If the next position is already a space or line break, + // we don't need to splice another space even under arround + if surround && !next_ch.is_whitespace() { + open_str.push_str(" "); + } else if !surround && next_ch.to_string() == " " { + end += 1; + } + } + None => {} + } + edits.push((start..end, open_str)); + anchors.push(start..start); + break; + } + } + + let mut reverse_chars_and_offsets = display_map + .reverse_buffer_chars_at( + range.end.to_offset(&display_map, Bias::Left), + ) + .peekable(); + while let Some((ch, offset)) = reverse_chars_and_offsets.next() { + if ch.to_string() == will_replace_pair.end { + let mut close_str = pair.end.clone(); + let mut start = offset; + let end = start + 1; + if let Some((next_ch, _)) = reverse_chars_and_offsets.peek() { + if surround && !next_ch.is_whitespace() { + close_str.insert_str(0, " ") + } else if !surround && next_ch.to_string() == " " { + start -= 1; + } + } + edits.push((start..end, close_str)); + break; + } + } + } else { + anchors.push(start..start); + } + } + + let stable_anchors = editor + .selections + .disjoint_anchors() + .into_iter() + .map(|selection| { + let start = selection.start.bias_left(&display_map.buffer_snapshot); + start..start + }) + .collect::>(); + edits.sort_by_key(|(range, _)| range.start); + editor.buffer().update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + editor.set_clip_at_line_ends(true, cx); + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_anchor_ranges(stable_anchors); + }); + }); + }); + }); + } +} + +/// Checks if any of the current cursors are surrounded by a valid pair of brackets. +/// +/// This method supports multiple cursors and checks each cursor for a valid pair of brackets. +/// A pair of brackets is considered valid if it is well-formed and properly closed. +/// +/// If a valid pair of brackets is found, the method returns `true` and the cursor is automatically moved to the start of the bracket pair. +/// If no valid pair of brackets is found for any cursor, the method returns `false`. +pub fn check_and_move_to_valid_bracket_pair( + vim: &mut Vim, + object: Object, + cx: &mut WindowContext, +) -> bool { + let mut valid = false; + if let Some(pair) = object_to_bracket_pair(object) { + vim.update_active_editor(cx, |_, editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + let (display_map, selections) = editor.selections.all_adjusted_display(cx); + let mut anchors = Vec::new(); + + for selection in &selections { + let start = selection.start.to_offset(&display_map, Bias::Left); + if let Some(range) = object.range(&display_map, selection.clone(), true) { + // If the current parenthesis object is single-line, + // then we need to filter whether it is the current line or not + if object.is_multiline() + || (!object.is_multiline() + && selection.start.row() == range.start.row() + && selection.end.row() == range.end.row()) + { + valid = true; + let mut chars_and_offset = display_map + .buffer_chars_at(range.start.to_offset(&display_map, Bias::Left)) + .peekable(); + while let Some((ch, offset)) = chars_and_offset.next() { + if ch.to_string() == pair.start { + anchors.push(offset..offset); + break; + } + } + } else { + anchors.push(start..start) + } + } else { + anchors.push(start..start) + } + } + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_ranges(anchors); + }); + editor.set_clip_at_line_ends(true, cx); + }); + }); + } + return valid; +} + +fn find_surround_pair<'a>(pairs: &'a [BracketPair], ch: &str) -> Option<&'a BracketPair> { + pairs.iter().find(|pair| pair.start == ch || pair.end == ch) +} + +fn all_support_surround_pair() -> Vec { + return vec![ + BracketPair { + start: "{".into(), + end: "}".into(), + close: true, + newline: false, + }, + BracketPair { + start: "'".into(), + end: "'".into(), + close: true, + newline: false, + }, + BracketPair { + start: "`".into(), + end: "`".into(), + close: true, + newline: false, + }, + BracketPair { + start: "\"".into(), + end: "\"".into(), + close: true, + newline: false, + }, + BracketPair { + start: "(".into(), + end: ")".into(), + close: true, + newline: false, + }, + BracketPair { + start: "|".into(), + end: "|".into(), + close: true, + newline: false, + }, + BracketPair { + start: "[".into(), + end: "]".into(), + close: true, + newline: false, + }, + BracketPair { + start: "{".into(), + end: "}".into(), + close: true, + newline: false, + }, + BracketPair { + start: "<".into(), + end: ">".into(), + close: true, + newline: false, + }, + ]; +} + +fn pair_to_object(pair: &BracketPair) -> Option { + match pair.start.as_str() { + "'" => Some(Object::Quotes), + "`" => Some(Object::BackQuotes), + "\"" => Some(Object::DoubleQuotes), + "|" => Some(Object::VerticalBars), + "(" => Some(Object::Parentheses), + "[" => Some(Object::SquareBrackets), + "{" => Some(Object::CurlyBrackets), + "<" => Some(Object::AngleBrackets), + _ => None, + } +} + +fn object_to_bracket_pair(object: Object) -> Option { + match object { + Object::Quotes => Some(BracketPair { + start: "'".to_string(), + end: "'".to_string(), + close: true, + newline: false, + }), + Object::BackQuotes => Some(BracketPair { + start: "`".to_string(), + end: "`".to_string(), + close: true, + newline: false, + }), + Object::DoubleQuotes => Some(BracketPair { + start: "\"".to_string(), + end: "\"".to_string(), + close: true, + newline: false, + }), + Object::VerticalBars => Some(BracketPair { + start: "|".to_string(), + end: "|".to_string(), + close: true, + newline: false, + }), + Object::Parentheses => Some(BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + newline: false, + }), + Object::SquareBrackets => Some(BracketPair { + start: "[".to_string(), + end: "]".to_string(), + close: true, + newline: false, + }), + Object::CurlyBrackets => Some(BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + newline: false, + }), + Object::AngleBrackets => Some(BracketPair { + start: "<".to_string(), + end: ">".to_string(), + close: true, + newline: false, + }), + _ => None, + } +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::{state::Mode, test::VimTestContext}; + + #[gpui::test] + async fn test_add_surrounds(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // test add surrounds with arround + cx.set_state( + indoc! {" + The quˇick brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["y", "s", "i", "w", "{"]); + cx.assert_state( + indoc! {" + The ˇ{ quick } brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + // test add surrounds not with arround + cx.set_state( + indoc! {" + The quˇick brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["y", "s", "i", "w", "}"]); + cx.assert_state( + indoc! {" + The ˇ{quick} brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + // test add surrounds with motion + cx.set_state( + indoc! {" + The quˇick brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["y", "s", "$", "}"]); + cx.assert_state( + indoc! {" + The quˇ{ick brown} + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + // test add surrounds with multi cursor + cx.set_state( + indoc! {" + The quˇick brown + fox jumps over + the laˇzy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["y", "s", "i", "w", "'"]); + cx.assert_state( + indoc! {" + The ˇ'quick' brown + fox jumps over + the ˇ'lazy' dog."}, + Mode::Normal, + ); + + // test multi cursor add surrounds with motion + cx.set_state( + indoc! {" + The quˇick brown + fox jumps over + the laˇzy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["y", "s", "$", "'"]); + cx.assert_state( + indoc! {" + The quˇ'ick brown' + fox jumps over + the laˇ'zy dog.'"}, + Mode::Normal, + ); + + // test multi cursor add surrounds with motion and custom string + cx.set_state( + indoc! {" + The quˇick brown + fox jumps over + the laˇzy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["y", "s", "$", "1"]); + cx.assert_state( + indoc! {" + The quˇ1ick brown1 + fox jumps over + the laˇ1zy dog.1"}, + Mode::Normal, + ); + } + + #[gpui::test] + async fn test_delete_surrounds(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // test delete surround + cx.set_state( + indoc! {" + The {quˇick} brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["d", "s", "{"]); + cx.assert_state( + indoc! {" + The ˇquick brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + // test delete not exist surrounds + cx.set_state( + indoc! {" + The {quˇick} brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["d", "s", "["]); + cx.assert_state( + indoc! {" + The {quˇick} brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + // test delete surround forward exist, in the surrounds plugin of other editors, + // the bracket pair in front of the current line will be deleted here, which is not implemented at the moment + cx.set_state( + indoc! {" + The {quick} brˇown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["d", "s", "{"]); + cx.assert_state( + indoc! {" + The {quick} brˇown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + // test cursor delete inner surrounds + cx.set_state( + indoc! {" + The { quick brown + fox jumˇps over } + the lazy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["d", "s", "{"]); + cx.assert_state( + indoc! {" + The ˇquick brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + // test multi cursor delete surrounds + cx.set_state( + indoc! {" + The [quˇick] brown + fox jumps over + the [laˇzy] dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["d", "s", "]"]); + cx.assert_state( + indoc! {" + The ˇquick brown + fox jumps over + the ˇlazy dog."}, + Mode::Normal, + ); + + // test multi cursor delete surrounds with arround + cx.set_state( + indoc! {" + Tˇhe [ quick ] brown + fox jumps over + the [laˇzy] dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["d", "s", "["]); + cx.assert_state( + indoc! {" + The ˇquick brown + fox jumps over + the ˇlazy dog."}, + Mode::Normal, + ); + + cx.set_state( + indoc! {" + Tˇhe [ quick ] brown + fox jumps over + the [laˇzy ] dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["d", "s", "["]); + cx.assert_state( + indoc! {" + The ˇquick brown + fox jumps over + the ˇlazy dog."}, + Mode::Normal, + ); + + // test multi cursor delete different surrounds + // the pair corresponding to the two cursors is the same, + // so they are combined into one cursor + cx.set_state( + indoc! {" + The [quˇick] brown + fox jumps over + the {laˇzy} dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["d", "s", "{"]); + cx.assert_state( + indoc! {" + The [quick] brown + fox jumps over + the ˇlazy dog."}, + Mode::Normal, + ); + + // test delete surround with multi cursor and nest surrounds + cx.set_state( + indoc! {" + fn test_surround() { + ifˇ 2 > 1 { + ˇprintln!(\"it is fine\"); + }; + }"}, + Mode::Normal, + ); + cx.simulate_keystrokes(["d", "s", "}"]); + cx.assert_state( + indoc! {" + fn test_surround() ˇ + if 2 > 1 ˇ + println!(\"it is fine\"); + ; + "}, + Mode::Normal, + ); + } + + #[gpui::test] + async fn test_change_surrounds(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + The {quˇick} brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["c", "s", "{", "["]); + cx.assert_state( + indoc! {" + The ˇ[ quick ] brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + // test multi cursor change surrounds + cx.set_state( + indoc! {" + The {quˇick} brown + fox jumps over + the {laˇzy} dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["c", "s", "{", "["]); + cx.assert_state( + indoc! {" + The ˇ[ quick ] brown + fox jumps over + the ˇ[ lazy ] dog."}, + Mode::Normal, + ); + + // test multi cursor delete different surrounds with after cursor + cx.set_state( + indoc! {" + Thˇe {quick} brown + fox jumps over + the {laˇzy} dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["c", "s", "{", "["]); + cx.assert_state( + indoc! {" + The ˇ[ quick ] brown + fox jumps over + the ˇ[ lazy ] dog."}, + Mode::Normal, + ); + + // test multi cursor change surrount with not arround + cx.set_state( + indoc! {" + Thˇe { quick } brown + fox jumps over + the {laˇzy} dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["c", "s", "{", "]"]); + cx.assert_state( + indoc! {" + The ˇ[quick] brown + fox jumps over + the ˇ[lazy] dog."}, + Mode::Normal, + ); + + // test multi cursor change with not exist surround + cx.set_state( + indoc! {" + The {quˇick} brown + fox jumps over + the [laˇzy] dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["c", "s", "[", "'"]); + cx.assert_state( + indoc! {" + The {quick} brown + fox jumps over + the ˇ'lazy' dog."}, + Mode::Normal, + ); + + // test change nesting surrounds + cx.set_state( + indoc! {" + fn test_surround() { + ifˇ 2 > 1 { + ˇprintln!(\"it is fine\"); + } + };"}, + Mode::Normal, + ); + cx.simulate_keystrokes(["c", "s", "{", "["]); + cx.assert_state( + indoc! {" + fn test_surround() ˇ[ + if 2 > 1 ˇ[ + println!(\"it is fine\"); + ] + ];"}, + Mode::Normal, + ); + } + + #[gpui::test] + async fn test_surrounds(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + The quˇick brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + cx.simulate_keystrokes(["y", "s", "i", "w", "["]); + cx.assert_state( + indoc! {" + The ˇ[ quick ] brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + cx.simulate_keystrokes(["c", "s", "[", "}"]); + cx.assert_state( + indoc! {" + The ˇ{quick} brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + cx.simulate_keystrokes(["d", "s", "{"]); + cx.assert_state( + indoc! {" + The ˇquick brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + + cx.simulate_keystrokes(["u"]); + cx.assert_state( + indoc! {" + The ˇ{quick} brown + fox jumps over + the lazy dog."}, + Mode::Normal, + ); + } +} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index ac26f02501..84636af1d0 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -12,6 +12,7 @@ mod normal; mod object; mod replace; mod state; +mod surrounds; mod utils; mod visual; @@ -37,6 +38,7 @@ use serde_derive::Serialize; use settings::{update_settings_file, Settings, SettingsStore}; use state::{EditorState, Mode, Operator, RecordedSelection, WorkspaceState}; use std::{ops::Range, sync::Arc}; +use surrounds::{add_surrounds, change_surrounds, delete_surrounds}; use ui::BorrowAppContext; use visual::{visual_block_motion, visual_replace}; use workspace::{self, Workspace}; @@ -170,7 +172,14 @@ fn observe_keystrokes(keystroke_event: &KeystrokeEvent, cx: &mut WindowContext) } Vim::update(cx, |vim, cx| match vim.active_operator() { - Some(Operator::FindForward { .. } | Operator::FindBackward { .. } | Operator::Replace) => {} + Some( + Operator::FindForward { .. } + | Operator::FindBackward { .. } + | Operator::Replace + | Operator::AddSurrounds { .. } + | Operator::ChangeSurrounds { .. } + | Operator::DeleteSurrounds, + ) => {} Some(_) => { vim.clear_operator(cx); } @@ -622,6 +631,31 @@ impl Vim { Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_replace(text, cx), _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)), }, + Some(Operator::AddSurrounds { target }) => match Vim::read(cx).state().mode { + Mode::Normal => { + if let Some(target) = target { + add_surrounds(text, target, cx); + Vim::update(cx, |vim, cx| vim.clear_operator(cx)); + } + } + _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)), + }, + Some(Operator::ChangeSurrounds { target }) => match Vim::read(cx).state().mode { + Mode::Normal => { + if let Some(target) = target { + change_surrounds(text, target, cx); + Vim::update(cx, |vim, cx| vim.clear_operator(cx)); + } + } + _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)), + }, + Some(Operator::DeleteSurrounds) => match Vim::read(cx).state().mode { + Mode::Normal => { + delete_surrounds(text, cx); + Vim::update(cx, |vim, cx| vim.clear_operator(cx)); + } + _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)), + }, _ => match Vim::read(cx).state().mode { Mode::Replace => multi_replace(text, cx), _ => {}