Files
vamp/tests/unit/session.rs
T
2026-10-21 15:18:47 +02:00

3997 lines
142 KiB
Rust

use super::*;
use std::{
fs,
sync::atomic::{AtomicU64, Ordering},
};
#[cfg(unix)]
#[test]
fn concurrent_shell_commands_share_status_and_queue_every_completed_output() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let mut session = fixture.open("");
session.editor.set_project_root(fixture.0.clone());
session.execute("!printf FIRST; while [ ! -f first ]; do sleep 0.01; done");
session.execute("!echo SECOND; while [ ! -f second ]; do sleep 0.01; done; exit 9");
assert_eq!(session.shell.len(), 2);
let fixed = Instant::now();
for running in &mut session.shell {
running.started = fixed;
}
session.poll_shell_at(fixed + Duration::from_millis(199));
assert_eq!(session.editor.shell_label(), "");
session.poll_shell_at(fixed + Duration::from_millis(200));
assert_eq!(session.editor.shell_label(), "pri... - ech... -");
fixture.file("second", "");
drain_until(&mut session, |session| session.shell.len() == 1);
assert_eq!(
session.editor.command_output().unwrap().content.lines[0],
"SECOND"
);
assert!(
session
.editor
.command_output()
.unwrap()
.title
.contains("exit 9")
);
assert!(session.editor.shell_label().starts_with("pri... "));
assert!(!session.editor.shell_label().contains("ech..."));
fixture.file("first", "");
drain_until(&mut session, |session| session.shell.is_empty());
assert_eq!(session.editor.shell_label(), "");
assert_eq!(
session.editor.command_output().unwrap().content.lines[0],
"SECOND"
);
session.handle(Input::Enter);
assert_eq!(
session.editor.command_output().unwrap().content.lines,
["FIRST"]
);
assert!(
session
.editor
.command_output()
.unwrap()
.title
.contains("exit 0")
);
session.handle(Input::Escape);
assert!(session.editor.command_output().is_none());
assert_eq!(session.editor.lines(), ["source"]);
}
#[cfg(unix)]
#[test]
fn shell_commands_leave_editing_live_and_present_both_streams_and_exit_status() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let mut session = fixture.open("");
session.editor.set_project_root(fixture.0.clone());
for ch in
":!while [ ! -f release ]; do sleep 0.01; done; pwd; printf 'out'; printf 'err' >&2; exit 7"
.chars()
{
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
let fixed = Instant::now();
session.shell[0].started = fixed;
assert!(!session.poll_shell_at(fixed + Duration::from_millis(199)));
assert_eq!(session.editor.shell_label(), "");
assert!(session.poll_shell_at(fixed + Duration::from_millis(200)));
assert_eq!(session.editor.shell_label(), "whi... -");
session.handle(Input::Char('i'));
session.handle(Input::Char('X'));
assert_eq!(session.editor.lines(), ["Xsource"]);
fixture.file("release", "");
drain_until(&mut session, |session| {
session.editor.command_output().is_some()
});
let output = session.editor.command_output().unwrap();
assert!(output.title.contains("exit 7"));
let text = output.content.lines.join("\n");
assert!(text.contains(fixture.0.to_str().unwrap()));
assert!(text.ends_with("out\n[stderr]\nerr"), "{text}");
assert_eq!(session.editor.shell_label(), "");
assert_eq!(session.editor.mode(), Mode::Insert);
session.handle(Input::Char('Z'));
assert_eq!(session.editor.lines(), ["Xsource"]);
session.handle(Input::Escape);
session.handle(Input::Char('Y'));
assert_eq!(session.editor.lines(), ["XYsource"]);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"source"
);
}
#[cfg(unix)]
#[test]
fn shell_commands_reject_empty_input_and_show_empty_success_without_editing() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let mut session = fixture.open("");
session.execute(":!");
assert_eq!(session.editor.message(), "Shell command is empty");
assert!(session.shell.is_empty());
session.execute(":!true");
drain_until(&mut session, |session| {
session.editor.command_output().is_some()
});
let output = session.editor.command_output().unwrap();
assert_eq!(output.content.lines, ["(no output)"]);
assert!(output.title.contains("exit 0"));
session.handle(Input::Enter);
assert!(session.editor.command_output().is_none());
assert_eq!(session.editor.lines(), ["source"]);
assert!(!session.editor.dirty());
}
#[test]
fn oversized_repository_text_skips_snapshots_and_recovers_after_shrinking() {
let fixture = Fixture::new();
let path = fixture.file("buffer.txt", "small\n");
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
let buffer = session.editor.buffer_id();
session.editor.set_gutter_changes(
buffer,
session.editor.text_revision(),
&path,
vec![Some(crate::gutter::Change::Added)],
);
assert_eq!(
session.editor.gutter_change(0),
Some(crate::gutter::Change::Added)
);
fs::write(&path, "x".repeat(crate::gutter::FILE_LIMIT + 1)).unwrap();
assert!(session.editor.refresh_files());
session.poll_gutter_at(Instant::now());
session.synchronize_documents();
let source = session
.gutter_sources
.iter()
.find(|source| source.buffer == buffer)
.unwrap();
assert!(source.contents.is_none());
assert!(session.blame_worker.is_none());
assert_eq!(session.editor.gutter_change(0), None);
fs::write(&path, "small again\n").unwrap();
assert!(session.editor.refresh_files());
session.poll_gutter_at(Instant::now());
session.synchronize_documents();
let source = session
.gutter_sources
.iter()
.find(|source| source.buffer == buffer)
.unwrap();
assert_eq!(source.contents.as_deref(), Some("small again\n"));
assert!(session.blame_worker.is_some());
}
#[test]
fn resolver_load_errors_return_through_real_session_without_touching_the_draft() {
let fixture = Fixture::new();
let path = fixture.file("buffer.txt", "disk\n");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session
.editor
.apply(vec![Effect::Replace("draft\n".into())])
.unwrap();
let buffer = session.editor.buffer_id();
session.execute("resolve");
assert!(session.editor.resolver().unwrap().loading);
let outcome = session
.resolver_worker
.as_ref()
.unwrap()
.results
.recv_timeout(Duration::from_secs(5))
.unwrap();
session.editor.apply_resolver_outcome(outcome);
assert!(!session.editor.resolver().unwrap().loading);
assert!(session.editor.resolver().unwrap().error.is_some());
session.execute("resolve.close");
assert!(
session
.editor
.documents()
.any(|document| document.id == buffer
&& document.dirty
&& document.lines == ["draft", ""])
);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"disk\n"
);
}
#[test]
fn pull_request_pages_stream_concurrently_and_retry_only_the_failed_category() {
use crate::diff::{PullRequestPage, RepoKind};
let (outgoing, results) = mpsc::channel();
let (started, starts) = mpsc::channel();
let (open, open_gate) = mpsc::channel::<Result<bool, String>>();
let (closed, closed_gate) = mpsc::channel::<Result<bool, String>>();
let mut gates = [Some(open_gate), Some(closed_gate)];
let cancelled = Arc::new(AtomicBool::new(false));
let fetches = std::array::from_fn(|index| {
let gate = gates[index].take().unwrap();
let started = started.clone();
let cancelled = Arc::clone(&cancelled);
move |page| {
started.send((index, page)).unwrap();
let has_more = loop {
match gate.try_recv() {
Ok(result) => break result?,
Err(mpsc::TryRecvError::Empty) if !cancelled.load(Ordering::Acquire) => {
thread::yield_now()
}
Err(_) => return Err("cancelled".into()),
}
};
Ok(PullRequestPage {
kind: RepoKind::Git,
remote: "origin".into(),
entries: vec![],
has_more,
})
}
});
let mut worker = PullRequestWorker::start_with(7, outgoing, cancelled, fetches).unwrap();
let mut first = [
starts.recv_timeout(Duration::from_secs(2)).unwrap(),
starts.recv_timeout(Duration::from_secs(2)).unwrap(),
];
first.sort_unstable();
assert_eq!(first, [(0, 1), (1, 1)]);
let receive = |worker: &mut PullRequestWorker| {
let (id, result) = results.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(id, 7);
let Ok(DiffOutcome::PullRequestPage { closed, result }) = result else {
panic!("expected independently published page");
};
worker.complete(closed, &result);
(closed, result)
};
closed.send(Ok(true)).unwrap();
let (category, page) = receive(&mut worker);
assert!(category && page.unwrap().has_more);
// Open's first request remains blocked while the active Closed category advances.
worker.update(1, [0, 0]).unwrap();
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2));
closed.send(Err("late failure".into())).unwrap();
assert_eq!(receive(&mut worker).1.unwrap_err(), "late failure");
worker.update(1, [0, 0]).unwrap();
assert!(starts.try_recv().is_err());
worker.update(1, [0, 1]).unwrap();
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2));
closed.send(Ok(false)).unwrap();
assert!(!receive(&mut worker).1.unwrap().has_more);
open.send(Ok(false)).unwrap();
assert!(!receive(&mut worker).0);
worker.update(2, [0, 1]).unwrap();
assert!(starts.try_recv().is_err());
}
#[test]
fn closing_pull_request_listing_cancels_both_outstanding_reads() {
let (outgoing, _results) = mpsc::channel();
let (started, starts) = mpsc::channel();
let cancelled = Arc::new(AtomicBool::new(false));
let fetches = std::array::from_fn(|index| {
let started = started.clone();
let cancelled = Arc::clone(&cancelled);
move |_| {
started.send(index).unwrap();
while !cancelled.load(Ordering::Acquire) {
thread::yield_now();
}
Err("cancelled".into())
}
});
let worker =
PullRequestWorker::start_with(1, outgoing, Arc::clone(&cancelled), fetches).unwrap();
starts.recv_timeout(Duration::from_secs(2)).unwrap();
starts.recv_timeout(Duration::from_secs(2)).unwrap();
drop(worker);
assert!(cancelled.load(Ordering::Acquire));
}
#[test]
fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() {
use crate::diff::FileStatus;
let fixture = Fixture::new();
fixture.file("buffer.txt", "base\n");
fixture.file("clean.txt", "clean\n");
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&fixture.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.com")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.com")
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
git(&["init", "-q", "--template="]);
git(&["add", "buffer.txt", "clean.txt"]);
git(&["commit", "-qm", "baseline"]);
fixture.file("buffer.txt", "disk\n");
fixture.file("added.txt", "new\n");
let mut session = fixture.open(
"Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n",
);
session.editor.set_project_root(fixture.0.clone());
let now = session.last_file_refresh;
let open = |session: &mut EditorSession| {
session.handle(Input::Char(' '));
session.handle(Input::Char('o'));
};
let status = |session: &EditorSession| {
session
.editor
.normal_file_picker()
.unwrap()
.entries()
.find(|row| row.path == Path::new("buffer.txt"))
.unwrap()
.status
};
let ready = |session: &mut EditorSession, clock: Instant, expected| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
session.poll_gutter_at(clock);
let picker = session.editor.normal_file_picker().unwrap();
let status = picker
.entries()
.find(|row| row.path == Path::new("buffer.txt"))
.expect("normal picker retained the buffer file")
.status;
if !session.gutter_pending && status == expected {
assert!(
picker
.entries()
.any(|row| { row.path == Path::new("clean.txt") && row.status.is_none() })
);
assert!(picker.entries().any(|row| {
row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added)
}));
break;
}
assert!(
Instant::now() < deadline,
"normal picker status did not settle: {}",
session.editor.message()
);
thread::sleep(Duration::from_millis(5));
}
};
let settle = |session: &mut EditorSession| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
session.poll_gutter_at(now);
if !session.gutter_pending {
break;
}
assert!(
Instant::now() < deadline,
"background file status did not settle"
);
thread::sleep(Duration::from_millis(5));
}
};
settle(&mut session);
open(&mut session);
assert_eq!(
status(&session),
Some(FileStatus::Modified),
"first open must reuse status loaded while the picker was closed",
);
ready(&mut session, now, Some(FileStatus::Modified));
session.handle(Input::Ctrl('w'));
settle(&mut session);
open(&mut session);
assert_eq!(
status(&session),
Some(FileStatus::Modified),
"reopening must retain completed status",
);
let request = session.gutter_request_id;
session.poll_gutter_at(now);
assert_eq!(
session.gutter_request_id, request,
"opening must not restart status work"
);
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session
.editor
.apply(vec![Effect::Replace("base\n".into())])
.unwrap();
assert!(session.editor.dirty());
settle(&mut session);
open(&mut session);
assert_eq!(
status(&session),
None,
"closed-picker edits must refresh the cache"
);
ready(&mut session, now, None);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"disk\n"
);
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session.handle(Input::Char('u'));
settle(&mut session);
open(&mut session);
assert_eq!(status(&session), Some(FileStatus::Modified));
ready(&mut session, now, Some(FileStatus::Modified));
git(&["add", "buffer.txt"]);
git(&["commit", "-qm", "new baseline"]);
ready(&mut session, now + Duration::from_secs(1), None);
assert!(!session.apply_picker_result(
session.gutter_request_id.wrapping_sub(1),
crate::gutter::PickerChanges {
root: fixture.0.clone(),
result: Ok(Vec::new()),
},
));
assert!(!session.apply_picker_result(
session.gutter_request_id,
crate::gutter::PickerChanges {
root: fixture.0.join("elsewhere"),
result: Ok(Vec::new()),
},
));
session.handle(Input::Ctrl('w'));
session.execute("tab.new");
open(&mut session);
assert_eq!(status(&session), None);
assert!(
session
.editor
.normal_file_picker()
.unwrap()
.entries()
.any(|row| row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added))
);
}
#[test]
fn gutter_results_follow_buffer_identity_and_reject_stale_edits() {
use crate::gutter::Change;
let fixture = Fixture::new();
let first_path = fixture.file("first.txt", "first\nsecond");
let second_path = fixture.file("second.txt", "other");
let mut session = EditorSession::open(Some(first_path), vec![], None).unwrap();
let now = session.last_gutter_refresh.unwrap();
let first_buffer = session.editor.buffer_id();
let first_request = session.gutter_request_id;
assert!(session.apply_gutter_result(
first_request,
vec![(first_buffer, Ok(vec![None, Some(Change::Modified)]))],
));
session.editor.handle(Input::Down);
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session
.editor
.execute(&format!("edit {}", second_path.display()));
session.poll_gutter_at(now);
let second_buffer = session.editor.buffer_id();
let second_request = session.gutter_request_id;
assert_ne!(second_request, first_request);
assert!(!session.apply_gutter_result(
first_request,
vec![(first_buffer, Ok(vec![Some(Change::Removed), None]))],
));
assert!(session.apply_gutter_result(
second_request,
vec![
(first_buffer, Ok(vec![None, Some(Change::Modified)])),
(second_buffer, Ok(vec![Some(Change::Added)])),
],
));
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
session.editor.execute(&format!("buffer {first_buffer}"));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Char('i'));
session.editor.handle(Input::Char('x'));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Char('y'));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Backspace);
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Escape);
session.editor.handle(Input::Char('u'));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Char('U'));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
assert!(!session.apply_gutter_result(
second_request,
vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))],
));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.poll_gutter_at(now);
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
assert!(session.apply_gutter_result(
session.gutter_request_id,
vec![(first_buffer, Ok(vec![None, Some(Change::Added)]))],
));
assert_eq!(session.editor.gutter_change(1), Some(Change::Added));
session.poll_gutter_at(now + Duration::from_secs(1));
assert!(session.apply_gutter_result(
session.gutter_request_id,
vec![(first_buffer, Ok(vec![None, None]))],
));
assert_eq!(session.editor.gutter_change(1), None);
session.editor.execute(&format!("buffer {second_buffer}"));
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
}
#[test]
fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_following() {
use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget};
let fixture = Fixture::new();
fixture.file("buffer.txt", "base");
for (working, bookmark) in [(true, false), (false, false), (true, true), (false, true)] {
let mut session = fixture.open("");
session.editor.execute("diff");
let (id, _) = session.editor.take_diff_request().unwrap();
session.editor.apply_diff_result(
id,
Ok(DiffOutcome::Catalog(Catalog {
kind: RepoKind::Git,
notice: None,
entries: [
RevisionTarget::Working,
if bookmark {
RevisionTarget::Bookmark {
name: "base".into(),
remote: None,
}
} else {
RevisionTarget::Commit("base".into())
},
]
.into_iter()
.map(|target| Revision {
target,
label: "revision".into(),
description: String::new(),
category: Category::Commits,
})
.collect(),
})),
);
session.editor.handle(Input::Down);
session.editor.handle(Input::Enter);
if !working {
session.editor.handle(Input::Down);
}
session.editor.handle(Input::Enter);
let (id, _) = session.editor.take_diff_request().unwrap();
session.editor.apply_diff_result(
id,
Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "after".into()],
root: fixture.0.clone(),
before: "base".into(),
after: "after".into(),
files: Vec::new(),
editable_paths: Default::default(),
})),
);
let now = session.last_diff_refresh;
for (tick, follow) in [(1, false), (2, true)] {
session.diff_follow_working_copy = follow;
session.refresh_checkout_diffs_at(now + Duration::from_millis(tick * 1000 - 1));
assert!(session.editor.take_next_diff_request().is_none());
session.refresh_checkout_diffs_at(now + Duration::from_secs(tick));
let request = session.editor.take_next_diff_request();
assert_eq!(request.is_some(), !working || follow || bookmark);
if let Some((_, refresh, operation)) = request {
assert!(
matches!(operation, DiffOperation::RefreshCheckout { comparison_id, follow_working, .. }
if comparison_id == id && follow_working == follow)
);
assert!(
!session
.editor
.apply_diff_result(refresh, Ok(DiffOutcome::CheckoutRefresh(None)))
);
}
}
}
}
#[test]
fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() {
let fixture = Fixture::new();
let path = fixture.file("source.txt", "first\nsecond\nthird");
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
assert!(session.editor.git_blame());
let first = session.blame_request_id;
let original = Arc::new(crate::blame::LineBlame {
commit: "0123456789".into(),
author: "Author".into(),
summary: "Original".into(),
uncommitted: false,
});
let second = Arc::new(crate::blame::LineBlame {
commit: "0123456789".into(),
author: "Author".into(),
summary: "Second".into(),
uncommitted: false,
});
let blame = || {
Ok(Some(vec![
Some(original.clone()),
Some(second.clone()),
Some(second.clone()),
]))
};
session.handle(Input::Down);
assert_eq!(session.blame_request_id, first);
session.blame_worker = None;
assert!(session.apply_blame_result(first, blame()));
assert_eq!(
session.editor.line_blame(),
Some("Author · Second (01234567)")
);
let label = session.editor.line_blame().unwrap().as_ptr();
session.handle(Input::Down);
assert_eq!(session.editor.line_blame().unwrap().as_ptr(), label);
assert!(!session.poll_blame());
session.handle(Input::Up);
session.handle(Input::Up);
assert_eq!(
session.editor.line_blame(),
Some("Author · Original (01234567)")
);
assert_eq!(session.blame_request_id, first);
session.execute("tab.new");
assert_eq!(session.blame_request_id, first);
assert_eq!(
session.editor.line_blame(),
Some("Author · Original (01234567)")
);
let other = fixture.file("other.txt", "other");
session.execute(&format!("edit {}", other.display()));
assert!(session.editor.line_blame().is_none());
assert!(!session.apply_blame_result(first, blame()));
session.execute("buffer 1");
assert!(!session.apply_blame_result(first, blame()));
let source = session.editor.blame_source().unwrap();
let before_edit = session.blame_request_id;
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
assert_ne!(session.blame_request_id, before_edit);
assert!(!session.apply_blame_result(before_edit, blame()));
assert!(!session.editor.set_line_blame(source, original.clone()));
assert!(session.editor.line_blame().is_none());
session.handle(Input::Escape);
session.execute("git_blame.toggle");
assert!(!session.editor.git_blame());
assert!(session.blame_worker.is_none());
assert!(session.blame_source.is_none());
assert!(session.editor.line_blame().is_none());
assert!(!session.apply_blame_result(session.blame_request_id, blame()));
session.poll_blame();
assert!(session.blame_worker.is_none());
let config = fixture.file(
"config.rb",
"Vamp.configure { |config| config.git_blame = false }",
);
let mut disabled = EditorSession::open(Some(path), vec![], Some(&config)).unwrap();
assert!(!disabled.editor.git_blame());
assert!(disabled.blame_worker.is_none());
disabled.poll_blame();
assert!(disabled.blame_worker.is_none());
}
#[test]
fn blame_navigation_uses_one_real_file_result_and_refreshes_its_baseline() {
let fixture = Fixture::new();
let path = fixture.file("source.txt", "first\nsecond\n");
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&fixture.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.com")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.com")
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
git(&["init", "-q"]);
git(&["add", "source.txt"]);
git(&["commit", "-qm", "Original"]);
fixture.file("source.txt", "first\nchanged\n");
git(&["add", "source.txt"]);
git(&["commit", "-qm", "Second"]);
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
let ready = |session: &mut EditorSession| {
let deadline = Instant::now() + Duration::from_secs(10);
while session.blame_lines.is_none() {
assert!(Instant::now() < deadline, "file attribution did not finish");
session.poll();
thread::sleep(Duration::from_millis(5));
}
};
let first = session.blame_request_id;
session.handle(Input::Down);
assert_eq!(session.blame_request_id, first);
ready(&mut session);
assert!(session.editor.line_blame().unwrap().contains("Second"));
session.handle(Input::Up);
assert!(session.editor.line_blame().unwrap().contains("Original"));
session.handle(Input::Down);
assert!(session.editor.line_blame().unwrap().contains("Second"));
session.handle(Input::Down);
assert!(session.editor.line_blame().is_none());
assert_eq!(session.blame_request_id, first);
session.handle(Input::Up);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
assert_ne!(session.blame_request_id, first);
ready(&mut session);
assert_eq!(session.editor.line_blame(), Some("Uncommitted"));
session.handle(Input::Escape);
let edited = session.blame_request_id;
session.execute("save");
assert_ne!(session.blame_request_id, edited);
ready(&mut session);
git(&["add", "source.txt"]);
git(&["commit", "-qm", "Saved"]);
let saved = session.blame_request_id;
session.refresh_blame();
assert_ne!(session.blame_request_id, saved);
ready(&mut session);
assert!(session.editor.line_blame().unwrap().contains("Saved"));
fixture.file("source.txt", "first\nxchanged\n");
let refreshed = session.blame_request_id;
assert!(!session.editor.refresh_files());
session.synchronize_documents();
assert_ne!(session.blame_request_id, refreshed);
ready(&mut session);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
ready(&mut session);
assert!(session.editor.dirty());
fs::remove_file(path).unwrap();
assert!(session.editor.refresh_files());
session.synchronize_documents();
assert!(session.blame_source.is_none());
assert!(session.editor.line_blame().is_none());
}
#[test]
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
use crate::plugin::Utf16Range;
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "source");
let target = fixture.file("target.txt", "A😀e\u{301}Z\n");
let mut session = EditorSession::open(Some(source.clone()), vec![], None).unwrap();
session.dispatch(Invocation {
plugin: "core".into(),
command: "lsp.definition".into(),
args: String::new(),
});
let buffer_id = session.editor.buffer_id();
let revision = session.editor.text_revision();
let request_id = session.lsp_request_id;
let span = Utf16Range {
start: (0, 5),
end: (0, 6),
};
let invalid = Utf16Range {
end: (9, 0),
..span
};
let update = |range, target_range| PluginUpdate {
request_id: Some(request_id),
effects: vec![UpdateEffect::NavigateUtf16 {
buffer_id,
revision,
path: target.clone(),
range,
target_range,
}],
};
for (range, target_range) in [(invalid, None), (span, Some(invalid))] {
assert!(
session
.apply_update("core", update(range, target_range))
.unwrap()
);
assert_eq!(session.editor.buffer_id(), buffer_id);
assert_eq!(session.editor.path(), Some(source.as_path()));
assert_eq!(session.editor.buffer_count(), 1);
assert_eq!(session.editor.tab_count(), 1);
assert_eq!(session.editor.cursor(), Cursor::default());
assert!(session.editor.message().contains("definition failed"));
}
assert!(session.apply_update("fixture", update(span, None)).is_err());
assert_eq!(session.editor.buffer_id(), buffer_id);
assert!(
session
.apply_update("core", update(span, Some(span)))
.unwrap()
);
assert_eq!(session.editor.path(), Some(target.as_path()));
assert_eq!(session.editor.cursor(), Cursor { row: 0, column: 3 });
assert_eq!(session.editor.tab_count(), 1);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor.path(), Some(source.as_path()));
assert_eq!(session.editor.cursor(), Cursor::default());
}
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
#[test]
fn render_swaps_preserve_session_sources_and_pending_effects_do_not_cross_panes() {
let fixture = Fixture::new();
let path = fixture.file("buffer.txt", "source");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let source = InvocationSource {
pane_id: session.editor.pane_id(),
buffer_id: session.editor.buffer_id(),
revision: session.editor.text_revision(),
};
session
.requests
.entry("fixture".into())
.or_default()
.pending
.insert(1, source);
session.requests.entry("fixture".into()).or_default().latest = Some((1, source));
session.handle(Input::Char('|'));
assert_eq!(session.editor.buffer_id(), source.buffer_id);
assert_eq!(session.editor.cursor(), Cursor::default());
assert!(!session.request_is_current("fixture", Some(1), source));
assert!(
!session
.apply_response("fixture", 1, vec![Effect::Insert("wrong pane".into())])
.unwrap()
);
session.requests.entry("core".into()).or_default().latest = Some((
2,
InvocationSource {
pane_id: session.editor.pane_id(),
..source
},
));
session.lsp_source_cursor = Some(session.editor.cursor());
session.lsp_source_mode = session.editor.mode();
session.lsp_source_view = Some((
session.editor.tab_id(),
session.editor.diff().and_then(|diff| diff.comparison_id),
));
session.editor_mut().with_render_pane(1, |pane| {
assert_eq!(pane.pane_id(), 1);
assert_eq!(pane.lines(), ["source"]);
});
session.synchronize_documents();
assert!(session.requests["core"].latest.is_some());
assert_eq!(session.editor.pane_id(), 2);
session.handle(Input::Alt('h'));
assert!(session.requests["core"].latest.is_none());
assert_eq!(session.editor.lines(), ["source"]);
}
#[test]
fn native_semantic_batches_are_atomic_and_malformed_updates_clear_hidden_overlays() {
use crate::plugin::{SemanticToken, SemanticTokenKind};
use std::sync::Arc;
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let target = fixture.file("target.txt", "target");
let mut session = fixture.open("");
let token = SemanticToken {
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 1 },
kind: SemanticTokenKind::Variable,
modifiers: 0,
};
let tokens: Arc<[SemanticToken]> = Arc::from([token]);
let semantic = |buffer_id, revision, tokens| UpdateEffect::SemanticTokens {
buffer_id,
revision,
tokens,
};
let update = |effects| PluginUpdate {
request_id: None,
effects,
};
session
.apply_update("core", update(vec![semantic(1, 0, Some(tokens.clone()))]))
.unwrap();
session.execute(&format!("edit {}", target.display()));
session
.apply_update("core", update(vec![semantic(2, 0, Some(tokens.clone()))]))
.unwrap();
let diagnostic = crate::plugin::Diagnostic {
start: token.start,
end: token.end,
severity: crate::plugin::DiagnosticSeverity::Warning,
message: "unchanged".into(),
};
session
.editor
.replace_diagnostics("plugin", 2, 0, vec![diagnostic.clone()])
.unwrap();
let malformed: Arc<[SemanticToken]> = Arc::from([SemanticToken {
end: TextPosition { row: 99, column: 0 },
..token
}]);
let before_message = session.editor.message().to_owned();
let result = session.apply_update(
"core",
update(vec![
UpdateEffect::Message {
text: "must not apply".into(),
},
UpdateEffect::Diagnostics {
buffer_id: 2,
revision: 0,
items: vec![diagnostic.clone()],
},
semantic(1, 0, Some(tokens.clone())),
semantic(2, 0, Some(malformed.clone())),
]),
);
assert!(result.is_err());
assert_eq!(session.editor.message(), before_message);
assert!(session.editor.semantic_tokens().is_empty());
assert_eq!(session.editor.diagnostics().count(), 1);
session.execute("buffer 1");
assert!(session.editor.semantic_tokens().is_empty());
assert!(
session
.apply_update(
"core",
update(vec![
semantic(999, 0, Some(malformed.clone())),
semantic(1, 99, Some(malformed)),
semantic(1, 0, Some(tokens.clone())),
])
)
.unwrap()
);
assert!(std::ptr::eq(
session.editor.semantic_tokens(),
tokens.as_ref()
));
assert_eq!(session.editor.lines(), ["source"]);
assert_eq!(session.editor.text_revision(), 0);
assert!(!session.editor.dirty());
assert!(
session
.apply_update("core", update(vec![semantic(1, 0, None)]))
.unwrap()
);
assert!(session.editor.semantic_tokens().is_empty());
}
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-session-{}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn file(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, text).unwrap();
path
}
fn open(&self, config: &str) -> EditorSession {
let config = self.file("config.rb", config);
let mut session = EditorSession::open(
Some(self.0.join("buffer.txt")),
vec!["text".into()],
Some(&config),
)
.unwrap();
session.editor.set_review_directory(self.0.join("reviews"));
// Legacy editing scenarios exercise the internal register, never the user's desktop.
session.editor.set_clipboard_mode("separate").unwrap();
session
}
#[cfg(unix)]
fn system_session(&self, config: &str) -> EditorSession {
use std::os::unix::fs::PermissionsExt;
let config = self.file("config.rb", config);
let mut session = EditorSession::open(
Some(self.0.join("buffer.txt")),
vec!["text".into()],
Some(&config),
)
.unwrap();
self.file("system.txt", "");
let reader = self.file("clip-read", "#!/bin/sh\n[ -e \"${0%/*}/read.fail\" ] && exit 13\nexec /bin/cat \"${0%/*}/system.txt\"\n");
let writer = self.file("clip-write", "#!/bin/sh\n[ -e \"${0%/*}/write.fail\" ] && exit 13\nexec /bin/cat > \"${0%/*}/system.txt\"\n");
fs::set_permissions(&reader, fs::Permissions::from_mode(0o700)).unwrap();
fs::set_permissions(&writer, fs::Permissions::from_mode(0o700)).unwrap();
session
.editor
.set_system_clipboard(crate::clipboard::SystemClipboard::with_commands(
reader, writer,
));
session
}
fn healthy(&self) {
self.file(
"healthy.rb",
r#"
Vamp.plugin "healthy", requires: ["text"]
Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
"#,
);
}
fn asynchronous(&self, file: Option<PathBuf>) -> EditorSession {
self.file(
"generic.rb",
r#"
Vamp.plugin "generic_fixture", requires: ["text", "diagnostics"], async: true
log_mutex = Mutex.new
record = ->(value) { log_mutex.synchronize { File.open(Vamp.options.fetch("log"), "a") { |file| file.puts JSON.generate(value) } } }
Vamp.on_document { |event| record.call(event) }
Vamp.command("fixture_barrier") { |editor, args| editor.message args }
Vamp.command("fixture_replace") { |editor, args| editor.replace args }
Vamp.command("fixture_crash") { |_editor, _args| Process.exit!(7) }
["fixture_publish", "fixture_hold"].each do |name|
Vamp.command(name) do |editor, args|
payload = JSON.parse(args)
effects = payload.fetch("effects")
effects.each do |effect|
next unless ["diagnostics", "navigate"].include?(effect["type"])
effect["buffer_id"] ||= editor.buffer_id
effect["revision"] ||= editor.revision
end
record.call({"invoke" => name, "buffer_id" => editor.buffer_id, "revision" => editor.revision,
"row" => editor.row, "column" => editor.column, "request_id" => editor.request_id})
if name == "fixture_hold"
Thread.new do
gate = payload.fetch("gate")
sleep 0.002 until File.exist?(gate)
Vamp.publish(effects + [{"type" => "message", "text" => "delivered #{gate}"}], request_id: editor.request_id)
end
else
Vamp.publish(effects, request_id: editor.request_id)
end
end
end
"#,
);
let log = self.0.join("events.jsonl");
self.file("events.jsonl", "");
let config = self.file(
"async-config.rb",
&format!(
"Vamp.configure {{ |config| config.clipboard_mode = \"separate\"; config.plugin \"generic.rb\", options: {{log: {}}} }}",
serde_json::to_string(&log.to_string_lossy()).unwrap(),
),
);
EditorSession::open(
file,
vec!["text".into(), "diagnostics".into()],
Some(&config),
)
.unwrap()
}
fn events(&self) -> Vec<serde_json::Value> {
fs::read_to_string(self.0.join("events.jsonl"))
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[cfg(unix)]
#[test]
fn invalid_clipboard_policy_retains_system_default_and_valid_bindings() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc");
let mut session = fixture.system_session(
r#"
Vamp.configure do |config|
config.clipboard_mode = "combined"
config.bind "n", config.core.right
end
"#,
);
assert_eq!(
session.editor().clipboard_mode(),
crate::clipboard::ClipboardMode::System
);
session.handle(Input::Char('n'));
session.handle(Input::Char('v'));
session.handle(Input::Char('y'));
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"b"
);
assert_eq!(session.editor().lines(), &["abc"]);
}
#[cfg(unix)]
#[test]
fn default_system_clipboard_tracks_external_text_and_preserves_cut_failures() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha beta");
let mut session = fixture.system_session("");
assert_eq!(
session.editor().clipboard_mode(),
crate::clipboard::ClipboardMode::System
);
fixture.file("system.txt", "external");
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["externalalpha beta"]);
session.handle(Input::Char('u'));
for ch in "vey".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"alpha"
);
fixture.file("system.txt", "outside");
session.handle(Input::Home);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["outsidealpha beta"]);
fixture.file("system.txt", "fresh");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["outsideoutsidealpha beta"]);
session.handle(Input::Char('u'));
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["alpha beta"]);
session.handle(Input::Home);
session.handle(Input::Char('v'));
session.handle(Input::Char('e'));
let selection = session.editor().selections();
fixture.file("write.fail", "");
session.handle(Input::Char('c'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Visual);
assert_eq!(session.editor().selections(), selection);
assert_eq!(session.editor().lines(), &["alpha beta"]);
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"fresh"
);
fs::remove_file(fixture.0.join("write.fail")).unwrap();
session.handle(Input::Escape);
session.handle(Input::Home);
fixture.file("system.txt", "\u{1b}");
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["alpha beta"]);
assert_eq!(session.editor().cursor(), crate::editor::Cursor::default());
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
}
#[cfg(unix)]
#[test]
fn separate_clipboards_keep_internal_and_explicit_system_routes_independent() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha beta");
let mut session = fixture.system_session(
r#"
Vamp.configure do |config|
config.clipboard_mode = "separate"
config.bind "ctrl+s", config.core.copy_system, mode: config.modes.visual
config.bind "ctrl+g", config.core.paste_system_before
end
"#,
);
fixture.file("system.txt", "outside");
for ch in "vey".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"outside"
);
for ch in "wve".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Ctrl('s'));
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"beta"
);
session.handle(Input::Home);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["alphaalpha beta"]);
session.handle(Input::Char('u'));
session.handle(Input::Home);
session.handle(Input::Ctrl('g'));
assert_eq!(session.editor().lines(), &["betaalpha beta"]);
session.handle(Input::Char('u'));
session.handle(Input::Home);
session.handle(Input::Char('v'));
session.handle(Input::Char('e'));
fixture.file("write.fail", "");
session.handle(Input::Char('d'));
assert_eq!(session.editor().lines(), &[" beta"]);
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"beta"
);
}
#[test]
fn selection_bindings_remap_palette_navigation_without_changing_editing_bindings() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "one two three\none two three");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind "ctrl+j", nil, mode: config.modes.selection
config.bind "ctrl+f", config.core.down, mode: config.modes.selection
config.bind "ctrl+b", config.core.up, mode: config.modes.selection
end
"#,
);
session.handle(Input::Down);
session.handle(Input::Ctrl('p'));
for ch in "core.word".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().palette().unwrap().selected, 0);
assert_eq!(
session.editor().cursor(),
crate::editor::Cursor { row: 1, column: 0 }
);
session.handle(Input::Ctrl('f'));
assert_eq!(
session.editor().palette().unwrap().entries[1].name,
"core.word_next"
);
assert_eq!(session.editor().palette().unwrap().selected, 1);
session.handle(Input::Ctrl('b'));
assert_eq!(session.editor().palette().unwrap().selected, 0);
session.handle(Input::Ctrl('f'));
session.handle(Input::Enter);
assert!(session.editor().palette().is_none());
assert_eq!(
session.editor().cursor(),
crate::editor::Cursor { row: 1, column: 4 }
);
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().selection_count(), 2);
assert_eq!(
session.editor().lines(),
&["one two three", "one two three"]
);
}
#[test]
fn configured_picker_presentations_preserve_editing_and_docked_focus() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.file_explorer_side = "left"
config.bind "ctrl+o", config.core.file_picker
config.bind "ctrl+e", config.core.file_explorer
end
"#,
);
assert_eq!(
session.editor().file_explorer_side(),
crate::files::DockSide::Left
);
session.handle(Input::Ctrl('e'));
assert_eq!(
session.editor().file_picker().unwrap().presentation,
crate::files::FilePresentation::Docked
);
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::Tree
);
assert!(!session.editor().file_picker().unwrap().searching);
session.handle(Input::Char('i'));
for ch in "dir e\u{301}".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Backspace);
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
session.handle(Input::Ctrl('h'));
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
session.handle(Input::Escape);
assert!(!session.editor().file_picker().unwrap().searching);
assert!(session.editor().file_picker().unwrap().focused);
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
session.handle(Input::Ctrl('u'));
assert_eq!(session.editor().file_picker().unwrap().query, "");
assert!(!session.editor().file_picker().unwrap().searching);
session.handle(Input::Char('i'));
for ch in "dir ".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Ctrl('u'));
assert_eq!(session.editor().file_picker().unwrap().query, "");
assert!(session.editor().file_picker().unwrap().searching);
for ch in "dir ".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Escape);
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
session.handle(Input::Escape);
assert!(!session.editor().file_picker().unwrap().focused);
session.handle(Input::Char('i'));
for ch in " a b ".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &[" a b alpha"]);
session.handle(Input::Ctrl('o'));
assert_eq!(
session.editor().file_picker().unwrap().presentation,
crate::files::FilePresentation::Floating
);
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::Tree
);
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
assert!(session.editor().file_picker().unwrap().searching);
session.handle(Input::Escape);
assert!(session.editor().file_picker().unwrap().focused);
session.handle(Input::Escape);
assert!(session.editor().file_picker().is_none());
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["alpha"]);
}
#[test]
fn configured_flat_dock_and_tree_picker_keep_control_h_distinct_from_backspace() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.file_explorer_view = "list"
config.file_picker_view = "tree"
config.bind "ctrl+f", config.core.right, mode: config.modes.tree
end
"#,
);
session.handle(Input::Char(' '));
session.handle(Input::Char('e'));
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::List
);
for ch in "e\u{301}".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Ctrl('h'));
assert_eq!(session.editor().file_picker().unwrap().query, "e\u{301}");
session.handle(Input::Backspace);
assert_eq!(session.editor().file_picker().unwrap().query, "");
session.handle(Input::Ctrl('w'));
session.handle(Input::Char(' '));
session.handle(Input::Char('o'));
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::Tree
);
assert!(session.editor().file_picker().unwrap().searching);
session.handle(Input::Char('x'));
session.handle(Input::Ctrl('h'));
assert_eq!(session.editor().file_picker().unwrap().query, "x");
session.handle(Input::Escape);
assert!(!session.editor().file_picker().unwrap().searching);
assert!(session.editor().file_picker().unwrap().focused);
assert_eq!(session.editor().file_picker().unwrap().query, "x");
session.handle(Input::Ctrl('t'));
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::List
);
session.handle(Input::Escape);
assert!(session.editor().file_picker().is_none());
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
session.handle(Input::Ctrl('h'));
assert_eq!(session.editor().lines(), &["xalpha"]);
session.handle(Input::Backspace);
assert_eq!(session.editor().lines(), &["alpha"]);
}
#[test]
fn invalid_explorer_preferences_preserve_defaults_and_valid_bindings() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.file_explorer_side = "diagonal"
config.file_explorer_view = "grid"
config.file_picker_view = "grid"
config.bind "n", config.core.right
end
"#,
);
assert_eq!(
session.editor().file_explorer_side(),
crate::files::DockSide::Right
);
assert_eq!(
session.editor().file_explorer_view(),
crate::files::FileView::Tree
);
session.handle(Input::Char('n'));
assert_eq!(
session.editor().cursor(),
crate::editor::Cursor { row: 0, column: 1 }
);
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["a!bc"]);
}
#[test]
fn configured_pull_request_symbols_reach_editor() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc");
let session = fixture.open(
r#"Vamp.configure do |config|
config.pull_request_review_requested_symbol = "👩‍🚀 review"
config.pull_request_approved_symbol = ""
end"#,
);
assert_eq!(
session.editor().pull_request_review_requested_symbol,
"👩‍🚀 review"
);
assert_eq!(session.editor().pull_request_approved_symbol, "");
}
#[test]
fn diff_display_preferences_survive_reopening() {
use crate::{diff::ViewMode, panes::SplitAxis};
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc");
for (mode, orientation, expected_mode, expected_axis) in [
(
"unified",
"horizontal",
ViewMode::Unified,
SplitAxis::Horizontal,
),
(
"split",
"horizontal",
ViewMode::Split,
SplitAxis::Horizontal,
),
("split", "vertical", ViewMode::Split, SplitAxis::Vertical),
] {
let mut session = fixture.open(&format!(
r#"Vamp.configure do |config|
config.diff_view_mode = "{mode}"
config.diff_split_orientation = "{orientation}"
config.bind "n", config.core.right
end"#
));
session.handle(Input::Char('n'));
assert_eq!(session.editor().cursor().column, 1);
for _ in 0..2 {
session.execute("diff");
let diff = session.editor().diff().unwrap();
assert_eq!(diff.view_mode, expected_mode);
assert_eq!(diff.split_axis, expected_axis);
session.execute("diff.close");
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().lines(), &["abc"]);
}
}
}
#[test]
fn no_config_uses_space_leader_for_palette_and_write() {
let fixture = Fixture::new();
let buffer = fixture.file("buffer.txt", "base");
let mut session = EditorSession::open(Some(buffer.clone()), vec!["text".into()], None).unwrap();
session.handle(Input::Char(' '));
assert_eq!(session.editor().sequence_pending(), Some("Space"));
session.handle(Input::Char(' '));
assert!(session.editor().palette().is_some());
assert!(session.editor().sequence_pending().is_none());
session.handle(Input::Escape);
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
session.handle(Input::Char(' '));
session.handle(Input::Char('w'));
assert_eq!(fs::read_to_string(buffer).unwrap(), "!base");
assert!(!session.editor().dirty());
}
#[test]
fn ruby_leader_sequences_share_one_config_and_plugin_host_across_buffers() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha");
let second = fixture.file("second.txt", "beta");
fixture.file(
"shared.rb",
r##"
Vamp.plugin "shared", requires: ["text"]
calls = 0
Vamp.command("stamp") do |editor, _|
calls += 1
editor.insert "#{calls}:#{File.basename(editor.path)}:"
end
Vamp.command("fail") { |_, _| raise "shared host failure" }
"##,
);
let mut session = fixture.open(
r#"
File.open("loads.txt", "a") { |file| file.puts "loaded" }
Vamp.configure do |config|
config.leader = ","
config.plugin "shared.rb"
config.bind "leader z n", "stamp"
config.bind "leader z f", "fail"
end
"#,
);
session.handle(Input::Char(','));
session.handle(Input::Char('z'));
assert_eq!(session.editor().sequence_pending(), Some(", z"));
assert_eq!(session.editor().lines(), &["alpha"]);
session.handle(Input::Char('n'));
assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]);
assert!(session.editor().sequence_pending().is_none());
session.handle(Input::Char(','));
session.handle(Input::Char('z'));
session.execute(&format!("edit {}", second.display()));
assert!(session.editor().sequence_pending().is_none());
for key in [',', 'z', 'n'] {
session.handle(Input::Char(key));
}
assert_eq!(session.editor().lines(), &["2:second.txt:beta"]);
for key in [',', 'b', 'p'] {
session.handle(Input::Char(key));
}
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Home);
for key in [',', 'z', 'n'] {
session.handle(Input::Char(key));
}
assert_eq!(
session.editor().lines(),
&["3:buffer.txt:1:buffer.txt:alpha"]
);
assert_eq!(
fs::read_to_string(fixture.0.join("loads.txt")).unwrap(),
"loaded\n"
);
for key in [',', 'z', 'f'] {
session.handle(Input::Char(key));
}
assert!(
session
.editor()
.message()
.contains("Plugin shared disabled:")
);
for key in [',', 'z', 'n'] {
session.handle(Input::Char(key));
}
assert_eq!(
session.editor().lines(),
&["3:buffer.txt:1:buffer.txt:alpha"]
);
session.handle(Input::Ctrl('p'));
assert!(
!session
.editor()
.palette()
.unwrap()
.entries
.iter()
.any(|entry| entry.owner == "shared")
);
}
#[test]
fn ruby_disabled_leader_suffix_and_cancellation_keep_counts_and_selections_safe() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abcdef");
fixture.healthy();
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.leader = ","
config.plugin "healthy.rb"
config.bind "leader z n", "healthy"
config.bind "leader z x", nil
config.bind "leader z h", config.core.left, mode: config.modes.visual
end
"#,
);
for last in ['x', 'd'] {
for key in ['3', ',', 'z', last] {
session.handle(Input::Char(key));
}
assert!(session.editor().sequence_pending().is_none());
assert_eq!(session.editor().lines(), &["abcdef"]);
session.handle(Input::Char('l'));
}
assert_eq!(session.editor().cursor().column, 2);
for key in ['3', ',', 'z'] {
session.handle(Input::Char(key));
}
session.handle(Input::Paste("n:q!\n".into()));
assert!(session.editor().sequence_pending().is_none());
session.handle(Input::Char('l'));
assert_eq!(session.editor().cursor().column, 3);
assert_eq!(session.editor().lines(), &["abcdef"]);
session.handle(Input::Char('v'));
let selected = session.editor().selections();
for key in ['3', ',', 'z'] {
session.handle(Input::Char(key));
}
session.handle(Input::Escape);
assert_eq!(session.editor().mode(), crate::editor::Mode::Visual);
assert_eq!(session.editor().selections(), selected);
assert!(session.editor().sequence_pending().is_none());
session.handle(Input::Char('h'));
assert_eq!(session.editor().cursor().column, 2);
assert_eq!(session.editor().lines(), &["abcdef"]);
assert!(!session.editor().should_quit());
}
#[test]
fn invalid_backend_leader_retains_space_and_installs_valid_config_bindings() {
for key in ["not-a-key", "esc", "7", "i"] {
let fixture = Fixture::new();
fixture.file("buffer.txt", "base");
fixture.healthy();
let mut session = fixture.open(&format!(
r#"
Vamp.configure do |config|
config.leader = "{key}"
config.plugin "healthy.rb"
config.bind "ctrl+f", config.core.right
config.bind "leader z n", "healthy"
end
"#,
));
assert!(
session
.editor()
.message()
.contains(&format!("leader {key:?}:"))
);
assert!(!session.editor().message().contains("config disabled"));
session.handle(Input::Ctrl('f'));
assert_eq!(session.editor().cursor().column, 1);
session.handle(Input::Char(' '));
assert_eq!(session.editor().sequence_pending(), Some("Space"));
session.handle(Input::Char('z'));
session.handle(Input::Char('n'));
assert_eq!(session.editor().lines(), &["healthy "]);
}
}
#[test]
fn buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() {
let fixture = Fixture::new();
let first = fixture.file("buffer.txt", "alpha");
let second = fixture.file("second.txt", "beta");
fixture.file(
"shared.rb",
r##"
Vamp.plugin "shared", requires: ["text"]
calls = 0
Vamp.command("stamp") do |editor, _|
calls += 1
editor.insert "#{calls}:#{File.basename(editor.path)}:"
end
Vamp.command("fail") { |_, _| raise "shared host failure" }
"##,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "shared.rb"
config.bind "g", "stamp"
end
"#,
);
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]);
session.execute(&format!("edit {}", second.display()));
assert_eq!(session.editor().buffer_id(), 2);
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &["2:second.txt:beta"]);
session.execute("bdelete");
assert_eq!(session.editor().buffer_id(), 2);
assert_eq!(session.editor().buffer_count(), 2);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["beta"]);
session.execute("q");
assert!(!session.editor().should_quit());
session.execute("wq");
assert!(!session.editor().should_quit());
assert_eq!(fs::read_to_string(&first).unwrap(), "alpha");
assert_eq!(fs::read_to_string(&second).unwrap(), "beta");
session.execute("buffer 1");
assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["alpha"]);
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]);
session.execute("w");
assert_eq!(fs::read_to_string(&first).unwrap(), "3:buffer.txt:alpha");
session.execute("bnext");
assert_eq!(session.editor().lines(), &["beta"]);
session.execute("fail");
assert!(session.editor().message().contains("disabled"));
session.execute("buffer 1");
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]);
session.execute("q");
assert!(session.editor().should_quit());
}
#[test]
fn typed_palette_binding_prepares_arguments_and_retires_failed_plugin_commands() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "base");
fixture.file(
"palette.rb",
r#"
Vamp.plugin "palette_fixture", requires: ["text"]
Vamp.command("palette_insert") { |editor, args| editor.insert args }
Vamp.command("palette_fail") { |_, _| raise "palette fixture failure" }
"#,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "palette.rb"
config.bind "ctrl+p", nil
config.bind "ctrl+f", config.core.palette
end
"#,
);
session.handle(Input::Ctrl('p'));
assert!(session.editor().palette().is_none());
session.handle(Input::Ctrl('f'));
for ch in "palette_insert".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(
session.editor().palette().unwrap().entries[0].owner,
"palette_fixture"
);
session.handle(Input::Enter);
assert_eq!(session.editor().mode(), crate::editor::Mode::Command);
assert_eq!(session.editor().command_line(), "palette_insert ");
assert_eq!(session.editor().lines(), &["base"]);
for ch in "界!".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
assert_eq!(session.editor().lines(), &["界!base"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["base"]);
session.handle(Input::Ctrl('f'));
for ch in "palette_fail".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
session.handle(Input::Enter);
assert!(session.editor().message().contains("disabled"));
session.handle(Input::Ctrl('f'));
assert!(
!session
.editor()
.palette()
.unwrap()
.entries
.iter()
.any(|entry| entry.owner == "palette_fixture")
);
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["base"]);
}
#[test]
fn configured_linewise_change_and_paste_preserve_rows_and_one_replacement_history() {
let fixture = Fixture::new();
fixture.file("buffer.txt", " alpha\n界beta\ntail");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.clipboard_mode = "separate"
config.bind "V", nil
config.bind "s", config.core.select_linewise
config.bind "s", config.core.select_linewise, mode: config.modes.visual
config.bind "r", config.core.change, mode: config.modes.visual
end
"#,
);
for key in "llsjrnew".chars() {
session.handle(Input::Char(key));
}
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["new", "tail"]);
session.handle(Input::Char('p'));
assert_eq!(
session.editor().lines(),
&["new", " alpha", "界beta", "tail"]
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["new", "tail"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[" alpha", "界beta", "tail"]);
session.handle(Input::Char('U'));
assert_eq!(session.editor().lines(), &["new", "tail"]);
}
#[test]
fn configured_selection_actions_edit_together_and_invoke_plugins_once() {
let fixture = Fixture::new();
fixture.file("buffer.txt", " alpha\nb\n gamma");
fixture.file(
"once.rb",
r#"
Vamp.plugin "once", requires: ["text"]
Vamp.command("once") { |editor, _| editor.insert "!" }
"#,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "once.rb"
config.bind "r", config.core.select_lines, mode: config.modes.visual
config.bind "z", config.core.clone_below
config.bind "f", "once"
end
"#,
);
session.handle(Input::Char('v'));
session.handle(Input::Char('j'));
session.handle(Input::Char('j'));
session.handle(Input::Char('r'));
assert_eq!(session.editor().selection_count(), 3);
session.handle(Input::Char('A'));
session.handle(Input::Char('?'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &[" alpha?", "b?", " gamma?"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]);
assert_eq!(session.editor().selection_count(), 3);
session.handle(Input::Char('f'));
assert_eq!(session.editor().selection_count(), 1);
assert_eq!(
session
.editor()
.lines()
.iter()
.map(|line| line.matches('!').count())
.sum::<usize>(),
1
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]);
assert_eq!(session.editor().selection_count(), 3);
}
#[test]
fn startup_rejection_installs_no_commands_and_keeps_other_plugins_and_core() {
let fixture = Fixture::new();
fixture.healthy();
fixture.file(
"graphics.rb",
r#"
Vamp.plugin "graphics", requires: ["graphics"]
Vamp.command("draw") { |editor, args| editor.insert "incompatible" }
"#,
);
fixture.file(
"collision.rb",
r#"
Vamp.plugin "collision", requires: ["text"]
Vamp.command("leaked") { |editor, args| editor.insert "collision" }
Vamp.command("q") { |editor, args| editor.insert "override" }
"#,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "graphics.rb"
config.plugin "collision.rb"
config.plugin "missing.rb"
config.plugin "healthy.rb"
config.bind "g", "healthy"
end
"#,
);
for command in ["draw", "leaked"] {
session.execute(command);
assert_eq!(session.editor().lines(), &[String::new()]);
}
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &["healthy "]);
session.execute("w");
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"healthy "
);
session.execute("q");
assert!(session.editor().should_quit());
}
#[test]
fn rejected_effects_discard_entire_transaction_and_retire_commands_and_bindings() {
let fixture = Fixture::new();
fixture.healthy();
fixture.file(
"invalid.rb",
r#"
Vamp.plugin "invalid", requires: ["text"]
Vamp.command "invalid" do |editor, args|
editor.insert "partial"
editor.message "\e"
end
Vamp.command("sibling") { |editor, args| editor.insert "must be retired too" }
"#,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "invalid.rb"
config.plugin "healthy.rb"
config.bind "g", "invalid"
end
"#,
);
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &[String::new()]);
assert!(!session.editor().dirty());
assert!(
session
.editor()
.message()
.contains("Plugin invalid disabled: Unsupported control character")
);
let message = session.editor().message().to_owned();
session.handle(Input::Char('g'));
assert_eq!(session.editor().message(), message);
for command in ["invalid", "sibling"] {
session.execute(command);
assert_eq!(
session.editor().message(),
format!("Unknown command: {command}")
);
}
session.execute("healthy survivor");
assert_eq!(session.editor().lines(), &["healthy survivor"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[String::new()]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().message(), "Nothing to undo");
}
#[test]
fn callback_crash_and_timeout_retire_plugin_without_losing_core_editing() {
for failure in ["raise 'callback failed'", "Process.exit!(7)", "sleep 30"] {
let fixture = Fixture::new();
fixture.healthy();
fixture.file(
"failing.rb",
&format!(
r#"
Vamp.plugin "failing", requires: ["text"]
Vamp.command "fail" do |editor, args|
editor.insert "never commit"
{failure}
end
"#
),
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "failing.rb"
config.plugin "healthy.rb"
config.bind "g", "fail"
end
"#,
);
session.execute("fail");
assert_eq!(session.editor().lines(), &[String::new()]);
assert!(
session
.editor()
.message()
.contains("Plugin failing disabled:")
);
session.execute("fail");
assert_eq!(session.editor().message(), "Unknown command: fail");
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &[String::new()]);
session.execute("healthy survivor");
session.execute("w");
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
session.handle(Input::Escape);
session.execute("q");
assert!(!session.editor().should_quit());
session.execute("wq");
assert!(session.editor().should_quit());
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"xhealthy survivor"
);
}
}
#[test]
fn configured_bindings_preserve_modal_editing_history_and_literal_paste() {
let fixture = Fixture::new();
fixture.healthy();
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "healthy.rb"
config.bind "n", config.core.insert_first_nonblank
config.bind "i", nil
config.bind "ctrl+r", config.core.redo
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
config.bind "esc", nil, mode: config.modes.insert
config.bind "!", config.core.newline, mode: config.modes.insert
config.bind "shift+x", nil, mode: config.modes.insert
config.bind ":", nil
config.bind "space", config.core.command
config.bind "enter", nil, mode: config.modes.command
config.bind "ctrl+x", config.core.submit, mode: config.modes.command
config.bind "ctrl+c", config.core.normal, mode: config.modes.command
config.bind "ctrl+h", config.core.backspace, mode: config.modes.command
config.bind "g", config.core.undo
config.bind "g", "healthy"
config.bind "n", config.core.newline
config.bind "unknown", config.core.undo
config.bind "ctrl+f", "healthy", mode: config.modes.insert
end
"#,
);
session.handle(Input::Char('i'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char('n'));
for ch in "界Xab!".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(session.editor().lines(), &["界ab", ""]);
session.handle(Input::Escape);
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
session.handle(Input::Paste("X!".into()));
assert_eq!(session.editor().lines(), &["界ab", "X!"]);
session.handle(Input::Ctrl('C'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["界ab", ""]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[String::new()]);
session.handle(Input::Ctrl('r'));
session.handle(Input::Ctrl('r'));
assert_eq!(session.editor().lines(), &["界ab", "X!"]);
session.handle(Input::Char(':'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char(' '));
assert_eq!(session.editor().mode(), crate::editor::Mode::Command);
assert!(session.editor().sequence_pending().is_none());
for ch in "w界".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Backspace);
assert_eq!(session.editor().command_line(), "w");
session.handle(Input::Enter);
assert_eq!(session.editor().mode(), crate::editor::Mode::Command);
session.handle(Input::Ctrl('x'));
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"界ab\nX!"
);
session.handle(Input::Char(' '));
session.handle(Input::Char('q'));
session.handle(Input::Ctrl('c'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert!(session.editor().command_line().is_empty());
assert!(!session.editor().should_quit());
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &["healthy "]);
session.handle(Input::Char(' '));
session.handle(Input::Char('q'));
session.handle(Input::Ctrl('x'));
assert!(!session.editor().should_quit());
session.handle(Input::Char(' '));
session.handle(Input::Char('q'));
session.handle(Input::Char('!'));
session.handle(Input::Ctrl('x'));
assert!(session.editor().should_quit());
}
#[test]
fn default_dot_replaces_fresh_words_and_each_repeat_has_one_undo() {
let fixture = Fixture::new();
let buffer = fixture.file("buffer.txt", "old elephant zebra");
let mut session = EditorSession::open(Some(buffer), vec!["text".into()], None).unwrap();
session.editor.set_clipboard_mode("separate").unwrap();
for key in ['v', 'e', 'c'] {
session.handle(Input::Char(key));
}
for ch in "item".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["item elephant zebra"]);
for key in ['w', '3', '.'] {
session.handle(Input::Char(key));
}
assert_eq!(session.editor().lines(), &["item item zebra"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item elephant zebra"]);
session.handle(Input::Home);
for key in ['w', 'w', 'v', 'e', '.'] {
session.handle(Input::Char(key));
}
assert_eq!(session.editor().lines(), &["item elephant item"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item elephant zebra"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["old elephant zebra"]);
assert!(!session.editor().dirty());
}
#[test]
fn typed_repeat_remaps_use_resolved_motions_and_current_visual_ranges() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "old enormous tail end");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind ".", nil
config.bind ".", nil, mode: config.modes.visual
config.bind "r", config.core.repeat_last_edit
config.bind "r", config.core.repeat_last_edit, mode: config.modes.visual
config.bind "e", config.core.left, mode: config.modes.visual
config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual
end
"#,
);
session.handle(Input::Char('v'));
session.handle(Input::Ctrl('f'));
session.handle(Input::Char('c'));
for ch in "item".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Escape);
session.handle(Input::Char('w'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["item enormous tail end"]);
session.handle(Input::Char('r'));
assert_eq!(session.editor().lines(), &["item item tail end"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item enormous tail end"]);
session.handle(Input::Home);
for key in ['w', 'v', '2'] {
session.handle(Input::Char(key));
}
session.handle(Input::Ctrl('f'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Visual);
assert_eq!(session.editor().lines(), &["item enormous tail end"]);
session.handle(Input::Char('r'));
assert_eq!(session.editor().lines(), &["item item end"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item enormous tail end"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["old enormous tail end"]);
}
#[test]
fn repeat_recipe_crosses_buffers_but_pending_insert_and_undo_stay_per_buffer() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha");
let second = fixture.file("second.txt", "beta");
let mut session = fixture.open("");
session.handle(Input::Char('i'));
for ch in "one".chars() {
session.handle(Input::Char(ch));
}
session.execute(&format!("edit {}", second.display()));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(session.editor().lines(), &["beta"]);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["onebeta"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["beta"]);
session.execute("buffer 1");
assert_eq!(session.editor().lines(), &["onealpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["alpha"]);
session.handle(Input::Char('U'));
assert_eq!(session.editor().lines(), &["onealpha"]);
session.execute("buffer 2");
assert_eq!(session.editor().lines(), &["beta"]);
session.handle(Input::Char('U'));
assert_eq!(session.editor().lines(), &["onebeta"]);
session.handle(Input::Home);
session.handle(Input::Char('i'));
for ch in "two".chars() {
session.handle(Input::Char(ch));
}
session.execute("buffer 1");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["twoonealpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["onealpha"]);
session.execute("buffer 2");
assert_eq!(session.editor().lines(), &["twoonebeta"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["onebeta"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["beta"]);
}
#[test]
fn plugin_effects_commit_pending_typing_without_replacing_or_reinvoking_native_recipe() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "base");
fixture.file(
"effects.rb",
r##"
Vamp.plugin "effects", requires: ["text"]
calls = 0
Vamp.command("effect") do |editor, args|
calls += 1
File.open("calls.txt", "a") { |file| file.puts "#{calls} #{args}" }
case args
when "replace"
editor.replace "plugin"
when "insert"
editor.insert "?"
end
editor.message "calls=#{calls}"
end
"##,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "effects.rb"
end
"#,
);
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.execute("effect replace");
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(session.editor().lines(), &["plugin"]);
assert_eq!(session.editor().message(), "calls=1");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["!plugin"]);
assert_eq!(
fs::read_to_string(fixture.0.join("calls.txt")).unwrap(),
"1 replace\n"
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["plugin"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["!base"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["base"]);
session.handle(Input::Home);
session.execute("effect insert");
assert_eq!(session.editor().lines(), &["?base"]);
assert_eq!(session.editor().message(), "calls=2");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["!?base"]);
assert_eq!(
fs::read_to_string(fixture.0.join("calls.txt")).unwrap(),
"1 replace\n2 insert\n"
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["?base"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["base"]);
session.execute("effect message");
assert_eq!(session.editor().message(), "calls=3");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["!base"]);
assert_eq!(
fs::read_to_string(fixture.0.join("calls.txt")).unwrap(),
"1 replace\n2 insert\n3 message\n"
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["base"]);
}
#[test]
fn explicit_save_commits_pending_change_and_repeat_does_not_save_again() {
let fixture = Fixture::new();
let buffer = fixture.file("buffer.txt", "old elephant");
let mut session = fixture.open("");
for key in ['v', 'e', 'c'] {
session.handle(Input::Char(key));
}
for ch in "item".chars() {
session.handle(Input::Char(ch));
}
session.execute("w");
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant");
session.handle(Input::Home);
session.handle(Input::Char('w'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["item item"]);
assert!(session.editor().dirty());
assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant");
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item elephant"]);
assert!(!session.editor().dirty());
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["old elephant"]);
}
#[test]
fn dots_in_insert_and_focused_queries_are_literal_and_excluded_from_recipe() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.file_explorer_view = "list"
config.file_picker_view = "list"
config.bind "ctrl+f", config.core.file_explorer
config.bind "ctrl+o", config.core.file_picker
end
"#,
);
session.handle(Input::Char('i'));
session.handle(Input::Char('X'));
session.handle(Input::Escape);
session.handle(Input::Home);
session.handle(Input::Ctrl('f'));
for ch in ".query".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(session.editor().file_picker().unwrap().query, ".query");
assert_eq!(session.editor().lines(), &["Xalpha"]);
session.handle(Input::Escape);
assert!(!session.editor().file_picker().unwrap().focused);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Ctrl('o'));
session.handle(Input::Ctrl('u'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().file_picker().unwrap().query, ".");
assert_eq!(session.editor().lines(), &["Xalpha"]);
session.handle(Input::Escape);
assert!(session.editor().file_picker().is_none());
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["XXalpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["Xalpha"]);
session.handle(Input::Home);
session.handle(Input::Char('i'));
session.handle(Input::Char('.'));
session.handle(Input::Ctrl('p'));
assert!(
session
.editor()
.palette()
.unwrap()
.entries
.iter()
.any(|entry| entry.name == "core.repeat_last_edit")
);
session.handle(Input::Char('.'));
assert_eq!(session.editor().palette().unwrap().query, ".");
assert_eq!(session.editor().lines(), &[".Xalpha"]);
session.handle(Input::Escape);
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &[".!Xalpha"]);
for prompt in [':', '/'] {
session.handle(Input::Char(prompt));
session.handle(Input::Char('.'));
assert_eq!(session.editor().command_line(), ".");
assert_eq!(session.editor().lines(), &[".!Xalpha"]);
session.handle(Input::Escape);
}
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &[".!.!Xalpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[".!Xalpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["Xalpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["alpha"]);
}
#[test]
fn repeated_clipboard_paste_uses_captured_text_after_a_different_yank() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "one two three");
let mut session = fixture.open("");
for key in ['v', 'e', 'y'] {
session.handle(Input::Char(key));
}
session.handle(Input::Home);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["oneone two three"]);
for key in ['w', 'v', 'e', 'y'] {
session.handle(Input::Char(key));
}
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["oneoneone two three"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["oneone two three"]);
session.handle(Input::Home);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["twooneone two three"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["oneone two three"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["one two three"]);
}
#[test]
fn literal_paste_repeats_without_dispatch_and_preserves_native_input_groups() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "seed");
let mut session = fixture.open("");
session.handle(Input::Char('i'));
session.handle(Input::Char('a'));
session.handle(Input::Paste(".:q!\r\n界".into()));
session.handle(Input::Char('b'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["a.:q!", "界bseed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["a.:q!", "界seed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["aseed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["seed"]);
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["bseed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["seed"]);
session.handle(Input::Home);
session.handle(Input::Char('i'));
session.handle(Input::Paste(".:q!\r\n界".into()));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &[".:q!", "界seed"]);
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &[".:q!", ".:q!", "界界seed"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert!(!session.editor().should_quit());
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[".:q!", "界seed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["seed"]);
}
#[test]
fn typed_visual_change_remaps_c_and_command_execution_commits_one_replacement() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc DEF tail");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind "c", nil, mode: config.modes.visual
config.bind "ctrl+r", config.core.change, mode: config.modes.visual
config.bind "ctrl+v", config.core.select
config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual
end
"#,
);
session.handle(Input::Char('c'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert!(!session.editor().dirty());
session.handle(Input::Ctrl('v'));
session.handle(Input::Ctrl('f'));
session.handle(Input::Char('c'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Visual);
assert_eq!(session.editor().lines(), &["abc DEF tail"]);
session.handle(Input::Ctrl('r'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
assert_eq!(session.editor().cursor().column, 0);
assert_eq!(session.editor().lines(), &[" DEF tail"]);
for ch in "cZ".chars() {
session.handle(Input::Char(ch));
}
session.execute("w");
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"cZ DEF tail"
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["abc DEF tail"]);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["ababcc DEF tail"]);
}
#[test]
fn typed_visual_actions_copy_cut_paste_and_cancel_without_splitting_history() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "one two");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind "v", nil
config.bind "ctrl+v", config.core.select
config.bind "ctrl+c", config.core.copy, mode: config.modes.visual
config.bind "ctrl+x", config.core.cut, mode: config.modes.visual
config.bind "ctrl+g", config.core.normal, mode: config.modes.visual
config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual
config.bind "ctrl+b", config.core.paste_before
config.bind "ctrl+p", config.core.paste_after
end
"#,
);
session.handle(Input::Char('v'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Ctrl('v'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Visual);
session.handle(Input::Ctrl('f'));
assert_eq!(session.editor().selections()[0].end.column, 3);
session.handle(Input::Ctrl('c'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert!(!session.editor().dirty());
session.handle(Input::Ctrl('p'));
assert_eq!(session.editor().lines(), &["oonene two"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["one two"]);
session.handle(Input::Ctrl('v'));
session.handle(Input::Ctrl('f'));
session.handle(Input::Ctrl('x'));
assert_eq!(session.editor().lines(), &[" two"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Ctrl('b'));
assert_eq!(session.editor().lines(), &["one two"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[" two"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["one two"]);
assert!(!session.editor().dirty());
session.handle(Input::Ctrl('v'));
session.handle(Input::Ctrl('g'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert!(session.editor().selections().is_empty());
}
#[test]
fn typed_word_motions_remap_keys_and_keep_insert_typing_in_one_undo_group() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "foo...bar baz");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind "w", nil
config.bind "ctrl+f", config.core.word_next
config.bind "ctrl+p", config.core.word_previous
config.bind "ctrl+e", config.core.word_end
config.bind "ctrl+a", config.core.big_word_next
config.bind "ctrl+z", config.core.big_word_previous
config.bind "ctrl+t", config.core.big_word_end
config.bind "ctrl+b", config.core.word_previous, mode: config.modes.insert
end
"#,
);
session.handle(Input::Char('w'));
assert_eq!(session.editor().cursor().column, 0);
for (key, column) in [
('f', 3),
('e', 5),
('f', 6),
('p', 3),
('z', 0),
('a', 10),
('t', 12),
('z', 10),
('z', 0),
] {
session.handle(Input::Ctrl(key));
assert_eq!(session.editor().cursor().column, column, "{key}");
}
assert!(!session.editor().dirty());
session.handle(Input::Char('i'));
for ch in "wbeWBE".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Ctrl('b'));
assert_eq!(session.editor().cursor().column, 0);
session.handle(Input::Char('X'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["XwbeWBEfoo...bar baz"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["foo...bar baz"]);
assert!(!session.editor().dirty());
}
#[test]
fn typed_search_bindings_drive_navigation_and_the_search_prompt_only() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "cat\ncat\ncat");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind "ctrl+f", config.core.search
config.bind "ctrl+n", config.core.search_next
config.bind "ctrl+p", config.core.search_previous
config.bind "ctrl+x", config.core.submit, mode: config.modes.search
config.bind "ctrl+c", config.core.normal, mode: config.modes.search
config.bind "ctrl+h", config.core.backspace, mode: config.modes.search
config.bind "enter", nil, mode: config.modes.search
config.bind "/", nil
end
"#,
);
session.handle(Input::Char('/'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Ctrl('f'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Search);
for ch in "cat界".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Backspace);
session.handle(Input::Enter);
assert_eq!(session.editor().mode(), crate::editor::Mode::Search);
session.handle(Input::Ctrl('x'));
assert_eq!(session.editor().cursor().row, 0);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().cursor().row, 1);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().cursor().row, 2);
session.handle(Input::Ctrl('p'));
assert_eq!(session.editor().cursor().row, 1);
session.handle(Input::Ctrl('f'));
session.handle(Input::Char('q'));
session.handle(Input::Ctrl('c'));
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().cursor().row, 2);
assert!(!session.editor().dirty());
assert!(!session.editor().should_quit());
}
#[test]
fn typed_config_errors_discard_partial_bindings_and_leave_default_editing_available() {
let fixture = Fixture::new();
for (expression, error) in [
("config.core.reod", "NoMethodError"),
("config.modes.insret", "NoMethodError"),
("config.leader = 7", "leader key must be a String"),
("config.leader = nil", "leader key must be a String"),
(r#"config.leader = """#, "leader key must not be empty"),
(
r#"config.resolve_history_layout = "invalid""#,
"resolve history layout must be columns or base_below",
),
(
"config.resolve_history_layout = 7",
"resolve history layout must be a String",
),
(
"config.resolve_history_layout = nil",
"resolve history layout must be a String",
),
(
r#"config.resolve_history_layout = """#,
"resolve history layout must not be empty",
),
(
r#"config.diff_view_mode = "invalid""#,
"diff view mode must be split or unified",
),
(
r#"config.diff_split_orientation = "invalid""#,
"diff split orientation must be vertical or horizontal",
),
(
r#"config.diff_split_orientation = "left_right""#,
"diff split orientation must be vertical or horizontal",
),
(
r#"config.diff_split_orientation = "above_below""#,
"diff split orientation must be vertical or horizontal",
),
(
"config.diff_view_mode = 7",
"diff view mode must be a String",
),
(
"config.diff_view_mode = nil",
"diff view mode must be a String",
),
(
r#"config.diff_view_mode = """#,
"diff view mode must not be empty",
),
(
"config.diff_split_orientation = 7",
"diff split orientation must be a String",
),
(
"config.diff_split_orientation = nil",
"diff split orientation must be a String",
),
(
r#"config.diff_split_orientation = """#,
"diff split orientation must not be empty",
),
(
"config.file_explorer_side = 7",
"file explorer side must be a String",
),
(
"config.file_explorer_side = nil",
"file explorer side must be a String",
),
(
r#"config.file_explorer_side = """#,
"file explorer side must not be empty",
),
(
"config.file_picker_view = 7",
"file picker view must be a String",
),
(
"config.file_picker_view = nil",
"file picker view must be a String",
),
(
r#"config.file_picker_view = """#,
"file picker view must not be empty",
),
(
"config.file_explorer_view = 7",
"file explorer view must be a String",
),
(
"config.file_explorer_view = nil",
"file explorer view must be a String",
),
(
r#"config.file_explorer_view = """#,
"file explorer view must not be empty",
),
(
"config.clipboard_mode = 7",
"clipboard mode must be a String",
),
(
"config.clipboard_mode = nil",
"clipboard mode must be a String",
),
(
r#"config.clipboard_mode = """#,
"clipboard mode must not be empty",
),
(r#"config.bind "n", "core.insert""#, "TypeError"),
(r#"config.bind "n", nil, mode: "insert""#, "TypeError"),
(r#"config.bind "n", config.modes.insert"#, "TypeError"),
(
r#"config.bind "n", nil, mode: config.core.insert"#,
"TypeError",
),
(r#"config.core.redo.name = "core.undo""#, "FrozenError"),
(
r#"config.core.redo.name.replace("core.undo")"#,
"FrozenError",
),
(r#"config.modes.insert.name = "normal""#, "FrozenError"),
(
r#"config.modes.insert.name.replace("normal")"#,
"FrozenError",
),
] {
let mut session = fixture.open(&format!(
"Vamp.configure do |config|\n config.leader = \",\"\n config.bind \"i\", nil\n {expression}\nend"
));
assert!(
session
.editor()
.startup_diagnostic()
.unwrap()
.lines
.iter()
.any(|line| line.contains(error)),
"{expression}"
);
session.handle(Input::Enter);
session.handle(Input::Char('i'));
session.handle(Input::Char('界'));
session.handle(Input::Escape);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(session.editor().lines(), &["界"], "{expression}");
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[String::new()], "{expression}");
session.handle(Input::Char(' '));
assert_eq!(
session.editor().sequence_pending(),
Some("Space"),
"{expression}"
);
session.handle(Input::Char(' '));
assert!(session.editor().palette().is_some(), "{expression}");
session.handle(Input::Escape);
}
}
#[test]
fn rejected_config_diagnostic_persists_until_dismissed_and_default_editing_survives() {
for (config, reason, dismiss) in [
("raise 'broken config'", "broken config", Input::Enter),
(
"Vamp.configure { |config| config.lsp['unsupported-language'] = true }",
"unsupported-language",
Input::Escape,
),
] {
let fixture = Fixture::new();
let mut session = fixture.open(config);
let diagnostic = session
.editor()
.startup_diagnostic()
.expect("config failure must be visible");
let text = diagnostic.lines.join("\n");
assert!(text.contains(&fixture.0.join("config.rb").display().to_string()));
assert!(text.contains(reason));
session.editor.set_message("unrelated background message");
session.poll();
for input in [
Input::Char('i'),
Input::Paste("accidental edit".into()),
Input::Ctrl('p'),
] {
session.handle(input);
}
assert_eq!(
session
.editor()
.startup_diagnostic()
.unwrap()
.lines
.join("\n"),
text
);
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(session.editor().lines(), &[""]);
session.handle(dismiss);
assert!(session.editor().startup_diagnostic().is_none());
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
session.handle(Input::Escape);
session.execute("wq");
assert!(session.editor().should_quit());
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"x"
);
}
}
fn drain_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
// Process-I/O timeout, not a date-dependent assertion.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
loop {
session.poll();
if ready(session) {
return;
}
assert!(
std::time::Instant::now() < deadline,
"plugin did not settle: {}",
session.editor().message()
);
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
fn barrier(session: &mut EditorSession, message: &str) {
session.execute(&format!("fixture_barrier {message}"));
drain_until(session, |session| session.editor().message() == message);
}
fn diagnostic_effect(id: usize, revision: u64, message: &str) -> serde_json::Value {
serde_json::json!({
"type": "diagnostics", "buffer_id": id, "revision": revision,
"items": [{"start": {"row": 0, "column": 0}, "end": {"row": 0, "column": 1},
"severity": "warning", "message": message}]
})
}
fn publish(session: &mut EditorSession, effects: Vec<serde_json::Value>) {
session.execute(&format!(
"fixture_publish {}",
serde_json::json!({"effects": effects})
));
}
fn hold(session: &mut EditorSession, gate: &Path, effects: Vec<serde_json::Value>) {
session.execute(&format!(
"fixture_hold {}",
serde_json::json!({"gate": gate, "effects": effects})
));
}
fn release(session: &mut EditorSession, gate: &Path) {
fs::write(gate, "").unwrap();
let delivered = format!("delivered {}", gate.display());
drain_until(session, |session| session.editor().message() == delivered);
}
#[test]
fn startup_chrome_installs_named_values_without_editing_the_document() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let session = fixture.open(
r#"
Vamp.configure do |config|
config.bar.top = {left: [:mode, :language], right: [:battery]}
config.bar.bottom = {left: [:tabs], center: [:workspace]}
config.labels = {battery: "bat 38%", workspace: "tools\n"}
end
"#,
);
assert_eq!(session.editor.label("battery"), "bat 38%");
assert_eq!(session.editor.label("workspace"), "tools�");
assert_eq!(session.editor.top_bar_config().left, ["mode", "language"]);
assert_eq!(session.editor.top_bar_config().right, ["battery"]);
assert_eq!(session.editor.bottom_bar_config().left, ["tabs"]);
assert_eq!(session.editor.bottom_bar_config().center, ["workspace"]);
assert_eq!(session.editor.lines(), ["source"]);
assert_eq!(session.editor.text_revision(), 0);
assert!(!session.editor.dirty());
}
#[test]
fn asynchronous_result_labels_outlive_the_source_without_applying_stale_edits() {
let fixture = Fixture::new();
let target = fixture.file("other.txt", "");
let mut session = EditorSession::open(None, vec![], None).unwrap();
let source = InvocationSource {
buffer_id: session.editor.buffer_id(),
pane_id: session.editor.pane_id(),
revision: session.editor.text_revision(),
};
session.editor.execute("tab.new");
session
.editor
.execute(&format!("edit {}", target.display()));
assert_ne!(session.editor.buffer_id(), source.buffer_id);
session.editor.handle(Input::Char('i'));
let message = session.editor.message().to_owned();
for (id, effects) in [
(
1,
vec![
Effect::Insert("stale".into()),
Effect::Message("stale message".into()),
Effect::Label {
name: "battery".into(),
text: "bat 38%".into(),
},
],
),
(
2,
vec![
Effect::Label {
name: "battery".into(),
text: "must not leak".into(),
},
Effect::Label {
name: "bad-name".into(),
text: "invalid".into(),
},
],
),
] {
session
.requests
.entry("fixture".into())
.or_default()
.pending
.insert(id, source);
let result = session.apply_response("fixture", id, effects);
if id == 1 {
assert!(result.unwrap());
} else {
assert!(result.is_err());
}
assert_eq!(session.editor.label("battery"), "bat 38%");
assert_eq!(session.editor.lines(), [""]);
assert_eq!(session.editor.message(), message);
assert_eq!(session.editor.mode(), Mode::Insert);
assert_eq!(session.editor.text_revision(), 0);
assert!(!session.editor.dirty());
}
session.editor.handle(Input::Char('x'));
session.editor.handle(Input::Escape);
session.editor.handle(Input::Char('u'));
assert_eq!(session.editor.lines(), [""]);
assert_eq!(session.editor.label("battery"), "bat 38%");
}
#[test]
fn named_labels_survive_tab_switches_and_remain_atomic_in_readonly_views() {
let fixture = Fixture::new();
let mut session = fixture.asynchronous(None);
publish(
&mut session,
vec![serde_json::json!({
"type": "label", "name": "battery", "text": "bat 37%"
})],
);
drain_until(&mut session, |session| {
session.editor().label("battery") == "bat 37%"
});
let gate = fixture.0.join("label.gate");
hold(
&mut session,
&gate,
vec![serde_json::json!({
"type": "label", "name": "battery", "text": "bat 38%"
})],
);
barrier(&mut session, "label request received");
session.execute("tab.new");
release(&mut session, &gate);
assert_eq!(session.editor().label("battery"), "bat 38%");
session.execute("tab.previous");
assert_eq!(session.editor().label("battery"), "bat 38%");
let update = |text: &str| PluginUpdate {
request_id: None,
effects: vec![UpdateEffect::Label {
name: "battery".into(),
text: text.into(),
}],
};
assert!(
!session
.apply_update("generic_fixture", update("bat 38%"))
.unwrap()
);
let rejected = PluginUpdate {
request_id: None,
effects: vec![
UpdateEffect::Label {
name: "battery".into(),
text: "must not leak".into(),
},
UpdateEffect::SemanticTokens {
buffer_id: session.editor().buffer_id(),
revision: session.editor().text_revision(),
tokens: None,
},
],
};
assert!(session.apply_update("generic_fixture", rejected).is_err());
assert_eq!(session.editor().label("battery"), "bat 38%");
let message = session.editor.message().to_owned();
for effects in [
vec![
UpdateEffect::Message {
text: "must not leak".into(),
},
UpdateEffect::Label {
name: "battery".into(),
text: "changed".into(),
},
UpdateEffect::Label {
name: "bad-name".into(),
text: "invalid".into(),
},
],
vec![
UpdateEffect::Label {
name: "battery".into(),
text: "x".repeat(2 * 1024 * 1024),
},
UpdateEffect::Label {
name: "workspace".into(),
text: "x".repeat(2 * 1024 * 1024),
},
],
] {
assert!(
session
.apply_update(
"generic_fixture",
PluginUpdate {
request_id: None,
effects
}
)
.is_err()
);
assert_eq!(session.editor.label("battery"), "bat 38%");
assert_eq!(session.editor.label("workspace"), "");
assert_eq!(session.editor.message(), message);
}
session.editor.execute("diff");
assert!(session.editor.mutation_blocked());
assert!(
session
.apply_update("generic_fixture", update("bat\t39%\n"))
.unwrap()
);
assert_eq!(session.editor().label("battery"), "bat�39%�");
assert_eq!(session.editor().lines(), [""]);
assert!(session.apply_update("generic_fixture", update("")).unwrap());
assert_eq!(session.editor().label("battery"), "");
}
#[test]
fn idle_poll_refreshes_clean_and_parked_files_on_the_interval_but_keeps_dirty_text() {
let fixture = Fixture::new();
let active = fixture.file("buffer.txt", "old");
let parked = fixture.file("parked.txt", "parked");
let mut session = fixture.open("");
session.execute(&format!("edit {}", parked.display()));
session.execute("buffer 1");
let now = session.last_file_refresh;
let revision = session.editor().text_revision();
fs::write(&active, "external replacement").unwrap();
fs::write(&parked, "external parked replacement").unwrap();
session.poll_at(now + Duration::from_millis(249));
assert_eq!(session.editor().lines(), &["old"]);
session.dispatch(Invocation {
plugin: "core".into(),
command: "lsp.definition".into(),
args: String::new(),
});
assert!(session.requests["core"].latest.is_some());
assert!(session.poll_at(now + Duration::from_millis(250)));
assert!(session.requests["core"].latest.is_none());
assert_eq!(session.editor().lines(), &["external replacement"]);
assert!(session.editor().text_revision() > revision);
let documents: Vec<_> = session.editor().documents().collect();
assert_eq!(documents[1].lines, ["external parked replacement"]);
session.handle(Input::Char('x'));
let dirty = session.editor().lines().to_vec();
let revision = session.editor().text_revision();
fs::write(&active, "second external replacement").unwrap();
session.poll_at(now + Duration::from_millis(500));
assert_eq!(session.editor().lines(), dirty.as_slice());
assert_eq!(session.editor().text_revision(), revision);
assert_eq!(
fs::read_to_string(&active).unwrap(),
"second external replacement"
);
}
#[test]
fn clean_external_reload_notifies_async_plugin_with_change_without_save() {
let fixture = Fixture::new();
let file = fixture.file("buffer.txt", "old");
let mut session = fixture.asynchronous(Some(file.clone()));
barrier(&mut session, "opened");
let now = session.last_file_refresh;
let revision = session.editor().text_revision();
fs::write(&file, "updated external text").unwrap();
assert!(session.poll_at(now + Duration::from_millis(250)));
assert_eq!(session.editor().lines(), &["updated external text"]);
let updated_revision = session.editor().text_revision();
assert!(updated_revision > revision);
assert_eq!(
session.documents[&session.editor().buffer_id()]
.snapshot
.revision,
updated_revision
);
barrier(&mut session, "reload delivered");
let events = fixture.events();
let changes: Vec<_> = events
.iter()
.filter(|event| event["event"] == "change")
.collect();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0]["document"]["text"], "updated external text");
assert_eq!(changes[0]["document"]["revision"], updated_revision);
assert!(!events.iter().any(|event| event["event"] == "save"));
}
#[test]
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
let fixture = Fixture::new();
let mut session = fixture.asynchronous(None);
barrier(&mut session, "initial");
let initial = fixture.events();
assert_eq!(initial[0]["event"], "open");
assert_eq!(initial[0]["document"]["id"], 1);
assert_eq!(initial[0]["document"]["revision"], 0);
assert_eq!(initial[0]["document"]["path"], serde_json::Value::Null);
assert_eq!(initial[0]["document"]["text"], "");
assert_eq!(
initial[0]["document"]["root"],
session.editor().project_root().to_string_lossy().as_ref()
);
session.handle(Input::Char('i'));
for input in [
Input::Char('😀'),
Input::Char('e'),
Input::Char('\u{301}'),
Input::Enter,
] {
session.handle(input);
}
session.handle(Input::Escape);
let revision = session.editor().text_revision();
session.handle(Input::Char('h'));
session.handle(Input::Char('l'));
let renamed = fixture.0.join("saved unicode 😀.txt");
session.execute(&format!("w {}", renamed.display()));
session.execute("w");
session.execute(&format!("w {}", fixture.0.join("missing/failed").display()));
barrier(&mut session, "saved");
assert_eq!(session.editor().text_revision(), revision);
assert_eq!(
session.editor().documents().next().unwrap().save_revision,
2
);
assert_eq!(fs::read_to_string(&renamed).unwrap(), "😀e\u{301}\n");
let events = fixture.events();
assert_eq!(
events
.iter()
.filter(|event| event["event"] == "change")
.count(),
revision as usize
);
let kinds: Vec<_> = events
.iter()
.filter_map(|event| event["event"].as_str())
.collect();
assert_eq!(&kinds[kinds.len() - 4..], ["close", "open", "save", "save"]);
let reopened = events
.iter()
.find(|event| event["event"] == "open" && !event["document"]["path"].is_null())
.unwrap();
assert_eq!(reopened["document"]["revision"], revision);
assert_eq!(reopened["document"]["text"], "😀e\u{301}\n");
let target = fixture.file("target.txt", "target");
session.execute(&format!("edit {}", target.display()));
session.handle(Input::Char('x'));
session.execute("buffer 1");
let documents: Vec<_> = session.editor().documents().collect();
assert_eq!(
documents
.iter()
.map(|document| document.id)
.collect::<Vec<_>>(),
[1, 2]
);
assert_eq!(documents[1].lines, ["arget"]);
assert_eq!(documents[1].revision, 1);
assert_eq!(documents[1].save_revision, 0);
session.execute("bdelete! 2");
barrier(&mut session, "closed");
let events = fixture.events();
let closed = events
.iter()
.find(|event| event["event"] == "close" && event["document"]["id"] == 2)
.unwrap();
assert_eq!(closed["document"]["text"], "arget");
assert_eq!(closed["document"]["revision"], 1);
session.execute("q!");
barrier(&mut session, "quit flushed");
assert!(session.editor().should_quit());
let events = fixture.events();
let final_close = events
.iter()
.rev()
.find(|event| event["event"] == "close")
.unwrap();
assert_eq!(final_close["document"]["id"], 1);
assert_eq!(
final_close["document"]["path"],
renamed.to_string_lossy().as_ref()
);
}
#[test]
fn async_annotations_and_messages_preserve_insert_group_and_ignore_stale_closed_sources() {
let fixture = Fixture::new();
let file = fixture.file("buffer.txt", "abc");
let mut session = fixture.asynchronous(Some(file));
let gate = fixture.0.join("annotations-ready");
hold(&mut session, &gate, vec![diagnostic_effect(1, 1, "live")]);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
release(&mut session, &gate);
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
assert_eq!(
session
.editor()
.diagnostic_at_cursor()
.map(|diagnostic| diagnostic.message.as_str()),
None
);
assert_eq!(session.editor().diagnostics().count(), 0);
session.handle(Input::Char('y'));
assert_eq!(session.editor().diagnostics().count(), 0);
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["abc"]);
let current = session.editor().text_revision();
let mut stale = diagnostic_effect(1, 0, "stale");
stale["items"][0]["end"]["row"] = serde_json::json!(999);
publish(
&mut session,
vec![
stale,
diagnostic_effect(999, current, "closed"),
serde_json::json!({"type":"message","text":"stale checked"}),
],
);
drain_until(&mut session, |session| {
session.editor().message() == "stale checked"
});
assert_eq!(session.editor().diagnostics().count(), 0);
assert!(session.plugins.contains_key("generic_fixture"));
let target = fixture.file("closed.txt", "closed");
session.execute(&format!("edit {}", target.display()));
let gate = fixture.0.join("closed-ready");
hold(
&mut session,
&gate,
vec![diagnostic_effect(2, 0, "late close")],
);
session.execute("bdelete 2");
release(&mut session, &gate);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().diagnostics().count(), 0);
assert!(session.plugins.contains_key("generic_fixture"));
}
#[test]
fn invalid_annotation_batches_retire_owner_and_clear_hidden_annotations_only() {
let fixture = Fixture::new();
let file = fixture.file("buffer.txt", "source");
let target = fixture.file("target.txt", "target");
let mut session = fixture.asynchronous(Some(file));
session.execute(&format!("edit {}", target.display()));
session.execute("buffer 1");
publish(
&mut session,
vec![
diagnostic_effect(1, 0, "source"),
diagnostic_effect(2, 0, "hidden"),
],
);
drain_until(&mut session, |session| {
session.editor().diagnostics().count() == 1
});
let other = crate::plugin::Diagnostic {
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 1 },
severity: crate::plugin::DiagnosticSeverity::Hint,
message: "other owner".into(),
};
session
.editor
.replace_diagnostics("other", 1, 0, vec![other])
.unwrap();
let mut invalid = diagnostic_effect(1, 0, "bad range");
invalid["items"][0]["end"]["column"] = serde_json::json!(999);
publish(
&mut session,
vec![diagnostic_effect(2, 0, "must not commit"), invalid],
);
drain_until(&mut session, |session| {
!session.plugins.contains_key("generic_fixture")
});
assert!(
session
.editor()
.message()
.contains("Diagnostic position is outside")
);
assert_eq!(session.editor().diagnostics().count(), 1);
assert_eq!(
session.editor().diagnostic_at_cursor().unwrap().message,
"other owner"
);
session.execute("buffer 2");
assert_eq!(session.editor().diagnostics().count(), 0);
session.execute("fixture_publish {}");
assert!(session.editor().message().contains("Unknown command"));
session.handle(Input::Char('x'));
assert_eq!(session.editor().lines(), ["arget"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["target"]);
}
fn navigation_effect(path: &Path) -> serde_json::Value {
serde_json::json!({"type":"navigate", "path":path, "row":0, "column":1})
}
#[test]
fn async_navigation_reuses_dirty_target_and_reports_open_failure_without_retiring() {
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "source");
let target = fixture.file("dirty 😀 target.txt", "a😀e\u{301}");
let mut session = fixture.asynchronous(Some(source));
session.execute(&format!("edit {}", target.display()));
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
session.execute("buffer 1");
publish(&mut session, vec![navigation_effect(&target)]);
drain_until(&mut session, |session| session.editor().buffer_id() == 2);
assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]);
assert_eq!(
session.editor().cursor(),
crate::editor::Cursor { row: 0, column: 1 }
);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(session.editor().buffer_count(), 2);
assert_eq!(fs::read_to_string(&target).unwrap(), "a😀e\u{301}");
let gate = fixture.0.join("missing-ready");
hold(
&mut session,
&gate,
vec![navigation_effect(&fixture.0.join("missing"))],
);
fs::write(&gate, "").unwrap();
drain_until(&mut session, |session| {
session
.requests
.get("generic_fixture")
.is_some_and(|requests| requests.pending.is_empty())
&& session.editor().message().starts_with("delivered ")
});
assert_eq!(session.editor().buffer_id(), 2);
assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]);
assert!(session.plugins.contains_key("generic_fixture"));
// A standalone failure message remains visible when no later plugin message overwrites it.
publish(
&mut session,
vec![navigation_effect(&fixture.0.join("missing"))],
);
drain_until(&mut session, |session| {
session.editor().message().starts_with("Navigation failed:")
});
assert_eq!(session.editor().buffer_id(), 2);
assert_eq!(session.editor().buffer_count(), 2);
assert!(session.plugins.contains_key("generic_fixture"));
}
#[test]
fn async_navigation_drops_superseded_changed_or_inactive_source_replies() {
for guard in ["superseded", "changed", "inactive", "closed"] {
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "source");
let target = fixture.file("target.txt", "target");
let mut session = fixture.asynchronous(Some(source));
let gate = fixture.0.join("navigation-ready");
hold(&mut session, &gate, vec![navigation_effect(&target)]);
match guard {
"superseded" => barrier(&mut session, "new request"),
"changed" => {
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
}
"inactive" => {
let other = fixture.file("other.txt", "other");
session.execute(&format!("edit {}", other.display()));
}
"closed" => session.execute("bdelete 1"),
_ => unreachable!(),
}
let active = session.editor().buffer_id();
let text = session.editor().context().text;
release(&mut session, &gate);
assert_eq!(session.editor().buffer_id(), active, "{guard}");
assert_eq!(session.editor().context().text, text, "{guard}");
assert!(session.plugins.contains_key("generic_fixture"), "{guard}");
assert!(
!session
.editor()
.buffers()
.any(|buffer| buffer.path == Some(target.as_path())),
"{guard}"
);
let invokes: Vec<_> = fixture
.events()
.into_iter()
.filter(|event| event["invoke"] == "fixture_hold")
.collect();
assert_eq!(invokes.len(), 1);
assert_eq!(invokes[0]["buffer_id"], 1);
assert_eq!(invokes[0]["revision"], 0);
assert_eq!(invokes[0]["row"], 1);
assert_eq!(invokes[0]["column"], 1);
}
}
#[test]
fn stale_async_text_response_and_dead_host_never_break_native_typing() {
let fixture = Fixture::new();
let file = fixture.file("buffer.txt", "source");
let mut session = fixture.asynchronous(Some(file));
session.execute("fixture_replace BAD");
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
drain_until(&mut session, |session| {
session
.requests
.get("generic_fixture")
.is_some_and(|requests| requests.pending.is_empty())
});
assert_eq!(session.editor().lines(), ["xsource"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
session.handle(Input::Char('y'));
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["source"]);
let revision = session.editor().text_revision();
publish(
&mut session,
vec![diagnostic_effect(1, revision, "before crash")],
);
drain_until(&mut session, |session| {
session.editor().diagnostics().count() == 1
});
session.execute("fixture_crash");
session.handle(Input::Char('i'));
session.handle(Input::Char('z'));
drain_until(&mut session, |session| {
!session.plugins.contains_key("generic_fixture")
});
assert_eq!(session.editor().diagnostics().count(), 0);
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
session.handle(Input::Char('q'));
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["source"]);
assert!(!session.editor().should_quit());
session.execute("plugins");
assert!(session.editor().message().contains("core [core]"));
}
#[test]
fn async_navigation_invokes_once_at_primary_with_multiple_carets() {
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "abc\nabc");
let target = fixture.file("target.txt", "target");
let mut session = fixture.asynchronous(Some(source));
session.handle(Input::Char('l'));
session.handle(Input::Ctrl('e'));
assert_eq!(session.editor().selection_count(), 2);
assert_eq!(
session.editor().cursor(),
crate::editor::Cursor { row: 1, column: 1 }
);
let gate = fixture.0.join("primary-ready");
hold(&mut session, &gate, vec![navigation_effect(&target)]);
release(&mut session, &gate);
assert_eq!(session.editor().path(), Some(target.as_path()));
assert_eq!(session.editor().selection_count(), 1);
let invokes: Vec<_> = fixture
.events()
.into_iter()
.filter(|event| event["invoke"] == "fixture_hold")
.collect();
assert_eq!(invokes.len(), 1);
assert_eq!(invokes[0]["row"], 2);
assert_eq!(invokes[0]["column"], 2);
assert_eq!(invokes[0]["revision"], 0);
}
#[test]
fn pending_plugin_edits_do_not_modify_diff_buffers_or_disable_the_plugin() {
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "original");
let started = fixture.0.join("started");
let release = fixture.0.join("release");
fixture.file(
"delayed.rb",
r#"Vamp.plugin "delayed", requires: ["text"], async: true
Vamp.command "slow" do |editor, _|
File.write(Vamp.options.fetch("started"), "ready")
sleep 0.001 until File.exist?(Vamp.options.fetch("release"))
editor.insert "late edit"
end
Vamp.command "ping" do |editor, _|
editor.message "still alive"
end
"#,
);
let config = fixture.file(
"config.rb",
&format!(
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin 'delayed.rb', options: {{started: {}, release: {}}}\nend\n",
serde_json::to_string(&started.to_string_lossy()).unwrap(),
serde_json::to_string(&release.to_string_lossy()).unwrap(),
),
);
let mut session =
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
session.execute("slow");
drain_until(&mut session, |_| started.exists());
session.execute("diff");
assert!(session.editor().diff().is_some());
fs::write(&release, "release").unwrap();
// Wait for transport consumption, then assert the consumer-visible result.
drain_until(&mut session, |session| {
session
.requests
.get("delayed")
.is_none_or(|requests| requests.pending.is_empty())
});
assert_eq!(session.editor().context().text, "original");
assert_eq!(fs::read_to_string(&source).unwrap(), "original");
session.execute("diff.close");
session.execute("ping");
drain_until(&mut session, |session| {
session.editor().message() == "still alive"
});
assert_eq!(session.editor().context().text, "original");
}
#[test]
fn async_responses_edit_only_current_bound_working_after_and_refresh_projection() {
use crate::diff::{
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, RevisionTarget,
};
let fixture = Fixture::new();
fixture.file("buffer.txt", "base");
let mut session = fixture.open("");
session.editor.execute("diff");
let (id, _) = session.editor.take_diff_request().unwrap();
let revision = |target, label: &str| Revision {
target,
label: label.into(),
description: String::new(),
category: Category::Commits,
};
session.editor.apply_diff_result(
id,
Ok(DiffOutcome::Catalog(Catalog {
kind: RepoKind::Git,
notice: None,
entries: vec![
revision(RevisionTarget::Commit("base".into()), "base"),
revision(RevisionTarget::Working, "Working"),
],
})),
);
for input in [Input::Down, Input::Enter, Input::Enter] {
session.editor.handle(input);
}
let (id, _) = session.editor.take_diff_request().unwrap();
session.editor.apply_diff_result(
id,
Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "working:snapshot".into()],
root: fixture.0.canonicalize().unwrap(),
before: "base".into(),
after: "Working".into(),
editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(),
files: vec![ChangedFile {
review_revisions: None,
stats: None,
path: PathBuf::from("buffer.txt"),
old_path: None,
status: FileStatus::Modified,
loaded: true,
before: vec!["base".into()],
after: vec!["base".into()],
before_eof: false,
after_eof: false,
hunks: Vec::new(),
notice: None,
}],
})),
);
session.editor.handle(Input::BackTab);
let source = InvocationSource {
buffer_id: session.editor.buffer_id(),
pane_id: session.editor.pane_id(),
revision: session.editor.text_revision(),
};
session
.requests
.entry("fixture".into())
.or_default()
.pending
.insert(1, source);
assert!(
session
.apply_response("fixture", 1, vec![Effect::Insert("accepted".into())])
.unwrap()
);
assert_eq!(session.editor.lines(), ["acceptedbase"]);
let diff = session.editor.diff().unwrap();
assert_eq!(diff.live_revision, Some(session.editor.text_revision()));
assert_eq!(
diff.comparison.as_ref().unwrap().files[0].after,
session.editor.lines()
);
session
.requests
.get_mut("fixture")
.unwrap()
.pending
.insert(2, source);
assert!(
!session
.apply_response("fixture", 2, vec![Effect::Insert("stale".into())])
.unwrap()
);
session.editor.scroll_diff(Focus::Before, false);
let readonly = InvocationSource {
buffer_id: session.editor.buffer_id(),
pane_id: session.editor.pane_id(),
revision: session.editor.text_revision(),
};
session
.requests
.get_mut("fixture")
.unwrap()
.pending
.insert(3, readonly);
assert!(
!session
.apply_response("fixture", 3, vec![Effect::Insert("readonly".into())])
.unwrap()
);
assert_eq!(session.editor.lines(), ["base"]);
assert!(
session
.editor
.documents()
.any(|document| document.lines == ["acceptedbase"])
);
session
.requests
.get_mut("fixture")
.unwrap()
.pending
.insert(4, readonly);
assert!(
session
.apply_response("fixture", 4, vec![Effect::Message("allowed".into())])
.unwrap()
);
assert_eq!(session.editor.message(), "allowed");
session.editor.execute("diff.close");
assert!(session.editor.dirty());
}
#[test]
fn applied_pull_request_filters_survive_picker_reopen_but_not_restart() {
use crate::pull_requests::ReviewFilter;
let fixture = Fixture::new();
let path = fixture.0.join("state/pull_requests.json");
let mut session =
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path.clone())))
.unwrap();
assert!(!path.exists());
session.editor.execute("pull_requests");
// Consume the queued listing so menu input cannot start a forge worker.
assert!(session.editor.take_diff_request().is_some());
session.handle(Input::Ctrl('f'));
session.handle(Input::Char(' '));
assert!(!path.exists());
session.handle(Input::Escape);
assert_eq!(
session.editor.pull_request_preferences,
crate::pull_requests::Preferences::default()
);
assert!(!path.exists());
session.handle(Input::Ctrl('f'));
session.handle(Input::Char(' '));
session.handle(Input::Down);
session.handle(Input::Char(' '));
assert!(!path.exists());
session.handle(Input::Enter);
assert!(!path.exists());
assert!(!session.editor.pull_request_preferences_changed);
assert_eq!(
session.editor.pull_request_preferences.filters,
[ReviewFilter::Other]
);
session.handle(Input::Escape);
session.editor.execute("pull_requests");
assert!(session.editor.take_diff_request().is_some());
session.handle(Input::Ctrl('f'));
session.handle(Input::Enter);
assert_eq!(
session.editor.pull_request_preferences.filters,
[ReviewFilter::Other]
);
// A sort edit still persists, without carrying the active filter to disk.
session.handle(Input::Ctrl('s'));
session.handle(Input::Char('1'));
session.handle(Input::Enter);
assert!(path.exists());
let reopened =
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path))).unwrap();
assert_eq!(
reopened.editor.pull_request_preferences.filters,
crate::pull_requests::Preferences::default().filters
);
assert_eq!(reopened.editor.pull_request_configured, [false, false]);
}
#[test]
fn configured_pull_request_sections_replace_only_their_persisted_section() {
use crate::pull_requests::{Direction, Preferences, ReviewFilter, SortField, SortRule};
let fixture = Fixture::new();
let path = fixture.0.join("state/pull_requests.json");
let (store, mut preferences, _) =
crate::pull_request_preferences::Store::load(Ok(path.clone()));
preferences.filters = vec![ReviewFilter::Other];
preferences.sort.insert(
SortField::UpdatedAt,
SortRule {
precedence: Some(1),
direction: Direction::Asc,
},
);
store.save(&preferences).unwrap();
let bytes = fs::read(&path).unwrap();
for (configuration, expected, configured) in [
(
"config.pull_request_sort = {}",
Preferences {
sort: Default::default(),
filters: Preferences::default().filters,
},
[true, false],
),
(
"config.pull_request_filters = []",
Preferences {
sort: preferences.sort.clone(),
filters: vec![],
},
[false, true],
),
(
"config.pull_request_sort = {created_at: {direction: :asc}}; config.pull_request_filters = nil",
Preferences {
sort: [(
SortField::CreatedAt,
SortRule {
precedence: None,
direction: Direction::Asc,
},
)]
.into(),
filters: Preferences::default().filters,
},
[true, false],
),
] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| {configuration} }}"),
);
let session = EditorSession::open_with_preferences_path(
None,
vec![],
Some(&config),
Some(Ok(path.clone())),
)
.unwrap();
assert_eq!(session.editor.pull_request_preferences, expected);
assert_eq!(session.editor.pull_request_configured, configured);
assert_eq!(fs::read(&path).unwrap(), bytes);
}
}
#[test]
fn corrupt_pull_request_storage_warns_and_apply_remains_session_only() {
let fixture = Fixture::new();
let path = fixture.file("pull_requests.json", "preserve invalid bytes");
let mut session =
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path.clone())))
.unwrap();
assert!(session.editor.message().contains("session-only"));
session.editor.execute("pull_requests");
assert!(session.editor.take_diff_request().is_some());
session.handle(Input::Ctrl('s'));
session.handle(Input::Char('1'));
session.handle(Input::Enter);
assert!(session.editor.message().contains("preserved"));
assert_eq!(fs::read_to_string(path).unwrap(), "preserve invalid bytes");
}
#[test]
fn failed_pull_request_save_keeps_applied_session_preferences_and_reports_failure() {
let fixture = Fixture::new();
let parent = fixture.0.join("state");
let path = parent.join("pull_requests.json");
let mut session =
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path))).unwrap();
fs::write(&parent, "do not replace").unwrap();
session.editor.execute("pull_requests");
assert!(session.editor.take_diff_request().is_some());
session.handle(Input::Ctrl('s'));
session.handle(Input::Char('1'));
session.handle(Input::Enter);
assert!(session.editor.message().contains("session-only"));
assert!(!session.editor.pull_request_preferences_changed);
assert_eq!(fs::read_to_string(parent).unwrap(), "do not replace");
}