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"); } } #[cfg(unix)] #[test] fn executable_restart_keeps_the_alternate_screen_and_restores_terminal_on_exit() { for scenario in ["quit", "startup-error"] { let fixture = Fixture::new(); let path = fixture.0.join("startup file.txt"); fs::write(&path, "SCREEN_HANDOFF_DOCUMENT\n").unwrap(); let output = std::process::Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into())) .args([ "-rpty", "-rtimeout", "-rio/console", "-e", r#" scenario, binary, path = ARGV captured = +"".b status = nil PTY.spawn({"TERM" => "xterm-256color"}, binary, "--no-config", "--", path) do |output, input, pid| phase = 0 begin input.winsize = [24, 100] Timeout.timeout(20) do loop do if IO.select([output], nil, nil, 0.05) begin chunk = output.read_nonblock(65536) captured << chunk STDOUT.write(chunk) rescue IO::WaitReadable rescue EOFError, Errno::EIO end end unless status waited = Process.waitpid2(pid, Process::WNOHANG) status = waited[1] if waited end if status # waitpid can win the race with the final output read. loop do begin chunk = output.read_nonblock(65536) captured << chunk STDOUT.write(chunk) rescue IO::WaitReadable, EOFError, Errno::EIO break end end break end # A fresh process re-enables input reporting before its first repaint. # Command-overlay frames must not be mistaken for a relaunched editor. sessions = captured.split("\e[?2004h", -1) next unless sessions.length == phase + 2 frame = sessions.last next unless frame.include?("\e[?2026l") visible = frame.gsub(/\e\[[0-?]*[ -\/]*[@-~]/, "") next unless visible.include?("SCREEN_HANDOFF_DOCUMENT") raise "alternate screen re-entered during restart" unless captured.scan("\e[?1049h").length == 1 raise "normal screen exposed during restart" if captured.include?("\e[?1049l") if scenario == "startup-error" File.binwrite(path, "\xff".b) input.write(":restart\r") else input.write([":restart\r", ":restart!\r", ":q\r"].fetch(phase)) end input.flush phase += 1 end expected_phases = scenario == "quit" ? 3 : 1 raise "exited before requested restart/quit" unless phase == expected_phases raise "unexpected exit status: #{status.inspect}" unless status.success? == (scenario == "quit") # Inspect the master: macOS revokes slave descriptors when their session exits. raise "terminal echo was not restored" unless input.echo? settings = IO.popen(["stty", "-a"], in: input, &:read) raise "terminal remained noncanonical: #{settings}" unless settings.match?(/(?:^|[;\s])icanon(?:[;\s]|$)/) end ensure unless status begin Process.kill("KILL", pid) rescue Errno::ESRCH end begin Process.waitpid(pid) rescue Errno::ECHILD end end input.close end end "#, "--", scenario, env!("CARGO_BIN_EXE_vamp"), ]) .arg(&path) .output() .expect("Ruby with stdlib PTY and io/console is required (set VAMP_RUBY)"); let captured = String::from_utf8_lossy(&output.stdout); assert!( output.status.success(), "{scenario}: {}\nterminal output: {captured:?}", String::from_utf8_lossy(&output.stderr), ); assert_eq!( captured.matches("\x1b[?1049h").count(), 1, "{scenario}: {captured:?}", ); assert_eq!( captured.matches("\x1b[?1049l").count(), 1, "{scenario}: {captured:?}", ); } }