diff --git a/.typos.toml b/.typos.toml index a3059c83..084d0da0 100644 --- a/.typos.toml +++ b/.typos.toml @@ -4,6 +4,7 @@ extend-exclude = ["src/core_editor/line_buffer.rs"] [default.extend-words] # Ignore false-positives iterm = "iterm" +nd = "nd" # For testing completion of the word build bui = "bui" # For testing partial completion diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 0cb1b4ca..4f514971 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -134,6 +134,7 @@ impl Editor { EditCommand::Backspace => self.backspace(), EditCommand::Delete => self.delete(), EditCommand::CutChar => self.cut_char(), + EditCommand::CutCharLeft => self.cut_char_left(), EditCommand::BackspaceWord => self.line_buffer.delete_word_left(), EditCommand::DeleteWord => self.line_buffer.delete_word_right(), EditCommand::Clear => self.line_buffer.clear(), @@ -184,7 +185,9 @@ impl Editor { self.move_left_until_char(*c, true, true, *select) } EditCommand::SelectAll => self.select_all(), - EditCommand::CutSelection => self.cut_selection_to_cut_buffer(), + EditCommand::CutSelection { granularity } => { + self.cut_selection_to_cut_buffer(*granularity) + } EditCommand::CopySelection => self.copy_selection_to_cut_buffer(), EditCommand::Paste => self.paste_cut_buffer(), EditCommand::CopyFromStart => self.copy_from_start(), @@ -677,7 +680,7 @@ impl Editor { fn cut_char(&mut self) { if self.line_buffer.selection_anchor().is_some() { - self.cut_selection_to_cut_buffer(); + self.cut_selection_to_cut_buffer(Granularity::CharWise); } else { let insertion_offset = self.line_buffer.insertion_point(); let next_char = self.line_buffer.grapheme_right_index(); @@ -824,9 +827,10 @@ impl Editor { } } - fn cut_selection_to_cut_buffer(&mut self) { + fn cut_selection_to_cut_buffer(&mut self, granularity: Granularity) { if let Some((start, end)) = self.get_selection() { - self.cut_range(start..end); + let sel = Cursor::new(start, end); + self.operate(sel, OperatorVerb::Cut, granularity); self.clear_selection(); } } @@ -891,6 +895,25 @@ impl Editor { } } + + /// Cut the grapheme left of the caret into the cut buffer (vi `X`). + /// + /// Intended for vi normal mode only, where there is no selection, since + /// visual `X` is dispatched as a linewise `CutSelection`. Bound directly + /// elsewhere, a selection could exist, so clear it first: `X` always means + /// "delete the grapheme before the caret," and clearing avoids leaving a + /// stale anchor pointing into the mutated buffer. The cut is clamped to + /// the current line so `X` never crosses a terminator (unconditional, + /// unlike the `cross_line_cursor`-gated clamp in `resolve_head`). + fn cut_char_left(&mut self) { + self.clear_selection(); + let cur_pos = self.line_buffer.insertion_point(); + let left_index = self.line_buffer.grapheme_left_index(); + if left_index < cur_pos && left_index >= self.line_buffer.current_line_range().start { + self.cut_range(left_index..cur_pos); + } + } + fn delete(&mut self) { if self.line_buffer.selection_anchor().is_some() { self.delete_selection(); @@ -1500,7 +1523,9 @@ mod test { } // head on 'l' (byte 2); Vi-normal selection is inclusive → covers [0,3) assert_eq!(editor.get_selection(), Some((0, 3))); - editor.run_edit_command(&EditCommand::CutSelection); + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::CharWise, + }); assert_eq!(editor.get_buffer(), "lo"); assert_eq!(editor.cut_buffer.get().0, "hel"); } @@ -1517,7 +1542,9 @@ mod test { } // head on 'é' (byte 3); inclusive end extends over both bytes of é → 5 assert_eq!(editor.get_selection(), Some((0, 5))); - editor.run_edit_command(&EditCommand::CutSelection); + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::CharWise, + }); assert_eq!(editor.get_buffer(), "x"); assert_eq!(editor.cut_buffer.get().0, "café"); } @@ -2045,6 +2072,98 @@ mod test { assert_eq!(editor.get_buffer(), "This is a \r\n test"); } + #[test] + fn test_cut_char_left_cuts_char_and_puts_in_buffer() { + let mut editor = editor_with("hello"); + editor.line_buffer.set_insertion_point(3); + editor.run_edit_command(&EditCommand::CutCharLeft); + assert_eq!(editor.get_buffer(), "helo"); + assert_eq!(editor.cut_buffer.get().0, "l"); + } + + #[test] + fn test_cut_char_left_at_beginning_of_line() { + let starting_line = "This is a single line test"; + let mut editor = editor_with(starting_line); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::CutCharLeft); + assert_eq!(editor.get_buffer(), starting_line); + } + + #[test] + fn test_cut_char_left_at_beginning_of_2nd_line() { + let starting_line = "This is a \r\nmulti-line test"; + let mut editor = editor_with(starting_line); + editor.line_buffer.set_insertion_point(12); + editor.run_edit_command(&EditCommand::CutCharLeft); + assert_eq!(editor.get_buffer(), starting_line); + } + + #[test] + fn test_cut_char_left_clears_stale_selection() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(2); + editor.update_selection_anchor(true); + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + // stale selection present + editor.run_edit_command(&EditCommand::CutCharLeft); + assert_eq!(editor.get_buffer(), "helo"); + assert_eq!(editor.cut_buffer.get().0, "l"); + assert!(editor.get_selection().is_none()); + } + + #[test] + fn test_cut_selection_linewise_single_line() { + let mut editor = editor_with("hello world"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(2); + editor.update_selection_anchor(true); + // Select "llo" (positions 2..5 inclusive in normal mode) + for _ in 0..2 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::LineWise, + }); + // X in visual mode should cut the entire line + assert_eq!(editor.get_buffer(), ""); + } + + #[test] + fn test_cut_selection_linewise_multi_line() { + let mut editor = editor_with("first\nsecond\nthird"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + // Place cursor in "second", select a portion + editor.line_buffer.set_insertion_point(8); // 's' of "second" + editor.update_selection_anchor(true); + for _ in 0..2 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::LineWise, + }); + // X in visual mode should cut the entire line(s) covered by the selection + assert_eq!(editor.get_buffer(), "first\nthird"); + } + + #[test] + fn test_cut_selection_linewise_spanning_two_lines() { + let mut editor = editor_with("first\nsecond\nthird"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + // Select from end of "first" to beginning of "second" + editor.line_buffer.set_insertion_point(3); // in "first" + editor.update_selection_anchor(true); + for _ in 0..6 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::LineWise, + }); + // Should cut both "first\n" and "second\n" + assert_eq!(editor.get_buffer(), "third"); + } + #[test] fn test_undo_delete_with_newline() { let mut editor = editor_with("This \n is a test"); @@ -2151,7 +2270,9 @@ mod test { // Should select "his " (from position 1 to 5, inclusive of char at position 4) assert_eq!(editor.get_selection(), Some((1, 5))); - editor.run_edit_command(&EditCommand::CutSelection); + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::CharWise, + }); // After cutting, should have "Tis some test content" (removed "his ") assert_eq!(editor.get_buffer(), "Tis some test content"); @@ -2178,7 +2299,9 @@ mod test { assert_eq!(editor.get_selection(), Some((0, 5))); // should include character at position 4 // Now simulate pressing 'c' - this should cut the selection - editor.run_edit_command(&EditCommand::CutSelection); + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::CharWise, + }); // Should have " world" left (removed "hello") assert_eq!(editor.get_buffer(), " world"); @@ -2204,7 +2327,9 @@ mod test { assert_eq!(editor.get_selection(), Some((0, 5))); // inclusive selection // Now simulate pressing 'c' - this should cut the selection - editor.run_edit_command(&EditCommand::CutSelection); + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::CharWise, + }); // Should have " world" left (removed "hello" including the 'o') assert_eq!(editor.get_buffer(), " world"); @@ -2435,7 +2560,9 @@ mod test { assert_eq!(editor.line_buffer().selection_anchor(), Some(0)); assert_eq!(editor.get_selection(), Some((0, 5))); // inclusive selection - editor.run_edit_command(&EditCommand::CutSelection); + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::CharWise, + }); assert_eq!(editor.get_buffer(), " world"); assert_eq!(editor.insertion_point(), 0); @@ -2479,7 +2606,9 @@ mod test { // Simulate vi 'c' command: mode switches to insert then cuts selection editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Insert)); - editor.run_edit_command(&EditCommand::CutSelection); + editor.run_edit_command(&EditCommand::CutSelection { + granularity: Granularity::CharWise, + }); assert_eq!(editor.get_buffer(), " world"); assert_eq!(editor.insertion_point(), 0); diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 7f2c8924..e79b5099 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -109,6 +109,10 @@ where let _ = input.next(); Some(Command::DeleteChar) } + Some('X') => { + let _ = input.next(); + Some(Command::DeleteCharBackward) + } Some('r') => { let _ = input.next(); input @@ -177,6 +181,7 @@ pub enum Command { Incomplete, Delete, DeleteChar, + DeleteCharBackward, ReplaceChar(char), SubstituteCharWithInsert, NewlineAbove, @@ -241,6 +246,15 @@ impl Command { Self::PrependToStart => vec![ReedlineOption::Edit(EditCommand::MoveToLineStart { select: false, })], + Self::DeleteCharBackward => { + if vi_state.mode == ViMode::Visual { + vec![ReedlineOption::Edit(EditCommand::CutSelection { + granularity: Granularity::LineWise, + })] + } else { + vec![ReedlineOption::Edit(EditCommand::CutCharLeft)] + } + } // `S` ≡ `cc` (vim): change the whole line, keeping the blank line // for insert mode and filling the register linewise. Self::RewriteCurrentLine => vec![ReedlineOption::Edit(EditCommand::Change { @@ -249,7 +263,9 @@ impl Command { })], Self::DeleteChar => { if vi_state.mode == ViMode::Visual { - vec![ReedlineOption::Edit(EditCommand::CutSelection)] + vec![ReedlineOption::Edit(EditCommand::CutSelection { + granularity: Granularity::CharWise, + })] } else { vec![ReedlineOption::Edit(EditCommand::CutChar)] } @@ -259,7 +275,9 @@ impl Command { } Self::SubstituteCharWithInsert => { if vi_state.mode == ViMode::Visual { - vec![ReedlineOption::Edit(EditCommand::CutSelection)] + vec![ReedlineOption::Edit(EditCommand::CutSelection { + granularity: Granularity::CharWise, + })] } else { vec![ReedlineOption::Edit(EditCommand::CutChar)] } @@ -267,7 +285,9 @@ impl Command { Self::HistorySearch => vec![ReedlineOption::Event(ReedlineEvent::SearchHistory)], Self::Switchcase => vec![ReedlineOption::Edit(EditCommand::SwitchcaseChar)], // 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 | Self::Change => vec![ReedlineOption::Edit(EditCommand::CutSelection { + granularity: Granularity::CharWise, + })], Self::Yank => vec![ReedlineOption::Edit(EditCommand::CopySelection)], Self::Incomplete => vec![ReedlineOption::Incomplete], Self::RepeatLastAction => match &vi_state.previous { diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index e4f29923..a670613b 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -562,7 +562,9 @@ mod test { assert_eq!( result, - ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutSelection])]), + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutSelection { + granularity: Granularity::CharWise + }])]), ); assert!(matches!(vi.mode, ViMode::Normal)); } diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index ae1b1f86..ec6624fe 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -236,6 +236,36 @@ mod tests { } } + #[test] + fn test_delete_char_backward() { + let input = ['X']; + let output = vi_parse(&input); + assert_eq!( + output, + ParsedViSequence { + multiplier: None, + command: Some(Command::DeleteCharBackward), + count: None, + motion: ParseResult::Incomplete, + } + ); + } + + #[test] + fn test_two_delete_char_backward() { + let input = ['2', 'X']; + let output = vi_parse(&input); + assert_eq!( + output, + ParsedViSequence { + multiplier: Some(2), + command: Some(Command::DeleteCharBackward), + count: None, + motion: ParseResult::Incomplete, + } + ); + } + #[test] fn test_delete_without_motion() { let input = ['d']; @@ -705,7 +735,7 @@ mod tests { ReedlineEvent::Edit(vec![EditCommand::Undo]) ]))] #[case(&['d'], ReedlineEvent::Multiple(vec![ - ReedlineEvent::Edit(vec![EditCommand::CutSelection])]))] + ReedlineEvent::Edit(vec![EditCommand::CutSelection { granularity: Granularity::CharWise }]),]))] fn test_reedline_move_in_visual_mode(#[case] input: &[char], #[case] expected: ReedlineEvent) { let mut vi = Vi { mode: ViMode::Visual, diff --git a/src/enums.rs b/src/enums.rs index 2469279d..b95c208a 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -416,6 +416,9 @@ pub enum EditCommand { /// Delete in-place from the current insertion point Delete, + /// Cut the grapheme left from the current insertion point + CutCharLeft, + /// Cut the grapheme right from the current insertion point CutChar, @@ -576,7 +579,10 @@ pub enum EditCommand { SelectAll, /// Cut selection to local buffer - CutSelection, + CutSelection { + /// Char-wise span or whole lines. + granularity: Granularity, + }, /// Copy selection to local buffer CopySelection, @@ -741,6 +747,7 @@ impl EditCommand { | EditCommand::Backspace | EditCommand::Delete | EditCommand::CutChar + | EditCommand::CutCharLeft | EditCommand::InsertString(_) | EditCommand::InsertNewline | EditCommand::InsertNewlineAbove @@ -779,7 +786,7 @@ impl EditCommand { | EditCommand::CutRightBefore(_) | EditCommand::CutLeftUntil(_) | EditCommand::CutLeftBefore(_) - | EditCommand::CutSelection + | EditCommand::CutSelection { .. } | EditCommand::Paste | EditCommand::CutInsidePair { .. } | EditCommand::CutAroundPair { .. }