// tests/lsp_spawn_guidance_acceptance.rs --- journey Stage 1b-2. //! `COHERENCE.md` §1.2's canonical silence: a preconfigured language //! server that is not installed used to fail with no status message, no //! record, and no modeline marker, while tree-sitter highlighting kept //! working and masked it. //! //! Pins are labelled **N** (new behaviour, must fail on full revert) or //! **P** (preservation, falsified by a named targeted mutation), per //! `docs/journey-stage1b2-lsp-guidance-framing.md` §4. //! //! **Every fixture points the config at a path that does not exist**, //! rather than relying on a real server's absence — a developer with //! `rust-analyzer` installed must get the same result as CI. Each //! fixture asserts that precondition, because a fixture that //! accidentally named a real binary would make every absence assertion //! here vacuous. use std::path::{Path, PathBuf}; use pmacs::editor::EditorState; use pmacs::protocol::FrontendId; use pmacs::statusline::{ StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, }; use tempfile::TempDir; 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 status(state: &EditorState) -> String { state.core.borrow().status.clone() } fn lua_str(s: &str) -> String { format!("{s:?}") } /// A command path that cannot exist. Asserted, not assumed: if this ever /// resolved, every "did not start" assertion below would be vacuous. fn absent_command(dir: &Path, name: &str) -> String { let p = dir.join("no-such-bin").join(name); assert!( !p.exists(), "fixture precondition: {} must not exist", p.display() ); p.display().to_string() } /// Point the default `rust` server at `command`. fn configure_rust(state: &EditorState, command: &str) { exec( state, &format!( "pmacs.lsp.config.rust = {{ command = {} }}", lua_str(command) ), ); } /// A Cargo project, so `key_uri` is non-nil (root detected by marker). fn cargo_project() -> TempDir { let td = tempfile::tempdir().expect("tempdir"); std::fs::write(td.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").expect("write"); td } fn write_rs(dir: &Path, name: &str) -> PathBuf { let p = dir.join(name); std::fs::write(&p, b"fn main() {}\n").expect("write rs"); p } /// Open a file the way `buffer.after-load` sees it. fn open(state: &EditorState, path: &Path) { exec( state, &format!( "pmacs.buffer.find_or_open({})", lua_str(&path.display().to_string()) ), ); } fn editor_for(dir: &Path) -> EditorState { let state = EditorState::new_with_roots(&crate::iso::roots()); // Clamp detection so a stray marker above the tempdir cannot leak in. exec( &state, &format!( "pmacs.project.set_search_boundary({})", lua_str(&dir.display().to_string()) ), ); state } /// The `lsp` modeline segment's text for the active buffer, or `None`. fn lsp_segment(state: &EditorState) -> Option { let outcome = evaluate_statusline( state.lua_host.lua(), &state.core, &state.statusline_registry, StatuslineEvaluationTarget::Grid { frontend_id: FrontendId::LOCAL, }, ); let StatuslineEvaluationOutcome::Ready(windows) = outcome.outcome else { return None; }; // Found by face rather than by name: `EvaluatedStatuslineSegment` // carries `provider_id`, not the registration's name, and the face // is the stable public identity of this segment. windows .into_iter() .flat_map(|w| w.right) .find(|s| s.face == "ui.modeline.lsp") .map(|s| s.text) } fn clear_status(state: &EditorState) { exec(state, "pmacs.editor.set_status('')"); } fn failure_count(state: &EditorState) -> i64 { eval(state, "return #pmacs.lsp.spawn_failures()") } // --------------------------------------------------------------------------- // Reporting // --------------------------------------------------------------------------- /// **N** (framing acceptance 2) — the failure is reported, through the /// real `buffer.after-load` path, naming the command, the language and /// the underlying error. #[test] fn j1b2_a_missing_server_is_reported_with_guidance() { let td = cargo_project(); let state = editor_for(td.path()); let cmd = absent_command(td.path(), "rust-analyzer"); configure_rust(&state, &cmd); open(&state, &write_rs(td.path(), "a.rs")); let msg = status(&state); assert!(msg.contains(&cmd), "names the command; got {msg:?}"); assert!(msg.contains("rust"), "names the language; got {msg:?}"); assert!( msg.contains("No such file") || msg.contains("os error 2"), "passes the underlying error through; got {msg:?}" ); assert!( msg.contains("init.lua"), "says what the user can do; got {msg:?}" ); } /// **N** (3) — reported once per `(language, key_uri, command)`. The /// spawn is still attempted on the second open; only the message is /// suppressed. #[test] fn j1b2_a_repeat_failure_in_the_same_project_is_not_reannounced() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); open(&state, &write_rs(td.path(), "a.rs")); assert!(!status(&state).is_empty(), "first open reports"); clear_status(&state); open(&state, &write_rs(td.path(), "b.rs")); assert_eq!( status(&state), "", "a second file in the same project must not re-announce" ); // The failure is still current — the memo is on the report, not the // failure — so the surface still knows about it. assert_eq!(failure_count(&state), 1); } /// **N** (4) — the markerless case shares one memo, because it shares /// one server. Two loose files in *different* directories both resolve /// `key_uri = nil`. /// /// Falsified by keying the memo on the resolved root, which reports /// twice. This is the pin where the root and the affinity key differ. #[test] fn j1b2_markerless_files_in_different_directories_report_once() { let td = tempfile::tempdir().expect("tempdir"); let one = td.path().join("one"); let two = td.path().join("two"); std::fs::create_dir_all(&one).expect("mkdir one"); std::fs::create_dir_all(&two).expect("mkdir two"); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); open(&state, &write_rs(&one, "a.rs")); assert!(!status(&state).is_empty(), "first markerless open reports"); clear_status(&state); open(&state, &write_rs(&two, "b.rs")); assert_eq!( status(&state), "", "a markerless file elsewhere shares the same (language, nil) key" ); assert_eq!( failure_count(&state), 1, "and therefore one failure, not two" ); } /// **N** (5) — a genuinely different root reports again. #[test] fn j1b2_a_different_project_root_reports_again() { let outer = tempfile::tempdir().expect("tempdir"); let a = outer.path().join("a"); let b = outer.path().join("b"); std::fs::create_dir_all(&a).expect("mkdir a"); std::fs::create_dir_all(&b).expect("mkdir b"); std::fs::write(a.join("Cargo.toml"), b"[package]\nname=\"a\"\n").expect("w"); std::fs::write(b.join("Cargo.toml"), b"[package]\nname=\"b\"\n").expect("w"); let state = editor_for(outer.path()); configure_rust(&state, &absent_command(outer.path(), "rust-analyzer")); open(&state, &write_rs(&a, "a.rs")); clear_status(&state); open(&state, &write_rs(&b, "b.rs")); assert!( !status(&state).is_empty(), "a different detected root is a different affinity" ); assert_eq!(failure_count(&state), 2); } /// **N** (6) — a changed command reports again, because the reported /// identity includes it. #[test] fn j1b2_repointing_at_another_missing_command_reports_again() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); open(&state, &write_rs(td.path(), "a.rs")); clear_status(&state); let second = absent_command(td.path(), "rust-analyzer-2"); configure_rust(&state, &second); open(&state, &write_rs(td.path(), "b.rs")); let msg = status(&state); assert!( msg.contains(&second), "a different missing executable is a new failure; got {msg:?}" ); } // --------------------------------------------------------------------------- // Recovery // --------------------------------------------------------------------------- /// **N** (7, 8) — the memo is on the report, not the failure: the spawn /// is retried, so a resolvable command recovers with nothing to /// invalidate, and both surfaces go quiet. #[test] fn j1b2_recovery_clears_the_failure_surface() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); open(&state, &write_rs(td.path(), "a.rs")); assert_eq!(failure_count(&state), 1, "precondition: a failure exists"); // `/bin/sh` exists and is spawnable; it is not an LSP server, but // this pin is about the spawn succeeding, not about initialize. configure_rust(&state, "/bin/sh"); open(&state, &write_rs(td.path(), "b.rs")); assert_eq!( failure_count(&state), 0, "a successful spawn for the same affinity clears the failure" ); } /// **N** (9) — recovery reaches **every** buffer sharing the affinity, /// not just the one that succeeded. /// /// Asserted on A, deliberately: a version of this pin that checked B /// passes on the broken implementation, where only the succeeding /// buffer's projection is cleared and A keeps rendering `LSP:!` while /// `lsp.status` reports nothing wrong. #[test] fn j1b2_recovery_reaches_every_buffer_sharing_the_affinity() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); let a = write_rs(td.path(), "a.rs"); open(&state, &a); assert_eq!( lsp_segment(&state).as_deref(), Some("LSP:!"), "precondition: A is marked failed" ); configure_rust(&state, "/bin/sh"); open(&state, &write_rs(td.path(), "b.rs")); // Back to A — the buffer that never succeeded itself. open(&state, &a); assert_ne!( lsp_segment(&state).as_deref(), Some("LSP:!"), "A must stop claiming a failure that the shared affinity has resolved" ); } // --------------------------------------------------------------------------- // Modeline // --------------------------------------------------------------------------- /// **N** (13) — the modeline distinguishes "failed" from "not /// applicable". Both halves asserted: a pin checking only the failing /// case passes if the segment renders `!` unconditionally. #[test] fn j1b2_the_modeline_distinguishes_failed_from_unsupported() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); open(&state, &write_rs(td.path(), "a.rs")); assert_eq!( lsp_segment(&state).as_deref(), Some("LSP:!"), "a source file whose server failed says so" ); let txt = td.path().join("notes.txt"); std::fs::write(&txt, b"plain\n").expect("write txt"); open(&state, &txt); assert_eq!( lsp_segment(&state), None, "a file with no configured server renders nothing at all" ); } // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- /// **N** (14) — a killed buffer's projection is removed. /// /// Without the `pmacs.buffer.on_removed` registration the entry outlives /// its buffer for the session, and nothing else can reach it: the LSP /// resource reconciliation iterates `attachments`, and a failed buffer /// has none by construction. #[test] fn j1b2_a_killed_buffer_drops_its_failure_projection() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); let a = write_rs(td.path(), "a.rs"); open(&state, &a); assert_eq!(lsp_segment(&state).as_deref(), Some("LSP:!")); let before: i64 = eval(&state, "return #pmacs.buffer.list()"); exec(&state, "pmacs.buffer.remove(pmacs.window.buffer())"); let after: i64 = eval(&state, "return #pmacs.buffer.list()"); assert!( after < before, "precondition: the buffer really was removed" ); // Re-open the same path: a fresh buffer that has never failed must // not inherit a marker, and the stale projection must not be what // answers for it. open(&state, &a); configure_rust(&state, "/bin/sh"); open(&state, &write_rs(td.path(), "b.rs")); assert_eq!( failure_count(&state), 0, "the sweep still terminates and clears with a killed buffer in play" ); } /// **N** (15) — a rename clears the projection rather than leaving an /// old-path failure attached to a changed buffer. #[test] fn j1b2_a_rename_clears_the_failure_projection() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); let a = write_rs(td.path(), "a.rs"); open(&state, &a); assert_eq!(lsp_segment(&state).as_deref(), Some("LSP:!")); let renamed = td.path().join("renamed.rs"); exec( &state, &format!( "pmacs.hook.run('resource.renamed', {}, {})", lua_str(&a.display().to_string()), lua_str(&renamed.display().to_string()) ), ); assert_ne!( lsp_segment(&state).as_deref(), Some("LSP:!"), "after a rename the projection no longer describes this buffer" ); } /// **N** (16) — a delete clears it too. #[test] fn j1b2_a_delete_clears_the_failure_projection() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); let a = write_rs(td.path(), "a.rs"); open(&state, &a); assert_eq!(lsp_segment(&state).as_deref(), Some("LSP:!")); exec( &state, &format!( "pmacs.hook.run('resource.deleted', {})", lua_str(&a.display().to_string()) ), ); assert_ne!( lsp_segment(&state).as_deref(), Some("LSP:!"), "a deleted path leaves no failure to project" ); } // --------------------------------------------------------------------------- // The *lsp* panel // --------------------------------------------------------------------------- fn named_text(state: &EditorState, name: &str) -> String { eval( state, &format!( r#" for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == {name:?} then return id:slice(0, id:len()) end end return "" "# ), ) } /// **N** (10) — `M-x lsp.status` renders both sections. Asserts content /// produced, not that a buffer exists. #[test] fn j1b2_lsp_status_renders_failures_and_servers() { let td = cargo_project(); let state = editor_for(td.path()); let cmd = absent_command(td.path(), "rust-analyzer"); configure_rust(&state, &cmd); open(&state, &write_rs(td.path(), "a.rs")); exec(&state, "pmacs.command.invoke('lsp.status')"); let text = named_text(&state, "*lsp*"); assert!( text.contains(&cmd), "the failure section names the command; got:\n{text}" ); assert!( text.contains("Servers:"), "and `status_buffer_text` still renders beneath it; got:\n{text}" ); } /// **N** (11) — `g` refreshes. The **reattach is load-bearing**: making /// the command resolvable changes no state on its own, since `failures` /// is cleared by a successful spawn. #[test] fn j1b2_g_refreshes_the_lsp_panel_after_recovery() { let td = cargo_project(); let state = editor_for(td.path()); let cmd = absent_command(td.path(), "rust-analyzer"); configure_rust(&state, &cmd); open(&state, &write_rs(td.path(), "a.rs")); exec(&state, "pmacs.command.invoke('lsp.status')"); assert!(named_text(&state, "*lsp*").contains(&cmd)); // Resolve AND reattach, then refresh in place. configure_rust(&state, "/bin/sh"); open(&state, &write_rs(td.path(), "b.rs")); exec(&state, "pmacs.command.invoke('lsp.status')"); exec(&state, "pmacs.command.invoke('listview.refresh')"); assert!( !named_text(&state, "*lsp*").contains(&cmd), "g must re-render, not leave the panel stale" ); } /// **N** (12) — a foreign `*lsp*` buffer is never adopted. This is /// `listview.open`'s guarantee, pinned rather than assumed because it is /// exactly what a hand-rolled panel loses. #[test] fn j1b2_a_foreign_lsp_buffer_is_not_adopted() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); exec( &state, "local b = pmacs.buffer.create('*lsp*') b:insert(0, 'user bytes')", ); exec(&state, "pmacs.command.invoke('lsp.status')"); assert_eq!( named_text(&state, "*lsp*"), "user bytes", "the user's buffer is untouched" ); assert!( named_text(&state, "*lsp*<2>").contains("Servers:"), "and the panel opens beside it" ); } // --------------------------------------------------------------------------- // Preservation // --------------------------------------------------------------------------- /// **P** (20) — a failed spawn never fabricates an attachment record. /// Targeted mutation: recording the failure in `attachments`, which /// would route requests at a server that does not exist. #[test] fn j1b2_preservation_a_failed_spawn_creates_no_attachment() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, &absent_command(td.path(), "rust-analyzer")); open(&state, &write_rs(td.path(), "a.rs")); assert!( eval::(&state, "return pmacs.lsp.attachment_for_request() == nil"), "no request may be issued against a server that failed to start" ); } /// **P** (18) — a spawnable server is unaffected: it attaches, and the /// modeline reports the server rather than a failure. #[test] fn j1b2_preservation_a_spawnable_server_still_attaches() { let td = cargo_project(); let state = editor_for(td.path()); configure_rust(&state, "/bin/sh"); open(&state, &write_rs(td.path(), "a.rs")); let seg = lsp_segment(&state); assert!( seg.is_some() && seg.as_deref() != Some("LSP:!"), "a started server keeps its own label; got {seg:?}" ); assert_eq!(failure_count(&state), 0); } // 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;