diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 67b4b16b178e75316eb10b051ab9153737777e3f..397b57fa912d927e475a08968eeaf11ecbc1dff5 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -4,6 +4,7 @@ use crate::{ Vim, motion::{is_subword_end, is_subword_start, right}, state::{Mode, Operator}, + surrounds::{BRACKET_PAIRS, QUOTE_PAIRS, SurroundPair}, }; use editor::{ Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, ToOffset, @@ -606,7 +607,6 @@ impl Object { surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`') } Object::AnyQuotes => { - let quote_types = ['\'', '"', '`']; let cursor_offset = relative_to.to_offset(map, Bias::Left); // Find innermost range directly without collecting all ranges @@ -614,14 +614,14 @@ impl Object { let mut min_size = usize::MAX; // First pass: find innermost enclosing range - for quote in quote_types { + for &SurroundPair { open, close } in QUOTE_PAIRS { if let Some(range) = surrounding_markers( map, relative_to, around, self.is_multiline(), - quote, - quote, + open, + close, ) { let start_offset = range.start.to_offset(map, Bias::Left); let end_offset = range.end.to_offset(map, Bias::Right); @@ -641,16 +641,16 @@ impl Object { } // Fallback: find nearest pair if not inside any quotes - quote_types + QUOTE_PAIRS .iter() - .flat_map(|"e| { + .flat_map(|&SurroundPair { open, close }| { surrounding_markers( map, relative_to, around, self.is_multiline(), - quote, - quote, + open, + close, ) }) .min_by_key(|range| { @@ -681,14 +681,13 @@ impl Object { surrounding_html_tag(map, head, range, around) } Object::AnyBrackets => { - let bracket_pairs = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; let cursor_offset = relative_to.to_offset(map, Bias::Left); // Find innermost enclosing bracket range let mut innermost = None; let mut min_size = usize::MAX; - for &(open, close) in bracket_pairs.iter() { + for &SurroundPair { open, close } in BRACKET_PAIRS { if let Some(range) = surrounding_markers( map, relative_to, @@ -715,9 +714,9 @@ impl Object { } // Fallback: find nearest bracket pair if not inside any - bracket_pairs + BRACKET_PAIRS .iter() - .flat_map(|&(open, close)| { + .flat_map(|&SurroundPair { open, close }| { surrounding_markers( map, relative_to, diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index ace06a5a88defc1e50033b0b0c0178086ea593cc..f2b0b71705ae7db7fd557b55d176956f11598c33 100644 --- a/crates/vim/src/surrounds.rs +++ b/crates/vim/src/surrounds.rs @@ -61,13 +61,20 @@ pub const SURROUND_PAIRS: &[SurroundPair] = &[ ]; /// Bracket-only pairs for AnyBrackets matching. -const BRACKET_PAIRS: &[SurroundPair] = &[ +pub const BRACKET_PAIRS: &[SurroundPair] = &[ SurroundPair::new('(', ')'), SurroundPair::new('[', ']'), SurroundPair::new('{', '}'), SurroundPair::new('<', '>'), ]; +/// Quote-only pairs for AnyQuotes matching. +pub const QUOTE_PAIRS: &[SurroundPair] = &[ + SurroundPair::new('"', '"'), + SurroundPair::new('\'', '\''), + SurroundPair::new('`', '`'), +]; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum SurroundsType { Motion(Motion), @@ -472,10 +479,19 @@ impl Vim { return Some(pair.to_bracket_pair()); } - if object != Object::AnyBrackets { - return None; + match object { + Object::AnyBrackets => self.any_pair(BRACKET_PAIRS, cx), + Object::AnyQuotes => self.any_pair(QUOTE_PAIRS, cx), + Object::MiniQuotes | Object::MiniBrackets => self.mini_pair(object, cx), + _ => None, } + } + fn any_pair( + &self, + allowed_pairs: &[SurroundPair], + cx: &mut Context, + ) -> Option { // If we're dealing with `AnyBrackets`, which can map to multiple bracket // pairs, we'll need to first determine which `BracketPair` to target. // As such, we keep track of the smallest range size, so that in cases @@ -508,7 +524,7 @@ impl Vim { let relative_to = selection.head(); let cursor_offset = relative_to.to_offset(&display_map, Bias::Left); - for pair in BRACKET_PAIRS { + for pair in allowed_pairs { if let Some(range) = surrounding_markers( &display_map, relative_to, @@ -534,6 +550,25 @@ impl Vim { best_pair.map(|p| p.to_bracket_pair()) } + + fn mini_pair(&self, object: Object, cx: &mut Context) -> Option { + self.editor + .update(cx, |editor, cx| { + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_adjusted_display(&display_map); + // For now, only primary selection is used to select the bracket/quote pair. It might be weird + // if multi-select resulted in different quote kinds being replaced for different selections. + // any_pair uses the same logic, so this should be consistent across {Any,Mini}{Quotes,Brackets} + let selection = selections.first()?.clone(); + let range = object.range(&display_map, selection, true, None)?; + let start_offset = range.start.to_offset(&display_map, Bias::Left); + let (pair_char, _) = display_map.buffer_chars_at(start_offset).next()?; + literal_surround_pair(pair_char) + }) + .ok() + .flatten() + .map(|surround| surround.to_bracket_pair()) + } } /// Convert an Object to its corresponding SurroundPair. @@ -628,7 +663,12 @@ mod test { use gpui::KeyBinding; use indoc::indoc; - use crate::{PushAddSurrounds, object::AnyBrackets, state::Mode, test::VimTestContext}; + use crate::{ + PushAddSurrounds, + object::{AnyBrackets, AnyQuotes, MiniBrackets, MiniQuotes}, + state::Mode, + test::VimTestContext, + }; #[gpui::test] async fn test_add_surrounds(cx: &mut gpui::TestAppContext) { @@ -1335,6 +1375,108 @@ mod test { ); } + #[gpui::test] + async fn test_change_surrounds_mini_brackets(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Update keybindings so that using `csb` triggers Vim's `MiniBrackets` action. + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new( + "b", + MiniBrackets, + Some("vim_operator == a || vim_operator == i || vim_operator == cs"), + )]); + }); + + cx.set_state(indoc! {"{braˇcketed}"}, Mode::Normal); + cx.simulate_keystrokes("c s b ["); + cx.assert_state(indoc! {"ˇ[ bracketed ]"}, Mode::Normal); + + cx.set_state(indoc! {"[braˇcketed]"}, Mode::Normal); + cx.simulate_keystrokes("c s b {"); + cx.assert_state(indoc! {"ˇ{ bracketed }"}, Mode::Normal); + + cx.set_state(indoc! {""}, Mode::Normal); + cx.simulate_keystrokes("c s b ["); + cx.assert_state(indoc! {"ˇ[ bracketed ]"}, Mode::Normal); + + cx.set_state(indoc! {"(braˇcketed)"}, Mode::Normal); + cx.simulate_keystrokes("c s b ["); + cx.assert_state(indoc! {"ˇ[ bracketed ]"}, Mode::Normal); + + cx.set_state(indoc! {"(<ˇZed>)"}, Mode::Normal); + cx.simulate_keystrokes("c s b )"); + cx.assert_state(indoc! {"(ˇ(Zed))"}, Mode::Normal); + + cx.set_state( + indoc! {" + (<ˇZed>) + (<ˇDeltaDB>) + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("c s b ("); + cx.assert_state( + indoc! {" + (ˇ( Zed )) + (ˇ( DeltaDB )) + "}, + Mode::Normal, + ); + } + + #[gpui::test] + async fn test_change_surrounds_any_quotes(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Update keybindings so that using `csq` triggers Vim's `AnyQuotes` action. + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new( + "q", + AnyQuotes, + Some("vim_operator == a || vim_operator == i || vim_operator == cs"), + )]); + }); + + cx.set_state(indoc! {"' ˇstr '"}, Mode::Normal); + cx.simulate_keystrokes("c s q \""); + cx.assert_state(indoc! {"ˇ\" str \""}, Mode::Normal); + + cx.set_state(indoc! {"` ˇstr `"}, Mode::Normal); + cx.simulate_keystrokes("c s q '"); + cx.assert_state(indoc! {"ˇ' str '"}, Mode::Normal); + + cx.set_state(indoc! {"\" ˇstr \""}, Mode::Normal); + cx.simulate_keystrokes("c s q `"); + cx.assert_state(indoc! {"ˇ` str `"}, Mode::Normal); + } + + #[gpui::test] + async fn test_change_surrounds_mini_quotes(cx: &mut gpui::TestAppContext) { + // NOTE: needs TypeScript test cx to recognize single/backquotes + let mut cx = VimTestContext::new_typescript(cx).await; + + // Update keybindings so that using `csq` triggers Vim's `MiniQuotes` action. + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new( + "q", + MiniQuotes, + Some("vim_operator == a || vim_operator == i || vim_operator == cs"), + )]); + }); + cx.set_state(indoc! {"' ˇstr '"}, Mode::Normal); + cx.simulate_keystrokes("c s q \""); + cx.assert_state(indoc! {"ˇ\" str \""}, Mode::Normal); + + cx.set_state(indoc! {"` ˇstr `"}, Mode::Normal); + cx.simulate_keystrokes("c s q '"); + cx.assert_state(indoc! {"ˇ' str '"}, Mode::Normal); + + cx.set_state(indoc! {"\" ˇstr \""}, Mode::Normal); + cx.simulate_keystrokes("c s q `"); + cx.assert_state(indoc! {"ˇ` str `"}, Mode::Normal); + } + // The following test cases all follow tpope/vim-surround's behaviour // and are more focused on how whitespace is handled. #[gpui::test]