Skip to content

Commit

Permalink
Vi-Mode Feature: Atomic unified commands for ChangeInside/DeleteInsid…
Browse files Browse the repository at this point in the history
…e/YankInside(to be added):

- added thorough unittests to emulate standard Vi behavior.
- handle nested brackets but not nested quotes, alinged with Vi behavior
  • Loading branch information
deephbz committed Jan 18, 2025
1 parent a478473 commit 1490b42
Show file tree
Hide file tree
Showing 5 changed files with 273 additions and 69 deletions.
100 changes: 100 additions & 0 deletions src/core_editor/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ impl Editor {
EditCommand::CopySelectionSystem => self.copy_selection_to_system(),
#[cfg(feature = "system_clipboard")]
EditCommand::PasteSystem => self.paste_from_system(),
EditCommand::CutInside {
left_char,
right_char,
} => self.cut_inside(*left_char, *right_char),
}
if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) {
self.selection_anchor = None;
Expand Down Expand Up @@ -661,6 +665,35 @@ impl Editor {
pub(crate) fn reset_selection(&mut self) {
self.selection_anchor = None;
}

/// Delete text strictly between matching `left_char` and `right_char`.
/// Places deleted text into the cut buffer.
/// Leaves the parentheses/quotes/etc. themselves.
/// On success, move the cursor just after the `left_char`.
/// If matching chars can't be found, restore the original cursor.
pub(crate) fn cut_inside(&mut self, left_char: char, right_char: char) {
let old_pos = self.insertion_point();
let buffer_len = self.line_buffer.len();

if let Some((lp, rp)) =
self.line_buffer
.find_matching_pair(left_char, right_char, self.insertion_point())
{
let inside_start = lp + left_char.len_utf8();
if inside_start < rp && rp <= buffer_len {
let inside_slice = &self.line_buffer.get_buffer()[inside_start..rp];
if !inside_slice.is_empty() {
self.cut_buffer.set(inside_slice, ClipboardMode::Normal);
self.line_buffer.clear_range_safe(inside_start, rp);
}
self.line_buffer
.set_insertion_point(lp + left_char.len_utf8());
return;
}
}
// If no valid pair was found, restore original cursor
self.line_buffer.set_insertion_point(old_pos);
}
}

fn insert_clipboard_content_before(line_buffer: &mut LineBuffer, clipboard: &mut dyn Clipboard) {
Expand Down Expand Up @@ -911,4 +944,71 @@ mod test {
pretty_assertions::assert_eq!(editor.line_buffer.len(), s.len() * 2);
}
}

#[test]
fn test_cut_inside_brackets() {
let mut editor = editor_with("foo(bar)baz");
editor.move_to_position(5, false); // Move inside brackets
editor.cut_inside('(', ')');
assert_eq!(editor.get_buffer(), "foo()baz");
assert_eq!(editor.insertion_point(), 4);
assert_eq!(editor.cut_buffer.get().0, "bar");

// Test with cursor outside brackets
let mut editor = editor_with("foo(bar)baz");
editor.move_to_position(0, false);
editor.cut_inside('(', ')');
assert_eq!(editor.get_buffer(), "foo(bar)baz");
assert_eq!(editor.insertion_point(), 0);
assert_eq!(editor.cut_buffer.get().0, "");

// Test with no matching brackets
let mut editor = editor_with("foo bar baz");
editor.move_to_position(4, false);
editor.cut_inside('(', ')');
assert_eq!(editor.get_buffer(), "foo bar baz");
assert_eq!(editor.insertion_point(), 4);
assert_eq!(editor.cut_buffer.get().0, "");
}

#[test]
fn test_cut_inside_quotes() {
let mut editor = editor_with("foo\"bar\"baz");
editor.move_to_position(5, false); // Move inside quotes
editor.cut_inside('"', '"');
assert_eq!(editor.get_buffer(), "foo\"\"baz");
assert_eq!(editor.insertion_point(), 4);
assert_eq!(editor.cut_buffer.get().0, "bar");

// Test with cursor outside quotes
let mut editor = editor_with("foo\"bar\"baz");
editor.move_to_position(0, false);
editor.cut_inside('"', '"');
assert_eq!(editor.get_buffer(), "foo\"bar\"baz");
assert_eq!(editor.insertion_point(), 0);
assert_eq!(editor.cut_buffer.get().0, "");

// Test with no matching quotes
let mut editor = editor_with("foo bar baz");
editor.move_to_position(4, false);
editor.cut_inside('"', '"');
assert_eq!(editor.get_buffer(), "foo bar baz");
assert_eq!(editor.insertion_point(), 4);
}

#[test]
fn test_cut_inside_nested() {
let mut editor = editor_with("foo(bar(baz)qux)quux");
editor.move_to_position(8, false); // Move inside inner brackets
editor.cut_inside('(', ')');
assert_eq!(editor.get_buffer(), "foo(bar()qux)quux");
assert_eq!(editor.insertion_point(), 8);
assert_eq!(editor.cut_buffer.get().0, "baz");

editor.move_to_position(4, false); // Move inside outer brackets
editor.cut_inside('(', ')');
assert_eq!(editor.get_buffer(), "foo()quux");
assert_eq!(editor.insertion_point(), 4);
assert_eq!(editor.cut_buffer.get().0, "bar()qux");
}
}
113 changes: 113 additions & 0 deletions src/core_editor/line_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,119 @@ impl LineBuffer {
self.insertion_point = index + c.len_utf8();
}
}

