//! Arc 8 Stage 3a acceptance — LSP notification/response dispatch seams //! and `pmacs.fs.canonicalize`. //! //! `docs/lean4-mode-framing.md` Q#LN9 and Q#LN20, acceptance 29–34 plus //! 34a/34b. //! //! This suite deliberately contains **no Lean content**. //! `handle_server_requests` (`builtin/runtime/lsp.lua`) is the single //! LSP event drain for every language in pmacs, so the change is //! exercised through an already-shipped language driven against //! `pmacs_fake_lsp`. A suite that reached the drain only through Lean //! would understate the blast radius — the same reasoning that shaped //! Stage 2's suite. //! //! Every fixture calls `pmacs.project.set_search_boundary` at its own //! tempdir root, so a stray marker above the temp directory cannot make //! a "markerless" case silently detected. use std::path::{Path, PathBuf}; use std::time::Duration; use pmacs::editor::EditorState; fn exec(state: &EditorState, source: &str) { state.lua_host.lua().load(source.to_owned()).exec().unwrap(); } fn eval(state: &EditorState, source: &str) -> T { state.lua_host.lua().load(source.to_owned()).eval().unwrap() } fn fake_lsp_path() -> String { env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() } /// A fresh editor with the shipped language configs cleared, so the only /// server any test can spawn is the fake one it configures itself. fn editor() -> EditorState { let state = EditorState::new(); exec(&state, "pmacs.lsp.config = {}"); state } fn lua_str(path: &Path) -> String { path.display() .to_string() .replace('\\', "\\\\") .replace('"', "\\\"") } 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 { self.root.join(rel) } fn bind(&self, state: &EditorState) { exec( state, &format!( "pmacs.project.set_search_boundary(\"{}\")", lua_str(&self.root) ), ); } } fn configure(state: &EditorState, language: &str) { exec( state, &format!( "pmacs.lsp.config.{language} = {{ command = \"{}\" }}", fake_lsp_path() ), ); } fn open(state: &EditorState, path: &Path) { exec( state, &format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)), ); } /// `tick_async` is what drives the drain: `handle_server_requests` is /// wrapped onto `pmacs._async.tick`, so a settle loop without it moves /// the LSP state machine while never delivering a single event to Lua. fn settle(state: &mut EditorState) { for _ in 0..8 { state.tick_processes(); state.tick_lsp(); state.tick_async(); std::thread::sleep(Duration::from_millis(2)); } } /// A rust project with one file, an attached fake server, and the /// probes below installed. Returns the opened file's path. fn attached_rust(state: &mut EditorState, fx: &Fixture) -> PathBuf { fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); let file = fx.write("proj/src/main.rs", "fn main() {}\nlet x = 1;\n"); fx.bind(state); configure(state, "rust"); open(state, &file); settle(state); file } /// The sid of the single live server, as a Lua expression fragment. const THE_SID: &str = "pmacs.lsp.list()[1].id"; // --------------------------------------------------------------------------- // Acceptance 29 — a notification reaches a registered subscriber. // --------------------------------------------------------------------------- #[test] fn acc29_notification_reaches_a_registered_subscriber() { let fx = Fixture::new(); let mut state = editor(); // Registered BEFORE the open, so the didOpen-triggered `pmacs/echo` // is in the first drain. exec( &state, r#" _G.seen = {} pmacs.lsp.on_notification("pmacs/echo", function(sid, params) _G.seen[#_G.seen + 1] = tostring(params and params.uri) end) "#, ); attached_rust(&mut state, &fx); let n: i64 = eval(&state, "return #_G.seen"); assert!( n >= 1, "expected at least one pmacs/echo notification, got {n}" ); let first: String = eval(&state, "return _G.seen[1]"); assert!( first.starts_with("file://") && first.ends_with("main.rs"), "subscriber got the document uri; saw {first:?}" ); } #[test] fn acc29_subscriber_for_an_unsent_method_does_not_fire() { let fx = Fixture::new(); let mut state = editor(); exec( &state, r#" _G.hits = 0 pmacs.lsp.on_notification("pmacs/never", function() _G.hits = _G.hits + 1 end) "#, ); attached_rust(&mut state, &fx); // Non-vacuity for acc29: the seam is method-keyed, not a firehose. // Without this, a subscriber invoked for every notification would // pass the test above while being wrong. let hits: i64 = eval(&state, "return _G.hits"); assert_eq!(hits, 0, "a subscriber must only fire for its own method"); } // --------------------------------------------------------------------------- // Acceptance 30 + 33 — dispatch integrity: with subscribers registered, // a `workspace/applyEdit` request in the same drain is still handled. // // The fake server writes the applyEdit request and the executeCommand // response back to back, so both land in one `events_take` batch. That // co-occurrence is the point: a seam that consumed the batch, or that // returned early, would starve the `request` arms that share it. // --------------------------------------------------------------------------- fn drive_apply_edit(state: &mut EditorState, file: &Path) { exec( state, &format!( r#" local sid = {THE_SID} local uri = "file://{}" _G.rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{ command = "pmacs.fake.applyEdit", arguments = {{ uri }}, }}) _G.response_hits = 0 pmacs.lsp.on_response(sid, _G.rid, function(result, err) _G.response_hits = _G.response_hits + 1 end) "#, lua_str(file) ), ); settle(state); } fn buffer_text(state: &EditorState) -> String { eval( state, "local b = pmacs.window.buffer() return b:slice(0, b:len())", ) } #[test] fn acc30_apply_edit_still_handled_with_a_notification_subscriber() { let fx = Fixture::new(); let mut state = editor(); exec( &state, r#" _G.notes = 0 pmacs.lsp.on_notification("pmacs/echo", function() _G.notes = _G.notes + 1 end) "#, ); let file = attached_rust(&mut state, &fx); assert!( eval::(&state, "return _G.notes") >= 1, "precondition: the notification subscriber is actually firing" ); drive_apply_edit(&mut state, &file); assert!( buffer_text(&state).contains("ED2"), "workspace/applyEdit must still be applied with a subscriber \ registered; buffer was {:?}", buffer_text(&state) ); } #[test] fn acc33_apply_edit_still_handled_with_a_response_subscriber() { let fx = Fixture::new(); let mut state = editor(); let file = attached_rust(&mut state, &fx); drive_apply_edit(&mut state, &file); // Both halves in one drain: the response was delivered to its // one-shot AND the server-originated request was serviced. assert_eq!( eval::(&state, "return _G.response_hits"), 1, "the executeCommand response reaches its one-shot" ); assert!( buffer_text(&state).contains("ED2"), "workspace/applyEdit must still be applied with a response \ subscriber registered; buffer was {:?}", buffer_text(&state) ); } // --------------------------------------------------------------------------- // Acceptance 31 — a raising subscriber does not stop later events in the // same drain (and does not stop the `request` arms either). // --------------------------------------------------------------------------- #[test] fn acc31_raising_notification_subscriber_does_not_stop_the_drain() { let fx = Fixture::new(); let mut state = editor(); exec( &state, r#" _G.second_hits = 0 pmacs.lsp.on_notification("pmacs/echo", function() error("subscriber blew up") end) pmacs.lsp.on_notification("pmacs/echo", function() _G.second_hits = _G.second_hits + 1 end) "#, ); let file = attached_rust(&mut state, &fx); assert!( eval::(&state, "return _G.second_hits") >= 1, "a raising subscriber must not starve the ones after it" ); // And the shared `request` arms still run in a later drain. drive_apply_edit(&mut state, &file); assert!( buffer_text(&state).contains("ED2"), "a raising subscriber must not stop workspace/applyEdit" ); } #[test] fn acc33_raising_response_handler_does_not_stop_the_drain() { let fx = Fixture::new(); let mut state = editor(); let file = attached_rust(&mut state, &fx); exec( &state, &format!( r#" local sid = {THE_SID} _G.notes_after = 0 pmacs.lsp.on_notification("pmacs/echo", function() _G.notes_after = _G.notes_after + 1 end) local rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{ command = "pmacs.fake.applyEdit", arguments = {{ "file://{}" }}, }}) pmacs.lsp.on_response(sid, rid, function() error("handler blew up") end) "#, lua_str(&file) ), ); settle(&mut state); assert!( buffer_text(&state).contains("ED2"), "a raising response handler must not stop workspace/applyEdit in \ the same drain" ); } // --------------------------------------------------------------------------- // Acceptance 32 — the one-shot is removed exactly once, whether or not // the handler raises. // // Named for what it pins rather than for the framing's wording. Q#LN9 // specifies removal *before* invocation, and the implementation does // that — but bite-testing showed the before/after ordering is not // observable on its own: `pcall` catches the raise either way, so // removal after the call is behaviorally identical unless a handler // re-enters the drain, which nothing does. What IS observable, and what // this pins, is that removal is **unconditional**: the bite that moves // it inside `if ok then` fails here 2 != 1, because the surviving // registration gets invoked a second time by the purge. // --------------------------------------------------------------------------- #[test] fn acc32_response_one_shot_is_removed_even_when_the_handler_raises() { let fx = Fixture::new(); let mut state = editor(); attached_rust(&mut state, &fx); exec( &state, &format!( r#" local sid = {THE_SID} _G.calls = 0 local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 1 }}) pmacs.lsp.on_response(sid, rid, function(result, err) _G.calls = _G.calls + 1 error("handler raises after being removed") end) "# ), ); settle(&mut state); assert_eq!( eval::(&state, "return _G.calls"), 1, "the one-shot fires exactly once for its reply" ); exec(&state, &format!("pmacs.lsp.stop({THE_SID})")); settle(&mut state); assert_eq!( eval::(&state, "return _G.calls"), 1, "a delivered one-shot must not be re-invoked by the purge — \ removal is unconditional, not gated on a clean return" ); } #[test] fn acc32_response_carries_the_servers_result() { let fx = Fixture::new(); let mut state = editor(); attached_rust(&mut state, &fx); exec( &state, &format!( r#" local sid = {THE_SID} _G.echoed = nil _G.saw_err = "unset" local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 42 }}) pmacs.lsp.on_response(sid, rid, function(result, err) _G.echoed = result and result.echo and result.echo.v _G.saw_err = tostring(err) end) "# ), ); settle(&mut state); // Non-vacuity: without this the seam could "fire" with nil payloads // and every count-based assertion above would still pass. assert_eq!( eval::(&state, "return _G.echoed or -1"), 42, "the handler receives the server's result payload" ); assert_eq!( eval::(&state, "return _G.saw_err"), "nil", "a successful reply passes nil for err" ); } // --------------------------------------------------------------------------- // Acceptance 34 — the pending purge, driven off `pmacs.lsp.list()` and // NOT off a death event seen in the drain. // // The second test is the load-bearing one. `handle_server_requests` // builds its sid list from `attachments`, so a server that is in no // attachment is never drained — and its `stopped` event is therefore // never seen. A purge wired to that event leaks exactly there. // --------------------------------------------------------------------------- #[test] fn acc34_purge_settles_a_pending_one_shot_when_the_server_dies() { let fx = Fixture::new(); let mut state = editor(); attached_rust(&mut state, &fx); exec( &state, &format!( r#" local sid = {THE_SID} _G.err_msg = "never called" -- A method the fake server answers only after a delay would -- be ideal; instead the server is stopped in the same breath, -- so the reply can never arrive. local rid = pmacs.lsp.send_request(sid, "test/slow", {{}}) pmacs.lsp.on_response(sid, rid, function(result, err) _G.err_msg = tostring(err and err.message) end) pmacs.lsp.stop(sid) "# ), ); settle(&mut state); let msg: String = eval(&state, "return _G.err_msg"); assert!( msg.contains("server gone") || msg == "nil", "a pending one-shot must be settled, not left waiting; saw {msg:?}" ); assert_ne!( msg, "never called", "the one-shot was never settled — it leaked" ); } #[test] fn acc34_purge_reaches_a_server_that_is_in_no_attachment() { let fx = Fixture::new(); let mut state = editor(); fx.bind(&state); // Spawned directly, never attached to a buffer. `attachments` is // empty, so `handle_server_requests` never visits this sid and its // `stopped` event is never drained. exec( &state, &format!( r#" _G.settled = "never called" local sid = pmacs.lsp.spawn({{ label = "orphan", language_id = "rust", command = "{}", args = {{}}, }}) _G.orphan = sid "#, fake_lsp_path() ), ); settle(&mut state); exec( &state, r#" local rid = pmacs.lsp.send_request(_G.orphan, "test/slow", {}) pmacs.lsp.on_response(_G.orphan, rid, function(result, err) _G.settled = tostring(err and err.message) end) pmacs.lsp.stop(_G.orphan) "#, ); settle(&mut state); let settled: String = eval(&state, "return _G.settled"); assert_ne!( settled, "never called", "the purge must not depend on the drain reaching this server — \ it is in no attachment, so the drain never does" ); assert!( settled.contains("server gone"), "settled with the purge's error; saw {settled:?}" ); } // --------------------------------------------------------------------------- // Acceptance 34a — `pmacs.fs.canonicalize` (Q#LN20). // --------------------------------------------------------------------------- #[test] #[cfg(unix)] fn acc34a_canonicalize_resolves_symlinks_and_dot_segments() { let fx = Fixture::new(); fx.write("pkg/sub/a.txt", "x\n"); // Built here rather than assumed: the whole point is the symlink. std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap(); let state = editor(); let noncanon = format!("{}/sub/./../sub/a.txt", fx.dir("linkpkg").display()); let got: String = eval( &state, &format!("return tostring(pmacs.fs.canonicalize(\"{noncanon}\"))"), ); let want = fx.root.join("pkg/sub/a.txt").display().to_string(); assert_eq!(got, want, "symlink and dot segments both resolved"); // Falsification for 34b: the uncanonicalized spelling really is // different, so the affinity test below is not vacuous. assert_ne!(noncanon, want); } #[test] fn acc34a_canonicalize_returns_nil_for_a_missing_path() { let fx = Fixture::new(); let state = editor(); let missing = fx.dir("nope/not-here").display().to_string(); let got: String = eval( &state, &format!("return tostring(pmacs.fs.canonicalize(\"{missing}\"))"), ); assert_eq!( got, "nil", "a nonexistent path declines rather than raising" ); } // --------------------------------------------------------------------------- // Acceptance 34b — affinity survives a symlinked open. // // Asserted at the affinity layer, not just at the binding: the // regression Q#LN20 exists to prevent is *two servers for one project*, // and only this shape observes it. // --------------------------------------------------------------------------- fn server_count(state: &EditorState) -> i64 { eval(state, "return #pmacs.lsp.list()") } #[test] #[cfg(unix)] fn acc34b_canonicalizing_resolver_reuses_one_server_across_a_symlink() { let fx = Fixture::new(); fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); let real = fx.write("proj/src/main.rs", "fn main() {}\n"); std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap(); let linked = fx.dir("linkproj").join("src/main.rs"); let mut state = editor(); fx.bind(&state); exec( &state, &format!( r#" pmacs.lsp.config.rust = {{ command = "{}", root = function(path) local dir = path:match("^(.*)/[^/]*$") if not dir then return nil end -- Walk up to the directory holding Cargo.toml, then -- canonicalize — the Q#LN8 shape Stage 3b will use. while dir and #dir > 0 do local f = io.open(dir .. "/Cargo.toml", "r") if f then f:close() return pmacs.fs.canonicalize(dir) end dir = dir:match("^(.*)/[^/]*$") end return nil end, }} "#, fake_lsp_path() ), ); open(&state, &real); settle(&mut state); assert_eq!(server_count(&state), 1, "the real path spawns one server"); open(&state, &linked); settle(&mut state); assert_eq!( server_count(&state), 1, "the symlinked path must reuse the same server — two here is the \ exact regression Q#LN20 exists to prevent" ); } #[test] #[cfg(unix)] fn acc34b_falsified_by_a_resolver_that_skips_canonicalization() { let fx = Fixture::new(); fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); let real = fx.write("proj/src/main.rs", "fn main() {}\n"); std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap(); let linked = fx.dir("linkproj").join("src/main.rs"); let mut state = editor(); fx.bind(&state); // Same resolver, minus the canonicalize call. This is the bite: if // it also produced one server, the test above would be vacuous and // `pmacs.fs.canonicalize` would be doing nothing. exec( &state, &format!( r#" pmacs.lsp.config.rust = {{ command = "{}", root = function(path) local dir = path:match("^(.*)/[^/]*$") while dir and #dir > 0 do local f = io.open(dir .. "/Cargo.toml", "r") if f then f:close() return dir end dir = dir:match("^(.*)/[^/]*$") end return nil end, }} "#, fake_lsp_path() ), ); open(&state, &real); settle(&mut state); open(&state, &linked); settle(&mut state); assert_eq!( server_count(&state), 2, "without canonicalization the two spellings key differently and \ spawn two servers — this is what 34b's positive case rules out" ); } // --------------------------------------------------------------------------- // Acceptance 34a, non-UTF-8 arm — an unrepresentable resolution declines // rather than returning a lossy string. // // Review finding on PR #167: `display().to_string()` substitutes U+FFFD, // which would hand back a path that does not exist on disk. That is // strictly worse than nil here, because the value becomes a // server-affinity key via `file_uri_for` and would silently fail to // round-trip. Bites against the `display()` form, which returns a // non-nil string for this fixture. // // **Linux-gated, and `cfg(unix)` was not enough** — CI caught that. // APFS enforces valid UTF-8 in filenames, so on macOS the `write` below // fails with EILSEQ ("Illegal byte sequence") before the code under test // is ever reached: the fixture cannot be built there. That is a // filesystem refusing to represent the case, not a behavioral // difference — the subject itself, `to_str()` returning None, is // platform-independent Rust. Gated explicitly rather than skipped at // runtime, so a future failure here is a real failure and not a silent // no-op. // --------------------------------------------------------------------------- #[test] #[cfg(target_os = "linux")] fn acc34a_canonicalize_declines_a_non_utf8_resolution() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt as _; let fx = Fixture::new(); // 0xFF is not valid UTF-8 in any position. let raw = OsStr::from_bytes(b"bad-\xffname"); let target = fx.root.join(raw); std::fs::write(&target, "x\n").unwrap(); // Reached through an ASCII symlink, so the *input* is representable // and only the resolved output is not — which is the case // `to_str()` has to catch and a UTF-8-only input check would miss. let link = fx.dir("ascii-link"); std::os::unix::fs::symlink(&target, &link).unwrap(); let state = editor(); let got: String = eval( &state, &format!( "return tostring(pmacs.fs.canonicalize(\"{}\"))", lua_str(&link) ), ); assert_eq!( got, "nil", "a resolution that lands on non-UTF-8 bytes must decline, not \ return a U+FFFD-substituted path that exists nowhere" ); }