2053 lines
74 KiB
Rust
2053 lines
74 KiB
Rust
//! dired Stage 2a acceptance — rename and delete reconciliation.
|
||
//!
|
||
//! `docs/dired-stage2-framing.md` §5, §6, §10; acceptance items 23–38
|
||
//! and 50–55.
|
||
//!
|
||
//! **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;
|