Detailed changes
@@ -643,6 +643,9 @@
"context": "vim_operator == helix_m",
"bindings": {
"m": "vim::Matching",
+ "s": "vim::PushHelixSurroundAdd",
+ "r": "vim::PushHelixSurroundReplace",
+ "d": "vim::PushHelixSurroundDelete",
},
},
{
@@ -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 {
@@ -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);
+ }
+}
@@ -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 { .. }
@@ -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);
+ }
}
@@ -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)