From 5409d4793f1f3208993bb3d26496bf0c6b5efc44 Mon Sep 17 00:00:00 2001 From: Ian Chamberlain Date: Fri, 8 May 2026 21:10:54 -0700 Subject: [PATCH] vim: Change surrounds with Mini{Quotes,Brackets} and AnyQuotes (#51067) Part of #48241 (`dsq` still needs to be implemented, I can try to do in another PR if+when this is merged) AnyBrackets was already supported, and these various surrounds were supported with other vim motions, this just brings parity for "change surrounds". Also adds MiniBrackets support since it works the same way as MiniQuotes does. Most of this change is just test cases for vim edits with `csq` + `csb`, using the keybinds described in the docs: https://zed.dev/docs/vim#any-bracket-functionality Also did a slight refactor to reuse some constants for supported pairs, for consistency. Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed vim `change surrounds` for MiniQuotes, MiniBrackets, and AnyQuotes --- crates/vim/src/object.rs | 23 +++--- crates/vim/src/surrounds.rs | 152 ++++++++++++++++++++++++++++++++++-- 2 files changed, 158 insertions(+), 17 deletions(-) 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]