pmacs/tests/resource_reconciliation_acc...

2053 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! dired Stage 2a acceptance — rename and delete reconciliation.
//!
//! `docs/dired-stage2-framing.md` §5, §6, §10; acceptance items 2338
//! and 5055.
//!
//! **This suite contains no dired content.** Stage 2a ships no dired
//! surface at all: it is the substrate transaction that Stage 2b's `R`,
//! `D` and `x` then stand on, and it closes three defects on `main`
//! that need no dired to be worth fixing — the workspace-edit phantom
//! buffer, the raw first-match registry lookup both `apply_resource_op`
//! arms used, and the incomplete removal lifecycle.
//!
//! Two disciplines the framing forces on every row here:
//!
//! * **Drive the real entry point.** A reconciliation with no
//! production caller passes every direct-call test, so the rename
//! rows go through `pmacs.fs.rename` (worker-dispatched, harvested in
//! the drain) or through `pmacs.buffer.apply_resource_op` (synchronous,
//! main-thread), never through `EditorCore::reconcile_rename`.
//! * **Pump to quiescence, never to a frame count**, because every
//! mutation is worker-dispatched.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use pmacs::editor::EditorState;
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
fn exec(state: &EditorState, source: &str) {
state
.lua_host
.lua()
.load(source.to_owned())
.exec()
.unwrap_or_else(|e| panic!("lua exec failed: {e}\n--- source ---\n{source}"));
}
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
state
.lua_host
.lua()
.load(source.to_owned())
.eval()
.unwrap_or_else(|e| panic!("lua eval failed: {e}\n--- source ---\n{source}"))
}
/// Escape a path for embedding in a Lua double-quoted string.
fn lua_str(path: &Path) -> String {
path.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
}
/// Pump the async runtime until `predicate` holds or the deadline
/// lapses. Quiescence, not a frame count: the whole point of items 24
/// and 25 is that the reconciliation happens in the drain, and the drain
/// runs whenever a reply arrives.
fn pump_until<F: Fn(&EditorState) -> bool>(state: &mut EditorState, what: &str, predicate: F) {
let deadline = Instant::now() + Duration::from_secs(5);
while !predicate(state) {
assert!(Instant::now() < deadline, "pump deadline exceeded: {what}");
state.tick_async();
std::thread::sleep(Duration::from_millis(2));
}
}
/// Pump a fixed number of times without any expectation. Used only to
/// give a dispatched job every chance to settle before asserting that
/// something did **not** happen.
fn pump_a_while(state: &mut EditorState) {
for _ in 0..80 {
state.tick_async();
std::thread::sleep(Duration::from_millis(2));
}
}
/// A canonicalized temp directory. Canonicalized because the buffer
/// registry stores lexically-normalized absolute paths and macOS's
/// `/var` is a symlink to `/private/var`; without this the expected
/// paths below would differ from the stored ones by a symlink hop.
struct Fixture {
_dir: tempfile::TempDir,
root: PathBuf,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
Self { _dir: dir, root }
}
fn write(&self, rel: &str, contents: &str) -> PathBuf {
let path = self.root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, contents).unwrap();
path
}
fn dir(&self, rel: &str) -> PathBuf {
let path = self.root.join(rel);
std::fs::create_dir_all(&path).unwrap();
path
}
fn at(&self, rel: &str) -> PathBuf {
self.root.join(rel)
}
}
fn editor() -> EditorState {
let state = EditorState::new_with_roots(&crate::iso::roots());
// No language server may spawn from these fixtures. The LSP rows
// that DO want one configure it explicitly.
exec(&state, "pmacs.lsp.config = {}");
state
}
/// Open `path` into a buffer and return the Lua global name holding its
/// handle. Buffers are held on globals so a test can re-read their path
/// and name after the reconciliation moved them.
fn open_as(state: &EditorState, global: &str, path: &Path) {
exec(
state,
&format!(
"_G.{global} = pmacs.buffer.find_or_open(\"{}\")",
lua_str(path)
),
);
}
fn buffer_path(state: &EditorState, global: &str) -> Option<String> {
eval(
state,
&format!("local b = _G.{global}; return b and b:path() or nil"),
)
}
fn buffer_name(state: &EditorState, global: &str) -> Option<String> {
eval(
state,
&format!("local b = _G.{global}; return b and b:name() or nil"),
)
}
fn status(state: &EditorState) -> String {
state.core.borrow().status.clone()
}
fn buffer_is_valid(state: &EditorState, global: &str) -> bool {
eval(
state,
&format!("local b = _G.{global}; return (b ~= nil) and b:is_valid()"),
)
}
/// Dispatch a rename **without awaiting** the handle, then pump.
/// Fire-and-forget is the shape item 25 pins: the reconciliation must
/// not live at result consumption.
fn rename_fire_and_forget(state: &mut EditorState, from: &Path, to: &Path) {
exec(
state,
&format!(
"pmacs.fs.rename(\"{}\", \"{}\")",
lua_str(from),
lua_str(to)
),
);
pump_until(state, "rename lands on disk", |_| to.exists());
// The rename landing on disk and the reply reaching the main thread
// are two events; pump past the first to reach the second.
pump_a_while(state);
}
fn remove_fire_and_forget(state: &mut EditorState, path: &Path) {
exec(state, &format!("pmacs.fs.remove(\"{}\")", lua_str(path)));
pump_until(state, "remove lands on disk", |_| !path.exists());
pump_a_while(state);
}
// ---------------------------------------------------------------------------
// 25 — no-await rename
// ---------------------------------------------------------------------------
/// Acceptance 25. Dispatch `pmacs.fs.rename`, **never take the
/// result**, pump: the open buffer's path has moved.
///
/// Bite: fails if the reconciliation lives at result consumption
/// (`_take_result`) rather than in the drain, because nothing here ever
/// consumes the handle.
#[test]
fn acc25_a_never_awaited_rename_still_moves_the_open_buffers_path() {
let fx = Fixture::new();
let old = fx.write("notes.txt", "hello\n");
let new = fx.at("renamed.txt");
let mut state = editor();
open_as(&state, "B", &old);
assert_eq!(
buffer_path(&state, "B").as_deref(),
Some(old.to_str().unwrap())
);
rename_fire_and_forget(&mut state, &old, &new);
assert_eq!(
buffer_path(&state, "B").as_deref(),
Some(new.to_str().unwrap()),
"the buffer must follow a fire-and-forget rename"
);
}
// ---------------------------------------------------------------------------
// 26, 27, 28 — the walk
// ---------------------------------------------------------------------------
/// Acceptance 26. A buffer open on `dir/child.txt` follows
/// `dir` → `newdir`.
#[test]
fn acc26_a_buffer_under_a_renamed_directory_follows_it() {
let fx = Fixture::new();
fx.dir("tree");
let child = fx.write("tree/child.txt", "x\n");
let mut state = editor();
open_as(&state, "B", &child);
rename_fire_and_forget(&mut state, &fx.at("tree"), &fx.at("newtree"));
assert_eq!(
buffer_path(&state, "B").as_deref(),
Some(fx.at("newtree/child.txt").to_str().unwrap()),
"a descendant keeps its relative tail under the new root"
);
}
/// Acceptance 27. **Every** match, not the first: two descendant
/// buffers under the renamed directory *and* two buffers visiting the
/// same exact path all move.
///
/// Bite: fails against `find_by_path`'s first match. One child buffer
/// would not defeat a first-match implementation; this does, twice over
/// — and the duplicate-path pair is the case `find_by_path` cannot even
/// see, because it returns on the first hit.
#[test]
fn acc27_every_affected_buffer_moves_not_only_the_first() {
let fx = Fixture::new();
fx.dir("tree");
let one = fx.write("tree/one.txt", "1\n");
let two = fx.write("tree/nested/two.txt", "2\n");
let mut state = editor();
open_as(&state, "ONE", &one);
open_as(&state, "TWO", &two);
// Two buffers on the SAME exact path. `pmacs.buffer.from_file`
// creates a fresh buffer without deduping, which is how a duplicate
// path binding is reachable from public Lua.
exec(
&state,
&format!("_G.DUP = pmacs.buffer.from_file(\"{}\")", lua_str(&one)),
);
let dup_first: String = eval(&state, "return _G.ONE:path()");
let dup_second: String = eval(&state, "return _G.DUP:path()");
assert_eq!(
dup_first, dup_second,
"precondition: two distinct buffers bound to one path"
);
rename_fire_and_forget(&mut state, &fx.at("tree"), &fx.at("newtree"));
assert_eq!(
buffer_path(&state, "ONE").as_deref(),
Some(fx.at("newtree/one.txt").to_str().unwrap()),
"first descendant"
);
assert_eq!(
buffer_path(&state, "TWO").as_deref(),
Some(fx.at("newtree/nested/two.txt").to_str().unwrap()),
"second, more deeply nested descendant"
);
assert_eq!(
buffer_path(&state, "DUP").as_deref(),
Some(fx.at("newtree/one.txt").to_str().unwrap()),
"the second buffer on the same path — invisible to a first-match \
lookup, and left pointing at nothing by one"
);
}
/// Acceptance 28. Renaming `/…/foo` must not rebind a buffer on
/// `/…/foobar`.
///
/// Bite: fails against a string `starts_with` instead of a
/// path-component prefix.
#[test]
fn acc28_a_false_string_prefix_is_not_a_path_prefix() {
let fx = Fixture::new();
fx.dir("foo");
let inside = fx.write("foo/a.txt", "in\n");
let sibling = fx.write("foobar.txt", "out\n");
let mut state = editor();
open_as(&state, "IN", &inside);
open_as(&state, "OUT", &sibling);
rename_fire_and_forget(&mut state, &fx.at("foo"), &fx.at("renamed"));
assert_eq!(
buffer_path(&state, "IN").as_deref(),
Some(fx.at("renamed/a.txt").to_str().unwrap()),
"the real descendant moves"
);
assert_eq!(
buffer_path(&state, "OUT").as_deref(),
Some(sibling.to_str().unwrap()),
"`foobar.txt` shares a string prefix with `foo` and is not under it"
);
}
/// Acceptance 28, delete side — **and this is the row that bites.**
///
/// The rename row above cannot falsify a string-prefix walk on its own:
/// `reconcile_rename` calls `Path::strip_prefix` to rebuild the
/// descendant's tail, and that is component-aware too, so a false
/// prefix match is silently dropped a second time and the buffer stays
/// put. Deletion has no such second guard — the walk's verdict IS the
/// kill list — so the containment rule has to be pinned here.
///
/// Bite: a string `starts_with` instead of `Path::starts_with` kills a
/// buffer on `foobar.txt` when `foo/` is deleted.
#[test]
fn acc28_delete_a_false_string_prefix_does_not_widen_the_kill_list() {
let fx = Fixture::new();
fx.dir("foo");
let inside = fx.write("foo/a.txt", "in\n");
let sibling = fx.write("foobar.txt", "out\n");
let state = editor();
open_as(&state, "KEEP", &fx.write("keep.txt", "k\n"));
open_as(&state, "IN", &inside);
open_as(&state, "OUT", &sibling);
exec(
&state,
&format!(
"pmacs.buffer.apply_resource_op {{ kind = \"delete\", \
path = \"{}\", recursive = true }}",
lua_str(&fx.at("foo"))
),
);
assert!(!fx.at("foo").exists(), "the directory is gone");
assert!(
!buffer_is_valid(&state, "IN"),
"the real descendant is reconciled away"
);
assert!(
buffer_is_valid(&state, "OUT"),
"`foobar.txt` shares a string prefix with `foo` and is not under \
it — killing it destroys an unrelated buffer whose file still \
exists"
);
assert!(
sibling.exists(),
"and that file is indeed still on disk, which is what makes the \
kill wrong rather than merely early"
);
}
// ---------------------------------------------------------------------------
// 29 — name provenance, both directions
// ---------------------------------------------------------------------------
/// Acceptance 29(a). A buffer opened by a **relative** path is named
/// `foo.rs` while its stored path is absolute, and its name follows the
/// rename because its load site recorded `PathDerived`.
///
/// Bite: rev 7's string-equality rule fails this — the name never
/// equalled the normalized path, so it would have been left stale while
/// insisting it was user-chosen.
#[test]
fn acc29a_a_relative_opens_name_follows_the_rename() {
let fx = Fixture::new();
let old = fx.write("relative.txt", "x\n");
let new = fx.at("moved.txt");
let mut state = editor();
// Open by a path whose *spelling* is not the stored path: a `.`
// component is folded by normalization but kept in the name, which
// reproduces the relative-open shape without depending on the
// process cwd.
let as_given = fx.at("./relative.txt");
open_as(&state, "B", &as_given);
assert_eq!(
buffer_name(&state, "B").as_deref(),
Some(as_given.to_str().unwrap()),
"precondition: the name is the path AS GIVEN, not the stored path"
);
assert_eq!(
buffer_path(&state, "B").as_deref(),
Some(old.to_str().unwrap()),
"precondition: only the stored path is normalized"
);
rename_fire_and_forget(&mut state, &old, &new);
assert_eq!(
buffer_path(&state, "B").as_deref(),
Some(new.to_str().unwrap())
);
assert_eq!(
buffer_name(&state, "B").as_deref(),
Some(new.to_str().unwrap()),
"the name follows because the load site recorded path provenance, \
not because the old name happened to match the old path"
);
}
/// Acceptance 29(b). A name set explicitly through
/// `pmacs.buffer.set_name` survives the rename **even when that string
/// normalizes to the file's own stored path**.
///
/// Bite: rev 8's path-equivalence heuristic fails this — the chosen
/// name normalizes to the exact stored path, so the heuristic would
/// overwrite it.
#[test]
fn acc29b_an_explicitly_set_name_survives_even_when_it_denotes_the_file() {
let fx = Fixture::new();
let old = fx.write("notes", "x\n");
let new = fx.at("notes-renamed");
let mut state = editor();
open_as(&state, "B", &old);
// The chosen name IS the file's absolute path. Under a
// path-equivalence rule this is indistinguishable from a
// path-derived name; under recorded provenance it is not.
exec(
&state,
&format!("pmacs.buffer.set_name(_G.B, \"{}\")", lua_str(&old)),
);
assert_eq!(
buffer_name(&state, "B").as_deref(),
Some(old.to_str().unwrap()),
"precondition: the explicit name normalizes to the stored path"
);
rename_fire_and_forget(&mut state, &old, &new);
assert_eq!(
buffer_path(&state, "B").as_deref(),
Some(new.to_str().unwrap()),
"the PATH always follows"
);
assert_eq!(
buffer_name(&state, "B").as_deref(),
Some(old.to_str().unwrap()),
"and the explicitly chosen name does not, however much it looks \
like a path-derived one"
);
}
// ---------------------------------------------------------------------------
// 36, 37 — the synchronous arm, and failure
// ---------------------------------------------------------------------------
/// Acceptance 36. `apply_resource_op`'s rename finds a buffer whose
/// stored path is normalized but whose op names it **un-normalized**.
///
/// Bite: fails against the raw `find_by_path(&from)` this arm used —
/// stored paths are normalized on write, so a raw lookup with a `.`
/// component in it misses the buffer entirely and the rename silently
/// reconciles nothing.
#[test]
fn acc36_the_synchronous_arm_matches_an_un_normalized_op_path() {
let fx = Fixture::new();
let old = fx.write("sync.txt", "x\n");
let new = fx.at("sync-moved.txt");
let state = editor();
open_as(&state, "B", &old);
let unnormalized = fx.at("./sync.txt");
exec(
&state,
&format!(
"pmacs.buffer.apply_resource_op {{ kind = \"rename\", \
old_path = \"{}\", new_path = \"{}\" }}",
lua_str(&unnormalized),
lua_str(&new)
),
);
assert!(new.exists(), "the rename happened on disk");
assert_eq!(
buffer_path(&state, "B").as_deref(),
Some(new.to_str().unwrap()),
"the buffer must be found even though the op spelled the source \
path differently from the stored one"
);
}
/// Acceptance 37. A **failed** rename reconciles nothing — and fires no
/// hook.
#[test]
fn acc37_a_failed_rename_reconciles_nothing_and_fires_no_hook() {
let fx = Fixture::new();
let present = fx.write("present.txt", "x\n");
let missing = fx.at("does-not-exist.txt");
let mut state = editor();
open_as(&state, "B", &present);
exec(
&state,
"_G.FIRED = 0
pmacs.hook.add('resource.renamed', function() _G.FIRED = _G.FIRED + 1 end)",
);
// Renaming a path that does not exist fails in the worker.
exec(
&state,
&format!(
"pmacs.fs.rename(\"{}\", \"{}\")",
lua_str(&missing),
lua_str(&fx.at("target.txt"))
),
);
pump_a_while(&mut state);
let fired: i64 = eval(&state, "return _G.FIRED");
assert_eq!(fired, 0, "a failed mutation reconciles nothing");
assert_eq!(
buffer_path(&state, "B").as_deref(),
Some(present.to_str().unwrap()),
"and no unrelated buffer moved"
);
}
// ---------------------------------------------------------------------------
// 50, 55 — the hooks
// ---------------------------------------------------------------------------
/// Acceptance 50. `resource.renamed` fires **exactly once** per
/// successful rename, with `(old, new)` as normalized absolute paths,
/// and does not fire for a rename that failed. The symmetric assertion
/// for `resource.deleted` accompanies it.
///
/// Bite: fails if the hook fires for a failed rename, or fires with the
/// un-normalized path a caller happened to spell.
#[test]
fn acc50_the_hooks_fire_once_with_normalized_paths() {
let fx = Fixture::new();
let old = fx.write("hooked.txt", "x\n");
let new = fx.at("hooked-moved.txt");
let doomed = fx.write("doomed.txt", "y\n");
let mut state = editor();
exec(
&state,
"_G.RENAMES = {}
_G.DELETES = {}
pmacs.hook.add('resource.renamed', function(a, b)
_G.RENAMES[#_G.RENAMES + 1] = tostring(a) .. ' -> ' .. tostring(b)
end)
pmacs.hook.add('resource.deleted', function(p)
_G.DELETES[#_G.DELETES + 1] = tostring(p)
end)",
);
// Spell BOTH paths un-normalized, so the hook's arguments can only
// be canonical if the fire site normalizes them.
exec(
&state,
&format!(
"pmacs.fs.rename(\"{}\", \"{}\")",
lua_str(&fx.at("./hooked.txt")),
lua_str(&fx.at("./hooked-moved.txt"))
),
);
pump_until(&mut state, "rename hook", |s| {
let n: i64 = eval(s, "return #_G.RENAMES");
n > 0
});
pump_a_while(&mut state);
let renames: String = eval(&state, "return table.concat(_G.RENAMES, '|')");
assert_eq!(
renames,
format!("{} -> {}", old.display(), new.display()),
"exactly one row, and both paths canonical — a path-keyed \
subscriber needs the form the registry keys on"
);
exec(
&state,
&format!("pmacs.fs.remove(\"{}\")", lua_str(&fx.at("./doomed.txt"))),
);
pump_until(&mut state, "delete hook", |s| {
let n: i64 = eval(s, "return #_G.DELETES");
n > 0
});
pump_a_while(&mut state);
let deletes: String = eval(&state, "return table.concat(_G.DELETES, '|')");
assert_eq!(
deletes,
doomed.display().to_string(),
"one row, canonical path"
);
}
/// Acceptance 55. Both hooks are `all-must-succeed`, not
/// short-circuit: with two subscribers registered and the **first one
/// raising**, the second still runs and the error is reported rather
/// than swallowed.
///
/// Bite: fails against a `short-circuit` registration, where the first
/// subscriber's return would stop the fan-out and silently prevent every
/// later one from reconciling — which no test asserting only "the hook
/// fired" would catch.
#[test]
fn acc55_a_raising_subscriber_does_not_stop_the_fan_out() {
let fx = Fixture::new();
let old = fx.write("fanout.txt", "x\n");
let new = fx.at("fanout-moved.txt");
let doomed = fx.write("fanout-doomed.txt", "y\n");
let mut state = editor();
exec(
&state,
"_G.SECOND_RAN = 0
_G.SECOND_DELETED = 0
pmacs.hook.add('resource.renamed', function() error('first subscriber explodes') end)
pmacs.hook.add('resource.renamed', function() _G.SECOND_RAN = _G.SECOND_RAN + 1 end)
pmacs.hook.add('resource.deleted', function() error('first subscriber explodes') end)
pmacs.hook.add('resource.deleted', function() _G.SECOND_DELETED = _G.SECOND_DELETED + 1 end)",
);
rename_fire_and_forget(&mut state, &old, &new);
let ran: i64 = eval(&state, "return _G.SECOND_RAN");
assert_eq!(
ran, 1,
"`all-must-succeed` collects the first callback's error and \
continues; a short-circuit registration would have stopped here"
);
remove_fire_and_forget(&mut state, &doomed);
let deleted: i64 = eval(&state, "return _G.SECOND_DELETED");
assert_eq!(deleted, 1, "same for `resource.deleted`");
// The error is reported, not swallowed: the hook error log is the
// `*errors*` buffer.
let errors: String = eval(
&state,
"for _, b in ipairs(pmacs.buffer.list()) do
if b:name() == '*errors*' then return b:slice(0, b:len()) end
end
return ''",
);
assert!(
errors.contains("first subscriber explodes"),
"the raising subscriber's error must be reported; *errors* held: \
{errors:?}"
);
}
// ---------------------------------------------------------------------------
// 23, 24 — the delete lookups
// ---------------------------------------------------------------------------
/// Acceptance 23. `apply_resource_op`'s delete reaches **descendants**
/// and a **second buffer on the same path** — the raw first-match lookup
/// replaced by the shared prefix-aware, normalizing query.
///
/// (#190 owns the modified-buffer refusal; it refuses before disk, so by
/// the time this lane's reconciliation runs there is no modified buffer
/// on the synchronous path to spare. This row asserts the lookup fix.)
#[test]
fn acc23_the_synchronous_delete_reaches_descendants_and_duplicates() {
let fx = Fixture::new();
fx.dir("tree/nested");
let one = fx.write("tree/one.txt", "1\n");
fx.write("tree/nested/two.txt", "2\n");
let state = editor();
open_as(&state, "ONE", &one);
open_as(&state, "TWO", &fx.at("tree/nested/two.txt"));
exec(
&state,
&format!("_G.DUP = pmacs.buffer.from_file(\"{}\")", lua_str(&one)),
);
// Keep an unrelated buffer alive so the last-buffer refusal is not
// what this row measures.
open_as(&state, "KEEP", &fx.write("keep.txt", "k\n"));
exec(
&state,
&format!(
"pmacs.buffer.apply_resource_op {{ kind = \"delete\", \
path = \"{}\", recursive = true }}",
lua_str(&fx.at("./tree"))
),
);
assert!(!fx.at("tree").exists(), "the tree is gone from disk");
assert!(
!buffer_is_valid(&state, "ONE"),
"a buffer directly under the deleted directory"
);
assert!(
!buffer_is_valid(&state, "TWO"),
"a more deeply nested descendant"
);
assert!(
!buffer_is_valid(&state, "DUP"),
"the second buffer on the same path — the one a first-match \
lookup cannot see, which #190 deliberately left in place \
because it had no two-phase kill to route it through"
);
assert!(buffer_is_valid(&state, "KEEP"), "an unrelated buffer");
}
/// Acceptance 24. A **fire-and-forget** `pmacs.fs.remove` reconciles
/// too: never taking the handle still kills the unmodified buffer, which
/// is what makes the drain harvest the right seam rather than dired
/// firing the hook itself.
#[test]
fn acc24_a_never_awaited_remove_still_kills_the_unmodified_buffer() {
let fx = Fixture::new();
let doomed = fx.write("gone.txt", "x\n");
let mut state = editor();
open_as(&state, "KEEP", &fx.write("keep.txt", "k\n"));
open_as(&state, "B", &doomed);
assert!(buffer_is_valid(&state, "B"));
remove_fire_and_forget(&mut state, &doomed);
assert!(
!buffer_is_valid(&state, "B"),
"the harvest must reconcile a delete no one awaited"
);
assert!(buffer_is_valid(&state, "KEEP"));
}
/// Acceptance 18's substrate half, and §6's modified case: a **modified**
/// buffer whose file is deleted out from under it keeps its contents. The
/// buffer half is the part of the promise that is robust, because it runs
/// at drain time against whatever state exists then.
#[test]
fn a_modified_buffer_survives_a_delete_with_its_contents() {
let fx = Fixture::new();
let doomed = fx.write("dirty.txt", "original\n");
let mut state = editor();
open_as(&state, "KEEP", &fx.write("keep.txt", "k\n"));
open_as(&state, "B", &doomed);
exec(&state, "_G.B:insert(0, 'edited ')");
let modified: bool = eval(&state, "return _G.B:is_modified()");
assert!(modified, "precondition");
remove_fire_and_forget(&mut state, &doomed);
assert!(
buffer_is_valid(&state, "B"),
"a modified buffer is kept alive deliberately, not killed"
);
let contents: String = eval(&state, "return _G.B:slice(0, _G.B:len())");
assert_eq!(contents, "edited original\n", "with its contents intact");
assert!(!doomed.exists(), "while the file is gone");
}
// ---------------------------------------------------------------------------
// 51, 52, 53 — the removal lifecycle
// ---------------------------------------------------------------------------
/// Acceptance 51. A killed buffer completes **both** removal phases:
/// after a delete reconciles, an `on_removed` callback registered for
/// that buffer has fired and its buffer-local keymap entries are gone.
///
/// Bite: fails against an implementation that calls only
/// `EditorCore::kill_buffer`, which does no phase-2 cleanup at all.
///
/// Note 51 and 52 are a matched pair and **neither alone is
/// sufficient** — each pre-existing removal path passes one and fails
/// the other, which is exactly why both phases had to be named.
#[test]
fn acc51_a_killed_buffer_completes_both_removal_phases() {
let fx = Fixture::new();
let doomed = fx.write("phase2.txt", "x\n");
let mut state = editor();
open_as(&state, "KEEP", &fx.write("keep.txt", "k\n"));
open_as(&state, "B", &doomed);
exec(
&state,
"_G.ON_REMOVED = 0
pmacs.buffer.on_removed(_G.B, function() _G.ON_REMOVED = _G.ON_REMOVED + 1 end)
pmacs.command.define { name = 'test.noop', description = 'x', fn = function() end }
pmacs.keymap.bind { scope = 'buffer', buffer = _G.B,
sequence = 'C-c C-1', command = 'test.noop' }
-- `pmacs.keymap.lookup` is deliberately raw-global, so the
-- buffer-scoped row is only visible through `list()`.
function _G.BOUND_ROWS()
local n = 0
for _, e in ipairs(pmacs.keymap.list()) do
if e.command == 'test.noop' then n = n + 1 end
end
return n
end",
);
let bound_before: i64 = eval(&state, "return _G.BOUND_ROWS()");
assert_eq!(
bound_before, 1,
"precondition: the buffer-local binding exists"
);
remove_fire_and_forget(&mut state, &doomed);
assert!(!buffer_is_valid(&state, "B"), "phase 1 removed the buffer");
let fired: i64 = eval(&state, "return _G.ON_REMOVED");
assert_eq!(
fired, 1,
"phase 2 must fire the registered on_removed callback; 0 means the \
reconciliation called `EditorCore::kill_buffer` alone"
);
let bound_after: i64 = eval(&state, "return _G.BOUND_ROWS()");
assert_eq!(
bound_after, 0,
"phase 2 must purge the buffer-scoped keymap, so a later buffer \
cannot inherit a dead one's bindings"
);
}
/// Acceptance 52. A window displaying the deleted buffer is
/// **redirected**, not left dangling: no window holds a removed id.
///
/// Bite: fails against `remove_buffer_and_fire`, which is what
/// `apply_resource_op` used — phase 2 without phase 1, so
/// `BufferRegistry::remove` runs while every window showing the buffer
/// keeps pointing at the id it just dropped.
#[test]
fn acc52_a_window_showing_the_deleted_buffer_is_redirected() {
let fx = Fixture::new();
let doomed = fx.write("shown.txt", "x\n");
let state = editor();
open_as(&state, "KEEP", &fx.write("keep.txt", "k\n"));
open_as(&state, "B", &doomed);
exec(&state, "pmacs.window.switch_buffer(_G.B)");
let doomed_id = state.core.borrow().active_buffer_id();
assert!(
state
.core
.borrow()
.windows
.values()
.any(|w| w.buffer_id == doomed_id),
"precondition: a window shows the doomed buffer"
);
exec(
&state,
&format!(
"pmacs.buffer.apply_resource_op {{ kind = \"delete\", path = \"{}\" }}",
lua_str(&doomed)
),
);
let core = state.core.borrow();
assert!(
!core.registry.borrow().contains(doomed_id),
"the buffer was removed"
);
let dangling: Vec<_> = core
.windows
.iter()
.filter(|(_, w)| !core.registry.borrow().contains(w.buffer_id))
.map(|(id, w)| (*id, w.buffer_id))
.collect();
assert!(
dangling.is_empty(),
"no window may hold a removed buffer id; dangling: {dangling:?}"
);
assert!(
!core.windows.values().any(|w| w.buffer_id == doomed_id),
"and specifically not the deleted one"
);
}
/// Acceptance 53. The last-buffer and mid-edit refusals are
/// **reported, not silent**, and neither aborts the reconciliation of
/// other buffers.
#[test]
fn acc53_the_last_buffer_refusal_keeps_the_buffer_and_the_rest_proceeds() {
// Half one: the file behind the only open buffer. `kill_buffer`
// refuses to remove the last remaining buffer, so the file goes and
// the buffer stays.
let fx = Fixture::new();
let only = fx.write("only.txt", "x\n");
let mut state = editor();
// Drop every other buffer so the target really is the last one.
exec(
&state,
&format!(
"_G.ONLY = pmacs.buffer.find_or_open(\"{}\")
for _, b in ipairs(pmacs.buffer.list()) do
if tostring(b) ~= tostring(_G.ONLY) then
pcall(pmacs.buffer.kill, b)
end
end
return #pmacs.buffer.list()",
lua_str(&only)
),
);
let count: i64 = eval(&state, "return #pmacs.buffer.list()");
assert_eq!(count, 1, "precondition: exactly one buffer is open");
remove_fire_and_forget(&mut state, &only);
assert!(
buffer_is_valid(&state, "ONLY"),
"the last remaining buffer cannot be killed, so it survives the \
deletion of its file"
);
// **Reported, not silent.** Survival alone is not the criterion: the
// buffer is still bound to a path whose file is gone, so the next
// `C-x C-s` recreates the file the user deleted. That is recoverable
// only if the user is told.
let said = status(&state);
assert!(
said.contains("could not be closed"),
"the refusal must reach the status channel; status was {said:?}"
);
// Asserted as the buffer's OWN name, not as the basename. The
// message opens with `deleted only.txt:` — the *path* — so a
// `contains("only.txt")` check passes with the attribution stripped,
// which is exactly how this assertion was vacuous when first
// written. A path-backed buffer's name is the full path, and only
// the `buffer "…"` prefix can produce it.
let expect_named = format!("buffer {:?}", only.display().to_string());
assert!(
said.contains(&expect_named),
"the refusal must name the buffer, because `cannot kill the last \
remaining buffer` alone does not say WHICH buffer is now bound \
to a deleted path; wanted {expect_named:?} in {said:?}"
);
// Half two: a directory of buffers where one refuses removal. The
// rest must still reconcile.
let fx2 = Fixture::new();
fx2.dir("batch");
let a = fx2.write("batch/a.txt", "a\n");
let b = fx2.write("batch/b.txt", "b\n");
let c = fx2.write("batch/c.txt", "c\n");
let mut state2 = editor();
open_as(&state2, "KEEP", &fx2.write("keep.txt", "k\n"));
open_as(&state2, "A", &a);
open_as(&state2, "B", &b);
open_as(&state2, "C", &c);
// B refuses: it is modified.
exec(&state2, "_G.B:insert(0, 'dirty ')");
remove_fire_and_forget(&mut state2, &fx2.at("batch/a.txt"));
remove_fire_and_forget(&mut state2, &fx2.at("batch/b.txt"));
remove_fire_and_forget(&mut state2, &fx2.at("batch/c.txt"));
assert!(!buffer_is_valid(&state2, "A"), "A reconciled");
assert!(
buffer_is_valid(&state2, "B"),
"B was kept because it is modified"
);
assert!(
!buffer_is_valid(&state2, "C"),
"and C still reconciled afterwards — one refusal must not abort \
the rest"
);
// The kept-modified case reports too, and says what the consequence
// is. This is the asynchronous race the framing's H1 leaves open:
// #190 refuses before disk on the synchronous path, but
// `pmacs.fs.remove` dispatches a worker, so a buffer modified in the
// interval reaches the drain with its file already gone.
let said2 = status(&state2);
assert!(
said2.contains("unsaved changes kept"),
"a modified buffer kept alive over a deleted file must be \
reported; status was {said2:?}"
);
assert!(
said2.contains("RECREATE"),
"and the report must state the consequence — saving it puts the \
deleted file back; status was {said2:?}"
);
// Same discipline: the full path is the buffer's name, while the
// message's `deleted b.txt:` prefix is only the basename.
assert!(
said2.contains(&b.display().to_string()),
"the kept buffer must be named, and by its own name rather than \
the deleted path's basename; status was {said2:?}"
);
}
// ---------------------------------------------------------------------------
// 53b — a mid-edit refusal leaves editor state UNCHANGED
// ---------------------------------------------------------------------------
/// Acceptance 53b, all three assertions, **stated individually**.
///
/// With the buffer `editing_in_progress`, displayed in an ordinary
/// window, shown in a side window, and present in `round_trip_buffers`,
/// a delete reconciling it must leave each of the following provably
/// untouched. Each fails independently against the same one-line bite —
/// removing the `editing_in_progress` preflight — which is the point: a
/// single compound assertion can pass on two of the three and hide the
/// third.
///
/// | # | Assertion | What the missing preflight breaks |
/// |---|---|---|
/// | i | the ordinary window still shows the buffer, cursor/selection/`view_top` intact | `kill_buffer` redirects the window to the fallback before `BufferRegistry::remove` refuses |
/// | ii | the side window is still open and still shows the buffer | `remove_side_window` collapses it first |
/// | iii | the buffer is still in `round_trip_buffers` | `round_trip_buffers.remove` runs first — the **first** thing `kill_buffer` does, and the easiest to miss |
#[test]
#[allow(
clippy::too_many_lines,
reason = "three independent assertions, each with its own precondition; a compound check is exactly what this row exists to avoid"
)]
fn acc53b_a_mid_edit_refusal_leaves_window_side_and_round_trip_state_untouched() {
let fx = Fixture::new();
let doomed = fx.write("midedit.txt", "0123456789\nsecond line\n");
let mut state = editor();
// A grid frontend's real frame size is its declaration, and a side
// window needs one before it can be placed.
state.sync_frame_geometry(
pmacs::protocol::FrontendId::LOCAL,
pmacs::cell::CellSize::new(24, 80),
);
open_as(&state, "KEEP", &fx.write("keep.txt", "k\n"));
open_as(&state, "B", &doomed);
exec(&state, "pmacs.window.switch_buffer(_G.B)");
exec(&state, "pmacs.buffer.set_round_trip_input(_G.B, true)");
let doomed_id = state.core.borrow().active_buffer_id();
let ordinary_window = state.core.borrow().active_window_id();
// Seat a distinctive cursor + selection + scroll position on the
// ORDINARY window, so a redirect is detectable as more than "the
// window moved".
{
let mut core = state.core.borrow_mut();
let win = core
.windows
.get_mut(&ordinary_window)
.expect("ordinary window");
win.cursor = 4;
win.selection = Some(pmacs::window::Selection { anchor: 2 });
win.view_top = 1;
}
// A SIDE window showing the same buffer, so the collapse the
// preflight prevents has something to collapse.
exec(
&state,
"pmacs.window.display(_G.B, { side = \"bottom\", height = 4 })",
);
let side_windows: Vec<_> = state
.core
.borrow()
.side_window_for(pmacs::protocol::FrontendId::LOCAL)
.into_iter()
.collect();
assert!(
!side_windows.is_empty(),
"precondition: a side window exists"
);
assert_eq!(
state
.core
.borrow()
.windows
.get(&side_windows[0])
.expect("side window")
.buffer_id,
doomed_id,
"precondition: the side window shows the doomed buffer"
);
assert_ne!(
side_windows[0], ordinary_window,
"precondition: the side window is a second window"
);
assert!(
state.core.borrow().buffer_round_trips(doomed_id),
"precondition: the buffer round-trips input"
);
// Put the buffer mid-edit. `begin_edit` is the flag
// `BufferRegistry::remove` refuses on, and the whole point of the
// preflight is that the refusal arrives too late.
{
let core = state.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(doomed_id)
.expect("doomed buffer")
.begin_edit()
.expect("begin edit");
}
remove_fire_and_forget(&mut state, &doomed);
let core = state.core.borrow();
// (i) the ordinary window, with its seated state.
let win = core
.windows
.get(&ordinary_window)
.expect("the ordinary window still exists");
assert_eq!(
win.buffer_id, doomed_id,
"(i) the ordinary window must still show the buffer"
);
assert_eq!(win.cursor, 4, "(i) cursor");
assert_eq!(
win.selection,
Some(pmacs::window::Selection { anchor: 2 }),
"(i) selection"
);
assert_eq!(win.view_top, 1, "(i) view_top");
// (ii) the side window.
for side in &side_windows {
let side_win = core.windows.get(side).unwrap_or_else(|| {
panic!(
"(ii) side window {side:?} was collapsed by a kill that should never have started"
)
});
assert_eq!(
side_win.buffer_id, doomed_id,
"(ii) the side window must still show the buffer"
);
}
// (iii) round-trip membership.
assert!(
core.buffer_round_trips(doomed_id),
"(iii) the buffer must still round-trip input — this is the FIRST \
thing `kill_buffer` drops and the easiest to miss"
);
assert!(
core.registry.borrow().contains(doomed_id),
"and the buffer itself is still in the registry"
);
drop(core);
// And the refusal is REPORTED. Leaving state untouched is only half
// the contract: the file is gone, so a user who is not told keeps a
// buffer bound to a path that no longer exists.
let said = status(&state);
assert!(
said.contains("mid-edit") && said.contains("could not be closed"),
"a mid-edit refusal must reach the status channel; status was \
{said:?}"
);
}
// ---------------------------------------------------------------------------
// 54 — independent mutations both reconcile, in either arrival order
// ---------------------------------------------------------------------------
/// Acceptance 54, integration layer. Dispatch a rename and a delete on
/// **disjoint** paths, wait for both, and assert both registry effects
/// occurred. It fails against dropping or deduplicating one resource
/// kind.
///
/// The disjoint end state is confidence coverage, **not** a claimed bite
/// against interdependent sequencing: disjoint paths necessarily
/// commute. The controlled-bus layer that does pin arrival order lives
/// in `src/async_runtime.rs`, and no test here pretends to pin an order
/// the mechanism does not establish.
#[test]
fn acc54_a_rename_and_a_delete_on_disjoint_paths_both_reconcile() {
for reverse in [false, true] {
let fx = Fixture::new();
let renamed_from = fx.write("moves.txt", "m\n");
let renamed_to = fx.at("moved.txt");
let deleted = fx.write("goes.txt", "g\n");
let mut state = editor();
open_as(&state, "KEEP", &fx.write("keep.txt", "k\n"));
open_as(&state, "MOVES", &renamed_from);
open_as(&state, "GOES", &deleted);
let rename = format!(
"pmacs.fs.rename(\"{}\", \"{}\")",
lua_str(&renamed_from),
lua_str(&renamed_to)
);
let remove = format!("pmacs.fs.remove(\"{}\")", lua_str(&deleted));
if reverse {
exec(&state, &remove);
exec(&state, &rename);
} else {
exec(&state, &rename);
exec(&state, &remove);
}
pump_until(&mut state, "both mutations", |s| {
renamed_to.exists() && !deleted.exists() && !buffer_is_valid(s, "GOES")
});
pump_a_while(&mut state);
assert_eq!(
buffer_path(&state, "MOVES").as_deref(),
Some(renamed_to.to_str().unwrap()),
"the rename reconciled (dispatch order reversed: {reverse})"
);
assert!(
!buffer_is_valid(&state, "GOES"),
"the delete reconciled (dispatch order reversed: {reverse})"
);
}
}
// ---------------------------------------------------------------------------
// The LSP-facing rows (30, 31c, 32, 34, 35)
// ---------------------------------------------------------------------------
//
// Driven against `pmacs_fake_lsp` so nothing here needs a real toolchain
// on PATH. The fake publishes two synthetic diagnostics (one Error at
// line 0, one Warning at line 2) on every `didOpen`, which is what makes
// "the new URI's diagnostics" observable at all.
fn fake_lsp_path() -> String {
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
}
/// Configure `language` to spawn the fake, and pin the project-marker
/// walk to `root` so a stray `.git` above the tempdir cannot silently
/// turn a markerless fixture into a detected one.
fn configure_fake(state: &EditorState, root: &Path, language: &str) {
exec(
state,
&format!(
"pmacs.project.set_search_boundary(\"{}\")
pmacs.lsp.config.{language} = {{ command = \"{}\" }}",
lua_str(root),
fake_lsp_path()
),
);
}
/// Pump the real frame order — processes, LSP, async — until `predicate`
/// holds. All three are needed: the fake's frames arrive through the
/// supervisor, the manager parses them, and the rename settles on the
/// async bus.
fn settle_until<F: Fn(&mut EditorState) -> bool>(
state: &mut EditorState,
what: &str,
predicate: F,
) {
let deadline = Instant::now() + Duration::from_secs(20);
while !predicate(state) {
assert!(
Instant::now() < deadline,
"settle deadline exceeded: {what}"
);
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(5));
}
}
fn settle_a_while(state: &mut EditorState) {
for _ in 0..120 {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(3));
}
}
fn server_count(state: &EditorState) -> i64 {
eval(state, "return #pmacs.lsp.list()")
}
/// `language|root_uri|state` per live server, sorted, so assertions do
/// not depend on spawn order.
fn server_rows(state: &EditorState) -> Vec<String> {
let joined: String = eval(
state,
r#"
local out = {}
for _, s in ipairs(pmacs.lsp.list()) do
out[#out + 1] = table.concat({
s.language_id or "", s.root_uri or "",
(s.state and s.state.kind) or "",
}, "|")
end
table.sort(out)
return table.concat(out, "\n")
"#,
);
if joined.is_empty() {
Vec::new()
} else {
joined.lines().map(str::to_owned).collect()
}
}
fn diag_count(state: &EditorState, uri: &str) -> i64 {
eval(
state,
&format!(
"local e, w, i, h = pmacs.diag.count(\"{uri}\")
return (e or 0) + (w or 0) + (i or 0) + (h or 0)"
),
)
}
/// Mirror of `file_uri_for` in `builtin/runtime/lsp.lua`. Reimplemented
/// rather than imported, so the test states the expected encoding
/// independently of the code under test.
fn file_uri(path: &Path) -> String {
let mut out = String::from("file://");
for byte in path.display().to_string().as_bytes() {
match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' | b':' => {
out.push(*byte as char);
}
_ => {
use std::fmt::Write as _;
let _ = write!(out, "%{byte:02X}");
}
}
}
out
}
/// Every window's overlay kinds, in composition order, keyed by window.
fn overlay_kinds_per_window(state: &EditorState) -> Vec<(u64, Vec<&'static str>)> {
let core = state.core.borrow();
let mut rows: Vec<(u64, Vec<&'static str>)> = core
.windows
.iter()
.map(|(id, w)| (id.raw(), w.overlay_kinds()))
.collect();
rows.sort_by_key(|(id, _)| *id);
rows
}
/// Count cells carrying a diagnostic **error** underline colour, per
/// window rect, by painting one real frame.
///
/// This is the only per-window, view-level observation available:
/// `DiagnosticView.uri` is private and `View` has no downcast, so
/// asserting on the store would prove nothing about whether the overlay
/// was re-rooted. A view still pointing at the old URI renders nothing,
/// because `forget_uri` emptied that key.
fn error_underlines_per_window(state: &EditorState) -> Vec<(u64, usize)> {
use pmacs::cell::{Cell, CellGrid, CellSize, Color};
use pmacs::protocol::FrontendId;
use pmacs::window::Rect;
let size = CellSize::new(24, 80);
let mut cells = vec![Cell::default(); (size.rows * size.cols) as usize];
let mut grid = CellGrid {
cells: &mut cells,
stride: size.cols,
size,
};
pmacs::editor::paint_frame(
state,
FrontendId::LOCAL,
&std::collections::HashMap::new(),
&mut grid,
size,
);
let placements = {
let core = state.core.borrow();
let view = core.views.get(&FrontendId::LOCAL).expect("LOCAL view");
let area = Rect::new(0, 0, size.rows - 1, size.cols);
let fixed = core.panel_fixed_rows(FrontendId::LOCAL, area.size.rows);
view.layout.compute(area, &fixed)
};
let error = Color::Indexed(1);
let mut rows: Vec<(u64, usize)> = placements
.into_iter()
.map(|(win, rect)| {
let mut n = 0;
for row in rect.origin.row..rect.origin.row + rect.size.rows {
for col in rect.origin.col..rect.origin.col + rect.size.cols {
let idx = (row * size.cols + col) as usize;
if cells.get(idx).map(|c| c.style.underline_color) == Some(error) {
n += 1;
}
}
}
(win.raw(), n)
})
.collect();
rows.sort_by_key(|(id, _)| *id);
rows
}
/// Acceptance 30. An attached LSP buffer with **diagnostics present
/// before** the rename, shown in **at least two windows**: afterwards
/// both windows render the **new** URI's diagnostics, the old URI's
/// store is empty, and each window's overlay keeps its **position in
/// the composition order**.
///
/// Bite, two mutations: `rec.uri` updated without re-rooting the
/// diagnostic view (both windows then render nothing, because the old
/// key is empty); and a remove-and-re-push, which would pass a
/// one-window render test while moving the diagnostic overlay to the end
/// of the stack — caught by the composition-order assertion.
#[test]
fn acc30_diagnostics_re_root_in_every_window_and_keep_their_stack_position() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let old = fx.write(
"proj/src/main.rs",
"fn main() {}\n// second\n// third line here\n",
);
let new = fx.at("proj/src/renamed.rs");
let mut state = editor();
state.sync_frame_geometry(
pmacs::protocol::FrontendId::LOCAL,
pmacs::cell::CellSize::new(24, 80),
);
configure_fake(&state, &fx.root, "rust");
open_as(&state, "B", &old);
let old_uri = file_uri(&old);
let new_uri = file_uri(&new);
settle_until(&mut state, "diagnostics for the old URI", |s| {
diag_count(s, &old_uri) > 0
});
// A second window showing the same buffer, with its own
// `DiagnosticView`. A split alone does not carry one — the view
// does not implement `clone_for_split` — so the switch hook is what
// attaches it, and that path only ever touches the ACTIVE window.
exec(&state, "pmacs.window.split_horizontal()");
// `try_split_active` leaves focus where it was, so the switch hook —
// which can only reach the ACTIVE window — has to be given the new
// one explicitly.
exec(&state, "pmacs.window.focus_next()");
exec(&state, "pmacs.window.switch_buffer(_G.B)");
settle_a_while(&mut state);
// Push one more overlay AFTER the diagnostic in each window.
// Without this the composition-order assertion below cannot bite:
// the LSP attach leaves `diagnostic` LAST in the stack, and moving
// the last element to the end is a no-op, so a remove-and-re-push
// would be indistinguishable from an in-place mutation.
// `_attach_highlight` uses `push_overlay` (no dedup), so a second
// call appends.
exec(
&state,
"pmacs.parse._attach_highlight(_G.B, pmacs.parse.buffer_language(_G.B))",
);
exec(&state, "pmacs.window.focus_next()");
exec(
&state,
"pmacs.parse._attach_highlight(_G.B, pmacs.parse.buffer_language(_G.B))",
);
let before_kinds = overlay_kinds_per_window(&state);
assert!(
before_kinds
.iter()
.all(|(_, kinds)| kinds.iter().position(|k| *k == "diagnostic")
< Some(kinds.len() - 1)),
"precondition: the diagnostic overlay must NOT be last, or \
\"keeps its stack position\" is unfalsifiable; got {before_kinds:?}"
);
let before_paint = error_underlines_per_window(&state);
assert_eq!(
before_paint.len(),
2,
"precondition: two windows are placed; got {before_paint:?}"
);
for (win, n) in &before_paint {
assert!(
*n > 0,
"precondition: window {win} must already paint diagnostic \
underlines; got {before_paint:?} with overlays \
{before_kinds:?}"
);
}
let diag_positions_before: Vec<(u64, Option<usize>)> = before_kinds
.iter()
.map(|(w, kinds)| (*w, kinds.iter().position(|k| *k == "diagnostic")))
.collect();
assert!(
diag_positions_before.iter().all(|(_, p)| p.is_some()),
"precondition: every window carries a diagnostic overlay; got \
{before_kinds:?}"
);
rename_fire_and_forget(&mut state, &old, &new);
settle_until(&mut state, "diagnostics for the new URI", |s| {
diag_count(s, &new_uri) > 0
});
settle_a_while(&mut state);
assert_eq!(
diag_count(&state, &old_uri),
0,
"the old URI's store must be empty"
);
assert!(
diag_count(&state, &new_uri) > 0,
"and the new URI's must be populated"
);
let after_kinds = overlay_kinds_per_window(&state);
let diag_positions_after: Vec<(u64, Option<usize>)> = after_kinds
.iter()
.map(|(w, kinds)| (*w, kinds.iter().position(|k| *k == "diagnostic")))
.collect();
assert_eq!(
diag_positions_after, diag_positions_before,
"each window's diagnostic overlay must keep its position in the \
composition order; a remove-and-re-push would move it to the end \
({before_kinds:?} -> {after_kinds:?})"
);
let after_paint = error_underlines_per_window(&state);
assert_eq!(after_paint.len(), 2, "still two windows: {after_paint:?}");
for (win, n) in &after_paint {
assert!(
*n > 0,
"window {win} must render the NEW URI's diagnostics; 0 means \
its overlay is still keyed under the old URI, whose store the \
forget emptied ({after_paint:?})"
);
}
}
/// Acceptance 31c, at the Lua binding. Raises for an unknown server id;
/// **succeeds** for a URI with no state under a known server.
///
/// The second arm is the one that matters: the `resource.renamed`
/// subscriber calls this per attachment, and an attachment need not have
/// any pending route or populated result store. An over-strict binding
/// would turn that ordinary idempotent case into an error inside a hook.
#[test]
fn acc31c_the_forget_uri_binding_raises_for_an_unknown_server_and_not_for_an_unknown_uri() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
let mut state = editor();
configure_fake(&state, &fx.root, "rust");
open_as(&state, "B", &file);
settle_until(&mut state, "one live server", |s| server_count(s) == 1);
// An unknown id has to be a real handle to a server the manager no
// longer holds: `LspServerIdLua` is opaque and cannot be forged from
// an integer, which is itself the binding's first line of defence.
let raised: String = eval(
&state,
"local sid
for _, row in ipairs(pmacs.lsp.list()) do sid = row.id end
assert(sid, 'no server to stale out')
_G.STALE = sid
pmacs.lsp.stop(sid)
return 'stopped'",
);
assert_eq!(raised, "stopped");
settle_until(&mut state, "the server is forgotten", |s| {
let gone: bool = eval(
s,
"local ok = pcall(pmacs.lsp.forget, _G.STALE)
return #pmacs.lsp.list() == 0",
);
gone
});
let raised: String = eval(
&state,
"local ok, err = pcall(pmacs.lsp.forget_uri, _G.STALE, 'file:///nope.rs')
if ok then return 'DID NOT RAISE' end
return tostring(err)",
);
assert!(
raised.contains("unknown server"),
"an unknown server id must raise, matching `pmacs.lsp.forget`; got \
{raised:?}"
);
// And the success arm, against a live server.
open_as(
&state,
"C",
&fx.write("proj/src/second.rs", "fn second() {}\n"),
);
settle_until(&mut state, "a live server again", |s| server_count(s) == 1);
let ok: bool = eval(
&state,
"local sid
for _, row in ipairs(pmacs.lsp.list()) do sid = row.id end
local a = pcall(pmacs.lsp.forget_uri, sid, 'file:///never-opened.rs')
local b = pcall(pmacs.lsp.forget_uri, sid, 'file:///never-opened.rs')
return a and b",
);
assert!(
ok,
"a URI with no state under a known server is an idempotent \
success, and repeating it stays safe"
);
}
/// Acceptance 32. A rename **across project roots** re-runs
/// `ensure_server` and the buffer ends up attached to a **different**
/// server; a same-root rename reuses the existing one (#161's affinity
/// key is the detected project root).
#[test]
fn acc32_a_cross_root_rename_re_runs_ensure_server_and_a_same_root_one_reuses() {
// Same root first: renaming within one package must not spawn a
// second server.
let fx = Fixture::new();
fx.write("a/Cargo.toml", "[package]\nname = \"a\"\n");
let inside = fx.write("a/src/main.rs", "fn main() {}\n");
let mut state = editor();
configure_fake(&state, &fx.root, "rust");
open_as(&state, "B", &inside);
settle_until(&mut state, "one initialized server", |s| {
server_rows(s) == vec![format!("rust|{}|initialized", file_uri(&fx.at("a")))]
});
let root_a = file_uri(&fx.at("a"));
assert_eq!(
server_rows(&state),
vec![format!("rust|{root_a}|initialized")],
"precondition: one server, rooted at package a"
);
rename_fire_and_forget(&mut state, &inside, &fx.at("a/src/moved.rs"));
settle_a_while(&mut state);
assert_eq!(
server_count(&state),
1,
"a same-root rename reuses the existing server: {:?}",
server_rows(&state)
);
// Now across roots: `b` is its own package, so its file needs its
// own server.
fx.write("b/Cargo.toml", "[package]\nname = \"b\"\n");
std::fs::create_dir_all(fx.at("b/src")).unwrap();
rename_fire_and_forget(
&mut state,
&fx.at("a/src/moved.rs"),
&fx.at("b/src/moved.rs"),
);
settle_until(&mut state, "a second server for package b", |s| {
server_count(s) == 2
});
settle_a_while(&mut state);
let root_b = file_uri(&fx.at("b"));
let rows = server_rows(&state);
assert!(
rows.iter().any(|r| r.contains(&root_b)),
"the cross-root rename must spawn a server rooted at package b; \
rows were {rows:?}"
);
assert_eq!(
rows.len(),
2,
"exactly one new server, not one per reconciliation pass: {rows:?}"
);
}
/// Point the `rust` server at the `applyeditplan` fake carrying `plan`,
/// and hand back the sink the client's response to the server-initiated
/// `workspace/applyEdit` lands in. Must run before the first `.rs` file
/// is opened — that open is what launches the server.
fn plan_server(state: &EditorState, dir: &Path, plan: &serde_json::Value) -> PathBuf {
let plan_path = dir.join("plan.json");
std::fs::write(&plan_path, serde_json::to_vec(plan).unwrap()).unwrap();
let sink = dir.join("applyedit-response.json");
exec(
state,
&format!(
"pmacs.lsp.config.rust = {{
command = \"{}\",
env = {{
PMACS_FAKE_LSP_MODE = 'applyeditplan',
PMACS_FAKE_LSP_EDIT_PLAN = '{}',
PMACS_FAKE_LSP_APPLYEDIT_SINK = '{}',
}},
}}",
fake_lsp_path(),
plan_path.display(),
sink.display()
),
);
sink
}
/// Ask the fake to deliver its planned `workspace/applyEdit`. Driven by
/// an `executeCommand` rather than fired at `initialized`, so the test
/// controls *when* the batch arrives — these fixtures depend on a
/// specific buffer being active first, and a server-timed request would
/// race that setup.
fn trigger_apply_edit(state: &EditorState) {
exec(
state,
"local sid
for _, row in ipairs(pmacs.lsp.list()) do
if row.state and row.state.kind == 'initialized' then sid = row.id end
end
assert(sid, 'no initialized server')
pmacs.lsp.request_execute_command(sid, 'pmacs.fake.applyEdit', {})",
);
}
fn wait_for_apply_response(state: &mut EditorState, sink: &Path) -> serde_json::Value {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
state.tick_processes();
state.tick_lsp();
state.tick_async();
if let Ok(raw) = std::fs::read(sink)
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&raw)
{
assert!(
v.get("fakeError").is_none(),
"the fixture itself failed: {v:?}"
);
return v;
}
assert!(
Instant::now() < deadline,
"the client never answered the server's workspace/applyEdit"
);
std::thread::sleep(Duration::from_millis(10));
}
}
/// Acceptance 34. Renaming the **active** file through the full
/// `apply_workspace_edit` path returns the user to the **same buffer**
/// (now under its new path), and leaves no buffer bound to the obsolete
/// path.
///
/// The plan deliberately edits *another* file first. Without that the
/// row cannot bite at all: the applier only has to restore the origin if
/// something moved the active buffer away, and a lone rename op does not.
///
/// Bite: the applier restoring by path instead of by buffer handle. A
/// captured path no longer resolves after its own batch renamed it, so
/// `find_or_open` raises, the `pcall` swallows it, and the user is
/// stranded in whatever buffer the last applied op left active. No
/// reconciliation can reach a string a Lua local already captured.
///
/// **One framing claim corrected here.** §5's G1 says the stale path
/// "materializes a phantom": that `find_or_open(origin)` reaches
/// `resolve_target_buffer`'s `NotFound` arm, which creates an empty
/// path-backed buffer and selects it. It does not.
/// `pmacs.buffer.find_or_open` (`src/lua_bindings/mod.rs`) calls
/// `crate::file_io::load_file` directly and maps the error, so a missing
/// path **raises**; the `NotFound` arm belongs to
/// `EditorCore::resolve_target_buffer`, which serves
/// `pmacs.window.display_file` and the startup/daemon target, not this
/// binding. The defect is real but smaller than G1 states — a silently
/// swallowed restore, not a fabricated file — and this row asserts the
/// half that is true. The no-buffer-at-the-old-path assertion is kept as
/// a cheap guard against a future fallback that *would* create one, and
/// is not the biting half.
#[test]
fn acc34_renaming_the_active_file_through_the_applier_returns_the_same_buffer() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let old = fx.write("proj/src/main.rs", "fn main() {}\n");
let other = fx.write("proj/src/other.rs", "fn other() {}\n");
let new = fx.at("proj/src/renamed.rs");
let mut state = editor();
configure_fake(&state, &fx.root, "rust");
let sink = plan_server(
&state,
&fx.root,
&serde_json::json!({
"documentChanges": [
{
// Moves the active buffer away, so the restore has
// something to undo.
"textDocument": { "uri": file_uri(&other), "version": 1 },
"edits": [{
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 },
},
"newText": "// touched\n",
}],
},
{
"kind": "rename",
"oldUri": file_uri(&old),
"newUri": file_uri(&new),
},
],
}),
);
open_as(&state, "B", &old);
settle_until(&mut state, "server initialized", |s| {
server_rows(s).iter().any(|r| r.ends_with("|initialized"))
});
// The applier restores the buffer that was active when the batch
// began, so make that the file being renamed.
exec(&state, "pmacs.window.switch_buffer(_G.B)");
let active_before = state.core.borrow().active_buffer_id();
trigger_apply_edit(&state);
let response = wait_for_apply_response(&mut state, &sink);
assert_eq!(
response["result"]["applied"], true,
"the batch must apply: {response:?}"
);
settle_a_while(&mut state);
assert!(new.exists(), "the rename landed on disk");
assert!(!old.exists());
assert_eq!(
state.core.borrow().active_buffer_id(),
active_before,
"the user must be returned to the SAME buffer, now under its new \
path — a path-based restore raises on the renamed-away path, the \
pcall swallows it, and the user is left wherever the last applied \
op put them"
);
assert_eq!(
buffer_path(&state, "B").as_deref(),
Some(new.to_str().unwrap()),
"and that buffer's path followed the rename"
);
let stale: bool = eval(
&state,
&format!(
"for _, b in ipairs(pmacs.buffer.list()) do
if b:path() == \"{}\" then return true end
end
return false",
lua_str(&old)
),
);
assert!(!stale, "no buffer may remain bound to the obsolete path");
}
/// Acceptance 35. When the origin buffer is **gone** after the edit, the
/// applier restores **nothing** rather than falling back to the old
/// path.
///
/// **The plan deletes the origin's file and then RECREATES it, and that
/// is what makes the row bite at all.** With a plain delete the forbidden
/// fallback is unobservable: `find_or_open` on a path that no longer
/// exists raises straight out of `file_io::load_file`, the surrounding
/// `pcall` swallows it, and nothing happens — so "no buffer at the old
/// path" and "the active buffer is live" both hold with the fallback
/// present. Recreating the path gives the fallback something to open, and
/// it is not a contrived shape: a `documentChanges` batch that deletes
/// and recreates a file is ordinary LSP refactoring output.
///
/// Bite: the applier restoring by path instead of by buffer handle. The
/// handle is invalid (reconciliation killed the buffer) so a
/// handle-based restore does nothing; a path-based one loads the
/// recreated file into a NEW buffer and switches the user into it —
/// dropping them, silently, into a file they asked to delete.
#[test]
fn acc35_when_the_origin_buffer_is_gone_the_applier_restores_nothing() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let doomed = fx.write("proj/src/main.rs", "fn main() {}\n");
let other = fx.write("proj/src/other.rs", "fn other() {}\n");
let mut state = editor();
configure_fake(&state, &fx.root, "rust");
let sink = plan_server(
&state,
&fx.root,
&serde_json::json!({
"documentChanges": [
{
"kind": "delete",
"uri": file_uri(&doomed),
},
{
// Recreates the path, so a path-based restore has a
// file to open and the fallback becomes observable.
"kind": "create",
"uri": file_uri(&doomed),
},
],
}),
);
// `other` keeps the registry non-empty so the delete's kill is not
// refused for being the last buffer.
open_as(&state, "OTHER", &other);
open_as(&state, "B", &doomed);
settle_until(&mut state, "server initialized", |s| {
server_rows(s).iter().any(|r| r.ends_with("|initialized"))
});
exec(&state, "pmacs.window.switch_buffer(_G.B)");
trigger_apply_edit(&state);
let response = wait_for_apply_response(&mut state, &sink);
assert_eq!(
response["result"]["applied"], true,
"the batch must apply: {response:?}"
);
settle_a_while(&mut state);
assert!(
doomed.exists(),
"precondition for the bite: the batch recreated the path, so a \
path-based restore CAN open it"
);
assert!(
!buffer_is_valid(&state, "B"),
"the origin buffer was reconciled away by the delete"
);
let reopened: bool = eval(
&state,
&format!(
"for _, b in ipairs(pmacs.buffer.list()) do
if b:path() == \"{}\" then return true end
end
return false",
lua_str(&doomed)
),
);
assert!(
!reopened,
"the applier must restore NOTHING. A path-based restore loads the \
recreated file into a fresh buffer, which is the editor silently \
re-opening a file the user asked to delete"
);
let active_path: Option<String> = eval(
&state,
"local b = pmacs.window.buffer(); return b and b:path() or nil",
);
assert_ne!(
active_path.as_deref(),
Some(doomed.to_str().unwrap()),
"and the user must not be sitting in it either"
);
let active_valid = {
let core = state.core.borrow();
let id = core.active_buffer_id();
core.registry.borrow().contains(id)
};
assert!(
active_valid,
"the window it left behind must still sit on a live buffer"
);
}
/// Review round 1 — a reconciliation failure inside the LSP subscriber
/// must be **reported and attributed**, and must not stop the remaining
/// attachments from reconciling.
///
/// The scenario is the reviewer's: a **stale server id**. The attachment
/// record still names a server the manager has forgotten, so
/// `did_close` and `forget_uri` both raise. With ignored `pcall`s the
/// callback returned successfully, so the `all-must-succeed` hook logger
/// had nothing to log, and the old stores, routes and `documents` entry
/// stayed live under a URI the editor no longer held — silently.
///
/// Two packages under one parent directory give two servers, and only
/// one is staled out, so the row can assert both halves at once: the
/// failure is surfaced, **and** the healthy attachment still moves.
///
/// Bite: fails against ignored `pcall`s (nothing on either channel), and
/// against a fix that lets the first failure `error()` out of the loop
/// (the healthy attachment would never reconcile).
#[test]
fn a_subscriber_reconciliation_failure_is_reported_and_the_rest_still_reconcile() {
let fx = Fixture::new();
fx.write("w/a/Cargo.toml", "[package]\nname = \"a\"\n");
fx.write("w/b/Cargo.toml", "[package]\nname = \"b\"\n");
let file_a = fx.write("w/a/src/main.rs", "fn main() {}\n");
let file_b = fx.write("w/b/src/main.rs", "fn main() {}\n");
let mut state = editor();
configure_fake(&state, &fx.root, "rust");
open_as(&state, "A", &file_a);
settle_until(&mut state, "server for package a", |s| server_count(s) == 1);
open_as(&state, "B", &file_b);
settle_until(&mut state, "server for package b", |s| server_count(s) == 2);
// Stale out the server serving package `a` only: stop it, then forget
// it, leaving `attachments[a].server` naming a server the manager no
// longer holds. `forget_uri` raises for an unknown server id, which
// is exactly the failure mode under test.
let root_a = file_uri(&fx.at("w/a"));
exec(
&state,
&format!(
"local victim
for _, row in ipairs(pmacs.lsp.list()) do
if row.root_uri == \"{root_a}\" then victim = row.id end
end
assert(victim, 'no server rooted at package a')
pcall(pmacs.lsp.stop, victim)
_G.VICTIM = victim"
),
);
settle_until(&mut state, "the victim is forgotten", |s| {
let gone: bool = eval(
s,
"pcall(pmacs.lsp.forget, _G.VICTIM)
for _, row in ipairs(pmacs.lsp.list()) do
if row.id == _G.VICTIM then return false end
end
return true",
);
gone
});
exec(&state, "pmacs.editor.set_status('')");
// Rename the parent, so BOTH attachments are in the fan-out.
let new_uri_b = file_uri(&fx.at("w2/b/src/main.rs"));
rename_fire_and_forget(&mut state, &fx.at("w"), &fx.at("w2"));
settle_a_while(&mut state);
// Half one: the failure is surfaced, on both channels, attributed to
// the operation that failed.
let said = status(&state);
assert!(
said.contains("resource.renamed") && said.contains("reconciliation failure"),
"the failure must reach the status channel; status was {said:?}"
);
assert!(
said.contains("forget_uri"),
"and must name WHICH step failed — an unattributed count does not \
tell anyone that the URI-keyed stores were left live; status was \
{said:?}"
);
let errors: String = eval(
&state,
"for _, b in ipairs(pmacs.buffer.list()) do
if b:name() == '*errors*' then return b:slice(0, b:len()) end
end
return ''",
);
assert!(
errors.contains("resource.renamed") && errors.contains("forget_uri"),
"the callback must RAISE, so the all-must-succeed hook logger has \
something to record; *errors* held {errors:?}"
);
// Half two: the healthy attachment still reconciled. The fake
// republishes diagnostics on every `didOpen`, so diagnostics under
// the NEW uri prove the whole ordered teardown ran for package b
// after package a's failed.
settle_until(&mut state, "package b reattached at its new uri", |s| {
diag_count(s, &new_uri_b) > 0
});
assert!(
diag_count(&state, &new_uri_b) > 0,
"one unreachable server must not leave every other attachment \
unreconciled — the raise has to come after the loop, not inside it"
);
}
// Isolated bootstrap storage roots (see the module docs): an
// integration test is compiled without `cfg(test)`, so a raw
// `EditorState::new()` would read the developer's real `init.lua` and
// write into their real data root.
#[path = "common/iso.rs"]
mod iso;