From 7d0baa3e072caca76441bc6249c23f45b54eed62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Mon, 4 May 2026 17:16:45 -0400 Subject: [PATCH 1/7] fix(vi): clamp cursor to last grapheme in normal mode In Vi normal mode the cursor sits *on* a character and must not go past the last grapheme. Add a clamp_cursor() method to Editor that enforces this invariant whenever the edit mode is Vi normal. Call clamp_cursor() after every EditCommand in run_edit_command(), and in set_buffer(), move_to_end() and move_to_line_end() so that history loading and end-of-line motions always respect the bound. Also adjust is_cursor_at_buffer_end() to treat "cursor on the last grapheme" as equivalent to "at buffer end" in Vi normal mode, so that prefix history search and inline hints continue to work. Fixes #694 (pt 2), #788 (pt 4) --- src/core_editor/editor.rs | 142 +++++++++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 0d7673ba..a67e1686 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -201,6 +201,9 @@ impl Editor { EditCommand::CutTextObject { text_object } => self.cut_text_object(*text_object), EditCommand::CopyTextObject { text_object } => self.copy_text_object(*text_object), } + + self.clamp_cursor(); + if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.clear_selection(); } @@ -251,6 +254,17 @@ impl Editor { pub fn set_edit_mode(&mut self, mode: PromptEditMode) { self.edit_mode = mode; } + + pub(crate) fn clamp_cursor(&mut self) { + if matches!(self.edit_mode, PromptEditMode::Vi(PromptViMode::Normal)) { + let len = self.get_buffer().len(); + if !self.get_buffer().is_empty() && self.line_buffer.insertion_point() >= len { + let last = self.line_buffer.grapheme_left_index_from_pos(len); + self.line_buffer.set_insertion_point(last); + } + } + } + fn move_to_position(&mut self, position: usize, select: bool) { self.update_selection_anchor(select); self.line_buffer.set_insertion_point(position) @@ -287,6 +301,7 @@ impl Editor { pub(crate) fn set_buffer(&mut self, buffer: String, undo_behavior: UndoBehavior) { self.line_buffer.set_buffer(buffer); self.update_undo_state(undo_behavior); + self.clamp_cursor(); } pub(crate) fn insertion_point(&self) -> usize { @@ -306,7 +321,13 @@ impl Editor { } pub(crate) fn is_cursor_at_buffer_end(&self) -> bool { - self.line_buffer.insertion_point() == self.get_buffer().len() + let pos = self.line_buffer.insertion_point(); + let len = self.get_buffer().len(); + if pos == len { + return true; + } + matches!(self.edit_mode, PromptEditMode::Vi(PromptViMode::Normal)) + && self.line_buffer.grapheme_right_index() == len } pub(crate) fn reset_undo_stack(&mut self) { @@ -321,6 +342,7 @@ impl Editor { pub(crate) fn move_to_end(&mut self, select: bool) { self.update_selection_anchor(select); self.line_buffer.move_to_end(); + self.clamp_cursor(); } pub(crate) fn move_to_line_start(&mut self, select: bool) { @@ -336,6 +358,7 @@ impl Editor { pub(crate) fn move_to_line_end(&mut self, select: bool) { self.update_selection_anchor(select); self.line_buffer.move_to_line_end(); + self.clamp_cursor(); } fn undo(&mut self) { @@ -2168,4 +2191,121 @@ mod test { assert_eq!(bracket_result, expected_bracket); assert_eq!(quote_result, expected_quote); } + + #[test] + fn test_clamp_cursor_vi_normal_mode() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(5); + editor.clamp_cursor(); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn test_clamp_cursor_does_not_affect_insert_mode() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Insert)); + editor.line_buffer.set_insertion_point(5); + editor.clamp_cursor(); + assert_eq!(editor.insertion_point(), 5); + } + + #[test] + fn test_clamp_cursor_empty_buffer() { + let mut editor = editor_with(""); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.clamp_cursor(); + assert_eq!(editor.insertion_point(), 0); + } + + #[test] + fn test_clamp_cursor_already_in_bounds() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(2); + editor.clamp_cursor(); + assert_eq!(editor.insertion_point(), 2); + } + + #[test] + fn test_is_cursor_at_buffer_end_vi_normal() { + let mut editor = editor_with("ls"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + + // Cursor on last char 's' (position 1) — at end in normal mode + editor.line_buffer.set_insertion_point(1); + assert!(editor.is_cursor_at_buffer_end()); + + // Cursor on first char 'l' (position 0) — not at end + editor.line_buffer.set_insertion_point(0); + assert!(!editor.is_cursor_at_buffer_end()); + + // Cursor after last char (position 2) — still at end + editor.line_buffer.set_insertion_point(2); + assert!(editor.is_cursor_at_buffer_end()); + } + + #[test] + fn test_is_cursor_at_buffer_end_insert_mode() { + let mut editor = editor_with("ls"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Insert)); + + // Cursor on last char 's' — NOT at end in insert mode + editor.line_buffer.set_insertion_point(1); + assert!(!editor.is_cursor_at_buffer_end()); + + // Cursor after last char — at end + editor.line_buffer.set_insertion_point(2); + assert!(editor.is_cursor_at_buffer_end()); + } + + #[test] + fn test_move_to_end_vi_normal_clamps() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(0); + editor.move_to_end(false); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn test_move_to_line_end_vi_normal_clamps() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(0); + editor.move_to_line_end(false); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn test_set_buffer_vi_normal_clamps() { + let mut editor = editor_with(""); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.set_buffer("hello".to_string(), UndoBehavior::CreateUndoPoint); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn test_move_right_vi_normal_clamps() { + let mut editor = editor_with("ab"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(0); + + editor.run_edit_command(&EditCommand::MoveRight { select: false }); + assert_eq!(editor.insertion_point(), 1); + + // Should not advance past last character + editor.run_edit_command(&EditCommand::MoveRight { select: false }); + assert_eq!(editor.insertion_point(), 1); + } + + #[test] + fn test_clamp_cursor_multibyte() { + let mut editor = editor_with("café"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point("café".len()); + editor.clamp_cursor(); + // 'é' is 2 bytes, so last grapheme starts at byte index 3 + assert_eq!(editor.insertion_point(), "caf".len()); + } } From e5611b57e841103679292b39b6c3e1b9bf457b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Mon, 4 May 2026 17:16:53 -0400 Subject: [PATCH 2/7] fix(vi): move cursor left when exiting insert mode with Escape In Vi, insert mode places the cursor between characters while normal mode places it on a character. Pressing Escape to leave insert mode must move the cursor one position left to match standard Vi behavior. When Escape is pressed from normal mode the cursor is not moved. Fixes #694 (pt 1), #788 (pt 1) --- src/edit_mode/vi/mod.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 721e45d9..643fa771 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -156,8 +156,20 @@ impl EditMode for Vi { } (_, KeyModifiers::NONE, KeyCode::Esc) => { self.cache.clear(); + let was_insert = self.mode == ViMode::Insert; self.mode = ViMode::Normal; - ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + // In Vi, exiting insert mode moves the cursor one position + // left because insert mode places the cursor between + // characters while normal mode places it on a character. + if was_insert { + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Esc, + ReedlineEvent::Repaint, + ]) + } else { + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + } } (ViMode::Normal | ViMode::Visual, _, _) => self .normal_keybindings @@ -238,6 +250,28 @@ mod test { .unwrap(); let result = vi.parse_event(esc); + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Esc, + ReedlineEvent::Repaint + ]) + ); + assert!(matches!(vi.mode, ViMode::Normal)); + } + + #[test] + fn esc_from_normal_mode_does_not_move_cursor() { + let mut vi = Vi { + mode: ViMode::Normal, + ..Default::default() + }; + let esc = + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(); + let result = vi.parse_event(esc); + assert_eq!( result, ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) From 649a6bb8a0d606d10649c2a992fb81d1fdabaf44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Mon, 4 May 2026 17:17:00 -0400 Subject: [PATCH 3/7] fix(vi): propagate mode and clamp cursor on Escape event ReedlineEvent::Esc is handled outside of run_edit_commands(), so the editor's edit_mode was never updated on mode transitions triggered by Escape. Sync the editor's mode and clamp the cursor at that point so that the cursor invariant is enforced immediately. --- src/engine.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/engine.rs b/src/engine.rs index ac7ccf84..28121f1e 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1229,6 +1229,8 @@ impl Reedline { ReedlineEvent::Esc => { self.deactivate_menus(); self.editor.clear_selection(); + self.editor.set_edit_mode(self.edit_mode.edit_mode()); + self.editor.clamp_cursor(); Ok(EventStatus::Handled) } ReedlineEvent::CtrlD => { From c014513ba0095ec241a7aed0b863346cd8edab2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Fri, 8 May 2026 18:47:47 -0400 Subject: [PATCH 4/7] fix(vi): enforce cursor invariant via ClampCursorToNormalMode command Add EditCommand::ClampCursorToNormalMode, implemented in Editor as a call to clamp_cursor(). The Vi normal mode emits this command after every editing operation (delete, yank, paste, undo, replace) via command.rs and to_reedline_with_motion. Add clamp_cursor() guards to move_right, move_word_right* and move_big_word_right* in Editor so that pure motion commands also respect the Vi normal mode cursor invariant. LineBuffer remains fully mode-agnostic. The Vi mode owns its own cursor invariant at the dispatch boundary, per the design discussed in nushell/reedline#1066. --- src/core_editor/editor.rs | 23 +-- src/edit_mode/vi/command.rs | 371 ++++++++++++++++++++++-------------- src/edit_mode/vi/mod.rs | 22 +++ src/enums.rs | 5 + 4 files changed, 257 insertions(+), 164 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index a67e1686..e8bf5840 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -200,10 +200,9 @@ impl Editor { EditCommand::CopyAroundPair { left, right } => self.copy_around_pair(*left, *right), EditCommand::CutTextObject { text_object } => self.cut_text_object(*text_object), EditCommand::CopyTextObject { text_object } => self.copy_text_object(*text_object), + EditCommand::ClampCursorToNormalMode => self.clamp_cursor(), } - self.clamp_cursor(); - if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.clear_selection(); } @@ -658,6 +657,7 @@ impl Editor { fn move_right(&mut self, select: bool) { self.update_selection_anchor(select); self.line_buffer.move_right(); + self.clamp_cursor(); } fn select_all(&mut self) { @@ -768,22 +768,27 @@ impl Editor { fn move_word_right(&mut self, select: bool) { self.move_to_position(self.line_buffer.word_right_index(), select); + self.clamp_cursor(); } fn move_word_right_start(&mut self, select: bool) { self.move_to_position(self.line_buffer.word_right_start_index(), select); + self.clamp_cursor(); } fn move_big_word_right_start(&mut self, select: bool) { self.move_to_position(self.line_buffer.big_word_right_start_index(), select); + self.clamp_cursor(); } fn move_word_right_end(&mut self, select: bool) { self.move_to_position(self.line_buffer.word_right_end_index(), select); + self.clamp_cursor(); } fn move_big_word_right_end(&mut self, select: bool) { self.move_to_position(self.line_buffer.big_word_right_end_index(), select); + self.clamp_cursor(); } fn insert_char(&mut self, c: char) { @@ -2285,20 +2290,6 @@ mod test { assert_eq!(editor.insertion_point(), 4); } - #[test] - fn test_move_right_vi_normal_clamps() { - let mut editor = editor_with("ab"); - editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); - editor.line_buffer.set_insertion_point(0); - - editor.run_edit_command(&EditCommand::MoveRight { select: false }); - assert_eq!(editor.insertion_point(), 1); - - // Should not advance past last character - editor.run_edit_command(&EditCommand::MoveRight { select: false }); - assert_eq!(editor.insertion_point(), 1); - } - #[test] fn test_clamp_cursor_multibyte() { let mut editor = editor_with("café"); diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 18999e5c..7bdddc5b 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -230,11 +230,23 @@ impl Command { })], Self::NewlineAbove => vec![ReedlineOption::Edit(EditCommand::InsertNewlineAbove)], Self::NewlineBelow => vec![ReedlineOption::Edit(EditCommand::InsertNewlineBelow)], - Self::PasteAfter => vec![ReedlineOption::Edit(EditCommand::PasteCutBufferAfter)], - Self::PasteBefore => vec![ReedlineOption::Edit(EditCommand::PasteCutBufferBefore)], - Self::Undo => vec![ReedlineOption::Edit(EditCommand::Undo)], + Self::PasteAfter => vec![ + ReedlineOption::Edit(EditCommand::PasteCutBufferAfter), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], + Self::PasteBefore => vec![ + ReedlineOption::Edit(EditCommand::PasteCutBufferBefore), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], + Self::Undo => vec![ + ReedlineOption::Edit(EditCommand::Undo), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], Self::ChangeToLineEnd => vec![ReedlineOption::Edit(EditCommand::ClearToLineEnd)], - Self::DeleteToEnd => vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)], + Self::DeleteToEnd => vec![ + ReedlineOption::Edit(EditCommand::CutToLineEnd), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], Self::AppendToEnd => vec![ReedlineOption::Edit(EditCommand::MoveToLineEnd { select: false, })], @@ -244,13 +256,22 @@ impl Command { Self::RewriteCurrentLine => vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)], Self::DeleteChar => { if vi_state.mode == ViMode::Visual { - vec![ReedlineOption::Edit(EditCommand::CutSelection)] + vec![ + ReedlineOption::Edit(EditCommand::CutSelection), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } else { - vec![ReedlineOption::Edit(EditCommand::CutChar)] + vec![ + ReedlineOption::Edit(EditCommand::CutChar), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } } Self::ReplaceChar(c) => { - vec![ReedlineOption::Edit(EditCommand::ReplaceChar(*c))] + vec![ + ReedlineOption::Edit(EditCommand::ReplaceChar(*c)), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::SubstituteCharWithInsert => { if vi_state.mode == ViMode::Visual { @@ -260,9 +281,16 @@ impl Command { } } Self::HistorySearch => vec![ReedlineOption::Event(ReedlineEvent::SearchHistory)], - Self::Switchcase => vec![ReedlineOption::Edit(EditCommand::SwitchcaseChar)], + Self::Switchcase => vec![ + ReedlineOption::Edit(EditCommand::SwitchcaseChar), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], // Whenever a motion is required to finish the command we must be in visual mode - Self::Delete | Self::Change => vec![ReedlineOption::Edit(EditCommand::CutSelection)], + Self::Delete => vec![ + ReedlineOption::Edit(EditCommand::CutSelection), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], + Self::Change => vec![ReedlineOption::Edit(EditCommand::CutSelection)], Self::Yank => vec![ReedlineOption::Edit(EditCommand::CopySelection)], Self::Incomplete => vec![ReedlineOption::Incomplete], Self::RepeatLastAction => match &vi_state.previous { @@ -276,28 +304,40 @@ impl Command { })] } Self::DeleteInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CutInsidePair { - left: *left, - right: *right, - })] + vec![ + ReedlineOption::Edit(EditCommand::CutInsidePair { + left: *left, + right: *right, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::YankInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CopyInsidePair { - left: *left, - right: *right, - })] + vec![ + ReedlineOption::Edit(EditCommand::CopyInsidePair { + left: *left, + right: *right, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::DeleteAroundPair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CutAroundPair { - left: *left, - right: *right, - })] + vec![ + ReedlineOption::Edit(EditCommand::CutAroundPair { + left: *left, + right: *right, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::YankAroundPair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CopyAroundPair { - left: *left, - right: *right, - })] + vec![ + ReedlineOption::Edit(EditCommand::CopyAroundPair { + left: *left, + right: *right, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::ChangeTextObject { text_object } => { vec![ReedlineOption::Edit(EditCommand::CutTextObject { @@ -305,17 +345,26 @@ impl Command { })] } Self::YankTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::CopyTextObject { - text_object: *text_object, - })] + vec![ + ReedlineOption::Edit(EditCommand::CopyTextObject { + text_object: *text_object, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::DeleteTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::CutTextObject { - text_object: *text_object, - })] + vec![ + ReedlineOption::Edit(EditCommand::CutTextObject { + text_object: *text_object, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::SwapCursorAndAnchor => { - vec![ReedlineOption::Edit(EditCommand::SwapCursorAndAnchor)] + vec![ + ReedlineOption::Edit(EditCommand::SwapCursorAndAnchor), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } } } @@ -326,66 +375,79 @@ impl Command { vi_state: &mut Vi, ) -> Option> { match self { - Self::Delete => match motion { - Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)]), - Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)]), - Motion::NextWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightToNext)]) - } - Motion::NextBigWord => Some(vec![ReedlineOption::Edit( - EditCommand::CutBigWordRightToNext, - )]), - Motion::NextWordEnd => Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]), - Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) - } - Motion::PreviousWord => Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]), - Motion::PreviousBigWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) - } - Motion::RightUntil(c) => { - vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CutRightUntil(*c))]) - } - Motion::RightBefore(c) => { - vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CutRightBefore(*c))]) - } - Motion::LeftUntil(c) => { - vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CutLeftUntil(*c))]) - } - Motion::LeftBefore(c) => { - vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CutLeftBefore(*c))]) - } - Motion::Start => Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]), - Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( - EditCommand::CutFromLineNonBlankStart, - )]), - Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), - Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), - Motion::Up => None, - Motion::Down => None, - Motion::FirstLine => Some(vec![ReedlineOption::Edit( - EditCommand::CutFromStartLinewise { - leave_blank_line: false, - }, - )]), - Motion::LastLine => { - Some(vec![ReedlineOption::Edit(EditCommand::CutToEndLinewise { - leave_blank_line: false, - })]) - } - Motion::ReplayCharSearch => vi_state - .last_char_search - .as_ref() - .map(|char_search| vec![ReedlineOption::Edit(char_search.to_cut())]), - Motion::ReverseCharSearch => vi_state - .last_char_search - .as_ref() - .map(|char_search| vec![ReedlineOption::Edit(char_search.reverse().to_cut())]), - }, + Self::Delete => { + let op = match motion { + Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)]), + Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)]), + Motion::NextWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightToNext)]) + } + Motion::NextBigWord => Some(vec![ReedlineOption::Edit( + EditCommand::CutBigWordRightToNext, + )]), + Motion::NextWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]) + } + Motion::NextBigWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) + } + Motion::PreviousWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]) + } + Motion::PreviousBigWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) + } + Motion::RightUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightUntil(*c))]) + } + Motion::RightBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightBefore(*c))]) + } + Motion::LeftUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftUntil(*c))]) + } + Motion::LeftBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftBefore(*c))]) + } + Motion::Start => { + Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]) + } + Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( + EditCommand::CutFromLineNonBlankStart, + )]), + Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), + Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), + Motion::Up => None, + Motion::Down => None, + Motion::FirstLine => Some(vec![ReedlineOption::Edit( + EditCommand::CutFromStartLinewise { + leave_blank_line: false, + }, + )]), + Motion::LastLine => { + Some(vec![ReedlineOption::Edit(EditCommand::CutToEndLinewise { + leave_blank_line: false, + })]) + } + Motion::ReplayCharSearch => vi_state + .last_char_search + .as_ref() + .map(|char_search| vec![ReedlineOption::Edit(char_search.to_cut())]), + Motion::ReverseCharSearch => { + vi_state.last_char_search.as_ref().map(|char_search| { + vec![ReedlineOption::Edit(char_search.reverse().to_cut())] + }) + } + }; + op.map(|mut vec| { + vec.push(ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode)); + vec + }) + } Self::Change => { let op = match motion { Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)]), @@ -461,62 +523,75 @@ impl Command { vec }) } - Self::Yank => match motion { - Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CopyToLineEnd)]), - Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CopyCurrentLine)]), - Motion::NextWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRightToNext)]) - } - Motion::NextBigWord => Some(vec![ReedlineOption::Edit( - EditCommand::CopyBigWordRightToNext, - )]), - Motion::NextWordEnd => Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRight)]), - Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordRight)]) - } - Motion::PreviousWord => Some(vec![ReedlineOption::Edit(EditCommand::CopyWordLeft)]), - Motion::PreviousBigWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordLeft)]) - } - Motion::RightUntil(c) => { - vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CopyRightUntil(*c))]) - } - Motion::RightBefore(c) => { - vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CopyRightBefore(*c))]) - } - Motion::LeftUntil(c) => { - vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CopyLeftUntil(*c))]) - } - Motion::LeftBefore(c) => { - vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CopyLeftBefore(*c))]) - } - Motion::Start => Some(vec![ReedlineOption::Edit(EditCommand::CopyFromLineStart)]), - Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( - EditCommand::CopyFromLineNonBlankStart, - )]), - Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::CopyLeft)]), - Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::CopyRight)]), - Motion::Up => None, - Motion::Down => None, - Motion::FirstLine => Some(vec![ReedlineOption::Edit( - EditCommand::CopyFromStartLinewise, - )]), - Motion::LastLine => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyToEndLinewise)]) - } - Motion::ReplayCharSearch => vi_state - .last_char_search - .as_ref() - .map(|char_search| vec![ReedlineOption::Edit(char_search.to_copy())]), - Motion::ReverseCharSearch => vi_state - .last_char_search - .as_ref() - .map(|char_search| vec![ReedlineOption::Edit(char_search.reverse().to_copy())]), - }, + Self::Yank => { + let op = match motion { + Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CopyToLineEnd)]), + Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CopyCurrentLine)]), + Motion::NextWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRightToNext)]) + } + Motion::NextBigWord => Some(vec![ReedlineOption::Edit( + EditCommand::CopyBigWordRightToNext, + )]), + Motion::NextWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRight)]) + } + Motion::NextBigWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordRight)]) + } + Motion::PreviousWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyWordLeft)]) + } + Motion::PreviousBigWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordLeft)]) + } + Motion::RightUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CopyRightUntil(*c))]) + } + Motion::RightBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CopyRightBefore(*c))]) + } + Motion::LeftUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CopyLeftUntil(*c))]) + } + Motion::LeftBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CopyLeftBefore(*c))]) + } + Motion::Start => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyFromLineStart)]) + } + Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( + EditCommand::CopyFromLineNonBlankStart, + )]), + Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::CopyLeft)]), + Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::CopyRight)]), + Motion::Up => None, + Motion::Down => None, + Motion::FirstLine => Some(vec![ReedlineOption::Edit( + EditCommand::CopyFromStartLinewise, + )]), + Motion::LastLine => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyToEndLinewise)]) + } + Motion::ReplayCharSearch => vi_state + .last_char_search + .as_ref() + .map(|char_search| vec![ReedlineOption::Edit(char_search.to_copy())]), + Motion::ReverseCharSearch => { + vi_state.last_char_search.as_ref().map(|char_search| { + vec![ReedlineOption::Edit(char_search.reverse().to_copy())] + }) + } + }; + op.map(|mut vec| { + vec.push(ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode)); + vec + }) + } _ => None, } } diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 643fa771..64b5608e 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -242,6 +242,28 @@ mod test { use super::*; use pretty_assertions::assert_eq; + #[test] + fn normal_mode_delete_char_emits_clamp() { + let mut vi = Vi { + mode: ViMode::Normal, + ..Default::default() + }; + let xkey = ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))) + .unwrap(); + let event = vi.parse_event(xkey); + match &event { + ReedlineEvent::Multiple(events) => { + assert!(events.contains(&ReedlineEvent::Edit(vec![ + EditCommand::ClampCursorToNormalMode + ]))); + } + _ => panic!("Expected Multiple event, got {:?}", event), + } + } + #[test] fn esc_leads_to_normal_mode_test() { let mut vi = Vi::default(); diff --git a/src/enums.rs b/src/enums.rs index 46228e0b..13883901 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -506,6 +506,9 @@ pub enum EditCommand { /// The text object to operate on text_object: TextObject, }, + /// Clamp the cursor to the last grapheme if in Vi normal mode. + /// Emitted by the Vi mode to enforce its cursor-on-character invariant. + ClampCursorToNormalMode, } // FIXME: This implementation makes no sense to be here, and should be removed in a future version @@ -647,6 +650,7 @@ impl Display for EditCommand { EditCommand::CopyAroundPair { .. } => write!(f, "CopyAroundPair Value: "), EditCommand::CutTextObject { .. } => write!(f, "CutTextObject Value: "), EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject Value: "), + EditCommand::ClampCursorToNormalMode => write!(f, "ClampCursorToNormalMode"), } } } @@ -680,6 +684,7 @@ impl EditCommand { | EditCommand::MoveLeftBefore { select, .. } => { EditType::MoveCursor { select: *select } } + EditCommand::ClampCursorToNormalMode => EditType::MoveCursor { select: false }, EditCommand::SwapCursorAndAnchor => EditType::MoveCursor { select: true }, EditCommand::SelectAll => EditType::MoveCursor { select: true }, From 644b0df1656fbc139ea217e77dd66878e27b1ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Fri, 8 May 2026 18:47:51 -0400 Subject: [PATCH 5/7] test(vi): update parser tests to expect ClampCursorToNormalMode All Vi normal mode editing commands now emit ClampCursorToNormalMode after their edit command. Update the expected ReedlineEvent values in test_reedline_move, test_reedline_memory_move and test_reedline_move_in_visual_mode accordingly. --- src/edit_mode/vi/parser.rs | 87 +++++++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index da0839cd..2c837e65 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -512,23 +512,29 @@ mod tests { #[case(&['0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart{select:false}])]))] #[case(&['$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd{select:false}])]))] #[case(&['i'], ReedlineEvent::Multiple(vec![ReedlineEvent::Repaint]))] - #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])]))] + #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['2', 'p'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), - ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]) + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), ]))] - #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo])]))] + #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['2', 'u'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::Undo]), - ReedlineEvent::Edit(vec![EditCommand::Undo]) + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), ]))] #[case(&['d', 'd'], ReedlineEvent::Multiple(vec![ - 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', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft])]))] + ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ]))] + #[case(&['d', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightToNext]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightToNext]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordLeft]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[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]))] @@ -536,21 +542,26 @@ mod tests { #[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', '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', 'h'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Backspace]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'l'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Delete]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['2', 'd', 'd'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ]))] // #[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', '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])]))] - #[case(&['d', 'f', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')])]))] - #[case(&['d', 't', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightBefore('a')])]))] - #[case(&['d', 'F', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')])]))] - #[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(&['d', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', '$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'f', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 't', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightBefore('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'F', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'T', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftBefore('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromStartLinewise { leave_blank_line: false }]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToEndLinewise { leave_blank_line: false }]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['c', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight]), 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]))] @@ -567,9 +578,9 @@ mod tests { ReedlineEvent::Edit(vec![EditCommand::CutToEndLinewise { leave_blank_line: true }]), ReedlineEvent::Repaint, ]))] - #[case(&['y', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromLineNonBlankStart])]))] - #[case(&['y', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromStartLinewise])]))] - #[case(&['y', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyToEndLinewise])]))] + #[case(&['y', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromLineNonBlankStart]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['y', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromStartLinewise]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['y', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyToEndLinewise]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] fn test_reedline_move(#[case] input: &[char], #[case] expected: ReedlineEvent) { let mut vi = Vi::default(); let res = vi_parse(input); @@ -583,10 +594,10 @@ mod tests { #[case(&['f', 'a'], &[','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveLeftUntil{c: 'a', select: false}])]))] #[case(&['F', 'a'], &[','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveRightUntil{c: 'a', select: false}])]))] #[case(&['F', 'a'], &[';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveLeftUntil{c: 'a', select: false}])]))] - #[case(&['f', 'a'], &['d', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')])]))] - #[case(&['f', 'a'], &['d', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')])]))] - #[case(&['F', 'a'], &['d', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')])]))] - #[case(&['F', 'a'], &['d', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')])]))] + #[case(&['f', 'a'], &['d', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['f', 'a'], &['d', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['F', 'a'], &['d', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['F', 'a'], &['d', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['f', 'a'], &['c', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Repaint]))] #[case(&['f', 'a'], &['c', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')]), ReedlineEvent::Repaint]))] #[case(&['F', 'a'], &['c', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Repaint]))] @@ -643,18 +654,24 @@ mod tests { #[case(&['0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart{select:true}])]))] #[case(&['$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd{select:true}])]))] #[case(&['i'], ReedlineEvent::Multiple(vec![ReedlineEvent::Repaint]))] - #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])]))] + #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['2', 'p'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), - ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]) + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), ]))] - #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo])]))] + #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['2', 'u'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::Undo]), - ReedlineEvent::Edit(vec![EditCommand::Undo]) + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), ]))] #[case(&['d'], ReedlineEvent::Multiple(vec![ - ReedlineEvent::Edit(vec![EditCommand::CutSelection])]))] + ReedlineEvent::Edit(vec![EditCommand::CutSelection]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ]))] fn test_reedline_move_in_visual_mode(#[case] input: &[char], #[case] expected: ReedlineEvent) { let mut vi = Vi { mode: ViMode::Visual, From d0a0829d499cfbcb9e13934e30888714182a9039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Fri, 8 May 2026 19:06:32 -0400 Subject: [PATCH 6/7] test(vi): add ClampCursorToNormalMode emission tests Add tests verifying that Vi normal mode commands emit ClampCursorToNormalMode when they should (x, p, P, u, dd, D, ~, r, dw, db, d$, yw) and do not emit it when they transition to insert mode (i, a, cc, cw, s). --- src/edit_mode/vi/mod.rs | 149 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 64b5608e..8e742fb2 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -398,4 +398,153 @@ mod test { assert_eq!(result, ReedlineEvent::None); } + + /// Helper: parse a sequence of chars in Vi normal mode and return the event. + fn parse_normal_keys(keys: &[char]) -> ReedlineEvent { + let mut vi = Vi { + mode: ViMode::Normal, + ..Default::default() + }; + let mut event = ReedlineEvent::None; + for &c in keys { + event = vi.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char(c), + if c.is_ascii_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }, + ))) + .unwrap(), + ); + } + event + } + + /// Assert that parsing the given keys in normal mode produces an event + /// containing ClampCursorToNormalMode. + fn assert_emits_clamp(keys: &[char]) { + let event = parse_normal_keys(keys); + match &event { + ReedlineEvent::Multiple(events) => { + assert!( + events.contains(&ReedlineEvent::Edit(vec![ + EditCommand::ClampCursorToNormalMode + ])), + "Expected ClampCursorToNormalMode in {:?} for keys {:?}", + events, + keys + ); + } + _ => panic!( + "Expected Multiple event for keys {:?}, got {:?}", + keys, event + ), + } + } + + /// Assert that parsing the given keys in normal mode produces an event + /// that does NOT contain ClampCursorToNormalMode. + fn assert_does_not_emit_clamp(keys: &[char]) { + let event = parse_normal_keys(keys); + let contains_clamp = match &event { + ReedlineEvent::Multiple(events) => events.contains(&ReedlineEvent::Edit(vec![ + EditCommand::ClampCursorToNormalMode, + ])), + ReedlineEvent::Edit(cmds) => cmds.contains(&EditCommand::ClampCursorToNormalMode), + _ => false, + }; + assert!( + !contains_clamp, + "Expected NO ClampCursorToNormalMode for keys {:?}, got {:?}", + keys, event + ); + } + + // --- Commands that SHOULD emit ClampCursorToNormalMode --- + + #[test] + fn normal_mode_paste_after_emits_clamp() { + assert_emits_clamp(&['p']); + } + + #[test] + fn normal_mode_paste_before_emits_clamp() { + assert_emits_clamp(&['P']); + } + + #[test] + fn normal_mode_undo_emits_clamp() { + assert_emits_clamp(&['u']); + } + + #[test] + fn normal_mode_delete_line_emits_clamp() { + assert_emits_clamp(&['d', 'd']); + } + + #[test] + fn normal_mode_delete_to_end_emits_clamp() { + assert_emits_clamp(&['D']); + } + + #[test] + fn normal_mode_switchcase_emits_clamp() { + assert_emits_clamp(&['~']); + } + + #[test] + fn normal_mode_replace_char_emits_clamp() { + assert_emits_clamp(&['r', 'a']); + } + + // --- Commands with motion that SHOULD emit ClampCursorToNormalMode --- + + #[test] + fn normal_mode_delete_word_emits_clamp() { + assert_emits_clamp(&['d', 'w']); + } + + #[test] + fn normal_mode_delete_back_word_emits_clamp() { + assert_emits_clamp(&['d', 'b']); + } + + #[test] + fn normal_mode_delete_to_line_end_emits_clamp() { + assert_emits_clamp(&['d', '$']); + } + + #[test] + fn normal_mode_yank_word_emits_clamp() { + assert_emits_clamp(&['y', 'w']); + } + + // --- Commands that should NOT emit ClampCursorToNormalMode --- + + #[test] + fn normal_mode_insert_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['i']); + } + + #[test] + fn normal_mode_append_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['a']); + } + + #[test] + fn normal_mode_change_line_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['c', 'c']); + } + + #[test] + fn normal_mode_change_word_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['c', 'w']); + } + + #[test] + fn normal_mode_substitute_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['s']); + } } From c7fd612843d7ef09552ff16d0ec03f5e3e8b7377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=A9saulniers?= Date: Fri, 8 May 2026 21:53:50 -0400 Subject: [PATCH 7/7] chore: add 'caf' to typos ignore list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit False positive from the multibyte cursor clamping test that uses the string "café" and asserts against "caf".len(). --- .typos.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.typos.toml b/.typos.toml index 805fe638..bc61d59f 100644 --- a/.typos.toml +++ b/.typos.toml @@ -21,3 +21,5 @@ l3ine = "l3ine" 4should = "4should" wr5ap = "wr5ap" ine = "ine" +# For testing multibyte cursor clamping +caf = "caf"