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_delete_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"), ] { 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}"); 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 word_delete_uses_unicode_word_motion_boundaries_across_punctuation_and_rows() { 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"), ("one\ntwo three\n", "d2w", "three\n"), ("one\ntwo three\n", "2wd2b", "three\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_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", "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", ), ] { 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 in ["d2w", "2wd2b"] { 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); assert_eq!(editor.context().text, "keep\nstay\n", "{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"), ] { 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); } }