/// Attempts to find the matching `(left_char, right_char)` pair *enclosing*
/// the cursor position, respecting nested pairs.
///
/// Algorithm:
/// 1. Walk left from `cursor` until we find the "outermost" `left_char`,
/// ignoring any extra `right_char` we see (i.e., we keep a depth counter).
/// 2. Then from that left bracket, walk right to find the matching `right_char`,
/// also respecting nesting.
///
/// Returns `Some((left_index, right_index))` if found, or `None` otherwise.
pub fn find_matching_pair(
&self,
left_char: char,
right_char: char,
cursor: usize,
) -> Option<(usize, usize)> {
// Special case: quotes or the same char for left & right
// (Vi doesn't do nested quotes, so no depth counting).
if left_char == right_char {
// 1) Walk left to find the first matching quote
let mut scan_pos = cursor;
while scan_pos > 0 {
// Move left by one grapheme
let mut tmp = LineBuffer {
lines: self.lines.clone(),
insertion_point: scan_pos,
};
tmp.move_left();
scan_pos = tmp.insertion_point;

if scan_pos >= self.lines.len() {
break;
}
let ch = self.lines[scan_pos..].chars().next().unwrap_or('\0');
if ch == left_char {
// Found the "left quote"
let left_index = scan_pos;
// 2) Now walk right to find the next matching quote
let mut scan_pos_r = left_index + left_char.len_utf8();
while scan_pos_r < self.lines.len() {
let next_ch = self.lines[scan_pos_r..].chars().next().unwrap();
if next_ch == right_char {
return Some((left_index, scan_pos_r));
}
scan_pos_r += next_ch.len_utf8();
}
return None; // no right quote found
}
}
return None; // no left quote found
}

// Step 1: search left
let mut scan_pos = cursor;
let mut depth = 0;

while scan_pos > 0 {
// Move left by one grapheme
scan_pos = {
// a small helper to move left from an arbitrary position
let mut tmp = LineBuffer {
lines: self.lines.clone(),
insertion_point: scan_pos,
};
tmp.move_left();
tmp.insertion_point
};
if scan_pos >= self.lines.len() {
break;
}

let ch = self.lines[scan_pos..].chars().next().unwrap_or('\0');

if ch == left_char && depth == 0 {
// Found the "outermost" left bracket
let left_index = scan_pos;
// Step 2: search right from `left_index + left_char.len_utf8()` to find matching
let mut scan_pos_r = left_index + left_char.len_utf8();
let mut depth_r = 0;

while scan_pos_r < self.lines.len() {
let next_ch = self.lines[scan_pos_r..].chars().next().unwrap();
if next_ch == left_char {
depth_r += 1;
} else if next_ch == right_char {
if depth_r == 0 {
// Found the matching close
let right_index = scan_pos_r;
return Some((left_index, right_index));
} else {
depth_r -= 1;
}
}
scan_pos_r += next_ch.len_utf8();
}
// Matching right bracket not found
return None;
} else if ch == right_char {
// This means we are "inside" nested parentheses, so increment nesting
depth += 1;
} else if ch == left_char {
// If we see another left_char while depth>0, it just closes one nesting level
if depth > 0 {
depth -= 1;
} else {
// That would be the outer bracket if depth==0,
// but we handle that in the `if ch == left_char && depth == 0` above
}
}
}
None
}
}

