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
117 changes: 106 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,14 @@ impl Editor {
}
}

fn cut_char_left(&mut self) {

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.

Suggested change
fn cut_char_left(&mut self) {
/// 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();
Expand Down Expand Up @@ -1500,7 +1512,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 +1531,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 +2061,75 @@ 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.

#[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_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 +2236,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 +2265,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 +2293,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 +2526,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 +2572,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
11 changes: 9 additions & 2 deletions src/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -741,6 +747,7 @@ impl EditCommand {
| EditCommand::Backspace
| EditCommand::Delete
| EditCommand::CutChar
| EditCommand::CutCharLeft
| EditCommand::InsertString(_)
| EditCommand::InsertNewline
| EditCommand::InsertNewlineAbove
Expand Down Expand Up @@ -779,7 +786,7 @@ impl EditCommand {
| EditCommand::CutRightBefore(_)
| EditCommand::CutLeftUntil(_)
| EditCommand::CutLeftBefore(_)
| EditCommand::CutSelection
| EditCommand::CutSelection { .. }
| EditCommand::Paste
| EditCommand::CutInsidePair { .. }
| EditCommand::CutAroundPair { .. }
Expand Down
Loading