712 lines
23 KiB
Rust
712 lines
23 KiB
Rust
//! Arc 8 Stage 2 acceptance — multi-root LSP server affinity.
|
||
//!
|
||
//! `docs/lean4-mode-framing.md` Q#LN15, acceptance 13–21.
|
||
//!
|
||
//! This suite deliberately contains **no Lean content**. `ensure_server`
|
||
//! (`builtin/runtime/lsp.lua`) is the single server-affinity function for
|
||
//! every LSP language in pmacs, so the change is exercised through the
|
||
//! four languages that already shipped attach paths — rust, python, go,
|
||
//! typescript — driven against `pmacs_fake_lsp` so nothing here needs a
|
||
//! real toolchain on PATH.
|
||
//!
|
||
//! Every fixture calls `pmacs.project.set_search_boundary` at its own
|
||
//! tempdir root. Without it the marker walk climbs to the filesystem
|
||
//! root, and a stray `.git` above the temp directory would silently turn
|
||
//! the "markerless" cases into detected ones — the assertions would still
|
||
//! pass while testing nothing.
|
||
|
||
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<T: mlua::FromLuaMulti>(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_with_roots(&crate::iso::roots());
|
||
exec(&state, "pmacs.lsp.config = {}");
|
||
state
|
||
}
|
||
|
||
fn lua_str(path: &Path) -> String {
|
||
path.display()
|
||
.to_string()
|
||
.replace('\\', "\\\\")
|
||
.replace('"', "\\\"")
|
||
}
|
||
|
||
/// Mirror of `file_uri_for` in `builtin/runtime/lsp.lua` and
|
||
/// `path_to_file_uri` in `src/lsp.rs`. 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 ch in path.display().to_string().chars() {
|
||
match ch {
|
||
'a'..='z' | 'A'..='Z' | '0'..='9' | '/' | '-' | '_' | '.' | '~' | ':' => out.push(ch),
|
||
_ => {
|
||
use std::fmt::Write as _;
|
||
let mut buf = [0u8; 4];
|
||
for byte in ch.encode_utf8(&mut buf).as_bytes() {
|
||
let _ = write!(out, "%{byte:02X}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
struct Fixture {
|
||
_dir: tempfile::TempDir,
|
||
root: PathBuf,
|
||
}
|
||
|
||
impl Fixture {
|
||
/// Canonicalized so the expected roots below compare equal to what
|
||
/// `pmacs.project.detect` returns (it canonicalizes before walking,
|
||
/// which matters on macOS where `/var` is a symlink to `/private/var`).
|
||
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)),
|
||
);
|
||
}
|
||
|
||
fn settle(state: &mut EditorState) {
|
||
for _ in 0..8 {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
}
|
||
|
||
/// One `language_id|root_uri|cwd|state` row per live server, sorted so
|
||
/// assertions do not depend on spawn order. Absent fields read as "".
|
||
fn rows(state: &EditorState) -> Vec<String> {
|
||
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.cwd 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 status(state: &EditorState) -> String {
|
||
state.core.borrow().status.clone()
|
||
}
|
||
|
||
fn count(state: &EditorState) -> usize {
|
||
let n: i64 = eval(state, "return #pmacs.lsp.list()");
|
||
usize::try_from(n).expect("server count is non-negative")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 13 — `lsp.list()` rows carry `root_uri` and `cwd`.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc13_list_rows_carry_root_uri_and_cwd() {
|
||
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();
|
||
fx.bind(&state);
|
||
configure(&state, "rust");
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let proj = fx.dir("proj");
|
||
let rows = rows(&state);
|
||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||
let fields: Vec<&str> = rows[0].split('|').collect();
|
||
assert_eq!(fields[0], "rust");
|
||
assert_eq!(
|
||
fields[1],
|
||
file_uri(&proj),
|
||
"root_uri must be the project root"
|
||
);
|
||
assert_eq!(
|
||
fields[2],
|
||
proj.display().to_string(),
|
||
"cwd must be the root"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 14 — two roots, same language, two servers.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc14_two_project_roots_of_one_language_spawn_two_servers() {
|
||
let fx = Fixture::new();
|
||
fx.write("a/Cargo.toml", "[package]\nname = \"a\"\n");
|
||
fx.write("b/Cargo.toml", "[package]\nname = \"b\"\n");
|
||
let first = fx.write("a/src/main.rs", "fn main() {}\n");
|
||
let second = fx.write("b/src/main.rs", "fn main() {}\n");
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
configure(&state, "rust");
|
||
open(&state, &first);
|
||
settle(&mut state);
|
||
open(&state, &second);
|
||
settle(&mut state);
|
||
|
||
let rows = rows(&state);
|
||
assert_eq!(rows.len(), 2, "one server per project root: {rows:?}");
|
||
let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect();
|
||
assert!(
|
||
roots.contains(&file_uri(&fx.dir("a")).as_str()),
|
||
"{roots:?}"
|
||
);
|
||
assert!(
|
||
roots.contains(&file_uri(&fx.dir("b")).as_str()),
|
||
"{roots:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 15 — same root, two files, one server. The pre-change
|
||
// behavior, pinned so the fix cannot degrade into "always spawn".
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc15_two_files_in_one_root_reuse_a_single_server() {
|
||
let fx = Fixture::new();
|
||
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||
let first = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||
let second = fx.write("proj/src/other.rs", "pub fn other() {}\n");
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
configure(&state, "rust");
|
||
open(&state, &first);
|
||
settle(&mut state);
|
||
open(&state, &second);
|
||
settle(&mut state);
|
||
|
||
let rows = rows(&state);
|
||
assert_eq!(rows.len(), 1, "same root must reuse: {rows:?}");
|
||
assert_eq!(
|
||
rows[0].split('|').nth(1).unwrap(),
|
||
file_uri(&fx.dir("proj"))
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 16 — per-language regression pin. The single-root case is
|
||
// all the shipped attach paths ever exercised; it must be untouched.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc16_shipped_languages_are_unchanged_for_the_single_root_case() {
|
||
// (language id, project marker, two source files under it)
|
||
let cases: [(&str, &str, &str, &str); 4] = [
|
||
("rust", "Cargo.toml", "one.rs", "two.rs"),
|
||
("python", "pyproject.toml", "one.py", "two.py"),
|
||
("go", "go.mod", "one.go", "two.go"),
|
||
("typescript", "package.json", "one.ts", "two.ts"),
|
||
];
|
||
for (language, marker, first_name, second_name) in cases {
|
||
let fx = Fixture::new();
|
||
fx.write(&format!("proj/{marker}"), "{}\n");
|
||
let first = fx.write(&format!("proj/src/{first_name}"), "\n");
|
||
let second = fx.write(&format!("proj/src/{second_name}"), "\n");
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
configure(&state, language);
|
||
open(&state, &first);
|
||
settle(&mut state);
|
||
open(&state, &second);
|
||
settle(&mut state);
|
||
|
||
let rows = rows(&state);
|
||
assert_eq!(
|
||
rows.len(),
|
||
1,
|
||
"{language}: expected one server, got {rows:?}"
|
||
);
|
||
let fields: Vec<&str> = rows[0].split('|').collect();
|
||
assert_eq!(fields[0], language, "{language}: language_id");
|
||
assert_eq!(
|
||
fields[1],
|
||
file_uri(&fx.dir("proj")),
|
||
"{language}: root must be the marker directory"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 17 — hoist pin. `project_root_for` now runs on the *reuse*
|
||
// path, and a function-valued `root` is memoized per directory.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc17_function_root_runs_on_the_reuse_path_and_memoizes_per_directory() {
|
||
let fx = Fixture::new();
|
||
let shared = fx.dir("shared");
|
||
std::fs::create_dir_all(&shared).unwrap();
|
||
let a1 = fx.write("one/a.rs", "fn a() {}\n");
|
||
let a2 = fx.write("one/b.rs", "fn b() {}\n");
|
||
let b1 = fx.write("two/c.rs", "fn c() {}\n");
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
// A resolver that answers the same root for every directory: the
|
||
// second directory therefore REUSES the first directory's server,
|
||
// which is exactly the path the hoist put the resolver on.
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
_G.ROOT_CALLS = 0
|
||
pmacs.lsp.config.rust = {{
|
||
command = "{}",
|
||
root = function(_)
|
||
_G.ROOT_CALLS = _G.ROOT_CALLS + 1
|
||
return "{}"
|
||
end,
|
||
}}
|
||
"#,
|
||
fake_lsp_path(),
|
||
lua_str(&shared)
|
||
),
|
||
);
|
||
|
||
open(&state, &a1);
|
||
settle(&mut state);
|
||
assert_eq!(eval::<i64>(&state, "return _G.ROOT_CALLS"), 1, "spawn path");
|
||
|
||
// Same directory: served from the memo, so the count does not move.
|
||
open(&state, &a2);
|
||
settle(&mut state);
|
||
assert_eq!(
|
||
eval::<i64>(&state, "return _G.ROOT_CALLS"),
|
||
1,
|
||
"second file in the same directory must hit the memo"
|
||
);
|
||
|
||
// Different directory: the resolver runs again — proving the reuse
|
||
// path resolves at all — but resolves to the same root, so no second
|
||
// server appears.
|
||
open(&state, &b1);
|
||
settle(&mut state);
|
||
assert_eq!(
|
||
eval::<i64>(&state, "return _G.ROOT_CALLS"),
|
||
2,
|
||
"a new directory must consult the resolver on the reuse path"
|
||
);
|
||
let rows = rows(&state);
|
||
assert_eq!(rows.len(), 1, "one resolved root, one server: {rows:?}");
|
||
assert_eq!(rows[0].split('|').nth(1).unwrap(), file_uri(&shared));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 18 — a hand-spawned server carrying only `cwd` is not
|
||
// adopted by a root-bearing attach. A deliberate behavior change.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc18_hand_spawned_server_without_root_uri_is_not_adopted() {
|
||
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 proj = fx.dir("proj");
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
configure(&state, "rust");
|
||
// Exactly what an init.lua would write: cwd, no root_uri.
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.spawn({{
|
||
label = "hand-rolled",
|
||
language_id = "rust",
|
||
command = "{}",
|
||
cwd = "{}",
|
||
}})
|
||
"#,
|
||
fake_lsp_path(),
|
||
lua_str(&proj)
|
||
),
|
||
);
|
||
settle(&mut state);
|
||
assert_eq!(count(&state), 1, "the hand-spawned server is up");
|
||
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let rows = rows(&state);
|
||
assert_eq!(rows.len(), 2, "the attach must not adopt it: {rows:?}");
|
||
let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect();
|
||
assert!(
|
||
roots.contains(&""),
|
||
"hand-spawned reads back nil: {roots:?}"
|
||
);
|
||
assert!(
|
||
roots.contains(&file_uri(&proj).as_str()),
|
||
"the attach's own server carries the root: {roots:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 19 — a dead server in the matching root is not reused.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc19_stopped_server_in_the_matching_root_is_not_reused() {
|
||
let fx = Fixture::new();
|
||
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||
let first = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||
let second = fx.write("proj/src/other.rs", "pub fn other() {}\n");
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
configure(&state, "rust");
|
||
open(&state, &first);
|
||
settle(&mut state);
|
||
let original: i64 = eval(&state, "return pmacs.lsp.list()[1].id:raw()");
|
||
|
||
exec(&state, "pmacs.lsp.stop(pmacs.lsp.list()[1].id)");
|
||
for _ in 0..200 {
|
||
settle(&mut state);
|
||
let dead: bool = eval(
|
||
&state,
|
||
r#"
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
local k = s.state and s.state.kind
|
||
if k == "stopped" or k == "crashed" then return true end
|
||
end
|
||
return false
|
||
"#,
|
||
);
|
||
if dead {
|
||
break;
|
||
}
|
||
}
|
||
|
||
open(&state, &second);
|
||
settle(&mut state);
|
||
let live: i64 = eval(
|
||
&state,
|
||
r#"
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
local k = s.state and s.state.kind
|
||
if k ~= "stopped" and k ~= "crashed" then
|
||
return s.id:raw()
|
||
end
|
||
end
|
||
return -1
|
||
"#,
|
||
);
|
||
assert_ne!(live, -1, "a replacement server must exist");
|
||
assert_ne!(live, original, "the dead server must not be reused");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 20 — the loose-file pin (Q#LN15 part 2). This is the
|
||
// no-change case, and the one a naive `(language_id, root)` key breaks.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc20_markerless_files_in_different_directories_share_one_server() {
|
||
let fx = Fixture::new();
|
||
let first = fx.write("loose_a/one.rs", "fn one() {}\n");
|
||
let second = fx.write("loose_b/two.rs", "fn two() {}\n");
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
configure(&state, "rust");
|
||
open(&state, &first);
|
||
settle(&mut state);
|
||
open(&state, &second);
|
||
settle(&mut state);
|
||
|
||
let rows = rows(&state);
|
||
assert_eq!(
|
||
rows.len(),
|
||
1,
|
||
"loose files must keep sharing one server: {rows:?}"
|
||
);
|
||
let fields: Vec<&str> = rows[0].split('|').collect();
|
||
assert_eq!(fields[1], "", "the fallback root is not an affinity key");
|
||
assert_eq!(
|
||
fields[2],
|
||
fx.dir("loose_a").display().to_string(),
|
||
"cwd still carries the first file's directory"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 21 — detected and fallback are different servers, and the
|
||
// fallback one still carries its directory as `cwd`.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc21_detected_root_and_markerless_file_get_different_servers() {
|
||
let fx = Fixture::new();
|
||
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||
let inside = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||
let loose = fx.write("loose/stray.rs", "fn stray() {}\n");
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
configure(&state, "rust");
|
||
open(&state, &inside);
|
||
settle(&mut state);
|
||
open(&state, &loose);
|
||
settle(&mut state);
|
||
|
||
let rows = rows(&state);
|
||
assert_eq!(rows.len(), 2, "detected and fallback must differ: {rows:?}");
|
||
let detected = rows
|
||
.iter()
|
||
.find(|r| r.split('|').nth(1).unwrap() == file_uri(&fx.dir("proj")))
|
||
.unwrap_or_else(|| panic!("no server rooted at the project: {rows:?}"));
|
||
assert_eq!(
|
||
detected.split('|').nth(2).unwrap(),
|
||
fx.dir("proj").display().to_string()
|
||
);
|
||
let fallback = rows
|
||
.iter()
|
||
.find(|r| r.split('|').nth(1).unwrap().is_empty())
|
||
.unwrap_or_else(|| panic!("no rootless server: {rows:?}"));
|
||
assert_eq!(
|
||
fallback.split('|').nth(2).unwrap(),
|
||
fx.dir("loose").display().to_string(),
|
||
"the markerless server keeps the fallback directory as cwd"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Review-round-1 pins. Neither is a numbered acceptance criterion; both
|
||
// cover a branch the nine above leave untested.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// A *string* `config.root` is an affinity key. acc17 covers the function
|
||
/// form; without this the `return configured, "config"` arm has no test.
|
||
///
|
||
/// The bite: both files sit in their own marked project, so if the config
|
||
/// arm were dropped they would key on their own detected roots and spawn
|
||
/// two servers. One server keyed on the configured root is only possible
|
||
/// if the override wins.
|
||
#[test]
|
||
fn config_string_root_overrides_detection_as_the_affinity_key() {
|
||
let fx = Fixture::new();
|
||
fx.write("a/Cargo.toml", "[package]\nname = \"a\"\n");
|
||
fx.write("b/Cargo.toml", "[package]\nname = \"b\"\n");
|
||
let first = fx.write("a/src/main.rs", "fn main() {}\n");
|
||
let second = fx.write("b/src/main.rs", "fn main() {}\n");
|
||
let shared = fx.dir("shared");
|
||
std::fs::create_dir_all(&shared).unwrap();
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
"pmacs.lsp.config.rust = {{ command = \"{}\", root = \"{}\" }}",
|
||
fake_lsp_path(),
|
||
lua_str(&shared)
|
||
),
|
||
);
|
||
open(&state, &first);
|
||
settle(&mut state);
|
||
open(&state, &second);
|
||
settle(&mut state);
|
||
|
||
let rows = rows(&state);
|
||
assert_eq!(
|
||
rows.len(),
|
||
1,
|
||
"a configured root outranks both detected roots: {rows:?}"
|
||
);
|
||
let fields: Vec<&str> = rows[0].split('|').collect();
|
||
assert_eq!(fields[1], file_uri(&shared), "keyed on the configured root");
|
||
assert_eq!(fields[2], shared.display().to_string());
|
||
}
|
||
|
||
/// `root = false` reads as unset, as it always has. Defended in
|
||
/// `project_root_for` by a truthiness check rather than `~= nil`; this
|
||
/// pins the behavior instead of trusting the comment.
|
||
///
|
||
/// The bite: under a `~= nil` test the config arm would return
|
||
/// `false, "config"`, and `file_uri_for(false)` returns nil — so the file
|
||
/// would land on a rootless server instead of its detected project.
|
||
#[test]
|
||
fn config_root_false_reads_as_unset_and_detection_still_wins() {
|
||
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();
|
||
fx.bind(&state);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
"pmacs.lsp.config.rust = {{ command = \"{}\", root = false }}",
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let rows = rows(&state);
|
||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||
assert_eq!(
|
||
rows[0].split('|').nth(1).unwrap(),
|
||
file_uri(&fx.dir("proj")),
|
||
"`false` must not become a root; detection still wins"
|
||
);
|
||
}
|
||
|
||
/// COHERENCE §1.2: background wiring must leave an attributed trace
|
||
/// rather than discard a failure. A throwing root resolver is a config
|
||
/// bug, and the per-directory memo would otherwise bury it permanently.
|
||
///
|
||
/// The bite: drop the reporting arm and `*errors*` stays empty while the
|
||
/// attach still succeeds — the exact silence §1.2 names as the canonical
|
||
/// anti-pattern, in the function it cites.
|
||
#[test]
|
||
fn a_throwing_root_resolver_leaves_an_attributed_trace() {
|
||
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();
|
||
fx.bind(&state);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.rust = {{
|
||
command = "{}",
|
||
root = function(_) error("resolver blew up") end,
|
||
}}
|
||
"#,
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let msg = status(&state);
|
||
assert!(
|
||
msg.contains("root resolver"),
|
||
"a raising resolver must leave an attributed trace; got: {msg:?}"
|
||
);
|
||
assert!(
|
||
msg.contains("rust"),
|
||
"the trace must name the language that owns it; got: {msg:?}"
|
||
);
|
||
assert!(
|
||
msg.contains("resolver blew up"),
|
||
"the underlying error text must survive; got: {msg:?}"
|
||
);
|
||
|
||
// ...and the failure must degrade to a decline, not a failed attach:
|
||
// detection still wins and the buffer still gets its server.
|
||
let rows = rows(&state);
|
||
assert_eq!(rows.len(), 1, "the attach must still succeed: {rows:?}");
|
||
assert_eq!(
|
||
rows[0].split('|').nth(1).unwrap(),
|
||
file_uri(&fx.dir("proj")),
|
||
"a declining resolver falls through to the marker walk"
|
||
);
|
||
}
|
||
|
||
/// The decline path stays silent. Without this, "report failures" could
|
||
/// be satisfied by reporting *every* resolution, which would spam
|
||
/// `*errors*` on every attach in a Lean project.
|
||
#[test]
|
||
fn a_resolver_returning_nil_declines_silently() {
|
||
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();
|
||
fx.bind(&state);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
"pmacs.lsp.config.rust = {{ command = \"{}\", root = function(_) return nil end }}",
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let msg = status(&state);
|
||
assert!(
|
||
!msg.contains("root resolver"),
|
||
"returning nil is the documented decline, not a failure; got: {msg:?}"
|
||
);
|
||
assert_eq!(
|
||
rows(&state)[0].split('|').nth(1).unwrap(),
|
||
file_uri(&fx.dir("proj"))
|
||
);
|
||
}
|
||
|
||
// 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;
|