use std::{ fs, path::PathBuf, sync::atomic::{AtomicU64, Ordering}, }; use vamp::editor::{Editor, Feature, Input}; static NEXT: AtomicU64 = AtomicU64::new(0); struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let root = std::env::temp_dir().join(format!( "vamp-commands-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed), )); fs::create_dir_all(&root).unwrap(); Self(root) } fn editor(&self, name: &str, text: &str) -> Editor { let path = self.0.join(name); fs::write(&path, text).unwrap(); Editor::open(Some(path), vec!["text".into()]).unwrap() } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn insert(editor: &mut Editor, text: &str) { for input in [Input::Char('i'), Input::Paste(text.into()), Input::Escape] { editor.handle(input); } } #[test] fn save_and_w_preserve_spaced_path_arguments_and_write_current_contents() { for command in ["save", "w"] { let fixture = Fixture::new(); let mut editor = fixture.editor("original file.txt", "original\n"); insert(&mut editor, "changed "); let destination = fixture.0.join("saved file with spaces.txt"); editor.execute(&format!(":{command} {}", destination.display())); assert_eq!(editor.path(), Some(destination.as_path()), "{command}"); assert_eq!( fs::read_to_string(&destination).unwrap(), "changed original\n" ); assert_eq!( fs::read_to_string(fixture.0.join("original file.txt")).unwrap(), "original\n" ); assert!(!editor.dirty(), "{command}"); assert!(!editor.should_quit(), "{command}"); } } #[test] fn quit_and_q_refuse_dirty_buffers_then_exit_when_clean() { for command in ["quit", "q"] { let fixture = Fixture::new(); let mut editor = fixture.editor("file.txt", "original\n"); insert(&mut editor, "changed "); editor.execute(command); assert!(!editor.should_quit(), "{command}"); assert!(editor.dirty(), "{command}"); assert_eq!(editor.context().text, "changed original\n"); assert_eq!( fs::read_to_string(fixture.0.join("file.txt")).unwrap(), "original\n" ); editor.handle(Input::Char('u')); assert!(!editor.dirty(), "{command}"); editor.execute(command); assert!(editor.should_quit(), "{command}"); } } #[test] fn force_quit_and_q_bang_validate_arguments_before_discarding_changes() { for command in ["force_quit", "q!"] { let fixture = Fixture::new(); let mut editor = fixture.editor("file.txt", "original\n"); insert(&mut editor, "changed "); editor.execute(&format!("{command} unexpected argument")); assert!(!editor.should_quit(), "{command}"); assert!(editor.dirty(), "{command}"); assert_eq!(editor.context().text, "changed original\n"); editor.execute(command); assert!(editor.should_quit(), "{command}"); assert_eq!( fs::read_to_string(fixture.0.join("file.txt")).unwrap(), "original\n" ); } } #[test] fn buffer_next_and_bnext_preserve_each_buffers_dirty_text_and_undo() { for command in ["buffer_next", "bnext"] { let fixture = Fixture::new(); let mut editor = fixture.editor("first.txt", "first\n"); let first_id = editor.buffer_id(); insert(&mut editor, "changed "); let second_path = fixture.0.join("second file.txt"); fs::write(&second_path, "second\n").unwrap(); editor.execute(&format!("edit {}", second_path.display())); let second_id = editor.buffer_id(); assert_ne!(first_id, second_id); insert(&mut editor, "edited "); editor.execute(command); assert_eq!(editor.buffer_id(), first_id, "{command}"); assert_eq!(editor.context().text, "changed first\n"); assert!(editor.dirty(), "{command}"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "first\n"); assert!(!editor.dirty(), "{command}"); editor.execute(command); assert_eq!(editor.buffer_id(), second_id, "{command}"); assert_eq!(editor.context().text, "edited second\n"); assert!(editor.dirty(), "{command}"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "second\n"); assert!(!editor.dirty(), "{command}"); assert_eq!(editor.buffer_count(), 2); assert_eq!( fs::read_to_string(fixture.0.join("first.txt")).unwrap(), "first\n" ); assert_eq!(fs::read_to_string(&second_path).unwrap(), "second\n"); } } #[test] fn restart_closes_clean_buffers_across_tabs_and_panes_but_preserves_dirty_history() { let fixture = Fixture::new(); let mut editor = fixture.editor("first.txt", "first\n"); let first_id = editor.buffer_id(); insert(&mut editor, "changed "); editor.handle(Input::Char('0')); insert(&mut editor, "extra "); let first_redo_text = editor.context().text; editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "changed first\n"); editor.execute("pane.split_vertical"); let clean_path = fixture.0.join("clean.txt"); fs::write(&clean_path, "clean\n").unwrap(); editor.execute(&format!("edit {}", clean_path.display())); let clean_id = editor.buffer_id(); editor.execute("tab.new"); let second_path = fixture.0.join("second.txt"); fs::write(&second_path, "second\n").unwrap(); editor.execute(&format!("edit {}", second_path.display())); let second_id = editor.buffer_id(); insert(&mut editor, "edited "); editor.execute("pane.split_horizontal"); let active_clean_path = fixture.0.join("active-clean.txt"); fs::write(&active_clean_path, "active clean\n").unwrap(); editor.execute(&format!("edit {}", active_clean_path.display())); let active_clean_id = editor.buffer_id(); assert_eq!(editor.tab_count(), 2); assert_eq!(editor.pane_layout().len(), 2); editor.execute(":restart"); assert!(!editor.should_restart()); assert!(!editor.should_quit()); assert!(editor.message().to_lowercase().contains("unsaved")); assert_eq!(editor.buffer_count(), 2); assert!(!editor.has_buffer(clean_id)); assert!(!editor.has_buffer(active_clean_id)); assert!(editor.has_buffer(first_id)); assert!(editor.has_buffer(second_id)); editor.execute(&format!("buffer {first_id}")); assert_eq!(editor.buffer_id(), first_id); assert_eq!(editor.context().text, "changed first\n"); assert!(editor.dirty()); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, first_redo_text); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "changed first\n"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "first\n"); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "changed first\n"); editor.execute("save"); editor.execute(&format!("buffer {second_id}")); assert_eq!(editor.buffer_id(), second_id); assert_eq!(editor.context().text, "edited second\n"); assert!(editor.dirty()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "second\n"); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "edited second\n"); editor.execute("save"); editor.execute(":restart"); assert!(editor.should_restart()); assert!(editor.should_quit()); assert_eq!( fs::read_to_string(fixture.0.join("first.txt")).unwrap(), "changed first\n" ); assert_eq!(fs::read_to_string(second_path).unwrap(), "edited second\n"); } #[test] fn restart_commands_validate_arguments_before_closing_or_discarding_buffers() { for command in ["restart", "force_restart", "restart!"] { let fixture = Fixture::new(); let mut editor = fixture.editor("dirty.txt", "original\n"); let dirty_id = editor.buffer_id(); insert(&mut editor, "changed "); let clean_path = fixture.0.join("clean.txt"); fs::write(&clean_path, "clean\n").unwrap(); editor.execute(&format!("edit {}", clean_path.display())); let clean_id = editor.buffer_id(); editor.execute(&format!(":{command} unexpected argument")); assert!(!editor.should_restart(), "{command}"); assert!(!editor.should_quit(), "{command}"); assert_eq!(editor.buffer_count(), 2, "{command}"); assert_eq!(editor.buffer_id(), clean_id, "{command}"); assert!(editor.has_buffer(dirty_id), "{command}"); editor.execute(&format!("buffer {dirty_id}")); assert_eq!(editor.context().text, "changed original\n", "{command}"); assert!(editor.dirty(), "{command}"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "original\n", "{command}"); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "changed original\n", "{command}"); if command != "restart" { editor.execute(&format!(":{command}")); assert!(editor.should_restart(), "{command}"); assert!(editor.should_quit(), "{command}"); } assert_eq!( fs::read_to_string(fixture.0.join("dirty.txt")).unwrap(), "original\n", "{command}" ); assert_eq!(fs::read_to_string(clean_path).unwrap(), "clean\n"); } } #[test] fn configured_restart_bindings_execute_directly_without_force_confirmation() { for action in ["core.restart", "core.force_restart"] { let fixture = Fixture::new(); let mut editor = fixture.editor("file.txt", "original\n"); editor.bind("normal", "x", Some(action.into())).unwrap(); if action == "core.force_restart" { insert(&mut editor, "changed "); } editor.handle(Input::Char('x')); assert!(editor.should_restart(), "{action}"); assert!(editor.should_quit(), "{action}"); assert_eq!( fs::read_to_string(fixture.0.join("file.txt")).unwrap(), "original\n", "{action}" ); } } #[test] fn builtin_canonical_and_alias_collisions_reject_the_entire_feature() { for reserved in ["save", "w", "quit", "q"] { let mut editor = Editor::open(None, vec!["text".into()]).unwrap(); let feature = |commands: Vec| Feature { name: "example".into(), required_capabilities: vec!["text".into()], commands, core: false, }; assert!( editor .register(feature(vec!["example.run".into(), reserved.into()])) .is_err(), "{reserved}" ); assert!(editor.execute("example.run").is_none(), "{reserved}"); editor .register(feature(vec!["example.run".into()])) .unwrap(); let invocation = editor.execute("example.run remaining args").unwrap(); assert_eq!(invocation.plugin, "example"); } }