pmacs/tests/lean4_server_acceptance.rs

641 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Arc 8 Stage 3b acceptance — the Lean 4 language server.
//!
//! `docs/lean4-mode-framing.md` Q#LN7, Q#LN8, Q#LN16; acceptance 2228,
//! 24a/24b, 35, 36, 36a, 37.
//!
//! No live toolchain required. The server side is `pmacs_fake_lsp`
//! configured under the `lean4` language id; the probe and latch are
//! driven through shell stubs the fixture writes, so nothing here needs
//! `lake`, `lean`, or an elan toolchain on PATH (§2.9).
//!
//! Every fixture sets `pmacs.project.set_search_boundary` at its own
//! tempdir root. Without it the `lean-toolchain` walk climbs to the
//! filesystem root and acceptance 23's outermost assertion stops being
//! hermetic.
#![cfg(unix)]
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()
}
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 mkdir(&self, rel: &str) -> PathBuf {
let path = self.root.join(rel);
std::fs::create_dir_all(&path).unwrap();
path
}
fn dir(&self, rel: &str) -> PathBuf {
self.root.join(rel)
}
/// A `lean-toolchain` marker file. Content is irrelevant to the
/// resolver by design (existence semantics), which 24b pins.
fn toolchain(&self, rel_dir: &str, body: &str) {
self.write(&format!("{rel_dir}/lean-toolchain"), body);
}
fn bind(&self, state: &EditorState) {
exec(
state,
&format!(
"pmacs.project.set_search_boundary(\"{}\")",
lua_str(&self.root)
),
);
}
}
/// A fresh editor with every shipped language config cleared, then the
/// `lean4` entry rebuilt against the fake server while KEEPING the real
/// resolver. That combination is the point: the root rule under test is
/// production code, only the command is a stand-in.
fn editor(fx: &Fixture) -> EditorState {
let state = EditorState::new();
exec(&state, "pmacs.lsp.config = {}");
exec(
&state,
&format!(
r#"
pmacs.lsp.config.lean4 = {{
command = "{}",
args = {{}},
root = pmacs.lean.root_for,
}}
"#,
fake_lsp_path()
),
);
fx.bind(&state);
state
}
fn settle(state: &mut EditorState) {
for _ in 0..10 {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(2));
}
}
fn open(state: &EditorState, path: &Path) {
exec(
state,
&format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)),
);
}
/// `language_id|root_uri|cwd` for every live server, sorted.
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 "",
}, "|")
end
table.sort(out)
return table.concat(out, "\n")
"#,
);
if joined.is_empty() {
Vec::new()
} else {
joined.lines().map(str::to_owned).collect()
}
}
fn resolved_root(state: &EditorState, file: &Path) -> String {
eval(
state,
&format!(
"return tostring(pmacs.lean.root_for(\"{}\"))",
lua_str(file)
),
)
}
// ---------------------------------------------------------------------------
// Acceptance 22 — a Lean file in a Lake package spawns one server rooted
// at the package.
// ---------------------------------------------------------------------------
#[test]
fn acc22_lean_file_in_a_lake_package_spawns_one_server_at_the_package_root() {
let fx = Fixture::new();
fx.toolchain("pkg", "leanprover/lean4:v4.9.0\n");
let file = fx.write("pkg/Pkg/Basic.lean", "def x : Nat := 1\n");
let mut state = editor(&fx);
open(&state, &file);
settle(&mut state);
let pkg = fx.dir("pkg").display().to_string();
assert_eq!(
rows(&state),
vec![format!("lean4|file://{pkg}|{pkg}")],
"one server, rooted and cwd'd at the Lake package"
);
}
// ---------------------------------------------------------------------------
// Acceptance 23 — outermost wins.
//
// The case `pmacs.project.detect` cannot express: it is innermost-wins by
// construction, so a dependency vendored under `.lake/packages` would get
// its own server and its own (wrong) view of the world.
// ---------------------------------------------------------------------------
#[test]
fn acc23_nested_toolchains_resolve_to_the_outermost_package() {
let fx = Fixture::new();
fx.toolchain("pkg", "leanprover/lean4:v4.9.0\n");
fx.toolchain("pkg/.lake/packages/dep", "leanprover/lean4:v4.8.0\n");
let inner = fx.write(
"pkg/.lake/packages/dep/Dep/Core.lean",
"def dep : Nat := 2\n",
);
let state = editor(&fx);
assert_eq!(
resolved_root(&state, &inner),
fx.dir("pkg").display().to_string(),
"a file under .lake/packages/dep belongs to the outer package"
);
// Non-vacuity: the inner marker really exists, so "outermost" is a
// choice between two candidates rather than the only one found.
assert!(fx.dir("pkg/.lake/packages/dep/lean-toolchain").exists());
}
// ---------------------------------------------------------------------------
// Acceptance 24 — the walk stops at the search boundary.
// ---------------------------------------------------------------------------
#[test]
fn acc24_walk_stops_at_the_search_boundary() {
let fx = Fixture::new();
// Boundary is the fixture root; this marker sits INSIDE it.
fx.toolchain("pkg", "v4.9.0\n");
let file = fx.write("pkg/A.lean", "def a := 1\n");
// And this one sits AT the fixture root, i.e. above `pkg` but still
// within the boundary — it must win, being outermost.
fx.toolchain(".", "v4.7.0\n");
let state = editor(&fx);
assert_eq!(
resolved_root(&state, &file),
fx.root.display().to_string(),
"within the boundary, the outermost marker wins"
);
// Now move the boundary IN to `pkg`. The root-level marker is above
// it and must not be reached.
exec(
&state,
&format!(
"pmacs.project.set_search_boundary(\"{}\")",
lua_str(&fx.dir("pkg"))
),
);
assert_eq!(
resolved_root(&state, &file),
fx.dir("pkg").display().to_string(),
"a marker above the boundary is not consulted"
);
}
// ---------------------------------------------------------------------------
// Acceptance 24a / 24b — the marker test, both directions.
//
// These two must each fail against the implementation that satisfies only
// the other. 24a bites the bare `io.open` truth test (which succeeds on a
// directory); 24b bites the read-a-byte-and-require-non-nil rule (which
// rejects an empty file at EOF).
// ---------------------------------------------------------------------------
#[test]
fn acc24a_a_lean_toolchain_directory_is_not_a_marker() {
let fx = Fixture::new();
fx.mkdir("pkg/lean-toolchain");
let file = fx.write("pkg/A.lean", "def a := 1\n");
let state = editor(&fx);
assert_eq!(
resolved_root(&state, &file),
"nil",
"a `lean-toolchain` DIRECTORY must not mark a root"
);
}
#[test]
fn acc24b_an_empty_lean_toolchain_file_is_a_marker() {
let fx = Fixture::new();
fx.toolchain("pkg", "");
let file = fx.write("pkg/A.lean", "def a := 1\n");
let state = editor(&fx);
assert_eq!(
resolved_root(&state, &file),
fx.dir("pkg").display().to_string(),
"marker semantics are existence, not content — an empty \
`lean-toolchain` still marks the package"
);
// Non-vacuity: the file really is empty.
assert_eq!(
std::fs::read(fx.dir("pkg/lean-toolchain")).unwrap().len(),
0
);
}
#[test]
fn acc24_resolver_declines_when_no_marker_exists() {
let fx = Fixture::new();
let file = fx.write("loose/A.lean", "def a := 1\n");
let state = editor(&fx);
assert_eq!(
resolved_root(&state, &file),
"nil",
"no marker anywhere is a decline, which falls through to \
`pmacs.project.detect`"
);
}
// ---------------------------------------------------------------------------
// Acceptance 25 — a string-valued root still works.
// ---------------------------------------------------------------------------
#[test]
fn acc25_string_valued_root_still_works() {
let fx = Fixture::new();
let pkg = fx.mkdir("elsewhere");
let file = fx.write("pkg/A.lean", "def a := 1\n");
let mut state = editor(&fx);
exec(
&state,
&format!("pmacs.lsp.config.lean4.root = \"{}\"", lua_str(&pkg)),
);
open(&state, &file);
settle(&mut state);
let want = pkg.display().to_string();
assert_eq!(
rows(&state),
vec![format!("lean4|file://{want}|{want}")],
"the Q#LN8 generalization is additive; a plain string still wins"
);
}
// ---------------------------------------------------------------------------
// Acceptance 26 — didOpen carries languageId = "lean4".
// ---------------------------------------------------------------------------
#[test]
fn acc26_did_open_carries_the_lean4_language_id() {
let fx = Fixture::new();
fx.toolchain("pkg", "v4.9.0\n");
let file = fx.write("pkg/A.lean", "def a := 1\n");
let mut state = editor(&fx);
open(&state, &file);
settle(&mut state);
let lang: String = eval(
&state,
"return tostring(pmacs.lsp.list()[1] and pmacs.lsp.list()[1].language_id)",
);
assert_eq!(
lang, "lean4",
"the grammar entry name is the didOpen language id (Q#LN2)"
);
}
// ---------------------------------------------------------------------------
// Acceptance 28 (probe) — the version predicate.
//
// The parse is unit-tested directly because the spawn path is timing-
// bound; the latch's *effect* is pinned separately below.
// ---------------------------------------------------------------------------
#[test]
fn acc28_version_predicate_triggers_only_below_3_1() {
let fx = Fixture::new();
let state = editor(&fx);
let check = |v: &str| -> bool {
eval(
&state,
&format!("return pmacs.lean._version_below_3_1(\"{v}\")"),
)
};
assert!(check("Lake version 3.0.0"), "3.0.0 is below 3.1");
assert!(!check("Lake version 3.1.0"), "3.1.0 is not below 3.1");
assert!(!check("Lake version 5.0.0-abc"), "5.0.0 is not below 3.1");
assert!(check("Lake version 2.9.9"), "2.9.9 is below 3.1");
assert!(
!check("no default toolchain configured"),
"an unparseable line must NOT trigger the fallback — that is the \
elan-shim case, which the failure latch handles better"
);
}
// ---------------------------------------------------------------------------
// Acceptance 27 / 35 / 36 — the fallback latch.
// ---------------------------------------------------------------------------
#[test]
fn acc35_latch_preserves_user_config_and_swaps_only_command_and_args() {
let fx = Fixture::new();
let state = editor(&fx);
// A user's init.lua settings, on the shipped shape.
exec(
&state,
r#"
pmacs.lsp.config.lean4.command = "lake"
pmacs.lsp.config.lean4.args = { "serve" }
pmacs.lsp.config.lean4.env = { MYVAR = "1" }
pmacs.lsp.config.lean4.settings = { lean = { verbose = true } }
pmacs.lsp.config.lean4.init_options = { hasWidgets = false }
_G.root_before = pmacs.lsp.config.lean4.root
pmacs.lean._fire_latch(nil, "test")
"#,
);
let after: String = eval(
&state,
r#"
local c = pmacs.lsp.config.lean4
return table.concat({
tostring(c.command),
tostring(c.args and c.args[1]),
tostring(c.env and c.env.MYVAR),
tostring(c.settings and c.settings.lean and c.settings.lean.verbose),
tostring(c.init_options and c.init_options.hasWidgets),
tostring(c.root == _G.root_before),
}, "|")
"#,
);
assert_eq!(
after, "lean|--server|1|true|false|true",
"only command/args change; env, settings, init_options and root \
survive the swap"
);
}
#[test]
fn acc27_the_latch_is_one_shot_and_does_not_re_arm() {
let fx = Fixture::new();
let state = editor(&fx);
exec(
&state,
r#"
pmacs.lsp.config.lean4.command = "lake"
pmacs.lsp.config.lean4.args = { "serve" }
pmacs.lean._fire_latch(nil, "first failure")
_G.after_first = pmacs.lsp.config.lean4.command
-- A second failure must not rewrite the command again; if it did,
-- a user who deliberately set something else after the fallback
-- would have it silently replaced.
pmacs.lsp.config.lean4.command = "user-choice"
pmacs.lean._fire_latch(nil, "second failure")
_G.after_second = pmacs.lsp.config.lean4.command
"#,
);
assert_eq!(eval::<String>(&state, "return _G.after_first"), "lean");
assert_eq!(
eval::<String>(&state, "return _G.after_second"),
"user-choice",
"the latch never re-arms within a session"
);
}
#[test]
fn acc36_latch_stops_the_failing_server_before_spawning_the_fallback() {
let fx = Fixture::new();
fx.toolchain("pkg", "v4.9.0\n");
let file = fx.write("pkg/A.lean", "def a := 1\n");
let mut state = editor(&fx);
open(&state, &file);
settle(&mut state);
assert_eq!(rows(&state).len(), 1, "precondition: one server is up");
// Fire the latch against the live server, exactly as `poll_latch`
// would. `pmacs.lsp.stop` sets `restart = Never` on the way out —
// which is what prevents `RestartPolicy::OnCrash` from respawning the
// broken command underneath the latch, forever, with no attempt cap.
exec(
&state,
r#"
pmacs.lsp.config.lean4.command = "lake"
pmacs.lsp.config.lean4.args = { "serve" }
pmacs.lean._fire_latch(pmacs.lsp.list()[1].id, "failed to start")
"#,
);
settle(&mut state);
let terminal: bool = 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 false end
end
return true
"#,
);
assert!(
terminal,
"the failing server is stopped, not left to be respawned under \
the latch"
);
}
// ---------------------------------------------------------------------------
// Acceptance 36a — attribution (COHERENCE §9 / §1.2).
// ---------------------------------------------------------------------------
#[test]
fn acc36a_latch_leaves_a_status_line_trace() {
let fx = Fixture::new();
let state = editor(&fx);
exec(
&state,
r#"
pmacs.lsp.config.lean4.command = "lake"
pmacs.lsp.config.lean4.args = { "serve" }
pmacs.lean._fire_latch(nil, "`lake serve` failed to start")
"#,
);
let status = state.core.borrow().status.clone();
assert!(
status.contains("lean4") && status.contains("lean --server"),
"the fallback names itself and what it fell back to; saw {status:?}"
);
// The channel assertion is the point (COHERENCE §1.2): a report made
// only through `pmacs.error` — undefined in production — would leave
// this empty while every other assertion here still passed.
assert!(!status.is_empty());
}
#[test]
fn acc36a_probe_carries_a_lean_owned_process_label() {
// `ProcessSpec.label` is the only identity a process has, and it is
// what `pmacs.process.list` renders. Asserted on the spec the module
// builds rather than on a live `lake`, which CI does not have.
let fx = Fixture::new();
let state = editor(&fx);
let src = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("builtin/runtime/lean.lua"),
)
.unwrap();
assert!(
src.contains("label = \"lean:lake-version-probe\""),
"the probe process is attributed to Lean by label"
);
// And it is genuinely lazy: no probe without an attachment.
let procs: i64 = eval(&state, "return #pmacs.process.list()");
assert_eq!(procs, 0, "configuring Lean does not start the probe");
}
// ---------------------------------------------------------------------------
// Acceptance 37 — waitForDiagnostics resolves through the response seam.
// ---------------------------------------------------------------------------
#[test]
fn acc37_wait_for_diagnostics_resolves_through_the_response_seam() {
let fx = Fixture::new();
fx.toolchain("pkg", "v4.9.0\n");
let file = fx.write("pkg/A.lean", "def a : Nat := 1\n");
let mut state = editor(&fx);
open(&state, &file);
settle(&mut state);
exec(
&state,
r#"
_G.settled = "never"
local rec = pmacs.lsp.active_attachment()
pmacs.lean.wait_for_diagnostics(rec.server, rec.uri, function(err)
_G.settled = tostring(err)
end)
"#,
);
settle(&mut state);
assert_eq!(
eval::<String>(&state, "return _G.settled"),
"nil",
"the reply reaches the callback with no error — this is the \
Stage 3a response seam carrying its first production caller"
);
}
// ---------------------------------------------------------------------------
// Acceptance 29, Lean's side — `$/lean/fileProgress` reaches the module.
//
// Driven end-to-end through the real drain: the fake server's
// `leanprogress` mode emits the notification on didOpen. Calling the
// handler directly would pin nothing about the wiring, which is the only
// part that can break.
// ---------------------------------------------------------------------------
#[test]
fn file_progress_notification_is_recorded_for_its_document() {
let fx = Fixture::new();
fx.toolchain("pkg", "v4.9.0\n");
let file = fx.write("pkg/A.lean", "def a := 1\n");
let mut state = editor(&fx);
exec(
&state,
"pmacs.lsp.config.lean4.env = { PMACS_FAKE_LSP_MODE = \"leanprogress\" }",
);
// Nothing recorded before the server speaks — so the assertion below
// cannot pass on a pre-populated table.
let before: i64 = eval(
&state,
"local n = 0 for _ in pairs(pmacs.lean.file_progress) do n = n + 1 end return n",
);
assert_eq!(before, 0);
open(&state, &file);
settle(&mut state);
let uri: String = eval(
&state,
r#"
for k, v in pairs(pmacs.lean.file_progress) do
if type(v) == "table" and v[1] and v[1].range then return k end
end
return "none"
"#,
);
assert!(
uri.starts_with("file://") && uri.ends_with("A.lean"),
"the subscriber recorded the processing ranges under the \
document uri; saw {uri:?}"
);
}
// ---------------------------------------------------------------------------
// Q#LN20 in the Lean resolver — a symlinked open reuses one server.
// ---------------------------------------------------------------------------
#[test]
fn lean_root_is_canonical_so_a_symlinked_open_reuses_one_server() {
let fx = Fixture::new();
fx.toolchain("pkg", "v4.9.0\n");
let real = fx.write("pkg/A.lean", "def a := 1\n");
std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap();
let linked = fx.dir("linkpkg").join("A.lean");
let mut state = editor(&fx);
open(&state, &real);
settle(&mut state);
assert_eq!(rows(&state).len(), 1, "the real path spawns one server");
open(&state, &linked);
settle(&mut state);
assert_eq!(
rows(&state).len(),
1,
"the symlinked path reuses it — the resolver canonicalizes, so \
both spellings produce the same affinity key"
);
}