pmacs/tests/lsp_spawn_guidance_acceptan...

568 lines
19 KiB
Rust

// 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<T: mlua::FromLuaMulti>(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();
// 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<String> {
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::<bool>(&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);
}