From 353aa95eba859a6b486d201ae4aae63e5298fb27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Mon, 20 Apr 2026 23:58:03 -0400 Subject: [PATCH 1/5] fix(vi): use Vi-standard word boundary rules Replace unicode_segmentation word boundaries with a Vi-compatible three-class system (keyword, punctuation, whitespace) for w/e/b motions. Separate inclusive Vi cut/copy (de/dE/ce/cE/ye/yE) from exclusive Emacs word commands (Alt+d) by adding dedicated EditCommand variants. Closes: #563, #667 Addresses: #788 (point 2) --- src/core_editor/editor.rs | 96 ++++++++++++++- src/core_editor/line_buffer.rs | 218 +++++++++++++++++++++++++++++++++ src/edit_mode/vi/command.rs | 32 +++-- src/edit_mode/vi/motion.rs | 6 +- src/edit_mode/vi/parser.rs | 20 +-- src/enums.rs | 60 +++++++++ 6 files changed, 406 insertions(+), 26 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 0d7673ba..6d72686e 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -77,6 +77,9 @@ impl Editor { } EditCommand::MoveWordRightEnd { select } => self.move_word_right_end(*select), EditCommand::MoveBigWordRightEnd { select } => self.move_big_word_right_end(*select), + EditCommand::MoveViWordLeft { select } => self.move_vi_word_left(*select), + EditCommand::MoveViWordRightStart { select } => self.move_vi_word_right_start(*select), + EditCommand::MoveViWordRightEnd { select } => self.move_vi_word_right_end(*select), EditCommand::InsertChar(c) => self.insert_char(*c), EditCommand::Complete => {} EditCommand::InsertString(str) => self.insert_str(str), @@ -107,8 +110,11 @@ impl Editor { EditCommand::KillLine => self.kill_line(), EditCommand::CutWordLeft => self.cut_word_left(), EditCommand::CutBigWordLeft => self.cut_big_word_left(), + EditCommand::CutViWordLeft => self.cut_vi_word_left(), EditCommand::CutWordRight => self.cut_word_right(), EditCommand::CutBigWordRight => self.cut_big_word_right(), + EditCommand::CutWordRightEnd => self.cut_word_right_end(), + EditCommand::CutBigWordRightEnd => self.cut_big_word_right_end(), EditCommand::CutWordRightToNext => self.cut_word_right_to_next(), EditCommand::CutBigWordRightToNext => self.cut_big_word_right_to_next(), EditCommand::PasteCutBufferBefore => self.insert_cut_buffer_before(), @@ -150,8 +156,11 @@ impl Editor { EditCommand::CopyToLineEnd => self.copy_to_line_end(), EditCommand::CopyWordLeft => self.copy_word_left(), EditCommand::CopyBigWordLeft => self.copy_big_word_left(), + EditCommand::CopyViWordLeft => self.copy_vi_word_left(), EditCommand::CopyWordRight => self.copy_word_right(), EditCommand::CopyBigWordRight => self.copy_big_word_right(), + EditCommand::CopyWordRightEnd => self.copy_word_right_end(), + EditCommand::CopyBigWordRightEnd => self.copy_big_word_right_end(), EditCommand::CopyWordRightToNext => self.copy_word_right_to_next(), EditCommand::CopyBigWordRightToNext => self.copy_big_word_right_to_next(), EditCommand::CopyRightUntil(c) => self.copy_right_until_char(*c, false, true), @@ -482,6 +491,12 @@ impl Editor { self.cut_range(big_word_start..insertion_offset); } + fn cut_vi_word_left(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let word_start = self.line_buffer.vi_word_left_index(); + self.cut_range(word_start..insertion_offset); + } + fn cut_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); let word_end = self.line_buffer.word_right_index(); @@ -494,9 +509,27 @@ impl Editor { self.cut_range(insertion_offset..big_word_end); } + /// Cut from cursor to end of next word (inclusive). + /// Used by Vi `de` — the `e` motion is inclusive. + fn cut_word_right_end(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let word_end = self.line_buffer.vi_word_right_end_index(); + let inclusive_end = self.line_buffer.grapheme_right_index_from_pos(word_end); + self.cut_range(insertion_offset..inclusive_end); + } + + /// Cut from cursor to end of next WORD (inclusive). + /// Used by Vi `dE` — the `E` motion is inclusive. + fn cut_big_word_right_end(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let word_end = self.line_buffer.big_word_right_end_index(); + let inclusive_end = self.line_buffer.grapheme_right_index_from_pos(word_end); + self.cut_range(insertion_offset..inclusive_end); + } + fn cut_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(); + let next_word_start = self.line_buffer.vi_word_right_start_index(); self.cut_range(insertion_offset..next_word_start); } @@ -763,6 +796,18 @@ impl Editor { self.move_to_position(self.line_buffer.big_word_right_end_index(), select); } + fn move_vi_word_left(&mut self, select: bool) { + self.move_to_position(self.line_buffer.vi_word_left_index(), select); + } + + fn move_vi_word_right_start(&mut self, select: bool) { + self.move_to_position(self.line_buffer.vi_word_right_start_index(), select); + } + + fn move_vi_word_right_end(&mut self, select: bool) { + self.move_to_position(self.line_buffer.vi_word_right_end_index(), select); + } + fn insert_char(&mut self, c: char) { self.delete_selection(); self.line_buffer.insert_char(c); @@ -1038,6 +1083,12 @@ impl Editor { self.copy_range(big_word_start..insertion_offset); } + pub(crate) fn copy_vi_word_left(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let word_start = self.line_buffer.vi_word_left_index(); + self.copy_range(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(); @@ -1050,9 +1101,27 @@ impl Editor { self.copy_range(insertion_offset..big_word_end); } + /// Copy from cursor to end of next word (inclusive). + /// Used by Vi `ye` — the `e` motion is inclusive. + pub(crate) fn copy_word_right_end(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let word_end = self.line_buffer.vi_word_right_end_index(); + let inclusive_end = self.line_buffer.grapheme_right_index_from_pos(word_end); + self.copy_range(insertion_offset..inclusive_end); + } + + /// Copy from cursor to end of next WORD (inclusive). + /// Used by Vi `yE` — the `E` motion is inclusive. + pub(crate) fn copy_big_word_right_end(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let word_end = self.line_buffer.big_word_right_end_index(); + let inclusive_end = self.line_buffer.grapheme_right_index_from_pos(word_end); + self.copy_range(insertion_offset..inclusive_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(); + let next_word_start = self.line_buffer.vi_word_right_start_index(); self.copy_range(insertion_offset..next_word_start); } @@ -1192,6 +1261,29 @@ mod test { assert_eq!(editor.get_buffer(), expected); } + #[rstest] + // de from start of "abc def": cuts "abc" (inclusive end of word) + #[case("abc def", 0, "abc", " def")] + // de on 'b' in "a.b.c": cuts "b." (word 'b' end is 'b', next word '.' + // end is '.', so inclusive range covers "b.") + #[case("a.b.c", 2, "b.", "a.c")] + // de on 'a' in "abc.def": cuts "abc" (end of word 'abc') + #[case("abc.def", 0, "abc", ".def")] + fn test_cut_word_right_end( + #[case] input: &str, + #[case] position: usize, + #[case] expected_cut: &str, + #[case] expected_buffer: &str, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + + editor.cut_word_right_end(); + + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + #[rstest] #[case("hello world", 0, 'l', 1, false, "lo world")] #[case("hello world", 0, 'l', 1, true, "llo world")] diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 416e13fe..c22ca057 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -199,6 +199,14 @@ impl LineBuffer { .unwrap_or_else(|| self.lines.len()) } + /// Cursor position *behind* the next Vi word to the right + pub fn vi_word_right_index(&self) -> usize { + vi_word_segments(&self.lines[self.insertion_point..]) + .find(|(_, word)| !is_whitespace_str(word)) + .map(|(i, word)| self.insertion_point + i + word.len()) + .unwrap_or_else(|| self.lines.len()) + } + /// Cursor position *behind* the next WORD to the right pub fn big_word_right_index(&self) -> usize { let mut found_ws = false; @@ -232,6 +240,24 @@ impl LineBuffer { }) } + /// Cursor position *at end of* the next Vi word to the right + pub fn vi_word_right_end_index(&self) -> usize { + vi_word_segments(&self.lines[self.insertion_point..]) + .find_map(|(i, word)| { + word.grapheme_indices(true) + .next_back() + .map(|x| self.insertion_point + x.0 + i) + .filter(|x| !is_whitespace_str(word) && *x != self.insertion_point) + }) + .unwrap_or_else(|| { + self.lines + .grapheme_indices(true) + .next_back() + .map(|x| x.0) + .unwrap_or(0) + }) + } + /// Cursor position *at end of* the next WORD to the right pub fn big_word_right_end_index(&self) -> usize { self.lines[self.insertion_point..] @@ -266,6 +292,14 @@ impl LineBuffer { .unwrap_or_else(|| self.lines.len()) } + /// Cursor position *in front of* the next Vi word to the right + pub fn vi_word_right_start_index(&self) -> usize { + vi_word_segments(&self.lines[self.insertion_point..]) + .find(|(i, word)| *i != 0 && !is_whitespace_str(word)) + .map(|(i, _)| self.insertion_point + i) + .unwrap_or_else(|| self.lines.len()) + } + /// Cursor position *in front of* the next WORD to the right pub fn big_word_right_start_index(&self) -> usize { let mut found_ws = false; @@ -289,6 +323,15 @@ impl LineBuffer { .unwrap_or(0) } + /// Cursor position *in front of* the next Vi word to the left + pub fn vi_word_left_index(&self) -> usize { + vi_word_segments(&self.lines[..self.insertion_point]) + .filter(|(_, word)| !is_whitespace_str(word)) + .last() + .map(|(i, _)| i) + .unwrap_or(0) + } + /// Cursor position *in front of* the next WORD to the left pub fn big_word_left_index(&self) -> usize { self.lines[..self.insertion_point] @@ -529,6 +572,18 @@ impl LineBuffer { left_index..right_index } + /// Gets the range of the Vi word the current edit position is pointing to + pub fn vi_current_word_range(&self) -> Range { + let right_index = self.vi_word_right_index(); + let left_index = vi_word_segments(&self.lines[..right_index]) + .filter(|(_, word)| !is_whitespace_str(word)) + .last() + .map(|(i, _)| i) + .unwrap_or(0); + + left_index..right_index + } + /// Range over the current line /// /// Starts on the first non-newline character and is an exclusive range @@ -1065,6 +1120,56 @@ impl LineBuffer { } } +/// Character class for Vi-style word motions (w/e/b). +/// +/// Vi defines three classes of characters: +/// - **Keyword**: letters, digits, and underscores +/// - **Punctuation**: non-blank, non-keyword characters +/// - **Whitespace**: spaces, tabs, and other blanks +/// +/// A *word* is a maximal sequence of keyword characters OR a maximal sequence +/// of punctuation characters. Transitions between classes are word boundaries. +/// +/// Reference: Vim's `:help word`, GNU Readline's `_rl_isident()`, POSIX vi. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ViCharClass { + Keyword, + Punctuation, + Whitespace, +} + +impl ViCharClass { + fn of(c: char) -> Self { + if c.is_alphanumeric() || c == '_' { + Self::Keyword + } else if c.is_whitespace() { + Self::Whitespace + } else { + Self::Punctuation + } + } +} + +/// Iterate over Vi-style word segments in a string. +/// +/// Each yielded item is `(byte_offset, substring)` where all characters in +/// `substring` belong to the same [`ViCharClass`]. +fn vi_word_segments(s: &str) -> impl Iterator { + let mut chars = s.char_indices().peekable(); + std::iter::from_fn(move || { + let (first_idx, first_char) = chars.next()?; + let class = ViCharClass::of(first_char); + while let Some(&(_, next_char)) = chars.peek() { + if ViCharClass::of(next_char) != class { + break; + } + chars.next(); + } + let end = chars.peek().map_or(s.len(), |&(i, _)| i); + Some((first_idx, &s[first_idx..end])) + }) +} + /// Match any sequence of characters that are considered a word boundary fn is_whitespace_str(s: &str) -> bool { s.chars().all(char::is_whitespace) @@ -1907,6 +2012,119 @@ mod test { assert_eq!(index, expected); } + // ── Vi word segmentation tests ────────────────────────────────────── + + #[test] + fn test_vi_word_segments_basic() { + let segs: Vec<_> = vi_word_segments("abc def").collect(); + assert_eq!(segs, vec![(0, "abc"), (3, " "), (4, "def")]); + } + + #[test] + fn test_vi_word_segments_punctuation() { + // a.b.c → 5 segments: a, ., b, ., c + let segs: Vec<_> = vi_word_segments("a.b.c").collect(); + assert_eq!(segs, vec![(0, "a"), (1, "."), (2, "b"), (3, "."), (4, "c")]); + } + + #[test] + fn test_vi_word_segments_consecutive_punct() { + // "..." is a single punctuation word + let segs: Vec<_> = vi_word_segments("...").collect(); + assert_eq!(segs, vec![(0, "...")]); + } + + #[test] + fn test_vi_word_segments_mixed() { + let segs: Vec<_> = vi_word_segments("foo_bar.baz").collect(); + assert_eq!(segs, vec![(0, "foo_bar"), (7, "."), (8, "baz")]); + } + + #[test] + fn test_vi_word_segments_colons() { + let segs: Vec<_> = vi_word_segments("abc:def").collect(); + assert_eq!(segs, vec![(0, "abc"), (3, ":"), (4, "def")]); + } + + #[test] + fn test_vi_word_segments_mixed_punct() { + let segs: Vec<_> = vi_word_segments("abc...def").collect(); + assert_eq!(segs, vec![(0, "abc"), (3, "..."), (6, "def")]); + } + + #[test] + fn test_vi_word_segments_empty() { + let segs: Vec<_> = vi_word_segments("").collect(); + assert_eq!(segs, Vec::<(usize, &str)>::new()); + } + + // ── Additional Vi word motion tests (punctuation) ─────────────────── + + #[rstest] + // a.b.c: w from 'a' → position 1 (the dot) + #[case("a.b.c", 0, 1)] + // abc:def: w from 'a' → position 3 (the colon) + #[case("abc:def", 0, 3)] + // abc...def: w from 'a' → position 3 (first dot) + #[case("abc...def", 0, 3)] + // foo_bar.baz: w from 'f' → position 7 (the dot) + #[case("foo_bar.baz", 0, 7)] + // a.b.c: w from position 1 (dot) → position 2 ('b') + #[case("a.b.c", 1, 2)] + fn test_word_right_start_index_punct( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + assert_eq!(line_buffer.vi_word_right_start_index(), expected); + } + + #[rstest] + // a.b.c: e from 'a' → position 1 (the dot, next non-ws word end) + #[case("a.b.c", 0, 1)] + // abc:def: e from 'a' → position 2 (end of 'abc') + #[case("abc:def", 0, 2)] + // abc...def: e from 'a' → position 2 (end of 'abc') + #[case("abc...def", 0, 2)] + // foo_bar.baz: e from 'f' → position 6 (end of 'foo_bar') + #[case("foo_bar.baz", 0, 6)] + // a.b.c: e from 'b' (pos 2) → position 3 (the dot after b) + #[case("a.b.c", 2, 3)] + fn test_word_right_end_index_punct( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + assert_eq!(line_buffer.vi_word_right_end_index(), expected); + } + + #[rstest] + // a.b.c: b from 'c' (pos 4) → position 3 (the second dot) + #[case("a.b.c", 4, 3)] + // abc:def: b from end (pos 7) → position 4 ('d') + #[case("abc:def", 7, 4)] + // abc:def: b from 'd' (pos 4) → position 3 (':') + #[case("abc:def", 4, 3)] + // abc...def: b from end (pos 9) → position 6 ('d') + #[case("abc...def", 9, 6)] + // foo_bar.baz: b from end (pos 11) → position 8 ('b') + #[case("foo_bar.baz", 11, 8)] + // a.b.c.d: b from '.' between c and d (pos 5) → position 4 ('c') + #[case("a.b.c.d", 5, 4)] + fn test_word_left_index_punct( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + assert_eq!(line_buffer.vi_word_left_index(), expected); + } + #[rstest] #[case("abc def", 0, 3)] #[case("abc def ghi", 3, 7)] diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 18999e5c..d77df0e7 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -335,11 +335,15 @@ impl Command { Motion::NextBigWord => Some(vec![ReedlineOption::Edit( EditCommand::CutBigWordRightToNext, )]), - Motion::NextWordEnd => Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]), + Motion::NextWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightEnd)]) + } Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRightEnd)]) + } + Motion::PreviousWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutViWordLeft)]) } - Motion::PreviousWord => Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]), Motion::PreviousBigWord => { Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) } @@ -393,18 +397,20 @@ impl Command { ReedlineOption::Edit(EditCommand::MoveToLineStart { select: false }), ReedlineOption::Edit(EditCommand::CutToLineEnd), ]), - Motion::NextWord => Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]), + Motion::NextWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightEnd)]) + } Motion::NextBigWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRightEnd)]) } Motion::NextWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]) + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightEnd)]) } Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRightEnd)]) } Motion::PreviousWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]) + Some(vec![ReedlineOption::Edit(EditCommand::CutViWordLeft)]) } Motion::PreviousBigWord => { Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) @@ -470,11 +476,15 @@ impl Command { Motion::NextBigWord => Some(vec![ReedlineOption::Edit( EditCommand::CopyBigWordRightToNext, )]), - Motion::NextWordEnd => Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRight)]), + Motion::NextWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRightEnd)]) + } Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordRight)]) + Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordRightEnd)]) + } + Motion::PreviousWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyViWordLeft)]) } - Motion::PreviousWord => Some(vec![ReedlineOption::Edit(EditCommand::CopyWordLeft)]), Motion::PreviousBigWord => { Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordLeft)]) } diff --git a/src/edit_mode/vi/motion.rs b/src/edit_mode/vi/motion.rs index 6fac6ec7..d525cc57 100644 --- a/src/edit_mode/vi/motion.rs +++ b/src/edit_mode/vi/motion.rs @@ -195,13 +195,13 @@ impl Motion { ReedlineEvent::Down, ])) }], - Motion::NextWord => vec![ReedlineOption::Edit(EditCommand::MoveWordRightStart { + Motion::NextWord => vec![ReedlineOption::Edit(EditCommand::MoveViWordRightStart { select: select_mode, })], Motion::NextBigWord => vec![ReedlineOption::Edit(EditCommand::MoveBigWordRightStart { select: select_mode, })], - Motion::NextWordEnd => vec![ReedlineOption::Edit(EditCommand::MoveWordRightEnd { + Motion::NextWordEnd => vec![ReedlineOption::Edit(EditCommand::MoveViWordRightEnd { select: select_mode, })], Motion::NextBigWordEnd => { @@ -209,7 +209,7 @@ impl Motion { select: select_mode, })] } - Motion::PreviousWord => vec![ReedlineOption::Edit(EditCommand::MoveWordLeft { + Motion::PreviousWord => vec![ReedlineOption::Edit(EditCommand::MoveViWordLeft { select: select_mode, })], Motion::PreviousBigWord => vec![ReedlineOption::Edit(EditCommand::MoveBigWordLeft { diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index da0839cd..d860c075 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -491,7 +491,7 @@ mod tests { ReedlineEvent::Up, ])]))] #[case(&['w'], - ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveWordRightStart{select:false}])]))] + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveViWordRightStart{select:false}])]))] #[case(&['W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveBigWordRightStart{select:false}])]))] #[case(&['2', 'l'], ReedlineEvent::Multiple(vec![ @@ -526,22 +526,22 @@ mod tests { ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] #[case(&['d', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightToNext])]))] #[case(&['d', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightToNext])]))] - #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight])]))] - #[case(&['d', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordLeft])]))] + #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightEnd])]))] + #[case(&['d', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViWordLeft])]))] #[case(&['d', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft])]))] #[case(&['c', 'c'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart { select: false }]), ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd]), ReedlineEvent::Repaint]))] - #[case(&['c', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight]), ReedlineEvent::Repaint]))] - #[case(&['c', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight]), ReedlineEvent::Repaint]))] - #[case(&['c', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight]), ReedlineEvent::Repaint]))] - #[case(&['c', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordLeft]), ReedlineEvent::Repaint]))] + #[case(&['c', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightEnd]), ReedlineEvent::Repaint]))] + #[case(&['c', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightEnd]), ReedlineEvent::Repaint]))] + #[case(&['c', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightEnd]), ReedlineEvent::Repaint]))] + #[case(&['c', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViWordLeft]), ReedlineEvent::Repaint]))] #[case(&['c', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft]), ReedlineEvent::Repaint]))] #[case(&['d', 'h'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Backspace])]))] #[case(&['d', 'l'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Delete])]))] #[case(&['2', 'd', 'd'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] // #[case(&['d', 'j'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] // #[case(&['d', 'k'], ReedlineEvent::Multiple(vec![ReedlineEvent::Up, ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] - #[case(&['d', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight])]))] + #[case(&['d', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightEnd])]))] #[case(&['d', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart])]))] #[case(&['d', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart])]))] #[case(&['d', '$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd])]))] @@ -551,7 +551,7 @@ mod tests { #[case(&['d', 'T', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftBefore('a')])]))] #[case(&['d', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromStartLinewise { leave_blank_line: false }])]))] #[case(&['d', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToEndLinewise { leave_blank_line: false }])]))] - #[case(&['c', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight]), ReedlineEvent::Repaint]))] + #[case(&['c', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightEnd]), ReedlineEvent::Repaint]))] #[case(&['c', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart]), ReedlineEvent::Repaint]))] #[case(&['c', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart]), ReedlineEvent::Repaint]))] #[case(&['c', '$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd]), ReedlineEvent::Repaint]))] @@ -622,7 +622,7 @@ mod tests { ])]))] #[case(&['k'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveLineUp { select: true }])]))] #[case(&['w'], - ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveWordRightStart{select:true}])]))] + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveViWordRightStart{select:true}])]))] #[case(&['W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveBigWordRightStart{select:true}])]))] #[case(&['2', 'l'], ReedlineEvent::Multiple(vec![ diff --git a/src/enums.rs b/src/enums.rs index 46228e0b..b7bcdb92 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -194,6 +194,24 @@ pub enum EditCommand { select: bool, }, + /// Move one Vi word to the left (uses Vi three-class word boundaries) + MoveViWordLeft { + /// Select the text between the current cursor position and destination + select: bool, + }, + + /// Move to the start of the next Vi word (uses Vi three-class word boundaries) + MoveViWordRightStart { + /// Select the text between the current cursor position and destination + select: bool, + }, + + /// Move to the end of the next Vi word (uses Vi three-class word boundaries) + MoveViWordRightEnd { + /// Select the text between the current cursor position and destination + select: bool, + }, + /// Move to position MoveToPosition { /// Position to move to @@ -302,12 +320,21 @@ pub enum EditCommand { /// Cut the word right of the insertion point CutBigWordRight, + /// Cut from the insertion point to the end of the next word (inclusive, for Vi `de`) + CutWordRightEnd, + + /// Cut from the insertion point to the end of the next WORD (inclusive, for Vi `dE`) + CutBigWordRightEnd, + /// Cut the word right of the insertion point and any following space CutWordRightToNext, /// Cut the WORD right of the insertion point and any following space CutBigWordRightToNext, + /// Cut the Vi word left of the insertion point (uses Vi three-class word boundaries) + CutViWordLeft, + /// Paste the cut buffer in front of the insertion point (Emacs, vi `P`) PasteCutBufferBefore, @@ -424,12 +451,21 @@ pub enum EditCommand { /// Copy the WORD left of the insertion point CopyBigWordLeft, + /// Copy the Vi word left of the insertion point (uses Vi three-class word boundaries) + CopyViWordLeft, + /// Copy the word right of the insertion point CopyWordRight, /// Copy the WORD right of the insertion point CopyBigWordRight, + /// Copy from the insertion point to the end of the next word (inclusive, for Vi `ye`) + CopyWordRightEnd, + + /// Copy from the insertion point to the end of the next WORD (inclusive, for Vi `yE`) + CopyBigWordRightEnd, + /// Copy the word right of the insertion point and any following space CopyWordRightToNext, @@ -545,6 +581,15 @@ impl Display for EditCommand { EditCommand::MoveBigWordRightEnd { .. } => { write!(f, "MoveBigWordRightEnd Optional[select: ]") } + EditCommand::MoveViWordLeft { .. } => { + write!(f, "MoveViWordLeft Optional[select: ]") + } + EditCommand::MoveViWordRightStart { .. } => { + write!(f, "MoveViWordRightStart Optional[select: ]") + } + EditCommand::MoveViWordRightEnd { .. } => { + write!(f, "MoveViWordRightEnd Optional[select: ]") + } EditCommand::MoveWordRightStart { .. } => { write!(f, "MoveWordRightStart Optional[select: ]") } @@ -592,8 +637,11 @@ impl Display for EditCommand { EditCommand::CutBigWordLeft => write!(f, "CutBigWordLeft"), EditCommand::CutWordRight => write!(f, "CutWordRight"), EditCommand::CutBigWordRight => write!(f, "CutBigWordRight"), + EditCommand::CutWordRightEnd => write!(f, "CutWordRightEnd"), + EditCommand::CutBigWordRightEnd => write!(f, "CutBigWordRightEnd"), EditCommand::CutWordRightToNext => write!(f, "CutWordRightToNext"), EditCommand::CutBigWordRightToNext => write!(f, "CutBigWordRightToNext"), + EditCommand::CutViWordLeft => write!(f, "CutViWordLeft"), EditCommand::PasteCutBufferBefore => write!(f, "PasteCutBufferBefore"), EditCommand::PasteCutBufferAfter => write!(f, "PasteCutBufferAfter"), EditCommand::UppercaseWord => write!(f, "UppercaseWord"), @@ -624,8 +672,11 @@ impl Display for EditCommand { EditCommand::CopyCurrentLine => write!(f, "CopyCurrentLine"), EditCommand::CopyWordLeft => write!(f, "CopyWordLeft"), EditCommand::CopyBigWordLeft => write!(f, "CopyBigWordLeft"), + EditCommand::CopyViWordLeft => write!(f, "CopyViWordLeft"), EditCommand::CopyWordRight => write!(f, "CopyWordRight"), EditCommand::CopyBigWordRight => write!(f, "CopyBigWordRight"), + EditCommand::CopyWordRightEnd => write!(f, "CopyWordRightEnd"), + EditCommand::CopyBigWordRightEnd => write!(f, "CopyBigWordRightEnd"), EditCommand::CopyWordRightToNext => write!(f, "CopyWordRightToNext"), EditCommand::CopyBigWordRightToNext => write!(f, "CopyBigWordRightToNext"), EditCommand::CopyLeft => write!(f, "CopyLeft"), @@ -674,6 +725,9 @@ impl EditCommand { | EditCommand::MoveBigWordRightStart { select, .. } | EditCommand::MoveWordRightEnd { select, .. } | EditCommand::MoveBigWordRightEnd { select, .. } + | EditCommand::MoveViWordLeft { select, .. } + | EditCommand::MoveViWordRightStart { select, .. } + | EditCommand::MoveViWordRightEnd { select, .. } | EditCommand::MoveRightUntil { select, .. } | EditCommand::MoveRightBefore { select, .. } | EditCommand::MoveLeftUntil { select, .. } @@ -712,8 +766,11 @@ impl EditCommand { | EditCommand::CutBigWordLeft | EditCommand::CutWordRight | EditCommand::CutBigWordRight + | EditCommand::CutWordRightEnd + | EditCommand::CutBigWordRightEnd | EditCommand::CutWordRightToNext | EditCommand::CutBigWordRightToNext + | EditCommand::CutViWordLeft | EditCommand::PasteCutBufferBefore | EditCommand::PasteCutBufferAfter | EditCommand::UppercaseWord @@ -750,8 +807,11 @@ impl EditCommand { | EditCommand::CopyCurrentLine | EditCommand::CopyWordLeft | EditCommand::CopyBigWordLeft + | EditCommand::CopyViWordLeft | EditCommand::CopyWordRight | EditCommand::CopyBigWordRight + | EditCommand::CopyWordRightEnd + | EditCommand::CopyBigWordRightEnd | EditCommand::CopyWordRightToNext | EditCommand::CopyBigWordRightToNext | EditCommand::CopyLeft From 315dd8613c08804d9d35134246c087d96398662c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Tue, 21 Apr 2026 00:36:26 -0400 Subject: [PATCH 2/5] refactor: deprecate ambiguous word_* functions in favor of emacs_* aliases Add emacs_word_right_index, emacs_word_right_end_index, emacs_word_right_start_index, emacs_word_left_index, and emacs_current_word_range as the canonical Emacs-path functions. Deprecate the unprefixed word_* variants to encourage callers to choose explicitly between emacs_* and vi_* word boundaries. All internal callers migrated to emacs_* variants. --- src/core_editor/editor.rs | 18 +++---- src/core_editor/line_buffer.rs | 86 ++++++++++++++++++++++++++++------ 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 6d72686e..2c432664 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -481,7 +481,7 @@ impl Editor { fn cut_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let word_start = self.line_buffer.word_left_index(); + let word_start = self.line_buffer.emacs_word_left_index(); self.cut_range(word_start..insertion_offset); } @@ -499,7 +499,7 @@ impl Editor { fn cut_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let word_end = self.line_buffer.word_right_index(); + let word_end = self.line_buffer.emacs_word_right_index(); self.cut_range(insertion_offset..word_end); } @@ -769,7 +769,7 @@ impl Editor { } fn move_word_left(&mut self, select: bool) { - self.move_to_position(self.line_buffer.word_left_index(), select); + self.move_to_position(self.line_buffer.emacs_word_left_index(), select); } fn move_big_word_left(&mut self, select: bool) { @@ -777,11 +777,11 @@ impl Editor { } fn move_word_right(&mut self, select: bool) { - self.move_to_position(self.line_buffer.word_right_index(), select); + self.move_to_position(self.line_buffer.emacs_word_right_index(), select); } fn move_word_right_start(&mut self, select: bool) { - self.move_to_position(self.line_buffer.word_right_start_index(), select); + self.move_to_position(self.line_buffer.emacs_word_right_start_index(), select); } fn move_big_word_right_start(&mut self, select: bool) { @@ -789,7 +789,7 @@ impl Editor { } fn move_word_right_end(&mut self, select: bool) { - self.move_to_position(self.line_buffer.word_right_end_index(), select); + self.move_to_position(self.line_buffer.emacs_word_right_end_index(), select); } fn move_big_word_right_end(&mut self, select: bool) { @@ -890,7 +890,7 @@ impl Editor { self.line_buffer .current_whitespace_range() .unwrap_or_else(|| { - let word_range = self.line_buffer.current_word_range(); + let word_range = self.line_buffer.emacs_current_word_range(); match text_object_scope { TextObjectScope::Inner => word_range, TextObjectScope::Around => { @@ -1073,7 +1073,7 @@ impl Editor { 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(); + let word_start = self.line_buffer.emacs_word_left_index(); self.copy_range(word_start..insertion_offset); } @@ -1091,7 +1091,7 @@ impl Editor { 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(); + let word_end = self.line_buffer.emacs_word_right_index(); self.copy_range(insertion_offset..word_end); } diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index c22ca057..a1733011 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -191,7 +191,18 @@ impl LineBuffer { } /// Cursor position *behind* the next word to the right + /// + /// Uses Unicode UAX #29 word boundaries. For Vi-style word boundaries, + /// use [`vi_word_right_index`](Self::vi_word_right_index). + #[deprecated(note = "use emacs_word_right_index or vi_word_right_index for clarity")] pub fn word_right_index(&self) -> usize { + self.emacs_word_right_index() + } + + /// Cursor position *behind* the next Emacs word to the right + /// + /// Uses Unicode UAX #29 word boundaries. + pub fn emacs_word_right_index(&self) -> usize { self.lines[self.insertion_point..] .split_word_bound_indices() .find(|(_, word)| !is_whitespace_str(word)) @@ -222,7 +233,18 @@ impl LineBuffer { } /// Cursor position *at end of* the next word to the right + /// + /// Uses Unicode UAX #29 word boundaries. For Vi-style word boundaries, + /// use [`vi_word_right_end_index`](Self::vi_word_right_end_index). + #[deprecated(note = "use emacs_word_right_end_index or vi_word_right_end_index for clarity")] pub fn word_right_end_index(&self) -> usize { + self.emacs_word_right_end_index() + } + + /// Cursor position *at end of* the next Emacs word to the right + /// + /// Uses Unicode UAX #29 word boundaries. + pub fn emacs_word_right_end_index(&self) -> usize { self.lines[self.insertion_point..] .split_word_bound_indices() .find_map(|(i, word)| { @@ -284,7 +306,20 @@ impl LineBuffer { } /// Cursor position *in front of* the next word to the right + /// + /// Uses Unicode UAX #29 word boundaries. For Vi-style word boundaries, + /// use [`vi_word_right_start_index`](Self::vi_word_right_start_index). + #[deprecated( + note = "use emacs_word_right_start_index or vi_word_right_start_index for clarity" + )] pub fn word_right_start_index(&self) -> usize { + self.emacs_word_right_start_index() + } + + /// Cursor position *in front of* the next Emacs word to the right + /// + /// Uses Unicode UAX #29 word boundaries. + pub fn emacs_word_right_start_index(&self) -> usize { self.lines[self.insertion_point..] .split_word_bound_indices() .find(|(i, word)| *i != 0 && !is_whitespace_str(word)) @@ -315,7 +350,18 @@ impl LineBuffer { } /// Cursor position *in front of* the next word to the left + /// + /// Uses Unicode UAX #29 word boundaries. For Vi-style word boundaries, + /// use [`vi_word_left_index`](Self::vi_word_left_index). + #[deprecated(note = "use emacs_word_left_index or vi_word_left_index for clarity")] pub fn word_left_index(&self) -> usize { + self.emacs_word_left_index() + } + + /// Cursor position *in front of* the next Emacs word to the left + /// + /// Uses Unicode UAX #29 word boundaries. + pub fn emacs_word_left_index(&self) -> usize { self.lines[..self.insertion_point] .split_word_bound_indices() .rfind(|(_, word)| !is_whitespace_str(word)) @@ -415,7 +461,7 @@ impl LineBuffer { /// Move cursor position *in front of* the next word to the left pub fn move_word_left(&mut self) { - self.insertion_point = self.word_left_index(); + self.insertion_point = self.emacs_word_left_index(); } /// Move cursor position *in front of* the next WORD to the left @@ -425,12 +471,12 @@ impl LineBuffer { /// Move cursor position *behind* the next word to the right pub fn move_word_right(&mut self) { - self.insertion_point = self.word_right_index(); + self.insertion_point = self.emacs_word_right_index(); } /// Move cursor position to the start of the next word pub fn move_word_right_start(&mut self) { - self.insertion_point = self.word_right_start_index(); + self.insertion_point = self.emacs_word_right_start_index(); } /// Move cursor position to the start of the next WORD @@ -440,7 +486,7 @@ impl LineBuffer { /// Move cursor position to the end of the next word pub fn move_word_right_end(&mut self) { - self.insertion_point = self.word_right_end_index(); + self.insertion_point = self.emacs_word_right_end_index(); } /// Move cursor position to the end of the next WORD @@ -561,8 +607,20 @@ impl LineBuffer { } /// Gets the range of the word the current edit position is pointing to + /// + /// Uses Unicode UAX #29 word boundaries. For Vi-style word boundaries, + /// use [`vi_current_word_range`](Self::vi_current_word_range). + #[deprecated(note = "use emacs_current_word_range or vi_current_word_range for clarity")] + #[allow(deprecated)] pub fn current_word_range(&self) -> Range { - let right_index = self.word_right_index(); + self.emacs_current_word_range() + } + + /// Gets the range of the Emacs word the current edit position is pointing to + /// + /// Uses Unicode UAX #29 word boundaries. + pub fn emacs_current_word_range(&self) -> Range { + let right_index = self.emacs_word_right_index(); let left_index = self.lines[..right_index] .split_word_bound_indices() .rfind(|(_, word)| !is_whitespace_str(word)) @@ -602,7 +660,7 @@ impl LineBuffer { /// Uppercases the current word pub fn uppercase_word(&mut self) { - let change_range = self.current_word_range(); + let change_range = self.emacs_current_word_range(); let uppercased = self.get_buffer()[change_range.clone()].to_uppercase(); self.replace_range(change_range, &uppercased); self.move_word_right(); @@ -610,7 +668,7 @@ impl LineBuffer { /// Lowercases the current word pub fn lowercase_word(&mut self) { - let change_range = self.current_word_range(); + let change_range = self.emacs_current_word_range(); let uppercased = self.get_buffer()[change_range.clone()].to_lowercase(); self.replace_range(change_range, &uppercased); self.move_word_right(); @@ -678,22 +736,22 @@ impl LineBuffer { /// Deletes one word to the left pub fn delete_word_left(&mut self) { - let left_word_index = self.word_left_index(); + let left_word_index = self.emacs_word_left_index(); self.clear_range(left_word_index..self.insertion_point()); self.insertion_point = left_word_index; } /// Deletes one word to the right pub fn delete_word_right(&mut self) { - let right_word_index = self.word_right_index(); + let right_word_index = self.emacs_word_right_index(); self.clear_range(self.insertion_point()..right_word_index); } /// Swaps current word with word on right pub fn swap_words(&mut self) { - let word_1_range = self.current_word_range(); + let word_1_range = self.emacs_current_word_range(); self.move_word_right(); - let word_2_range = self.current_word_range(); + let word_2_range = self.emacs_current_word_range(); if word_1_range != word_2_range { self.move_word_left(); @@ -1912,7 +1970,7 @@ mod test { let mut line_buffer = buffer_with(input); line_buffer.set_insertion_point(position); - let index = line_buffer.word_left_index(); + let index = line_buffer.emacs_word_left_index(); assert_eq!(index, expected); } @@ -1947,7 +2005,7 @@ mod test { let mut line_buffer = buffer_with(input); line_buffer.set_insertion_point(position); - let index = line_buffer.word_right_start_index(); + let index = line_buffer.emacs_word_right_start_index(); assert_eq!(index, expected); } @@ -1984,7 +2042,7 @@ mod test { let mut line_buffer = buffer_with(input); line_buffer.set_insertion_point(position); - let index = line_buffer.word_right_end_index(); + let index = line_buffer.emacs_word_right_end_index(); assert_eq!(index, expected); } From 5c2a4e248b20d0f66ee17e5b9d922704912919e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Tue, 21 Apr 2026 02:33:14 -0400 Subject: [PATCH 3/5] test(vi): add unit tests for vi_word_* functions Cover basic cases (spaces, single word, empty string, edge positions) for vi_word_right_start_index, vi_word_right_end_index, vi_word_left_index, vi_word_right_index, and vi_current_word_range. --- src/core_editor/line_buffer.rs | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index a1733011..d2f57bf0 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -2183,6 +2183,87 @@ mod test { assert_eq!(line_buffer.vi_word_left_index(), expected); } + // ── Vi word motion tests (basic / spaces / edge cases) ───────────── + + #[rstest] + #[case("abc def ghi", 0, 4)] // basic: skip 'abc' + space + #[case("abc def ghi", 4, 8)] // from second word + #[case("abc", 0, 3)] // single word → end + #[case("", 0, 0)] // empty string + #[case(" abc", 0, 2)] // leading spaces + fn test_vi_word_right_start_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + assert_eq!(line_buffer.vi_word_right_start_index(), expected); + } + + #[rstest] + #[case("abc def ghi", 0, 2)] // end of 'abc' + #[case("abc", 1, 2)] // from middle of word + #[case("abc", 2, 2)] // already at end + #[case("abc def", 2, 6)] // skip to end of 'def' + fn test_vi_word_right_end_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + assert_eq!(line_buffer.vi_word_right_end_index(), expected); + } + + #[rstest] + #[case("abc def ghi", 10, 8)] // from last word → start of 'ghi' + #[case("abc def ghi", 8, 4)] // from 'ghi' → start of 'def' + #[case("abc", 3, 0)] // single word → start + #[case("abc", 0, 0)] // already at start + fn test_vi_word_left_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + assert_eq!(line_buffer.vi_word_left_index(), expected); + } + + #[rstest] + #[case("abc def", 0, 3)] // first word end + #[case("abc def", 4, 7)] // second word end + #[case("abc", 0, 3)] // single word + #[case("a.b", 0, 1)] // stops at punctuation boundary + #[case("abc...def", 0, 3)] // stops at punctuation boundary + fn test_vi_word_right_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + assert_eq!(line_buffer.vi_word_right_index(), expected); + } + + #[rstest] + #[case("abc def", 0, 0..3)] // cursor on 'a' → word "abc" + #[case("abc def", 4, 4..7)] // cursor on 'd' → word "def" + #[case("abc.def", 0, 0..3)] // cursor on 'a' → word "abc" (not "abc.def") + #[case("abc.def", 3, 3..4)] // cursor on '.' → word "." + #[case("abc.def", 4, 4..7)] // cursor on 'd' → word "def" + #[case("a.b.c", 2, 2..3)] // cursor on 'b' → word "b" + fn test_vi_current_word_range( + #[case] input: &str, + #[case] position: usize, + #[case] expected: Range, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + assert_eq!(line_buffer.vi_current_word_range(), expected); + } + #[rstest] #[case("abc def", 0, 3)] #[case("abc def ghi", 3, 7)] From 036afbe3db4eedd3487773896705b588498abdeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Tue, 21 Apr 2026 02:43:42 -0400 Subject: [PATCH 4/5] refactor(vi): rename Cut/CopyWordRightEnd to Cut/CopyViWordRightEnd Add Vi prefix to CutWordRightEnd, CutBigWordRightEnd, CopyWordRightEnd, and CopyBigWordRightEnd for consistency with other Vi-specific EditCommand variants. --- src/core_editor/editor.rs | 20 ++++++++++---------- src/edit_mode/vi/command.rs | 32 ++++++++++++++++---------------- src/edit_mode/vi/parser.rs | 12 ++++++------ src/enums.rs | 24 ++++++++++++------------ 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 2c432664..f116f885 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -113,8 +113,8 @@ impl Editor { EditCommand::CutViWordLeft => self.cut_vi_word_left(), EditCommand::CutWordRight => self.cut_word_right(), EditCommand::CutBigWordRight => self.cut_big_word_right(), - EditCommand::CutWordRightEnd => self.cut_word_right_end(), - EditCommand::CutBigWordRightEnd => self.cut_big_word_right_end(), + EditCommand::CutViWordRightEnd => self.cut_vi_word_right_end(), + EditCommand::CutViBigWordRightEnd => self.cut_vi_big_word_right_end(), EditCommand::CutWordRightToNext => self.cut_word_right_to_next(), EditCommand::CutBigWordRightToNext => self.cut_big_word_right_to_next(), EditCommand::PasteCutBufferBefore => self.insert_cut_buffer_before(), @@ -159,8 +159,8 @@ impl Editor { EditCommand::CopyViWordLeft => self.copy_vi_word_left(), EditCommand::CopyWordRight => self.copy_word_right(), EditCommand::CopyBigWordRight => self.copy_big_word_right(), - EditCommand::CopyWordRightEnd => self.copy_word_right_end(), - EditCommand::CopyBigWordRightEnd => self.copy_big_word_right_end(), + EditCommand::CopyViWordRightEnd => self.copy_vi_word_right_end(), + EditCommand::CopyViBigWordRightEnd => self.copy_vi_big_word_right_end(), EditCommand::CopyWordRightToNext => self.copy_word_right_to_next(), EditCommand::CopyBigWordRightToNext => self.copy_big_word_right_to_next(), EditCommand::CopyRightUntil(c) => self.copy_right_until_char(*c, false, true), @@ -511,7 +511,7 @@ impl Editor { /// Cut from cursor to end of next word (inclusive). /// Used by Vi `de` — the `e` motion is inclusive. - fn cut_word_right_end(&mut self) { + fn cut_vi_word_right_end(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); let word_end = self.line_buffer.vi_word_right_end_index(); let inclusive_end = self.line_buffer.grapheme_right_index_from_pos(word_end); @@ -520,7 +520,7 @@ impl Editor { /// Cut from cursor to end of next WORD (inclusive). /// Used by Vi `dE` — the `E` motion is inclusive. - fn cut_big_word_right_end(&mut self) { + fn cut_vi_big_word_right_end(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); let word_end = self.line_buffer.big_word_right_end_index(); let inclusive_end = self.line_buffer.grapheme_right_index_from_pos(word_end); @@ -1103,7 +1103,7 @@ impl Editor { /// Copy from cursor to end of next word (inclusive). /// Used by Vi `ye` — the `e` motion is inclusive. - pub(crate) fn copy_word_right_end(&mut self) { + pub(crate) fn copy_vi_word_right_end(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); let word_end = self.line_buffer.vi_word_right_end_index(); let inclusive_end = self.line_buffer.grapheme_right_index_from_pos(word_end); @@ -1112,7 +1112,7 @@ impl Editor { /// Copy from cursor to end of next WORD (inclusive). /// Used by Vi `yE` — the `E` motion is inclusive. - pub(crate) fn copy_big_word_right_end(&mut self) { + pub(crate) fn copy_vi_big_word_right_end(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); let word_end = self.line_buffer.big_word_right_end_index(); let inclusive_end = self.line_buffer.grapheme_right_index_from_pos(word_end); @@ -1269,7 +1269,7 @@ mod test { #[case("a.b.c", 2, "b.", "a.c")] // de on 'a' in "abc.def": cuts "abc" (end of word 'abc') #[case("abc.def", 0, "abc", ".def")] - fn test_cut_word_right_end( + fn test_cut_vi_word_right_end( #[case] input: &str, #[case] position: usize, #[case] expected_cut: &str, @@ -1278,7 +1278,7 @@ mod test { let mut editor = editor_with(input); editor.line_buffer.set_insertion_point(position); - editor.cut_word_right_end(); + editor.cut_vi_word_right_end(); assert_eq!(editor.get_buffer(), expected_buffer); assert_eq!(editor.cut_buffer.get().0, expected_cut); diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index d77df0e7..2fbf5bdf 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -336,11 +336,11 @@ impl Command { EditCommand::CutBigWordRightToNext, )]), Motion::NextWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightEnd)]) - } - Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRightEnd)]) + Some(vec![ReedlineOption::Edit(EditCommand::CutViWordRightEnd)]) } + Motion::NextBigWordEnd => Some(vec![ReedlineOption::Edit( + EditCommand::CutViBigWordRightEnd, + )]), Motion::PreviousWord => { Some(vec![ReedlineOption::Edit(EditCommand::CutViWordLeft)]) } @@ -398,17 +398,17 @@ impl Command { ReedlineOption::Edit(EditCommand::CutToLineEnd), ]), Motion::NextWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightEnd)]) - } - Motion::NextBigWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRightEnd)]) + Some(vec![ReedlineOption::Edit(EditCommand::CutViWordRightEnd)]) } + Motion::NextBigWord => Some(vec![ReedlineOption::Edit( + EditCommand::CutViBigWordRightEnd, + )]), Motion::NextWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightEnd)]) - } - Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRightEnd)]) + Some(vec![ReedlineOption::Edit(EditCommand::CutViWordRightEnd)]) } + Motion::NextBigWordEnd => Some(vec![ReedlineOption::Edit( + EditCommand::CutViBigWordRightEnd, + )]), Motion::PreviousWord => { Some(vec![ReedlineOption::Edit(EditCommand::CutViWordLeft)]) } @@ -477,11 +477,11 @@ impl Command { EditCommand::CopyBigWordRightToNext, )]), Motion::NextWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRightEnd)]) - } - Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordRightEnd)]) + Some(vec![ReedlineOption::Edit(EditCommand::CopyViWordRightEnd)]) } + Motion::NextBigWordEnd => Some(vec![ReedlineOption::Edit( + EditCommand::CopyViBigWordRightEnd, + )]), Motion::PreviousWord => { Some(vec![ReedlineOption::Edit(EditCommand::CopyViWordLeft)]) } diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index d860c075..4fde9b41 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -526,14 +526,14 @@ mod tests { ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] #[case(&['d', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightToNext])]))] #[case(&['d', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightToNext])]))] - #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightEnd])]))] + #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViWordRightEnd])]))] #[case(&['d', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViWordLeft])]))] #[case(&['d', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft])]))] #[case(&['c', 'c'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart { select: false }]), ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd]), ReedlineEvent::Repaint]))] - #[case(&['c', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightEnd]), ReedlineEvent::Repaint]))] - #[case(&['c', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightEnd]), ReedlineEvent::Repaint]))] - #[case(&['c', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightEnd]), ReedlineEvent::Repaint]))] + #[case(&['c', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViWordRightEnd]), ReedlineEvent::Repaint]))] + #[case(&['c', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViBigWordRightEnd]), ReedlineEvent::Repaint]))] + #[case(&['c', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViWordRightEnd]), ReedlineEvent::Repaint]))] #[case(&['c', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViWordLeft]), ReedlineEvent::Repaint]))] #[case(&['c', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft]), ReedlineEvent::Repaint]))] #[case(&['d', 'h'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Backspace])]))] @@ -541,7 +541,7 @@ mod tests { #[case(&['2', 'd', 'd'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] // #[case(&['d', 'j'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] // #[case(&['d', 'k'], ReedlineEvent::Multiple(vec![ReedlineEvent::Up, ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] - #[case(&['d', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightEnd])]))] + #[case(&['d', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViBigWordRightEnd])]))] #[case(&['d', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart])]))] #[case(&['d', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart])]))] #[case(&['d', '$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd])]))] @@ -551,7 +551,7 @@ mod tests { #[case(&['d', 'T', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftBefore('a')])]))] #[case(&['d', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromStartLinewise { leave_blank_line: false }])]))] #[case(&['d', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToEndLinewise { leave_blank_line: false }])]))] - #[case(&['c', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightEnd]), ReedlineEvent::Repaint]))] + #[case(&['c', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutViBigWordRightEnd]), ReedlineEvent::Repaint]))] #[case(&['c', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart]), ReedlineEvent::Repaint]))] #[case(&['c', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart]), ReedlineEvent::Repaint]))] #[case(&['c', '$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd]), ReedlineEvent::Repaint]))] diff --git a/src/enums.rs b/src/enums.rs index b7bcdb92..07384fca 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -321,10 +321,10 @@ pub enum EditCommand { CutBigWordRight, /// Cut from the insertion point to the end of the next word (inclusive, for Vi `de`) - CutWordRightEnd, + CutViWordRightEnd, /// Cut from the insertion point to the end of the next WORD (inclusive, for Vi `dE`) - CutBigWordRightEnd, + CutViBigWordRightEnd, /// Cut the word right of the insertion point and any following space CutWordRightToNext, @@ -461,10 +461,10 @@ pub enum EditCommand { CopyBigWordRight, /// Copy from the insertion point to the end of the next word (inclusive, for Vi `ye`) - CopyWordRightEnd, + CopyViWordRightEnd, /// Copy from the insertion point to the end of the next WORD (inclusive, for Vi `yE`) - CopyBigWordRightEnd, + CopyViBigWordRightEnd, /// Copy the word right of the insertion point and any following space CopyWordRightToNext, @@ -637,8 +637,8 @@ impl Display for EditCommand { EditCommand::CutBigWordLeft => write!(f, "CutBigWordLeft"), EditCommand::CutWordRight => write!(f, "CutWordRight"), EditCommand::CutBigWordRight => write!(f, "CutBigWordRight"), - EditCommand::CutWordRightEnd => write!(f, "CutWordRightEnd"), - EditCommand::CutBigWordRightEnd => write!(f, "CutBigWordRightEnd"), + EditCommand::CutViWordRightEnd => write!(f, "CutViWordRightEnd"), + EditCommand::CutViBigWordRightEnd => write!(f, "CutViBigWordRightEnd"), EditCommand::CutWordRightToNext => write!(f, "CutWordRightToNext"), EditCommand::CutBigWordRightToNext => write!(f, "CutBigWordRightToNext"), EditCommand::CutViWordLeft => write!(f, "CutViWordLeft"), @@ -675,8 +675,8 @@ impl Display for EditCommand { EditCommand::CopyViWordLeft => write!(f, "CopyViWordLeft"), EditCommand::CopyWordRight => write!(f, "CopyWordRight"), EditCommand::CopyBigWordRight => write!(f, "CopyBigWordRight"), - EditCommand::CopyWordRightEnd => write!(f, "CopyWordRightEnd"), - EditCommand::CopyBigWordRightEnd => write!(f, "CopyBigWordRightEnd"), + EditCommand::CopyViWordRightEnd => write!(f, "CopyViWordRightEnd"), + EditCommand::CopyViBigWordRightEnd => write!(f, "CopyViBigWordRightEnd"), EditCommand::CopyWordRightToNext => write!(f, "CopyWordRightToNext"), EditCommand::CopyBigWordRightToNext => write!(f, "CopyBigWordRightToNext"), EditCommand::CopyLeft => write!(f, "CopyLeft"), @@ -766,8 +766,8 @@ impl EditCommand { | EditCommand::CutBigWordLeft | EditCommand::CutWordRight | EditCommand::CutBigWordRight - | EditCommand::CutWordRightEnd - | EditCommand::CutBigWordRightEnd + | EditCommand::CutViWordRightEnd + | EditCommand::CutViBigWordRightEnd | EditCommand::CutWordRightToNext | EditCommand::CutBigWordRightToNext | EditCommand::CutViWordLeft @@ -810,8 +810,8 @@ impl EditCommand { | EditCommand::CopyViWordLeft | EditCommand::CopyWordRight | EditCommand::CopyBigWordRight - | EditCommand::CopyWordRightEnd - | EditCommand::CopyBigWordRightEnd + | EditCommand::CopyViWordRightEnd + | EditCommand::CopyViBigWordRightEnd | EditCommand::CopyWordRightToNext | EditCommand::CopyBigWordRightToNext | EditCommand::CopyLeft From 7fba73654ccabb8a91ad84eac0ad8b3dda58985f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Tue, 21 Apr 2026 03:01:47 -0400 Subject: [PATCH 5/5] test(vi): add tests for Vi inclusive cut/copy functions in editor Add tests for cut_vi_big_word_right_end (dE), copy_vi_word_right_end (ye), and copy_vi_big_word_right_end (yE) which contain non-trivial inclusive-end logic via grapheme_right_index_from_pos. --- src/core_editor/editor.rs | 108 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index f116f885..b60ca085 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1284,6 +1284,114 @@ mod test { assert_eq!(editor.cut_buffer.get().0, expected_cut); } + #[rstest] + // dE from start of "abc-def ghi": cuts "abc-def" (inclusive end of WORD) + #[case("abc-def ghi", 0, "abc-def", " ghi")] + // dE from start of "abc def": cuts "abc" (inclusive end of WORD) + #[case("abc def", 0, "abc", " def")] + // dE on '-' in "abc-def ghi": cuts "-def" (rest of WORD) + #[case("abc-def ghi", 3, "-def", "abc ghi")] + fn test_cut_vi_big_word_right_end( + #[case] input: &str, + #[case] position: usize, + #[case] expected_cut: &str, + #[case] expected_buffer: &str, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + + editor.cut_vi_big_word_right_end(); + + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // ye from start of "abc def": yanks "abc" (inclusive), buffer unchanged + #[case("abc def", 0, "abc", "abc def")] + // ye on 'a' in "abc.def": yanks "abc" + #[case("abc.def", 0, "abc", "abc.def")] + // ye on '.' in "a.b.c": yanks ".b" + #[case("a.b.c", 1, ".b", "a.b.c")] + fn test_copy_vi_word_right_end( + #[case] input: &str, + #[case] position: usize, + #[case] expected_copy: &str, + #[case] expected_buffer: &str, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + + editor.copy_vi_word_right_end(); + + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.cut_buffer.get().0, expected_copy); + } + + #[rstest] + // yE from start of "abc-def ghi": yanks "abc-def" (inclusive), buffer unchanged + #[case("abc-def ghi", 0, "abc-def", "abc-def ghi")] + // yE from '-' in "abc-def ghi": yanks "-def" + #[case("abc-def ghi", 3, "-def", "abc-def ghi")] + fn test_copy_vi_big_word_right_end( + #[case] input: &str, + #[case] position: usize, + #[case] expected_copy: &str, + #[case] expected_buffer: &str, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + + editor.copy_vi_big_word_right_end(); + + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.cut_buffer.get().0, expected_copy); + } + + #[rstest] + // db from end of "abc.def": cuts "def" + #[case("abc.def", 7, "def", "abc.")] + // db from '.' in "abc.def": cuts "." + #[case("abc.def", 4, ".", "abcdef")] + // db from end of "abc def": cuts "def" + #[case("abc def", 7, "def", "abc ")] + fn test_cut_vi_word_left( + #[case] input: &str, + #[case] position: usize, + #[case] expected_cut: &str, + #[case] expected_buffer: &str, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + + editor.cut_vi_word_left(); + + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // yb from end of "abc.def": yanks "def", buffer unchanged + #[case("abc.def", 7, "def", "abc.def")] + // yb from '.' in "abc.def": yanks "." + #[case("abc.def", 4, ".", "abc.def")] + // yb from end of "abc def": yanks "def" + #[case("abc def", 7, "def", "abc def")] + fn test_copy_vi_word_left( + #[case] input: &str, + #[case] position: usize, + #[case] expected_copy: &str, + #[case] expected_buffer: &str, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + + editor.copy_vi_word_left(); + + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.cut_buffer.get().0, expected_copy); + } + #[rstest] #[case("hello world", 0, 'l', 1, false, "lo world")] #[case("hello world", 0, 'l', 1, true, "llo world")]