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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
117 changes: 115 additions & 2 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 @@ -564,10 +568,19 @@ impl Editor {
/// The range is guaranteed to be ascending.
pub fn get_selection(&self) -> Option<(usize, usize)> {
self.selection_anchor.map(|selection_anchor| {
let buffer_len = self.line_buffer.len();
if self.insertion_point() > selection_anchor {
(selection_anchor, self.insertion_point())
(
selection_anchor,
self.line_buffer.grapheme_right_index().min(buffer_len),
)
} else {
(self.insertion_point(), selection_anchor)
(
self.insertion_point(),
self.line_buffer
.grapheme_right_index_from_pos(selection_anchor)
.min(buffer_len),
)
}
})
}
Expand Down Expand Up @@ -648,6 +661,39 @@ impl Editor {
self.delete_selection();
insert_clipboard_content_before(&mut self.line_buffer, self.cut_buffer.deref_mut());
}

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 @@ -898,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");
}
}
144 changes: 144 additions & 0 deletions src/core_editor/line_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@ impl LineBuffer {
.unwrap_or(0)
}

/// Cursor position *behind* the next unicode grapheme to the right from the given position
pub fn grapheme_right_index_from_pos(&self, pos: usize) -> usize {
self.lines[pos..]
.grapheme_indices(true)
.nth(1)
.map(|(i, _)| pos + i)
.unwrap_or_else(|| self.lines.len())
}

/// Cursor position *behind* the next word to the right
pub fn word_right_index(&self) -> usize {
self.lines[self.insertion_point..]
Expand Down Expand Up @@ -767,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 Expand Up @@ -1597,4 +1719,26 @@ mod test {

assert_eq!(index, expected);
}

#[rstest]
#[case("abc", 0, 1)] // Basic ASCII
#[case("abc", 1, 2)] // From middle position
#[case("abc", 2, 3)] // From last char
#[case("abc", 3, 3)] // From end of string
#[case("🦀rust", 0, 4)] // Unicode emoji
#[case("🦀rust", 4, 5)] // After emoji
#[case("é́", 0, 4)] // Combining characters
fn test_grapheme_right_index_from_pos(
#[case] input: &str,
#[case] position: usize,
#[case] expected: usize,
) {
let mut line = LineBuffer::new();
line.insert_str(input);
assert_eq!(
line.grapheme_right_index_from_pos(position),
expected,
"input: {input:?}, pos: {position}"
);
}
}
Loading
Loading