diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 1df298df..96e8e911 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1,9 +1,9 @@ use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; #[cfg(feature = "system_clipboard")] use crate::core_editor::get_system_clipboard; -use crate::enums::{EditType, UndoBehavior}; +use crate::enums::{EditType, TextObject, TextObjectScope, TextObjectType, UndoBehavior}; use crate::{core_editor::get_local_clipboard, EditCommand}; -use std::ops::DerefMut; +use std::ops::{DerefMut, Range}; /// Stateful editor executing changes to the underlying [`LineBuffer`] /// @@ -171,8 +171,12 @@ 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), - EditCommand::YankInside { left, right } => self.yank_inside(*left, *right), + EditCommand::CutInsidePair { left, right } => self.cut_inside_pair(*left, *right), + EditCommand::CopyInsidePair { left, right } => self.copy_inside_pair(*left, *right), + EditCommand::CutAroundPair { left, right } => self.cut_around_pair(*left, *right), + EditCommand::CopyAroundPair { left, right } => self.copy_around_pair(*left, *right), + EditCommand::CutTextObject { text_object } => self.cut_text_object(*text_object), + EditCommand::CopyTextObject { text_object } => self.copy_text_object(*text_object), } if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.selection_anchor = None; @@ -374,95 +378,44 @@ impl Editor { fn cut_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let left_index = self.line_buffer.word_left_index(); - if left_index < insertion_offset { - let cut_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - self.line_buffer.set_insertion_point(left_index); - } + let word_start = self.line_buffer.word_left_index(); + self.cut_range(word_start..insertion_offset); } fn cut_big_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let left_index = self.line_buffer.big_word_left_index(); - if left_index < insertion_offset { - let cut_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - self.line_buffer.set_insertion_point(left_index); - } + let big_word_start = self.line_buffer.big_word_left_index(); + self.cut_range(big_word_start..insertion_offset); } fn cut_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.word_right_index(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let word_end = self.line_buffer.word_right_index(); + self.cut_range(insertion_offset..word_end); } fn cut_big_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.next_whitespace(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let big_word_end = self.line_buffer.next_whitespace(); + self.cut_range(insertion_offset..big_word_end); } fn cut_word_right_to_next(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.word_right_start_index(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let next_word_start = self.line_buffer.word_right_start_index(); + self.cut_range(insertion_offset..next_word_start); } fn cut_big_word_right_to_next(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.big_word_right_start_index(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let next_big_word_start = self.line_buffer.big_word_right_start_index(); + self.cut_range(insertion_offset..next_big_word_start); } fn cut_char(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.grapheme_right_index(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let next_char = self.line_buffer.grapheme_right_index(); + self.cut_range(insertion_offset..next_char); } fn insert_cut_buffer_before(&mut self) { @@ -592,18 +545,13 @@ impl Editor { #[cfg(feature = "system_clipboard")] fn cut_selection_to_system(&mut self) { if let Some((start, end)) = self.get_selection() { - let cut_slice = &self.line_buffer.get_buffer()[start..end]; - self.system_clipboard.set(cut_slice, ClipboardMode::Normal); - self.line_buffer.clear_range_safe(start, end); - self.selection_anchor = None; + self.cut_range(start..end); } } fn cut_selection_to_cut_buffer(&mut self) { if let Some((start, end)) = self.get_selection() { - let cut_slice = &self.line_buffer.get_buffer()[start..end]; - self.cut_buffer.set(cut_slice, ClipboardMode::Normal); - self.line_buffer.clear_range_safe(start, end); + self.cut_range(start..end); self.selection_anchor = None; } } @@ -646,7 +594,7 @@ impl Editor { fn delete_selection(&mut self) { if let Some((start, end)) = self.get_selection() { - self.line_buffer.clear_range_safe(start, end); + self.line_buffer.clear_range_safe(start..end); self.selection_anchor = None; } } @@ -725,28 +673,159 @@ impl Editor { 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(); + fn cut_range(&mut self, range: Range) { + if range.start <= range.end { + self.copy_range(range.clone()); + self.line_buffer.clear_range_safe(range.clone()); + self.line_buffer.set_insertion_point(range.start); + } + } + + fn copy_range(&mut self, range: Range) { + if range.start < range.end { + let slice = &self.line_buffer.get_buffer()[range]; + self.cut_buffer.set(slice, ClipboardMode::Normal); + } + } - if let Some((lp, rp)) = - self.line_buffer - .find_matching_pair(left_char, right_char, self.insertion_point()) + /// Delete text strictly between matching `open_char` and `close_char`. + fn cut_inside_pair(&mut self, open_char: char, close_char: char) { + if let Some(range) = self + .line_buffer + .range_inside_current_pair(open_char, close_char) + .or_else(|| { + self.line_buffer + .range_inside_next_pair(open_char, close_char) + }) { - 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.cut_range(range) + } + } + + /// Return the range of the word under the cursor. + /// A word consists of a sequence of letters, digits and underscores, + /// separated with white space. + /// A block of whitespace under the cursor is also treated as a word. + /// + /// `text_object_scope` Inner includes only the word itself + /// while Around also includes trailing whitespace, + /// or preceding whitespace if there is no trailing whitespace. + fn word_text_object_range(&self, text_object_scope: TextObjectScope) -> Range { + self.line_buffer + .current_whitespace_range() + .unwrap_or_else(|| { + let word_range = self.line_buffer.current_word_range(); + match text_object_scope { + TextObjectScope::Inner => word_range, + TextObjectScope::Around => { + self.line_buffer.expand_range_with_whitespace(word_range) + } + } + }) + } + + /// Return the range of the WORD under the cursor. + /// A WORD consists of a sequence of non-blank characters, separated with white space. + /// A block of whitespace under the cursor is also treated as a word. + /// + /// `text_object_scope` Inner includes only the word itself + /// while Around also includes trailing whitespace, + /// or preceding whitespace if there is no trailing whitespace. + fn big_word_text_object_range(&self, text_object_scope: TextObjectScope) -> Range { + self.line_buffer + .current_whitespace_range() + .unwrap_or_else(|| { + let big_word_range = self.line_buffer.current_big_word_range(); + match text_object_scope { + TextObjectScope::Inner => big_word_range, + TextObjectScope::Around => self + .line_buffer + .expand_range_with_whitespace(big_word_range), } + }) + } + + /// Returns `Some(Range)` for range inside the character pair in `pair_group` + /// at or surrounding the cursor, the next pair if no pairs in `pair_group` + /// surround the cursor, or `None` if there are no pairs from `pair_group` found. + /// + /// `text_object_scope` [`TextObjectScope::Inner`] includes only the range inside the pair + /// whereas [`TextObjectScope::Around`] also includes the surrounding pair characters + /// + /// If multiple pair types exist, returns the innermost pair that surrounds + /// the cursor. Handles empty pair as zero-length ranges inside pair. + /// For asymmetric pairs like `(` `)` the search is multi-line, however, + /// for symmetric pairs like `"` `"` the search is restricted to the current line. + fn matching_pair_group_text_object_range( + &self, + text_object_scope: TextObjectScope, + matching_pair_group: &[(char, char)], + ) -> Option> { + self.line_buffer + .range_inside_current_pair_in_group(matching_pair_group) + .or_else(|| { self.line_buffer - .set_insertion_point(lp + left_char.len_utf8()); - } + .range_inside_next_pair_in_group(matching_pair_group) + }) + .and_then(|pair_range| match text_object_scope { + TextObjectScope::Inner => Some(pair_range), + TextObjectScope::Around => self.expand_range_to_include_pair(pair_range), + }) + } + + /// Returns `Some(Range)` for range inside brackets (`()`, `[]`, `{}`) + /// at or surrounding the cursor, the next pair of brackets if no brackets + /// surround the cursor, or `None` if there are no brackets found. + /// + /// `text_object_scope` [`TextObjectScope::Inner`] includes only the range inside the pair + /// whereas [`TextObjectScope::Around`] also includes the surrounding pair characters + /// + /// If multiple bracket types exist, returns the innermost pair that surrounds + /// the cursor. Handles empty brackets as zero-length ranges inside brackets. + /// Includes brackets that span multiple lines. + fn bracket_text_object_range( + &self, + text_object_scope: TextObjectScope, + ) -> Option> { + const BRACKET_PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}')]; + self.matching_pair_group_text_object_range(text_object_scope, BRACKET_PAIRS) + } + + /// Returns `Some(Range)` for the range inside quotes (`""`, `''` or `\`\`\`) + /// at the cursor, the next pair of quotes if the cursor is not within quotes, + /// or `None` if there are no quotes found. + /// + /// Quotes are restricted to the current line. + /// + /// `text_object_scope` [`TextObjectScope::Inner`] includes only the range inside the pair + /// whereas [`TextObjectScope::Around`] also includes the surrounding pair characters + /// + /// If multiple quote types exist, returns the innermost pair that surrounds + /// the cursor. Handles empty quotes as zero-length ranges inside quote. + fn quote_text_object_range(&self, text_object_scope: TextObjectScope) -> Option> { + const QUOTE_PAIRS: &[(char, char)] = &[('"', '"'), ('\'', '\''), ('`', '`')]; + self.matching_pair_group_text_object_range(text_object_scope, QUOTE_PAIRS) + } + + /// Get the bounds for a text object operation + fn text_object_range(&self, text_object: TextObject) -> Option> { + match text_object.object_type { + TextObjectType::Word => Some(self.word_text_object_range(text_object.scope)), + TextObjectType::BigWord => Some(self.big_word_text_object_range(text_object.scope)), + TextObjectType::Brackets => self.bracket_text_object_range(text_object.scope), + TextObjectType::Quote => self.quote_text_object_range(text_object.scope), + } + } + + fn cut_text_object(&mut self, text_object: TextObject) { + if let Some(range) = self.text_object_range(text_object) { + self.cut_range(range); + } + } + + fn copy_text_object(&mut self, text_object: TextObject) { + if let Some(range) = self.text_object_range(text_object) { + self.copy_range(range); } } @@ -770,143 +849,122 @@ impl Editor { start }; let copy_range = start_offset..previous_offset; - let copy_slice = &self.line_buffer.get_buffer()[copy_range]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + self.copy_range(copy_range); } pub(crate) fn copy_from_end(&mut self) { - let copy_slice = &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + let copy_range = self.line_buffer.insertion_point()..self.line_buffer.len(); + self.copy_range(copy_range); } pub(crate) fn copy_to_line_end(&mut self) { - let copy_slice = &self.line_buffer.get_buffer() - [self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end()]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + let copy_range = + self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end(); + self.copy_range(copy_range); } pub(crate) fn copy_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let left_index = self.line_buffer.word_left_index(); - if left_index < insertion_offset { - let copy_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let word_start = self.line_buffer.word_left_index(); + self.copy_range(word_start..insertion_offset); } pub(crate) fn copy_big_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let left_index = self.line_buffer.big_word_left_index(); - if left_index < insertion_offset { - let copy_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let big_word_start = self.line_buffer.big_word_left_index(); + self.copy_range(big_word_start..insertion_offset); } pub(crate) fn copy_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.word_right_index(); - if right_index > insertion_offset { - let copy_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let word_end = self.line_buffer.word_right_index(); + self.copy_range(insertion_offset..word_end); } pub(crate) fn copy_big_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.next_whitespace(); - if right_index > insertion_offset { - let copy_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let big_word_end = self.line_buffer.next_whitespace(); + self.copy_range(insertion_offset..big_word_end); } pub(crate) fn copy_word_right_to_next(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.word_right_start_index(); - if right_index > insertion_offset { - let copy_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let next_word_start = self.line_buffer.word_right_start_index(); + self.copy_range(insertion_offset..next_word_start); } pub(crate) fn copy_big_word_right_to_next(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.big_word_right_start_index(); - if right_index > insertion_offset { - let copy_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let next_big_word_start = self.line_buffer.big_word_right_start_index(); + self.copy_range(insertion_offset..next_big_word_start); } pub(crate) fn copy_right_until_char(&mut self, c: char, before_char: bool, current_line: bool) { if let Some(index) = self.line_buffer.find_char_right(c, current_line) { let extra = if before_char { 0 } else { c.len_utf8() }; - let copy_slice = - &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..index + extra]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + let copy_range = self.line_buffer.insertion_point()..index + extra; + self.copy_range(copy_range); } } pub(crate) fn copy_left_until_char(&mut self, c: char, before_char: bool, current_line: bool) { if let Some(index) = self.line_buffer.find_char_left(c, current_line) { let extra = if before_char { c.len_utf8() } else { 0 }; - let copy_slice = - &self.line_buffer.get_buffer()[index + extra..self.line_buffer.insertion_point()]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + let copy_range = index + extra..self.line_buffer.insertion_point(); + self.copy_range(copy_range); } } - /// Yank text strictly between matching `left_char` and `right_char`. - /// Copies it into the cut buffer without removing anything. - /// Leaves the buffer unchanged and restores the original cursor. - pub(crate) fn yank_inside(&mut self, left_char: char, right_char: char) { - let old_pos = self.insertion_point(); - let buffer_len = self.line_buffer.len(); + /// Copy text strictly between matching `open_char` and `close_char`. + fn copy_inside_pair(&mut self, open_char: char, close_char: char) { + if let Some(range) = self + .line_buffer + .range_inside_current_pair(open_char, close_char) + .or_else(|| { + self.line_buffer + .range_inside_next_pair(open_char, close_char) + }) + { + self.copy_range(range); + } + } + + /// Expand the range to include `open_char` and `close_char` + fn expand_range_to_include_pair(&self, range: Range) -> Option> { + let start = self.line_buffer.grapheme_left_index_from_pos(range.start); + let end = self.line_buffer.grapheme_right_index_from_pos(range.end); - if let Some((lp, rp)) = - self.line_buffer - .find_matching_pair(left_char, right_char, self.insertion_point()) + Some(start..end) + } + + /// Delete text around matching `open_char` and `close_char` (including the pair characters). + fn cut_around_pair(&mut self, open_char: char, close_char: char) { + if let Some(around_range) = self + .line_buffer + .range_inside_current_pair(open_char, close_char) + .or_else(|| { + self.line_buffer + .range_inside_next_pair(open_char, close_char) + }) + .and_then(|range| self.expand_range_to_include_pair(range)) { - 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.cut_range(around_range); } + } - // Always restore the cursor position - self.line_buffer.set_insertion_point(old_pos); + /// Copy text around matching `open_char` and `close_char` (including the pair characters). + fn copy_around_pair(&mut self, open_char: char, close_char: char) { + if let Some(around_range) = self + .line_buffer + .range_inside_current_pair(open_char, close_char) + .or_else(|| { + self.line_buffer + .range_inside_next_pair(open_char, close_char) + }) + .and_then(|range| self.expand_range_to_include_pair(range)) + { + self.copy_range(around_range); + } } } @@ -1188,7 +1246,7 @@ mod 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('(', ')'); + editor.cut_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo()baz"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, "bar"); @@ -1196,15 +1254,15 @@ mod test { // 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, ""); + editor.cut_inside_pair('(', ')'); + assert_eq!(editor.get_buffer(), "foo()baz"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, "bar"); // Test with no matching brackets let mut editor = editor_with("foo bar baz"); editor.move_to_position(4, false); - editor.cut_inside('(', ')'); + editor.cut_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo bar baz"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, ""); @@ -1214,7 +1272,7 @@ mod 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('"', '"'); + editor.cut_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo\"\"baz"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, "bar"); @@ -1222,15 +1280,15 @@ mod test { // 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, ""); + editor.cut_inside_pair('"', '"'); + assert_eq!(editor.get_buffer(), "foo\"\"baz"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, "bar"); // Test with no matching quotes let mut editor = editor_with("foo bar baz"); editor.move_to_position(4, false); - editor.cut_inside('"', '"'); + editor.cut_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo bar baz"); assert_eq!(editor.insertion_point(), 4); } @@ -1239,13 +1297,13 @@ mod 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('(', ')'); + editor.cut_inside_pair('(', ')'); 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('(', ')'); + editor.cut_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo()quux"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, "bar()qux"); @@ -1255,7 +1313,7 @@ mod test { fn test_yank_inside_brackets() { let mut editor = editor_with("foo(bar)baz"); editor.move_to_position(5, false); // Move inside brackets - editor.yank_inside('(', ')'); + editor.copy_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar)baz"); // Buffer shouldn't change assert_eq!(editor.insertion_point(), 5); // Cursor should return to original position @@ -1266,7 +1324,7 @@ mod test { // Test with cursor outside brackets let mut editor = editor_with("foo(bar)baz"); editor.move_to_position(0, false); - editor.yank_inside('(', ')'); + editor.copy_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar)baz"); assert_eq!(editor.insertion_point(), 0); } @@ -1275,7 +1333,7 @@ mod test { fn test_yank_inside_quotes() { let mut editor = editor_with("foo\"bar\"baz"); editor.move_to_position(5, false); // Move inside quotes - editor.yank_inside('"', '"'); + editor.copy_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo\"bar\"baz"); // Buffer shouldn't change assert_eq!(editor.insertion_point(), 5); // Cursor should return to original position assert_eq!(editor.cut_buffer.get().0, "bar"); @@ -1283,7 +1341,7 @@ mod test { // Test with no matching quotes let mut editor = editor_with("foo bar baz"); editor.move_to_position(4, false); - editor.yank_inside('"', '"'); + editor.copy_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo bar baz"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, ""); @@ -1293,7 +1351,7 @@ mod test { fn test_yank_inside_nested() { let mut editor = editor_with("foo(bar(baz)qux)quux"); editor.move_to_position(8, false); // Move inside inner brackets - editor.yank_inside('(', ')'); + editor.copy_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar(baz)qux)quux"); // Buffer shouldn't change assert_eq!(editor.insertion_point(), 8); assert_eq!(editor.cut_buffer.get().0, "baz"); @@ -1303,7 +1361,7 @@ mod test { assert_eq!(editor.get_buffer(), "foo(bar(bazbaz)qux)quux"); editor.move_to_position(4, false); // Move inside outer brackets - editor.yank_inside('(', ')'); + editor.copy_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar(bazbaz)qux)quux"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, "bar(bazbaz)qux"); @@ -1363,4 +1421,389 @@ mod test { assert_eq!(editor.insertion_point(), 3); // Cursor should return to original position assert_eq!(editor.cut_buffer.get().0, "\r\n"); } + + #[rstest] + #[case("hello world test", 7, "hello test", 6, "world")] // cursor inside word + #[case("hello world test", 6, "hello test", 6, "world")] // cursor at start of word + #[case("hello world test", 10, "hello test", 6, "world")] // cursor at end of word + fn test_cut_inside_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + #[case("hello world test", 7, "world")] // cursor inside word + #[case("hello world test", 6, "world")] // cursor at start of word + #[case("hello world test", 10, "world")] // cursor at end of word + fn test_yank_inside_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_yank: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.copy_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), input); // Buffer shouldn't change + assert_eq!(editor.insertion_point(), cursor_pos); // Cursor should return to original position + assert_eq!(editor.cut_buffer.get().0, expected_yank); + } + + #[rstest] + #[case("hello world test", 7, "hello test", 6, "world ")] // word with following space + #[case("hello world", 7, "hello", 5, " world")] // word at end, gets preceding space + #[case("word test", 2, "test", 0, "word ")] // first word with following space + #[case("hello word", 7, "hello", 5, " word")] // last word gets preceding space + // Edge cases at end of string + #[case("word", 2, "", 0, "word")] // single word, no whitespace + #[case(" word", 2, "", 0, " word")] // word with only leading space + // Edge cases with punctuation boundaries + #[case("word.", 2, ".", 0, "word")] // word followed by punctuation + #[case(".word", 2, ".", 1, "word")] // word preceded by punctuation + #[case("(word)", 2, "()", 1, "word")] // word surrounded by punctuation + #[case("hello,world", 2, ",world", 0, "hello")] // word followed by punct+word + #[case("hello,world", 7, "hello,", 6, "world")] // word preceded by word+punct + fn test_cut_around_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Around, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + #[case("hello world test", 7, "world ")] // word with following space + #[case("hello world", 7, " world")] // word at end, gets preceding space + #[case("word test", 2, "word ")] // first word with following space + fn test_yank_around_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_yank: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.copy_text_object(TextObject { + scope: TextObjectScope::Around, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), input); // Buffer shouldn't change + assert_eq!(editor.insertion_point(), cursor_pos); // Cursor should return to original position + assert_eq!(editor.cut_buffer.get().0, expected_yank); + } + + #[rstest] + #[case("hello big-word test", 10, "hello test", 6, "big-word")] // big word with punctuation + #[case("hello BIGWORD test", 10, "hello test", 6, "BIGWORD")] // simple big word + #[case("test@example.com file", 8, " file", 0, "test@example.com")] //cursor on email address + #[case("test@example.com file", 17, "test@example.com ", 17, "file")] // cursor at end of "file" + fn test_cut_inside_big_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::BigWord, + }); + + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + #[case("hello-world test", 2, "-world test", 0, "hello")] // cursor on "hello" + #[case("hello-world test", 5, "helloworld test", 5, "-")] // cursor on "-" + #[case("hello-world test", 8, "hello- test", 6, "world")] // cursor on "world" + #[case("a-b-c test", 0, "-b-c test", 0, "a")] // single char "a" + #[case("a-b-c test", 2, "a--c test", 2, "b")] // single char "b" + fn test_cut_inside_word_with_punctuation( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + #[case("hello-world test", 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "-world test", "hello")] // small word gets just "hello" + #[case("hello-world test", 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::BigWord }, " test", "hello-world")] // big word gets "hello-word" + #[case("test@example.com", 6, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "test@", "example.com")] // small word in email (UAX#29 extends across punct) + #[case("test@example.com", 6, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::BigWord }, "", "test@example.com")] // big word gets entire email + fn test_word_vs_big_word_comparison( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] text_object: TextObject, + #[case] expected_buffer: &str, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(text_object); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // Test inside operations (iw) at word boundaries + #[case("hello world", 0, "hello")] // start of first word + #[case("hello world", 4, "hello")] // end of first word + #[case("hello world", 6, "world")] // start of second word + #[case("hello world", 10, "world")] // end of second word + // Test at exact word boundaries with punctuation + #[case("hello-world", 4, "hello")] // just before punctuation + #[case("hello-world", 5, "-")] // on punctuation + #[case("hello-world", 6, "world")] // just after punctuation + fn test_cut_inside_word_boundaries( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // Test around operations (aw) at word boundaries + #[case("hello world", 0, "hello ")] // start of first word + #[case("hello world", 4, "hello ")] // end of first word + #[case("hello world", 6, " world")] // start of second word (gets preceding space) + #[case("hello world", 10, " world")] // end of second word + #[case("word", 0, "word")] // single word, no whitespace + #[case("word ", 0, "word ")] // word with trailing space + #[case(" word", 1, " word")] // word with leading space + fn test_cut_around_word_boundaries( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Around, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + fn test_cut_text_object_unicode_safety() { + let mut editor = editor_with("hello 🦀end"); + editor.move_to_position(10, false); // Position after the emoji + editor.move_to_position(6, false); // Move to the emoji + + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); // Cut the emoji + + assert!(editor.line_buffer.is_valid()); // Should not panic or be invalid + } + + #[rstest] + // Test operations when cursor is IN WHITESPACE (middle of spaces) + #[case("hello world test", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld test", 5, " ")] // single space + #[case("hello world", 6, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld", 5, " ")] // multiple spaces, cursor on second + #[case("hello world", 7, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld", 5, " ")] // multiple spaces, cursor on middle + #[case(" hello", 1, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "hello", 0, " ")] // leading spaces, cursor on middle + #[case("hello ", 7, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "hello", 5, " ")] // trailing spaces, cursor on middle + #[case("hello\tworld", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld", 5, "\t")] // tab character + #[case("hello\nworld", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld", 5, "\n")] // newline character + #[case("hello world test", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::BigWord }, "helloworld test", 5, " ")] // single space (big word) + #[case("hello world", 6, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::BigWord }, "helloworld", 5, " ")] // multiple spaces (big word) + #[case(" ", 0, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "", 0, " ")] // only whitespace at start + #[case(" ", 1, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "", 0, " ")] // only whitespace at end + #[case("hello ", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "hello", 5, " ")] // trailing whitespace at string end + #[case(" hello", 0, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "hello", 0, " ")] // leading whitespace at string start + fn test_text_object_in_whitespace( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] text_object: TextObject, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(text_object); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // Test text object jumping behavior in various scenarios + // Cursor inside empty pairs should operate on current pair (cursor stays, nothing cut) + #[case(r#"foo()bar"#, 4, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Brackets }, "foo()bar", 4, "")] // inside empty brackets + #[case(r#"foo""bar"#, 4, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Quote }, "foo\"\"bar", 4, "")] // inside empty quotes + // Cursor outside pairs should jump to next pair (even if empty) + #[case(r#"foo ()bar"#, 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Brackets }, "foo ()bar", 5, "")] // jump to empty brackets + #[case(r#"foo ""bar"#, 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Quote }, "foo \"\"bar", 5, "")] // jump to empty quote + #[case(r#"foo (content)bar"#, 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Brackets }, "foo ()bar", 5, "content")] // jump to non-empty brackets + #[case(r#"foo "content"bar"#, 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Quote }, "foo \"\"bar", 5, "content")] // jump to non-empty quotes + // Cursor between pairs should jump to next pair + #[case(r#"(first) (second)"#, 8, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Brackets }, "(first) ()", 9, "second")] // between brackets + #[case(r#""first" "second""#, 8, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Quote }, "\"first\"\"second\"", 7, " ")] // between quotes + // Around scope should include the pair characters + #[case(r#"foo (bar)"#, 2, TextObject { scope: TextObjectScope::Around, object_type: TextObjectType::Brackets }, "foo ", 4, "(bar)")] // around includes parentheses + #[case(r#"foo "bar""#, 2, TextObject { scope: TextObjectScope::Around, object_type: TextObjectType::Quote }, "foo ", 4, "\"bar\"")] // around includes quotes + fn test_text_object_jumping_behavior( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] text_object: TextObject, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(text_object); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // Test bracket_text_object_range with Inner scope - just the content inside brackets + #[case("foo(bar)baz", 5, TextObjectScope::Inner, Some(4..7))] // cursor inside brackets + #[case("foo[bar]baz", 5, TextObjectScope::Inner, Some(4..7))] // square brackets + #[case("foo{bar}baz", 5, TextObjectScope::Inner, Some(4..7))] // square brackets + #[case("foo()bar", 4, TextObjectScope::Inner, Some(4..4))] // empty brackets + #[case("(nested[inner]outer)", 8, TextObjectScope::Inner, Some(8..13))] // nested, innermost + #[case("(nested[mixed{inner}brackets]outer)", 8, TextObjectScope::Inner, Some(8..28))] // nested, innermost + #[case("next(nested[mixed{inner}brackets]outer)", 0, TextObjectScope::Inner, Some(5..38))] // next nested mixed + #[case("foo (bar)baz", 0, TextObjectScope::Inner, Some(5..8))] // next pair from line start + #[case(" (bar)baz", 1, TextObjectScope::Inner, Some(5..8))] // next pair from whitespace + #[case("foo(bar)baz", 2, TextObjectScope::Inner, Some(4..7))] // next pair from word + #[case("foo(bar\nbaz)qux", 8, TextObjectScope::Inner, Some(4..11))] // multi-line brackets + #[case("foo\n(bar\nbaz)qux", 0, TextObjectScope::Inner, Some(5..12))] // next multi-line brackets + #[case("foo\n(bar\nbaz)qux", 3, TextObjectScope::Around, Some(4..13))] // next multi-line brackets + #[case("{hello}", 3, TextObjectScope::Around, Some(0..7))] // includes curly brackets + #[case("foo()bar", 4, TextObjectScope::Around, Some(3..5))] // around empty brackets + #[case("(nested(inner)outer)", 8, TextObjectScope::Around, Some(7..14))] // nested around includes delimiters + #[case("start(nested(inner)outer)", 2, TextObjectScope::Around, Some(5..25))] // Next outer nested pair + #[case("(mixed{nested)brackets", 1, TextObjectScope::Inner, Some(1..13))] // mixed nesting + #[case("(unclosed(nested)brackets", 1, TextObjectScope::Inner, Some(10..16))] // unclosed bracket, find next closed + #[case("no brackets here", 5, TextObjectScope::Inner, None)] // no brackets found + #[case("(unclosed", 1, TextObjectScope::Inner, None)] // unclosed bracket + #[case("(mismatched}", 1, TextObjectScope::Inner, None)] // mismatched brackets + fn test_bracket_text_object_range( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] scope: TextObjectScope, + #[case] expected: Option>, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + let result = editor.bracket_text_object_range(scope); + assert_eq!(result, expected); + } + + #[rstest] + // Test quote_text_object_range with Inner scope - just the content inside quotes + #[case(r#"foo"bar"baz"#, 5, TextObjectScope::Inner, Some(4..7))] // cursor inside double quotes + #[case("foo'bar'baz", 5, TextObjectScope::Inner, Some(4..7))] // single quotes + #[case("foo`bar`baz", 5, TextObjectScope::Inner, Some(4..7))] // backticks + #[case(r#"foo""bar"#, 4, TextObjectScope::Inner, Some(4..4))] // empty quotes + #[case(r#""nested'inner'outer""#, 8, TextObjectScope::Inner, Some(8..13))] // nested, innermost + #[case(r#""nested`mixed'inner'backticks`outer""#, 8, TextObjectScope::Inner, Some(8..29))] // nested, innermost + #[case(r#"next"nested'mixed`inner`quotes'outer""#, 0, TextObjectScope::Inner, Some(5..36))] // next nested mixed + #[case(r#"foo "bar"baz"#, 0, TextObjectScope::Inner, Some(5..8))] // next pair + #[case(r#"foo"bar"baz"#, 2, TextObjectScope::Inner, Some(4..7))] // next from inside word + #[case(r#"foo"bar"baz"#, 4, TextObjectScope::Around, Some(3..8))] // around includes quotes + #[case(r#"foo"bar"baz"#, 3, TextObjectScope::Around, Some(3..8))] // around on opening quote + #[case(r#"foo"bar"baz"#, 2, TextObjectScope::Around, Some(3..8))] // around next quotes + #[case(r#"foo""bar"#, 4, TextObjectScope::Around, Some(3..5))] // around empty quotes + #[case(r#"foo""bar"#, 1, TextObjectScope::Around, Some(3..5))] // around empty quotes + #[case(r#""nested"inner"outer""#, 8, TextObjectScope::Around, Some(7..14))] // nested around includes delimiters + #[case(r#"start"nested'inner'outer""#, 2, TextObjectScope::Around, Some(5..25))] // Next outer nested pair + #[case("no quotes here", 5, TextObjectScope::Inner, None)] // no quotes found + #[case(r#"foo"bar"#, 1, TextObjectScope::Inner, None)] // unclosed quote + #[case("foo'bar\nbaz'qux", 5, TextObjectScope::Inner, None)] // quotes don't span multiple lines + #[case("foo'bar\nbaz'qux", 0, TextObjectScope::Inner, None)] // quotes don't span multiple lines + #[case("foobar\n`baz`qux", 6, TextObjectScope::Inner, None)] // quotes don't span multiple lines + #[case("foo\n(bar\nbaz)qux", 0, TextObjectScope::Inner, None)] // next multi-line brackets + #[case("foo\n(bar\nbaz)qux", 3, TextObjectScope::Around, None)] // next multi-line brackets + fn test_quote_text_object_range( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] scope: TextObjectScope, + #[case] expected: Option>, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(cursor_pos); + let result = editor.quote_text_object_range(scope); + assert_eq!(result, expected); + } + + #[rstest] + // Test edge cases and complex scenarios for both bracket and quote text objects + #[case("", 0, TextObjectScope::Inner, None, None)] // empty buffer + #[case("a", 0, TextObjectScope::Inner, None, None)] // single character + #[case("()", 1, TextObjectScope::Inner, Some(1..1), None)] // empty brackets, cursor inside + #[case(r#""""#, 1, TextObjectScope::Inner, None, Some(1..1))] // empty quotes, cursor inside + #[case("([{}])", 3, TextObjectScope::Inner, Some(3..3), None)] // deeply nested brackets + #[case(r#""'`text`'""#, 5, TextObjectScope::Inner, None, Some(3..7))] // deeply nested quotes + #[case("(text) and [more]", 5, TextObjectScope::Around, Some(0..6), None)] // multiple bracket types + #[case(r#""text" and 'more'"#, 5, TextObjectScope::Around, None, Some(0..6))] // multiple quote types + fn test_text_object_edge_cases( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] scope: TextObjectScope, + #[case] expected_bracket: Option>, + #[case] expected_quote: Option>, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + + let bracket_result = editor.bracket_text_object_range(scope); + let quote_result = editor.quote_text_object_range(scope); + + assert_eq!(bracket_result, expected_bracket); + assert_eq!(quote_result, expected_quote); + } } diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 07eaa035..57d79f27 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -152,20 +152,12 @@ impl LineBuffer { /// Cursor position *behind* the next unicode grapheme to the right pub fn grapheme_right_index(&self) -> usize { - self.lines[self.insertion_point..] - .grapheme_indices(true) - .nth(1) - .map(|(i, _)| self.insertion_point + i) - .unwrap_or_else(|| self.lines.len()) + self.grapheme_right_index_from_pos(self.insertion_point) } /// Cursor position *in front of* the next unicode grapheme to the left pub fn grapheme_left_index(&self) -> usize { - self.lines[..self.insertion_point] - .grapheme_indices(true) - .next_back() - .map(|(i, _)| i) - .unwrap_or(0) + self.grapheme_left_index_from_pos(self.insertion_point) } /// Cursor position *behind* the next unicode grapheme to the right from the given position @@ -177,6 +169,15 @@ impl LineBuffer { .unwrap_or_else(|| self.lines.len()) } + /// Cursor position *behind* the previous unicode grapheme to the left from the given position + pub(crate) fn grapheme_left_index_from_pos(&self, pos: usize) -> usize { + self.lines[..pos] + .grapheme_indices(true) + .next_back() + .map(|(i, _)| i) + .unwrap_or(0) + } + /// Cursor position *behind* the next word to the right pub fn word_right_index(&self) -> usize { self.lines[self.insertion_point..] @@ -307,6 +308,47 @@ impl LineBuffer { .unwrap_or_else(|| self.lines.len()) } + /// Returns true if cursor is at the end of the buffer with preceding whitespace. + fn at_end_of_line_with_preceding_whitespace(&self) -> bool { + !self.is_empty() // No point checking if empty + && self.insertion_point == self.lines.len() + && self.lines.chars().last().map_or(false, |c| c.is_whitespace()) + } + + /// Cursor position at the end of the current whitespace block. + fn current_whitespace_end_index(&self) -> usize { + self.lines[self.insertion_point..] + .char_indices() + .find(|(_, ch)| !ch.is_whitespace()) + .map(|(i, _)| self.insertion_point + i) + .unwrap_or(self.lines.len()) + } + + /// Cursor position at the start of the current whitespace block. + fn current_whitespace_start_index(&self) -> usize { + self.lines[..self.insertion_point] + .char_indices() + .rev() + .find(|(_, ch)| !ch.is_whitespace()) + .map(|(i, _)| i + 1) + .unwrap_or(0) + } + + /// Returns the range of consecutive whitespace characters that includes + /// the cursor position. If cursor is at the end of trailing whitespace, includes + /// that trailing block. Return None if no surrounding whitespace. + pub(crate) fn current_whitespace_range(&self) -> Option> { + let range_start = self.current_whitespace_start_index(); + if self.on_whitespace() { + let range_end = self.current_whitespace_end_index(); + Some(range_start..range_end) + } else if self.at_end_of_line_with_preceding_whitespace() { + Some(range_start..self.insertion_point) + } else { + None + } + } + /// Move cursor position *behind* the next unicode grapheme to the right pub fn move_right(&mut self) { self.insertion_point = self.grapheme_right_index(); @@ -409,11 +451,11 @@ impl LineBuffer { /// /// If the cursor is located between `start` and `end` it is adjusted to `start`. /// If the cursor is located after `end` it is adjusted to stay at its current char boundary. - pub fn clear_range_safe(&mut self, start: usize, end: usize) { - let (start, end) = if start > end { - (end, start) + pub fn clear_range_safe(&mut self, range: Range) { + let (start, end) = if range.start > range.end { + (range.end, range.start) } else { - (start, end) + (range.start, range.end) }; if self.insertion_point <= start { // No action necessary @@ -777,67 +819,241 @@ impl LineBuffer { } } - /// Attempts to find the matching `(left_char, right_char)` pair *enclosing* - /// the cursor position, respecting nested pairs. + /// Returns `Some(Range)` for the range inside the surrounding + /// `open_char` and `close_char`, or `None` if no pair is found. /// - /// 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. + /// If cursor is positioned just before an opening character, treat it as + /// being "inside" that pair. /// - /// Returns `Some((left_index, right_index))` if found, or `None` otherwise. - pub fn find_matching_pair( + /// For symmetric characters (e.g. quotes), the search is restricted to the current line only. + /// For asymmetric characters (e.g. brackets), the search spans the entire buffer. + pub(crate) fn range_inside_current_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)) + open_char: char, + close_char: char, + ) -> Option> { + let only_search_current_line: bool = open_char == close_char; + let find_range_between_pair_at_position = |pos| { + self.range_between_matching_pair_at_pos( + pos, + only_search_current_line, + open_char, + close_char, + ) + }; + + // First try to find pair from current cursor position + find_range_between_pair_at_position(self.insertion_point).or_else(|| { + // Second try, if cursor is positioned just before an opening character, + // treat it as being "inside" that pair and try from the next position + self.grapheme_right() + .starts_with(open_char) + .then(|| find_range_between_pair_at_position(self.grapheme_right_index())) + .flatten() + }) + } + + /// Returns `Some(Range)` for the range inside the next pair + /// or `None` if no pair is found + /// + /// Search forward from the cursor to find the next occurrence of `open_char` + /// (including char at cursors current position), then finds its matching + /// `close_char` and returns the range of text inside those characters. + /// Note the end of Range is exclusive so the end of the range returned so + /// the end of the range is index of final char + 1. + /// + /// For symmetric characters (e.g. quotes), the search is restricted to the current line only. + /// For asymmetric characters (e.g. brackets), the search spans the entire buffer. + pub(crate) fn range_inside_next_pair( + &self, + open_char: char, + close_char: char, + ) -> Option> { + let only_search_current_line: bool = open_char == close_char; + + // Find the next opening character, including the current position + let open_pair_index = if self.grapheme_right().starts_with(open_char) { + self.insertion_point + } else { + self.find_char_right(open_char, only_search_current_line)? + }; + + self.range_between_matching_pair_at_pos( + self.grapheme_right_index_from_pos(open_pair_index), + only_search_current_line, + open_char, + close_char, + ) + } + + /// Returns `Some(Range)` for the range inside the pair `open_char` + /// and `close_char` surrounding the cursor position NOT including the character + /// at the current cursor position, or `None` if no valid pair is found. + /// + /// This is the underlying algorithm used by both `range_inside_current_pair` and + /// `range_inside_next_pair`. + /// It uses a forward-first search approach: + /// 1. Search forward from cursor to find the closing character (ignoring nested pairs) + /// 2. Search backward from closing to find the matching opening character + /// 3. Return the range between them + fn range_between_matching_pair_at_pos( + &self, + position: usize, + only_search_current_line: bool, + open_char: char, + close_char: char, + ) -> Option> { + let search_range = if only_search_current_line { + self.current_line_range() + } else { + 0..self.lines.len() + }; + + let after_cursor = &self.lines[position..search_range.end]; + let close_pair_index_after_cursor = + Self::find_index_of_matching_pair(after_cursor, open_char, close_char, false)?; + let close_char_index_in_buffer = position + close_pair_index_after_cursor; + + let start_to_close_char = &self.lines[search_range.start..close_char_index_in_buffer]; + + Self::find_index_of_matching_pair(start_to_close_char, open_char, close_char, true).map( + |open_char_index_from_start| { + let open_char_index_in_buffer = search_range.start + open_char_index_from_start; + (open_char_index_in_buffer + open_char.len_utf8())..close_char_index_in_buffer + }, + ) + } + + /// Find the index of a pair character that matches the nesting depth at the + /// start or end of `slice` using depth counting to handle nested pairs. + /// Helper for [`LineBuffer::range_between_matching_pair_at_pos`] + /// + /// If `search_backwards` is false: + /// Find close_char at same level of nesting as start of slice. + /// + /// If `search_backwards` is true: + /// Find open_char at same level of nesting as end of slice. + /// + /// Returns index of the open or closing character that matches the start of slice, + /// or `None` if not found. + fn find_index_of_matching_pair( + slice: &str, + open_char: char, + close_char: char, + search_backwards: bool, + ) -> Option { + let mut depth = 0; + let mut graphemes: Vec<(usize, &str)> = slice.grapheme_indices(true).collect(); + + if search_backwards { + graphemes.reverse(); + } + + let (target, increment) = if search_backwards { + (open_char, close_char) + } else { + (close_char, open_char) + }; + + for (index, grapheme) in graphemes { + if let Some(char) = grapheme.chars().next() { + if char == target { + if depth == 0 { + return Some(index); + } + depth -= 1; + } else if char == increment && index > 0 { + depth += 1; + } + } + } + None + } + + /// Returns `Some(Range)` for the range inside pair in `pair_group` + /// at cursor position including pair of character at current cursor position, + /// or `None` if cursor is not inside or at a pair included in `pair_group. + /// + /// If the opening and closing char in the pair are equal then search is + /// restricted to the current line. + /// + /// If multiple pair types are found in the buffer or line, return the innermost + /// pair that surrounds the cursor. Handles empty quotes as zero-length ranges inside quote. + pub(crate) fn range_inside_current_pair_in_group( + &self, + matching_pair_group: &[(char, char)], + ) -> Option> { + matching_pair_group + .iter() + .filter_map(|(open_char, close_char)| { + self.range_inside_current_pair(*open_char, *close_char) + }) + .min_by_key(|range| range.len()) + } + + /// Returns `Some(Range)` for the range inside the next pair in `pair_group` + /// or `None` if cursor is not inside a pair included in `pair_group`. + /// + /// If the opening and closing char in the pair are equal then search is + /// restricted to the current line. + /// + /// If multiple pair types are found in the buffer or line, return the innermost + /// pair that surrounds the cursor. Handles empty pairs as zero-length ranges + /// inside pair (this enables caller to still get the location of the pair). + pub(crate) fn range_inside_next_pair_in_group( + &self, + matching_pair_group: &[(char, char)], + ) -> Option> { + matching_pair_group + .iter() + .filter_map(|(open_char, close_char)| { + self.range_inside_next_pair(*open_char, *close_char) + }) + .min_by_key(|range| range.start) } -} -/// Helper function for [`LineBuffer::find_matching_pair`] -fn find_with_depth( - slice: &str, - deep_char: &str, - shallow_char: &str, - reverse: bool, -) -> Option { - 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, - _ => (), + /// Get the range of the current big word (WORD) at cursor position + pub(crate) fn current_big_word_range(&self) -> Range { + let right_index = self.big_word_right_end_index(); + + let mut left_index = 0; + for (i, char) in self.lines[..right_index].char_indices().rev() { + if char.is_whitespace() { + left_index = i + char.len_utf8(); + break; + } } + left_index..(right_index + 1) + } + + /// Return range of `range` expanded with neighbouring whitespace for "around" operations + /// Prioritizes whitespace after the word, falls back to whitespace before if none after + pub(crate) fn expand_range_with_whitespace(&self, range: Range) -> Range { + let end = self.next_non_whitespace_index(range.end); + let start = if end == range.end { + self.prev_non_whitespace_index(range.start) + } else { + range.start + }; + start..end } - None + /// Return next non-whitespace character index after `pos` + fn next_non_whitespace_index(&self, pos: usize) -> usize { + self.lines[pos..] + .char_indices() + .find(|(_, char)| !char.is_whitespace()) + .map_or(self.lines.len(), |(i, _)| pos + i) + } + + /// Extend range leftward to include leading whitespace + fn prev_non_whitespace_index(&self, pos: usize) -> usize { + self.lines[..pos] + .char_indices() + .rev() + .find(|(_, char)| !char.is_whitespace()) + .map_or(0, |(i, char)| i + char.len_utf8()) + } } /// Match any sequence of characters that are considered a word boundary @@ -1691,35 +1907,285 @@ mod test { ); } + const BRACKET_PAIRS: &[(char, char); 3] = &[('(', ')'), ('[', ']'), ('{', '}')]; + const QUOTE_PAIRS: &[(char, char); 3] = &[('"', '"'), ('\'', '\''), ('`', '`')]; + // Tests for range_inside_current_quote - cursor inside or on the boundary + #[rstest] + #[case("foo(bar)baz", 5, BRACKET_PAIRS, Some(4..7))] // cursor on 'a' in "bar" + #[case("foo[bar]baz", 5, BRACKET_PAIRS, Some(4..7))] // square brackets + #[case("foo{bar}baz", 5, BRACKET_PAIRS, Some(4..7))] // curly brackets + #[case("foo(bar(baz)qux)end", 9, BRACKET_PAIRS, Some(8..11))] // cursor on 'a' in "baz", finds inner + #[case("foo(bar(baz)qux)end", 5, BRACKET_PAIRS, Some(4..15))] // cursor on 'a' in "bar", finds outer + #[case("foo([bar])baz", 6, BRACKET_PAIRS, Some(5..8))] // mixed bracket types, cursor on 'a' - should find [bar], not (...) + #[case("foo[(bar)]baz", 6, BRACKET_PAIRS, Some(5..8))] // reversed nesting, cursor on 'a' - should find (bar), not [...] + #[case("foo(bar)baz", 4, BRACKET_PAIRS, Some(4..7))] // cursor just after opening bracket + #[case("foo(bar)baz", 7, BRACKET_PAIRS, Some(4..7))] // cursor just before closing bracket + #[case("foo[]bar", 4, BRACKET_PAIRS, Some(4..4))] // empty square brackets + #[case("(content)", 0, BRACKET_PAIRS, Some(1..8))] // brackets at buffer start/end + #[case("a(b)c", 2, BRACKET_PAIRS, Some(2..3))] // minimal case - cursor inside brackets + #[case(r#"foo("bar")baz"#, 6, BRACKET_PAIRS, Some(4..9))] // quotes inside brackets + #[case(r#"foo"(bar)"baz"#, 6, BRACKET_PAIRS, Some(5..8))] // brackets inside quotes + #[case("())", 1, BRACKET_PAIRS, Some(1..1))] // extra closing bracket + #[case("", 0, BRACKET_PAIRS, None)] // empty buffer + #[case("(", 0, BRACKET_PAIRS, None)] // single opening bracket + #[case(")", 0, BRACKET_PAIRS, None)] // single closing bracket + #[case("", 0, BRACKET_PAIRS, None)] // empty buffer + #[case(r#"foo"bar"baz"#, 5, QUOTE_PAIRS, Some(4..7))] // cursor on 'a' in "bar" + #[case("foo'bar'baz", 5, QUOTE_PAIRS, Some(4..7))] // single quotes + #[case("foo`bar`baz", 5, QUOTE_PAIRS, Some(4..7))] // backticks + #[case(r#"'foo"baz`bar`taz"baz'"#, 0, QUOTE_PAIRS, Some(1..20))] // backticks + #[case(r#""foo"'bar'`baz`"#, 0, QUOTE_PAIRS, Some(1..4))] // cursor at start, should find first (double) + #[case("no quotes here", 5, QUOTE_PAIRS, None)] // no quotes in buffer + #[case(r#"unclosed "quotes"#, 10, QUOTE_PAIRS, None)] // unmatched quotes + #[case("", 0, QUOTE_PAIRS, None)] // empty buffer + fn test_range_inside_current_pair_group( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] pairs: &[(char, char); 3], + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + assert_eq!(buf.range_inside_current_pair_in_group(pairs), expected); + } + + // Tests for range_inside_next_pair_in_group - cursor before pairs, return range inside next pair if exists + #[rstest] + #[case("foo (bar)baz", 1, BRACKET_PAIRS, Some(5..8))] // cursor before brackets + #[case("foo []bar", 1, BRACKET_PAIRS, Some(5..5))] // cursor before empty brackets + #[case("(first)(second)", 4, BRACKET_PAIRS, Some(8..14))] // inside first, should find second + #[case("foo{bar[baz]qux}end", 0, BRACKET_PAIRS, Some(4..15))] // cursor at start, finds outermost + #[case("foo{bar[baz]qux}end", 1, BRACKET_PAIRS, Some(4..15))] // cursor before nested, finds innermost + #[case("foo{bar[baz]qux}end", 4, BRACKET_PAIRS, Some(8..11))] // cursor before nested, finds innermost + #[case("(){}[]", 0, BRACKET_PAIRS, Some(1..1))] // cursor at start, finds first empty pair + #[case("(){}[]", 2, BRACKET_PAIRS, Some(3..3))] // cursor between pairs, finds next + #[case("no brackets here", 5, BRACKET_PAIRS, None)] // no brackets found + #[case("", 0, BRACKET_PAIRS, None)] // empty buffer + #[case(r#"foo "'bar'" baz"#, 1, QUOTE_PAIRS, Some(5..10))] // cursor before nested quotes + #[case(r#"foo '' "bar" baz"#, 1, QUOTE_PAIRS, Some(5..5))] // cursor before first quotes + #[case(r#""foo"'bar`b'az`"#, 1, QUOTE_PAIRS, Some(6..11))] // cursor inside first quotes, find single quotes + #[case(r#""foo"'bar'`baz`"#, 6, QUOTE_PAIRS, Some(11..14))] // cursor after second quotes, find backticks + #[case(r#"zaz'foo"b`a`r"baz'zaz"#, 3, QUOTE_PAIRS, Some(4..17))] // range inside outermost nested quotes + #[case(r#""""#, 0, QUOTE_PAIRS, Some(1..1))] // single quote pair (empty) - should find it ahead + #[case(r#"""asdf"#, 0, QUOTE_PAIRS, Some(1..1))] // unmatched trailing quote + #[case(r#""foo"'bar'`baz`"#, 0, QUOTE_PAIRS, Some(1..4))] // cursor at start, should find first quotes + #[case(r#"foo'bar""#, 1, QUOTE_PAIRS, None)] // mismatched quotes + #[case("no quotes here", 5, QUOTE_PAIRS, None)] // no quotes in buffer + #[case("", 0, QUOTE_PAIRS, None)] // empty buffer + fn test_range_inside_next_pair_in_group( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] pairs: &[(char, char); 3], + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + assert_eq!(buf.range_inside_next_pair_in_group(pairs), expected); + } + + // Tests for range_inside_current_pair - when cursor is inside a pair + #[rstest] + #[case("(abc)", 1, '(', ')', Some(1..4))] // cursor inside simple pair + #[case("foo(bar)baz", 3, '(', ')', Some(4..7))] // cursor inside pair + #[case("[abc]", 1, '[', ']', Some(1..4))] // square brackets + #[case("{abc}", 1, '{', '}', Some(1..4))] // curly brackets + #[case("foo(🦀bar)baz", 8, '(', ')', Some(4..11))] // emoji inside brackets - cursor inside (on 'b') + #[case("🦀(bar)🦀", 6, '(', ')', Some(5..8))] // emoji outside brackets - cursor inside + #[case("()", 1, '(', ')', Some(1..1))] // empty pair + #[case("foo()bar", 4, '(', ')', Some(4..4))] // empty pair - cursor inside + // Cases where cursor is not inside any pair + #[case("(abc)", 0, '(', ')', Some(1..4))] // cursor at start, not inside + #[case("foo(bar)baz", 2, '(', ')', None)] // cursor before pair + #[case("foo(bar)baz", 0, '(', ')', None)] // cursor at start of buffer + #[case("", 0, '(', ')', None)] // empty string + #[case("no brackets", 5, '(', ')', None)] // no brackets + #[case("(unclosed", 1, '(', ')', None)] // unclosed bracket + #[case("unclosed)", 1, '(', ')', None)] // unclosed bracket + #[case("end of line", 11, '(', ')', None)] // unclosed bracket + fn test_range_inside_current_pair( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_current_pair(open_char, close_char); + assert_eq!( + result, expected, + "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", + input, cursor_pos, open_char, close_char + ); + } + + // Tests for range_inside_next_pair - when looking for the next pair forward #[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("(abc)", 0, '(', ')', Some(1..4))] // cursor at start, find first pair + #[case("foo(bar)baz", 2, '(', ')', Some(4..7))] // cursor before pair + #[case("(first)(second)", 4, '(', ')', Some(8..14))] // inside first, should find second + #[case("()", 0, '(', ')', Some(1..1))] // empty pair + #[case("foo()bar", 2, '(', ')', Some(4..4))] // empty pair + #[case("[abc]", 0, '[', ']', Some(1..4))] // square brackets + #[case("{abc}", 0, '{', '}', Some(1..4))] // curly brackets + #[case("foo(🦀bar)baz", 0, '(', ')', Some(4..11))] // emoji inside brackets - find from start + #[case("🦀(bar)🦀", 0, '(', ')', Some(5..8))] // emoji outside brackets - find from start + #[case("", 0, '(', ')', None)] // empty string + #[case("no brackets", 5, '(', ')', None)] // no brackets + #[case("(unclosed", 1, '(', ')', None)] // unclosed bracket + #[case("(abc)", 4, '(', ')', None)] // cursor after pair, no more pairs + #[case(r#""""#, 0, '"', '"', Some(1..1))] // single quote pair (empty) - should find it ahead + #[case(r#"""asdf"#, 0, '"', '"', Some(1..1))] // unmatched quote - should find it ahead + #[case(r#""foo"'bar'`baz`"#, 0, '"', '"', Some(1..4))] // cursor at start, should find first quotes + fn test_range_inside_next_pair( #[case] input: &str, - #[case] cursor: usize, - #[case] left_char: char, - #[case] right_char: char, - #[case] expected: Option<(usize, usize)>, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, ) { - let buf = LineBuffer::from(input); + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(open_char, close_char); assert_eq!( - buf.find_matching_pair(left_char, right_char, cursor), + result, expected, + "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", + input, cursor_pos, open_char, close_char + ); + } + + #[rstest] + // Test next quote is restricted to single line + #[case("line1\n\"quote\"", 7, '"', '"', None)] // Inside second line quote, no quotes after + #[case("\"quote\"\nline2", 2, '"', '"', None)] // No next quote on current line + #[case("line1\n\"quote\"", 6, '"', '"', Some(7..12))] // cursor at start of line 2 + #[case("line1\n\"quote\"", 0, '"', '"', None)] // cursor line 1 doesn't find quote on line 2 + #[case("line1\n\"quote\"", 5, '"', '"', None)] // cursor at end of line 1 + fn test_multiline_next_quote_multiline( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(open_char, close_char); + assert_eq!( + result, + expected, + "MULTILINE TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", + input, + cursor_pos, + open_char, + close_char, + input.lines().collect::>() + ); + } + + // Test that range_inside_current_pair work across multiple lines + #[rstest] + #[case("line1\n(bracket)", 7, '(', ')', Some(7..14))] // cursor at bracket start on line 2 + #[case("(bracket)\nline2", 2, '(', ')', Some(1..8))] // cursor inside bracket on line 1 + #[case("line1\n(bracket)", 5, '(', ')', None)] // cursor end of line 1 + #[case("(1\ninner\n3)", 4, '(', ')', Some(1..10))] // bracket spanning 3 lines + #[case("(1\ninner\n3)", 2, '(', ')', Some(1..10))] // bracket spanning 3 lines, cursor end of line 1 + #[case("outer(\ninner(\ndeep\n)\nback\n)", 15, '(', ')', Some(13..19))] // nested multiline brackets + #[case("outer(\ninner(\ndeep\n)\nback\n)", 8, '(', ')', Some(6..26))] // nested multiline brackets + #[case("{\nkey: [\n value\n]\n}", 10, '[', ']', Some(8..17))] // mixed bracket types across lines + fn test_multiline_bracket_behavior( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_current_pair(open_char, close_char); + assert_eq!( + result, expected, - "Failed for input: {}, cursor: {}", + "MULTILINE BRACKET TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", input, - cursor + cursor_pos, + open_char, + close_char, + input.lines().collect::>() ); } + + // Test next brackets work across multiple lines (unlike quotes which are line-restricted) + #[rstest] + #[case("line1\n(bracket)", 2, '(', ')', Some(7..14))] // cursor at bracket start on line 2 + #[case("line1\n(bracket)", 5, '(', ')', Some(7..14))] // cursor end of line 1 + #[case("outer(\ninner(\ndeep\n)\nback\n)", 0, '(', ')', Some(6..26))] // nested multiline brackets + #[case("outer(\ninner(\ndeep\n)\nback\n)", 8, '(', ')', Some(13..19))] // nested multiline brackets + fn test_multiline_next_bracket_behavior( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(open_char, close_char); + assert_eq!( + result, + expected, + "MULTILINE BRACKET TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", + input, + cursor_pos, + open_char, + close_char, + input.lines().collect::>() + ); + } + + // Unicode safety tests for core pair-finding functionality + #[rstest] + #[case("(🦀)", 1, '(', ')', Some(1..5))] // emoji inside brackets + #[case("🦀(text)🦀", 5, '(', ')', Some(5..9))] // emojis outside brackets + #[case("(multi👨‍👩‍👧‍👦family)", 1, '(', ')', Some(1..37))] // complex emoji family inside (25 bytes) + #[case("(åëïöü)", 1, '(', ')', Some(1..11))] // accented characters + #[case("(mixed🦀åëïtext)", 1, '(', ')', Some(1..20))] // mixed unicode content + #[case("'🦀emoji🦀'", 1, '\'', '\'', Some(1..14))] // emojis in quotes + #[case("'mixed👨‍👩‍👧‍👦åëï'", 1, '\'', '\'', Some(1..37))] // complex 25 byte family emoji + fn test_range_inside_current_pair_unicode_safety( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_current_pair(open_char, close_char); + assert_eq!(result, expected); + // Verify buffer remains valid after operations + assert!(buf.is_valid()); + } + + #[rstest] + #[case("start🦀(content)end", 0, '(', ')', Some(10..17))] // emoji before brackets + #[case("start(🦀)end", 0, '(', ')', Some(6..10))] // emoji inside brackets to find + #[case("🦀'text'🦀", 0, '\'', '\'', Some(5..9))] // emoji before quotes + #[case("start'🦀text🦀'", 0, '\'', '\'', Some(6..18))] // emoji before quotes + #[case("start'multi👨‍👩‍👧‍👦family'end", 0, '\'', '\'', Some(6..42))] // complex 25 byte family emoji + #[case("start'👨‍👩‍👧‍👦multifamily'end", 0, '\'', '\'', Some(6..42))] // complex 25 byte family emoji + fn test_range_inside_next_pair_unicode_safety( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(open_char, close_char); + assert_eq!(result, expected); + // Verify buffer remains valid after operations + assert!(buf.is_valid()); + } } diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 94871c19..8c856418 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -1,4 +1,5 @@ use super::{motion::Motion, motion::ViCharSearch, parser::ReedlineOption, ViMode}; +use crate::enums::{TextObject, TextObjectScope, TextObjectType}; use crate::{EditCommand, ReedlineEvent, Vi}; use std::iter::Peekable; @@ -9,26 +10,54 @@ where match input.peek() { Some('d') => { let _ = input.next(); - // Checking for "di(" or "di)" etc. + // Checking for "di(" or "diw" etc. if let Some('i') = input.peek() { let _ = input.next(); - input - .next() - .and_then(|c| bracket_pair_for(*c)) - .map(|(left, right)| Command::DeleteInsidePair { left, right }) + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::DeleteInsidePair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Inner) + .map(|text_object| Command::DeleteTextObject { text_object }) + }) + }) + } else if let Some('a') = input.peek() { + let _ = input.next(); + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::DeleteAroundPair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Around) + .map(|text_object| Command::DeleteTextObject { text_object }) + }) + }) } else { Some(Command::Delete) } } - // Checking for "yi(" or "yi)" etc. + // Checking for "yi(" or "yiw" etc. Some('y') => { let _ = input.next(); if let Some('i') = input.peek() { let _ = input.next(); - input - .next() - .and_then(|c| bracket_pair_for(*c)) - .map(|(left, right)| Command::YankInsidePair { left, right }) + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::YankInsidePair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Inner) + .map(|text_object| Command::YankTextObject { text_object }) + }) + }) + } else if let Some('a') = input.peek() { + let _ = input.next(); + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::YankAroundPair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Around) + .map(|text_object| Command::YankTextObject { text_object }) + }) + }) } else { Some(Command::Yank) } @@ -53,15 +82,25 @@ where let _ = input.next(); Some(Command::Undo) } - // Checking for "ci(" or "ci)" etc. + // Checking for "ci(" or "ciw" etc. Some('c') => { let _ = input.next(); if let Some('i') = input.peek() { let _ = input.next(); - input - .next() - .and_then(|c| bracket_pair_for(*c)) - .map(|(left, right)| Command::ChangeInsidePair { left, right }) + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::ChangeInsidePair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Inner) + .map(|text_object| Command::ChangeTextObject { text_object }) + }) + }) + } else if let Some('a') = input.peek() { + let _ = input.next(); + input.next().and_then(|c| { + char_to_text_object(*c, TextObjectScope::Around) + .map(|text_object| Command::ChangeTextObject { text_object }) + }) } else { Some(Command::Change) } @@ -147,6 +186,11 @@ pub enum Command { ChangeInsidePair { left: char, right: char }, DeleteInsidePair { left: char, right: char }, YankInsidePair { left: char, right: char }, + DeleteAroundPair { left: char, right: char }, + YankAroundPair { left: char, right: char }, + ChangeTextObject { text_object: TextObject }, + YankTextObject { text_object: TextObject }, + DeleteTextObject { text_object: TextObject }, SwapCursorAndAnchor, } @@ -210,23 +254,50 @@ impl Command { None => vec![], }, Self::ChangeInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CutInside { + vec![ReedlineOption::Edit(EditCommand::CutInsidePair { left: *left, right: *right, })] } Self::DeleteInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CutInside { + vec![ReedlineOption::Edit(EditCommand::CutInsidePair { left: *left, right: *right, })] } Self::YankInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::YankInside { + vec![ReedlineOption::Edit(EditCommand::CopyInsidePair { left: *left, right: *right, })] } + Self::DeleteAroundPair { left, right } => { + vec![ReedlineOption::Edit(EditCommand::CutAroundPair { + left: *left, + right: *right, + })] + } + Self::YankAroundPair { left, right } => { + vec![ReedlineOption::Edit(EditCommand::CopyAroundPair { + left: *left, + right: *right, + })] + } + Self::ChangeTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::CutTextObject { + text_object: *text_object, + })] + } + Self::YankTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::CopyTextObject { + text_object: *text_object, + })] + } + Self::DeleteTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::CutTextObject { + text_object: *text_object, + })] + } Self::SwapCursorAndAnchor => { vec![ReedlineOption::Edit(EditCommand::SwapCursorAndAnchor)] } @@ -400,6 +471,28 @@ impl Command { } } +fn char_to_text_object(c: char, scope: TextObjectScope) -> Option { + match c { + 'w' => Some(TextObject { + scope, + object_type: TextObjectType::Word, + }), + 'W' => Some(TextObject { + scope, + object_type: TextObjectType::BigWord, + }), + 'b' => Some(TextObject { + scope, + object_type: TextObjectType::Brackets, + }), + 'q' => Some(TextObject { + scope, + object_type: TextObjectType::Quote, + }), + _ => None, + } +} + fn bracket_pair_for(c: char) -> Option<(char, char)> { match c { '(' | ')' => Some(('(', ')')), diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index 3c39a3ea..d7bebb8b 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -114,6 +114,7 @@ impl ParsedViSequence { Some(ViMode::Normal) } (Some(Command::ChangeInsidePair { .. }), _) => Some(ViMode::Insert), + (Some(Command::ChangeTextObject { .. }), _) => Some(ViMode::Insert), (Some(Command::Delete), ParseResult::Incomplete) | (Some(Command::DeleteChar), ParseResult::Incomplete) | (Some(Command::DeleteToEnd), ParseResult::Incomplete) diff --git a/src/enums.rs b/src/enums.rs index 643afb48..8d6f3270 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -14,6 +14,46 @@ pub enum Signal { CtrlD, // End terminal session } +/// Scope of text object operation ("i" inner or "a" around) +#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum TextObjectScope { + /// Just the text object itself + Inner, + /// Expanded to include surrounding based on object type + Around, +} + +/// Type of text object to operate on +#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum TextObjectType { + /// word (delimited by non-alphanumeric characters) + Word, + /// WORD (delimited only by whitespace) + BigWord, + /// (, ), [, ], {, } + Brackets, + /// ", ', ` + Quote, +} + +/// Text objects that can be operated on with vim-style commands +#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct TextObject { + /// Whether to include surrounding context + pub scope: TextObjectScope, + /// The type of text object + pub object_type: TextObjectType, +} + +impl Default for TextObject { + fn default() -> Self { + Self { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + } + } +} + /// Editing actions which can be mapped to key bindings. /// /// Executed by `Reedline::run_edit_commands()` @@ -337,19 +377,43 @@ pub enum EditCommand { PasteSystem, /// Delete text between matching characters atomically - CutInside { + CutInsidePair { /// Left character of the pair left: char, /// Right character of the pair (usually matching bracket) right: char, }, /// Yank text between matching characters atomically - YankInside { + CopyInsidePair { + /// Left character of the pair + left: char, + /// Right character of the pair (usually matching bracket) + right: char, + }, + /// Delete text around matching characters atomically (including the pair characters) + CutAroundPair { /// Left character of the pair left: char, /// Right character of the pair (usually matching bracket) right: char, }, + /// Yank text around matching characters atomically (including the pair characters) + CopyAroundPair { + /// Left character of the pair + left: char, + /// Right character of the pair (usually matching bracket) + right: char, + }, + /// Cut the specified text object + CutTextObject { + /// The text object to operate on + text_object: TextObject, + }, + /// Copy the specified text object + CopyTextObject { + /// The text object to operate on + text_object: TextObject, + }, } impl Display for EditCommand { @@ -462,8 +526,12 @@ impl Display for EditCommand { EditCommand::CopySelectionSystem => write!(f, "CopySelectionSystem"), #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => write!(f, "PasteSystem"), - EditCommand::CutInside { .. } => write!(f, "CutInside Value: "), - EditCommand::YankInside { .. } => write!(f, "YankInside Value: "), + EditCommand::CutInsidePair { .. } => write!(f, "CutInsidePair Value: "), + EditCommand::CopyInsidePair { .. } => write!(f, "CopyInsidePair Value: "), + EditCommand::CutAroundPair { .. } => write!(f, "CutAroundPair Value: "), + EditCommand::CopyAroundPair { .. } => write!(f, "CopyAroundPair Value: "), + EditCommand::CutTextObject { .. } => write!(f, "CutTextObject Value: "), + EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject Value: "), } } } @@ -546,8 +614,12 @@ impl EditCommand { EditCommand::CopySelection => EditType::NoOp, #[cfg(feature = "system_clipboard")] EditCommand::CopySelectionSystem => EditType::NoOp, - EditCommand::CutInside { .. } => EditType::EditText, - EditCommand::YankInside { .. } => EditType::EditText, + EditCommand::CutInsidePair { .. } => EditType::EditText, + EditCommand::CopyInsidePair { .. } => EditType::EditText, + EditCommand::CutAroundPair { .. } => EditType::EditText, + EditCommand::CopyAroundPair { .. } => EditType::EditText, + EditCommand::CutTextObject { .. } => EditType::EditText, + EditCommand::CopyTextObject { .. } => EditType::NoOp, EditCommand::CopyFromStart | EditCommand::CopyFromLineStart | EditCommand::CopyToEnd