/// Match any sequence of characters that are considered a word boundary
Expand Down
102 changes: 41 additions & 61 deletions src/edit_mode/vi/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ where
match input.peek() {
Some('d') => {
let _ = input.next();
// Checking for "di(" or "di)" etc.
if let Some('i') = input.peek() {
let _ = input.next();
match input.next() {
Some(c)
if is_valid_change_inside_left(c) || is_valid_change_inside_right(c) =>
{
Some(Command::DeleteInside(*c))
Some(&c) => {
if let Some((l, r)) = bracket_pair_for(c) {
Some(Command::DeleteInsidePair { left: l, right: r })
} else {
None
}
}
_ => None,
}
Expand Down Expand Up @@ -43,15 +46,18 @@ where
let _ = input.next();
Some(Command::Undo)
}
// Checking for "ci(" or "ci)" etc.
Some('c') => {
let _ = input.next();
if let Some('i') = input.peek() {
let _ = input.next();
match input.next() {
Some(c)
if is_valid_change_inside_left(c) || is_valid_change_inside_right(c) =>
{
Some(Command::ChangeInside(*c))
Some(&c) => {
if let Some((l, r)) = bracket_pair_for(c) {
Some(Command::ChangeInsidePair { left: l, right: r })
} else {
None
}
}
_ => None,
}
Expand Down Expand Up @@ -131,8 +137,9 @@ pub enum Command {
HistorySearch,
Switchcase,
RepeatLastAction,
ChangeInside(char),
DeleteInside(char),
// These DoSthInsidePair commands are agnostic to whether user pressed the left char or right char
ChangeInsidePair { left: char, right: char },
DeleteInsidePair { left: char, right: char },
}

impl Command {
Expand Down Expand Up @@ -192,39 +199,17 @@ impl Command {
Some(event) => vec![ReedlineOption::Event(event.clone())],
None => vec![],
},
Self::ChangeInside(left) if is_valid_change_inside_left(left) => {
let right = bracket_for(left);
vec![
ReedlineOption::Edit(EditCommand::CutLeftBefore(*left)),
ReedlineOption::Edit(EditCommand::CutRightBefore(right)),
]
Self::ChangeInsidePair { left, right } => {
vec![ReedlineOption::Edit(EditCommand::CutInside {
left_char: *left,
right_char: *right,
})]
}
Self::ChangeInside(right) if is_valid_change_inside_right(right) => {
let left = bracket_for(right);
vec![
ReedlineOption::Edit(EditCommand::CutLeftBefore(left)),
ReedlineOption::Edit(EditCommand::CutRightBefore(*right)),
]
}
Self::ChangeInside(_) => {
vec![]
}
Self::DeleteInside(left) if is_valid_change_inside_left(left) => {
let right = bracket_for(left);
vec![
ReedlineOption::Edit(EditCommand::CutLeftBefore(*left)),
ReedlineOption::Edit(EditCommand::CutRightBefore(right)),
]
}
Self::DeleteInside(right) if is_valid_change_inside_right(right) => {
let left = bracket_for(right);
vec![
ReedlineOption::Edit(EditCommand::CutLeftBefore(left)),
ReedlineOption::Edit(EditCommand::CutRightBefore(*right)),
]
}
Self::DeleteInside(_) => {
vec![]
Self::DeleteInsidePair { left, right } => {
vec![ReedlineOption::Edit(EditCommand::CutInside {
left_char: *left,
right_char: *right,
})]
}
}
}
Expand Down Expand Up @@ -349,24 +334,19 @@ impl Command {
}
}

fn bracket_for(c: &char) -> char {
match *c {
'(' => ')',
'[' => ']',
'{' => '}',
'<' => '>',
')' => '(',
']' => '[',
'}' => '{',
'>' => '<',
_ => *c,
fn bracket_pair_for(c: char) -> Option<(char, char)> {
match c {
'(' => Some(('(', ')')),
'[' => Some(('[', ']')),
'{' => Some(('{', '}')),
'<' => Some(('<', '>')),
')' => Some(('(', ')')),
']' => Some(('[', ']')),
'}' => Some(('{', '}')),
'>' => Some(('<', '>')),
'"' => Some(('"', '"')),
'\'' => Some(('\'', '\'')),
'`' => Some(('`', '`')),
_ => None,
}
}

pub(crate) fn is_valid_change_inside_left(c: &char) -> bool {
matches!(c, '(' | '[' | '{' | '"' | '\'' | '`' | '<')
}

pub(crate) fn is_valid_change_inside_right(c: &char) -> bool {
matches!(c, ')' | ']' | '}' | '"' | '\'' | '`' | '>')
}
Loading

0 comments on commit 1490b42

Please sign in to comment.