use std::{ fs, path::PathBuf, sync::atomic::{AtomicU64, Ordering}, }; use vamp::editor::{Cursor, Editor, Input, Mode}; static NEXT: AtomicU64 = AtomicU64::new(0); struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let root = std::env::temp_dir().join(format!( "vamp-normal-editing-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed), )); fs::create_dir_all(&root).unwrap(); Self(root) } fn editor(&self, text: &str) -> Editor { let path = self.0.join("buffer.txt"); fs::write(&path, text).unwrap(); let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn keys(editor: &mut Editor, text: &str) { for ch in text.chars() { editor.handle(Input::Char(ch)); } } #[test] fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() { for operation in ["C", "D", "9C", "9D"] { let fixture = Fixture::new(); let source = "ae\u{301}πŸ‘©β€πŸš€tail\nnext\n"; let mut editor = fixture.editor(source); keys(&mut editor, "2l"); let caret = editor.cursor(); keys(&mut editor, operation); assert_eq!(editor.context().text, "ae\u{301}\nnext\n", "{operation}"); if operation.ends_with('C') { assert_eq!(editor.mode(), Mode::Insert); keys(&mut editor, "replacement"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "ae\u{301}replacement\nnext\n"); } else { assert_eq!(editor.mode(), Mode::Normal); } keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.cursor(), caret); assert!(!editor.dirty()); } } #[test] fn suffix_commands_at_last_grapheme_and_empty_lines_never_remove_newlines() { for operation in ["C", "D"] { for source in ["aπŸ‘©β€πŸš€\nnext\n", "aπŸ‘©β€πŸš€\nnext"] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, "$"); keys(&mut editor, operation); assert_eq!(editor.context().text, source.replacen("aπŸ‘©β€πŸš€", "a", 1)); if editor.mode() == Mode::Insert { editor.handle(Input::Escape); } keys(&mut editor, "u"); assert_eq!(editor.context().text, source); } let fixture = Fixture::new(); let mut editor = fixture.editor("\nnext\n"); keys(&mut editor, operation); assert_eq!(editor.context().text, "\nnext\n"); assert_eq!( editor.mode(), if operation == "C" { Mode::Insert } else { Mode::Normal } ); assert!(!editor.dirty()); } } #[test] fn yank_line_suffix_is_characterwise_and_does_not_create_an_undo_entry() { for operation in ["Y", "8Y"] { let fixture = Fixture::new(); let source = "aπŸ‘©β€πŸš€e\u{301}tail\nnext\n"; let mut editor = fixture.editor(source); keys(&mut editor, "l"); let caret = editor.cursor(); keys(&mut editor, operation); assert_eq!(editor.context().text, source); assert_eq!(editor.cursor(), caret); assert_eq!(editor.mode(), Mode::Normal); assert!(!editor.dirty()); keys(&mut editor, "j0P"); assert_eq!( editor.context().text, "aπŸ‘©β€πŸš€e\u{301}tail\nπŸ‘©β€πŸš€e\u{301}tailnext\n" ); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert!(!editor.dirty()); } } #[test] fn yank_and_cancelled_replace_preserve_the_previous_repeatable_edit() { let fixture = Fixture::new(); let mut editor = fixture.editor("abc\nxyz\n"); keys(&mut editor, "xY3r"); editor.handle(Input::Escape); keys(&mut editor, "j0."); assert_eq!(editor.context().text, "bc\nyz\n"); keys(&mut editor, "u"); assert_eq!(editor.context().text, "bc\nxyz\n"); keys(&mut editor, "u"); assert_eq!(editor.context().text, "abc\nxyz\n"); } #[test] fn replace_consumes_a_literal_character_and_replaces_whole_counted_graphemes() { for (command, expected) in [ ("rx", "axπŸ‘©β€πŸš€z\nnext\n"), ("r2", "a2πŸ‘©β€πŸš€z\nnext\n"), ("rη•Œ", "aη•ŒπŸ‘©β€πŸš€z\nnext\n"), ("r\t", "a\tπŸ‘©β€πŸš€z\nnext\n"), ("2rQ", "aQQz\nnext\n"), ("9rQ", "aQQQ\nnext\n"), ] { let fixture = Fixture::new(); let source = "ae\u{301}πŸ‘©β€πŸš€z\nnext\n"; let mut editor = fixture.editor(source); keys(&mut editor, "l"); keys(&mut editor, command); assert_eq!(editor.context().text, expected, "{command:?}"); assert_eq!(editor.mode(), Mode::Normal); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); assert!(!editor.dirty()); } let fixture = Fixture::new(); let mut editor = fixture.editor("\nnext\n"); keys(&mut editor, "rQ"); assert_eq!(editor.context().text, "\nnext\n"); assert_eq!(editor.mode(), Mode::Normal); assert!(!editor.dirty()); } #[test] fn substitute_deletes_counted_whole_graphemes_and_groups_replacement_with_undo() { for (command, after_delete, expected) in [ ("s", "aπŸ‘©β€πŸš€z\nnext\n", "aQπŸ‘©β€πŸš€z\nnext\n"), ("2s", "az\nnext\n", "aQz\nnext\n"), ("9s", "a\nnext\n", "aQ\nnext\n"), ] { let fixture = Fixture::new(); let source = "ae\u{301}πŸ‘©β€πŸš€z\nnext\n"; let mut editor = fixture.editor(source); keys(&mut editor, "l"); keys(&mut editor, command); assert_eq!(editor.context().text, after_delete, "{command}"); assert_eq!(editor.mode(), Mode::Insert); keys(&mut editor, "Q"); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); assert!(!editor.dirty()); } } #[test] fn word_cut_counts_before_and_after_operator_multiply_in_both_directions() { let source = "one two three four five six seven eight nine\n"; for (position, command, expected) in [ ("", "dw", "two three four five six seven eight nine\n"), ("", "d2w", "three four five six seven eight nine\n"), ("", "2dw", "three four five six seven eight nine\n"), ("", "2d3w", "seven eight nine\n"), ("2w", "db", "one three four five six seven eight nine\n"), ("4w", "d2b", "one two five six seven eight nine\n"), ("4w", "2db", "one two five six seven eight nine\n"), ("6w", "2d3b", "seven eight nine\n"), ] { for operator in ["d", "c"] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, position); let caret = editor.cursor(); let command = command.replacen('d', operator, 1); keys(&mut editor, &command); assert_eq!(editor.context().text, expected, "{position} {command}"); assert_eq!( editor.mode(), if operator == "c" { Mode::Insert } else { Mode::Normal } ); if operator == "c" { keys(&mut editor, "Q"); editor.handle(Input::Escape); } keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.cursor(), caret); assert!(!editor.dirty()); } } } #[test] fn word_changes_preserve_line_boundaries_and_group_replacement_with_undo() { for (source, position, command, after_cut, expected) in [ ("one two\nnext\n", "", "cw", "two\nnext\n", "Qtwo\nnext\n"), ("one two\nnext\n", "w", "cb", "two\nnext\n", "Qtwo\nnext\n"), ("one\ntwo\n", "", "c9w", "\ntwo\n", "Q\ntwo\n"), ("one\ntwo\n", "j0", "c9b", "one\ntwo\n", "one\nQtwo\n"), ("one\n\ntwo\n", "j", "cw", "one\n\ntwo\n", "one\nQ\ntwo\n"), ("one\n\ntwo\n", "j", "cb", "one\n\ntwo\n", "one\nQ\ntwo\n"), ("one\n \ntwo\n", "j", "cw", "one\n\ntwo\n", "one\nQ\ntwo\n"), ("one \nnext\n", "", "cw", "\nnext\n", "Q\nnext\n"), ("e\u{301}lan δΈ–η•Œ fin\n", "", "c2w", "fin\n", "Qfin\n"), ("e\u{301}lan δΈ–η•Œ", "$", "c9b", "η•Œ", "Qη•Œ"), ("πŸ‘©β€πŸš€!!! next\n", "", "cw", "next\n", "Qnext\n"), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, position); let caret = editor.cursor(); keys(&mut editor, command); assert_eq!(editor.context().text, after_cut, "{position} {command}"); assert_eq!(editor.mode(), Mode::Insert); keys(&mut editor, "Q"); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected, "{position} {command}"); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.cursor(), caret); assert!(!editor.dirty()); keys(&mut editor, "U"); assert_eq!(editor.context().text, expected); } } #[test] fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation() { for (source, command, expected) in [ ("e\u{301}lan δΈ–η•Œ fin\n", "d2w", "fin\n"), ("e\u{301}lan δΈ–η•Œ fin\n", "2wd2b", "fin\n"), ("alpha... beta gamma\n", "d2w", "beta gamma\n"), ("alpha... beta gamma\n", "2wd2b", "beta gamma\n"), ("πŸ‘©β€πŸš€!!! next\n", "dw", "next\n"), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, command); assert_eq!(editor.context().text, expected, "{command} on {source:?}"); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); } } #[test] fn word_delete_stops_at_current_line_boundaries() { for (source, position, command, expected) in [ ("one\ntwo three\n", "", "dw", "\ntwo three\n"), ("one\ntwo three\n", "", "d2w", "\ntwo three\n"), ("one two\nthree four\n", "w", "2d3w", "one \nthree four\n"), ("one\ntwo three\n", "2w", "d2b", "one\nthree\n"), ("one\n two three\n", "jw", "2d3b", "one\ntwo three\n"), ("one \ntwo\n", "", "d9w", "\ntwo\n"), ("one\n \ntwo\n", "j", "dw", "one\n\ntwo\n"), ("one\n\ntwo\n", "j", "d9w", "one\n\ntwo\n"), ("one\n\ntwo\n", "j", "d9b", "one\n\ntwo\n"), ("one\ntwo\n", "j0", "db", "one\ntwo\n"), ("one\n two\n", "jl", "db", "one\n two\n"), ( "one\ne\u{301}lan δΈ–η•Œ\nnext\n", "j0", "d9w", "one\n\nnext\n", ), ( "one\ne\u{301}lan δΈ–η•Œ\nnext\n", "j$", "d9b", "one\nη•Œ\nnext\n", ), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, position); let caret = editor.cursor(); keys(&mut editor, command); assert_eq!( editor.context().text, expected, "{position} {command} on {source:?}" ); assert_eq!(editor.mode(), Mode::Normal); if source == expected { assert_eq!(editor.cursor(), caret); assert!(!editor.dirty()); } else { keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.cursor(), caret); assert!(!editor.dirty()); } } } #[test] fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() { let source = "e\u{301}lan δΈ–η•Œ"; for (command, expected) in [ ("d2w".to_owned(), ""), (format!("{}d2w", usize::MAX), ""), ("$d3b".to_owned(), "η•Œ"), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, &command); assert_eq!(editor.context().text, expected, "{command}"); assert_eq!(editor.mode(), Mode::Normal); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert!(!editor.dirty()); } } #[test] fn pending_operator_and_replace_cancellation_clear_counts_without_editing() { for prefix in ["3d", "d2", "2d3", "3c", "c2", "2c3", "3r"] { let fixture = Fixture::new(); let source = "abcd\nnext\n"; let mut editor = fixture.editor(source); keys(&mut editor, prefix); editor.handle(Input::Escape); assert_eq!(editor.context().text, source, "{prefix}"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); assert_eq!(editor.mode(), Mode::Normal); assert!(!editor.dirty()); keys(&mut editor, "x"); assert_eq!(editor.context().text, "bcd\nnext\n", "{prefix}"); } for input in [ Input::Enter, Input::Char('\n'), Input::Paste("ignored".into()), ] { let fixture = Fixture::new(); let mut editor = fixture.editor("abcd\nnext\n"); keys(&mut editor, "3r"); editor.handle(input); keys(&mut editor, "lx"); assert_eq!(editor.context().text, "acd\nnext\n"); assert_eq!(editor.mode(), Mode::Normal); } } #[test] fn new_edit_recipes_repeat_against_the_current_text_and_undo_as_single_changes() { for (source, first, after_first, position, expected) in [ ( "prefix one\nlongprefix two\n", "7lCnew", "prefix new\nlongprefix two\n", "j011l", "prefix new\nlongprefix new\n", ), ( "prefix one\nlongprefix two\n", "7lD", "prefix \nlongprefix two\n", "j011l", "prefix \nlongprefix \n", ), ( "e\u{301}abc\nπŸ‘©β€πŸš€xyz\n", "rQ", "Qabc\nπŸ‘©β€πŸš€xyz\n", "j0", "Qabc\nQxyz\n", ), ( "e\u{301}abc\nπŸ‘©β€πŸš€xyz\n", "sQ", "Qabc\nπŸ‘©β€πŸš€xyz\n", "j0", "Qabc\nQxyz\n", ), ( "one two keep\nthree four stay\n", "d2w", "keep\nthree four stay\n", "j0", "keep\nstay\n", ), ( "one two keep\nthree four stay\n", "2wd2b", "keep\nthree four stay\n", "j02w", "keep\nstay\n", ), ("one\nthree four\n", "d9w", "\nthree four\n", "j0", "\n\n"), ( "one keep\nthree four stay\n", "wd9b", "keep\nthree four stay\n", "j02w", "keep\nstay\n", ), ( "one two keep\nthree four stay\n", "c2wQ", "Qkeep\nthree four stay\n", "j0", "Qkeep\nQstay\n", ), ( "one two keep\nthree four stay\n", "2wc2bQ", "Qkeep\nthree four stay\n", "j02w", "Qkeep\nQstay\n", ), ( "one\nthree four\n", "c9wQ", "Q\nthree four\n", "j0", "Q\nQ\n", ), ( "one keep\nthree four stay\n", "wc9bQ", "Qkeep\nthree four stay\n", "j02w", "Qkeep\nQstay\n", ), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, first); if editor.mode() == Mode::Insert { editor.handle(Input::Escape); } assert_eq!(editor.context().text, after_first, "{first}"); keys(&mut editor, position); keys(&mut editor, "."); assert_eq!(editor.context().text, expected, "{first} then {position}."); assert_eq!(editor.mode(), Mode::Normal); keys(&mut editor, "u"); assert_eq!(editor.context().text, after_first); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert!(!editor.dirty()); } } #[test] fn dd_and_cc_keep_linewise_behavior_with_counts_after_the_operator() { let source = "1\n2\n3\n4\n5\n6\n7\n8\n9\n"; for (command, remaining) in [ ("dd", "2\n3\n4\n5\n6\n7\n8\n9\n"), ("2dd", "3\n4\n5\n6\n7\n8\n9\n"), ("d2d", "3\n4\n5\n6\n7\n8\n9\n"), ("2d3d", "7\n8\n9\n"), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, command); assert_eq!(editor.context().text, remaining, "{command}"); keys(&mut editor, "P"); assert_eq!(editor.context().text, source, "{command} linewise paste"); } for (command, remaining) in [ ("cc", "2\n3\n4\n5\n6\n7\n8\n9\n"), ("2cc", "3\n4\n5\n6\n7\n8\n9\n"), ("c2c", "3\n4\n5\n6\n7\n8\n9\n"), ("2c3c", "7\n8\n9\n"), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, command); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.context().text, format!("\n{remaining}"), "{command}"); keys(&mut editor, "Q"); editor.handle(Input::Escape); assert_eq!(editor.context().text, format!("Q\n{remaining}")); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert!(!editor.dirty()); } } #[test] fn native_multicarets_edit_whole_graphemes_and_yank_matching_suffix_fragments() { let source = "aπŸ‘©β€πŸš€tail\nbe\u{301}rest\n"; for (command, expected) in [ ("D", "a\nb\n"), ("CQ", "aQ\nbQ\n"), ("rQ", "aQtail\nbQrest\n"), ("sQ", "aQtail\nbQrest\n"), ("YP", "aπŸ‘©β€πŸš€tailπŸ‘©β€πŸš€tail\nbe\u{301}reste\u{301}rest\n"), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(source); keys(&mut editor, "l"); editor.handle(Input::Ctrl('e')); let carets = editor.carets(); assert_eq!(carets.len(), 2); keys(&mut editor, command); if editor.mode() == Mode::Insert { editor.handle(Input::Escape); } assert_eq!(editor.context().text, expected, "{command}"); assert_eq!(editor.selection_count(), 2); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.carets(), carets); } for (command, expected) in [ ("d2w", "keep\nstay\n"), ("2wd2b", "keep\nstay\n"), ("d9w", "\n\n"), ("2wd9b", "keep\nstay\n"), ("c2wQ", "Qkeep\nQstay\n"), ("2wc2bQ", "Qkeep\nQstay\n"), ("c9wQ", "Q\nQ\n"), ("2wc9bQ", "Qkeep\nQstay\n"), ] { let fixture = Fixture::new(); let source = "one two keep\nthree four stay\n"; let mut editor = fixture.editor(source); editor.handle(Input::Ctrl('e')); keys(&mut editor, command); if editor.mode() == Mode::Insert { editor.handle(Input::Escape); } assert_eq!(editor.context().text, expected, "{command}"); assert_eq!(editor.selection_count(), 2); keys(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.selection_count(), 2); } } #[test] fn remapped_core_edit_actions_keep_counts_pending_input_and_clipboard_semantics() { for (action, original_key, position, command, expected) in [ ("core.change_to_line_end", "C", "l", "qX", "aX\nnext\n"), ("core.delete_to_line_end", "D", "l", "q", "a\nnext\n"), ( "core.copy_to_line_end", "Y", "l", "qj0P", "abc def ghi\nbc def ghinext\n", ), ( "core.replace_character", "r", "l", "qZ", "aZc def ghi\nnext\n", ), ( "core.substitute_character", "s", "l", "qZ", "aZc def ghi\nnext\n", ), ("core.delete_word_forward", "d", "", "2q", "ghi\nnext\n"), ("core.delete_word_backward", "d", "2w", "2q", "ghi\nnext\n"), ("core.change_word_forward", "c", "", "2qQ", "Qghi\nnext\n"), ( "core.change_word_backward", "c", "2w", "2qQ", "Qghi\nnext\n", ), ] { let fixture = Fixture::new(); let mut editor = fixture.editor("abc def ghi\nnext\n"); editor.bind("normal", original_key, None).unwrap(); editor.bind("normal", "q", Some(action.into())).unwrap(); keys(&mut editor, position); keys(&mut editor, command); if editor.mode() == Mode::Insert { editor.handle(Input::Escape); } assert_eq!(editor.context().text, expected, "{action}"); assert_eq!(editor.mode(), Mode::Normal); } }