//! 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(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 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 { eval( state, &format!("local b = _G.{global}; return b and b:path() or nil"), ) } fn buffer_name(state: &EditorState, global: &str) -> Option { 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 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 { 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)> = 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)> = 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::(&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 = 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;