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:
parent
b70393762e
commit
1e1be67b49
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 22–28,
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue