From e5a968b709feeeac1e12de054645671d64071676 Mon Sep 17 00:00:00 2001 From: Dino Date: Wed, 3 Sep 2025 00:03:14 +0100 Subject: [PATCH] vim: Fix change surround with any brackets text object (#37386) This commit fixes an issue with how the `AnyBrackets` object was handled with change surrounds (`cs`). With the keymap below, if one was to use `csb{` with the text `(bracketed)` and the cursor inside the parentheses, the text would not change. ```json { "context": "vim_operator == a || vim_operator == i || vim_operator == cs", "bindings": { "b": "vim::AnyBrackets" } } ``` Unfortunately there was no implementation for finding a corresponding `BracketPair` for the `AnyBrackets` object, meaning that, when using `cs` (change surrounds) the code would simply do nothing. This commit updates this logic so as to try and find the nearest surrounding bracket (parentheses, curly brackets, square brackets or angle brackets), ensuring that `cs` also works with `AnyBrackets`. Closes #24439 Release Notes: - Fixed handling of `AnyBrackets` in vim's change surrounds (`cs`) --- crates/vim/src/object.rs | 2 +- crates/vim/src/surrounds.rs | 255 ++++++++++++++++++++++++++---------- 2 files changed, 190 insertions(+), 67 deletions(-) diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 693de9f697..366acb740b 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -1510,7 +1510,7 @@ pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> D map.max_point() } -fn surrounding_markers( +pub fn surrounding_markers( map: &DisplaySnapshot, relative_to: DisplayPoint, around: bool, diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index 83500cf88b..7c36ebe674 100644 --- a/crates/vim/src/surrounds.rs +++ b/crates/vim/src/surrounds.rs @@ -1,7 +1,7 @@ use crate::{ Vim, motion::{self, Motion}, - object::Object, + object::{Object, surrounding_markers}, state::Mode, }; use editor::{Bias, movement}; @@ -224,7 +224,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - if let Some(will_replace_pair) = object_to_bracket_pair(target) { + if let Some(will_replace_pair) = self.object_to_bracket_pair(target, cx) { self.stop_recording(cx); self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { @@ -358,7 +358,7 @@ impl Vim { cx: &mut Context, ) -> bool { let mut valid = false; - if let Some(pair) = object_to_bracket_pair(object) { + if let Some(pair) = self.object_to_bracket_pair(object, cx) { self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -405,6 +405,140 @@ impl Vim { } valid } + + fn object_to_bracket_pair( + &self, + object: Object, + cx: &mut Context, + ) -> Option { + match object { + Object::Quotes => Some(BracketPair { + start: "'".to_string(), + end: "'".to_string(), + close: true, + surround: true, + newline: false, + }), + Object::BackQuotes => Some(BracketPair { + start: "`".to_string(), + end: "`".to_string(), + close: true, + surround: true, + newline: false, + }), + Object::DoubleQuotes => Some(BracketPair { + start: "\"".to_string(), + end: "\"".to_string(), + close: true, + surround: true, + newline: false, + }), + Object::VerticalBars => Some(BracketPair { + start: "|".to_string(), + end: "|".to_string(), + close: true, + surround: true, + newline: false, + }), + Object::Parentheses => Some(BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: false, + }), + Object::SquareBrackets => Some(BracketPair { + start: "[".to_string(), + end: "]".to_string(), + close: true, + surround: true, + newline: false, + }), + Object::CurlyBrackets => Some(BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: false, + }), + Object::AngleBrackets => Some(BracketPair { + start: "<".to_string(), + end: ">".to_string(), + close: true, + surround: true, + newline: false, + }), + Object::AnyBrackets => { + // 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 like `({ name: "John" })` if the cursor is + // inside the curly brackets, we target the curly brackets + // instead of the parentheses. + let mut bracket_pair = None; + let mut min_range_size = usize::MAX; + + let _ = self.editor.update(cx, |editor, cx| { + let (display_map, selections) = editor.selections.all_adjusted_display(cx); + // Even if there's multiple cursors, we'll simply rely on + // the first one to understand what bracket pair to map to. + // I believe we could, if worth it, go one step above and + // have a `BracketPair` per selection, so that `AnyBracket` + // could work in situations where the transformation below + // could be done. + // + // ``` + // (< name:ˇ'Zed' >) + // <[ name:ˇ'DeltaDB' ]> + // ``` + // + // After using `csb{`: + // + // ``` + // (ˇ{ name:'Zed' }) + // <ˇ{ name:'DeltaDB' }> + // ``` + if let Some(selection) = selections.first() { + let relative_to = selection.head(); + let bracket_pairs = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; + let cursor_offset = relative_to.to_offset(&display_map, Bias::Left); + + for &(open, close) in bracket_pairs.iter() { + if let Some(range) = surrounding_markers( + &display_map, + relative_to, + true, + false, + open, + close, + ) { + let start_offset = range.start.to_offset(&display_map, Bias::Left); + let end_offset = range.end.to_offset(&display_map, Bias::Right); + + if cursor_offset >= start_offset && cursor_offset <= end_offset { + let size = end_offset - start_offset; + if size < min_range_size { + min_range_size = size; + bracket_pair = Some(BracketPair { + start: open.to_string(), + end: close.to_string(), + close: true, + surround: true, + newline: false, + }) + } + } + } + } + } + }); + + bracket_pair + } + _ => None, + } + } } fn find_surround_pair<'a>(pairs: &'a [BracketPair], ch: &str) -> Option<&'a BracketPair> { @@ -505,74 +639,12 @@ fn pair_to_object(pair: &BracketPair) -> Option { } } -fn object_to_bracket_pair(object: Object) -> Option { - match object { - Object::Quotes => Some(BracketPair { - start: "'".to_string(), - end: "'".to_string(), - close: true, - surround: true, - newline: false, - }), - Object::BackQuotes => Some(BracketPair { - start: "`".to_string(), - end: "`".to_string(), - close: true, - surround: true, - newline: false, - }), - Object::DoubleQuotes => Some(BracketPair { - start: "\"".to_string(), - end: "\"".to_string(), - close: true, - surround: true, - newline: false, - }), - Object::VerticalBars => Some(BracketPair { - start: "|".to_string(), - end: "|".to_string(), - close: true, - surround: true, - newline: false, - }), - Object::Parentheses => Some(BracketPair { - start: "(".to_string(), - end: ")".to_string(), - close: true, - surround: true, - newline: false, - }), - Object::SquareBrackets => Some(BracketPair { - start: "[".to_string(), - end: "]".to_string(), - close: true, - surround: true, - newline: false, - }), - Object::CurlyBrackets => Some(BracketPair { - start: "{".to_string(), - end: "}".to_string(), - close: true, - surround: true, - newline: false, - }), - Object::AngleBrackets => Some(BracketPair { - start: "<".to_string(), - end: ">".to_string(), - close: true, - surround: true, - newline: false, - }), - _ => None, - } -} - #[cfg(test)] mod test { use gpui::KeyBinding; use indoc::indoc; - use crate::{PushAddSurrounds, state::Mode, test::VimTestContext}; + use crate::{PushAddSurrounds, object::AnyBrackets, state::Mode, test::VimTestContext}; #[gpui::test] async fn test_add_surrounds(cx: &mut gpui::TestAppContext) { @@ -1171,6 +1243,57 @@ mod test { ); } + #[gpui::test] + async fn test_change_surrounds_any_brackets(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Update keybindings so that using `csb` triggers Vim's `AnyBrackets` + // action. + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new( + "b", + AnyBrackets, + 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! {"(< name: ˇ'Zed' >)"}, Mode::Normal); + cx.simulate_keystrokes("c s b {"); + cx.assert_state(indoc! {"(ˇ{ name: 'Zed' })"}, Mode::Normal); + + cx.set_state( + indoc! {" + (< name: ˇ'Zed' >) + (< nˇame: 'DeltaDB' >) + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("c s b {"); + cx.set_state( + indoc! {" + (ˇ{ name: 'Zed' }) + (ˇ{ name: 'DeltaDB' }) + "}, + Mode::Normal, + ); + } + #[gpui::test] async fn test_surrounds(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await;