use std::{ fs, path::PathBuf, process::Command, sync::atomic::{AtomicU64, Ordering}, thread, time::{Duration, Instant}, }; use vamp::{ diff::{DisplayRow, Focus, Phase}, editor::{Cursor, Editor, Input, Mode, Selection}, session::EditorSession, }; static NEXT: AtomicU64 = AtomicU64::new(0); struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let root = std::env::temp_dir().join(format!( "vamp-navigation-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed), )); fs::create_dir_all(&root).unwrap(); Self(root) } fn file(&self, name: &str, text: &str) -> PathBuf { let path = self.0.join(name); fs::write(&path, text).unwrap(); path } fn editor(&self, text: &str) -> Editor { let mut editor = Editor::open(Some(self.file("buffer.txt", text)), vec!["text".into()]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor } fn session(&self, text: &str, bindings: &str) -> EditorSession { let source = self.file("buffer.txt", text); let config = self.file( "config.rb", &format!( "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n{bindings}\nend\n" ), ); let session = EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap(); assert!(session.editor().startup_diagnostic().is_none()); session } fn git(&self, args: &[&str]) -> String { let output = Command::new("git") .args(args) .current_dir(self.0.join("repo")) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_AUTHOR_NAME", "Navigation Fixture") .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") .env("GIT_COMMITTER_NAME", "Navigation Fixture") .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap().trim().to_owned() } } 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)); } } fn session_keys(session: &mut EditorSession, text: &str) { for ch in text.chars() { session.handle(Input::Char(ch)); } } fn palette_seek(session: &mut EditorSession, action: &str, target: char) { session.handle(Input::Ctrl('p')); session_keys(session, action); session.handle(Input::Enter); assert!(session.editor().palette().is_none()); session.handle(Input::Char(target)); } fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) { let deadline = Instant::now() + Duration::from_secs(10); while !ready(session) { session.poll(); assert!( Instant::now() < deadline, "diff state: {:?}", session.editor().diff() ); if let Some(diff) = session.editor().diff() { assert!(diff.error.is_none(), "{:?}", diff.error); } thread::sleep(Duration::from_millis(1)); } } #[test] fn submitting_search_keeps_each_caret_inside_a_current_match() { let fixture = Fixture::new(); for (text, query, column, end) in [ ("jj next jj", "jj", 0, 2), ("jj next jj", "jj", 1, 2), ("界e\u{301} next 界e\u{301}", "界e\u{301}", 1, 2), ("aaaa next aa", "aa", 1, 3), ] { let mut editor = fixture.editor(text); keys(&mut editor, &"l".repeat(column)); let cursor = editor.cursor(); keys(&mut editor, &format!("/{query}")); editor.handle(Input::Enter); assert_eq!(editor.cursor(), cursor, "{text:?}"); assert_eq!(editor.search_match().unwrap().end_column, end); keys(&mut editor, "n"); assert_ne!(editor.cursor(), cursor); assert!(!editor.dirty()); } let mut editor = fixture.editor("jj next jj\n-- next jj"); editor.handle(Input::Ctrl('e')); keys(&mut editor, "/jj"); editor.handle(Input::Enter); assert_eq!( editor.carets(), [Cursor::default(), Cursor { row: 1, column: 8 }] ); } #[test] fn normal_escape_hides_highlights_but_retains_search_and_rearms_star() { let fixture = Fixture::new(); let mut editor = fixture.editor("cat cat"); keys(&mut editor, "*"); assert_eq!(editor.search_matches_on_row(0).count(), 2); editor.handle(Input::Escape); assert_eq!(editor.search_matches_on_row(0).count(), 0); assert_eq!(editor.search_match(), None); assert_eq!(editor.cursor(), Cursor::default()); keys(&mut editor, "*"); assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(editor.search_matches_on_row(0).count(), 2); keys(&mut editor, "*"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); editor.handle(Input::Escape); keys(&mut editor, "n"); assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(editor.search_matches_on_row(0).count(), 2); keys(&mut editor, "/missing"); editor.handle(Input::Escape); assert_eq!(editor.search_matches_on_row(0).count(), 2); editor.handle(Input::Escape); editor.handle(Input::Char('/')); editor.handle(Input::Enter); assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(editor.search_matches_on_row(0).count(), 2); assert!(!editor.dirty()); } #[test] fn star_search_arms_without_moving_then_navigates_and_rearms_for_a_new_word_or_mode() { let fixture = Fixture::new(); let mut editor = fixture.editor("cat dog cat dog"); keys(&mut editor, "l2*"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); assert_eq!(editor.search_match().unwrap().start, Cursor::default()); keys(&mut editor, "*"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); keys(&mut editor, "w*"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 12 }); keys(&mut editor, "*"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); keys(&mut editor, "/cat"); editor.handle(Input::Enter); assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); keys(&mut editor, "*"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); keys(&mut editor, "*"); assert_eq!(editor.cursor(), Cursor::default()); } #[test] fn star_search_keeps_whole_word_counts_history_and_literal_search_distinct() { let fixture = Fixture::new(); let text = "cat\nscatter\ncat_cat\ncat2\n2cat\nCat\ncat\ncat\n\n"; let mut editor = fixture.editor(text); keys(&mut editor, "l**"); assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 }); assert_eq!(editor.search_match().unwrap().end_column, 3); keys(&mut editor, "nN2*"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 }); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); keys(&mut editor, "/scatter"); editor.handle(Input::Escape); editor.handle(Input::Char('/')); editor.handle(Input::Enter); assert_eq!(editor.cursor(), Cursor::default()); keys(&mut editor, "gb*"); assert_eq!(editor.cursor(), Cursor { row: 9, column: 0 }); keys(&mut editor, "n"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); keys(&mut editor, "/cat"); editor.handle(Input::Enter); keys(&mut editor, "n"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); assert_eq!(editor.search_match().unwrap().end_column, 4); assert_eq!(editor.context().text, text); assert!(!editor.dirty()); } #[test] fn star_search_uses_unicode_words_and_vim_same_line_fallbacks() { let fixture = Fixture::new(); for (text, positioning, row, length) in [ ("e\u{301}lan\ne\u{301}lanx\ne\u{301}lan", "l", 2, 4), ("世界\n世界中\n世界", "l", 2, 2), ("snake_case\nsnake_case2\nsnake_case", "3l", 2, 10), ("\t-- word\nword", "", 1, 4), ("-- word\nword", "", 1, 4), ("word --\n--", "$", 1, 2), (" .* \n.*", "", 1, 2), ("👩‍🚀\n👩\n👩‍🚀", "", 2, 1), ("only", "l", 0, 4), ] { let mut editor = fixture.editor(text); keys(&mut editor, positioning); let origin = editor.cursor(); editor.handle(Input::Char('*')); assert_eq!(editor.cursor(), origin, "{text:?}"); editor.handle(Input::Char('*')); assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}"); assert_eq!( editor.search_match().unwrap().end_column, length, "{text:?}" ); keys(&mut editor, "Nn"); assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}"); assert!(!editor.dirty()); } } #[test] fn star_search_rebinding_moves_all_carets_and_remains_view_local() { let fixture = Fixture::new(); let mut session = fixture.session( "cat cat_cat cat cat\ncat cat_cat cat cat", " config.bind '*', nil\n config.bind 'q', config.core.search_word", ); session.handle(Input::Ctrl('e')); session_keys(&mut session, "2q"); assert_eq!( session.editor().carets(), [Cursor::default(), Cursor { row: 1, column: 0 }] ); session_keys(&mut session, "2q"); assert_eq!( session.editor().carets(), [Cursor { row: 0, column: 16 }, Cursor { row: 1, column: 16 }] ); session_keys(&mut session, "|2N"); assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 }); session_keys(&mut session, "/cat"); session.handle(Input::Enter); session_keys(&mut session, "n"); assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 4 }); session.handle(Input::Alt('h')); session_keys(&mut session, "2N"); assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 }); assert!(!session.editor().dirty()); } #[test] fn counted_character_seeks_move_every_native_caret_independently() { let fixture = Fixture::new(); let text = "xaxbx tail\nx---x----x tail\nx only\n"; let mut editor = fixture.editor(text); editor.handle(Input::Ctrl('e')); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 3); keys(&mut editor, "2fx"); assert_eq!( editor.carets(), [ Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 9 }, Cursor { row: 2, column: 0 }, ] ); keys(&mut editor, "2Fx"); assert_eq!( editor.carets(), [ Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }, Cursor { row: 2, column: 0 }, ] ); assert_eq!(editor.context().text, text); assert!(!editor.dirty()); } #[test] fn visual_character_and_line_seeks_yank_the_selected_text_into_another_buffer() { for (mode, expected) in [('v', "a x x"), ('V', "a x xx tail\n")] { let fixture = Fixture::new(); let mut editor = fixture.editor("a x xx tail\n"); editor.handle(Input::Char(mode)); keys(&mut editor, "2fx"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); assert_eq!(editor.visual_linewise(), mode == 'V'); assert_eq!( editor.selections(), [Selection { start: Cursor { row: 0, column: 0 }, end: if mode == 'V' { Cursor { row: 1, column: 0 } } else { Cursor { row: 0, column: 5 } }, }] ); editor.handle(Input::Char('y')); assert_eq!(editor.mode(), Mode::Normal); assert!(!editor.dirty()); let destination = fixture.file("destination.txt", ""); editor.execute(&format!("edit {}", destination.display())); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, expected, "{mode}"); assert_eq!( fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), "a x xx tail\n" ); } } #[test] fn typed_ruby_seek_remaps_and_palette_actions_preserve_normal_and_visual_navigation() { let fixture = Fixture::new(); let text = "a x x tail\n"; let mut session = fixture.session( text, r#" config.bind 'leader s', config.core.seek_forward config.bind 'leader r', config.core.seek_backward config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual config.bind 'leader r', config.core.seek_backward, mode: config.modes.visual"#, ); session_keys(&mut session, "2 sx"); assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 }); session_keys(&mut session, " rx"); assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 }); session_keys(&mut session, "v sx"); let expanded = [Selection { start: Cursor { row: 0, column: 2 }, end: Cursor { row: 0, column: 5 }, }]; assert_eq!(session.editor().selections(), expanded); session_keys(&mut session, " rx"); let contracted = [Selection { start: Cursor { row: 0, column: 2 }, end: Cursor { row: 0, column: 3 }, }]; assert_eq!(session.editor().selections(), contracted); palette_seek(&mut session, "core.seek_forward", 'x'); assert_eq!(session.editor().mode(), Mode::Visual); assert_eq!(session.editor().selections(), expanded); palette_seek(&mut session, "core.seek_backward", 'x'); assert_eq!(session.editor().selections(), contracted); session.handle(Input::Escape); palette_seek(&mut session, "core.seek_forward", 'x'); assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 }); palette_seek(&mut session, "core.seek_backward", 'x'); assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 }); assert_eq!(session.editor().mode(), Mode::Normal); assert_eq!(session.editor().context().text, text); assert!(!session.editor().dirty()); } #[test] fn repeating_a_visual_seek_change_resolves_the_counted_character_on_the_new_line() { let fixture = Fixture::new(); let mut session = fixture.session( "a x x tail\nlong x gap x tail\n", " config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual", ); session_keys(&mut session, "v2 sxc!"); session.handle(Input::Escape); assert_eq!( session.editor().context().text, "! tail\nlong x gap x tail\n" ); session_keys(&mut session, "j0."); assert_eq!(session.editor().context().text, "! tail\n! tail\n"); assert_eq!(session.editor().mode(), Mode::Normal); session.handle(Input::Char('u')); assert_eq!( session.editor().context().text, "! tail\nlong x gap x tail\n" ); } #[test] fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_excerpts() { let fixture = Fixture::new(); fs::create_dir_all(fixture.0.join("repo")).unwrap(); fixture.git(&["init", "-q"]); let source = fixture.0.join("repo/context.txt"); let mut lines = (1..=40) .map(|row| format!("stable_{row:03} [x] [x]")) .collect::>(); lines[19] = "before x x tail".into(); let before_text = lines.join("\n") + "\n"; fs::write(&source, &before_text).unwrap(); fixture.git(&["add", "context.txt"]); fixture.git(&["commit", "-qm", "before"]); let before = fixture.git(&["rev-parse", "HEAD"]); lines[19] = "after x x tail".into(); let after_text = lines.join("\n") + "\n"; fs::write(&source, &after_text).unwrap(); fixture.git(&["commit", "-qam", "after"]); let after = fixture.git(&["rev-parse", "HEAD"]); fixture.git(&["checkout", "--detach", &before]); lines[19] = "working x x tail".into(); let working_text = lines.join("\n") + "\n"; fs::write(&source, &working_text).unwrap(); let config = fixture.file( "config.rb", "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\nend\n", ); let mut session = EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap(); assert!(session.editor().startup_diagnostic().is_none()); session_keys(&mut session, "iunsaved "); session.handle(Input::Escape); let unsaved_text = session.editor().context().text; let original_cursor = session.editor().cursor(); session.execute("diff"); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.catalog.is_some() && !diff.loading) }); for revision in [&before, &after] { session_keys(&mut session, revision); session.handle(Input::Down); session.handle(Input::Enter); } wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded()) }); let excerpts = session.editor().diff().unwrap().display_rows.clone(); assert!( excerpts .iter() .any(|row| matches!(row, DisplayRow::Fold { .. })) ); for (focus, text, first_x, second_x) in [ (Focus::Before, &before_text, 7, 9), (Focus::After, &after_text, 6, 8), ] { for _ in 0..3 { if session.editor().diff().unwrap().focus == focus { break; } session.handle(Input::Char('\t')); } assert_eq!(session.editor().diff().unwrap().focus, focus); let revision = session.editor().text_revision(); session_keys(&mut session, "gt02f["); assert_eq!( session.editor().cursor(), Cursor { row: 18, column: 15 } ); session_keys(&mut session, "F[f]"); assert_eq!( session.editor().cursor(), Cursor { row: 18, column: 13 } ); session_keys(&mut session, "j02fx"); assert_eq!( session.editor().cursor(), Cursor { row: 19, column: second_x } ); session_keys(&mut session, "*"); assert_eq!( session.editor().cursor(), Cursor { row: 19, column: second_x } ); session_keys(&mut session, "*"); assert_eq!( session.editor().cursor(), Cursor { row: 20, column: 12 } ); assert_eq!(session.editor().search_match().unwrap().end_column, 13); session_keys(&mut session, "3*N"); assert_eq!( session.editor().cursor(), Cursor { row: 18, column: 12 } ); session_keys(&mut session, "3n"); assert_eq!( session.editor().cursor(), Cursor { row: 19, column: second_x } ); session_keys(&mut session, "Fxvfx"); assert_eq!( session.editor().selections(), [Selection { start: Cursor { row: 19, column: first_x }, end: Cursor { row: 19, column: second_x + 1 }, }] ); session_keys(&mut session, "yVfx"); assert!(session.editor().visual_linewise()); assert_eq!( session.editor().cursor(), Cursor { row: 19, column: second_x } ); session.handle(Input::Escape); assert_eq!(session.editor().context().text, *text); assert_eq!(session.editor().text_revision(), revision); assert!(!session.editor().dirty()); assert_eq!(session.editor().diff().unwrap().display_rows, excerpts); } session.execute("diff.close"); assert_eq!(session.editor().context().text, unsaved_text); assert_eq!(session.editor().cursor(), original_cursor); assert!(session.editor().dirty()); assert_eq!(fs::read_to_string(&source).unwrap(), working_text); assert_eq!(fixture.git(&["rev-parse", "HEAD"]), before); }