Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 140 additions & 11 deletions src/core_editor/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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) {
Comment thread
sim590 marked this conversation as resolved.
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();
Expand Down Expand Up @@ -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");
}
Expand All @@ -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é");
}
Expand Down Expand Up @@ -2045,6 +2072,98 @@ mod test {
assert_eq!(editor.get_buffer(), "This is a \r\n test");
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a positive test for cut_char_left?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also If you want a defensive test for cut char left with a selection present.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized you commented. I'll have a look at the comments and follow-up soon!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great thanks. Now that the release is out we can try to merge it soon and then bump nushells reedline so we can test it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just added the two tests in a9fc508. Your comment was also added verbatim in the branch in 2cbc43a.

#[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");
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 23 additions & 3 deletions src/edit_mode/vi/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -177,6 +181,7 @@ pub enum Command {
Incomplete,
Delete,
DeleteChar,
DeleteCharBackward,
ReplaceChar(char),
SubstituteCharWithInsert,
NewlineAbove,
Expand Down Expand Up @@ -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 {
Expand All @@ -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)]
}
Expand All @@ -259,15 +275,19 @@ 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)]
}
}
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 {
Expand Down
4 changes: 3 additions & 1 deletion src/edit_mode/vi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
32 changes: 31 additions & 1 deletion src/edit_mode/vi/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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,
Expand Down
Loading