575 lines
19 KiB
Rust
575 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_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<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);
|
|
}
|
|
|
|
// 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;
|