feat(lean): the Lean 4 language server (Arc 8 Stage 3b)

Framing Q#LN7, Q#LN8, Q#LN16; acceptance 22–28, 24a/24b, 35, 36, 36a, 37.
Stacked on Stage 3a (#167), whose notification/response seams and
`pmacs.fs.canonicalize` this consumes. No protocol change; the only Rust
outside the test helper is one `include_str!` line.

**The Lake-aware root (Q#LN8).** `pmacs.project.detect` cannot express
this rule — it is innermost-wins by construction, and a Lake package's
`lean-toolchain` sits at the outermost level, so a file under
`<pkg>/.lake/packages/dep/` belongs to `<pkg>`'s server rather than
`dep`'s. The resolver walks up collecting markers and returns the
outermost, stopping at `pmacs.project.search_boundary()` so a stray
marker above a fixture cannot leak in.

Two things about the marker test are easy to get wrong in opposite
directions, and both are pinned. `io.open` **succeeds on a directory**,
so a truthiness check accepts a `lean-toolchain` directory; but
requiring a non-nil read rejects an **empty** `lean-toolchain`, which is
a legitimate marker — `locate-dominating-file` semantics are existence,
not content. The discriminator is `read`'s second return: decline only
on a non-nil error. Acceptance 24a and 24b each fail against the
implementation that satisfies only the other; both bites are recorded.

The root is canonicalized once up front, because a configured root
reaches `file_uri_for` verbatim and that URI is the affinity key (#161).
Canonicalizing the starting directory suffices — every ancestor of a
canonical path is canonical, since the walk only strips components.

**`lake serve` with a lazy probe and a one-shot latch (Q#LN7).** Nothing
runs at init: `pmacs.lsp.config` is declarative, and spawning a process
at startup for every user, Lean-using or not, is the cost rev 1 refused.
Both the probe and the server spawn are gated on a real Lean attachment.

The probe cannot gate the first attach — there is no blocking process
run, so its verdict arrives after `ensure_server` has already decided.
Hence the optimistic spawn, with the probe and latch correcting it. A
non-zero probe exit is deliberately NOT a trigger: §2.9's elan-shim case
makes `lake --version` fail on machines where `lake serve` still works,
and the server-failure latch covers that better. The probe answers only
the question failure detection would answer slowly — an old-but-working
lake that starts a useless server.

The latch stops the failing server **before** spawning the fallback, and
that ordering is load-bearing rather than defensive: the spec default is
`OnCrash`, the termination handler never consults the exit code, and
`maybe_restart` has no attempt ceiling, so a broken `lake` respawns
forever underneath the latch. `pmacs.lsp.stop` sets `restart = Never`,
which is what disarms it. Bitten: removing the stop fails acceptance 36.

The swap rewrites `command` and `args` only, so a user's `env`,
`settings`, `init_options` and `root` survive — a wholesale table
replacement would discard their `init.lua` at the moment they are least
likely to notice.

**`waitForDiagnostics` (Q#LN16)** resolves through Stage 3a's response
seam, with `M-x lean.wait-for-diagnostics` on top. `$/lean/fileProgress`
subscribes on the notification seam and is pinned end-to-end through a
new `leanprogress` mode on the fake server rather than by calling the
handler directly — the wiring is the only part that can break.

**Attribution (COHERENCE §9/§1.2).** The probe spawns as
`lean:lake-version-probe`, so a user wondering why their editor touched
`lake` finds an owner in `pmacs.process.list`. The latch reports through
`pmacs.editor.set_status` — the channel that exists — and acceptance 36a
observes that channel, so a report made only through the undefined
`pmacs.error` would fail it.

**Stage 1's acceptance 12 is updated, half superseded.** It asserted
`pmacs.lsp.config.lean4 == nil` to guard against a Stage-3 front-run;
Stage 3b is that stage, so keeping it would pin the opposite of the
intended behavior. The half that survives is the one about restraint,
and it matters more now: constructing an editor spawns nothing even
though the config exists and names `lake`, and opening a Lean buffer
with no server configured spawns nothing either. That is what holds
Q#LN7's "not at init" promise.

Bites recorded, all against the committed tree: bare `io.open` -> 24a
fails, 24b passes; require-non-nil-read -> 24b fails, 24a passes; no
canonicalization -> the symlink case spawns two servers; no stop before
fallback -> acceptance 36 fails.
This commit is contained in:
Levi Neuwirth 2026-07-25 17:40:02 -04:00
parent b70393762e
commit 1e1be67b49
5 changed files with 1069 additions and 24 deletions

359
builtin/runtime/lean.lua Normal file
View File

@ -0,0 +1,359 @@
-- builtin/runtime/lean.lua --- Arc 8 Stage 3b: the Lean 4 language server.
--
-- Framing: `docs/lean4-mode-framing.md` Q#LN7 (lake serve + probe +
-- fallback latch), Q#LN8 (Lake-aware outermost root), Q#LN16
-- (waitForDiagnostics). Stage 1 shipped the grammar, mode, comment
-- strings and pair set; Stage 3a shipped the notification/response
-- seams and `pmacs.fs.canonicalize` this file consumes.
--
-- Loaded after `lsp.lua`, which owns `pmacs.lsp.config` and the drain.
local M = {}
-- Q#LN8 — the Lake-aware root -----------------------------------------
--
-- `pmacs.project.detect` cannot express this rule. It is innermost-wins
-- by construction, and a Lake package's `lean-toolchain` sits at the
-- OUTERMOST level: a file under `<pkg>/.lake/packages/dep/Foo.lean`
-- belongs to `<pkg>`'s server, not to `dep`'s, because `lake serve` is
-- bound to one package and analyzes its dependencies from inside it.
-- Inverting `detect` globally would change Rust/Go/Node roots for every
-- user, so the rule lives here as a function-valued `config.root` —
-- the generalization Stage 2 (#161) added for exactly this.
-- The marker test, and the two ways to get it wrong.
--
-- `pmacs.fs.stat` is UNUSABLE here: it returns an awaitable handle
-- (`fs.lua`), and this runs synchronously inside `ensure_server` <-
-- `attach_buffer` <- the `buffer.after-load` hook, where there is no
-- coroutine to await on. The Lua stdlib's `io.open` is the only
-- synchronous existence check available.
--
-- But `io.open` alone is wrong in BOTH directions:
-- * it SUCCEEDS on a directory (probed), so a truthiness test would
-- accept a `lean-toolchain` directory as a marker; and
-- * requiring a non-nil read rejects an EMPTY `lean-toolchain`, which
-- is a legitimate marker — `locate-dominating-file` semantics are
-- existence, not content.
-- The discriminator is `read`'s SECOND return (probed on LuaJIT 2.1):
-- file with content -> "l", no error -> marker
-- empty file -> nil, NO error -> marker
-- directory -> nil, "Is a directory" -> decline
-- missing -> io.open returns nil -> decline
-- so: decline only on a non-nil `err`. This needs no per-platform
-- re-probe, because both directory behaviors are declines — a platform
-- whose `fopen` refuses directories fails at `io.open` instead. There
-- is no platform where a directory both opens and yields a byte.
local function has_toolchain(dir)
local f = io.open(dir .. "/lean-toolchain", "r")
if not f then return false end
local _, err = f:read(1)
f:close()
return err == nil
end
local function parent_of(dir)
local up = dir:match("^(.*)/[^/]+$")
if up == nil or up == dir or up == "" then return nil end
return up
end
-- The walk stops at `pmacs.project.search_boundary()`. Not politeness:
-- `detect_project_within` (`src/project.rs`) exists precisely so a
-- stray marker above a temp fixture cannot leak into detection, and a
-- Lua walk that ignored the boundary would break that contract — and
-- make acceptance 23's outermost assertion non-hermetic against any
-- `lean-toolchain` sitting above the test's tempdir.
local function within_boundary(dir, boundary)
if not boundary then return true end
return dir == boundary or dir:sub(1, #boundary + 1) == boundary .. "/"
end
-- Returns the OUTERMOST ancestor holding a `lean-toolchain`, or nil to
-- decline (which falls through to `pmacs.project.detect`, then the
-- file's own directory).
--
-- **The result is canonical, and must be.** A configured root — which
-- this is — reaches `file_uri_for` verbatim and that URI is the
-- server-affinity key (#161). `pmacs.editor.file_path()` collapses `.`
-- and `..` lexically but leaves symlinks intact, so one package opened
-- through a symlink and through its real path would otherwise spawn two
-- `lake serve` processes. Canonicalizing ONCE up front is enough:
-- every ancestor of a canonical path is itself canonical, since the
-- walk only strips trailing components.
--
-- If canonicalization fails (deleted file, broken symlink) the resolver
-- declines rather than returning a path it cannot vouch for.
function M.root_for(path)
if type(path) ~= "string" then return nil end
local dir = path:match("^(.*)/[^/]*$")
if not dir then return nil end
dir = pmacs.fs.canonicalize(dir)
if not dir then return nil end
local boundary
local ok, b = pcall(pmacs.project.search_boundary)
if ok then boundary = b end
-- The boundary is canonicalized at set time (`set_search_boundary`),
-- so comparing it against a canonical `dir` is apples to apples.
local outermost = nil
local cur = dir
while cur and within_boundary(cur, boundary) do
if has_toolchain(cur) then outermost = cur end
cur = parent_of(cur)
end
return outermost
end
-- Q#LN7 — `lake serve`, with a lazy probe and a one-shot latch --------
--
-- `pmacs.lsp.config.lean4` is declarative and must stay cheap: spawning
-- a process at startup for every user, Lean-using or not, is the cost
-- rev 1 refused. So no probe runs here — it runs on the first `.lean`
-- attach, below.
pmacs.lsp.config.lean4 = pmacs.lsp.config.lean4 or {
command = "lake",
args = { "serve" },
root = M.root_for,
-- No `init_options`: `hasWidgets?` defaults to false, which is the
-- correct posture for a client reading plain goals out of standard
-- messages rather than driving the `$/lean/rpc/*` widget stack.
}
-- Session state. The latch is one-shot and never re-arms: a user whose
-- toolchain is broken sees one fallback attempt, not a loop.
local probe = {
started = false, -- the `lake --version` probe has been spawned
latched = false, -- the fallback has fired (or been ruled out)
proc = nil, -- process id of the running probe
buf = "", -- accumulated probe stdout
watching = nil, -- sid we are waiting to see fail before initialize
saw_initialized = false,
}
local function report(msg)
-- COHERENCE §1.2: background work must leave an attributed trace.
-- `pmacs.editor.set_status` is the channel that EXISTS; `pmacs.error`
-- is referenced by fifteen call sites and defined nowhere in
-- production, so it rides along rather than standing alone.
pcall(pmacs.editor.set_status, msg)
if pmacs.error then pcall(pmacs.error, msg) end
end
-- `lake serve` below 3.1.0 starts a server that cannot answer, which is
-- worse than failing: `lean4-mode` probes for exactly this and falls
-- back to `lean --server`. Parses the leading `x.y` of a version line.
local function version_below_3_1(text)
local major, minor = text:match("(%d+)%.(%d+)")
if not major then return false end
major, minor = tonumber(major), tonumber(minor)
if major < 3 then return true end
return major == 3 and minor < 1
end
-- Swap `command`/`args` ONLY. A wholesale table replacement would
-- silently discard a user's `env` / `settings` / `init_options` / `root`
-- from `init.lua` at exactly the moment they are least likely to notice.
local function swap_to_lean_server()
local cfg = pmacs.lsp.config.lean4
if not cfg then return false end
if cfg.command ~= "lake" then return false end
cfg.command = "lean"
cfg.args = { "--server" }
return true
end
-- Fire the fallback: stop the failing server FIRST, then swap, then let
-- the next attach spawn afresh.
--
-- Stopping first is load-bearing, not defensive. The spec default is
-- `LspRestartPolicy::OnCrash`, the termination handler never consults
-- the exit code, and `maybe_restart` has no attempt ceiling — so a
-- broken `lake` respawns forever on a backoff, underneath the latch,
-- producing a loop the latch cannot see the end of. `pmacs.lsp.stop`
-- sets `restart = Never` on the way out, which is what disarms it. The
-- fallback is therefore a FRESH server, not a restart of the old one.
local function fire_latch(sid, why)
if probe.latched then return end
probe.latched = true
if sid then pcall(pmacs.lsp.stop, sid) end
if swap_to_lean_server() then
report("LSP: lean4 " .. why .. "; falling back to `lean --server`")
else
report("LSP: lean4 " .. why)
end
probe.watching = nil
end
local function drain_probe()
if not probe.proc then return end
local ok, evs = pcall(pmacs.process.events_take, probe.proc)
if not ok or not evs then return end
for _, ev in ipairs(evs) do
if ev.kind == "stdout" or ev.kind == "stderr" then
probe.buf = probe.buf .. tostring(ev.bytes)
elseif ev.kind == "exited" or ev.kind == "signaled"
or ev.kind == "crashed" then
local proc = probe.proc
probe.proc = nil
pcall(pmacs.process.forget, proc)
-- A non-zero exit is NOT a fallback trigger on its own. §2.9: elan
-- shims make `lake --version` exit non-zero with "no default
-- toolchain configured" on a machine where `lake serve` may still
-- be the right command — the server-failure latch covers that
-- case, and covers it better. The probe answers only the ONE
-- question failure detection would otherwise answer slowly: an
-- old-but-working lake that starts a useless server.
if ev.kind == "exited" and ev.code == 0
and version_below_3_1(probe.buf) then
fire_latch(probe.watching, "lake is older than 3.1.0")
end
end
end
end
-- The probe cannot gate the first attach. There is no blocking process
-- run (§2.9): `spawn` + `events_take` off a tick is the only shape
-- available, so the verdict arrives AFTER `ensure_server` has already
-- had to decide. Hence the optimistic `lake serve` spawn, with the
-- probe and the latch correcting it.
local function start_probe(root)
if probe.started then return end
probe.started = true
local cfg = pmacs.lsp.config.lean4
if not cfg or cfg.command ~= "lake" then return end
local spec = {
-- COHERENCE §9: `ProcessSpec.label` is the only identity a process
-- carries, and it is what `pmacs.process.list` renders. A user
-- wondering why their editor touched `lake` finds an owner here.
label = "lean:lake-version-probe",
command = "lake",
args = { "--version" },
stdin = "null",
}
if root then spec.cwd = root end
local ok, proc = pcall(pmacs.process.spawn, spec)
if ok then probe.proc = proc end
-- A probe that cannot even spawn says nothing the latch will not say
-- more reliably a moment later, so it is not reported here.
end
-- How the latch observes server failure.
--
-- There is no event for "died before initialize" — the drain ignores
-- state events. So this polls `pmacs.lsp.list()` on the
-- `process.after-tick` cadence and treats a terminal state reached
-- WITHOUT an intervening `initialized` as the trigger. Watching stops
-- as soon as the server initializes, so an ordinary later crash (a real
-- server dying on a real error) does not silently rewrite the command.
local function poll_latch()
local sid = probe.watching
if not sid or probe.latched then return end
local skey = tostring(sid)
local ok, rows = pcall(pmacs.lsp.list)
if not ok or not rows then return end
for _, info in ipairs(rows) do
if tostring(info.id) == skey then
local kind = info.state and info.state.kind
if kind == "initialized" then
probe.saw_initialized = true
probe.watching = nil
return
end
if kind == "crashed" or kind == "stopped" then
fire_latch(sid, "`lake serve` failed to start")
end
return
end
end
-- Gone from the manager entirely without ever initializing.
fire_latch(nil, "`lake serve` failed to start")
end
-- Q#LN16 — `textDocument/waitForDiagnostics` --------------------------
--
-- A plain request: no position, so Q#LN12's `outbound_position` concern
-- does not apply. Resolves when the server has finished elaborating.
-- Awaited through Stage 3a's response seam.
--
-- `fn(err)` is called with nil on success. Registering the one-shot
-- requires the server to have an attached buffer — see the note on
-- `pmacs.lsp.on_response`; every caller here comes from an attachment.
function M.wait_for_diagnostics(sid, uri, fn)
local ok, rid = pcall(pmacs.lsp.send_request, sid,
"textDocument/waitForDiagnostics", { uri = uri })
if not ok then
if fn then pcall(fn, tostring(rid)) end
return nil
end
if fn then
pmacs.lsp.on_response(sid, rid, function(_, err)
fn(err and err.message or nil)
end)
end
return rid
end
pmacs.command.define {
name = "lean.wait-for-diagnostics",
description = "Wait for the Lean server to finish elaborating this file",
fn = function()
local rec = pmacs.lsp.active_attachment()
if not rec or rec.language ~= "lean4" then
pmacs.editor.set_status("lean: no Lean server for this buffer")
return
end
pmacs.editor.set_status("lean: elaborating…")
M.wait_for_diagnostics(rec.server, rec.uri, function(err)
if err then
pmacs.editor.set_status("lean: " .. tostring(err))
else
pmacs.editor.set_status("lean: elaboration complete")
end
end)
end,
}
-- `$/lean/fileProgress` — the elaboration-in-flight signal. Stage 5's
-- goal view reads it to distinguish "no goals" from "not done yet";
-- here it is recorded so that consumer has something to read and so the
-- notification seam has its first production subscriber.
M.file_progress = {}
pmacs.lsp.on_notification("$/lean/fileProgress", function(_, params)
local uri = params and params.textDocument and params.textDocument.uri
if type(uri) ~= "string" then return end
M.file_progress[uri] = params.processing or {}
end)
-- Wiring --------------------------------------------------------------
-- Runs after `lsp.lua`'s own `buffer.after-load` subscription, so the
-- attachment already exists. The attachment's `language` IS the Lean
-- test — no separate major-mode lookup, which would be a second source
-- of truth for the same question.
pmacs.hook.add("buffer.after-load", function()
local rec = pmacs.lsp.active_attachment()
if not rec or rec.language ~= "lean4" then return end
if not probe.started then
local path = pmacs.editor.file_path()
start_probe(path and M.root_for(path) or nil)
end
-- Watch only the FIRST Lean server: the latch is per session.
if not probe.latched and not probe.saw_initialized
and probe.watching == nil then
probe.watching = rec.server
end
end)
pmacs.hook.add("process.after-tick", function()
drain_probe()
poll_latch()
end)
-- Test seam: acceptance drives the latch deterministically rather than
-- waiting on real process timing. Not part of the public surface.
M._probe = probe
M._fire_latch = fire_latch
M._version_below_3_1 = version_below_3_1
pmacs.lean = M

View File

@ -483,6 +483,27 @@ fn main() {
}
});
write_frame(&mut stdout, &echo);
// Arc 8 Stage 3b: `leanprogress` mode emits one
// `$/lean/fileProgress` covering line 0, so the Lean
// subscriber can be pinned end-to-end through the real
// drain rather than by calling its handler directly.
if mode == "leanprogress" && uri.is_string() {
let progress = serde_json::json!({
"jsonrpc": "2.0",
"method": "$/lean/fileProgress",
"params": {
"textDocument": { "uri": uri, "version": 1 },
"processing": [{
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 1, "character": 0 }
},
"kind": 1
}]
}
});
write_frame(&mut stdout, &progress);
}
// Also push a synthetic `publishDiagnostics`
// notification with two entries (one Error, one
// Warning) so M4.6 tests can exercise the store.

View File

@ -436,6 +436,17 @@ impl EditorState {
include_str!("../builtin/runtime/lsp.lua"),
)
.expect("load lsp builtin chunk");
// Arc 8 Stage 3b: the Lean 4 language server. Loaded after
// lsp.lua because it registers `pmacs.lsp.config.lean4`,
// subscribes on the Stage 3a notification seam, and adds a
// `buffer.after-load` hook that must run AFTER lsp.lua's own
// (it reads the attachment lsp.lua creates).
lua_host
.eval(
Some("@pmacs/builtin/runtime/lean.lua"),
include_str!("../builtin/runtime/lean.lua"),
)
.expect("load lean builtin chunk");
// Arc 1a: the in-buffer completion popup driver. Loaded after
// lsp.lua because it drives `pmacs.lsp.request_completion` /
// `pmacs.lsp.attachment_for_request` and after the framework

View File

@ -0,0 +1,640 @@
//! 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"
);
}

View File

@ -305,36 +305,50 @@ fn acc11b_an_unknown_fence_name_still_injects_nothing() {
// ---------------------------------------------------------------------------
#[test]
fn acc12_stage1_ships_no_lsp_config_and_spawns_no_process() {
// Stage 1 is grammar + Lua tables only. Opening a Lean file must not
// reach for `lake`, `lean`, or `elan` — the LSP arrives in Stage 3, and
// even then it is fallible by design (Q#LN7).
fn acc12_opening_lean_spawns_no_process_without_a_server_config() {
// **Superseded in half by Stage 3b.** This criterion originally also
// asserted `pmacs.lsp.config.lean4 == nil`, guarding against a Stage-3
// front-run. Stage 3b *is* Stage 3: `builtin/runtime/lean.lua` now ships
// that config deliberately, and its shape is pinned by
// `tests/lean4_server_acceptance.rs`. Asserting the absence here would
// now pin the opposite of the intended behavior, so it is gone rather
// than weakened.
//
// What survives is the half that was always about *restraint*, and it
// matters more now than it did in Stage 1 — it is what holds Q#LN7's
// "not at init" promise. `pmacs.lsp.config` is a declarative table, and
// spawning a process at startup for every user, Lean-using or not, is
// the cost rev 1 refused. Both the `lake serve` spawn and the
// `lake --version` probe are gated on a real Lean attachment.
// The load-bearing assertion, and it must run against a PRISTINE editor.
// The shared `editor()` helper wipes `pmacs.lsp.config` before any
// buffer opens, so an assertion about the server list under that harness
// holds for every language regardless of what Stage 1 ships — it could
// not fail for the regression it names. This checks the real claim
// directly: no builtin runtime file defines a Lean server config. A
// Stage-3 front-run adding `pmacs.lsp.config.lean4` fails here.
// Constructing an editor touches no process, even though the Lean
// config now exists and names `lake`.
let pristine = EditorState::new();
let no_lean_config: bool = eval(&pristine, "return pmacs.lsp.config.lean4 == nil");
assert!(
no_lean_config,
"Stage 1 defines no `pmacs.lsp.config.lean4`; the LSP is Stage 3"
let at_init: i64 = eval(&pristine, "return #pmacs.process.list()");
assert_eq!(
at_init, 0,
"constructing an editor must not probe or spawn for Lean"
);
// Non-vacuity for the assertion above: the config really is present and
// really does name a command, so "nothing spawned" is restraint rather
// than an empty table having nothing to act on.
let names_lake: bool = eval(
&pristine,
"return pmacs.lsp.config.lean4 ~= nil and pmacs.lsp.config.lean4.command == \"lake\"",
);
// Non-vacuity: the same lookup finds the configs that DO ship, so this
// is not passing because `pmacs.lsp.config` is empty or absent.
let rust_config_exists: bool = eval(&pristine, "return pmacs.lsp.config.rust ~= nil");
assert!(
rust_config_exists,
"the config table is populated, so the lean4 absence above is meaningful"
names_lake,
"Stage 3b ships a lean4 config naming `lake`, so the no-spawn \
assertion above is meaningful"
);
// And nothing is spawned by opening the file. This half retains its
// value under the wiped config: a direct probe spawn from `lean.lua`
// would show up here whatever `pmacs.lsp.config` contains.
// And opening a Lean buffer with no server configured spawns nothing —
// the `editor()` helper wipes `pmacs.lsp.config`, so this catches a
// probe that fires off the mode rather than off an attachment.
let s = editor_visiting("Basic.lean", "def x : Nat := 1\n");
let procs: i64 = eval(&s, "return #pmacs.process.list()");
assert_eq!(procs, 0, "opening a Lean buffer spawns no child process");
assert_eq!(
procs, 0,
"with no server configured, opening a Lean buffer spawns nothing"
);
}