438 lines
15 KiB
Rust
438 lines
15 KiB
Rust
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<String>| 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:?}",
|
|
);
|
|
}
|
|
}
|