Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vi-Mode Feature: Atomic unified commands for ChangeInside/DeleteInside #874

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/core_editor/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ impl Editor {
EditCommand::CopySelectionSystem => self.copy_selection_to_system(),
#[cfg(feature = "system_clipboard")]
EditCommand::PasteSystem => self.paste_from_system(),
EditCommand::CutInside { left, right } => self.cut_inside(*left, *right),
}
if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) {
self.selection_anchor = None;
Expand Down Expand Up @@ -661,6 +662,31 @@ 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 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());
}
}
}
}

fn insert_clipboard_content_before(line_buffer: &mut LineBuffer, clipboard: &mut dyn Clipboard) {
Expand Down Expand Up @@ -911,4 +937,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");
}
}
94 changes: 94 additions & 0 deletions src/core_editor/line_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,68 @@ 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)> {
// encode to &str so we can compare with &strs later
let mut tmp = ([0u8; 4], [0u8, 4]);
let left_str = left_char.encode_utf8(&mut tmp.0);
let right_str = right_char.encode_utf8(&mut tmp.1);
// search left for left char
let to_cursor = self.lines.get(..=cursor)?;
let left_index = find_with_depth(to_cursor, left_str, right_str, true)?;

// search right for right char
let scan_start = left_index + left_char.len_utf8();
let after_left = self.lines.get(scan_start..)?;
let right_offset = find_with_depth(after_left, right_str, left_str, false)?;

Some((left_index, scan_start + right_offset))
}
}

/// Helper function for [`LineBuffer::find_matching_pair`]
fn find_with_depth(
slice: &str,
deep_char: &str,
shallow_char: &str,
reverse: bool,
) -> Option<usize> {
let mut depth: i32 = 0;

let mut indices: Vec<_> = slice.grapheme_indices(true).collect();
if reverse {
indices.reverse();
}

for (idx, c) in indices.into_iter() {
match c {
c if c == deep_char && depth == 0 => return Some(idx),
c if c == deep_char => depth -= 1,
// special case: shallow char at end of slice shouldn't affect depth.
// cursor over right bracket should be counted as the end of the pair,
// not as a closing a separate nested pair
c if c == shallow_char && idx == (slice.len() - 1) => (),
c if c == shallow_char => depth += 1,
_ => (),
}
}

None
}

/// Match any sequence of characters that are considered a word boundary
Expand Down Expand Up @@ -1628,4 +1690,36 @@ mod test {
"input: {input:?}, pos: {position}"
);
}

#[rstest]
#[case("(abc)", 0, '(', ')', Some((0, 4)))] // Basic matching
#[case("(abc)", 4, '(', ')', Some((0, 4)))] // Cursor at end
#[case("(abc)", 2, '(', ')', Some((0, 4)))] // Cursor in middle
#[case("((abc))", 0, '(', ')', Some((0, 6)))] // Nested pairs outer
#[case("((abc))", 1, '(', ')', Some((1, 5)))] // Nested pairs inner
#[case("(abc)(def)", 0, '(', ')', Some((0, 4)))] // Multiple pairs first
#[case("(abc)(def)", 5, '(', ')', Some((5, 9)))] // Multiple pairs second
#[case("(abc", 0, '(', ')', None)] // Incomplete open
#[case("abc)", 3, '(', ')', None)] // Incomplete close
#[case("()", 0, '(', ')', Some((0, 1)))] // Empty pair
#[case("()", 1, '(', ')', Some((0, 1)))] // Empty pair from end
#[case("(αβγ)", 0, '(', ')', Some((0, 7)))] // Unicode content
#[case("([)]", 0, '(', ')', Some((0, 2)))] // Mixed brackets
#[case("\"abc\"", 0, '"', '"', Some((0, 4)))] // Quotes
fn test_find_matching_pair(
#[case] input: &str,
#[case] cursor: usize,
#[case] left_char: char,
#[case] right_char: char,
#[case] expected: Option<(usize, usize)>,
) {
let buf = LineBuffer::from(input);
assert_eq!(
buf.find_matching_pair(left_char, right_char, cursor),
expected,
"Failed for input: {}, cursor: {}",
input,
cursor
);
}
}
111 changes: 38 additions & 73 deletions src/edit_mode/vi/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,13 @@ 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))
}
_ => None,
}
input
.next()
.and_then(|c| bracket_pair_for(*c))
.map(|(left, right)| Command::DeleteInsidePair { left, right })
} else {
Some(Command::Delete)
}
Expand All @@ -43,18 +40,15 @@ 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))
}
_ => None,
}
input
.next()
.and_then(|c| bracket_pair_for(*c))
.map(|(left, right)| Command::ChangeInsidePair { left, right })
} else {
Some(Command::Change)
}
Expand All @@ -65,10 +59,10 @@ where
}
Some('r') => {
let _ = input.next();
match input.next() {
Some(c) => Some(Command::ReplaceChar(*c)),
None => Some(Command::Incomplete),
}
input
.next()
.map(|c| Command::ReplaceChar(*c))
.or(Some(Command::Incomplete))
}
Some('s') => {
let _ = input.next();
Expand Down Expand Up @@ -131,8 +125,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 +187,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::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::ChangeInsidePair { left, right } => {
vec![ReedlineOption::Edit(EditCommand::CutInside {
left: *left,
right: *right,
})]
}
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: *left,
right: *right,
})]
}
}
}
Expand Down Expand Up @@ -349,24 +322,16 @@ 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(('`', '`')),
_ => 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