Add helix match surround operations (#44317)

Leon Qadirie created

Partially addresses #38151

Release Notes:

- Added Helix match surround operations

Change summary

assets/keymaps/vim.json          |   3 
crates/vim/src/helix.rs          |  31 ++
crates/vim/src/helix/surround.rs | 436 ++++++++++++++++++++++++++++++++
crates/vim/src/state.rs          |  24 +
crates/vim/src/surrounds.rs      | 456 +++++++++++++++++----------------
crates/vim/src/vim.rs            |  60 ++++
6 files changed, 785 insertions(+), 225 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -643,6 +643,9 @@
     "context": "vim_operator == helix_m",
     "bindings": {
       "m": "vim::Matching",
+      "s": "vim::PushHelixSurroundAdd",
+      "r": "vim::PushHelixSurroundReplace",
+      "d": "vim::PushHelixSurroundDelete",
     },
   },
   {

crates/vim/src/helix.rs 🔗

@@ -3,6 +3,7 @@ mod duplicate;
 mod object;
 mod paste;
 mod select;
+mod surround;
 
 use editor::display_map::DisplaySnapshot;
 use editor::{
@@ -19,9 +20,9 @@ use workspace::searchable::FilteredSearchRange;
 use workspace::searchable::{self, Direction};
 
 use crate::motion::{self, MotionKind};
-use crate::state::SearchState;
+use crate::state::{Operator, SearchState};
 use crate::{
-    Vim,
+    PushHelixSurroundAdd, PushHelixSurroundDelete, PushHelixSurroundReplace, Vim,
     motion::{Motion, right},
     state::Mode,
 };
@@ -80,6 +81,32 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::helix_substitute_no_yank);
     Vim::action(editor, cx, Vim::helix_select_next);
     Vim::action(editor, cx, Vim::helix_select_previous);
+    Vim::action(editor, cx, |vim, _: &PushHelixSurroundAdd, window, cx| {
+        vim.clear_operator(window, cx);
+        vim.push_operator(Operator::HelixSurroundAdd, window, cx);
+    });
+    Vim::action(
+        editor,
+        cx,
+        |vim, _: &PushHelixSurroundReplace, window, cx| {
+            vim.clear_operator(window, cx);
+            vim.push_operator(
+                Operator::HelixSurroundReplace {
+                    replaced_char: None,
+                },
+                window,
+                cx,
+            );
+        },
+    );
+    Vim::action(
+        editor,
+        cx,
+        |vim, _: &PushHelixSurroundDelete, window, cx| {
+            vim.clear_operator(window, cx);
+            vim.push_operator(Operator::HelixSurroundDelete, window, cx);
+        },
+    );
 }
 
 impl Vim {

crates/vim/src/helix/surround.rs 🔗

@@ -0,0 +1,436 @@
+use editor::display_map::DisplaySnapshot;
+use editor::{Bias, DisplayPoint, MultiBufferOffset};
+use gpui::{Context, Window};
+use multi_buffer::Anchor;
+use text::Selection;
+
+use crate::Vim;
+use crate::object::surrounding_markers;
+use crate::surrounds::{SURROUND_PAIRS, bracket_pair_for_str_helix, surround_pair_for_char_helix};
+
+/// Find the nearest surrounding bracket pair around the cursor.
+fn find_nearest_surrounding_pair(
+    display_map: &DisplaySnapshot,
+    cursor: DisplayPoint,
+) -> Option<(char, char)> {
+    let cursor_offset = cursor.to_offset(display_map, Bias::Left);
+    let mut best_pair: Option<(char, char)> = None;
+    let mut min_range_size = usize::MAX;
+
+    for pair in SURROUND_PAIRS {
+        if let Some(range) =
+            surrounding_markers(display_map, cursor, true, true, pair.open, pair.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;
+                    best_pair = Some((pair.open, pair.close));
+                }
+            }
+        }
+    }
+
+    best_pair
+}
+
+fn selection_cursor(map: &DisplaySnapshot, selection: &Selection<DisplayPoint>) -> DisplayPoint {
+    if selection.reversed || selection.is_empty() {
+        selection.head()
+    } else {
+        editor::movement::left(map, selection.head())
+    }
+}
+
+type SurroundEdits = Vec<(std::ops::Range<MultiBufferOffset>, String)>;
+type SurroundAnchors = Vec<std::ops::Range<Anchor>>;
+
+fn apply_helix_surround_edits<F>(
+    vim: &mut Vim,
+    window: &mut Window,
+    cx: &mut Context<Vim>,
+    mut build: F,
+) where
+    F: FnMut(&DisplaySnapshot, Vec<Selection<DisplayPoint>>) -> (SurroundEdits, SurroundAnchors),
+{
+    vim.update_editor(cx, |_, editor, cx| {
+        editor.transact(window, cx, |editor, window, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+
+            let display_map = editor.display_snapshot(cx);
+            let selections = editor.selections.all_display(&display_map);
+            let (mut edits, anchors) = build(&display_map, selections);
+
+            edits.sort_by(|a, b| b.0.start.cmp(&a.0.start));
+            editor.edit(edits, cx);
+
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.select_anchor_ranges(anchors);
+            });
+            editor.set_clip_at_line_ends(true, cx);
+        });
+    });
+}
+
+impl Vim {
+    /// ms - Add surrounding characters around selection.
+    pub fn helix_surround_add(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
+        self.stop_recording(cx);
+
+        let pair = bracket_pair_for_str_helix(text);
+
+        apply_helix_surround_edits(self, window, cx, |display_map, selections| {
+            let mut edits = Vec::new();
+            let mut anchors = Vec::new();
+
+            for selection in selections {
+                let range = selection.range();
+                let start = range.start.to_offset(display_map, Bias::Right);
+                let end = range.end.to_offset(display_map, Bias::Left);
+
+                let end_anchor = display_map.buffer_snapshot().anchor_before(end);
+                edits.push((end..end, pair.end.clone()));
+                edits.push((start..start, pair.start.clone()));
+                anchors.push(end_anchor..end_anchor);
+            }
+
+            (edits, anchors)
+        });
+    }
+
+    /// mr - Replace innermost surrounding pair containing the cursor.
+    pub fn helix_surround_replace(
+        &mut self,
+        old_char: char,
+        new_char: char,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.stop_recording(cx);
+
+        let new_char_str = new_char.to_string();
+        let new_pair = bracket_pair_for_str_helix(&new_char_str);
+
+        apply_helix_surround_edits(self, window, cx, |display_map, selections| {
+            let mut edits: Vec<(std::ops::Range<MultiBufferOffset>, String)> = Vec::new();
+            let mut anchors = Vec::new();
+
+            for selection in selections {
+                let cursor = selection_cursor(display_map, &selection);
+
+                // For 'm', find the nearest surrounding pair
+                let markers = match surround_pair_for_char_helix(old_char) {
+                    Some(pair) => Some((pair.open, pair.close)),
+                    None => find_nearest_surrounding_pair(display_map, cursor),
+                };
+
+                let Some((open_marker, close_marker)) = markers else {
+                    let offset = selection.head().to_offset(display_map, Bias::Left);
+                    let anchor = display_map.buffer_snapshot().anchor_before(offset);
+                    anchors.push(anchor..anchor);
+                    continue;
+                };
+
+                if let Some(range) =
+                    surrounding_markers(display_map, cursor, true, true, open_marker, close_marker)
+                {
+                    let open_start = range.start.to_offset(display_map, Bias::Left);
+                    let open_end = open_start + open_marker.len_utf8();
+                    let close_end = range.end.to_offset(display_map, Bias::Left);
+                    let close_start = close_end - close_marker.len_utf8();
+
+                    edits.push((close_start..close_end, new_pair.end.clone()));
+                    edits.push((open_start..open_end, new_pair.start.clone()));
+
+                    let cursor_offset = cursor.to_offset(display_map, Bias::Left);
+                    let anchor = display_map.buffer_snapshot().anchor_before(cursor_offset);
+                    anchors.push(anchor..anchor);
+                } else {
+                    let offset = selection.head().to_offset(display_map, Bias::Left);
+                    let anchor = display_map.buffer_snapshot().anchor_before(offset);
+                    anchors.push(anchor..anchor);
+                }
+            }
+
+            (edits, anchors)
+        });
+    }
+
+    /// md - Delete innermost surrounding pair containing the cursor.
+    pub fn helix_surround_delete(
+        &mut self,
+        target_char: char,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.stop_recording(cx);
+
+        apply_helix_surround_edits(self, window, cx, |display_map, selections| {
+            let mut edits: Vec<(std::ops::Range<MultiBufferOffset>, String)> = Vec::new();
+            let mut anchors = Vec::new();
+
+            for selection in selections {
+                let cursor = selection_cursor(display_map, &selection);
+
+                // For 'm', find the nearest surrounding pair
+                let markers = match surround_pair_for_char_helix(target_char) {
+                    Some(pair) => Some((pair.open, pair.close)),
+                    None => find_nearest_surrounding_pair(display_map, cursor),
+                };
+
+                let Some((open_marker, close_marker)) = markers else {
+                    let offset = selection.head().to_offset(display_map, Bias::Left);
+                    let anchor = display_map.buffer_snapshot().anchor_before(offset);
+                    anchors.push(anchor..anchor);
+                    continue;
+                };
+
+                if let Some(range) =
+                    surrounding_markers(display_map, cursor, true, true, open_marker, close_marker)
+                {
+                    let open_start = range.start.to_offset(display_map, Bias::Left);
+                    let open_end = open_start + open_marker.len_utf8();
+                    let close_end = range.end.to_offset(display_map, Bias::Left);
+                    let close_start = close_end - close_marker.len_utf8();
+
+                    edits.push((close_start..close_end, String::new()));
+                    edits.push((open_start..open_end, String::new()));
+
+                    let cursor_offset = cursor.to_offset(display_map, Bias::Left);
+                    let anchor = display_map.buffer_snapshot().anchor_before(cursor_offset);
+                    anchors.push(anchor..anchor);
+                } else {
+                    let offset = selection.head().to_offset(display_map, Bias::Left);
+                    let anchor = display_map.buffer_snapshot().anchor_before(offset);
+                    anchors.push(anchor..anchor);
+                }
+            }
+
+            (edits, anchors)
+        });
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::{state::Mode, test::VimTestContext};
+
+    #[gpui::test]
+    async fn test_helix_surround_add(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state("hello ˇworld", Mode::HelixNormal);
+        cx.simulate_keystrokes("m s (");
+        cx.assert_state("hello (wˇ)orld", Mode::HelixNormal);
+
+        cx.set_state("hello ˇworld", Mode::HelixNormal);
+        cx.simulate_keystrokes("m s )");
+        cx.assert_state("hello (wˇ)orld", Mode::HelixNormal);
+
+        cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
+        cx.simulate_keystrokes("m s [");
+        cx.assert_state("hello [worlˇ]d", Mode::HelixNormal);
+
+        cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
+        cx.simulate_keystrokes("m s \"");
+        cx.assert_state("hello \"worlˇ\"d", Mode::HelixNormal);
+    }
+
+    #[gpui::test]
+    async fn test_helix_surround_delete(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state("hello (woˇrld) test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d (");
+        cx.assert_state("hello woˇrld test", Mode::HelixNormal);
+
+        cx.set_state("hello \"woˇrld\" test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d \"");
+        cx.assert_state("hello woˇrld test", Mode::HelixNormal);
+
+        cx.set_state("hello woˇrld test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d (");
+        cx.assert_state("hello woˇrld test", Mode::HelixNormal);
+
+        cx.set_state("((woˇrld))", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d (");
+        cx.assert_state("(woˇrld)", Mode::HelixNormal);
+    }
+
+    #[gpui::test]
+    async fn test_helix_surround_replace(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state("hello (woˇrld) test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m r ( [");
+        cx.assert_state("hello [woˇrld] test", Mode::HelixNormal);
+
+        cx.set_state("hello (woˇrld) test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m r ( ]");
+        cx.assert_state("hello [woˇrld] test", Mode::HelixNormal);
+
+        cx.set_state("hello \"woˇrld\" test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m r \" {");
+        cx.assert_state("hello {woˇrld} test", Mode::HelixNormal);
+
+        cx.set_state("((woˇrld))", Mode::HelixNormal);
+        cx.simulate_keystrokes("m r ( [");
+        cx.assert_state("([woˇrld])", Mode::HelixNormal);
+    }
+
+    #[gpui::test]
+    async fn test_helix_surround_multiline(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state(
+            indoc! {"
+            function test() {
+                return ˇvalue;
+            }"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("m d {");
+        cx.assert_state(
+            indoc! {"
+            function test() 
+                return ˇvalue;
+            "},
+            Mode::HelixNormal,
+        );
+    }
+
+    #[gpui::test]
+    async fn test_helix_surround_select_mode(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state("hello «worldˇ» test", Mode::HelixSelect);
+        cx.simulate_keystrokes("m s {");
+        cx.assert_state("hello {worldˇ} test", Mode::HelixNormal);
+    }
+
+    #[gpui::test]
+    async fn test_helix_surround_multi_cursor(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state(
+            indoc! {"
+            (heˇllo)
+            (woˇrld)"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("m d (");
+        cx.assert_state(
+            indoc! {"
+            heˇllo
+            woˇrld"},
+            Mode::HelixNormal,
+        );
+    }
+
+    #[gpui::test]
+    async fn test_helix_surround_escape_cancels(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state("hello ˇworld", Mode::HelixNormal);
+        cx.simulate_keystrokes("m escape");
+        cx.assert_state("hello ˇworld", Mode::HelixNormal);
+
+        cx.set_state("hello (woˇrld)", Mode::HelixNormal);
+        cx.simulate_keystrokes("m r ( escape");
+        cx.assert_state("hello (woˇrld)", Mode::HelixNormal);
+    }
+
+    #[gpui::test]
+    async fn test_helix_surround_no_vim_aliases(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        // In Helix mode, 'b', 'B', 'r', 'a' are NOT aliases for brackets.
+        // They are treated as literal characters, so 'mdb' looks for 'b...b' surrounds.
+
+        // 'b' is not an alias - it looks for literal 'b...b', finds none, does nothing
+        cx.set_state("hello (woˇrld) test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d b");
+        cx.assert_state("hello (woˇrld) test", Mode::HelixNormal);
+
+        // 'B' looks for literal 'B...B', not {}
+        cx.set_state("hello {woˇrld} test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d B");
+        cx.assert_state("hello {woˇrld} test", Mode::HelixNormal);
+
+        // 'r' looks for literal 'r...r', not []
+        cx.set_state("hello [woˇrld] test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d r");
+        cx.assert_state("hello [woˇrld] test", Mode::HelixNormal);
+
+        // 'a' looks for literal 'a...a', not <>
+        cx.set_state("hello <woˇrld> test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d a");
+        cx.assert_state("hello <woˇrld> test", Mode::HelixNormal);
+
+        // Arbitrary chars work as symmetric pairs (Helix feature)
+        cx.set_state("hello *woˇrld* test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d *");
+        cx.assert_state("hello woˇrld test", Mode::HelixNormal);
+
+        // ms (add) also doesn't use aliases - 'msb' adds literal 'b' surrounds
+        cx.set_state("hello ˇworld", Mode::HelixNormal);
+        cx.simulate_keystrokes("m s b");
+        cx.assert_state("hello bwˇborld", Mode::HelixNormal);
+
+        // mr (replace) also doesn't use aliases
+        cx.set_state("hello (woˇrld) test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m r ( b");
+        cx.assert_state("hello bwoˇrldb test", Mode::HelixNormal);
+    }
+
+    #[gpui::test]
+    async fn test_helix_surround_match_nearest(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        // mdm - delete nearest surrounding pair
+        cx.set_state("hello (woˇrld) test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d m");
+        cx.assert_state("hello woˇrld test", Mode::HelixNormal);
+
+        cx.set_state("hello [woˇrld] test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d m");
+        cx.assert_state("hello woˇrld test", Mode::HelixNormal);
+
+        cx.set_state("hello {woˇrld} test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d m");
+        cx.assert_state("hello woˇrld test", Mode::HelixNormal);
+
+        // Nested - deletes innermost
+        cx.set_state("([woˇrld])", Mode::HelixNormal);
+        cx.simulate_keystrokes("m d m");
+        cx.assert_state("(woˇrld)", Mode::HelixNormal);
+
+        // mrm - replace nearest surrounding pair
+        cx.set_state("hello (woˇrld) test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m r m [");
+        cx.assert_state("hello [woˇrld] test", Mode::HelixNormal);
+
+        cx.set_state("hello {woˇrld} test", Mode::HelixNormal);
+        cx.simulate_keystrokes("m r m (");
+        cx.assert_state("hello (woˇrld) test", Mode::HelixNormal);
+
+        // Nested - replaces innermost
+        cx.set_state("([woˇrld])", Mode::HelixNormal);
+        cx.simulate_keystrokes("m r m {");
+        cx.assert_state("({woˇrld})", Mode::HelixNormal);
+    }
+}

crates/vim/src/state.rs 🔗

@@ -142,6 +142,11 @@ pub enum Operator {
     HelixPrevious {
         around: bool,
     },
+    HelixSurroundAdd,
+    HelixSurroundReplace {
+        replaced_char: Option<char>,
+    },
+    HelixSurroundDelete,
 }
 
 #[derive(Default, Clone, Debug)]
@@ -1043,6 +1048,9 @@ impl Operator {
             Operator::HelixMatch => "helix_m",
             Operator::HelixNext { .. } => "helix_next",
             Operator::HelixPrevious { .. } => "helix_previous",
+            Operator::HelixSurroundAdd => "helix_ms",
+            Operator::HelixSurroundReplace { .. } => "helix_mr",
+            Operator::HelixSurroundDelete => "helix_md",
         }
     }
 
@@ -1067,6 +1075,14 @@ impl Operator {
             Operator::HelixMatch => "m".to_string(),
             Operator::HelixNext { .. } => "]".to_string(),
             Operator::HelixPrevious { .. } => "[".to_string(),
+            Operator::HelixSurroundAdd => "ms".to_string(),
+            Operator::HelixSurroundReplace {
+                replaced_char: None,
+            } => "mr".to_string(),
+            Operator::HelixSurroundReplace {
+                replaced_char: Some(c),
+            } => format!("mr{}", c),
+            Operator::HelixSurroundDelete => "md".to_string(),
             _ => self.id().to_string(),
         }
     }
@@ -1111,6 +1127,9 @@ impl Operator {
             | Operator::HelixMatch
             | Operator::HelixNext { .. }
             | Operator::HelixPrevious { .. } => false,
+            Operator::HelixSurroundAdd
+            | Operator::HelixSurroundReplace { .. }
+            | Operator::HelixSurroundDelete => true,
         }
     }
 
@@ -1136,7 +1155,10 @@ impl Operator {
             | Operator::DeleteSurrounds
             | Operator::Exchange
             | Operator::HelixNext { .. }
-            | Operator::HelixPrevious { .. } => true,
+            | Operator::HelixPrevious { .. }
+            | Operator::HelixSurroundAdd
+            | Operator::HelixSurroundReplace { .. }
+            | Operator::HelixSurroundDelete => true,
             Operator::Yank
             | Operator::Object { .. }
             | Operator::FindForward { .. }

crates/vim/src/surrounds.rs 🔗

@@ -10,6 +10,64 @@ use language::BracketPair;
 
 use std::sync::Arc;
 
+/// A char-based surround pair definition.
+/// Single source of truth for all supported surround pairs.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SurroundPair {
+    pub open: char,
+    pub close: char,
+}
+
+impl SurroundPair {
+    pub const fn new(open: char, close: char) -> Self {
+        Self { open, close }
+    }
+
+    pub fn to_bracket_pair(self) -> BracketPair {
+        BracketPair {
+            start: self.open.to_string(),
+            end: self.close.to_string(),
+            close: true,
+            surround: true,
+            newline: false,
+        }
+    }
+
+    pub fn to_object(self) -> Option<Object> {
+        match self.open {
+            '\'' => 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,
+        }
+    }
+}
+
+/// All supported surround pairs - single source of truth.
+pub const SURROUND_PAIRS: &[SurroundPair] = &[
+    SurroundPair::new('(', ')'),
+    SurroundPair::new('[', ']'),
+    SurroundPair::new('{', '}'),
+    SurroundPair::new('<', '>'),
+    SurroundPair::new('"', '"'),
+    SurroundPair::new('\'', '\''),
+    SurroundPair::new('`', '`'),
+    SurroundPair::new('|', '|'),
+];
+
+/// Bracket-only pairs for AnyBrackets matching.
+const BRACKET_PAIRS: &[SurroundPair] = &[
+    SurroundPair::new('(', ')'),
+    SurroundPair::new('[', ']'),
+    SurroundPair::new('{', '}'),
+    SurroundPair::new('<', '>'),
+];
+
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum SurroundsType {
     Motion(Motion),
@@ -34,16 +92,7 @@ impl Vim {
             editor.transact(window, cx, |editor, window, 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,
-                        surround: true,
-                        newline: false,
-                    },
-                };
+                let pair = bracket_pair_for_str_vim(&text);
                 let surround = pair.end != surround_alias((*text).as_ref());
                 let display_map = editor.display_snapshot(cx);
                 let display_selections = editor.selections.all_adjusted_display(&display_map);
@@ -131,14 +180,16 @@ impl Vim {
         self.stop_recording(cx);
 
         // only legitimate surrounds can be removed
-        let pair = match find_surround_pair(&all_support_surround_pair(), &text) {
-            Some(pair) => pair.clone(),
-            None => return,
+        let Some(first_char) = text.chars().next() else {
+            return;
+        };
+        let Some(surround_pair) = surround_pair_for_char_vim(first_char) else {
+            return;
         };
-        let pair_object = match pair_to_object(&pair) {
-            Some(pair_object) => pair_object,
-            None => return,
+        let Some(pair_object) = surround_pair.to_object() else {
+            return;
         };
+        let pair = surround_pair.to_bracket_pair();
         let surround = pair.end != *text;
 
         self.update_editor(cx, |_, editor, cx| {
@@ -233,16 +284,7 @@ impl Vim {
                 editor.transact(window, cx, |editor, window, 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,
-                            surround: true,
-                            newline: false,
-                        },
-                    };
+                    let pair = bracket_pair_for_str_vim(&text);
 
                     // A single space should be added if the new surround is a
                     // bracket and not a quote (pair.start != pair.end) and if
@@ -437,144 +479,91 @@ impl Vim {
         object: Object,
         cx: &mut Context<Self>,
     ) -> Option<BracketPair> {
-        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 = editor.display_snapshot(cx);
-                    let selections = editor.selections.all_adjusted_display(&display_map);
-                    // 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,
-                                        })
-                                    }
-                                }
+        if let Some(pair) = object_to_surround_pair(object) {
+            return Some(pair.to_bracket_pair());
+        }
+
+        if object != Object::AnyBrackets {
+            return None;
+        }
+
+        // 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 best_pair = None;
+        let mut min_range_size = usize::MAX;
+
+        let _ = self.editor.update(cx, |editor, cx| {
+            let display_map = editor.display_snapshot(cx);
+            let selections = editor.selections.all_adjusted_display(&display_map);
+            // 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 cursor_offset = relative_to.to_offset(&display_map, Bias::Left);
+
+                for pair in BRACKET_PAIRS {
+                    if let Some(range) = surrounding_markers(
+                        &display_map,
+                        relative_to,
+                        true,
+                        false,
+                        pair.open,
+                        pair.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;
+                                best_pair = Some(*pair);
                             }
                         }
                     }
-                });
-
-                bracket_pair
+                }
             }
-            _ => None,
-        }
+        });
+
+        best_pair.map(|p| p.to_bracket_pair())
     }
 }
 
-fn find_surround_pair<'a>(pairs: &'a [BracketPair], ch: &str) -> Option<&'a BracketPair> {
-    pairs
-        .iter()
-        .find(|pair| pair.start == surround_alias(ch) || pair.end == surround_alias(ch))
+/// Convert an Object to its corresponding SurroundPair.
+fn object_to_surround_pair(object: Object) -> Option<SurroundPair> {
+    let open = match object {
+        Object::Quotes => '\'',
+        Object::BackQuotes => '`',
+        Object::DoubleQuotes => '"',
+        Object::VerticalBars => '|',
+        Object::Parentheses => '(',
+        Object::SquareBrackets => '[',
+        Object::CurlyBrackets { .. } => '{',
+        Object::AngleBrackets => '<',
+        _ => return None,
+    };
+    surround_pair_for_char_vim(open)
 }
 
-fn surround_alias(ch: &str) -> &str {
+pub fn surround_alias(ch: &str) -> &str {
     match ch {
         "b" => ")",
         "B" => "}",
@@ -584,79 +573,65 @@ fn surround_alias(ch: &str) -> &str {
     }
 }
 
-fn all_support_surround_pair() -> Vec<BracketPair> {
-    vec![
-        BracketPair {
-            start: "{".into(),
-            end: "}".into(),
-            close: true,
-            surround: true,
-            newline: false,
-        },
-        BracketPair {
-            start: "'".into(),
-            end: "'".into(),
-            close: true,
-            surround: true,
-            newline: false,
-        },
-        BracketPair {
-            start: "`".into(),
-            end: "`".into(),
-            close: true,
-            surround: true,
-            newline: false,
-        },
-        BracketPair {
-            start: "\"".into(),
-            end: "\"".into(),
-            close: true,
-            surround: true,
-            newline: false,
-        },
-        BracketPair {
-            start: "(".into(),
-            end: ")".into(),
-            close: true,
-            surround: true,
-            newline: false,
-        },
-        BracketPair {
-            start: "|".into(),
-            end: "|".into(),
-            close: true,
-            surround: true,
-            newline: false,
-        },
-        BracketPair {
-            start: "[".into(),
-            end: "]".into(),
-            close: true,
-            surround: true,
-            newline: false,
-        },
-        BracketPair {
-            start: "<".into(),
-            end: ">".into(),
+fn literal_surround_pair(ch: char) -> Option<SurroundPair> {
+    SURROUND_PAIRS
+        .iter()
+        .find(|p| p.open == ch || p.close == ch)
+        .copied()
+}
+
+/// Resolve a character (including Vim aliases) to its surround pair.
+/// Returns None for 'm' (match nearest) or unknown chars.
+pub fn surround_pair_for_char_vim(ch: char) -> Option<SurroundPair> {
+    let resolved = match ch {
+        'b' => ')',
+        'B' => '}',
+        'r' => ']',
+        'a' => '>',
+        'm' => return None,
+        _ => ch,
+    };
+    literal_surround_pair(resolved)
+}
+
+/// Get a BracketPair for the given string, with fallback for unknown chars.
+/// For vim surround operations that accept any character as a surround.
+pub fn bracket_pair_for_str_vim(text: &str) -> BracketPair {
+    text.chars()
+        .next()
+        .and_then(surround_pair_for_char_vim)
+        .map(|p| p.to_bracket_pair())
+        .unwrap_or_else(|| BracketPair {
+            start: text.to_string(),
+            end: text.to_string(),
             close: true,
             surround: true,
             newline: false,
-        },
-    ]
+        })
 }
 
-fn pair_to_object(pair: &BracketPair) -> Option<Object> {
-    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,
+/// Resolve a character to its surround pair using Helix semantics (no Vim aliases).
+/// Returns None only for 'm' (match nearest). Unknown chars map to symmetric pairs.
+pub fn surround_pair_for_char_helix(ch: char) -> Option<SurroundPair> {
+    if ch == 'm' {
+        return None;
     }
+    literal_surround_pair(ch).or_else(|| Some(SurroundPair::new(ch, ch)))
+}
+
+/// Get a BracketPair for the given string in Helix mode (literal, symmetric fallback).
+pub fn bracket_pair_for_str_helix(text: &str) -> BracketPair {
+    text.chars()
+        .next()
+        .and_then(surround_pair_for_char_helix)
+        .map(|p| p.to_bracket_pair())
+        .unwrap_or_else(|| BracketPair {
+            start: text.to_string(),
+            end: text.to_string(),
+            close: true,
+            surround: true,
+            newline: false,
+        })
 }
 
 #[cfg(test)]
@@ -1702,4 +1677,41 @@ mod test {
             Mode::Normal,
         );
     }
+
+    #[test]
+    fn test_surround_pair_for_char() {
+        use super::{SURROUND_PAIRS, surround_pair_for_char_helix, surround_pair_for_char_vim};
+
+        fn as_tuple(pair: Option<super::SurroundPair>) -> Option<(char, char)> {
+            pair.map(|p| (p.open, p.close))
+        }
+
+        assert_eq!(as_tuple(surround_pair_for_char_vim('b')), Some(('(', ')')));
+        assert_eq!(as_tuple(surround_pair_for_char_vim('B')), Some(('{', '}')));
+        assert_eq!(as_tuple(surround_pair_for_char_vim('r')), Some(('[', ']')));
+        assert_eq!(as_tuple(surround_pair_for_char_vim('a')), Some(('<', '>')));
+
+        assert_eq!(surround_pair_for_char_vim('m'), None);
+
+        for pair in SURROUND_PAIRS {
+            assert_eq!(
+                as_tuple(surround_pair_for_char_vim(pair.open)),
+                Some((pair.open, pair.close))
+            );
+            assert_eq!(
+                as_tuple(surround_pair_for_char_vim(pair.close)),
+                Some((pair.open, pair.close))
+            );
+        }
+
+        // Test unknown char returns None
+        assert_eq!(surround_pair_for_char_vim('x'), None);
+
+        // Helix resolves literal chars and falls back to symmetric pairs.
+        assert_eq!(
+            as_tuple(surround_pair_for_char_helix('*')),
+            Some(('*', '*'))
+        );
+        assert_eq!(surround_pair_for_char_helix('m'), None);
+    }
 }

crates/vim/src/vim.rs 🔗

@@ -252,6 +252,12 @@ actions!(
         ToggleProjectPanelFocus,
         /// Starts a match operation.
         PushHelixMatch,
+        /// Adds surrounding characters in Helix mode.
+        PushHelixSurroundAdd,
+        /// Replaces surrounding characters in Helix mode.
+        PushHelixSurroundReplace,
+        /// Deletes surrounding characters in Helix mode.
+        PushHelixSurroundDelete,
     ]
 );
 
@@ -1888,6 +1894,60 @@ impl Vim {
                 }
                 _ => self.clear_operator(window, cx),
             },
+            Some(Operator::HelixSurroundAdd) => match self.mode {
+                Mode::HelixNormal | Mode::HelixSelect => {
+                    self.update_editor(cx, |_, editor, cx| {
+                        editor.change_selections(Default::default(), window, cx, |s| {
+                            s.move_with(|map, selection| {
+                                if selection.is_empty() {
+                                    selection.end = movement::right(map, selection.start);
+                                }
+                            });
+                        });
+                    });
+                    self.helix_surround_add(&text, window, cx);
+                    self.switch_mode(Mode::HelixNormal, false, window, cx);
+                    self.clear_operator(window, cx);
+                }
+                _ => self.clear_operator(window, cx),
+            },
+            Some(Operator::HelixSurroundReplace {
+                replaced_char: Some(old),
+            }) => match self.mode {
+                Mode::HelixNormal | Mode::HelixSelect => {
+                    if let Some(new_char) = text.chars().next() {
+                        self.helix_surround_replace(old, new_char, window, cx);
+                    }
+                    self.clear_operator(window, cx);
+                }
+                _ => self.clear_operator(window, cx),
+            },
+            Some(Operator::HelixSurroundReplace {
+                replaced_char: None,
+            }) => match self.mode {
+                Mode::HelixNormal | Mode::HelixSelect => {
+                    if let Some(ch) = text.chars().next() {
+                        self.pop_operator(window, cx);
+                        self.push_operator(
+                            Operator::HelixSurroundReplace {
+                                replaced_char: Some(ch),
+                            },
+                            window,
+                            cx,
+                        );
+                    }
+                }
+                _ => self.clear_operator(window, cx),
+            },
+            Some(Operator::HelixSurroundDelete) => match self.mode {
+                Mode::HelixNormal | Mode::HelixSelect => {
+                    if let Some(ch) = text.chars().next() {
+                        self.helix_surround_delete(ch, window, cx);
+                    }
+                    self.clear_operator(window, cx);
+                }
+                _ => self.clear_operator(window, cx),
+            },
             Some(Operator::Mark) => self.create_mark(text, window, cx),
             Some(Operator::RecordRegister) => {
                 self.record_register(text.chars().next().unwrap(), window, cx)