From 9a50bd44082c854b48973735ffef9311d9d6154e Mon Sep 17 00:00:00 2001 From: Leon Qadirie <39130739+leonqadirie@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:42:13 +0100 Subject: [PATCH] Add helix match surround operations (#44317) Partially addresses #38151 Release Notes: - Added Helix match surround operations --- 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(-) create mode 100644 crates/vim/src/helix/surround.rs diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 6892082846b96e5cb3717c826c013f1ac29505ba..3eddcc3dd32275d6286b0070e5dfbbac445e17e4 100644 --- a/assets/keymaps/vim.json +++ b/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", }, }, { diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index f902a8ff6e9f08475fb6ce8323a924730d3621d1..ce9261d0feafc9c3b470db40ed7044c36925a6d9 100644 --- a/crates/vim/src/helix.rs +++ b/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::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 { diff --git a/crates/vim/src/helix/surround.rs b/crates/vim/src/helix/surround.rs new file mode 100644 index 0000000000000000000000000000000000000000..69f59c0506d0de032c72cad2bac432d2b8cb102e --- /dev/null +++ b/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 { + if selection.reversed || selection.is_empty() { + selection.head() + } else { + editor::movement::left(map, selection.head()) + } +} + +type SurroundEdits = Vec<(std::ops::Range, String)>; +type SurroundAnchors = Vec>; + +fn apply_helix_surround_edits( + vim: &mut Vim, + window: &mut Window, + cx: &mut Context, + mut build: F, +) where + F: FnMut(&DisplaySnapshot, Vec>) -> (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.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.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, 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.stop_recording(cx); + + apply_helix_surround_edits(self, window, cx, |display_map, selections| { + let mut edits: Vec<(std::ops::Range, 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 test", Mode::HelixNormal); + cx.simulate_keystrokes("m d a"); + cx.assert_state("hello 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); + } +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 2a8aa91063be89ebd616a2f9601f90c912cee8b5..f4c3af7131d6e76be9046f90ff425282ce03e97e 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -142,6 +142,11 @@ pub enum Operator { HelixPrevious { around: bool, }, + HelixSurroundAdd, + HelixSurroundReplace { + replaced_char: Option, + }, + 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 { .. } diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index b3f9307aac3df18334cf24a619dc640ccb625e24..07dec88a2262c26a2776b4580afb3e1b44b0e911 100644 --- a/crates/vim/src/surrounds.rs +++ b/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 { + 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, ) -> 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 = 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 { + 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 { - 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 { + 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 { + 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 { - 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 { + 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) -> 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); + } } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 26fec968fb261fbb80a9f84211357623147ca0f4..a232781657c5742e1aadc893827316ac6d8c7d42 100644 --- a/crates/vim/src/vim.rs +++ b/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)