From b9eb263f74d569f84c05e2f5617f7ebb399f7d1d Mon Sep 17 00:00:00 2001 From: JonLD Date: Tue, 22 Jul 2025 01:25:22 +0100 Subject: [PATCH 01/25] Addd initial change inner and around word text objects and handle whitespace Note a buffer full of whitespace is not properly considered and still causing incorrect behaviour. TODO fix this. --- src/core_editor/editor.rs | 396 +++++++++++++++++++++++++++++++++ src/core_editor/line_buffer.rs | 67 ++++++ src/edit_mode/vi/command.rs | 84 +++++-- src/edit_mode/vi/parser.rs | 2 + src/enums.rs | 42 ++++ 5 files changed, 576 insertions(+), 15 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 1df298df..e70eb966 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -173,6 +173,26 @@ impl Editor { 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::ChangeInsideTextObject { text_object } => { + self.cut_inside_text_object(*text_object) + }, + EditCommand::YankInsideTextObject { text_object } => { + self.yank_inside_text_object(*text_object) + } + EditCommand::DeleteInsideTextObject { text_object } => { + + self.delete_inside_text_object(*text_object) + } + EditCommand::ChangeAroundTextObject { text_object } => { + self.cut_around_text_object(*text_object) + }, + EditCommand::YankAroundTextObject { text_object } => { + self.yank_around_text_object(*text_object) + } + EditCommand::DeleteAroundTextObject { text_object } => { + + self.delete_around_text_object(*text_object) + } } if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.selection_anchor = None; @@ -750,6 +770,156 @@ impl Editor { } } + /// Get the bounds for a text object operation + fn get_text_object_bounds(&self, text_object: char, around: bool) -> Option> { + + match text_object { + 'w' => { + if self.line_buffer.is_in_whitespace_block() { + Some(self.line_buffer.current_whitespace_range()) + } else { + let word_range = self.line_buffer.current_word_range(); + if around { + self.expand_range_with_whitespace(word_range) + } else { + Some(word_range) + } + } + } + 'W' => { + if self.line_buffer.is_in_whitespace_block() { + Some(self.line_buffer.current_whitespace_range()) + } else { + let big_word_range = self.get_current_big_word_range(); + if around { + self.expand_range_with_whitespace(big_word_range) + } else { + Some(big_word_range) + } + } + } + _ => None, // Unsupported text object + } + } + + /// Get the range of the current big word (WORD) at cursor position + fn get_current_big_word_range(&self) -> std::ops::Range { + // Get the end of the current big word + let right_index = self.line_buffer.big_word_right_end_index(); + + // Find start by searching backwards for whitespace (same pattern as current_word_range) + let buffer = self.line_buffer.get_buffer(); + let mut left_index = 0; + for (i, ch) in buffer[..right_index].char_indices().rev() { + if ch.is_whitespace() { + left_index = i + ch.len_utf8(); + break; + } + } + + // right_end_index returns position ON the last character, we need position AFTER it + left_index..(right_index + 1) + } + + /// Expand a word range to include surrounding whitespace for "around" operations + /// Prioritizes whitespace after the word, falls back to whitespace before if none after + fn expand_range_with_whitespace(&self, range: std::ops::Range) -> Option> { + let buffer = self.line_buffer.get_buffer(); + let mut start = range.start; + let mut end = range.end; + + // First, try to extend right to include following whitespace + let original_end = end; + while end < buffer.len() { + if let Some(ch) = buffer[end..].chars().next() { + if ch.is_whitespace() { + end += ch.len_utf8(); + } else { + break; + } + } else { + break; + } + } + + // If no whitespace was found after the word, try to include whitespace before + if end == original_end { + while start > 0 { + let prev_char_start = buffer.char_indices().rev() + .find(|(i, _)| *i < start) + .map(|(i, _)| i) + .unwrap_or(0); + if let Some(ch) = buffer[prev_char_start..start].chars().next() { + if ch.is_whitespace() { + start = prev_char_start; + } else { + break; + } + } else { + break; + } + } + } + + Some(start..end) + } + + fn cut_inside_text_object(&mut self, text_object: char) { + if let Some(range) = self.get_text_object_bounds(text_object, false) { + let cut_slice = &self.line_buffer.get_buffer()[range.clone()]; + if !cut_slice.is_empty() { + self.cut_buffer.set(cut_slice, ClipboardMode::Normal); + self.line_buffer.clear_range(range.clone()); + self.line_buffer.set_insertion_point(range.start); + } + } + } + + fn yank_inside_text_object(&mut self, text_object: char) { + let old_pos = self.insertion_point(); + if let Some(range) = self.get_text_object_bounds(text_object, false) { + let yank_slice = &self.line_buffer.get_buffer()[range]; + if !yank_slice.is_empty() { + self.cut_buffer.set(yank_slice, ClipboardMode::Normal); + } + } + // Always restore cursor position for yank operations + self.line_buffer.set_insertion_point(old_pos); + } + + fn delete_inside_text_object(&mut self, text_object: char) { + // Delete is the same as cut for text objects + self.cut_inside_text_object(text_object); + } + + fn cut_around_text_object(&mut self, text_object: char) { + if let Some(range) = self.get_text_object_bounds(text_object, true) { + let cut_slice = &self.line_buffer.get_buffer()[range.clone()]; + if !cut_slice.is_empty() { + self.cut_buffer.set(cut_slice, ClipboardMode::Normal); + self.line_buffer.clear_range(range.clone()); + self.line_buffer.set_insertion_point(range.start); + } + } + } + + fn yank_around_text_object(&mut self, text_object: char) { + let old_pos = self.insertion_point(); + if let Some(range) = self.get_text_object_bounds(text_object, true) { + let yank_slice = &self.line_buffer.get_buffer()[range]; + if !yank_slice.is_empty() { + self.cut_buffer.set(yank_slice, ClipboardMode::Normal); + } + } + // Always restore cursor position for yank operations + self.line_buffer.set_insertion_point(old_pos); + } + + fn delete_around_text_object(&mut self, text_object: char) { + // Delete is the same as cut for text objects + self.cut_around_text_object(text_object); + } + pub(crate) fn copy_from_start(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); if insertion_offset > 0 { @@ -1363,4 +1533,230 @@ 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_inside_text_object('w'); + 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.yank_inside_text_object('w'); + 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_around_text_object('w'); + 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.yank_around_text_object('w'); + 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")] // big word - cursor on email address + #[case("test@example.com file", 17, "test@example.com ", 17, "file")] // cursor on "file" - now working correctly + 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_inside_text_object('W'); + + 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_inside_text_object('w'); + 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, 'w', "-world test", "hello")] // small word gets just "hello" + #[case("hello-world test", 2, 'W', " test", "hello-world")] // big word gets "hello-world" + #[case("test@example.com", 6, 'w', "test@", "example.com")] // small word in email (UAX#29 extends across punct) + #[case("test@example.com", 6, 'W', "", "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: char, + #[case] expected_buffer: &str, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_inside_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_inside_text_object('w'); + 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_around_text_object('w'); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // Test operations when cursor is IN WHITESPACE (middle of spaces) + #[case("hello world test", 5, 'w', "helloworld test", 5, " ")] // single space + #[case("hello world", 6, 'w', "helloworld", 5, " ")] // multiple spaces, cursor on second + #[case("hello world", 7, 'w', "helloworld", 5, " ")] // multiple spaces, cursor on middle + #[case(" hello", 1, 'w', "hello", 0, " ")] // leading spaces, cursor on middle + #[case("hello ", 7, 'w', "hello", 5, " ")] // trailing spaces, cursor on middle + #[case("hello\tworld", 5, 'w', "helloworld", 5, "\t")] // tab character + #[case("hello\nworld", 5, 'w', "helloworld", 5, "\n")] // newline character + #[case("hello world test", 5, 'W', "helloworld test", 5, " ")] // single space (big word) + #[case("hello world", 6, 'W', "helloworld", 5, " ")] // multiple spaces (big word) + #[case(" ", 0, 'w', " ", 0, "")] // only whitespace at start + #[case(" ", 1, 'w', " ", 0, "")] // only whitespace at end + #[case("hello ", 5, 'w', "hello", 5, " ")] // trailing whitespace at string end + #[case(" hello", 0, 'w', "hello", 0, " ")] // leading whitespace at string start + fn test_text_object_in_whitespace( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] text_object: char, + #[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_inside_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); + } + + // TODO: Punctuation boundary handling - due to UAX#29 word boundary limitations it currently + // behaves different to vim. + // Known issues with current implementation: + // 1. When cursor is on alphanumeric chars adjacent to punctuation (like 'l' in "example.com"), + // the word extends across the punctuation due to Unicode word boundaries + // 2. Sequential punctuation is not treated as a single word (each punct char is separate) + // 3. This differs from vim's word definition where punctuation breaks words consistently + // + // #[test] + // fn test_yank_inside_word_with_punctuation() { ... } } diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 07eaa035..e3b17e1e 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -307,6 +307,73 @@ impl LineBuffer { .unwrap_or_else(|| self.lines.len()) } + /// Returns true if cursor is at the end of the buffer with preceding whitespace. + /// + /// This handles the edge case where the cursor is positioned after the last character + /// in a buffer that ends with whitespace. In vim, this position is still considered + /// part of the trailing whitespace block for text object operations. + 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()) + } + + /// Returns true if the cursor is positioned within a whitespace block. + /// + /// This includes being on a whitespace character or at the end of trailing whitespace. + /// Used for vim-style text object operations where whitespace itself is a text object. + pub fn is_in_whitespace_block(&self) -> bool { + self.on_whitespace() || self.at_end_of_line_with_preceding_whitespace() + } + + /// Cursor position at the end of the current whitespace block. + /// + /// Searches forward from cursor position to find where the current whitespace + /// block ends (first non-whitespace character). Returns buffer length if + /// whitespace extends to end of buffer. + 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. + /// + /// Searches backward from cursor position to find where the current whitespace + /// block starts (position after last non-whitespace character). Returns 0 if + /// whitespace extends to start of buffer. + 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(self.lines.len()) + } + + /// Gets the range of the current whitespace block at cursor position. + /// + /// Returns the complete range of consecutive whitespace characters that includes + /// the cursor position. If cursor is at the end of trailing whitespace, includes + /// that trailing block. Returns an empty range (0..0) if not in a whitespace context. + /// + /// Used for vim-style text object operations (iw/aw when cursor is on whitespace). + pub fn current_whitespace_range(&self) -> Range { + if self.on_whitespace() { + let right_index = self.current_whitespace_end_index(); + let left_index = self.current_whitespace_start_index(); + left_index..right_index + } else if self.at_end_of_line_with_preceding_whitespace() { + let left_index = self.current_whitespace_start_index(); + left_index..self.insertion_point + } + else { + 0..0 + } + } + /// Move cursor position *behind* the next unicode grapheme to the right pub fn move_right(&mut self) { self.insertion_point = self.grapheme_right_index(); diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 94871c19..8aaf1beb 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -9,26 +9,38 @@ 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().map(|c| { + if let Some((left, right)) = bracket_pair_for(*c) { + Command::DeleteInsidePair { left, right } + } else { + Command::DeleteInsideTextObject { text_object: *c } + } + }) + } else if let Some('a') = input.peek() { + let _ = input.next(); + input.next().map(|c| Command::DeleteAroundTextObject { text_object: *c }) } 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().map(|c| { + if let Some((left, right)) = bracket_pair_for(*c) { + Command::YankInsidePair { left, right } + } else { + Command::YankInsideTextObject { text_object: *c } + } + }) + } else if let Some('a') = input.peek() { + let _ = input.next(); + input.next().map(|c| Command::YankAroundTextObject { text_object: *c }) } else { Some(Command::Yank) } @@ -53,15 +65,21 @@ 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().map(|c| { + if let Some((left, right)) = bracket_pair_for(*c) { + Command::ChangeInsidePair { left, right } + } else { + Command::ChangeInsideTextObject { text_object: *c } + } + }) + } else if let Some('a') = input.peek() { + let _ = input.next(); + input.next().map(|c| Command::ChangeAroundTextObject { text_object: *c }) } else { Some(Command::Change) } @@ -147,6 +165,12 @@ pub enum Command { ChangeInsidePair { left: char, right: char }, DeleteInsidePair { left: char, right: char }, YankInsidePair { left: char, right: char }, + ChangeInsideTextObject { text_object: char }, + YankInsideTextObject { text_object: char }, + DeleteInsideTextObject { text_object: char }, + ChangeAroundTextObject { text_object: char }, + YankAroundTextObject { text_object: char }, + DeleteAroundTextObject { text_object: char }, SwapCursorAndAnchor, } @@ -227,6 +251,36 @@ impl Command { right: *right, })] } + Self::ChangeInsideTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::ChangeInsideTextObject { + text_object: *text_object + })] + } + Self::YankInsideTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::YankInsideTextObject { + text_object: *text_object + })] + } + Self::DeleteInsideTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::DeleteInsideTextObject { + text_object: *text_object + })] + } + Self::ChangeAroundTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::ChangeAroundTextObject { + text_object: *text_object + })] + } + Self::YankAroundTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::YankAroundTextObject { + text_object: *text_object + })] + } + Self::DeleteAroundTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::DeleteAroundTextObject { + text_object: *text_object + })] + } Self::SwapCursorAndAnchor => { vec![ReedlineOption::Edit(EditCommand::SwapCursorAndAnchor)] } diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index 3c39a3ea..e3996c90 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -114,6 +114,8 @@ impl ParsedViSequence { Some(ViMode::Normal) } (Some(Command::ChangeInsidePair { .. }), _) => Some(ViMode::Insert), + (Some(Command::ChangeInsideTextObject { .. }), _) => Some(ViMode::Insert), + (Some(Command::ChangeAroundTextObject { .. }), _) => 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..09b0cfe9 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -350,6 +350,36 @@ pub enum EditCommand { /// Right character of the pair (usually matching bracket) right: char, }, + /// Cut text inside a text object (e.g. word) + ChangeInsideTextObject { + /// The text object character ('w' for word, 'W' for WORD, etc.) + text_object: char + }, + /// Yank text inside a text object (e.g. word) + YankInsideTextObject { + /// The text object character ('w' for word, 'W' for WORD, etc.) + text_object: char + }, + /// Delete text inside a text object (e.g. word) + DeleteInsideTextObject { + /// The text object character ('w' for word, 'W' for WORD, etc.) + text_object: char + }, + /// Cut text around a text object including surrounding whitespace + ChangeAroundTextObject { + /// The text object character ('w' for word, 'W' for WORD, etc.) + text_object: char + }, + /// Yank text around a text object including surrounding whitespace + YankAroundTextObject { + /// The text object character ('w' for word, 'W' for WORD, etc.) + text_object: char + }, + /// Delete text around a text object including surrounding whitespace + DeleteAroundTextObject { + /// The text object character ('w' for word, 'W' for WORD, etc.) + text_object: char + }, } impl Display for EditCommand { @@ -464,6 +494,12 @@ impl Display for EditCommand { EditCommand::PasteSystem => write!(f, "PasteSystem"), EditCommand::CutInside { .. } => write!(f, "CutInside Value: "), EditCommand::YankInside { .. } => write!(f, "YankInside Value: "), + EditCommand::ChangeInsideTextObject { .. } => write!(f, "CutInsideTextObject"), + EditCommand::YankInsideTextObject { .. } => write!(f, "YankInsideTextObject"), + EditCommand::DeleteInsideTextObject { .. } => write!(f, "DeleteInsideTextObject"), + EditCommand::ChangeAroundTextObject { .. } => write!(f, "CutAroundTextObject"), + EditCommand::YankAroundTextObject { .. } => write!(f, "YankAroundTextObject"), + EditCommand::DeleteAroundTextObject { .. } => write!(f, "DeleteAroundTextObject"), } } } @@ -548,6 +584,12 @@ impl EditCommand { EditCommand::CopySelectionSystem => EditType::NoOp, EditCommand::CutInside { .. } => EditType::EditText, EditCommand::YankInside { .. } => EditType::EditText, + EditCommand::ChangeInsideTextObject { .. } => EditType::EditText, + EditCommand::ChangeAroundTextObject { .. } => EditType::EditText, + EditCommand::YankInsideTextObject { .. } => EditType::NoOp, + EditCommand::DeleteInsideTextObject { .. } => EditType::NoOp, + EditCommand::YankAroundTextObject { .. } => EditType::NoOp, + EditCommand::DeleteAroundTextObject { .. } => EditType::NoOp, EditCommand::CopyFromStart | EditCommand::CopyFromLineStart | EditCommand::CopyToEnd From c32f7572ef08f13b7ae5ae36733b82f9f92f8e11 Mon Sep 17 00:00:00 2001 From: JonLD Date: Tue, 22 Jul 2025 14:32:29 +0100 Subject: [PATCH 02/25] Renaming functions and fix default value of current_whitespace_range_start --- src/core_editor/editor.rs | 14 +++++++------- src/core_editor/line_buffer.rs | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index e70eb966..1204cf19 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -771,7 +771,7 @@ impl Editor { } /// Get the bounds for a text object operation - fn get_text_object_bounds(&self, text_object: char, around: bool) -> Option> { + fn text_object_range(&self, text_object: char, around: bool) -> Option> { match text_object { 'w' => { @@ -790,7 +790,7 @@ impl Editor { if self.line_buffer.is_in_whitespace_block() { Some(self.line_buffer.current_whitespace_range()) } else { - let big_word_range = self.get_current_big_word_range(); + let big_word_range = self.current_big_word_range(); if around { self.expand_range_with_whitespace(big_word_range) } else { @@ -803,7 +803,7 @@ impl Editor { } /// Get the range of the current big word (WORD) at cursor position - fn get_current_big_word_range(&self) -> std::ops::Range { + fn current_big_word_range(&self) -> std::ops::Range { // Get the end of the current big word let right_index = self.line_buffer.big_word_right_end_index(); @@ -865,7 +865,7 @@ impl Editor { } fn cut_inside_text_object(&mut self, text_object: char) { - if let Some(range) = self.get_text_object_bounds(text_object, false) { + if let Some(range) = self.text_object_range(text_object, false) { let cut_slice = &self.line_buffer.get_buffer()[range.clone()]; if !cut_slice.is_empty() { self.cut_buffer.set(cut_slice, ClipboardMode::Normal); @@ -877,7 +877,7 @@ impl Editor { fn yank_inside_text_object(&mut self, text_object: char) { let old_pos = self.insertion_point(); - if let Some(range) = self.get_text_object_bounds(text_object, false) { + if let Some(range) = self.text_object_range(text_object, false) { let yank_slice = &self.line_buffer.get_buffer()[range]; if !yank_slice.is_empty() { self.cut_buffer.set(yank_slice, ClipboardMode::Normal); @@ -893,7 +893,7 @@ impl Editor { } fn cut_around_text_object(&mut self, text_object: char) { - if let Some(range) = self.get_text_object_bounds(text_object, true) { + if let Some(range) = self.text_object_range(text_object, true) { let cut_slice = &self.line_buffer.get_buffer()[range.clone()]; if !cut_slice.is_empty() { self.cut_buffer.set(cut_slice, ClipboardMode::Normal); @@ -905,7 +905,7 @@ impl Editor { fn yank_around_text_object(&mut self, text_object: char) { let old_pos = self.insertion_point(); - if let Some(range) = self.get_text_object_bounds(text_object, true) { + if let Some(range) = self.text_object_range(text_object, true) { let yank_slice = &self.line_buffer.get_buffer()[range]; if !yank_slice.is_empty() { self.cut_buffer.set(yank_slice, ClipboardMode::Normal); diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index e3b17e1e..84207bb4 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -331,7 +331,7 @@ impl LineBuffer { /// Searches forward from cursor position to find where the current whitespace /// block ends (first non-whitespace character). Returns buffer length if /// whitespace extends to end of buffer. - fn current_whitespace_end_index(&self) -> usize { + fn current_whitespace_range_end(&self) -> usize { self.lines[self.insertion_point..] .char_indices() .find(|(_, ch)| !ch.is_whitespace()) @@ -344,13 +344,13 @@ impl LineBuffer { /// Searches backward from cursor position to find where the current whitespace /// block starts (position after last non-whitespace character). Returns 0 if /// whitespace extends to start of buffer. - fn current_whitespace_start_index(&self) -> usize { + fn current_whitespace_range_start(&self) -> usize { self.lines[..self.insertion_point] .char_indices() .rev() .find(|(_, ch)| !ch.is_whitespace()) .map(|(i, _)| i + 1) - .unwrap_or(self.lines.len()) + .unwrap_or(0) } /// Gets the range of the current whitespace block at cursor position. @@ -362,12 +362,12 @@ impl LineBuffer { /// Used for vim-style text object operations (iw/aw when cursor is on whitespace). pub fn current_whitespace_range(&self) -> Range { if self.on_whitespace() { - let right_index = self.current_whitespace_end_index(); - let left_index = self.current_whitespace_start_index(); - left_index..right_index + let range_end = self.current_whitespace_range_end(); + let range_start = self.current_whitespace_range_start(); + range_start..range_end } else if self.at_end_of_line_with_preceding_whitespace() { - let left_index = self.current_whitespace_start_index(); - left_index..self.insertion_point + let range_start = self.current_whitespace_range_start(); + range_start..self.insertion_point } else { 0..0 From 8caebcbc21008c5eab1cd029db8b1b526e672ab5 Mon Sep 17 00:00:00 2001 From: JonLD Date: Tue, 22 Jul 2025 15:55:14 +0100 Subject: [PATCH 03/25] Rename cut/yank inside enums and methods to cut/yank inside pair and add general yank/cut range methods --- src/core_editor/editor.rs | 143 ++++++++++++++------------------- src/core_editor/line_buffer.rs | 8 +- src/edit_mode/vi/command.rs | 14 ++-- src/enums.rs | 38 +++------ 4 files changed, 82 insertions(+), 121 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 1204cf19..9916f857 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -171,28 +171,20 @@ 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::ChangeInsideTextObject { text_object } => { + EditCommand::CutInsidePair { left, right } => self.cut_inside_pair(*left, *right), + EditCommand::YankInsidePair { left, right } => self.yank_inside_pair(*left, *right), + EditCommand::CutInsideTextObject { text_object } => { self.cut_inside_text_object(*text_object) }, EditCommand::YankInsideTextObject { text_object } => { self.yank_inside_text_object(*text_object) } - EditCommand::DeleteInsideTextObject { text_object } => { - - self.delete_inside_text_object(*text_object) - } - EditCommand::ChangeAroundTextObject { text_object } => { + EditCommand::CutAroundTextObject { text_object } => { self.cut_around_text_object(*text_object) }, EditCommand::YankAroundTextObject { text_object } => { self.yank_around_text_object(*text_object) } - EditCommand::DeleteAroundTextObject { text_object } => { - - self.delete_around_text_object(*text_object) - } } if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.selection_anchor = None; @@ -614,7 +606,7 @@ impl Editor { 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.line_buffer.clear_range_safe(start..end); self.selection_anchor = None; } } @@ -623,7 +615,7 @@ impl Editor { 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.line_buffer.clear_range_safe(start..end); self.selection_anchor = None; } } @@ -666,7 +658,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; } } @@ -745,12 +737,28 @@ impl Editor { self.selection_anchor = None; } + fn cut_range(&mut self, range: std::ops::Range) { + self.yank_range(range.clone()); + self.line_buffer.clear_range_safe(range.clone()); + // Redundant as clear_range_safe should place insertion point at + // start of clear range but this ensures it's in the right place + self.line_buffer.set_insertion_point(range.start); + } + + fn yank_range(&mut self, range: std::ops::Range) { + let slice = &self.line_buffer.get_buffer()[range]; + if !slice.is_empty() { + self.cut_buffer.set(slice, ClipboardMode::Normal); + } + } + + /// 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) { + pub(crate) fn cut_inside_pair(&mut self, left_char: char, right_char: char) { let buffer_len = self.line_buffer.len(); if let Some((lp, rp)) = @@ -759,13 +767,7 @@ impl Editor { { 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()); + self.cut_range(inside_start..rp); } } } @@ -866,58 +868,26 @@ impl Editor { fn cut_inside_text_object(&mut self, text_object: char) { if let Some(range) = self.text_object_range(text_object, false) { - let cut_slice = &self.line_buffer.get_buffer()[range.clone()]; - if !cut_slice.is_empty() { - self.cut_buffer.set(cut_slice, ClipboardMode::Normal); - self.line_buffer.clear_range(range.clone()); - self.line_buffer.set_insertion_point(range.start); - } + self.cut_range(range); } } fn yank_inside_text_object(&mut self, text_object: char) { - let old_pos = self.insertion_point(); if let Some(range) = self.text_object_range(text_object, false) { - let yank_slice = &self.line_buffer.get_buffer()[range]; - if !yank_slice.is_empty() { - self.cut_buffer.set(yank_slice, ClipboardMode::Normal); - } + self.yank_range(range) } - // Always restore cursor position for yank operations - self.line_buffer.set_insertion_point(old_pos); - } - - fn delete_inside_text_object(&mut self, text_object: char) { - // Delete is the same as cut for text objects - self.cut_inside_text_object(text_object); } fn cut_around_text_object(&mut self, text_object: char) { if let Some(range) = self.text_object_range(text_object, true) { - let cut_slice = &self.line_buffer.get_buffer()[range.clone()]; - if !cut_slice.is_empty() { - self.cut_buffer.set(cut_slice, ClipboardMode::Normal); - self.line_buffer.clear_range(range.clone()); - self.line_buffer.set_insertion_point(range.start); - } + self.cut_range(range); } } fn yank_around_text_object(&mut self, text_object: char) { - let old_pos = self.insertion_point(); if let Some(range) = self.text_object_range(text_object, true) { - let yank_slice = &self.line_buffer.get_buffer()[range]; - if !yank_slice.is_empty() { - self.cut_buffer.set(yank_slice, ClipboardMode::Normal); - } + self.yank_range(range); } - // Always restore cursor position for yank operations - self.line_buffer.set_insertion_point(old_pos); - } - - fn delete_around_text_object(&mut self, text_object: char) { - // Delete is the same as cut for text objects - self.cut_around_text_object(text_object); } pub(crate) fn copy_from_start(&mut self) { @@ -1058,8 +1028,7 @@ impl Editor { /// 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(); + pub(crate) fn yank_inside_pair(&mut self, left_char: char, right_char: char) { let buffer_len = self.line_buffer.len(); if let Some((lp, rp)) = @@ -1068,15 +1037,9 @@ impl Editor { { 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.yank_range(inside_start..rp) } } - - // Always restore the cursor position - self.line_buffer.set_insertion_point(old_pos); } } @@ -1358,7 +1321,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"); @@ -1366,7 +1329,7 @@ mod test { // Test with cursor outside brackets let mut editor = editor_with("foo(bar)baz"); editor.move_to_position(0, false); - editor.cut_inside('(', ')'); + editor.cut_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar)baz"); assert_eq!(editor.insertion_point(), 0); assert_eq!(editor.cut_buffer.get().0, ""); @@ -1374,7 +1337,7 @@ mod test { // 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, ""); @@ -1384,7 +1347,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"); @@ -1392,7 +1355,7 @@ mod test { // Test with cursor outside quotes let mut editor = editor_with("foo\"bar\"baz"); editor.move_to_position(0, false); - editor.cut_inside('"', '"'); + editor.cut_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo\"bar\"baz"); assert_eq!(editor.insertion_point(), 0); assert_eq!(editor.cut_buffer.get().0, ""); @@ -1400,7 +1363,7 @@ mod test { // 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); } @@ -1409,13 +1372,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"); @@ -1425,7 +1388,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.yank_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 @@ -1436,7 +1399,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.yank_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar)baz"); assert_eq!(editor.insertion_point(), 0); } @@ -1445,7 +1408,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.yank_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"); @@ -1453,7 +1416,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.yank_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo bar baz"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, ""); @@ -1463,7 +1426,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.yank_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"); @@ -1473,7 +1436,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.yank_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"); @@ -1718,6 +1681,18 @@ mod test { 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_inside_text_object('w'); // 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, 'w', "helloworld test", 5, " ")] // single space @@ -1729,8 +1704,8 @@ mod test { #[case("hello\nworld", 5, 'w', "helloworld", 5, "\n")] // newline character #[case("hello world test", 5, 'W', "helloworld test", 5, " ")] // single space (big word) #[case("hello world", 6, 'W', "helloworld", 5, " ")] // multiple spaces (big word) - #[case(" ", 0, 'w', " ", 0, "")] // only whitespace at start - #[case(" ", 1, 'w', " ", 0, "")] // only whitespace at end + #[case(" ", 0, 'w', "", 0, " ")] // only whitespace at start + #[case(" ", 1, 'w', "", 0, " ")] // only whitespace at end #[case("hello ", 5, 'w', "hello", 5, " ")] // trailing whitespace at string end #[case(" hello", 0, 'w', "hello", 0, " ")] // leading whitespace at string start fn test_text_object_in_whitespace( diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 84207bb4..18e96127 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -476,11 +476,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 diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 8aaf1beb..a3f0cc5d 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -234,25 +234,25 @@ 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::YankInsidePair { left: *left, right: *right, })] } Self::ChangeInsideTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::ChangeInsideTextObject { + vec![ReedlineOption::Edit(EditCommand::CutInsideTextObject { text_object: *text_object })] } @@ -262,12 +262,12 @@ impl Command { })] } Self::DeleteInsideTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::DeleteInsideTextObject { + vec![ReedlineOption::Edit(EditCommand::CutInsideTextObject { text_object: *text_object })] } Self::ChangeAroundTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::ChangeAroundTextObject { + vec![ReedlineOption::Edit(EditCommand::CutAroundTextObject { text_object: *text_object })] } @@ -277,7 +277,7 @@ impl Command { })] } Self::DeleteAroundTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::DeleteAroundTextObject { + vec![ReedlineOption::Edit(EditCommand::CutAroundTextObject { text_object: *text_object })] } diff --git a/src/enums.rs b/src/enums.rs index 09b0cfe9..0a8247c8 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -337,21 +337,21 @@ 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 { + YankInsidePair { /// Left character of the pair left: char, /// Right character of the pair (usually matching bracket) right: char, }, /// Cut text inside a text object (e.g. word) - ChangeInsideTextObject { + CutInsideTextObject { /// The text object character ('w' for word, 'W' for WORD, etc.) text_object: char }, @@ -360,13 +360,8 @@ pub enum EditCommand { /// The text object character ('w' for word, 'W' for WORD, etc.) text_object: char }, - /// Delete text inside a text object (e.g. word) - DeleteInsideTextObject { - /// The text object character ('w' for word, 'W' for WORD, etc.) - text_object: char - }, /// Cut text around a text object including surrounding whitespace - ChangeAroundTextObject { + CutAroundTextObject { /// The text object character ('w' for word, 'W' for WORD, etc.) text_object: char }, @@ -375,11 +370,6 @@ pub enum EditCommand { /// The text object character ('w' for word, 'W' for WORD, etc.) text_object: char }, - /// Delete text around a text object including surrounding whitespace - DeleteAroundTextObject { - /// The text object character ('w' for word, 'W' for WORD, etc.) - text_object: char - }, } impl Display for EditCommand { @@ -492,14 +482,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::ChangeInsideTextObject { .. } => write!(f, "CutInsideTextObject"), + EditCommand::CutInsidePair { .. } => write!(f, "CutInside Value: "), + EditCommand::YankInsidePair { .. } => write!(f, "YankInside Value: "), + EditCommand::CutInsideTextObject { .. } => write!(f, "CutInsideTextObject"), EditCommand::YankInsideTextObject { .. } => write!(f, "YankInsideTextObject"), - EditCommand::DeleteInsideTextObject { .. } => write!(f, "DeleteInsideTextObject"), - EditCommand::ChangeAroundTextObject { .. } => write!(f, "CutAroundTextObject"), + EditCommand::CutAroundTextObject { .. } => write!(f, "CutAroundTextObject"), EditCommand::YankAroundTextObject { .. } => write!(f, "YankAroundTextObject"), - EditCommand::DeleteAroundTextObject { .. } => write!(f, "DeleteAroundTextObject"), } } } @@ -582,14 +570,12 @@ impl EditCommand { EditCommand::CopySelection => EditType::NoOp, #[cfg(feature = "system_clipboard")] EditCommand::CopySelectionSystem => EditType::NoOp, - EditCommand::CutInside { .. } => EditType::EditText, - EditCommand::YankInside { .. } => EditType::EditText, - EditCommand::ChangeInsideTextObject { .. } => EditType::EditText, - EditCommand::ChangeAroundTextObject { .. } => EditType::EditText, + EditCommand::CutInsidePair { .. } => EditType::EditText, + EditCommand::YankInsidePair { .. } => EditType::EditText, + EditCommand::CutInsideTextObject { .. } => EditType::EditText, + EditCommand::CutAroundTextObject { .. } => EditType::EditText, EditCommand::YankInsideTextObject { .. } => EditType::NoOp, - EditCommand::DeleteInsideTextObject { .. } => EditType::NoOp, EditCommand::YankAroundTextObject { .. } => EditType::NoOp, - EditCommand::DeleteAroundTextObject { .. } => EditType::NoOp, EditCommand::CopyFromStart | EditCommand::CopyFromLineStart | EditCommand::CopyToEnd From ba6bba8ae6b7e3ba5aac208314d4ee0b9f597434 Mon Sep 17 00:00:00 2001 From: JonLD Date: Wed, 23 Jul 2025 01:46:40 +0100 Subject: [PATCH 04/25] Use TextObject enum instead of passing through the character --- src/core_editor/editor.rs | 325 ++++++++++----------------------- src/core_editor/line_buffer.rs | 4 +- src/edit_mode/vi/command.rs | 102 ++++++----- src/edit_mode/vi/parser.rs | 3 +- src/enums.rs | 80 +++++--- 5 files changed, 206 insertions(+), 308 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 9916f857..66f8c862 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1,7 +1,7 @@ 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; @@ -172,20 +172,15 @@ impl Editor { #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => self.paste_from_system(), EditCommand::CutInsidePair { left, right } => self.cut_inside_pair(*left, *right), - EditCommand::YankInsidePair { left, right } => self.yank_inside_pair(*left, *right), - EditCommand::CutInsideTextObject { text_object } => { - self.cut_inside_text_object(*text_object) + EditCommand::CopyInsidePair { left, right } => self.yank_inside_pair(*left, *right), + EditCommand::CutTextObject { text_object } => { + self.cut_text_object(*text_object) }, - EditCommand::YankInsideTextObject { text_object } => { - self.yank_inside_text_object(*text_object) + EditCommand::CopyTextObject { text_object } => { + self.yank_text_object(*text_object) } - EditCommand::CutAroundTextObject { text_object } => { - self.cut_around_text_object(*text_object) - }, - EditCommand::YankAroundTextObject { text_object } => { - self.yank_around_text_object(*text_object) - } - } + , + } if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.selection_anchor = None; } @@ -386,95 +381,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) { @@ -604,18 +548,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; } } @@ -738,16 +677,18 @@ impl Editor { } fn cut_range(&mut self, range: std::ops::Range) { - self.yank_range(range.clone()); - self.line_buffer.clear_range_safe(range.clone()); - // Redundant as clear_range_safe should place insertion point at - // start of clear range but this ensures it's in the right place - self.line_buffer.set_insertion_point(range.start); + if range.start < range.end { + self.yank_range(range.clone()); + self.line_buffer.clear_range_safe(range.clone()); + // Redundant as clear_range_safe should place insertion point at + // start of clear range but this ensures it's in the right place + self.line_buffer.set_insertion_point(range.start); + } } fn yank_range(&mut self, range: std::ops::Range) { - let slice = &self.line_buffer.get_buffer()[range]; - if !slice.is_empty() { + if range.start < range.end { + let slice = &self.line_buffer.get_buffer()[range]; self.cut_buffer.set(slice, ClipboardMode::Normal); } } @@ -773,34 +714,30 @@ impl Editor { } /// Get the bounds for a text object operation - fn text_object_range(&self, text_object: char, around: bool) -> Option> { - - match text_object { - 'w' => { - if self.line_buffer.is_in_whitespace_block() { + fn text_object_range(&self, text_object: TextObject) -> Option> { + match text_object.object_type { + TextObjectType::Word => { + if self.line_buffer.in_whitespace_block() { Some(self.line_buffer.current_whitespace_range()) } else { let word_range = self.line_buffer.current_word_range(); - if around { - self.expand_range_with_whitespace(word_range) - } else { - Some(word_range) + match text_object.scope { + TextObjectScope::Inner => Some(word_range), + TextObjectScope::Around => self.expand_range_with_whitespace(word_range), } } } - 'W' => { - if self.line_buffer.is_in_whitespace_block() { + TextObjectType::BigWord => { + if self.line_buffer.in_whitespace_block() { Some(self.line_buffer.current_whitespace_range()) } else { let big_word_range = self.current_big_word_range(); - if around { - self.expand_range_with_whitespace(big_word_range) - } else { - Some(big_word_range) + match text_object.scope { + TextObjectScope::Inner => Some(big_word_range), + TextObjectScope::Around => self.expand_range_with_whitespace(big_word_range), } } } - _ => None, // Unsupported text object } } @@ -866,26 +803,14 @@ impl Editor { Some(start..end) } - fn cut_inside_text_object(&mut self, text_object: char) { - if let Some(range) = self.text_object_range(text_object, false) { + fn cut_text_object(&mut self, text_object: TextObject) { + if let Some(range) = self.text_object_range(text_object) { self.cut_range(range); } } - fn yank_inside_text_object(&mut self, text_object: char) { - if let Some(range) = self.text_object_range(text_object, false) { - self.yank_range(range) - } - } - - fn cut_around_text_object(&mut self, text_object: char) { - if let Some(range) = self.text_object_range(text_object, true) { - self.cut_range(range); - } - } - - fn yank_around_text_object(&mut self, text_object: char) { - if let Some(range) = self.text_object_range(text_object, true) { + fn yank_text_object(&mut self, text_object: TextObject) { + if let Some(range) = self.text_object_range(text_object) { self.yank_range(range); } } @@ -910,118 +835,68 @@ 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.yank_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.yank_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.yank_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.yank_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.yank_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.yank_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.yank_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.yank_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.yank_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.yank_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.yank_range(copy_range); } } @@ -1510,7 +1385,7 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.cut_inside_text_object('w'); + 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); @@ -1527,7 +1402,7 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.yank_inside_text_object('w'); + editor.yank_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); @@ -1556,7 +1431,7 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.cut_around_text_object('w'); + 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); @@ -1573,7 +1448,7 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.yank_around_text_object('w'); + editor.yank_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); @@ -1593,7 +1468,7 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.cut_inside_text_object('W'); + 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); @@ -1615,27 +1490,27 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.cut_inside_text_object('w'); + 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, 'w', "-world test", "hello")] // small word gets just "hello" - #[case("hello-world test", 2, 'W', " test", "hello-world")] // big word gets "hello-world" - #[case("test@example.com", 6, 'w', "test@", "example.com")] // small word in email (UAX#29 extends across punct) - #[case("test@example.com", 6, 'W', "", "test@example.com")] // big word gets entire email + #[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: char, + #[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_inside_text_object(text_object); + editor.cut_text_object(text_object); assert_eq!(editor.get_buffer(), expected_buffer); assert_eq!(editor.cut_buffer.get().0, expected_cut); } @@ -1657,7 +1532,7 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.cut_inside_text_object('w'); + editor.cut_text_object(TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }); assert_eq!(editor.cut_buffer.get().0, expected_cut); } @@ -1677,7 +1552,7 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.cut_around_text_object('w'); + editor.cut_text_object(TextObject { scope: TextObjectScope::Around, object_type: TextObjectType::Word }); assert_eq!(editor.cut_buffer.get().0, expected_cut); } @@ -1687,7 +1562,7 @@ mod test { editor.move_to_position(10, false); // Position after the emoji editor.move_to_position(6, false); // Move to the emoji - editor.cut_inside_text_object('w'); // Cut 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 } @@ -1695,30 +1570,30 @@ mod test { #[rstest] // Test operations when cursor is IN WHITESPACE (middle of spaces) - #[case("hello world test", 5, 'w', "helloworld test", 5, " ")] // single space - #[case("hello world", 6, 'w', "helloworld", 5, " ")] // multiple spaces, cursor on second - #[case("hello world", 7, 'w', "helloworld", 5, " ")] // multiple spaces, cursor on middle - #[case(" hello", 1, 'w', "hello", 0, " ")] // leading spaces, cursor on middle - #[case("hello ", 7, 'w', "hello", 5, " ")] // trailing spaces, cursor on middle - #[case("hello\tworld", 5, 'w', "helloworld", 5, "\t")] // tab character - #[case("hello\nworld", 5, 'w', "helloworld", 5, "\n")] // newline character - #[case("hello world test", 5, 'W', "helloworld test", 5, " ")] // single space (big word) - #[case("hello world", 6, 'W', "helloworld", 5, " ")] // multiple spaces (big word) - #[case(" ", 0, 'w', "", 0, " ")] // only whitespace at start - #[case(" ", 1, 'w', "", 0, " ")] // only whitespace at end - #[case("hello ", 5, 'w', "hello", 5, " ")] // trailing whitespace at string end - #[case(" hello", 0, 'w', "hello", 0, " ")] // leading whitespace at string start + #[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: char, + #[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_inside_text_object(text_object); + 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); diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 18e96127..eccd5fda 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -322,7 +322,7 @@ impl LineBuffer { /// /// This includes being on a whitespace character or at the end of trailing whitespace. /// Used for vim-style text object operations where whitespace itself is a text object. - pub fn is_in_whitespace_block(&self) -> bool { + pub(crate) fn in_whitespace_block(&self) -> bool { self.on_whitespace() || self.at_end_of_line_with_preceding_whitespace() } @@ -360,7 +360,7 @@ impl LineBuffer { /// that trailing block. Returns an empty range (0..0) if not in a whitespace context. /// /// Used for vim-style text object operations (iw/aw when cursor is on whitespace). - pub fn current_whitespace_range(&self) -> Range { + pub(crate) fn current_whitespace_range(&self) -> Range { if self.on_whitespace() { let range_end = self.current_whitespace_range_end(); let range_start = self.current_whitespace_range_start(); diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index a3f0cc5d..3a37c9dd 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -1,7 +1,16 @@ use super::{motion::Motion, motion::ViCharSearch, parser::ReedlineOption, ViMode}; use crate::{EditCommand, ReedlineEvent, Vi}; +use crate::enums::{TextObject, TextObjectScope, TextObjectType}; use std::iter::Peekable; +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 }), + _ => None, + } +} + pub fn parse_command<'iter, I>(input: &mut Peekable) -> Option where I: Iterator, @@ -12,16 +21,21 @@ where // Checking for "di(" or "diw" etc. if let Some('i') = input.peek() { let _ = input.next(); - input.next().map(|c| { - if let Some((left, right)) = bracket_pair_for(*c) { - Command::DeleteInsidePair { left, right } - } else { - Command::DeleteInsideTextObject { text_object: *c } - } + 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().map(|c| Command::DeleteAroundTextObject { text_object: *c }) + input.next().and_then(|c| { + char_to_text_object(*c, TextObjectScope::Around).map(|text_object| Command::DeleteTextObject { text_object }) + }) } else { Some(Command::Delete) } @@ -31,16 +45,19 @@ where let _ = input.next(); if let Some('i') = input.peek() { let _ = input.next(); - input.next().map(|c| { - if let Some((left, right)) = bracket_pair_for(*c) { - Command::YankInsidePair { left, right } - } else { - Command::YankInsideTextObject { text_object: *c } - } + 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().map(|c| Command::YankAroundTextObject { text_object: *c }) + input.next().and_then(|c| { + char_to_text_object(*c, TextObjectScope::Around).map(|text_object| Command::YankTextObject { text_object }) + }) } else { Some(Command::Yank) } @@ -70,16 +87,19 @@ where let _ = input.next(); if let Some('i') = input.peek() { let _ = input.next(); - input.next().map(|c| { - if let Some((left, right)) = bracket_pair_for(*c) { - Command::ChangeInsidePair { left, right } - } else { - Command::ChangeInsideTextObject { text_object: *c } - } + 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().map(|c| Command::ChangeAroundTextObject { text_object: *c }) + input.next().and_then(|c| { + char_to_text_object(*c, TextObjectScope::Around).map(|text_object| Command::ChangeTextObject { text_object }) + }) } else { Some(Command::Change) } @@ -165,12 +185,9 @@ pub enum Command { ChangeInsidePair { left: char, right: char }, DeleteInsidePair { left: char, right: char }, YankInsidePair { left: char, right: char }, - ChangeInsideTextObject { text_object: char }, - YankInsideTextObject { text_object: char }, - DeleteInsideTextObject { text_object: char }, - ChangeAroundTextObject { text_object: char }, - YankAroundTextObject { text_object: char }, - DeleteAroundTextObject { text_object: char }, + ChangeTextObject { text_object: TextObject }, + YankTextObject { text_object: TextObject }, + DeleteTextObject { text_object: TextObject }, SwapCursorAndAnchor, } @@ -246,38 +263,23 @@ impl Command { })] } Self::YankInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::YankInsidePair { + vec![ReedlineOption::Edit(EditCommand::CopyInsidePair { left: *left, right: *right, })] } - Self::ChangeInsideTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::CutInsideTextObject { - text_object: *text_object - })] - } - Self::YankInsideTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::YankInsideTextObject { - text_object: *text_object - })] - } - Self::DeleteInsideTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::CutInsideTextObject { - text_object: *text_object - })] - } - Self::ChangeAroundTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::CutAroundTextObject { + Self::ChangeTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::CutTextObject { text_object: *text_object })] } - Self::YankAroundTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::YankAroundTextObject { + Self::YankTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::CopyTextObject { text_object: *text_object })] } - Self::DeleteAroundTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::CutAroundTextObject { + Self::DeleteTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::CutTextObject { text_object: *text_object })] } @@ -287,7 +289,7 @@ impl Command { } } - pub fn to_reedline_with_motion( + pub fn to_reedline_with_motion( &self, motion: &Motion, vi_state: &mut Vi, diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index e3996c90..d7bebb8b 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -114,8 +114,7 @@ impl ParsedViSequence { Some(ViMode::Normal) } (Some(Command::ChangeInsidePair { .. }), _) => Some(ViMode::Insert), - (Some(Command::ChangeInsideTextObject { .. }), _) => Some(ViMode::Insert), - (Some(Command::ChangeAroundTextObject { .. }), _) => 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 0a8247c8..500d4902 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -14,6 +14,42 @@ 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, +} + +/// 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()` @@ -344,31 +380,21 @@ pub enum EditCommand { right: char, }, /// Yank text between matching characters atomically - YankInsidePair { + CopyInsidePair { /// Left character of the pair left: char, /// Right character of the pair (usually matching bracket) right: char, }, - /// Cut text inside a text object (e.g. word) - CutInsideTextObject { - /// The text object character ('w' for word, 'W' for WORD, etc.) - text_object: char - }, - /// Yank text inside a text object (e.g. word) - YankInsideTextObject { - /// The text object character ('w' for word, 'W' for WORD, etc.) - text_object: char - }, - /// Cut text around a text object including surrounding whitespace - CutAroundTextObject { - /// The text object character ('w' for word, 'W' for WORD, etc.) - text_object: char + /// Cut the specified text object + CutTextObject { + /// The text object to operate on + text_object: TextObject }, - /// Yank text around a text object including surrounding whitespace - YankAroundTextObject { - /// The text object character ('w' for word, 'W' for WORD, etc.) - text_object: char + /// Copy the specified text object + CopyTextObject { + /// The text object to operate on + text_object: TextObject }, } @@ -483,11 +509,9 @@ impl Display for EditCommand { #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => write!(f, "PasteSystem"), EditCommand::CutInsidePair { .. } => write!(f, "CutInside Value: "), - EditCommand::YankInsidePair { .. } => write!(f, "YankInside Value: "), - EditCommand::CutInsideTextObject { .. } => write!(f, "CutInsideTextObject"), - EditCommand::YankInsideTextObject { .. } => write!(f, "YankInsideTextObject"), - EditCommand::CutAroundTextObject { .. } => write!(f, "CutAroundTextObject"), - EditCommand::YankAroundTextObject { .. } => write!(f, "YankAroundTextObject"), + EditCommand::CopyInsidePair { .. } => write!(f, "YankInside Value: "), + EditCommand::CutTextObject { .. } => write!(f, "CutTextObject"), + EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject"), } } } @@ -571,11 +595,9 @@ impl EditCommand { #[cfg(feature = "system_clipboard")] EditCommand::CopySelectionSystem => EditType::NoOp, EditCommand::CutInsidePair { .. } => EditType::EditText, - EditCommand::YankInsidePair { .. } => EditType::EditText, - EditCommand::CutInsideTextObject { .. } => EditType::EditText, - EditCommand::CutAroundTextObject { .. } => EditType::EditText, - EditCommand::YankInsideTextObject { .. } => EditType::NoOp, - EditCommand::YankAroundTextObject { .. } => EditType::NoOp, + EditCommand::CopyInsidePair { .. } => EditType::EditText, + EditCommand::CutTextObject { .. } => EditType::EditText, + EditCommand::CopyTextObject { .. } => EditType::NoOp, EditCommand::CopyFromStart | EditCommand::CopyFromLineStart | EditCommand::CopyToEnd From 67d70ee8cb93bdc1c7283fea73d5b9275232a684 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 24 Jul 2025 01:24:40 +0100 Subject: [PATCH 05/25] WIP: Add quote and bracket text objects and add jumping if not inside objects --- src/core_editor/editor.rs | 68 ++++++++- src/core_editor/line_buffer.rs | 249 +++++++++++++++++++++++++++++++++ src/edit_mode/vi/command.rs | 2 + src/enums.rs | 4 + 4 files changed, 319 insertions(+), 4 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 66f8c862..72c296a0 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -677,7 +677,7 @@ impl Editor { } fn cut_range(&mut self, range: std::ops::Range) { - if range.start < range.end { + if range.start <= range.end { self.yank_range(range.clone()); self.line_buffer.clear_range_safe(range.clone()); // Redundant as clear_range_safe should place insertion point at @@ -738,6 +738,34 @@ impl Editor { } } } + // Return range for bracket of any sort that the insertion_point is currently within + // hitting the first bracket heading out from the insertion_point + TextObjectType::Brackets => { + if let Some(bracket_range) = self.line_buffer.current_inside_bracket_range() { + match text_object.scope { + TextObjectScope::Inner => Some(bracket_range), + TextObjectScope::Around => { + // Include the brackets themselves + Some((bracket_range.start - 1)..(bracket_range.end + 1)) + } + } + } else { + None + } + } + TextObjectType::Quote => { + if let Some(quote_range) = self.line_buffer.current_inside_quote_range() { + match text_object.scope { + TextObjectScope::Inner => Some(quote_range), + TextObjectScope::Around => { + // Include the quotes themselves + Some((quote_range.start - 1)..(quote_range.end + 1)) + } + } + } else { + None + } + } } } @@ -1458,7 +1486,7 @@ mod test { #[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")] // big word - cursor on email address - #[case("test@example.com file", 17, "test@example.com ", 17, "file")] // cursor on "file" - now working correctly + #[case("test@example.com file", 17, "test@example.com ", 17, "")] // cursor on "file" - now working correctly fn test_cut_inside_big_word( #[case] input: &str, #[case] cursor_pos: usize, @@ -1538,7 +1566,7 @@ mod test { #[rstest] // Test around operations (aw) at word boundaries - #[case("hello world", 0, "hello ")] // start of first word + #[case("hello world", 0, "")] // 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 @@ -1605,8 +1633,40 @@ mod test { // 1. When cursor is on alphanumeric chars adjacent to punctuation (like 'l' in "example.com"), // the word extends across the punctuation due to Unicode word boundaries // 2. Sequential punctuation is not treated as a single word (each punct char is separate) - // 3. This differs from vim's word definition where punctuation breaks words consistently + // 3. This differs from vim's word definition where pictuation breaks words consistently // // #[test] // fn test_yank_inside_word_with_punctuation() { ... } + + #[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, "")] // FIXME: should jump to position 4 inside empty quotes + #[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\" \"\"", 9, "second")] // 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); + } } diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index eccd5fda..0a28ae89 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -875,6 +875,145 @@ impl LineBuffer { Some((left_index, scan_start + right_offset)) } + + + /// Return range of current bracket text object + pub fn current_inside_bracket_range(&self) -> Option> { + // First, check if we're currently inside any bracket pair + if let Some(inside_range) = self.find_current_inside_bracket_pair() { + return Some(inside_range); + } + + // If not inside any pair, jump forward to next bracket pair + self.find_inside_next_bracket_pair() + } + + /// Check if cursor is currently inside a bracket pair + fn find_current_inside_bracket_pair(&self) -> Option> { + const BRACKET_PAIRS: &[(char, char)] = &[ + ('(', ')'), ('[', ']'), ('{', '}'), ('<', '>') + ]; + + let mut innermost_pair: Option<(Range, usize)> = None; + + // Check all bracket types and find the innermost pair containing the cursor + for &(left, right) in BRACKET_PAIRS { + if let Some((start, end)) = self.find_matching_pair(left, right, self.insertion_point) { + // Check if cursor is inside this pair OR on the bracket boundaries + if start <= self.insertion_point && self.insertion_point <= end { + let inside_range = (start + left.len_utf8())..end; + let range_size = end - start; + + // Keep track of the smallest (innermost) range + match innermost_pair { + None => innermost_pair = Some((inside_range, range_size)), + Some((_, current_size)) if range_size < current_size => { + innermost_pair = Some((inside_range, range_size)); + } + _ => {} // Keep the current innermost + } + } + } + } + + innermost_pair.map(|(range, _)| range) + } + + /// Jump forward to find next bracket pair + fn find_inside_next_bracket_pair(&self) -> Option> { + const OPENING_BRACKETS: &[char] = &['(', '[', '{', '<']; + + // Find bracket positions using grapheme indices (compatible with insertion_point) + for (grapheme_pos, grapheme_str) in self.lines.grapheme_indices(true) { + if grapheme_pos >= self.insertion_point { + if let Some(c) = grapheme_str.chars().next() { + if OPENING_BRACKETS.contains(&c) { + // Found an opening bracket, find its matching closing bracket + let right_char = match c { + '(' => ')', + '[' => ']', + '{' => '}', + '<' => '>', + _ => continue, + }; + + if let Some((start, end)) = self.find_matching_pair(c, right_char, grapheme_pos) { + return Some((start + c.len_utf8())..end); + } + } + } + } + } + None + } + + /// Return range of current quote text object + pub fn current_inside_quote_range(&self) -> Option> { + // First, check if we're currently inside any quote pair + if let Some(inside_range) = self.find_current_inside_quote_pair() { + return Some(inside_range); + } + + // If not inside any pair, jump forward to next quote pair + self.find_inside_next_quote_pair() + } + + /// Check if cursor is currently inside a quote pair + fn find_current_inside_quote_pair(&self) -> Option> { + const QUOTE_CHARS: &[char] = &['"', '\'', '`']; + + let mut innermost_pair: Option<(Range, usize)> = None; + + // Check all quote types and find the innermost pair containing the cursor + for "e_char in QUOTE_CHARS { + // First check for consecutive quotes (empty quotes) at cursor position + if self.insertion_point > 0 && + self.insertion_point < self.lines.len() && + self.lines.chars().nth(self.insertion_point - 1) == Some(quote_char) && + self.lines.chars().nth(self.insertion_point) == Some(quote_char) { + // Found empty quotes at cursor position + return Some(self.insertion_point..self.insertion_point); + } + + if let Some((start, end)) = self.find_matching_pair(quote_char, quote_char, self.insertion_point) { + // Check if cursor is inside this pair OR on the quote boundaries + if start <= self.insertion_point && self.insertion_point <= end { + let inside_range = (start + quote_char.len_utf8())..end; + let range_size = end - start; + + // Keep track of the smallest (innermost) range + match innermost_pair { + None => innermost_pair = Some((inside_range, range_size)), + Some((_, current_size)) if range_size < current_size => { + innermost_pair = Some((inside_range, range_size)); + } + _ => {} // Keep the current innermost + } + } + } + } + + innermost_pair.map(|(range, _)| range) + } + + /// Jump forward to find next quote pair + fn find_inside_next_quote_pair(&self) -> Option> { + const QUOTE_CHARS: &[char] = &['"', '\'', '`']; + + self.lines[self.insertion_point..] + .grapheme_indices(true) + .find_map(|(grapheme_pos, grapheme_str)| { + let c = grapheme_str.chars().next()?; + if !QUOTE_CHARS.contains(&c) { + return None; + } + + let (start, end) = self.find_matching_pair(c, c, grapheme_pos)?; + let inside_start = start + c.len_utf8(); + let inside_end = end; + Some(inside_start..inside_end) + }) + } } /// Helper function for [`LineBuffer::find_matching_pair`] @@ -1789,4 +1928,114 @@ mod test { cursor ); } + + // Tests for bracket text object functionality + #[rstest] + // Basic bracket pairs - cursor inside + #[case("foo(bar)baz", 5, Some(4..7))] // cursor on 'a' in "bar" + #[case("foo[bar]baz", 5, Some(4..7))] // square brackets + #[case("foo{bar}baz", 5, Some(4..7))] // curly brackets + #[case("foobaz", 5, Some(4..7))] // angle brackets + #[case("foo(bar(baz)qux)end", 9, Some(8..11))] // cursor on 'a' in "baz", finds inner + #[case("foo(bar(baz)qux)end", 5, Some(4..15))] // cursor on 'a' in "bar", finds outer + #[case("foo([bar])baz", 6, Some(5..8))] // mixed bracket types, cursor on 'a' - should find [bar], not (...) + #[case("foo[(bar)]baz", 6, Some(5..8))] // reversed nesting, cursor on 'a' - should find (bar), not [...] + #[case("foo(bar)baz", 4, Some(4..7))] // cursor just after opening bracket + #[case("foo(bar)baz", 7, Some(4..7))] // cursor just before closing bracket + #[case("foo()bar", 4, Some(4..4))] // empty brackets + #[case("foo[]bar", 4, Some(4..4))] // empty square brackets + #[case("foo (bar) baz", 1, Some(5..8))] // cursor before brackets, should jump forward + #[case("foo bar (baz)", 2, Some(9..12))] // cursor in middle, should jump to next pair + #[case("start (first) (second)", 2, Some(7..12))] // should find "first" + #[case("(content)", 2, Some(1..8))] // brackets at buffer start/end + #[case("a(b)c", 2, Some(2..3))] // minimal case - cursor inside brackets + #[case("no brackets here", 5, None)] // no brackets in buffer + #[case("unclosed (brackets", 10, None)] // unmatched brackets + #[case("foo(🦀bar)baz", 7, Some(4..11))] // emoji inside brackets - cursor after emoji + #[case("🦀(bar)🦀", 5, Some(5..8))] // emoji outside brackets - cursor after opening bracket + fn test_current_inside_bracket_range( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + assert_eq!(buf.current_inside_bracket_range(), expected); + } + + // Tests for quote text object functionality + #[rstest] + // Basic quote pairs - cursor inside + #[case(r#"foo"bar"baz"#, 5, Some(4..7))] // cursor on 'a' in "bar" + #[case("foo'bar'baz", 5, Some(4..7))] // single quotes + #[case("foo`bar`baz", 5, Some(4..7))] // backticks + #[case(r#"foo"bar"'baz'"#, 5, Some(4..7))] // cursor in double quotes + #[case(r#"foo"bar"'baz'"#, 11, Some(9..12))] // cursor in single quotes + #[case(r#"foo"bar"baz"#, 4, Some(4..7))] // cursor just after opening quote + #[case(r#"foo"bar"baz"#, 7, None)] // cursor just before closing quote + #[case(r#"foo""bar"#, 4, Some(4..4))] // empty double quotes + #[case("foo''bar", 4, Some(4..4))] // empty single quotes + #[case(r#"foo "bar" baz"#, 1, Some(5..8))] // cursor before quotes, should jump forward + #[case(r#"foo bar "baz""#, 2, Some(9..12))] // cursor in middle, should jump to next pair + #[case(r#"start "first" "second""#, 2, Some(7..12))] // should find "first" + #[case(r#""content""#, 2, Some(1..8))] // quotes at buffer start/end + #[case(r#"a"b"c"#, 3, None)] // minimal case + #[case("no quotes here", 5, None)] // no quotes in buffer + #[case(r#"unclosed "quotes"#, 10, None)] // unmatched quotes (if find_matching_pair handles this) + #[case(r#"foo"🦀bar"baz"#, 4, Some(4..11))] // emoji inside quotes + #[case(r#"🦀"bar"🦀"#, 4, Some(5..8))] // emoji outside quotes + #[case(r#"foo ""bar"#, 2, Some(5..5))] + fn test_current_inside_quote_range( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + assert_eq!(buf.current_inside_quote_range(), expected); + } + + // Edge case tests for bracket boundaries and complex nesting + #[rstest] + // Cursor on bracket characters themselves + #[case("foo(bar)baz", 3, Some(4..7))] // cursor on opening bracket - should jump forward + #[case("foo(bar)baz", 8, None)] // cursor on closing bracket - no more brackets ahead + #[case("(a(b(c)d)e)", 6, Some(5..6))] // deeply nested, cursor on 'c' + #[case("(a(b(c)d)e)", 4, Some(5..6))] // cursor on 'b', should find middle level + #[case("(a(b(c)d)e)", 2, Some(3..8))] // cursor on 'a', should find outermost + #[case(r#"foo("bar")baz"#, 6, Some(4..9))] // quotes inside brackets + #[case(r#"foo"(bar)"baz"#, 6, Some(5..8))] // brackets inside quotes + #[case("", 0, None)] // empty buffer + #[case("(", 0, None)] // single opening bracket + #[case(")", 0, None)] // single closing bracket + #[case("())", 1, Some(1..1))] // extra closing bracket + fn test_bracket_edge_cases( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + assert_eq!(buf.current_inside_bracket_range(), expected); + } + + // Edge case tests for quotes + #[rstest] + // Cursor on quote characters themselves + #[case(r#"foo"bar"baz"#, 3, Some(4..7))] // cursor on opening quote - should jump forward + #[case(r#"foo"bar"baz"#, 8, None)] // cursor on closing quote - no more quotes ahead + #[case("", 0, None)] // empty buffer + #[case(r#""""#, 0, Some(1..1))] // single quote pair (empty) + #[case(r#"""asdf"#, 0, Some(1..1))] // unmatched quote + #[case(r#""foo"'bar'`baz`"#, 1, Some(1..4))] // cursor before any quotes, should find first (double) + #[case(r#""foo"'bar'`baz`"#, 6, Some(6..9))] // cursor between quotes, should find single quotes + fn test_quote_edge_cases( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + assert_eq!(buf.current_inside_quote_range(), expected); + } } diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 3a37c9dd..d6478a0b 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -7,6 +7,8 @@ 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, } } diff --git a/src/enums.rs b/src/enums.rs index 500d4902..1edc13fa 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -30,6 +30,10 @@ pub enum TextObjectType { Word, /// WORD (delimited only by whitespace) BigWord, + /// (, ), [, ], {, } + Brackets, + /// ", ', ` + Quote, } /// Text objects that can be operated on with vim-style commands From ee884c0e30089ad0ed5e6782abb076d37c261c89 Mon Sep 17 00:00:00 2001 From: JonLD Date: Fri, 25 Jul 2025 03:33:31 +0100 Subject: [PATCH 06/25] Add my own methods for finding matching pair and jumping --- src/core_editor/editor.rs | 19 ++-- src/core_editor/line_buffer.rs | 170 ++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 16 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 72c296a0..84084491 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -700,16 +700,9 @@ impl Editor { /// 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_pair(&mut self, left_char: char, right_char: char) { - let buffer_len = self.line_buffer.len(); - - if let Some((lp, rp)) = - self.line_buffer - .find_matching_pair(left_char, right_char, self.insertion_point()) + if let Some(insde_range) = self.line_buffer.inside_next_matching_pair_range(left_char, right_char) { - let inside_start = lp + left_char.len_utf8(); - if inside_start < rp && rp <= buffer_len { - self.cut_range(inside_start..rp); - } + self.cut_range(insde_range); } } @@ -1485,8 +1478,8 @@ mod test { #[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")] // big word - cursor on email address - #[case("test@example.com file", 17, "test@example.com ", 17, "")] // cursor on "file" - now working correctly + #[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, @@ -1566,7 +1559,7 @@ mod test { #[rstest] // Test around operations (aw) at word boundaries - #[case("hello world", 0, "")] // start of first word + #[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 @@ -1645,7 +1638,7 @@ mod test { #[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, "")] // FIXME: should jump to position 4 inside empty quotes + #[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 diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 0a28ae89..08d25614 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -876,6 +876,126 @@ impl LineBuffer { Some((left_index, scan_start + right_offset)) } + pub fn inside_next_matching_pair_range(&self, left_char: char, right_char: char) -> Option> { + println!("=== DEBUG: inside_next_matching_pair_range ==="); + println!("Input: '{}', cursor: {}, looking for: '{}' '{}'", + self.lines, self.insertion_point, left_char, right_char); + + let slice_after_cursor = &self.lines[self.insertion_point..]; + println!("Slice after cursor: '{}'", slice_after_cursor); + + if let Some(right_pair_index) = self.find_pair_idx_and_depth( + slice_after_cursor, left_char, right_char, 0) + { + let right_index = self.insertion_point + right_pair_index; + println!("Found right pair: index={}", right_index); + + let right_pair_to_start = &self.get_buffer()[..right_index]; + println!("Slice before right pair: '{}'", right_pair_to_start); + + if let Some(left_pair_rev_index) = self.find_left_pair_index( + right_pair_to_start, left_char, right_char) + { + println!("Found left pair: index={}", left_pair_rev_index); + let result = Some(left_pair_rev_index + 1..(right_index)); + println!("Returning: {:?}", result); + return result; + } else { + println!("No left pair found"); + return None; + } + + } else if let Some(right_pair_index) = self.find_pair_idx_and_depth( + slice_after_cursor, left_char, right_char, 1) + { + let right_index = self.insertion_point + right_pair_index; + println!("Found right pair: index={}", right_index); + + let right_pair_to_start = &self.get_buffer()[..right_index]; + println!("Slice before right pair: '{}'", right_pair_to_start); + + if let Some(left_pair_rev_index) = self.find_left_pair_index( + right_pair_to_start, left_char, right_char) + { + println!("Found left pair: index={}", left_pair_rev_index); + let result = Some((left_pair_rev_index + 1)..(right_index)); + println!("Returning: {:?}", result); + return result; + } else { + println!("No left pair found"); + return None; + } + + } else { + println!("No right pair found"); + return None; + } + + } + + fn find_left_pair_index(&self, slice: &str, open_char: char, close_char: char) -> Option { + println!(" find_pair_idx_and_depth: slice='{}'", slice); + let mut depth_count: i64 = 0; + let graphemes: Vec<_> = slice.grapheme_indices(true).rev().collect(); + + println!(" graphemes: {:?}", graphemes); + + for (index, grapheme) in graphemes { + println!(" checking index={}, grapheme='{}', depth_count={}", index, grapheme, depth_count); + if let Some(ch) = grapheme.chars().next(){ + if ch == close_char && index > 0 { + // Found a left char, increase depth + depth_count += 1; + println!(" found left_char '{}', depth_count now = {}", open_char, depth_count); + } else if ch == open_char { + if depth_count == 0 { + + // If we have depth, return the index and current depth + println!(" returning index={}, depth={}", index, depth_count); + return Some(index); + } else { + depth_count -= 1; + } + } + + } + } + println!(" find_pair_idx_and_depth returning None"); + return None; + } + + // Return tuple of the index of right_char and the number of left_chars + // passed to reach it from the cursor + fn find_pair_idx_and_depth(&self, slice: &str, open_char: char, close_char: char, pair_offset: i64) -> Option { + println!(" find_pair_idx_and_depth: slice='{}'", slice); + let mut depth_count: i64 = 0; + let graphemes: Vec<_> = slice.grapheme_indices(true).collect(); + + println!(" graphemes: {:?}", graphemes); + + for (index, grapheme) in graphemes { + println!(" checking index={}, grapheme='{}', depth_count={}", index, grapheme, depth_count); + if let Some(ch) = grapheme.chars().next() { + if ch == open_char && index > 0 { + // Found a left char, increase depth + depth_count += 1; + println!(" found left_char '{}', depth_count now = {}", open_char, depth_count); + } else if ch == close_char { + if depth_count == pair_offset { + + // If we have depth, return the index and current depth + println!(" returning index={}, depth={}", index, depth_count); + return Some(index); + } else { + depth_count -= 1; + } + } + } + } + println!(" find_pair_idx_and_depth returning None"); + return None; + } + /// Return range of current bracket text object pub fn current_inside_bracket_range(&self) -> Option> { @@ -914,7 +1034,7 @@ impl LineBuffer { } } } - } + } innermost_pair.map(|(range, _)| range) } @@ -1008,7 +1128,7 @@ impl LineBuffer { return None; } - let (start, end) = self.find_matching_pair(c, c, grapheme_pos)?; + let (start, end) = self.find_matching_pair(c, c, self.insertion_point + grapheme_pos)?; let inside_start = start + c.len_utf8(); let inside_end = end; Some(inside_start..inside_end) @@ -1026,6 +1146,14 @@ fn find_with_depth( let mut depth: i32 = 0; let mut indices: Vec<_> = slice.grapheme_indices(true).collect(); + + // Find the byte offset of the last grapheme for boundary check BEFORE reversing + let last_grapheme_offset = if !indices.is_empty() { + indices[indices.len() - 1].0 + } else { + 0 + }; + if reverse { indices.reverse(); } @@ -1037,7 +1165,7 @@ fn find_with_depth( // 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 && idx == last_grapheme_offset => (), c if c == shallow_char => depth += 1, _ => (), } @@ -1910,6 +2038,7 @@ mod test { #[case("()", 0, '(', ')', Some((0, 1)))] // Empty pair #[case("()", 1, '(', ')', Some((0, 1)))] // Empty pair from end #[case("(αβγ)", 0, '(', ')', Some((0, 7)))] // Unicode content + #[case(r#"foo"🦀bar"baz"#, 4, '"', '"', Some((3, 12)))] // emoji inside quotes #[case("([)]", 0, '(', ')', Some((0, 2)))] // Mixed brackets #[case("\"abc\"", 0, '"', '"', Some((0, 4)))] // Quotes fn test_find_matching_pair( @@ -2038,4 +2167,39 @@ mod test { buf.set_insertion_point(cursor_pos); assert_eq!(buf.current_inside_quote_range(), expected); } + + // Tests for your new inside_next_matching_pair_range function + #[rstest] + // Basic tests + #[case("(abc)", 1, '(', ')', Some(1..4))] // cursor inside simple pair + #[case("(abc)", 0, '(', ')', Some(1..4))] // cursor at start + #[case("foo(bar)baz", 2, '(', ')', Some(4..7))] // cursor before pair + #[case("foo(bar)baz", 5, '(', ')', Some(4..7))] // cursor inside pair - should find next one but there isn't one + #[case("(a(b)c)", 1, '(', ')', Some(1..6))] // should find inner b + #[case("(a(b)c)", 0, '(', ')', Some(1..6))] // from start, should find inner pair first + #[case("(first)(second)", 2, '(', ')', Some(1..6))] // inside first, should find second + #[case("(first)(second)", 0, '(', ')', Some(1..6))] // at start, should find first + #[case("()", 0, '(', ')', Some(1..1))] // empty pair - might be wrong expectation + #[case("foo()bar", 2, '(', ')', Some(4..4))] // empty pair - might be wrong expectation + #[case("[abc]", 1, '[', ']', Some(1..4))] // square brackets + #[case("{abc}", 1, '{', '}', Some(1..4))] // curly brackets + #[case("foo(🦀bar)baz", 4, '(', ')', Some(4..11))] // emoji inside brackets - cursor after emoji + #[case("🦀(bar)🦀", 5, '(', ')', Some(5..8))] // emoji outside brackets - cursor after opening bracket + #[case("", 0, '(', ')', None)] // empty string + #[case("no brackets", 5, '(', ')', None)] // no brackets + #[case("(unclosed", 1, '(', ')', None)] // unclosed bracket + fn test_inside_next_matching_pair_range( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] left_char: char, + #[case] right_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.inside_next_matching_pair_range(left_char, right_char); + assert_eq!(result, expected, + "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", + input, cursor_pos, left_char, right_char); + } } From bd21b1f3105969c285d15c0a1a182bf6546a8567 Mon Sep 17 00:00:00 2001 From: JonLD Date: Sat, 26 Jul 2025 04:13:27 +0100 Subject: [PATCH 07/25] Fix bugs in new function to get matching pair range and it's finish features - Now handles jumps to next open/close or equal symbol pairs if not in a pair already - Searching only on current line for equal symbol pairs e.g. quotes - Correctly handles graphemes --- src/core_editor/editor.rs | 27 ++-- src/core_editor/line_buffer.rs | 218 +++++++++++++++------------------ 2 files changed, 108 insertions(+), 137 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 84084491..a211bcce 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -700,7 +700,7 @@ impl Editor { /// 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_pair(&mut self, left_char: char, right_char: char) { - if let Some(insde_range) = self.line_buffer.inside_next_matching_pair_range(left_char, right_char) + if let Some(insde_range) = self.line_buffer.inside_matching_pair_range(left_char, right_char) { self.cut_range(insde_range); } @@ -925,16 +925,9 @@ impl Editor { /// Copies it into the cut buffer without removing anything. /// Leaves the buffer unchanged and restores the original cursor. pub(crate) fn yank_inside_pair(&mut self, left_char: char, right_char: char) { - let buffer_len = self.line_buffer.len(); - - if let Some((lp, rp)) = - self.line_buffer - .find_matching_pair(left_char, right_char, self.insertion_point()) + if let Some(pair_range) = self.line_buffer.inside_matching_pair_range(left_char, right_char) { - let inside_start = lp + left_char.len_utf8(); - if inside_start < rp && rp <= buffer_len { - self.yank_range(inside_start..rp) - } + self.yank_range(pair_range); } } } @@ -1226,9 +1219,9 @@ mod test { let mut editor = editor_with("foo(bar)baz"); editor.move_to_position(0, false); editor.cut_inside_pair('(', ')'); - assert_eq!(editor.get_buffer(), "foo(bar)baz"); - assert_eq!(editor.insertion_point(), 0); - assert_eq!(editor.cut_buffer.get().0, ""); + 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"); @@ -1252,9 +1245,9 @@ mod test { let mut editor = editor_with("foo\"bar\"baz"); editor.move_to_position(0, false); editor.cut_inside_pair('"', '"'); - assert_eq!(editor.get_buffer(), "foo\"bar\"baz"); - assert_eq!(editor.insertion_point(), 0); - assert_eq!(editor.cut_buffer.get().0, ""); + 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"); @@ -1643,7 +1636,7 @@ mod test { #[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\" \"\"", 9, "second")] // between quotes + #[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 diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 08d25614..eed85cdf 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -876,124 +876,92 @@ impl LineBuffer { Some((left_index, scan_start + right_offset)) } - pub fn inside_next_matching_pair_range(&self, left_char: char, right_char: char) -> Option> { - println!("=== DEBUG: inside_next_matching_pair_range ==="); - println!("Input: '{}', cursor: {}, looking for: '{}' '{}'", - self.lines, self.insertion_point, left_char, right_char); - - let slice_after_cursor = &self.lines[self.insertion_point..]; - println!("Slice after cursor: '{}'", slice_after_cursor); - - if let Some(right_pair_index) = self.find_pair_idx_and_depth( - slice_after_cursor, left_char, right_char, 0) - { - let right_index = self.insertion_point + right_pair_index; - println!("Found right pair: index={}", right_index); - - let right_pair_to_start = &self.get_buffer()[..right_index]; - println!("Slice before right pair: '{}'", right_pair_to_start); - - if let Some(left_pair_rev_index) = self.find_left_pair_index( - right_pair_to_start, left_char, right_char) - { - println!("Found left pair: index={}", left_pair_rev_index); - let result = Some(left_pair_rev_index + 1..(right_index)); - println!("Returning: {:?}", result); - return result; - } else { - println!("No left pair found"); - return None; - } - - } else if let Some(right_pair_index) = self.find_pair_idx_and_depth( - slice_after_cursor, left_char, right_char, 1) - { - let right_index = self.insertion_point + right_pair_index; - println!("Found right pair: index={}", right_index); - - let right_pair_to_start = &self.get_buffer()[..right_index]; - println!("Slice before right pair: '{}'", right_pair_to_start); - - if let Some(left_pair_rev_index) = self.find_left_pair_index( - right_pair_to_start, left_char, right_char) - { - println!("Found left pair: index={}", left_pair_rev_index); - let result = Some((left_pair_rev_index + 1)..(right_index)); - println!("Returning: {:?}", result); - return result; - } else { - println!("No left pair found"); - return None; - } - + /// Return the range of the matching pair enclosing the cursor position + /// or if the cursor is not enclosed return the range of the next matching pair + pub fn inside_matching_pair_range(&self, left_char: char, right_char: char) -> Option> { + // Search for pair surrounding cursor + self.matching_pair_range_at_depth(self.insertion_point, left_char, right_char, 0) + .or_else(|| { + // Search for next pair after cursor + self.matching_pair_range_at_depth(self.insertion_point, left_char, right_char, 1) + }) + } + + fn matching_pair_range_at_depth( + &self, + insertion_point: usize, + left_char: char, + right_char: char, + mut depth: i64 + ) -> Option> { + let search_range = if left_char == right_char { + depth = depth * (-1); + self.current_line_range() // For equal pairs only match on current line } else { - println!("No right pair found"); - return None; - } + 0..self.lines.len() // Unequal pairs like brackets can be multi line + }; + let slice_after_cursor = &self.lines[insertion_point..search_range.end]; + let right_pair_in_slice_idx = self.find_matching_pair_index( + slice_after_cursor, left_char, right_char, depth)?; + let right_pair_in_buff_idx = insertion_point + right_pair_in_slice_idx; + let start_to_right_pair = &self.lines[search_range.start..right_pair_in_buff_idx]; + + self.find_left_pair_index(start_to_right_pair, left_char, right_char) + .map(|left_pair_rev_index| (search_range.start + left_pair_rev_index + 1)..right_pair_in_buff_idx) } fn find_left_pair_index(&self, slice: &str, open_char: char, close_char: char) -> Option { - println!(" find_pair_idx_and_depth: slice='{}'", slice); + println!("find_left_pair_index: slice='{:?}', open='{:?}', close='{:?}'", slice, open_char, close_char); let mut depth_count: i64 = 0; let graphemes: Vec<_> = slice.grapheme_indices(true).rev().collect(); - println!(" graphemes: {:?}", graphemes); for (index, grapheme) in graphemes { - println!(" checking index={}, grapheme='{}', depth_count={}", index, grapheme, depth_count); - if let Some(ch) = grapheme.chars().next(){ - if ch == close_char && index > 0 { - // Found a left char, increase depth - depth_count += 1; - println!(" found left_char '{}', depth_count now = {}", open_char, depth_count); - } else if ch == open_char { + if let Some(ch) = grapheme.chars().next() { + println!(" checking index {} char '{}', depth={}", index, ch, depth_count); + if ch == open_char { if depth_count == 0 { - - // If we have depth, return the index and current depth - println!(" returning index={}, depth={}", index, depth_count); + println!(" -> found opening at index {}", index); return Some(index); } else { depth_count -= 1; } + } else if ch == close_char && index > 0 { + depth_count += 1; } - } } - println!(" find_pair_idx_and_depth returning None"); - return None; + println!(" -> no match found"); + None } // Return tuple of the index of right_char and the number of left_chars // passed to reach it from the cursor - fn find_pair_idx_and_depth(&self, slice: &str, open_char: char, close_char: char, pair_offset: i64) -> Option { - println!(" find_pair_idx_and_depth: slice='{}'", slice); + fn find_matching_pair_index(&self, slice: &str, open_char: char, close_char: char, pair_offset: i64) -> Option { + println!("find_matching_pair_index: slice='{:?}', open='{:?}', close='{:?}', pair_offset={}", slice, open_char, close_char, pair_offset); let mut depth_count: i64 = 0; let graphemes: Vec<_> = slice.grapheme_indices(true).collect(); - println!(" graphemes: {:?}", graphemes); for (index, grapheme) in graphemes { - println!(" checking index={}, grapheme='{}', depth_count={}", index, grapheme, depth_count); if let Some(ch) = grapheme.chars().next() { - if ch == open_char && index > 0 { - // Found a left char, increase depth - depth_count += 1; - println!(" found left_char '{}', depth_count now = {}", open_char, depth_count); - } else if ch == close_char { + println!(" checking index {} char '{}', depth={}", index, ch, depth_count); + if ch == close_char { if depth_count == pair_offset { - - // If we have depth, return the index and current depth - println!(" returning index={}, depth={}", index, depth_count); + println!(" -> found closing at index {}", index); return Some(index); } else { depth_count -= 1; } } + else if ch == open_char && index > 0 { + depth_count += 1; + } } } - println!(" find_pair_idx_and_depth returning None"); - return None; + println!(" -> no match found"); + None } @@ -1018,20 +986,16 @@ impl LineBuffer { // Check all bracket types and find the innermost pair containing the cursor for &(left, right) in BRACKET_PAIRS { - if let Some((start, end)) = self.find_matching_pair(left, right, self.insertion_point) { - // Check if cursor is inside this pair OR on the bracket boundaries - if start <= self.insertion_point && self.insertion_point <= end { - let inside_range = (start + left.len_utf8())..end; - let range_size = end - start; - - // Keep track of the smallest (innermost) range - match innermost_pair { - None => innermost_pair = Some((inside_range, range_size)), - Some((_, current_size)) if range_size < current_size => { - innermost_pair = Some((inside_range, range_size)); - } - _ => {} // Keep the current innermost + if let Some(range) = self.inside_matching_pair_range(left, right) { + let range_size = range.end - range.start; + + // Keep track of the smallest (innermost) range + match innermost_pair { + None => innermost_pair = Some((range, range_size)), + Some((_, current_size)) if range_size < current_size => { + innermost_pair = Some((range, range_size)); } + _ => {} // Keep the current innermost } } } @@ -1057,8 +1021,8 @@ impl LineBuffer { _ => continue, }; - if let Some((start, end)) = self.find_matching_pair(c, right_char, grapheme_pos) { - return Some((start + c.len_utf8())..end); + if let Some(range) = self.inside_matching_pair_range(c, right_char) { + return Some(range); } } } @@ -1095,20 +1059,15 @@ impl LineBuffer { return Some(self.insertion_point..self.insertion_point); } - if let Some((start, end)) = self.find_matching_pair(quote_char, quote_char, self.insertion_point) { - // Check if cursor is inside this pair OR on the quote boundaries - if start <= self.insertion_point && self.insertion_point <= end { - let inside_range = (start + quote_char.len_utf8())..end; - let range_size = end - start; - - // Keep track of the smallest (innermost) range - match innermost_pair { - None => innermost_pair = Some((inside_range, range_size)), - Some((_, current_size)) if range_size < current_size => { - innermost_pair = Some((inside_range, range_size)); - } - _ => {} // Keep the current innermost + if let Some(inside_range) = self.inside_matching_pair_range(quote_char, quote_char) { + let range_size = inside_range.len(); + // Keep track of the smallest (innermost) range + match innermost_pair { + None => innermost_pair = Some((inside_range, range_size)), + Some((_, current_size)) if range_size < current_size => { + innermost_pair = Some((inside_range, range_size)); } + _ => {} // Keep the current innermost } } } @@ -1127,11 +1086,7 @@ impl LineBuffer { if !QUOTE_CHARS.contains(&c) { return None; } - - let (start, end) = self.find_matching_pair(c, c, self.insertion_point + grapheme_pos)?; - let inside_start = start + c.len_utf8(); - let inside_end = end; - Some(inside_start..inside_end) + self.inside_matching_pair_range(c, c) }) } } @@ -2038,7 +1993,7 @@ mod test { #[case("()", 0, '(', ')', Some((0, 1)))] // Empty pair #[case("()", 1, '(', ')', Some((0, 1)))] // Empty pair from end #[case("(αβγ)", 0, '(', ')', Some((0, 7)))] // Unicode content - #[case(r#"foo"🦀bar"baz"#, 4, '"', '"', Some((3, 12)))] // emoji inside quotes + #[case(r#"foo"🦀bar"baz"#, 4, '"', '"', Some((4, 11)))] // emoji inside quotes #[case("([)]", 0, '(', ')', Some((0, 2)))] // Mixed brackets #[case("\"abc\"", 0, '"', '"', Some((0, 4)))] // Quotes fn test_find_matching_pair( @@ -2080,7 +2035,7 @@ mod test { #[case("a(b)c", 2, Some(2..3))] // minimal case - cursor inside brackets #[case("no brackets here", 5, None)] // no brackets in buffer #[case("unclosed (brackets", 10, None)] // unmatched brackets - #[case("foo(🦀bar)baz", 7, Some(4..11))] // emoji inside brackets - cursor after emoji + #[case("foo(🦀bar)baz", 4, Some(4..11))] // emoji inside brackets - cursor after emoji #[case("🦀(bar)🦀", 5, Some(5..8))] // emoji outside brackets - cursor after opening bracket fn test_current_inside_bracket_range( #[case] input: &str, @@ -2101,14 +2056,14 @@ mod test { #[case(r#"foo"bar"'baz'"#, 5, Some(4..7))] // cursor in double quotes #[case(r#"foo"bar"'baz'"#, 11, Some(9..12))] // cursor in single quotes #[case(r#"foo"bar"baz"#, 4, Some(4..7))] // cursor just after opening quote - #[case(r#"foo"bar"baz"#, 7, None)] // cursor just before closing quote + #[case(r#"foo"bar"baz"#, 7, Some(4..7))] // cursor just before closing quote #[case(r#"foo""bar"#, 4, Some(4..4))] // empty double quotes #[case("foo''bar", 4, Some(4..4))] // empty single quotes #[case(r#"foo "bar" baz"#, 1, Some(5..8))] // cursor before quotes, should jump forward #[case(r#"foo bar "baz""#, 2, Some(9..12))] // cursor in middle, should jump to next pair #[case(r#"start "first" "second""#, 2, Some(7..12))] // should find "first" #[case(r#""content""#, 2, Some(1..8))] // quotes at buffer start/end - #[case(r#"a"b"c"#, 3, None)] // minimal case + #[case(r#"a"b"c"#, 3, Some(2..3))] // minimal case #[case("no quotes here", 5, None)] // no quotes in buffer #[case(r#"unclosed "quotes"#, 10, None)] // unmatched quotes (if find_matching_pair handles this) #[case(r#"foo"🦀bar"baz"#, 4, Some(4..11))] // emoji inside quotes @@ -2183,6 +2138,7 @@ mod test { #[case("foo()bar", 2, '(', ')', Some(4..4))] // empty pair - might be wrong expectation #[case("[abc]", 1, '[', ']', Some(1..4))] // square brackets #[case("{abc}", 1, '{', '}', Some(1..4))] // curly brackets + #[case(r#"foo"🦀bar"baz"#, 4, "\"", "\"", Some(4..11))] // emoji inside quotes #[case("foo(🦀bar)baz", 4, '(', ')', Some(4..11))] // emoji inside brackets - cursor after emoji #[case("🦀(bar)🦀", 5, '(', ')', Some(5..8))] // emoji outside brackets - cursor after opening bracket #[case("", 0, '(', ')', None)] // empty string @@ -2197,9 +2153,31 @@ mod test { ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - let result = buf.inside_next_matching_pair_range(left_char, right_char); + let result = buf.inside_matching_pair_range(left_char, right_char); assert_eq!(result, expected, "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", input, cursor_pos, left_char, right_char); } + + // Focused multiline tests for debugging the "jumping to line above" issue + #[rstest] + // Core test cases to isolate the bug + #[case("line1\n\"quote\"", 7, '"', '"', Some(7..12))] // cursor at quote start on line 2 + #[case("\"quote\"\nline2", 2, '"', '"', Some(1..6))] // cursor inside quote on line 1 + #[case("\"first\"\n\"second\"", 10, '"', '"', Some(9..15))] // cursor in second quote + #[case("line1\n\"quote\"", 6, '"', '"', Some(7..12))] // cursor at end of line 1, should find quote on line 2 + fn test_multiline_quote_behavior( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] left_char: char, + #[case] right_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.inside_matching_pair_range(left_char, right_char); + assert_eq!(result, expected, + "MULTILINE TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", + input, cursor_pos, left_char, right_char, input.lines().collect::>()); + } } From c223f32f82ac9fcab0349481745a42c81dec20b5 Mon Sep 17 00:00:00 2001 From: JonLD Date: Mon, 28 Jul 2025 01:18:55 +0100 Subject: [PATCH 08/25] Simplify heirarchy of pair range finding functions - Refactor the structure of the methods to get ranges, don't need to pass in depth unecessarily, high level functions don't require cursor passed in. - Now two seperate functions for ranges, one "next" and one "current" range that gets you either the range inside next text object or inside current one depending on position of cursor. - Finilise logic to correctly handle graphemes (not byte sized chars) TODO Update unit tests --- src/core_editor/editor.rs | 150 +++++++++++++------ src/core_editor/line_buffer.rs | 264 +++++++++++++++++++-------------- src/edit_mode/vi/command.rs | 29 +++- src/enums.rs | 18 +++ 4 files changed, 299 insertions(+), 162 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index a211bcce..bd463f43 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -173,14 +173,11 @@ impl Editor { EditCommand::PasteSystem => self.paste_from_system(), EditCommand::CutInsidePair { left, right } => self.cut_inside_pair(*left, *right), EditCommand::CopyInsidePair { left, right } => self.yank_inside_pair(*left, *right), - EditCommand::CutTextObject { text_object } => { - self.cut_text_object(*text_object) - }, - EditCommand::CopyTextObject { text_object } => { - self.yank_text_object(*text_object) - } - , - } + EditCommand::CutAroundPair { left, right } => self.cut_around_pair(*left, *right), + EditCommand::CopyAroundPair { left, right } => self.yank_around_pair(*left, *right), + EditCommand::CutTextObject { text_object } => self.cut_text_object(*text_object), + EditCommand::CopyTextObject { text_object } => self.yank_text_object(*text_object), + } if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.selection_anchor = None; } @@ -693,17 +690,18 @@ impl Editor { } } - /// 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_pair(&mut self, left_char: char, right_char: char) { - if let Some(insde_range) = self.line_buffer.inside_matching_pair_range(left_char, right_char) + if let Some(range) = self.line_buffer.range_inside_current_pair(left_char, right_char) + .or_else(|| self.line_buffer.range_inside_next_pair(left_char, right_char)) { - self.cut_range(insde_range); + self.cut_range(range); } + } /// Get the bounds for a text object operation @@ -727,14 +725,18 @@ impl Editor { let big_word_range = self.current_big_word_range(); match text_object.scope { TextObjectScope::Inner => Some(big_word_range), - TextObjectScope::Around => self.expand_range_with_whitespace(big_word_range), + TextObjectScope::Around => { + self.expand_range_with_whitespace(big_word_range) + } } } } // Return range for bracket of any sort that the insertion_point is currently within // hitting the first bracket heading out from the insertion_point TextObjectType::Brackets => { - if let Some(bracket_range) = self.line_buffer.current_inside_bracket_range() { + if let Some(bracket_range) = self.line_buffer.range_inside_current_bracket() + .or_else(|| self.line_buffer.range_inside_next_bracket()) + { match text_object.scope { TextObjectScope::Inner => Some(bracket_range), TextObjectScope::Around => { @@ -747,7 +749,11 @@ impl Editor { } } TextObjectType::Quote => { - if let Some(quote_range) = self.line_buffer.current_inside_quote_range() { + if let Some(quote_range) = self + .line_buffer + .range_inside_current_quote() + .or_else(|| self.line_buffer.range_inside_next_quote()) + { match text_object.scope { TextObjectScope::Inner => Some(quote_range), TextObjectScope::Around => { @@ -770,9 +776,9 @@ impl Editor { // Find start by searching backwards for whitespace (same pattern as current_word_range) let buffer = self.line_buffer.get_buffer(); let mut left_index = 0; - for (i, ch) in buffer[..right_index].char_indices().rev() { - if ch.is_whitespace() { - left_index = i + ch.len_utf8(); + for (i, char) in buffer[..right_index].char_indices().rev() { + if char.is_whitespace() { + left_index = i + char.len_utf8(); break; } } @@ -783,17 +789,26 @@ impl Editor { /// Expand a word range to include surrounding whitespace for "around" operations /// Prioritizes whitespace after the word, falls back to whitespace before if none after - fn expand_range_with_whitespace(&self, range: std::ops::Range) -> Option> { + fn expand_range_with_whitespace( + &self, + range: std::ops::Range, + ) -> Option> { let buffer = self.line_buffer.get_buffer(); - let mut start = range.start; - let mut end = range.end; - - // First, try to extend right to include following whitespace - let original_end = end; - while end < buffer.len() { - if let Some(ch) = buffer[end..].chars().next() { - if ch.is_whitespace() { - end += ch.len_utf8(); + let end = self.extend_range_right(buffer, range.end); + let start = if end == range.end { + self.extend_range_left(buffer, range.start) + } else { + range.start + }; + Some(start..end) + } + + /// Extend range rightward to include trailing whitespace + fn extend_range_right(&self, buffer: &str, mut pos: usize) -> usize { + while pos < buffer.len() { + if let Some(char) = buffer[pos..].chars().next() { + if char.is_whitespace() { + pos += char.len_utf8(); } else { break; } @@ -801,27 +816,29 @@ impl Editor { break; } } - - // If no whitespace was found after the word, try to include whitespace before - if end == original_end { - while start > 0 { - let prev_char_start = buffer.char_indices().rev() - .find(|(i, _)| *i < start) - .map(|(i, _)| i) - .unwrap_or(0); - if let Some(ch) = buffer[prev_char_start..start].chars().next() { - if ch.is_whitespace() { - start = prev_char_start; - } else { - break; - } + pos + } + + /// Extend range leftward to include leading whitespace + fn extend_range_left(&self, buffer: &str, mut pos: usize) -> usize { + while pos > 0 { + let prev_char_start = buffer + .char_indices() + .rev() + .find(|(i, _)| *i < pos) + .map(|(i, _)| i) + .unwrap_or(0); + if let Some(char) = buffer[prev_char_start..pos].chars().next() { + if char.is_whitespace() { + pos = prev_char_start; } else { break; } + } else { + break; } } - - Some(start..end) + pos } fn cut_text_object(&mut self, text_object: TextObject) { @@ -925,9 +942,51 @@ impl Editor { /// Copies it into the cut buffer without removing anything. /// Leaves the buffer unchanged and restores the original cursor. pub(crate) fn yank_inside_pair(&mut self, left_char: char, right_char: char) { - if let Some(pair_range) = self.line_buffer.inside_matching_pair_range(left_char, right_char) + if let Some(range) = self + .line_buffer + .range_inside_current_pair(left_char, right_char) + .or_else(|| { + self.line_buffer + .range_inside_next_pair(left_char, right_char) + }) { - self.yank_range(pair_range); + self.yank_range(range); + } + } + + /// Delete text around matching `left_char` and `right_char` (including the pair characters). + /// Places deleted text into the cut buffer. + /// On success, move the cursor to the position where the opening character was. + /// If matching chars can't be found, restore the original cursor. + pub(crate) fn cut_around_pair(&mut self, left_char: char, right_char: char) { + if let Some(range) = self + .line_buffer + .range_inside_current_pair(left_char, right_char) + .or_else(|| { + self.line_buffer.range_inside_next_pair(left_char, right_char) + }) + { + // Expand range to include the pair characters themselves + let around_range = (range.start - 1)..(range.end + 1); + self.cut_range(around_range); + } + } + + /// Yank text around matching `left_char` and `right_char` (including the pair characters). + /// Places yanked text into the cut buffer. + /// Cursor position is unchanged. + /// If matching chars can't be found, do nothing. + pub(crate) fn yank_around_pair(&mut self, left_char: char, right_char: char) { + if let Some(range) = self + .line_buffer + .range_inside_current_pair(left_char, right_char) + .or_else(|| { + self.line_buffer.range_inside_next_pair(left_char, right_char) + }) + { + // Expand range to include the pair characters themselves + let around_range = (range.start - 1)..(range.end + 1); + self.yank_range(around_range); } } } @@ -1581,7 +1640,6 @@ mod test { 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 diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index eed85cdf..82fe82d4 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -368,8 +368,7 @@ impl LineBuffer { } else if self.at_end_of_line_with_preceding_whitespace() { let range_start = self.current_whitespace_range_start(); range_start..self.insertion_point - } - else { + } else { 0..0 } } @@ -861,7 +860,7 @@ impl LineBuffer { cursor: usize, ) -> Option<(usize, usize)> { // encode to &str so we can compare with &strs later - let mut tmp = ([0u8; 4], [0u8, 4]); + 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 @@ -876,117 +875,164 @@ impl LineBuffer { Some((left_index, scan_start + right_offset)) } - /// Return the range of the matching pair enclosing the cursor position - /// or if the cursor is not enclosed return the range of the next matching pair - pub fn inside_matching_pair_range(&self, left_char: char, right_char: char) -> Option> { - // Search for pair surrounding cursor - self.matching_pair_range_at_depth(self.insertion_point, left_char, right_char, 0) - .or_else(|| { - // Search for next pair after cursor - self.matching_pair_range_at_depth(self.insertion_point, left_char, right_char, 1) + /// Find the range inside the current pair of characters at the cursor position. + /// + /// This method searches for a matching pair of `left_char` and `right_char` characters + /// that either contains the cursor position or starts immediately after the cursor. + /// Returns the range of text inside those characters. + /// + /// 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. + /// + /// # Special Cases + /// - If cursor is positioned just before an opening character, treats it as being "inside" that pair + /// - For quotes: `"|text"` (cursor before quote) → returns range inside the quotes + /// - For brackets: `|(text)` (cursor before bracket) → returns range inside the brackets + /// + /// Returns `Some(Range)` containing the range inside the pair, `None` if no pair is found + pub fn range_inside_current_pair( + &self, + 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(pos, only_search_current_line, open_char, close_char) + }; + + // First attempt: try to find pair from current cursor position + find_range_between_pair_at_position(self.insertion_point).or_else(|| { + // Second attempt: if cursor is positioned just before the opening character, + // treat it as being "inside" that pair and try from the next position + let current_char = self.grapheme_right().chars().next()?; + if current_char == open_char { + let next_char_position = self.grapheme_right_index(); + find_range_between_pair_at_position(next_char_position) + } else { + None + } }) } - fn matching_pair_range_at_depth( + /// Find the range inside the next pair of characters after the cursor position. + /// + /// This method searches forward from the cursor to find the next occurrence of `left_char`, + /// then finds its matching `right_char` and returns the range of text inside those characters. + /// Unlike `range_inside_current_pair`, this always looks for pairs that start after the cursor. + /// + /// 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. + /// + /// Returns `Some(Range)` containing the range inside the next pair, `None` if no pair is found + pub fn range_inside_next_pair( &self, - insertion_point: usize, - left_char: char, - right_char: char, - mut depth: i64 + open_char: char, + close_char: char, ) -> Option> { - let search_range = if left_char == right_char { - depth = depth * (-1); - self.current_line_range() // For equal pairs only match on current line + let only_search_current_line: bool = open_char == close_char; + + let found_open_char = self.find_char_right(open_char, only_search_current_line); + + // If found, get the range inside that pair + found_open_char.and_then(|open_pair_index| { + self.range_between_matching_pair( + open_pair_index + 1, + only_search_current_line, + open_char, + close_char, + ) + }) + } + + /// Core implementation for finding ranges inside character pairs. + /// + /// 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 + /// 2. Search backward from closing to find the opening character + /// 3. Return the range between them + /// + /// # Returns + /// `Some(Range)` containing the range inside the pair, `None` if no valid pair is found + fn range_between_matching_pair( + &self, + cursor: 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() // Unequal pairs like brackets can be multi line + 0..self.lines.len() }; - let slice_after_cursor = &self.lines[insertion_point..search_range.end]; - let right_pair_in_slice_idx = self.find_matching_pair_index( - slice_after_cursor, left_char, right_char, depth)?; - let right_pair_in_buff_idx = insertion_point + right_pair_in_slice_idx; - let start_to_right_pair = &self.lines[search_range.start..right_pair_in_buff_idx]; + let after_cursor = &self.lines[cursor..search_range.end]; + let close_pair_index_after_cursor = + Self::find_index_of_matching_char(after_cursor, open_char, close_char, false)?; + let close_char_index_in_buffer = cursor + close_pair_index_after_cursor; - self.find_left_pair_index(start_to_right_pair, left_char, right_char) - .map(|left_pair_rev_index| (search_range.start + left_pair_rev_index + 1)..right_pair_in_buff_idx) - } + let start_to_close_char = &self.lines[search_range.start..close_char_index_in_buffer]; - fn find_left_pair_index(&self, slice: &str, open_char: char, close_char: char) -> Option { - println!("find_left_pair_index: slice='{:?}', open='{:?}', close='{:?}'", slice, open_char, close_char); - let mut depth_count: i64 = 0; - let graphemes: Vec<_> = slice.grapheme_indices(true).rev().collect(); - println!(" graphemes: {:?}", graphemes); + Self::find_index_of_matching_char(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 + 1)..close_char_index_in_buffer + }, + ) + } - for (index, grapheme) in graphemes { - if let Some(ch) = grapheme.chars().next() { - println!(" checking index {} char '{}', depth={}", index, ch, depth_count); - if ch == open_char { - if depth_count == 0 { - println!(" -> found opening at index {}", index); - return Some(index); - } else { - depth_count -= 1; - } - } else if ch == close_char && index > 0 { - depth_count += 1; - } - } + /// Find the index of a matching character using depth counting to handle nested pairs. + /// Helper for [`LineBuffer::range_inside_matching_pair`] + /// + /// Forward search: find close_char at same level of nesting as start of slice + /// Backward search: find open_char at same level of nesting as end of slice + /// + /// Returns index of the target char from start of slice if found, or `None` if not found. + fn find_index_of_matching_char( + 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(); } - println!(" -> no match found"); - None - } - // Return tuple of the index of right_char and the number of left_chars - // passed to reach it from the cursor - fn find_matching_pair_index(&self, slice: &str, open_char: char, close_char: char, pair_offset: i64) -> Option { - println!("find_matching_pair_index: slice='{:?}', open='{:?}', close='{:?}', pair_offset={}", slice, open_char, close_char, pair_offset); - let mut depth_count: i64 = 0; - let graphemes: Vec<_> = slice.grapheme_indices(true).collect(); - println!(" graphemes: {:?}", graphemes); + let (target, increment) = if search_backwards { + (open_char, close_char) + } else { + (close_char, open_char) + }; for (index, grapheme) in graphemes { - if let Some(ch) = grapheme.chars().next() { - println!(" checking index {} char '{}', depth={}", index, ch, depth_count); - if ch == close_char { - if depth_count == pair_offset { - println!(" -> found closing at index {}", index); + if let Some(char) = grapheme.chars().next() { + if char == target { + if depth == 0 { return Some(index); - } else { - depth_count -= 1; } - } - else if ch == open_char && index > 0 { - depth_count += 1; + depth -= 1; + } else if char == increment && index > 0 { + depth += 1; } } } - println!(" -> no match found"); None } - - /// Return range of current bracket text object - pub fn current_inside_bracket_range(&self) -> Option> { - // First, check if we're currently inside any bracket pair - if let Some(inside_range) = self.find_current_inside_bracket_pair() { - return Some(inside_range); - } - - // If not inside any pair, jump forward to next bracket pair - self.find_inside_next_bracket_pair() - } - /// Check if cursor is currently inside a bracket pair - fn find_current_inside_bracket_pair(&self) -> Option> { - const BRACKET_PAIRS: &[(char, char)] = &[ - ('(', ')'), ('[', ']'), ('{', '}'), ('<', '>') - ]; + pub(crate) fn range_inside_current_bracket(&self) -> Option> { + const BRACKET_PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; let mut innermost_pair: Option<(Range, usize)> = None; // Check all bracket types and find the innermost pair containing the cursor for &(left, right) in BRACKET_PAIRS { - if let Some(range) = self.inside_matching_pair_range(left, right) { + if let Some(range) = self.range_inside_current_pair(left, right) + { let range_size = range.end - range.start; // Keep track of the smallest (innermost) range @@ -998,13 +1044,13 @@ impl LineBuffer { _ => {} // Keep the current innermost } } - } + } innermost_pair.map(|(range, _)| range) } /// Jump forward to find next bracket pair - fn find_inside_next_bracket_pair(&self) -> Option> { + pub(crate) fn range_inside_next_bracket(&self) -> Option> { const OPENING_BRACKETS: &[char] = &['(', '[', '{', '<']; // Find bracket positions using grapheme indices (compatible with insertion_point) @@ -1020,10 +1066,7 @@ impl LineBuffer { '<' => '>', _ => continue, }; - - if let Some(range) = self.inside_matching_pair_range(c, right_char) { - return Some(range); - } + return self.range_inside_next_pair(c, right_char); } } } @@ -1031,19 +1074,8 @@ impl LineBuffer { None } - /// Return range of current quote text object - pub fn current_inside_quote_range(&self) -> Option> { - // First, check if we're currently inside any quote pair - if let Some(inside_range) = self.find_current_inside_quote_pair() { - return Some(inside_range); - } - - // If not inside any pair, jump forward to next quote pair - self.find_inside_next_quote_pair() - } - /// Check if cursor is currently inside a quote pair - fn find_current_inside_quote_pair(&self) -> Option> { + pub(crate) fn range_inside_current_quote(&self) -> Option> { const QUOTE_CHARS: &[char] = &['"', '\'', '`']; let mut innermost_pair: Option<(Range, usize)> = None; @@ -1051,15 +1083,19 @@ impl LineBuffer { // Check all quote types and find the innermost pair containing the cursor for "e_char in QUOTE_CHARS { // First check for consecutive quotes (empty quotes) at cursor position - if self.insertion_point > 0 && - self.insertion_point < self.lines.len() && - self.lines.chars().nth(self.insertion_point - 1) == Some(quote_char) && - self.lines.chars().nth(self.insertion_point) == Some(quote_char) { + if self.insertion_point > 0 + && self.insertion_point < self.lines.len() + && self.lines.chars().nth(self.insertion_point - 1) == Some(quote_char) + && self.lines.chars().nth(self.insertion_point) == Some(quote_char) + { // Found empty quotes at cursor position return Some(self.insertion_point..self.insertion_point); } - if let Some(inside_range) = self.inside_matching_pair_range(quote_char, quote_char) { + if let Some(inside_range) = self + .range_inside_current_pair(quote_char, quote_char) + .or_else(|| self.range_inside_next_pair(quote_char, quote_char)) + { let range_size = inside_range.len(); // Keep track of the smallest (innermost) range match innermost_pair { @@ -1076,17 +1112,17 @@ impl LineBuffer { } /// Jump forward to find next quote pair - fn find_inside_next_quote_pair(&self) -> Option> { + pub(crate) fn range_inside_next_quote(&self) -> Option> { const QUOTE_CHARS: &[char] = &['"', '\'', '`']; self.lines[self.insertion_point..] .grapheme_indices(true) - .find_map(|(grapheme_pos, grapheme_str)| { + .find_map(|(_grapheme_pos, grapheme_str)| { let c = grapheme_str.chars().next()?; if !QUOTE_CHARS.contains(&c) { return None; } - self.inside_matching_pair_range(c, c) + return self.range_inside_next_pair(c, c); }) } } @@ -2013,6 +2049,7 @@ mod test { ); } + // Basic quote pairs - cursor inside // Tests for bracket text object functionality #[rstest] // Basic bracket pairs - cursor inside @@ -2049,7 +2086,6 @@ mod test { // Tests for quote text object functionality #[rstest] - // Basic quote pairs - cursor inside #[case(r#"foo"bar"baz"#, 5, Some(4..7))] // cursor on 'a' in "bar" #[case("foo'bar'baz", 5, Some(4..7))] // single quotes #[case("foo`bar`baz", 5, Some(4..7))] // backticks @@ -2089,6 +2125,7 @@ mod test { #[case("(a(b(c)d)e)", 2, Some(3..8))] // cursor on 'a', should find outermost #[case(r#"foo("bar")baz"#, 6, Some(4..9))] // quotes inside brackets #[case(r#"foo"(bar)"baz"#, 6, Some(5..8))] // brackets inside quotes + // Basic tests #[case("", 0, None)] // empty buffer #[case("(", 0, None)] // single opening bracket #[case(")", 0, None)] // single closing bracket @@ -2120,12 +2157,11 @@ mod test { ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - assert_eq!(buf.current_inside_quote_range(), expected); + assert_eq!(buf.range_inside_next_quote(), expected); } // Tests for your new inside_next_matching_pair_range function #[rstest] - // Basic tests #[case("(abc)", 1, '(', ')', Some(1..4))] // cursor inside simple pair #[case("(abc)", 0, '(', ')', Some(1..4))] // cursor at start #[case("foo(bar)baz", 2, '(', ')', Some(4..7))] // cursor before pair diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index d6478a0b..bec5c6ab 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -36,7 +36,13 @@ where } 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::DeleteTextObject { text_object }) + 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) @@ -58,7 +64,12 @@ where } 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::YankTextObject { text_object }) + 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) @@ -187,6 +198,8 @@ 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 }, @@ -270,6 +283,18 @@ impl Command { 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 diff --git a/src/enums.rs b/src/enums.rs index 1edc13fa..8310e805 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -390,6 +390,20 @@ pub enum EditCommand { /// 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 @@ -514,6 +528,8 @@ impl Display for EditCommand { EditCommand::PasteSystem => write!(f, "PasteSystem"), EditCommand::CutInsidePair { .. } => write!(f, "CutInside Value: "), EditCommand::CopyInsidePair { .. } => write!(f, "YankInside Value: "), + EditCommand::CutAroundPair { .. } => write!(f, "CutAround Value: "), + EditCommand::CopyAroundPair { .. } => write!(f, "YankAround Value: "), EditCommand::CutTextObject { .. } => write!(f, "CutTextObject"), EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject"), } @@ -600,6 +616,8 @@ impl EditCommand { EditCommand::CopySelectionSystem => EditType::NoOp, 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 From 6e476d055d69922e518df360caa5ef552943cbd1 Mon Sep 17 00:00:00 2001 From: JonLD Date: Mon, 28 Jul 2025 20:16:57 +0100 Subject: [PATCH 09/25] Refactoring range functions and tidy up/extend unit test cases and coverage --- src/core_editor/line_buffer.rs | 493 +++++++++++++++++---------------- 1 file changed, 254 insertions(+), 239 deletions(-) diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 82fe82d4..f64d942e 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -843,41 +843,9 @@ impl LineBuffer { } } - /// Attempts to find the matching `(left_char, right_char)` pair *enclosing* - /// the cursor position, respecting nested pairs. - /// - /// Algorithm: - /// 1. Walk left from `cursor` until we find the "outermost" `left_char`, - /// ignoring any extra `right_char` we see (i.e., we keep a depth counter). - /// 2. Then from that left bracket, walk right to find the matching `right_char`, - /// also respecting nesting. - /// - /// Returns `Some((left_index, right_index))` if found, or `None` otherwise. - pub fn find_matching_pair( - &self, - left_char: char, - right_char: char, - cursor: usize, - ) -> Option<(usize, usize)> { - // encode to &str so we can compare with &strs later - let mut tmp = ([0u8; 4], [0u8; 4]); - let left_str = left_char.encode_utf8(&mut tmp.0); - let right_str = right_char.encode_utf8(&mut tmp.1); - // search left for left char - let to_cursor = self.lines.get(..=cursor)?; - let left_index = find_with_depth(to_cursor, left_str, right_str, true)?; - - // search right for right char - let scan_start = left_index + left_char.len_utf8(); - let after_left = self.lines.get(scan_start..)?; - let right_offset = find_with_depth(after_left, right_str, left_str, false)?; - - Some((left_index, scan_start + right_offset)) - } - /// Find the range inside the current pair of characters at the cursor position. /// - /// This method searches for a matching pair of `left_char` and `right_char` characters + /// This method searches for a matching pair of `open_char` and `close_char` characters /// that either contains the cursor position or starts immediately after the cursor. /// Returns the range of text inside those characters. /// @@ -890,7 +858,7 @@ impl LineBuffer { /// - For brackets: `|(text)` (cursor before bracket) → returns range inside the brackets /// /// Returns `Some(Range)` containing the range inside the pair, `None` if no pair is found - pub fn range_inside_current_pair( + pub(crate) fn range_inside_current_pair( &self, open_char: char, close_char: char, @@ -900,17 +868,13 @@ impl LineBuffer { self.range_between_matching_pair(pos, only_search_current_line, open_char, close_char) }; - // First attempt: try to find pair from current cursor position + // First try to find pair from current cursor position find_range_between_pair_at_position(self.insertion_point).or_else(|| { - // Second attempt: if cursor is positioned just before the opening character, + // Second try, if cursor is positioned just before the opening character, // treat it as being "inside" that pair and try from the next position - let current_char = self.grapheme_right().chars().next()?; - if current_char == open_char { - let next_char_position = self.grapheme_right_index(); - find_range_between_pair_at_position(next_char_position) - } else { - None - } + self.grapheme_right().starts_with(open_char) + .then(|| find_range_between_pair_at_position(self.grapheme_right_index())) + .flatten() }) } @@ -924,24 +888,29 @@ impl LineBuffer { /// For asymmetric characters (e.g. brackets), the search spans the entire buffer. /// /// Returns `Some(Range)` containing the range inside the next pair, `None` if no pair is found - pub fn range_inside_next_pair( + 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; - let found_open_char = self.find_char_right(open_char, only_search_current_line); + // Find the next opening character, including the current position + let open_pair_index = if self.grapheme_right().starts_with(open_char) { + // Current position is the opening character + self.insertion_point + } else { + // Search forward for the opening character + self.find_char_right(open_char, only_search_current_line)? + }; - // If found, get the range inside that pair - found_open_char.and_then(|open_pair_index| { - self.range_between_matching_pair( - open_pair_index + 1, - only_search_current_line, - open_char, - close_char, - ) - }) + // Now find the range between this opening character and its matching closing character + self.range_between_matching_pair( + self.grapheme_right_index_from_pos(open_pair_index), + only_search_current_line, + open_char, + close_char, + ) } /// Core implementation for finding ranges inside character pairs. @@ -969,12 +938,12 @@ impl LineBuffer { let after_cursor = &self.lines[cursor..search_range.end]; let close_pair_index_after_cursor = - Self::find_index_of_matching_char(after_cursor, open_char, close_char, false)?; + Self::find_index_of_matching_pair(after_cursor, open_char, close_char, false)?; let close_char_index_in_buffer = cursor + 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_char(start_to_close_char, open_char, close_char, true).map( + 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 + 1)..close_char_index_in_buffer @@ -989,7 +958,7 @@ impl LineBuffer { /// Backward search: find open_char at same level of nesting as end of slice /// /// Returns index of the target char from start of slice if found, or `None` if not found. - fn find_index_of_matching_char( + fn find_index_of_matching_pair( slice: &str, open_char: char, close_char: char, @@ -1023,7 +992,12 @@ impl LineBuffer { None } - /// Check if cursor is currently inside a bracket pair + /// Find the range inside the innermost bracket pair surrounding the cursor. + /// + /// Searches for bracket pairs `()`, `[]`, `{}`, `<>` that contain the cursor position. + /// If multiple nested pairs exist, returns the outermost pair. The cursor does not move. + /// + /// Returns `Some(Range)` with the byte range inside the brackets, or `None` if cursor is not inside any bracket pair. pub(crate) fn range_inside_current_bracket(&self) -> Option> { const BRACKET_PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; @@ -1031,8 +1005,7 @@ impl LineBuffer { // Check all bracket types and find the innermost pair containing the cursor for &(left, right) in BRACKET_PAIRS { - if let Some(range) = self.range_inside_current_pair(left, right) - { + if let Some(range) = self.range_inside_current_pair(left, right) { let range_size = range.end - range.start; // Keep track of the smallest (innermost) range @@ -1049,32 +1022,38 @@ impl LineBuffer { innermost_pair.map(|(range, _)| range) } - /// Jump forward to find next bracket pair + /// Find the range inside the next bracket pair forward from the cursor. + /// + /// Searches forward from cursor position (including current character) for bracket pairs `()`, `[]`, `{}`, `<>`. + /// If cursor is on an opening bracket, uses that bracket as the start. Handles nested brackets with depth counting. + /// The cursor does not move. + /// + /// Returns `Some(Range)` with the byte range inside the next bracket pair, or `None` if no pair found. pub(crate) fn range_inside_next_bracket(&self) -> Option> { - const OPENING_BRACKETS: &[char] = &['(', '[', '{', '<']; - - // Find bracket positions using grapheme indices (compatible with insertion_point) - for (grapheme_pos, grapheme_str) in self.lines.grapheme_indices(true) { - if grapheme_pos >= self.insertion_point { - if let Some(c) = grapheme_str.chars().next() { - if OPENING_BRACKETS.contains(&c) { - // Found an opening bracket, find its matching closing bracket - let right_char = match c { - '(' => ')', - '[' => ']', - '{' => '}', - '<' => '>', - _ => continue, - }; - return self.range_inside_next_pair(c, right_char); - } - } - } - } - None + self.lines[self.insertion_point..] + .grapheme_indices(true) + .filter_map(|(_, grapheme_str)| grapheme_str.chars().next()) + .find_map(|c| { + let close = match c { + '(' => ')', + '[' => ']', + '{' => '}', + '<' => '>', + _ => return None, + }; + self.range_inside_next_pair(c, close) + }) } - /// Check if cursor is currently inside a quote pair + /// Find the range inside the innermost quote pair surrounding the cursor. + /// + /// Searches for quote pairs `""`, `''`, ``` `` ``` that contain the cursor position. + /// If multiple quote types exist, returns the innermost pair. + /// Search is restricted to the current line. + /// Handles empty quotes as zero-length ranges inside quote. + /// + /// Returns `Some(Range)` with the byte range inside the quotes + /// or `None` if cursor is not inside any quote pair. pub(crate) fn range_inside_current_quote(&self) -> Option> { const QUOTE_CHARS: &[char] = &['"', '\'', '`']; @@ -1092,10 +1071,7 @@ impl LineBuffer { return Some(self.insertion_point..self.insertion_point); } - if let Some(inside_range) = self - .range_inside_current_pair(quote_char, quote_char) - .or_else(|| self.range_inside_next_pair(quote_char, quote_char)) - { + if let Some(inside_range) = self.range_inside_current_pair(quote_char, quote_char) { let range_size = inside_range.len(); // Keep track of the smallest (innermost) range match innermost_pair { @@ -1111,60 +1087,29 @@ impl LineBuffer { innermost_pair.map(|(range, _)| range) } - /// Jump forward to find next quote pair + /// Find the range inside the next quote pair forward from the cursor. + /// + /// Searches forward from cursor position (including current character) for + /// quote pairs `""`, `''`, ``` `` ```. + /// If cursor is on an opening quote, uses that quote as the start. + /// Search is restricted to current line only. + /// + /// Returns `Some(Range)` with the byte range inside the next quote pair, or `None` if no pair found. pub(crate) fn range_inside_next_quote(&self) -> Option> { const QUOTE_CHARS: &[char] = &['"', '\'', '`']; self.lines[self.insertion_point..] .grapheme_indices(true) - .find_map(|(_grapheme_pos, grapheme_str)| { + .find_map(|(_, grapheme_str)| { let c = grapheme_str.chars().next()?; if !QUOTE_CHARS.contains(&c) { return None; } - return self.range_inside_next_pair(c, c); + self.range_inside_next_pair(c, c) }) } } -/// 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(); - - // Find the byte offset of the last grapheme for boundary check BEFORE reversing - let last_grapheme_offset = if !indices.is_empty() { - indices[indices.len() - 1].0 - } else { - 0 - }; - - 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 == last_grapheme_offset => (), - c if c == shallow_char => depth += 1, - _ => (), - } - } - - None -} - /// Match any sequence of characters that are considered a word boundary fn is_whitespace_str(s: &str) -> bool { s.chars().all(char::is_whitespace) @@ -2016,40 +1961,6 @@ mod test { ); } - #[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(r#"foo"🦀bar"baz"#, 4, '"', '"', Some((4, 11)))] // emoji inside quotes - #[case("([)]", 0, '(', ')', Some((0, 2)))] // Mixed brackets - #[case("\"abc\"", 0, '"', '"', Some((0, 4)))] // Quotes - fn test_find_matching_pair( - #[case] input: &str, - #[case] cursor: usize, - #[case] left_char: char, - #[case] right_char: char, - #[case] expected: Option<(usize, usize)>, - ) { - let buf = LineBuffer::from(input); - assert_eq!( - buf.find_matching_pair(left_char, right_char, cursor), - expected, - "Failed for input: {}, cursor: {}", - input, - cursor - ); - } - - // Basic quote pairs - cursor inside // Tests for bracket text object functionality #[rstest] // Basic bracket pairs - cursor inside @@ -2063,17 +1974,23 @@ mod test { #[case("foo[(bar)]baz", 6, Some(5..8))] // reversed nesting, cursor on 'a' - should find (bar), not [...] #[case("foo(bar)baz", 4, Some(4..7))] // cursor just after opening bracket #[case("foo(bar)baz", 7, Some(4..7))] // cursor just before closing bracket - #[case("foo()bar", 4, Some(4..4))] // empty brackets #[case("foo[]bar", 4, Some(4..4))] // empty square brackets - #[case("foo (bar) baz", 1, Some(5..8))] // cursor before brackets, should jump forward - #[case("foo bar (baz)", 2, Some(9..12))] // cursor in middle, should jump to next pair - #[case("start (first) (second)", 2, Some(7..12))] // should find "first" - #[case("(content)", 2, Some(1..8))] // brackets at buffer start/end + #[case("(content)", 0, Some(1..8))] // brackets at buffer start/end #[case("a(b)c", 2, Some(2..3))] // minimal case - cursor inside brackets + #[case("foo(🦀bar)baz", 4, Some(4..11))] // emoji inside brackets - cursor after emoji + #[case("🦀(bar)🦀", 8, Some(5..8))] // emoji outside brackets - cursor after opening bracket + #[case(r#"foo("bar")baz"#, 6, Some(4..9))] // quotes inside brackets + #[case(r#"foo"(bar)"baz"#, 6, Some(5..8))] // brackets inside quotes + #[case("())", 1, Some(1..1))] // extra closing bracket + #[case("", 0, None)] // empty buffer + #[case("(", 0, None)] // single opening bracket + #[case(")", 0, None)] // single closing bracket #[case("no brackets here", 5, None)] // no brackets in buffer + #[case("outside(brackets)", 3, None)] // unmatched brackets + #[case("(outside) (brackets)", 9, None)] // between brackets #[case("unclosed (brackets", 10, None)] // unmatched brackets - #[case("foo(🦀bar)baz", 4, Some(4..11))] // emoji inside brackets - cursor after emoji - #[case("🦀(bar)🦀", 5, Some(5..8))] // emoji outside brackets - cursor after opening bracket + #[case("unclosed (brackets}", 10, None)] // mismatched brackets + #[case("", 0, None)] // empty buffer fn test_current_inside_bracket_range( #[case] input: &str, #[case] cursor_pos: usize, @@ -2081,106 +1998,108 @@ mod test { ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - assert_eq!(buf.current_inside_bracket_range(), expected); + assert_eq!(buf.range_inside_current_bracket(), expected); } - // Tests for quote text object functionality + // Tests for range_inside_current_quote - cursor inside or on the boundary #[rstest] #[case(r#"foo"bar"baz"#, 5, Some(4..7))] // cursor on 'a' in "bar" #[case("foo'bar'baz", 5, Some(4..7))] // single quotes #[case("foo`bar`baz", 5, Some(4..7))] // backticks - #[case(r#"foo"bar"'baz'"#, 5, Some(4..7))] // cursor in double quotes - #[case(r#"foo"bar"'baz'"#, 11, Some(9..12))] // cursor in single quotes - #[case(r#"foo"bar"baz"#, 4, Some(4..7))] // cursor just after opening quote - #[case(r#"foo"bar"baz"#, 7, Some(4..7))] // cursor just before closing quote - #[case(r#"foo""bar"#, 4, Some(4..4))] // empty double quotes - #[case("foo''bar", 4, Some(4..4))] // empty single quotes - #[case(r#"foo "bar" baz"#, 1, Some(5..8))] // cursor before quotes, should jump forward - #[case(r#"foo bar "baz""#, 2, Some(9..12))] // cursor in middle, should jump to next pair - #[case(r#"start "first" "second""#, 2, Some(7..12))] // should find "first" - #[case(r#""content""#, 2, Some(1..8))] // quotes at buffer start/end - #[case(r#"a"b"c"#, 3, Some(2..3))] // minimal case + #[case(r#"'foo"baz`bar`taz"baz'"#, 9, Some(9..12))] // backticks + #[case(r#"'foo"baz`bar`taz"baz'"#, 6, Some(5..16))] // backticks + #[case(r#"'foo"baz`bar`taz"baz'"#, 0, Some(1..20))] // backticks + #[case(r#""foo"'bar'`baz`"#, 0, Some(1..4))] // cursor at start, should find first (double) #[case("no quotes here", 5, None)] // no quotes in buffer - #[case(r#"unclosed "quotes"#, 10, None)] // unmatched quotes (if find_matching_pair handles this) - #[case(r#"foo"🦀bar"baz"#, 4, Some(4..11))] // emoji inside quotes - #[case(r#"🦀"bar"🦀"#, 4, Some(5..8))] // emoji outside quotes - #[case(r#"foo ""bar"#, 2, Some(5..5))] - fn test_current_inside_quote_range( + #[case(r#"unclosed "quotes"#, 10, None)] // unmatched quotes + #[case("", 0, None)] // empty buffer + fn test_range_inside_current_quote( #[case] input: &str, #[case] cursor_pos: usize, #[case] expected: Option>, ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - assert_eq!(buf.current_inside_quote_range(), expected); + assert_eq!(buf.range_inside_current_quote(), expected); } - // Edge case tests for bracket boundaries and complex nesting + // Tests for range_inside_next_quote - cursor before quotes, jumping forward #[rstest] - // Cursor on bracket characters themselves - #[case("foo(bar)baz", 3, Some(4..7))] // cursor on opening bracket - should jump forward - #[case("foo(bar)baz", 8, None)] // cursor on closing bracket - no more brackets ahead - #[case("(a(b(c)d)e)", 6, Some(5..6))] // deeply nested, cursor on 'c' - #[case("(a(b(c)d)e)", 4, Some(5..6))] // cursor on 'b', should find middle level - #[case("(a(b(c)d)e)", 2, Some(3..8))] // cursor on 'a', should find outermost - #[case(r#"foo("bar")baz"#, 6, Some(4..9))] // quotes inside brackets - #[case(r#"foo"(bar)"baz"#, 6, Some(5..8))] // brackets inside quotes - // Basic tests + #[case(r#"foo "'bar'" baz"#, 1, Some(5..10))] // cursor before nested quotes + #[case(r#"foo '' "bar" baz"#, 1, Some(5..5))] // cursor before first quotes + #[case(r#""foo"'bar`b'az`"#, 1, Some(6..11))] // cursor inside first quotes, find single quotes + #[case(r#""foo"'bar'`baz`"#, 6, Some(11..14))] // cursor after second quotes, find backticks + #[case(r#"zaz'foo"b`a`r"baz'zaz"#, 3, Some(4..17))] // range inside outermost nested quotes + #[case(r#""""#, 0, Some(1..1))] // single quote pair (empty) - should find it ahead + #[case(r#"""asdf"#, 0, Some(1..1))] // unmatched trailing quote + #[case(r#""foo"'bar'`baz`"#, 0, Some(1..4))] // cursor at start, should find first quotes + #[case(r#"foo'bar""#, 1, None)] // mismatched quotes + #[case("no quotes here", 5, None)] // no quotes in buffer #[case("", 0, None)] // empty buffer - #[case("(", 0, None)] // single opening bracket - #[case(")", 0, None)] // single closing bracket - #[case("())", 1, Some(1..1))] // extra closing bracket - fn test_bracket_edge_cases( + fn test_range_inside_next_quote( #[case] input: &str, #[case] cursor_pos: usize, #[case] expected: Option>, ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - assert_eq!(buf.current_inside_bracket_range(), expected); + assert_eq!(buf.range_inside_next_quote(), expected); } - // Edge case tests for quotes + // Tests for range_inside_current_pair - when cursor is inside a pair #[rstest] - // Cursor on quote characters themselves - #[case(r#"foo"bar"baz"#, 3, Some(4..7))] // cursor on opening quote - should jump forward - #[case(r#"foo"bar"baz"#, 8, None)] // cursor on closing quote - no more quotes ahead - #[case("", 0, None)] // empty buffer - #[case(r#""""#, 0, Some(1..1))] // single quote pair (empty) - #[case(r#"""asdf"#, 0, Some(1..1))] // unmatched quote - #[case(r#""foo"'bar'`baz`"#, 1, Some(1..4))] // cursor before any quotes, should find first (double) - #[case(r#""foo"'bar'`baz`"#, 6, Some(6..9))] // cursor between quotes, should find single quotes - fn test_quote_edge_cases( + #[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 + fn test_range_inside_current_pair( #[case] input: &str, #[case] cursor_pos: usize, + #[case] left_char: char, + #[case] right_char: char, #[case] expected: Option>, ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - assert_eq!(buf.range_inside_next_quote(), expected); + let result = buf.range_inside_current_pair(left_char, right_char); + assert_eq!( + result, expected, + "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", + input, cursor_pos, left_char, right_char + ); } - // Tests for your new inside_next_matching_pair_range function + // Tests for range_inside_next_pair - when looking for the next pair forward #[rstest] - #[case("(abc)", 1, '(', ')', Some(1..4))] // cursor inside simple pair - #[case("(abc)", 0, '(', ')', Some(1..4))] // cursor at start + #[case("(abc)", 0, '(', ')', Some(1..4))] // cursor at start, find first pair #[case("foo(bar)baz", 2, '(', ')', Some(4..7))] // cursor before pair - #[case("foo(bar)baz", 5, '(', ')', Some(4..7))] // cursor inside pair - should find next one but there isn't one - #[case("(a(b)c)", 1, '(', ')', Some(1..6))] // should find inner b - #[case("(a(b)c)", 0, '(', ')', Some(1..6))] // from start, should find inner pair first - #[case("(first)(second)", 2, '(', ')', Some(1..6))] // inside first, should find second - #[case("(first)(second)", 0, '(', ')', Some(1..6))] // at start, should find first - #[case("()", 0, '(', ')', Some(1..1))] // empty pair - might be wrong expectation - #[case("foo()bar", 2, '(', ')', Some(4..4))] // empty pair - might be wrong expectation - #[case("[abc]", 1, '[', ']', Some(1..4))] // square brackets - #[case("{abc}", 1, '{', '}', Some(1..4))] // curly brackets - #[case(r#"foo"🦀bar"baz"#, 4, "\"", "\"", Some(4..11))] // emoji inside quotes - #[case("foo(🦀bar)baz", 4, '(', ')', Some(4..11))] // emoji inside brackets - cursor after emoji - #[case("🦀(bar)🦀", 5, '(', ')', Some(5..8))] // emoji outside brackets - cursor after opening bracket + #[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 - fn test_inside_next_matching_pair_range( + #[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_pos: usize, #[case] left_char: char, @@ -2189,19 +2108,20 @@ mod test { ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - let result = buf.inside_matching_pair_range(left_char, right_char); - assert_eq!(result, expected, + let result = buf.range_inside_next_pair(left_char, right_char); + assert_eq!( + result, expected, "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", - input, cursor_pos, left_char, right_char); + input, cursor_pos, left_char, right_char + ); } - // Focused multiline tests for debugging the "jumping to line above" issue #[rstest] - // Core test cases to isolate the bug + // Test quote is restricted to single line #[case("line1\n\"quote\"", 7, '"', '"', Some(7..12))] // cursor at quote start on line 2 #[case("\"quote\"\nline2", 2, '"', '"', Some(1..6))] // cursor inside quote on line 1 #[case("\"first\"\n\"second\"", 10, '"', '"', Some(9..15))] // cursor in second quote - #[case("line1\n\"quote\"", 6, '"', '"', Some(7..12))] // cursor at end of line 1, should find quote on line 2 + #[case("line1\n\"quote\"", 6, '"', '"', Some(7..12))] // cursor at start of line 2 fn test_multiline_quote_behavior( #[case] input: &str, #[case] cursor_pos: usize, @@ -2211,9 +2131,104 @@ mod test { ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - let result = buf.inside_matching_pair_range(left_char, right_char); - assert_eq!(result, expected, + let result = buf.range_inside_current_pair(left_char, right_char); + assert_eq!( + result, + expected, + "MULTILINE TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", + input, + cursor_pos, + left_char, + right_char, + input.lines().collect::>() + ); + } + + #[rstest] + // Test next quote is restricted to single line + #[case("line1\n\"quote\"", 0, '"', '"', None)] // cursor line 1, quote line 2 + #[case("\"quote\"\nline2", 2, '"', '"', None)] // cursor inside quote on line 1 + #[case("\"first\"\n\"second\"", 3, '"', '"', None)] // quote that spans multiple lines + #[case("line1\n\"quote\"", 5, '"', '"', None)] // cursor at end of line 1 + fn test_multiline_next_quote_behavior( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] left_char: char, + #[case] right_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(left_char, right_char); + assert_eq!( + result, + expected, "MULTILINE TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", - input, cursor_pos, left_char, right_char, input.lines().collect::>()); + input, + cursor_pos, + left_char, + right_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] left_char: char, + #[case] right_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_current_pair(left_char, right_char); + assert_eq!( + result, + expected, + "MULTILINE BRACKET TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", + input, + cursor_pos, + left_char, + right_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] left_char: char, + #[case] right_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(left_char, right_char); + assert_eq!( + result, + expected, + "MULTILINE BRACKET TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", + input, + cursor_pos, + left_char, + right_char, + input.lines().collect::>() + ); } } From 41d41de24d23473dfd75699f45d3a314957795b6 Mon Sep 17 00:00:00 2001 From: JonLD Date: Tue, 29 Jul 2025 01:44:12 +0100 Subject: [PATCH 10/25] More refactoring - Improve some text object ranges to use iterators rather than complex logic - Clean up documentation, add consts etc - Look through and refactor some editor functions --- src/core_editor/editor.rs | 252 +++++++++++++++------------------ src/core_editor/line_buffer.rs | 226 +++++++++++------------------ src/edit_mode/vi/command.rs | 98 +++++++------ src/enums.rs | 8 +- 4 files changed, 249 insertions(+), 335 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index bd463f43..418c3fb7 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -690,90 +690,72 @@ impl Editor { } } - /// 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_pair(&mut self, left_char: char, right_char: char) { - if let Some(range) = self.line_buffer.range_inside_current_pair(left_char, right_char) - .or_else(|| self.line_buffer.range_inside_next_pair(left_char, right_char)) + /// Delete text strictly between matching `open_char` and `close_char`. + pub(crate) 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) + }) { - self.cut_range(range); + self.cut_range(range) } - } /// Get the bounds for a text object operation fn text_object_range(&self, text_object: TextObject) -> Option> { match text_object.object_type { - TextObjectType::Word => { - if self.line_buffer.in_whitespace_block() { - Some(self.line_buffer.current_whitespace_range()) - } else { - let word_range = self.line_buffer.current_word_range(); - match text_object.scope { - TextObjectScope::Inner => Some(word_range), - TextObjectScope::Around => self.expand_range_with_whitespace(word_range), - } + TextObjectType::Word => self.line_buffer.current_whitespace_range().or_else(|| { + let word_range = self.line_buffer.current_word_range(); + match text_object.scope { + TextObjectScope::Inner => Some(word_range), + TextObjectScope::Around => Some(self.expand_range_with_whitespace(word_range)), } - } - TextObjectType::BigWord => { - if self.line_buffer.in_whitespace_block() { - Some(self.line_buffer.current_whitespace_range()) - } else { - let big_word_range = self.current_big_word_range(); - match text_object.scope { - TextObjectScope::Inner => Some(big_word_range), - TextObjectScope::Around => { - self.expand_range_with_whitespace(big_word_range) - } + }), + TextObjectType::BigWord => self.line_buffer.current_whitespace_range().or_else(|| { + let big_word_range = self.current_big_word_range(); + match text_object.scope { + TextObjectScope::Inner => Some(big_word_range), + TextObjectScope::Around => { + Some(self.expand_range_with_whitespace(big_word_range)) } } - } - // Return range for bracket of any sort that the insertion_point is currently within - // hitting the first bracket heading out from the insertion_point + }), TextObjectType::Brackets => { - if let Some(bracket_range) = self.line_buffer.range_inside_current_bracket() + self.line_buffer + .range_inside_current_bracket() .or_else(|| self.line_buffer.range_inside_next_bracket()) - { - match text_object.scope { - TextObjectScope::Inner => Some(bracket_range), - TextObjectScope::Around => { - // Include the brackets themselves - Some((bracket_range.start - 1)..(bracket_range.end + 1)) + .map(|bracket_range| { + match text_object.scope { + TextObjectScope::Inner => bracket_range, + TextObjectScope::Around => { + // Include the brackets themselves + (bracket_range.start - 1)..(bracket_range.end + 1) + } } - } - } else { - None - } + }) } TextObjectType::Quote => { - if let Some(quote_range) = self - .line_buffer + self.line_buffer .range_inside_current_quote() .or_else(|| self.line_buffer.range_inside_next_quote()) - { - match text_object.scope { - TextObjectScope::Inner => Some(quote_range), - TextObjectScope::Around => { - // Include the quotes themselves - Some((quote_range.start - 1)..(quote_range.end + 1)) + .map(|quote_range| { + match text_object.scope { + TextObjectScope::Inner => quote_range, + TextObjectScope::Around => { + // Include the quotes themselves + (quote_range.start - 1)..(quote_range.end + 1) + } } - } - } else { - None - } + }) } } } /// Get the range of the current big word (WORD) at cursor position fn current_big_word_range(&self) -> std::ops::Range { - // Get the end of the current big word let right_index = self.line_buffer.big_word_right_end_index(); - // Find start by searching backwards for whitespace (same pattern as current_word_range) let buffer = self.line_buffer.get_buffer(); let mut left_index = 0; for (i, char) in buffer[..right_index].char_indices().rev() { @@ -782,63 +764,40 @@ impl Editor { break; } } - - // right_end_index returns position ON the last character, we need position AFTER it left_index..(right_index + 1) } - /// Expand a word range to include surrounding whitespace for "around" operations + /// Return range of `range` expanded with neighbouring whitespace for "around" operations /// Prioritizes whitespace after the word, falls back to whitespace before if none after fn expand_range_with_whitespace( &self, range: std::ops::Range, - ) -> Option> { - let buffer = self.line_buffer.get_buffer(); - let end = self.extend_range_right(buffer, range.end); + ) -> std::ops::Range { + let end = self.next_non_whitespace_index(range.end); let start = if end == range.end { - self.extend_range_left(buffer, range.start) + self.prev_non_whitespace_index(range.start) } else { range.start }; - Some(start..end) + start..end } - /// Extend range rightward to include trailing whitespace - fn extend_range_right(&self, buffer: &str, mut pos: usize) -> usize { - while pos < buffer.len() { - if let Some(char) = buffer[pos..].chars().next() { - if char.is_whitespace() { - pos += char.len_utf8(); - } else { - break; - } - } else { - break; - } - } - pos + /// Return next non-whitespace character index after `pos` + fn next_non_whitespace_index(&self, pos: usize) -> usize { + let buffer = self.line_buffer.get_buffer(); + buffer[pos..] + .char_indices() + .find(|(_, char)| !char.is_whitespace()) + .map_or(buffer.len(), |(i, _)| pos + i) } /// Extend range leftward to include leading whitespace - fn extend_range_left(&self, buffer: &str, mut pos: usize) -> usize { - while pos > 0 { - let prev_char_start = buffer - .char_indices() - .rev() - .find(|(i, _)| *i < pos) - .map(|(i, _)| i) - .unwrap_or(0); - if let Some(char) = buffer[prev_char_start..pos].chars().next() { - if char.is_whitespace() { - pos = prev_char_start; - } else { - break; - } - } else { - break; - } - } - pos + fn prev_non_whitespace_index(&self, pos: usize) -> usize { + self.line_buffer.get_buffer()[..pos] + .char_indices() + .rev() + .find(|(_, char)| !char.is_whitespace()) + .map_or(0, |(i, char)| i + char.len_utf8()) } fn cut_text_object(&mut self, text_object: TextObject) { @@ -882,7 +841,8 @@ impl Editor { } pub(crate) fn copy_to_line_end(&mut self) { - let copy_range = self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end(); + let copy_range = + self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end(); self.yank_range(copy_range); } @@ -938,32 +898,28 @@ impl Editor { } } - /// 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_pair(&mut self, left_char: char, right_char: char) { + /// Yank text strictly between matching `open_char` and `close_char`. + pub(crate) fn yank_inside_pair(&mut self, open_char: char, close_char: char) { if let Some(range) = self .line_buffer - .range_inside_current_pair(left_char, right_char) + .range_inside_current_pair(open_char, close_char) .or_else(|| { self.line_buffer - .range_inside_next_pair(left_char, right_char) + .range_inside_next_pair(open_char, close_char) }) { self.yank_range(range); } } - /// Delete text around matching `left_char` and `right_char` (including the pair characters). - /// Places deleted text into the cut buffer. - /// On success, move the cursor to the position where the opening character was. - /// If matching chars can't be found, restore the original cursor. - pub(crate) fn cut_around_pair(&mut self, left_char: char, right_char: char) { + /// Delete text around matching `open_char` and `close_char` (including the pair characters). + pub(crate) fn cut_around_pair(&mut self, open_char: char, close_char: char) { if let Some(range) = self .line_buffer - .range_inside_current_pair(left_char, right_char) + .range_inside_current_pair(open_char, close_char) .or_else(|| { - self.line_buffer.range_inside_next_pair(left_char, right_char) + self.line_buffer + .range_inside_next_pair(open_char, close_char) }) { // Expand range to include the pair characters themselves @@ -972,16 +928,14 @@ impl Editor { } } - /// Yank text around matching `left_char` and `right_char` (including the pair characters). - /// Places yanked text into the cut buffer. - /// Cursor position is unchanged. - /// If matching chars can't be found, do nothing. - pub(crate) fn yank_around_pair(&mut self, left_char: char, right_char: char) { + /// Yank text around matching `open_char` and `close_char` (including the pair characters). + pub(crate) fn yank_around_pair(&mut self, open_char: char, close_char: char) { if let Some(range) = self .line_buffer - .range_inside_current_pair(left_char, right_char) + .range_inside_current_pair(open_char, close_char) .or_else(|| { - self.line_buffer.range_inside_next_pair(left_char, right_char) + self.line_buffer + .range_inside_next_pair(open_char, close_char) }) { // Expand range to include the pair characters themselves @@ -1458,7 +1412,10 @@ mod test { ) { 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 }); + 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); @@ -1475,7 +1432,10 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.yank_text_object(TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }); + editor.yank_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); @@ -1504,7 +1464,10 @@ mod test { ) { 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 }); + 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); @@ -1521,7 +1484,10 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.yank_text_object(TextObject { scope: TextObjectScope::Around, object_type: TextObjectType::Word }); + editor.yank_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); @@ -1541,7 +1507,10 @@ mod test { ) { 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 }); + 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); @@ -1563,7 +1532,10 @@ mod test { ) { 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 }); + 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); @@ -1605,7 +1577,10 @@ mod test { ) { 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 }); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); assert_eq!(editor.cut_buffer.get().0, expected_cut); } @@ -1625,7 +1600,10 @@ mod test { ) { 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 }); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Around, + object_type: TextObjectType::Word, + }); assert_eq!(editor.cut_buffer.get().0, expected_cut); } @@ -1633,9 +1611,12 @@ mod test { 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.move_to_position(6, false); // Move to the emoji - editor.cut_text_object(TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }); // Cut 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 } @@ -1671,17 +1652,6 @@ mod test { assert_eq!(editor.cut_buffer.get().0, expected_cut); } - // TODO: Punctuation boundary handling - due to UAX#29 word boundary limitations it currently - // behaves different to vim. - // Known issues with current implementation: - // 1. When cursor is on alphanumeric chars adjacent to punctuation (like 'l' in "example.com"), - // the word extends across the punctuation due to Unicode word boundaries - // 2. Sequential punctuation is not treated as a single word (each punct char is separate) - // 3. This differs from vim's word definition where pictuation breaks words consistently - // - // #[test] - // fn test_yank_inside_word_with_punctuation() { ... } - #[rstest] // Test text object jumping behavior in various scenarios // Cursor inside empty pairs should operate on current pair (cursor stays, nothing cut) diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index f64d942e..cee90343 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -4,6 +4,10 @@ use { unicode_segmentation::UnicodeSegmentation, }; +// Character pairs for text object operations +static BRACKET_PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; +static QUOTE_CHARS: &[char] = &['"', '\'', '`']; + /// In memory representation of the entered line(s) including a cursor position to facilitate cursor based editing. #[derive(Debug, PartialEq, Eq, Clone, Default)] pub struct LineBuffer { @@ -308,30 +312,14 @@ impl LineBuffer { } /// Returns true if cursor is at the end of the buffer with preceding whitespace. - /// - /// This handles the edge case where the cursor is positioned after the last character - /// in a buffer that ends with whitespace. In vim, this position is still considered - /// part of the trailing whitespace block for text object operations. 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()) } - /// Returns true if the cursor is positioned within a whitespace block. - /// - /// This includes being on a whitespace character or at the end of trailing whitespace. - /// Used for vim-style text object operations where whitespace itself is a text object. - pub(crate) fn in_whitespace_block(&self) -> bool { - self.on_whitespace() || self.at_end_of_line_with_preceding_whitespace() - } - /// Cursor position at the end of the current whitespace block. - /// - /// Searches forward from cursor position to find where the current whitespace - /// block ends (first non-whitespace character). Returns buffer length if - /// whitespace extends to end of buffer. - fn current_whitespace_range_end(&self) -> usize { + fn current_whitespace_end_index(&self) -> usize { self.lines[self.insertion_point..] .char_indices() .find(|(_, ch)| !ch.is_whitespace()) @@ -340,11 +328,7 @@ impl LineBuffer { } /// Cursor position at the start of the current whitespace block. - /// - /// Searches backward from cursor position to find where the current whitespace - /// block starts (position after last non-whitespace character). Returns 0 if - /// whitespace extends to start of buffer. - fn current_whitespace_range_start(&self) -> usize { + fn current_whitespace_start_index(&self) -> usize { self.lines[..self.insertion_point] .char_indices() .rev() @@ -360,16 +344,16 @@ impl LineBuffer { /// that trailing block. Returns an empty range (0..0) if not in a whitespace context. /// /// Used for vim-style text object operations (iw/aw when cursor is on whitespace). - pub(crate) fn current_whitespace_range(&self) -> Range { + pub(crate) fn current_whitespace_range(&self) -> Option> { if self.on_whitespace() { - let range_end = self.current_whitespace_range_end(); - let range_start = self.current_whitespace_range_start(); - range_start..range_end + let range_end = self.current_whitespace_end_index(); + let range_start = self.current_whitespace_start_index(); + Some(range_start..range_end) } else if self.at_end_of_line_with_preceding_whitespace() { - let range_start = self.current_whitespace_range_start(); - range_start..self.insertion_point + let range_start = self.current_whitespace_start_index(); + Some(range_start..self.insertion_point) } else { - 0..0 + None } } @@ -865,23 +849,29 @@ impl LineBuffer { ) -> Option> { let only_search_current_line: bool = open_char == close_char; let find_range_between_pair_at_position = |pos| { - self.range_between_matching_pair(pos, only_search_current_line, open_char, close_char) + 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 the opening character, + // 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() + self.grapheme_right() + .starts_with(open_char) + .then(|| find_range_between_pair_at_position(self.grapheme_right_index())) + .flatten() }) } /// Find the range inside the next pair of characters after the cursor position. /// - /// This method searches forward from the cursor to find the next occurrence of `left_char`, - /// then finds its matching `right_char` and returns the range of text inside those characters. + /// This method searches forward from the cursor to find the next occurrence of `open_char`, + /// then finds its matching `close_char` and returns the range of text inside those characters. /// Unlike `range_inside_current_pair`, this always looks for pairs that start after the cursor. /// /// For symmetric characters (e.g. quotes), the search is restricted to the current line only. @@ -905,7 +895,7 @@ impl LineBuffer { }; // Now find the range between this opening character and its matching closing character - self.range_between_matching_pair( + self.range_between_matching_pair_at_pos( self.grapheme_right_index_from_pos(open_pair_index), only_search_current_line, open_char, @@ -921,11 +911,10 @@ impl LineBuffer { /// 2. Search backward from closing to find the opening character /// 3. Return the range between them /// - /// # Returns - /// `Some(Range)` containing the range inside the pair, `None` if no valid pair is found - fn range_between_matching_pair( + /// Returns `Some(Range)` containing the range inside the pair, `None` if no valid pair is found + fn range_between_matching_pair_at_pos( &self, - cursor: usize, + position: usize, only_search_current_line: bool, open_char: char, close_char: char, @@ -936,10 +925,10 @@ impl LineBuffer { 0..self.lines.len() }; - let after_cursor = &self.lines[cursor..search_range.end]; + 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 = cursor + close_pair_index_after_cursor; + 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]; @@ -952,7 +941,7 @@ impl LineBuffer { } /// Find the index of a matching character using depth counting to handle nested pairs. - /// Helper for [`LineBuffer::range_inside_matching_pair`] + /// Helper for [`LineBuffer::range_between_matching_pair_at_pos`] /// /// Forward search: find close_char at same level of nesting as start of slice /// Backward search: find open_char at same level of nesting as end of slice @@ -999,27 +988,12 @@ impl LineBuffer { /// /// Returns `Some(Range)` with the byte range inside the brackets, or `None` if cursor is not inside any bracket pair. pub(crate) fn range_inside_current_bracket(&self) -> Option> { - const BRACKET_PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; - - let mut innermost_pair: Option<(Range, usize)> = None; - - // Check all bracket types and find the innermost pair containing the cursor - for &(left, right) in BRACKET_PAIRS { - if let Some(range) = self.range_inside_current_pair(left, right) { - let range_size = range.end - range.start; - - // Keep track of the smallest (innermost) range - match innermost_pair { - None => innermost_pair = Some((range, range_size)), - Some((_, current_size)) if range_size < current_size => { - innermost_pair = Some((range, range_size)); - } - _ => {} // Keep the current innermost - } - } - } - - innermost_pair.map(|(range, _)| range) + BRACKET_PAIRS + .iter() + .filter_map(|(open_char, close_char)| { + self.range_inside_current_pair(*open_char, *close_char) + }) + .min_by_key(|range| range.len()) } /// Find the range inside the next bracket pair forward from the cursor. @@ -1030,19 +1004,12 @@ impl LineBuffer { /// /// Returns `Some(Range)` with the byte range inside the next bracket pair, or `None` if no pair found. pub(crate) fn range_inside_next_bracket(&self) -> Option> { - self.lines[self.insertion_point..] - .grapheme_indices(true) - .filter_map(|(_, grapheme_str)| grapheme_str.chars().next()) - .find_map(|c| { - let close = match c { - '(' => ')', - '[' => ']', - '{' => '}', - '<' => '>', - _ => return None, - }; - self.range_inside_next_pair(c, close) + BRACKET_PAIRS + .iter() + .filter_map(|(open_char, close_char)| { + self.range_inside_next_pair(*open_char, *close_char) }) + .min_by_key(|range| range.start) } /// Find the range inside the innermost quote pair surrounding the cursor. @@ -1055,36 +1022,10 @@ impl LineBuffer { /// Returns `Some(Range)` with the byte range inside the quotes /// or `None` if cursor is not inside any quote pair. pub(crate) fn range_inside_current_quote(&self) -> Option> { - const QUOTE_CHARS: &[char] = &['"', '\'', '`']; - - let mut innermost_pair: Option<(Range, usize)> = None; - - // Check all quote types and find the innermost pair containing the cursor - for "e_char in QUOTE_CHARS { - // First check for consecutive quotes (empty quotes) at cursor position - if self.insertion_point > 0 - && self.insertion_point < self.lines.len() - && self.lines.chars().nth(self.insertion_point - 1) == Some(quote_char) - && self.lines.chars().nth(self.insertion_point) == Some(quote_char) - { - // Found empty quotes at cursor position - return Some(self.insertion_point..self.insertion_point); - } - - if let Some(inside_range) = self.range_inside_current_pair(quote_char, quote_char) { - let range_size = inside_range.len(); - // Keep track of the smallest (innermost) range - match innermost_pair { - None => innermost_pair = Some((inside_range, range_size)), - Some((_, current_size)) if range_size < current_size => { - innermost_pair = Some((inside_range, range_size)); - } - _ => {} // Keep the current innermost - } - } - } - - innermost_pair.map(|(range, _)| range) + QUOTE_CHARS + .iter() + .filter_map(|"e| self.range_inside_current_pair(quote, quote)) + .min_by_key(|range| range.len()) } /// Find the range inside the next quote pair forward from the cursor. @@ -1096,17 +1037,10 @@ impl LineBuffer { /// /// Returns `Some(Range)` with the byte range inside the next quote pair, or `None` if no pair found. pub(crate) fn range_inside_next_quote(&self) -> Option> { - const QUOTE_CHARS: &[char] = &['"', '\'', '`']; - - self.lines[self.insertion_point..] - .grapheme_indices(true) - .find_map(|(_, grapheme_str)| { - let c = grapheme_str.chars().next()?; - if !QUOTE_CHARS.contains(&c) { - return None; - } - self.range_inside_next_pair(c, c) - }) + QUOTE_CHARS + .iter() + .filter_map(|"e| self.range_inside_next_pair(quote, quote)) + .min_by_key(|range| range.start) } } @@ -2067,17 +2001,17 @@ mod test { fn test_range_inside_current_pair( #[case] input: &str, #[case] cursor_pos: usize, - #[case] left_char: char, - #[case] right_char: char, + #[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(left_char, right_char); + let result = buf.range_inside_current_pair(open_char, close_char); assert_eq!( result, expected, "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", - input, cursor_pos, left_char, right_char + input, cursor_pos, open_char, close_char ); } @@ -2102,17 +2036,17 @@ mod test { fn test_range_inside_next_pair( #[case] input: &str, #[case] cursor_pos: usize, - #[case] left_char: char, - #[case] right_char: char, + #[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(left_char, right_char); + let result = buf.range_inside_next_pair(open_char, close_char); assert_eq!( result, expected, "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", - input, cursor_pos, left_char, right_char + input, cursor_pos, open_char, close_char ); } @@ -2125,21 +2059,21 @@ mod test { fn test_multiline_quote_behavior( #[case] input: &str, #[case] cursor_pos: usize, - #[case] left_char: char, - #[case] right_char: char, + #[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(left_char, right_char); + let result = buf.range_inside_current_pair(open_char, close_char); assert_eq!( result, expected, "MULTILINE TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", input, cursor_pos, - left_char, - right_char, + open_char, + close_char, input.lines().collect::>() ); } @@ -2153,21 +2087,21 @@ mod test { fn test_multiline_next_quote_behavior( #[case] input: &str, #[case] cursor_pos: usize, - #[case] left_char: char, - #[case] right_char: char, + #[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(left_char, right_char); + let result = buf.range_inside_next_pair(open_char, close_char); assert_eq!( result, expected, "MULTILINE TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", input, cursor_pos, - left_char, - right_char, + open_char, + close_char, input.lines().collect::>() ); } @@ -2185,21 +2119,21 @@ mod test { fn test_multiline_bracket_behavior( #[case] input: &str, #[case] cursor_pos: usize, - #[case] left_char: char, - #[case] right_char: char, + #[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(left_char, right_char); + let result = buf.range_inside_current_pair(open_char, close_char); assert_eq!( result, expected, "MULTILINE BRACKET TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", input, cursor_pos, - left_char, - right_char, + open_char, + close_char, input.lines().collect::>() ); } @@ -2213,21 +2147,21 @@ mod test { fn test_multiline_next_bracket_behavior( #[case] input: &str, #[case] cursor_pos: usize, - #[case] left_char: char, - #[case] right_char: char, + #[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(left_char, right_char); + 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, - left_char, - right_char, + open_char, + close_char, input.lines().collect::>() ); } diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index bec5c6ab..8c856418 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -1,18 +1,8 @@ use super::{motion::Motion, motion::ViCharSearch, parser::ReedlineOption, ViMode}; -use crate::{EditCommand, ReedlineEvent, Vi}; use crate::enums::{TextObject, TextObjectScope, TextObjectType}; +use crate::{EditCommand, ReedlineEvent, Vi}; use std::iter::Peekable; -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, - } -} - pub fn parse_command<'iter, I>(input: &mut Peekable) -> Option where I: Iterator, @@ -25,24 +15,21 @@ where let _ = input.next(); 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 }) - }) + .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 }) - }) - + .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) @@ -55,21 +42,21 @@ where let _ = input.next(); 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 }) - }) + .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 }) - }) + .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) @@ -102,16 +89,17 @@ where let _ = input.next(); 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 }) - }) + .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 }) + char_to_text_object(*c, TextObjectScope::Around) + .map(|text_object| Command::ChangeTextObject { text_object }) }) } else { Some(Command::Change) @@ -297,17 +285,17 @@ impl Command { } Self::ChangeTextObject { text_object } => { vec![ReedlineOption::Edit(EditCommand::CutTextObject { - text_object: *text_object + text_object: *text_object, })] } Self::YankTextObject { text_object } => { vec![ReedlineOption::Edit(EditCommand::CopyTextObject { - text_object: *text_object + text_object: *text_object, })] } Self::DeleteTextObject { text_object } => { vec![ReedlineOption::Edit(EditCommand::CutTextObject { - text_object: *text_object + text_object: *text_object, })] } Self::SwapCursorAndAnchor => { @@ -316,7 +304,7 @@ impl Command { } } - pub fn to_reedline_with_motion( + pub fn to_reedline_with_motion( &self, motion: &Motion, vi_state: &mut Vi, @@ -483,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/enums.rs b/src/enums.rs index 8310e805..1809429b 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -407,12 +407,12 @@ pub enum EditCommand { /// Cut the specified text object CutTextObject { /// The text object to operate on - text_object: TextObject + text_object: TextObject, }, /// Copy the specified text object CopyTextObject { /// The text object to operate on - text_object: TextObject + text_object: TextObject, }, } @@ -530,8 +530,8 @@ impl Display for EditCommand { EditCommand::CopyInsidePair { .. } => write!(f, "YankInside Value: "), EditCommand::CutAroundPair { .. } => write!(f, "CutAround Value: "), EditCommand::CopyAroundPair { .. } => write!(f, "YankAround Value: "), - EditCommand::CutTextObject { .. } => write!(f, "CutTextObject"), - EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject"), + EditCommand::CutTextObject { .. } => write!(f, "CutTextObject Value: "), + EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject Value: "), } } } From d34e4072b10b8fd88b6499884864c1905a667490 Mon Sep 17 00:00:00 2001 From: JonLD Date: Tue, 29 Jul 2025 01:46:24 +0100 Subject: [PATCH 11/25] Move text object range methods into line_buffer from editor --- src/core_editor/editor.rs | 53 ++-------------------------------- src/core_editor/line_buffer.rs | 43 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 50 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 418c3fb7..73b7cf57 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -709,15 +709,15 @@ impl Editor { let word_range = self.line_buffer.current_word_range(); match text_object.scope { TextObjectScope::Inner => Some(word_range), - TextObjectScope::Around => Some(self.expand_range_with_whitespace(word_range)), + TextObjectScope::Around => Some(self.line_buffer.expand_range_with_whitespace(word_range)), } }), TextObjectType::BigWord => self.line_buffer.current_whitespace_range().or_else(|| { - let big_word_range = self.current_big_word_range(); + let big_word_range = self.line_buffer.current_big_word_range(); match text_object.scope { TextObjectScope::Inner => Some(big_word_range), TextObjectScope::Around => { - Some(self.expand_range_with_whitespace(big_word_range)) + Some(self.line_buffer.expand_range_with_whitespace(big_word_range)) } } }), @@ -752,53 +752,6 @@ impl Editor { } } - /// Get the range of the current big word (WORD) at cursor position - fn current_big_word_range(&self) -> std::ops::Range { - let right_index = self.line_buffer.big_word_right_end_index(); - - let buffer = self.line_buffer.get_buffer(); - let mut left_index = 0; - for (i, char) in buffer[..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 - fn expand_range_with_whitespace( - &self, - range: std::ops::Range, - ) -> std::ops::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 - } - - /// Return next non-whitespace character index after `pos` - fn next_non_whitespace_index(&self, pos: usize) -> usize { - let buffer = self.line_buffer.get_buffer(); - buffer[pos..] - .char_indices() - .find(|(_, char)| !char.is_whitespace()) - .map_or(buffer.len(), |(i, _)| pos + i) - } - - /// Extend range leftward to include leading whitespace - fn prev_non_whitespace_index(&self, pos: usize) -> usize { - self.line_buffer.get_buffer()[..pos] - .char_indices() - .rev() - .find(|(_, char)| !char.is_whitespace()) - .map_or(0, |(i, char)| i + char.len_utf8()) - } fn cut_text_object(&mut self, text_object: TextObject) { if let Some(range) = self.text_object_range(text_object) { diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index cee90343..9b2c5695 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -1042,6 +1042,49 @@ impl LineBuffer { .filter_map(|"e| self.range_inside_next_pair(quote, quote)) .min_by_key(|range| range.start) } + + /// 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 + } + + /// 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 From 85ed134cd8fdfe719035880bb5cbcc47f6142892 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 03:31:00 +0100 Subject: [PATCH 12/25] Combine line_buffer quote and pair text object functions into generic and rewrite a lot of doc strings --- src/core_editor/editor.rs | 154 ++++++++++++++++++++---------- src/core_editor/line_buffer.rs | 167 +++++++++++++++------------------ 2 files changed, 180 insertions(+), 141 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 73b7cf57..59095d6d 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -3,7 +3,7 @@ use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; use crate::core_editor::get_system_clipboard; 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`] /// @@ -673,17 +673,15 @@ impl Editor { self.selection_anchor = None; } - fn cut_range(&mut self, range: std::ops::Range) { + fn cut_range(&mut self, range: Range) { if range.start <= range.end { self.yank_range(range.clone()); self.line_buffer.clear_range_safe(range.clone()); - // Redundant as clear_range_safe should place insertion point at - // start of clear range but this ensures it's in the right place self.line_buffer.set_insertion_point(range.start); } } - fn yank_range(&mut self, range: std::ops::Range) { + fn yank_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); @@ -692,66 +690,126 @@ impl Editor { /// Delete text strictly between matching `open_char` and `close_char`. pub(crate) fn cut_inside_pair(&mut self, open_char: char, close_char: char) { - if let Some(range) = self.line_buffer + 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.line_buffer + .range_inside_next_pair(open_char, close_char) }) { self.cut_range(range) } } - /// Get the bounds for a text object operation - fn text_object_range(&self, text_object: TextObject) -> Option> { - match text_object.object_type { - TextObjectType::Word => self.line_buffer.current_whitespace_range().or_else(|| { + /// 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 => Some(word_range), - TextObjectScope::Around => Some(self.line_buffer.expand_range_with_whitespace(word_range)), + match text_object_scope { + TextObjectScope::Inner => word_range, + TextObjectScope::Around => { + self.line_buffer.expand_range_with_whitespace(word_range) + } } - }), - TextObjectType::BigWord => self.line_buffer.current_whitespace_range().or_else(|| { + }) + } + + /// 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 => Some(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 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. + /// + /// 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); 4] = &[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; + self.line_buffer + .range_inside_current_pair_in_group(*BRACKET_PAIRS) + .or_else(|| { + self.line_buffer + .range_inside_next_pair_in_group(*BRACKET_PAIRS) + }) + .map(|bracket_range| { + match text_object_scope { + TextObjectScope::Inner => bracket_range, TextObjectScope::Around => { - Some(self.line_buffer.expand_range_with_whitespace(big_word_range)) + // Include the brackets themselves + (bracket_range.start - 1)..(bracket_range.end + 1) } } - }), - TextObjectType::Brackets => { - self.line_buffer - .range_inside_current_bracket() - .or_else(|| self.line_buffer.range_inside_next_bracket()) - .map(|bracket_range| { - match text_object.scope { - TextObjectScope::Inner => bracket_range, - TextObjectScope::Around => { - // Include the brackets themselves - (bracket_range.start - 1)..(bracket_range.end + 1) - } - } - }) - } - TextObjectType::Quote => { + }) + } + + /// 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. + /// + /// 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); 3] = &[('"', '"'), ('\'', '\''), ('`', '`')]; + self.line_buffer + .range_inside_current_pair_in_group(*QUOTE_PAIRS) + .or_else(|| { self.line_buffer - .range_inside_current_quote() - .or_else(|| self.line_buffer.range_inside_next_quote()) - .map(|quote_range| { - match text_object.scope { - TextObjectScope::Inner => quote_range, - TextObjectScope::Around => { - // Include the quotes themselves - (quote_range.start - 1)..(quote_range.end + 1) - } - } - }) - } - } + .range_inside_next_pair_in_group(*QUOTE_PAIRS) + }) + .map(|quote_range| { + match text_object_scope { + TextObjectScope::Inner => quote_range, + TextObjectScope::Around => { + // Include the quotes themselves + (quote_range.start - 1)..(quote_range.end + 1) + } + } + }) } + /// 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) { diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 9b2c5695..0a7f0475 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -4,10 +4,6 @@ use { unicode_segmentation::UnicodeSegmentation, }; -// Character pairs for text object operations -static BRACKET_PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; -static QUOTE_CHARS: &[char] = &['"', '\'', '`']; - /// In memory representation of the entered line(s) including a cursor position to facilitate cursor based editing. #[derive(Debug, PartialEq, Eq, Clone, Default)] pub struct LineBuffer { @@ -337,20 +333,15 @@ impl LineBuffer { .unwrap_or(0) } - /// Gets the range of the current whitespace block at cursor position. - /// - /// Returns the complete range of consecutive whitespace characters that includes + /// 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. Returns an empty range (0..0) if not in a whitespace context. - /// - /// Used for vim-style text object operations (iw/aw when cursor is on whitespace). + /// 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(); - let range_start = self.current_whitespace_start_index(); Some(range_start..range_end) } else if self.at_end_of_line_with_preceding_whitespace() { - let range_start = self.current_whitespace_start_index(); Some(range_start..self.insertion_point) } else { None @@ -827,21 +818,14 @@ impl LineBuffer { } } - /// Find the range inside the current pair of characters at the cursor position. + /// Returns `Some(Range)` for the range inside the surrounding + /// `open_char` and `close_char`, or `None` if no pair is found. /// - /// This method searches for a matching pair of `open_char` and `close_char` characters - /// that either contains the cursor position or starts immediately after the cursor. - /// Returns the range of text inside those characters. + /// If cursor is positioned just before an opening character, treat it as + /// being "inside" that 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. - /// - /// # Special Cases - /// - If cursor is positioned just before an opening character, treats it as being "inside" that pair - /// - For quotes: `"|text"` (cursor before quote) → returns range inside the quotes - /// - For brackets: `|(text)` (cursor before bracket) → returns range inside the brackets - /// - /// Returns `Some(Range)` containing the range inside the pair, `None` if no pair is found pub(crate) fn range_inside_current_pair( &self, open_char: char, @@ -868,16 +852,17 @@ impl LineBuffer { }) } - /// Find the range inside the next pair of characters after the cursor position. + /// Returns `Some(Range)` for the range inside the next pair + /// or `None` if no pair is found /// - /// This method searches forward from the cursor to find the next occurrence of `open_char`, - /// then finds its matching `close_char` and returns the range of text inside those characters. - /// Unlike `range_inside_current_pair`, this always looks for pairs that start after the cursor. + /// 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. - /// - /// Returns `Some(Range)` containing the range inside the next pair, `None` if no pair is found pub(crate) fn range_inside_next_pair( &self, open_char: char, @@ -887,14 +872,11 @@ impl LineBuffer { // Find the next opening character, including the current position let open_pair_index = if self.grapheme_right().starts_with(open_char) { - // Current position is the opening character self.insertion_point } else { - // Search forward for the opening character self.find_char_right(open_char, only_search_current_line)? }; - // Now find the range between this opening character and its matching closing character self.range_between_matching_pair_at_pos( self.grapheme_right_index_from_pos(open_pair_index), only_search_current_line, @@ -903,15 +885,16 @@ impl LineBuffer { ) } - /// Core implementation for finding ranges inside character pairs. + /// 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 - /// 2. Search backward from closing to find the opening character + /// `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 - /// - /// Returns `Some(Range)` containing the range inside the pair, `None` if no valid pair is found fn range_between_matching_pair_at_pos( &self, position: usize, @@ -940,13 +923,18 @@ impl LineBuffer { ) } - /// Find the index of a matching character using depth counting to handle nested pairs. + /// 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`] /// - /// Forward search: find close_char at same level of nesting as start of slice - /// Backward search: find open_char at same level of nesting as end of slice + /// 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 target char from start of slice if found, or `None` if not found. + /// 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, @@ -981,68 +969,51 @@ impl LineBuffer { None } - /// Find the range inside the innermost bracket pair surrounding the cursor. + /// 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. /// - /// Searches for bracket pairs `()`, `[]`, `{}`, `<>` that contain the cursor position. - /// If multiple nested pairs exist, returns the outermost pair. The cursor does not move. + /// If the opening and closing char in the pair are equal then search is + /// restricted to the current line. /// - /// Returns `Some(Range)` with the byte range inside the brackets, or `None` if cursor is not inside any bracket pair. - pub(crate) fn range_inside_current_bracket(&self) -> Option> { - BRACKET_PAIRS - .iter() + /// 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, + pair_group: I, + ) -> Option> + where + I: IntoIterator, + { + pair_group + .into_iter() .filter_map(|(open_char, close_char)| { - self.range_inside_current_pair(*open_char, *close_char) + self.range_inside_current_pair(open_char, close_char) }) .min_by_key(|range| range.len()) } - /// Find the range inside the next bracket pair forward from the cursor. + /// 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`. /// - /// Searches forward from cursor position (including current character) for bracket pairs `()`, `[]`, `{}`, `<>`. - /// If cursor is on an opening bracket, uses that bracket as the start. Handles nested brackets with depth counting. - /// The cursor does not move. + /// If the opening and closing char in the pair are equal then search is + /// restricted to the current line. /// - /// Returns `Some(Range)` with the byte range inside the next bracket pair, or `None` if no pair found. - pub(crate) fn range_inside_next_bracket(&self) -> Option> { - BRACKET_PAIRS - .iter() + /// 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, pair_group: I) -> Option> + where + I: IntoIterator, + { + pair_group + .into_iter() .filter_map(|(open_char, close_char)| { - self.range_inside_next_pair(*open_char, *close_char) + self.range_inside_next_pair(open_char, close_char) }) .min_by_key(|range| range.start) } - /// Find the range inside the innermost quote pair surrounding the cursor. - /// - /// Searches for quote pairs `""`, `''`, ``` `` ``` that contain the cursor position. - /// If multiple quote types exist, returns the innermost pair. - /// Search is restricted to the current line. - /// Handles empty quotes as zero-length ranges inside quote. - /// - /// Returns `Some(Range)` with the byte range inside the quotes - /// or `None` if cursor is not inside any quote pair. - pub(crate) fn range_inside_current_quote(&self) -> Option> { - QUOTE_CHARS - .iter() - .filter_map(|"e| self.range_inside_current_pair(quote, quote)) - .min_by_key(|range| range.len()) - } - - /// Find the range inside the next quote pair forward from the cursor. - /// - /// Searches forward from cursor position (including current character) for - /// quote pairs `""`, `''`, ``` `` ```. - /// If cursor is on an opening quote, uses that quote as the start. - /// Search is restricted to current line only. - /// - /// Returns `Some(Range)` with the byte range inside the next quote pair, or `None` if no pair found. - pub(crate) fn range_inside_next_quote(&self) -> Option> { - QUOTE_CHARS - .iter() - .filter_map(|"e| self.range_inside_next_pair(quote, quote)) - .min_by_key(|range| range.start) - } - /// 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(); @@ -1973,11 +1944,18 @@ mod test { #[case] cursor_pos: usize, #[case] expected: Option>, ) { + const BRACKET_PAIRS: &[(char, char); 4] = &[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); - assert_eq!(buf.range_inside_current_bracket(), expected); + assert_eq!( + buf.range_inside_current_pair_in_group(*BRACKET_PAIRS), + expected + ); } + const QUOTE_PAIRS: &[(char, char); 3] = &[('"', '"'), ('\'', '\''), ('`', '`')]; + // Tests for range_inside_current_quote - cursor inside or on the boundary #[rstest] #[case(r#"foo"bar"baz"#, 5, Some(4..7))] // cursor on 'a' in "bar" @@ -1997,7 +1975,10 @@ mod test { ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - assert_eq!(buf.range_inside_current_quote(), expected); + assert_eq!( + buf.range_inside_current_pair_in_group(*QUOTE_PAIRS), + expected + ); } // Tests for range_inside_next_quote - cursor before quotes, jumping forward @@ -2020,7 +2001,7 @@ mod test { ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - assert_eq!(buf.range_inside_next_quote(), expected); + assert_eq!(buf.range_inside_next_pair_in_group(*QUOTE_PAIRS), expected); } // Tests for range_inside_current_pair - when cursor is inside a pair From a18f88561787e181330d9089016af7c39764f62b Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 03:31:20 +0100 Subject: [PATCH 13/25] Testing for quote and bracket text object functions in editor.rs --- src/core_editor/editor.rs | 90 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 59095d6d..fb97fffc 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1694,4 +1694,94 @@ mod test { 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("{hello}", 3, TextObjectScope::Inner, Some(1..6))] // curly brackets + #[case("", 3, TextObjectScope::Inner, Some(1..6))] // angle 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("foo(bar)baz", 0, TextObjectScope::Inner, Some(4..7))] // cursor outside, jumps to next + #[case("(first)(second)", 7, TextObjectScope::Inner, Some(8..14))] // jumps to next pair + #[case("no brackets here", 5, TextObjectScope::Inner, None)] // no brackets found + #[case("(unclosed", 1, TextObjectScope::Inner, None)] // unclosed bracket + #[case("foo(bar\nbaz)qux", 8, TextObjectScope::Inner, Some(4..11))] + // multiline brackets + // Test bracket_text_object_range with Around scope - includes the bracket characters + #[case("foo(bar)baz", 5, TextObjectScope::Around, Some(3..8))] // includes parentheses + #[case("foo[bar]baz", 5, TextObjectScope::Around, Some(3..8))] // includes square brackets + #[case("{hello}", 3, TextObjectScope::Around, Some(0..7))] // includes curly brackets + #[case("foo()bar", 4, TextObjectScope::Around, Some(3..5))] // empty brackets with delimiters + #[case("(nested(inner)outer)", 8, TextObjectScope::Around, Some(7..14))] // nested, includes delimiters + 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#"foo"bar"baz"#, 0, TextObjectScope::Inner, Some(4..7))] // cursor outside, jumps to next + #[case(r#""first""second""#, 7, TextObjectScope::Inner, Some(7..7))] // jumps to next pair + #[case("no quotes here", 5, TextObjectScope::Inner, None)] // no quotes found + #[case(r#""unclosed"#, 1, TextObjectScope::Inner, None)] // unclosed quote + #[case(r#"'mixed"quotes'"#, 5, TextObjectScope::Inner, Some(1..13))] + // mixed quote types + // Test quote_text_object_range with Around scope - includes the quote characters + #[case(r#"foo"bar"baz"#, 5, TextObjectScope::Around, Some(3..8))] // includes double quotes + #[case("foo'bar'baz", 5, TextObjectScope::Around, Some(3..8))] // includes single quotes + #[case("foo`bar`baz", 5, TextObjectScope::Around, Some(3..8))] // includes backticks + #[case(r#"foo""bar"#, 4, TextObjectScope::Around, Some(3..5))] // empty quotes with delimiters + #[case(r#""nested'inner'outer""#, 8, TextObjectScope::Around, Some(7..14))] // nested, includes delimiters + 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); + } } From 18b79e12c5b73f1c9d2a571bd6c8c36bce9a1920 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 03:45:12 +0100 Subject: [PATCH 14/25] Whitespace --- src/core_editor/line_buffer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 0a7f0475..1d8f2089 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -1954,7 +1954,7 @@ mod test { ); } - const QUOTE_PAIRS: &[(char, char); 3] = &[('"', '"'), ('\'', '\''), ('`', '`')]; + const QUOTE_PAIRS: &[(char, char); 3] = &[('"', '"'), ('\'', '\''), ('`', '`')]; // Tests for range_inside_current_quote - cursor inside or on the boundary #[rstest] From 0581fc8ec0550909b6770a192e2f21b2f9d34199 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 13:25:21 +0100 Subject: [PATCH 15/25] Rework unit tests for new function structure --- src/core_editor/editor.rs | 59 ++++++++------ src/core_editor/line_buffer.rs | 143 ++++++++++----------------------- 2 files changed, 78 insertions(+), 124 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index fb97fffc..fd145d99 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1699,22 +1699,27 @@ mod test { // 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("{hello}", 3, TextObjectScope::Inner, Some(1..6))] // curly brackets - #[case("", 3, TextObjectScope::Inner, Some(1..6))] // angle brackets + #[case("foo{bar}baz", 5, TextObjectScope::Inner, Some(4..7))] // square brackets + #[case("foobaz", 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("foo(bar)baz", 0, TextObjectScope::Inner, Some(4..7))] // cursor outside, jumps to next - #[case("(first)(second)", 7, TextObjectScope::Inner, Some(8..14))] // jumps to next pair + #[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("foo(bar\nbaz)qux", 8, TextObjectScope::Inner, Some(4..11))] - // multiline brackets - // Test bracket_text_object_range with Around scope - includes the bracket characters - #[case("foo(bar)baz", 5, TextObjectScope::Around, Some(3..8))] // includes parentheses - #[case("foo[bar]baz", 5, TextObjectScope::Around, Some(3..8))] // includes square brackets - #[case("{hello}", 3, TextObjectScope::Around, Some(0..7))] // includes curly brackets - #[case("foo()bar", 4, TextObjectScope::Around, Some(3..5))] // empty brackets with delimiters - #[case("(nested(inner)outer)", 8, TextObjectScope::Around, Some(7..14))] // nested, includes delimiters + #[case("(mismatched}", 1, TextObjectScope::Inner, None)] // mismatched brackets fn test_bracket_text_object_range( #[case] input: &str, #[case] cursor_pos: usize, @@ -1734,18 +1739,24 @@ mod test { #[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#"foo"bar"baz"#, 0, TextObjectScope::Inner, Some(4..7))] // cursor outside, jumps to next - #[case(r#""first""second""#, 7, TextObjectScope::Inner, Some(7..7))] // jumps to next pair + #[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#""unclosed"#, 1, TextObjectScope::Inner, None)] // unclosed quote - #[case(r#"'mixed"quotes'"#, 5, TextObjectScope::Inner, Some(1..13))] - // mixed quote types - // Test quote_text_object_range with Around scope - includes the quote characters - #[case(r#"foo"bar"baz"#, 5, TextObjectScope::Around, Some(3..8))] // includes double quotes - #[case("foo'bar'baz", 5, TextObjectScope::Around, Some(3..8))] // includes single quotes - #[case("foo`bar`baz", 5, TextObjectScope::Around, Some(3..8))] // includes backticks - #[case(r#"foo""bar"#, 4, TextObjectScope::Around, Some(3..5))] // empty quotes with delimiters - #[case(r#""nested'inner'outer""#, 8, TextObjectScope::Around, Some(7..14))] // nested, includes delimiters + #[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, diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 1d8f2089..40dcdebe 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -1909,76 +1909,46 @@ mod test { ); } - // Tests for bracket text object functionality - #[rstest] - // Basic bracket pairs - cursor inside - #[case("foo(bar)baz", 5, Some(4..7))] // cursor on 'a' in "bar" - #[case("foo[bar]baz", 5, Some(4..7))] // square brackets - #[case("foo{bar}baz", 5, Some(4..7))] // curly brackets - #[case("foobaz", 5, Some(4..7))] // angle brackets - #[case("foo(bar(baz)qux)end", 9, Some(8..11))] // cursor on 'a' in "baz", finds inner - #[case("foo(bar(baz)qux)end", 5, Some(4..15))] // cursor on 'a' in "bar", finds outer - #[case("foo([bar])baz", 6, Some(5..8))] // mixed bracket types, cursor on 'a' - should find [bar], not (...) - #[case("foo[(bar)]baz", 6, Some(5..8))] // reversed nesting, cursor on 'a' - should find (bar), not [...] - #[case("foo(bar)baz", 4, Some(4..7))] // cursor just after opening bracket - #[case("foo(bar)baz", 7, Some(4..7))] // cursor just before closing bracket - #[case("foo[]bar", 4, Some(4..4))] // empty square brackets - #[case("(content)", 0, Some(1..8))] // brackets at buffer start/end - #[case("a(b)c", 2, Some(2..3))] // minimal case - cursor inside brackets - #[case("foo(🦀bar)baz", 4, Some(4..11))] // emoji inside brackets - cursor after emoji - #[case("🦀(bar)🦀", 8, Some(5..8))] // emoji outside brackets - cursor after opening bracket - #[case(r#"foo("bar")baz"#, 6, Some(4..9))] // quotes inside brackets - #[case(r#"foo"(bar)"baz"#, 6, Some(5..8))] // brackets inside quotes - #[case("())", 1, Some(1..1))] // extra closing bracket - #[case("", 0, None)] // empty buffer - #[case("(", 0, None)] // single opening bracket - #[case(")", 0, None)] // single closing bracket - #[case("no brackets here", 5, None)] // no brackets in buffer - #[case("outside(brackets)", 3, None)] // unmatched brackets - #[case("(outside) (brackets)", 9, None)] // between brackets - #[case("unclosed (brackets", 10, None)] // unmatched brackets - #[case("unclosed (brackets}", 10, None)] // mismatched brackets - #[case("", 0, None)] // empty buffer - fn test_current_inside_bracket_range( - #[case] input: &str, - #[case] cursor_pos: usize, - #[case] expected: Option>, - ) { - const BRACKET_PAIRS: &[(char, char); 4] = &[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; - let mut buf = LineBuffer::from(input); - - buf.set_insertion_point(cursor_pos); - assert_eq!( - buf.range_inside_current_pair_in_group(*BRACKET_PAIRS), - expected - ); - } - + 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(r#"foo"bar"baz"#, 5, Some(4..7))] // cursor on 'a' in "bar" - #[case("foo'bar'baz", 5, Some(4..7))] // single quotes - #[case("foo`bar`baz", 5, Some(4..7))] // backticks - #[case(r#"'foo"baz`bar`taz"baz'"#, 9, Some(9..12))] // backticks - #[case(r#"'foo"baz`bar`taz"baz'"#, 6, Some(5..16))] // backticks - #[case(r#"'foo"baz`bar`taz"baz'"#, 0, Some(1..20))] // backticks - #[case(r#""foo"'bar'`baz`"#, 0, Some(1..4))] // cursor at start, should find first (double) - #[case("no quotes here", 5, None)] // no quotes in buffer - #[case(r#"unclosed "quotes"#, 10, None)] // unmatched quotes - #[case("", 0, None)] // empty buffer - fn test_range_inside_current_quote( + #[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] expected: Option>, + #[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(*QUOTE_PAIRS), - expected - ); + assert_eq!(buf.range_inside_current_pair_in_group(*pairs), expected); } // Tests for range_inside_next_quote - cursor before quotes, jumping forward @@ -1997,7 +1967,7 @@ mod test { fn test_range_inside_next_quote( #[case] input: &str, #[case] cursor_pos: usize, - #[case] expected: Option>, + #[case] expected: Option>, ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); @@ -2027,7 +1997,7 @@ mod test { #[case] cursor_pos: usize, #[case] open_char: char, #[case] close_char: char, - #[case] expected: Option>, + #[case] expected: Option>, ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); @@ -2062,7 +2032,7 @@ mod test { #[case] cursor_pos: usize, #[case] open_char: char, #[case] close_char: char, - #[case] expected: Option>, + #[case] expected: Option>, ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); @@ -2074,46 +2044,19 @@ mod test { ); } - #[rstest] - // Test quote is restricted to single line - #[case("line1\n\"quote\"", 7, '"', '"', Some(7..12))] // cursor at quote start on line 2 - #[case("\"quote\"\nline2", 2, '"', '"', Some(1..6))] // cursor inside quote on line 1 - #[case("\"first\"\n\"second\"", 10, '"', '"', Some(9..15))] // cursor in second quote - #[case("line1\n\"quote\"", 6, '"', '"', Some(7..12))] // cursor at start of line 2 - fn test_multiline_quote_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, - "MULTILINE TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", - input, - cursor_pos, - open_char, - close_char, - input.lines().collect::>() - ); - } - #[rstest] // Test next quote is restricted to single line - #[case("line1\n\"quote\"", 0, '"', '"', None)] // cursor line 1, quote line 2 - #[case("\"quote\"\nline2", 2, '"', '"', None)] // cursor inside quote on line 1 - #[case("\"first\"\n\"second\"", 3, '"', '"', None)] // quote that spans multiple lines + #[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_behavior( + fn test_multiline_next_quote_multiline( #[case] input: &str, #[case] cursor_pos: usize, #[case] open_char: char, #[case] close_char: char, - #[case] expected: Option>, + #[case] expected: Option>, ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); @@ -2145,7 +2088,7 @@ mod test { #[case] cursor_pos: usize, #[case] open_char: char, #[case] close_char: char, - #[case] expected: Option>, + #[case] expected: Option>, ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); @@ -2173,7 +2116,7 @@ mod test { #[case] cursor_pos: usize, #[case] open_char: char, #[case] close_char: char, - #[case] expected: Option>, + #[case] expected: Option>, ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); From 96126fadf5a69f8750b25a1faf9e0bc51011f933 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 13:25:58 +0100 Subject: [PATCH 16/25] Remove angle brackets from b text object --- src/core_editor/editor.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index fd145d99..ef6f8424 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -745,7 +745,7 @@ impl Editor { }) } - /// Returns `Some(Range)` for range inside brackets (`()`, `[]`, `{}`, `<>`) + /// 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. /// @@ -756,7 +756,7 @@ impl Editor { &self, text_object_scope: TextObjectScope, ) -> Option> { - const BRACKET_PAIRS: &[(char, char); 4] = &[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; + const BRACKET_PAIRS: &[(char, char); 3] = &[('(', ')'), ('[', ']'), ('{', '}')]; self.line_buffer .range_inside_current_pair_in_group(*BRACKET_PAIRS) .or_else(|| { @@ -1700,7 +1700,6 @@ mod test { #[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("foobaz", 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 From 3eaddca43a6073a80a035bca8c3c10d7e3c828d2 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 13:49:47 +0100 Subject: [PATCH 17/25] Rename yank text object functions to copy --- src/core_editor/editor.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index ef6f8424..6176e4ec 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -172,11 +172,11 @@ impl Editor { #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => self.paste_from_system(), EditCommand::CutInsidePair { left, right } => self.cut_inside_pair(*left, *right), - EditCommand::CopyInsidePair { left, right } => self.yank_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.yank_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.yank_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; @@ -817,7 +817,7 @@ impl Editor { } } - fn yank_text_object(&mut self, text_object: TextObject) { + fn copy_text_object(&mut self, text_object: TextObject) { if let Some(range) = self.text_object_range(text_object) { self.yank_range(range); } @@ -910,7 +910,7 @@ impl Editor { } /// Yank text strictly between matching `open_char` and `close_char`. - pub(crate) fn yank_inside_pair(&mut self, open_char: char, close_char: char) { + pub(crate) 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) @@ -940,7 +940,7 @@ impl Editor { } /// Yank text around matching `open_char` and `close_char` (including the pair characters). - pub(crate) fn yank_around_pair(&mut self, open_char: char, close_char: char) { + pub(crate) fn copy_around_pair(&mut self, open_char: char, close_char: char) { if let Some(range) = self .line_buffer .range_inside_current_pair(open_char, close_char) @@ -1301,7 +1301,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_pair('(', ')'); + 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 @@ -1312,7 +1312,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_pair('(', ')'); + editor.copy_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar)baz"); assert_eq!(editor.insertion_point(), 0); } @@ -1321,7 +1321,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_pair('"', '"'); + 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"); @@ -1329,7 +1329,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_pair('"', '"'); + 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, ""); @@ -1339,7 +1339,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_pair('(', ')'); + 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"); @@ -1349,7 +1349,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_pair('(', ')'); + 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"); @@ -1443,7 +1443,7 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.yank_text_object(TextObject { + editor.copy_text_object(TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word, }); @@ -1495,7 +1495,7 @@ mod test { ) { let mut editor = editor_with(input); editor.move_to_position(cursor_pos, false); - editor.yank_text_object(TextObject { + editor.copy_text_object(TextObject { scope: TextObjectScope::Around, object_type: TextObjectType::Word, }); From c85f7bf39568d63bd292d90051077912ab691b33 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 13:50:50 +0100 Subject: [PATCH 18/25] Add bracket test cases to range_inside_next_pair_in_group unit tests --- src/core_editor/line_buffer.rs | 40 ++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 40dcdebe..cdb3ff6e 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -1951,27 +1951,38 @@ mod test { assert_eq!(buf.range_inside_current_pair_in_group(*pairs), expected); } - // Tests for range_inside_next_quote - cursor before quotes, jumping forward + // Tests for range_inside_next_pair_in_group - cursor before pairs, return range inside next pair if exists #[rstest] - #[case(r#"foo "'bar'" baz"#, 1, Some(5..10))] // cursor before nested quotes - #[case(r#"foo '' "bar" baz"#, 1, Some(5..5))] // cursor before first quotes - #[case(r#""foo"'bar`b'az`"#, 1, Some(6..11))] // cursor inside first quotes, find single quotes - #[case(r#""foo"'bar'`baz`"#, 6, Some(11..14))] // cursor after second quotes, find backticks - #[case(r#"zaz'foo"b`a`r"baz'zaz"#, 3, Some(4..17))] // range inside outermost nested quotes - #[case(r#""""#, 0, Some(1..1))] // single quote pair (empty) - should find it ahead - #[case(r#"""asdf"#, 0, Some(1..1))] // unmatched trailing quote - #[case(r#""foo"'bar'`baz`"#, 0, Some(1..4))] // cursor at start, should find first quotes - #[case(r#"foo'bar""#, 1, None)] // mismatched quotes - #[case("no quotes here", 5, None)] // no quotes in buffer - #[case("", 0, None)] // empty buffer - fn test_range_inside_next_quote( + #[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(*QUOTE_PAIRS), expected); + assert_eq!(buf.range_inside_next_pair_in_group(*pairs), expected); } // Tests for range_inside_current_pair - when cursor is inside a pair @@ -1992,6 +2003,7 @@ mod test { #[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, From 4dfd27aaaa9463e7b49ef4fefddc65f9dbbfe0d2 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 15:11:06 +0100 Subject: [PATCH 19/25] Add more detailed unicode safety tests --- src/core_editor/line_buffer.rs | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index cdb3ff6e..51e9e1a3 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -2144,4 +2144,50 @@ mod test { 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()); + } } From cda254721dce78d1e19eb5e10ff2609c14770340 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 15:11:23 +0100 Subject: [PATCH 20/25] Fix display enum string for renamed enums --- src/enums.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/enums.rs b/src/enums.rs index 1809429b..3b01cbb3 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -527,9 +527,9 @@ impl Display for EditCommand { #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => write!(f, "PasteSystem"), EditCommand::CutInsidePair { .. } => write!(f, "CutInside Value: "), - EditCommand::CopyInsidePair { .. } => write!(f, "YankInside Value: "), - EditCommand::CutAroundPair { .. } => write!(f, "CutAround Value: "), - EditCommand::CopyAroundPair { .. } => write!(f, "YankAround 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: "), } From 27b46cde743a0a987661062fc184a22842b2636c Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 16:18:37 +0100 Subject: [PATCH 21/25] Unicode and overflow/underflow safety when expanding text object ranges --- src/core_editor/editor.rs | 41 ++++++++++++++++++++++------------ src/core_editor/line_buffer.rs | 23 ++++++++++--------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 6176e4ec..32b4a718 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -763,12 +763,11 @@ impl Editor { self.line_buffer .range_inside_next_pair_in_group(*BRACKET_PAIRS) }) - .map(|bracket_range| { + .and_then(|bracket_range| { match text_object_scope { - TextObjectScope::Inner => bracket_range, + TextObjectScope::Inner => Some(bracket_range), TextObjectScope::Around => { - // Include the brackets themselves - (bracket_range.start - 1)..(bracket_range.end + 1) + self.expand_range_to_include_pair(bracket_range) } } }) @@ -790,12 +789,11 @@ impl Editor { self.line_buffer .range_inside_next_pair_in_group(*QUOTE_PAIRS) }) - .map(|quote_range| { + .and_then(|quote_range| { match text_object_scope { - TextObjectScope::Inner => quote_range, + TextObjectScope::Inner => Some(quote_range), TextObjectScope::Around => { - // Include the quotes themselves - (quote_range.start - 1)..(quote_range.end + 1) + self.expand_range_to_include_pair(quote_range) } } }) @@ -923,34 +921,49 @@ impl Editor { } } + /// Safely expand the range to include `open_char` and `close_char` + /// This isn't safe against the pair being a grapheme. + 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); + + // Ensure we don't exceed buffer bounds + if end > self.line_buffer.len() { + return None; + } + + Some(start..end) + } + /// Delete text around matching `open_char` and `close_char` (including the pair characters). pub(crate) fn cut_around_pair(&mut self, open_char: char, close_char: char) { - if let Some(range) = self + 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)) { - // Expand range to include the pair characters themselves - let around_range = (range.start - 1)..(range.end + 1); self.cut_range(around_range); } } /// Yank text around matching `open_char` and `close_char` (including the pair characters). pub(crate) fn copy_around_pair(&mut self, open_char: char, close_char: char) { - if let Some(range) = self + 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)) { - // Expand range to include the pair characters themselves - let around_range = (range.start - 1)..(range.end + 1); self.yank_range(around_range); } } diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 51e9e1a3..da1e878a 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..] @@ -918,7 +919,7 @@ impl LineBuffer { 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 + 1)..close_char_index_in_buffer + (open_char_index_in_buffer + open_char.len_utf8())..close_char_index_in_buffer }, ) } From 21910c3bca3739cd61e9f137029f2c75c1babfd1 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 19:05:57 +0100 Subject: [PATCH 22/25] Pass through matching pair group const for quote and bracket text object functions --- src/core_editor/editor.rs | 68 +++++++++++++++++++--------------- src/core_editor/line_buffer.rs | 33 ++++++++--------- 2 files changed, 53 insertions(+), 48 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 32b4a718..055bc8e4 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -745,10 +745,41 @@ impl Editor { }) } + /// 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 + .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. @@ -756,21 +787,8 @@ impl Editor { &self, text_object_scope: TextObjectScope, ) -> Option> { - const BRACKET_PAIRS: &[(char, char); 3] = &[('(', ')'), ('[', ']'), ('{', '}')]; - self.line_buffer - .range_inside_current_pair_in_group(*BRACKET_PAIRS) - .or_else(|| { - self.line_buffer - .range_inside_next_pair_in_group(*BRACKET_PAIRS) - }) - .and_then(|bracket_range| { - match text_object_scope { - TextObjectScope::Inner => Some(bracket_range), - TextObjectScope::Around => { - self.expand_range_to_include_pair(bracket_range) - } - } - }) + 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 `\`\`\`) @@ -779,24 +797,14 @@ impl Editor { /// /// 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); 3] = &[('"', '"'), ('\'', '\''), ('`', '`')]; - self.line_buffer - .range_inside_current_pair_in_group(*QUOTE_PAIRS) - .or_else(|| { - self.line_buffer - .range_inside_next_pair_in_group(*QUOTE_PAIRS) - }) - .and_then(|quote_range| { - match text_object_scope { - TextObjectScope::Inner => Some(quote_range), - TextObjectScope::Around => { - self.expand_range_to_include_pair(quote_range) - } - } - }) + 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 diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index da1e878a..57d79f27 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -979,17 +979,14 @@ impl LineBuffer { /// /// 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( + pub(crate) fn range_inside_current_pair_in_group( &self, - pair_group: I, - ) -> Option> - where - I: IntoIterator, - { - pair_group - .into_iter() + 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) + self.range_inside_current_pair(*open_char, *close_char) }) .min_by_key(|range| range.len()) } @@ -1003,14 +1000,14 @@ impl LineBuffer { /// 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, pair_group: I) -> Option> - where - I: IntoIterator, - { - pair_group - .into_iter() + 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) + self.range_inside_next_pair(*open_char, *close_char) }) .min_by_key(|range| range.start) } @@ -1949,7 +1946,7 @@ mod test { ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - assert_eq!(buf.range_inside_current_pair_in_group(*pairs), expected); + 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 @@ -1983,7 +1980,7 @@ mod test { ) { let mut buf = LineBuffer::from(input); buf.set_insertion_point(cursor_pos); - assert_eq!(buf.range_inside_next_pair_in_group(*pairs), expected); + assert_eq!(buf.range_inside_next_pair_in_group(pairs), expected); } // Tests for range_inside_current_pair - when cursor is inside a pair From b6ec43ddbd840ab7f153cff99b7bd73f8ec5120d Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 19:07:01 +0100 Subject: [PATCH 23/25] Rename yank_range -> copy_range for consistency with other methods --- src/core_editor/editor.rs | 49 ++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 055bc8e4..9afee041 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -675,13 +675,13 @@ impl Editor { fn cut_range(&mut self, range: Range) { if range.start <= range.end { - self.yank_range(range.clone()); + self.copy_range(range.clone()); self.line_buffer.clear_range_safe(range.clone()); self.line_buffer.set_insertion_point(range.start); } } - fn yank_range(&mut self, range: Range) { + 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); @@ -689,7 +689,7 @@ impl Editor { } /// Delete text strictly between matching `open_char` and `close_char`. - pub(crate) fn cut_inside_pair(&mut self, open_char: char, close_char: 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) @@ -825,7 +825,7 @@ impl Editor { fn copy_text_object(&mut self, text_object: TextObject) { if let Some(range) = self.text_object_range(text_object) { - self.yank_range(range); + self.copy_range(range); } } @@ -849,61 +849,61 @@ impl Editor { start }; let copy_range = start_offset..previous_offset; - self.yank_range(copy_range); + self.copy_range(copy_range); } pub(crate) fn copy_from_end(&mut self) { let copy_range = self.line_buffer.insertion_point()..self.line_buffer.len(); - self.yank_range(copy_range); + self.copy_range(copy_range); } pub(crate) fn copy_to_line_end(&mut self) { let copy_range = self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end(); - self.yank_range(copy_range); + self.copy_range(copy_range); } pub(crate) fn copy_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); let word_start = self.line_buffer.word_left_index(); - self.yank_range(word_start..insertion_offset); + 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 big_word_start = self.line_buffer.big_word_left_index(); - self.yank_range(big_word_start..insertion_offset); + 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 word_end = self.line_buffer.word_right_index(); - self.yank_range(insertion_offset..word_end); + 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 big_word_end = self.line_buffer.next_whitespace(); - self.yank_range(insertion_offset..big_word_end); + 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 next_word_start = self.line_buffer.word_right_start_index(); - self.yank_range(insertion_offset..next_word_start); + 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 next_big_word_start = self.line_buffer.big_word_right_start_index(); - self.yank_range(insertion_offset..next_big_word_start); + 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_range = self.line_buffer.insertion_point()..index + extra; - self.yank_range(copy_range); + self.copy_range(copy_range); } } @@ -911,12 +911,12 @@ impl Editor { 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_range = index + extra..self.line_buffer.insertion_point(); - self.yank_range(copy_range); + self.copy_range(copy_range); } } - /// Yank text strictly between matching `open_char` and `close_char`. - pub(crate) fn copy_inside_pair(&mut self, open_char: char, close_char: char) { + /// 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) @@ -925,16 +925,13 @@ impl Editor { .range_inside_next_pair(open_char, close_char) }) { - self.yank_range(range); + self.copy_range(range); } } /// Safely expand the range to include `open_char` and `close_char` /// This isn't safe against the pair being a grapheme. - fn expand_range_to_include_pair( - &self, - range: Range, - ) -> Option> { + 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); @@ -947,7 +944,7 @@ impl Editor { } /// Delete text around matching `open_char` and `close_char` (including the pair characters). - pub(crate) fn cut_around_pair(&mut self, open_char: char, close_char: char) { + 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) @@ -961,8 +958,8 @@ impl Editor { } } - /// Yank text around matching `open_char` and `close_char` (including the pair characters). - pub(crate) fn copy_around_pair(&mut self, open_char: char, close_char: char) { + /// 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) @@ -972,7 +969,7 @@ impl Editor { }) .and_then(|range| self.expand_range_to_include_pair(range)) { - self.yank_range(around_range); + self.copy_range(around_range); } } } From bd13b69e7df032599c77c3228ee6422b3399baf8 Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 20:08:32 +0100 Subject: [PATCH 24/25] Remove unecessary guard clause from expand_range_to_include_pair --- src/core_editor/editor.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 9afee041..96e8e911 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -929,17 +929,11 @@ impl Editor { } } - /// Safely expand the range to include `open_char` and `close_char` - /// This isn't safe against the pair being a grapheme. + /// 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); - // Ensure we don't exceed buffer bounds - if end > self.line_buffer.len() { - return None; - } - Some(start..end) } From 14c93e03d064b3a4fa3946c1db4ca45ac54d101e Mon Sep 17 00:00:00 2001 From: JonLD Date: Thu, 31 Jul 2025 21:34:28 +0100 Subject: [PATCH 25/25] Correct display string for CutInsidePair --- src/enums.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/enums.rs b/src/enums.rs index 3b01cbb3..8d6f3270 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -526,7 +526,7 @@ impl Display for EditCommand { EditCommand::CopySelectionSystem => write!(f, "CopySelectionSystem"), #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => write!(f, "PasteSystem"), - EditCommand::CutInsidePair { .. } => write!(f, "CutInside 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: "),