Merge pull request #170 from levineuwirth/lean4-stage3b-server

feat(lean): the Lean 4 language server (Arc 8 Stage 3b)
This commit is contained in:
Levi Neuwirth 2026-07-26 13:49:16 +00:00 committed by GitHub
commit d400f30c06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 3066 additions and 37 deletions

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

@ -0,0 +1,750 @@
-- 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
out = "", -- accumulated probe stdout
buf_key = nil, -- tostring() of the buffer that started this
watching = nil, -- sid still being polled for die-before-initialize
primary = nil, -- sid the probe's verdict applies to; NOT cleared
-- when the server initializes, because a late
-- version verdict still has to retire it
armed = false, -- the target buffer + primary have been captured
repaired = {}, -- buffer key -> repair attempted (at most once)
repair_attempts = 0, -- COUNT of attach attempts, not distinct buffers:
-- table cardinality cannot tell "once per buffer"
-- from "every tick for one buffer"
fallback_installed = false,
fallback_watches = {}, -- sid key -> sid, each polled die-before-init
fallback_done = {}, -- sid key -> initialized or terminally handled
saw_initialized = false,
}
-- The command as configured, for status text. Hardcoding "lake serve"
-- was untruthful the moment the failure latch became command-agnostic:
-- a user whose `my-lean-wrapper` failed was told `lake serve` did.
local function configured_command()
local cfg = pmacs.lsp.config.lean4
local cmd = cfg and cfg.command
if not cmd then return "the Lean server" end
local args = cfg.args or {}
if #args > 0 then
return "`" .. tostring(cmd) .. " " .. table.concat(args, " ") .. "`"
end
return "`" .. tostring(cmd) .. "`"
end
-- The fallback command, for status text.
local function fallback_name()
local args = M._fallback.args or {}
if #args > 0 then
return "`" .. tostring(M._fallback.command) .. " "
.. table.concat(args, " ") .. "`"
end
return "`" .. tostring(M._fallback.command) .. "`"
end
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.
-- State kind for the server whose `tostring(id)` is `skey`, or nil if
-- the manager has forgotten it (which is itself a terminal answer).
local function server_state_kind_for_key(skey)
local ok, rows = pcall(pmacs.lsp.list)
if not ok or not rows then return nil end
for _, info in ipairs(rows) do
if tostring(info.id) == skey then
return info.state and info.state.kind
end
end
return nil
end
local function server_state_kind(sid)
return server_state_kind_for_key(tostring(sid))
end
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
-- What the latch falls back TO.
--
-- **Underscored: a test seam, not supported user configuration.** It is
-- a table only so the acceptance suite can point it at a stand-in server
-- and drive the real latch path end to end, instead of asserting on a
-- config mutation that proves nothing about whether a server ever
-- starts. Presenting it as public config would owe framing,
-- documentation, validation and mutation semantics that nothing here
-- provides; users configure Lean through `pmacs.lsp.config.lean4`.
M._fallback = { command = "lean", args = { "--server" } }
local function same_args(a, b)
a, b = a or {}, b or {}
if #a ~= #b then return false end
for i = 1, #a do
if a[i] ~= b[i] then return false end
end
return true
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.
--
-- The only guard is idempotence — already-the-fallback means nothing to
-- do. It deliberately does NOT refuse when the command is user-supplied:
-- the latch fires only when the configured Lean server actually failed
-- to start, and one visible fallback attempt beats leaving the user with
-- no server at all. `probe.latched` is what keeps it to exactly one.
local function swap_to_fallback()
local cfg = pmacs.lsp.config.lean4
if not cfg then return false end
-- Idempotence compares command AND args: the same command with
-- different arguments is not "already applied", and treating it as
-- such would silently skip a swap that still needed to happen.
if cfg.command == M._fallback.command
and same_args(cfg.args, M._fallback.args) then
return false
end
cfg.command = M._fallback.command
cfg.args = M._fallback.args
return true
end
-- Retire the failed server, swap the command, then rebuild the
-- attachment on the buffer that started this.
-- Retire `sid` so it cannot come back. **Which call to use depends on
-- the state, and using the wrong one is worse than doing nothing:**
--
-- * TERMINAL (`crashed` / `stopped`) -> `forget`. It requires a
-- terminal state and removes the client outright, which also drops
-- the `next_restart_at` the crash scheduled. `stop` here would take
-- its not-initialized branch and set `ShuttingDown { .. None }` on
-- the premise that "the next exit observation cleans up" — but the
-- exit already happened, which is what made it `Crashed`. No
-- further event arrives, so it sits in `ShuttingDown` forever:
-- `server_is_live` reads that as LIVE so `attach_buffer` never
-- rebuilds, and `forget` then refuses it for not being terminal.
-- * NON-TERMINAL -> `stop`. `forget` rejects it, and `stop` disables
-- restart and drives the polite shutdown.
--
-- Round 1 skipped the call entirely for terminal servers. That avoided
-- the corruption but left `next_restart_at` armed, so the crashed
-- primary respawned 500ms later and kept respawning underneath the
-- live fallback — invisible to a test that stopped ticking first.
local function retire_server(sid)
local kind = server_state_kind(sid)
if kind == nil then return end
if kind == "crashed" or kind == "stopped" then
pcall(pmacs.lsp.forget, sid)
else
pcall(pmacs.lsp.stop, sid)
end
end
-- Retire EVERY Lean server, not just the one that failed.
--
-- `pmacs.lsp.config.lean4` is a single global entry, so swapping its
-- command invalidates every server spawned from the old one — and
-- Q#LN15 gives one server per project root, so there can be several.
-- Retiring only the server that happened to fail left the others live
-- and every buffer attached to them stranded on a command the config no
-- longer names.
-- Only servers the config-driven path itself produced. A server's label,
-- language, command, and root are all caller-supplied public values; none
-- is an ownership discriminator. `lsp.lua` records the successful spawn
-- in a private origin table, which is the fact this lifecycle may act on.
local function is_derived_server(sid)
local ok, owned = pcall(pmacs.lsp._is_default_server, sid, "lean4")
return ok and owned == true
end
local function retire_derived_lean_servers()
local ok, rows = pcall(pmacs.lsp.list)
if not ok or not rows then return end
local ids = {}
for _, info in ipairs(rows) do
if is_derived_server(info.id) then ids[#ids + 1] = info.id end
end
for _, id in ipairs(ids) do
-- These ids predate the fallback spawn. Mark them handled before
-- retirement so the discovery poll cannot mistake their terminal
-- state for a fallback that failed to initialize.
probe.fallback_done[tostring(id)] = true
retire_server(id)
end
end
local function watch_fallback_server(sid)
if not sid or not is_derived_server(sid) then return end
local key = tostring(sid)
if probe.fallback_done[key] then return end
probe.fallback_watches[key] = sid
end
-- Rebuild the ACTIVE buffer's attachment if it is Lean and stale.
--
-- `_attach_buffer` is an active-buffer-only seam, so a global config
-- swap cannot be applied to every open buffer at once. It is applied
-- lazily instead: whenever a Lean buffer becomes the active one, if its
-- record points at a server that is gone or terminal, it is rebuilt.
--
-- **At most one attempt per buffer.** Without that bound a fallback
-- that also fails to spawn would retry every tick forever with nothing
-- reported — the round-2 defect, which a general repair loop would
-- otherwise reintroduce for every buffer instead of just one.
--
-- A `shutting-down` server is deliberately NOT treated as stale: it is
-- still live by `server_is_live`'s reckoning, so `attach_buffer` would
-- early-return the stale record and burn this buffer's single attempt
-- on a no-op. Skipping leaves the attempt for a later tick, once the
-- retirement has actually landed.
local function repair_active_if_stale()
-- **`fallback_installed`, not `latched`.** When the swap does not
-- happen — the config already names the fallback, or it vanished
-- before an asynchronous verdict landed — `fire_latch` returns early
-- but `latched` stays true. Gating repair on `latched` then retried
-- the UNCHANGED configuration and reported the result as a fallback
-- failure, which is both a second pointless spawn and a misleading
-- message. Repair exists to apply a swap; no swap, nothing to apply.
if not probe.fallback_installed then return end
local buf = pmacs.window.buffer()
if not buf then return end
local key = tostring(buf)
if probe.repaired[key] then return end
local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf)
if not ok_lang or lang ~= "lean4" then return end
local rec = pmacs.lsp.active_attachment()
local stale
if not rec then
stale = true
else
local kind = server_state_kind(rec.server)
stale = (kind == nil or kind == "crashed" or kind == "stopped")
end
if not stale then return end
probe.repaired[key] = true
probe.repair_attempts = probe.repair_attempts + 1
local ok, fresh = pcall(pmacs.lsp._attach_buffer)
if not ok or not fresh then
report("LSP: lean4 fallback " .. fallback_name()
.. " did not start either")
return
end
-- **A successful SPAWN is not a successful START.** The once-per-
-- buffer bound stops `_attach_buffer` being called again, but it says
-- nothing about the server it produced. Arm this id immediately; the
-- poll below also discovers servers created through lsp.lua's own
-- after-load and command paths.
watch_fallback_server(fresh.server)
end
-- Every fallback server gets its own die-before-initialize poll. A scalar
-- watch cannot cover Q#LN15's simultaneous per-root servers, and a server
-- may be created by lsp.lua's after-load or command path without passing
-- through `repair_active_if_stale`. Discovery from the private ownership
-- table closes both holes.
local function poll_fallbacks()
if not probe.fallback_installed then return end
local ok, rows = pcall(pmacs.lsp.list)
if not ok or not rows then return end
local by_key = {}
for _, info in ipairs(rows) do
local key = tostring(info.id)
by_key[key] = info
if not probe.fallback_done[key] and is_derived_server(info.id) then
probe.fallback_watches[key] = info.id
end
end
for key, sid in pairs(probe.fallback_watches) do
local info = by_key[key]
local kind = info and info.state and info.state.kind
if kind == "initialized" then
probe.fallback_watches[key] = nil
probe.fallback_done[key] = true
elseif info == nil or kind == "crashed" or kind == "stopped" then
probe.fallback_watches[key] = nil
probe.fallback_done[key] = true
if info ~= nil then retire_server(sid) end
report("LSP: lean4 fallback " .. fallback_name()
.. " started but did not stay up")
end
end
end
local function fire_latch(sid, why)
if probe.latched then return end
probe.latched = true
probe.watching = nil
if not swap_to_fallback() then
report("LSP: lean4 " .. why)
-- No shared config changed, so only the server whose failure
-- triggered this verdict is invalid. Sweeping every root here stops
-- healthy instances of a root-sensitive command for no reason.
if sid and is_derived_server(sid) then retire_server(sid) end
return
end
retire_derived_lean_servers()
probe.fallback_installed = true
report("LSP: lean4 " .. why .. "; falling back to " .. fallback_name())
-- Repair what is in front of the user now; everything else is
-- repaired lazily as it becomes active (see `repair_active_if_stale`).
repair_active_if_stale()
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.out = probe.out .. 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.
-- **`probe.primary`, NOT `probe.watching`.** `watching` is
-- failure-polling state and is cleared the moment the server
-- initializes. A slow `--version` that lands after a successful
-- initialize would then arrive with nil, and `fire_latch(nil)`
-- retires nothing: `_attach_buffer` finds the still-live primary
-- attachment, early-returns it, and the retry calls that success.
-- Status and config would say "fell back" while the buffer stayed
-- on the old server — the same silent no-op as round 1, reached
-- through a different event ordering. Initializing must stop the
-- failure poll, not erase the server the verdict has to retire.
if ev.kind == "exited" and ev.code == 0
and version_below_3_1(probe.out) then
fire_latch(probe.primary, "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 not cfg.command then return end
-- **Only probe something actually named `lake`.** `version_below_3_1`
-- parses the first `x.y` it finds anywhere in the output, which is a
-- rule about LAKE's output contract and nothing else. Run against a
-- user's wrapper it is a category error: a working `my-lean-wrapper`
-- reporting "wrapper 1.0" would be replaced despite its server having
-- initialized fine. The FAILURE latch stays command-agnostic — that
-- one keys on the server actually not starting, which is true of any
-- command — but the version rule only applies where its contract
-- holds.
local base = cfg.command:match("([^/]+)$") or cfg.command
if base ~= "lake" then return end
-- Probe the binary we would actually run, not the literal string
-- "lake": a user pointing `command` at an absolute path to lake should
-- have THAT probed, not whatever `lake` resolves to on PATH.
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 = cfg.command,
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
-- Stop polling for failure; `probe.primary` deliberately
-- survives, because a later version verdict still needs it.
probe.saw_initialized = true
probe.watching = nil
return
end
if kind == "crashed" or kind == "stopped" then
fire_latch(sid, configured_command() .. " failed to start")
end
return
end
end
-- Gone from the manager entirely without ever initializing.
fire_latch(nil, configured_command() .. " 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.
--
-- **`version` is required, not optional.** Lean's
-- `WaitForDiagnosticsParams` is `{ uri, version }` (v4.9.0,
-- `src/Lean/Data/Lsp/Extra.lean`), and the request is how the client
-- says *which* revision of the document it wants elaboration for.
-- Sending only `uri` is a malformed request against a real server; it
-- happened to look fine here because the fake server echoes any
-- payload. Callers pass the attachment's current `version`.
--
-- `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, version, fn)
local ok, rid = pcall(pmacs.lsp.send_request, sid,
"textDocument/waitForDiagnostics", { uri = uri, version = version })
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
local function when_server_ready(sid, fn)
local function state_kind()
local ok, state = pcall(pmacs.lsp.status, sid)
if not ok or not state then return nil end
return state.kind
end
local kind = state_kind()
if kind == "initialized" then
fn(nil)
return
end
if kind == nil or kind == "crashed" or kind == "stopped" then
fn("server did not initialize")
return
end
-- A command may have just healed a dead attachment, in which case the
-- replacement is still starting. Requests are not queued before
-- initialize, so issue this one after the lifecycle reaches ready
-- rather than replacing the attachment and immediately failing on it.
pmacs.async(function()
for _ = 1, 300 do
pmacs.async.yield_to_next_tick()
kind = state_kind()
if kind == "initialized" then
fn(nil)
return
end
if kind == nil or kind == "crashed" or kind == "stopped" then
fn("server did not initialize")
return
end
end
fn("server initialization timed out")
end)
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._attachment_for_command()
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…")
when_server_ready(rec.server, function(init_err)
if init_err then
pmacs.editor.set_status("lean: " .. tostring(init_err))
return
end
M.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err)
if err then
pmacs.editor.set_status("lean: " .. tostring(err))
else
pmacs.editor.set_status("lean: elaboration complete")
end
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.
--
-- **Keyed on the buffer's LANGUAGE, not on an attachment existing.**
-- Round 1 keyed on `active_attachment()` and returned early when it was
-- nil — which silently excluded the single most likely real-world
-- failure: `lake` not installed. `ensure_server` pcalls the spawn and
-- returns nil on ENOENT, so `attach_buffer` produces no record at all,
-- so the probe never started and the latch never armed. The case the
-- fallback exists for was the one case it could not see.
pmacs.hook.add("buffer.after-load", function()
local buf = pmacs.window.buffer()
if not buf then return end
local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf)
if not ok_lang or lang ~= "lean4" then return end
local rec = pmacs.lsp.active_attachment()
if rec and rec.language == "lean4" then
-- A matching-root server supplied by the user may be adopted by
-- `ensure_server`. Its lifecycle is not evidence about the
-- config-driven command, and neither the version probe nor fallback
-- latch may mutate config because that foreign server changed state.
if not is_derived_server(rec.server) 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
-- **Arm ONCE, capturing buffer and server together.** Setting
-- `buf_key` on every Lean load meant a second Lean buffer opened
-- before the verdict silently became the rebuild target while the
-- latch still watched the FIRST buffer's server — so the rebuild
-- either repaired the wrong buffer or accepted the second buffer's
-- unrelated live server as success, stranding the first. The pair
-- (target buffer, primary server) is one fact and is captured as
-- one.
if not probe.armed and not probe.latched and not probe.saw_initialized then
probe.armed = true
probe.buf_key = tostring(buf)
probe.primary = rec.server
probe.watching = rec.server
end
return
end
-- **Unconfigured is DISABLED, not failed.** A user who sets
-- `pmacs.lsp.config.lean4 = nil`, or clears its `command`, has turned
-- the Lean server off; reporting that "nil could not be started" is a
-- false alarm, and latching would poison the session so a later
-- configuration could never take effect. Only a CONFIGURED command
-- that produced no attachment is a failure.
local cfg = pmacs.lsp.config.lean4
if not cfg or not cfg.command 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
-- No attachment for a Lean buffer with a configured command means
-- `ensure_server` could not spawn at all — a synchronous ENOENT,
-- already swallowed upstream. That is not something to wait for; it
-- is the failure itself, and the only place it is still observable.
if not probe.latched then
-- No server was ever created, so there is no primary to retire —
-- but the rebuild still needs a target buffer.
if not probe.armed then
probe.armed = true
probe.buf_key = tostring(buf)
end
fire_latch(nil, configured_command() .. " could not be started")
end
end)
-- A buffer switch is the moment a stale Lean buffer becomes visible, so
-- repair immediately rather than waiting for the next tick. lsp.lua's
-- own `after-switch` subscription re-pushes views but does NOT rebuild a
-- stale attachment, so nothing else covers this.
pmacs.hook.add("buffer.after-switch", function()
repair_active_if_stale()
end)
pmacs.hook.add("process.after-tick", function()
drain_probe()
poll_latch()
-- Repair the active buffer if the latch invalidated it. Cheap when
-- there is nothing to do, and bounded to one attempt per buffer.
repair_active_if_stale()
poll_fallbacks()
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

@ -607,6 +607,12 @@ local function project_root_for(language, path)
return dir_of(path), "fallback" return dir_of(path), "fallback"
end end
-- Servers created by the automatic config-driven path. This is the
-- ownership fact a caller-supplied `label` cannot provide: labels are
-- public, unreserved display strings, while entries here are written
-- only after this module itself successfully spawns a server.
local default_servers = {}
local function ensure_server(language, path) local function ensure_server(language, path)
local cfg = pmacs.lsp.config[language] local cfg = pmacs.lsp.config[language]
if not cfg or not cfg.command then return nil end if not cfg or not cfg.command then return nil end
@ -660,7 +666,30 @@ local function ensure_server(language, path)
cwd = root, cwd = root,
root_uri = key_uri, root_uri = key_uri,
}) })
if ok then return sid end if ok then
default_servers[tostring(sid)] = language
return sid
end
return nil
end
-- Internal ownership seam for builtins whose lifecycle follows the
-- config-driven server set (currently Lean's one-shot fallback). A
-- user-managed server may deliberately use the same language id, label,
-- command, and root; none of those make it ours.
function pmacs.lsp._is_default_server(sid, language)
local owned_language = default_servers[tostring(sid)]
return owned_language ~= nil
and (language == nil or owned_language == language)
end
local function server_state_kind(sid)
if not sid then return nil end
for _, info in ipairs(pmacs.lsp.list()) do
if tostring(info.id) == tostring(sid) then
return info.state and info.state.kind
end
end
return nil return nil
end end
@ -669,14 +698,8 @@ end
-- forgotten, or was spawned against a now-replaced `pmacs.lsp.config` -- forgotten, or was spawned against a now-replaced `pmacs.lsp.config`
-- entry — get rebuilt on the next attach attempt. -- entry — get rebuilt on the next attach attempt.
local function server_is_live(sid) local function server_is_live(sid)
if not sid then return false end local kind = server_state_kind(sid)
for _, info in ipairs(pmacs.lsp.list()) do return kind ~= nil and kind ~= "crashed" and kind ~= "stopped"
if tostring(info.id) == tostring(sid) then
local kind = info.state and info.state.kind
return kind ~= "crashed" and kind ~= "stopped"
end
end
return false
end end
local function server_is_initialized(sid) local function server_is_initialized(sid)
@ -811,6 +834,15 @@ local function attach_buffer(buf)
local existing = attachments[key] local existing = attachments[key]
if existing and server_is_live(existing.server) then return existing end if existing and server_is_live(existing.server) then return existing end
if existing then if existing then
local kind = server_state_kind(existing.server)
if kind == "crashed" or kind == "stopped" then
-- A terminal OnCrash client may still have `next_restart_at`
-- armed. Spawning beside it creates two same-root servers when
-- the old id restarts. `forget` is the terminal-state operation:
-- it removes the client and cancels that pending restart before
-- the replacement is created.
pcall(pmacs.lsp.forget, existing.server)
end
attachments[key] = nil attachments[key] = nil
-- Unsent edits targeted the dead attachment; the did_open below -- Unsent edits targeted the dead attachment; the did_open below
-- carries the full current text, superseding them. -- carries the full current text, superseding them.
@ -871,6 +903,21 @@ local function attached_for_active()
if not buf then return nil end if not buf then return nil end
local key = tostring(buf) local key = tostring(buf)
local rec = attachments[key] local rec = attachments[key]
-- A record whose server is dead is worse than no record: every
-- command below issues requests against it and gets silence. Rebuild
-- instead, which is what `attach_buffer` does for a stale attachment
-- anyway — this just stops the dead record short-circuiting that.
--
-- Load-bearing for anything that retires a server out from under open
-- buffers (Arc 8 Stage 3b's fallback latch retires every Lean server
-- at once). Buffers in OTHER frontends get no `buffer.after-switch`
-- in this one, so an eager repair sweep keyed on the ambient active
-- buffer cannot reach them; healing at the point of USE is
-- frontend-agnostic, because whichever frontend runs the command is
-- the active one while it runs.
if rec and not server_is_live(rec.server) then
rec = nil
end
if rec then if rec then
-- Every interactive command resolves its attachment here before -- Every interactive command resolves its attachment here before
-- issuing requests; flushing now means the server answers those -- issuing requests; flushing now means the server answers those
@ -881,6 +928,14 @@ local function attached_for_active()
return attach_buffer(buf) return attach_buffer(buf)
end end
-- Internal command-path resolver for builtin request producers outside
-- this module. Unlike `active_attachment` it may replace a dead record;
-- unlike `attachment_for_request` it is called only from an explicit
-- user command, where attach-on-use is the intended policy.
function pmacs.lsp._attachment_for_command()
return attached_for_active()
end
-- Pure, side-effect-free attachment lookup for the active buffer: -- Pure, side-effect-free attachment lookup for the active buffer:
-- returns the live record (with `.uri`) when a server is already -- returns the live record (with `.uri`) when a server is already
-- attached, else nil. Unlike `attached_for_active`, it never *triggers* -- attached, else nil. Unlike `attached_for_active`, it never *triggers*
@ -893,6 +948,24 @@ function pmacs.lsp.active_attachment()
return attachments[tostring(buf)] return attachments[tostring(buf)]
end end
-- Re-run the attach for the ACTIVE buffer, rebuilding it against the
-- current `pmacs.lsp.config`.
--
-- Exists for the Arc 8 Stage 3b fallback latch (Q#LN7): after that latch
-- stops a server that failed to start and rewrites `config.lean4`,
-- something has to actually spawn the replacement and re-point the
-- buffer at it. Nothing else does — `attach_buffer` early-returns for a
-- live attachment, and no hook re-fires on a config change, so without
-- this the buffer stays bound to the stopped server and the "fallback"
-- is a config edit with no effect.
--
-- Deliberately keyed on the active buffer, matching `attach_buffer`'s
-- own use of `active_buffer_path()`; it is not a general re-attach for
-- arbitrary buffers and must not be used as one.
function pmacs.lsp._attach_buffer()
return attach_buffer(pmacs.window.buffer())
end
-- Arc 4 stage 3: pure modeline projection. This reads the private -- Arc 4 stage 3: pure modeline projection. This reads the private
-- per-buffer attachment map directly so passive split windows report their -- per-buffer attachment map directly so passive split windows report their
-- own buffer instead of the focused window. It never attaches, flushes -- own buffer instead of the focused window. It never attaches, flushes
@ -924,6 +997,19 @@ function pmacs.lsp.attachment_for_request()
local key = tostring(buf) local key = tostring(buf)
local rec = attachments[key] local rec = attachments[key]
if not rec then return nil end if not rec then return nil end
-- Same liveness rule as `attached_for_active`: a record naming a dead
-- server is worse than none, because the caller issues a request
-- against it and waits for a reply that cannot come. Unlike that
-- function this one is deliberately non-attaching (it must not
-- perturb LSP state), so a dead record reads as "no attachment"
-- rather than triggering a rebuild.
if not server_is_live(rec.server) then
-- Preserve the record. A crashed OnCrash server may restart under
-- the SAME id; clearing the map here would orphan that recovered
-- server, while this non-attaching lookup has no authority to
-- cancel the restart or create a replacement.
return nil
end
flush_did_change(key) flush_did_change(key)
return rec return rec
end end

View File

@ -55,7 +55,7 @@ git status --short --branch
The `git log` command must expose `d152120` or a newer intentional main. The `git log` command must expose `d152120` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration. If it does not, stop and repair the remote/fetch configuration.
## Lean 4 lane (Arc 8) — Stages 1+2 MERGED; Stage 3a IN REVIEW ## Lean 4 lane (Arc 8) — Stages 1+2 MERGED; 3a IN REVIEW (#167); 3b STACKED
- Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review - Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review
round, all twelve checks green). Branch `githubsucks/lean4-stage1` round, all twelve checks green). Branch `githubsucks/lean4-stage1`
@ -253,6 +253,216 @@ If it does not, stop and repair the remote/fetch configuration.
`#[cfg(unix)]` is NOT sufficient for such a fixture — `#[cfg(unix)]` is NOT sufficient for such a fixture —
`#[cfg(target_os = "linux")]` is. Cost one red CI round to learn. `#[cfg(target_os = "linux")]` is. Cost one red CI round to learn.
### Stage 3b — the Lean language server (branch `lean4-stage3b-server`)
- Same worktree `../pmacs-lean-stage3`, **branched off
`lean4-stage3a-seams`, not off `main`** — 3b consumes 3a's response
seam and `pmacs.fs.canonicalize`, so it is strictly sequential.
**Retarget PR #170 to `main` BEFORE merging #167, not after** — the
kill-ring lesson exactly. (Round 1 of this ledger entry stated the
reverse in its first sentence and the correct rule in the next; the
review caught it. A safety rule written twice with opposite senses is
worse than not written.)
- Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in
`src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`,
a `leanprogress` mode plus `waitForDiagnostics` validation on
`pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (40 tests).
No protocol change.
- **Stage 1's acceptance 12 is half superseded and was rewritten, not
deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a
Stage-3 front-run; 3b is that stage. What survives is the restraint
half — constructing an editor spawns nothing though the config now
names `lake`, and opening a Lean buffer with no server configured
spawns nothing — which is what holds Q#LN7's "not at init" promise.
- **The marker test is wrong in two opposite directions if done naively**
and both are pinned: `io.open` SUCCEEDS on a directory (so truthiness
accepts a `lean-toolchain` dir), but requiring a non-nil read rejects
an EMPTY `lean-toolchain` (a legitimate marker — existence semantics,
not content). Discriminator is `read`'s SECOND return; decline only on
a non-nil err. Probed on LuaJIT 2.1.
- **Fifteen bites recorded, each against the committed tree.** R1: bare
`io.open` → 24a fails / 24b passes; require-non-nil → 24b fails / 24a
passes; no canonicalization → symlinked open spawns two servers; no
re-attach after the swap → three latch tests fail; hook keyed on the
attachment → the missing-`lake` case fails; `waitForDiagnostics`
without `version` → acc37 fails with InvalidParams. R2: skip retiring
a terminal server → `attempt` reaches 3; no originating-buffer gate →
the Lean buffer is left on the `lake` stub; retry-forever → the
failing-fallback test fails; version-probe any command → the
working-wrapper test fails; no disabled guard → the unconfigured test
sees "`nil` could not be started". R3: verdict keyed on `watching`
the late-verdict test finds the buffer still on `lake`; `buf_key`
rewritten per load → the second-buffer test fails; hardcoded
`lake serve` → the wrapper-naming test fails.
- **Round-2 review: three more P1 lifecycle defects, suite 20/20 with
all of them live.** (1) The crashed primary respawned forever —
skipping the retire call avoided corrupting terminal servers but left
`next_restart_at` armed. **`forget` is the call for a TERMINAL server**
(it requires terminal state and removes the client, dropping the
restart timer); `stop` is for a live one and corrupts a terminal one.
(2) Re-attachment targeted whatever buffer was active when the async
verdict landed; an unrelated Rust attachment satisfied "a different
server id". (3) A failing fallback retried every tick forever, silent.
Plus two P2s: the Lake version parser was applied to arbitrary wrapper
output, and an UNCONFIGURED `config.lean4` was reported as failure and
latched, poisoning the session.
- **Round-3 review: two more P1s, both asynchronous correlation, suite
25/25.** (a) `probe.watching` is cleared when the server initializes,
so a SLOW version verdict arrived with nil and retired nothing —
`_attach_buffer` returned the still-live primary and the retry called
it success, so status and config said "fell back" while the buffer
stayed put. **That is the round-1 silent no-op reached through a third
event ordering.** `probe.primary` is now separate from
`probe.watching` and survives initialization. (b) `buf_key` was
rewritten on every Lean `after-load`, so a second Lean buffer opened
before the verdict became the rebuild target while the latch still
watched the first buffer's server. Target buffer and primary server
are one fact and are now armed together, once. Plus a P2: the failure
message hardcoded `lake serve` after the latch became
command-agnostic, sending wrapper users to debug the wrong binary.
- **Round-4 review: one P1, and it is the same defect a FOURTH time.**
`pmacs.lsp.config.lean4` is a single global entry, so swapping its
command invalidates **every** Lean buffer and **every** Lean server —
Q#LN15 gives one per project root. Rounds 13 each fixed the repair
for one buffer and one server; round 4 is "repair the armed target,
strand the rest". The shape that finally holds: retire ALL `lean4`
servers on latch, and repair each buffer **lazily and at most once**
when it becomes active (`buffer.after-switch` + the tick), because
`_attach_buffer` is active-buffer-only and cannot reach the others.
The per-buffer once-only bound is what stops a failing fallback
retrying forever — the round-2 defect a naive global repair loop would
have reintroduced for every buffer instead of one. Plus a P2: the
argument-inclusive attribution was implemented but pinned only by
"contains the command name", so a mutation dropping every argument
still passed.
- **Round-5 review: one P1 plus a frontend scope hole, and four more.**
(1) A fallback that SPAWNS and then dies retried forever: the
once-per-buffer guard bounds `_attach_buffer`, not the server it
produced, and `ensure_server` never forwards `cfg.restart` so the
fallback inherits `OnCrash` — respawned by the manager with no
ceiling, silently, because `latched` had disabled the primary's poll.
The fallback now gets its own one-shot die-before-initialize watch.
(2) **Simultaneous frontends**: both repair triggers read the ambient
`pmacs.window.buffer()`, and the daemon restores `active_frontend` to
the last-dispatched one before `tick_processes`, so a Lean buffer
active in ANOTHER frontend gets no `after-switch` and stays stale.
Fixed at the right seam — **make CONSUMPTION safe**: both
`attached_for_active` and `attachment_for_request` now refuse a record
whose server is dead (the former rebuilds, the latter reports none,
since it must not perturb LSP state). Healing at the point of use is
frontend-agnostic, because whichever frontend runs a command is active
while it runs. (3) The retirement sweep selected on `language_id`, so
it stopped USER-spawned Lean servers too; it now keys on the
`default-lean4` label `ensure_server` stamps, which is the derivation
discriminator. (4) `probe.latched` gated repair even when NO swap
occurred, so an already-fallback config was retried and misreported.
Split out `probe.fallback_installed`. (5) The once-per-buffer
assertion counted TABLE KEYS, which cannot distinguish "once per
buffer" from "every tick for one buffer" — cardinality stays 1 either
way. Now a numeric attempt counter; the bite shows **174 vs 1**.
- **Round-6 review: four P1s and one P2, suite 40/40.** (1) General
point-of-use healing treated a crashed OnCrash server as absent and
spawned beside it while its old id still had `next_restart_at` armed;
`attach_buffer` now forgets a terminal record before replacement.
`attachment_for_request` remains non-attaching and preserves the
record, so a same-id restart can recover instead of being orphaned.
(2) The fallback watch was scalar, while Q#LN15 permits simultaneous
per-root servers and lsp.lua can create them without passing through
Lean's repair function. Watches are now per-SID and discover every
config-driven Lean server from a private origin table. (3) The shipped
`lean.wait-for-diagnostics` command bypassed both safe resolvers and
still consumed a stopped record; it now uses a command-safe resolver,
waits asynchronously for a healed replacement to initialize, and the
test requires the real request to finish. (4) When no config swap
occurred, one failed root still swept a healthy root; that arm now
retires only the SID whose verdict fired. (5) `label` is public and
unreserved, therefore not ownership. lsp.lua records successful
config-driven spawns privately, and every Lean lifecycle decision keys
on that origin fact; the user-server pin deliberately collides on
`default-lean4`. All five bites against `19f48d4` discriminate: the
old files produce 2 same-root servers, a fallback attempt of 4, a
shipped command still targeting `stopped`, retirement of the healthy
root, and retirement of the colliding user server, respectively.
- **DURABLE LESSON — "the test that passes" vs "the test that
discriminates."** Green tests across six rounds repeatedly pinned only
a nearby helper or an absence, and only biting exposed it. **Carry this
to `docs/agent-handoff.md` when the lane lands.** The concrete shapes,
all from this branch:
1. R1 acceptance 36 asserted "every server is terminal" — pinning the
ABSENCE of the fallback it claimed to test.
2. "No live non-fallback server" misses a respawn loop: a respawning
server sits in `crashed` most of the time. `attempt` counts
respawns; liveness does not.
3. Returning to a buffer via `find_or_open` re-fires
`buffer.after-load`, which repairs the attachment regardless of the
code under test. Use `switch_buffer`.
4. A MISSING executable fails synchronously inside `after-load`, where
the rebuild happens inline — no async race can occur. Only the
probe path exercises asynchronous ordering.
5. A mutation that RAISES (indexing a nil config) is swallowed by the
hook's pcall, so the bite "passes" for the wrong reason. A bite must
reproduce the original shape, not merely break the code.
6. A fixture whose `serve` sleeps can never let the primary initialize
first, so it cannot reach the ordering where a late verdict must
retire a LIVE server.
7. Asserting on a field that no longer exists (`_probe.reattach_from`
after a refactor) reads as nil and passes for nothing. Assert
positive facts — a count, a command string — not absences.
8. Counting DISTINCT KEYS cannot bound REPEATED WORK: a per-tick retry
on one buffer keeps `#repaired == 1` forever. Count the attempts,
not the things attempted against (bite: 174 vs 1).
9. A NONEXISTENT executable only exercises synchronous ENOENT. To
reach "spawned, then died", the fixture must actually spawn.
10. Calling the two SAFE HELPERS directly does not pin a shipped
command that bypasses both. Drive the command registry entry and
require its terminal result — replacing a dead record with a
`starting` server is still not success if the request is issued
before initialize.
Rule: **a test is not evidence until the mutation it targets has been
shown to fail it.**
- **SECOND DURABLE LESSON — a scope error repeats until the scope is
named.** The "fallback silently does not happen" defect came back four
times: no re-attach; re-attach cleared by an unrelated buffer;
re-attach satisfied by the server being replaced; re-attach of one
buffer while the others stay stale. Every fix was locally correct and
none asked *what does this config swap invalidate?* — the answer being
every Lean buffer and every Lean server, because the config entry is
global and servers are per-root. **When a change edits shared state,
enumerate everything derived from it before repairing anything.**
- **SUBSTRATE BUG FOUND, not fixed here (framing §6).**
`LspManager::stop` on an ALREADY-terminal server takes its
not-initialized branch, terminates the dead process and sets
`ShuttingDown { .. None }` on the premise that "the next exit
observation cleans up" — but the exit already happened, which is what
made it `Crashed`. No further event arrives, so the client is stuck in
`ShuttingDown` **forever**: `server_is_live` reads it as LIVE, so
`attach_buffer` never rebuilds, and `forget` refuses it for not being
terminal. **Stopping a dead server is what makes it un-replaceable.**
Lean works around it by dispatching on state: `forget` when
terminal, `stop` when live. Merely SKIPPING the call is not
enough — that leaves `next_restart_at` armed.
- Round-1 review found four P1s, all real: the latch swapped the config
but never spawned or re-attached (and acc36 *asserted every server was
terminal*, pinning the absence of the fallback); a missing `lake`
bypassed probe and latch entirely because the hook keyed on an
attachment that ENOENT prevents; `waitForDiagnostics` omitted the
`version` Lean requires; and the ledger stated the dangerous stacking
order.
- The probe's non-zero exit is deliberately NOT a fallback trigger —
§2.9's elan shim makes `lake --version` fail where `lake serve` still
works. Only a parseable version below 3.1.0 triggers it; the
server-failure latch covers the rest.
- Verification on this branch: `cargo fmt --check` clean; strict
workspace Clippy clean; 1,829 default + 2,003 CRDT library tests;
lean4 server 40/40; lean4 stage 1 9/9; dispatch seams 15/15;
multi-root 13/13; M4 121; required GPU 155; **isolated-config
serial workspace sweep 3,229 across 94 suites, zero failures**;
`git diff --check` clean. (Round 1 of
this entry recorded 17/17 and 3,206 — the PRE-fix counts — after the
fixes were pushed. The ledger's protocol is that verification
describes the pushed tree; recording it late is the #161 fmt-blocker
error in a slower form.)
## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) ## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165)
- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the - Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the

View File

@ -1543,6 +1543,24 @@ What remains deferred:
which events may be dropped, which is a policy question with which events may be dropped, which is a policy question with
user-visible consequences for diagnostics and progress; Stage 3a states user-visible consequences for diagnostics and progress; Stage 3a states
the seam's contract around the behavior rather than changing it. the seam's contract around the behavior rather than changing it.
- **`LspManager::stop` on an already-terminal server strands it.** The
not-initialized branch terminates the (already-dead) process and sets
`ShuttingDown { shutdown_request_id: None }` on the premise that "the
next exit observation cleans up" — but for a `Crashed` client the exit
has already been observed, which is what produced that state. No
further event arrives, so the client sits in `ShuttingDown`
permanently: `server_is_live` counts it as live (neither crashed nor
stopped), so `attach_buffer` never rebuilds against it, and
`LspManager::forget` refuses it for not being terminal. **Stopping a
dead server is what makes it un-replaceable.** Found implementing
Stage 3b's latch, which works around it by dispatching on state:
`forget` for a terminal server (it requires terminal state, and
removing the client also drops the `next_restart_at` the crash armed),
`stop` for a live one. Merely *skipping* the call is not enough — that
leaves the restart timer running and the broken command respawns
underneath the fallback. The fix belongs in `stop` (treat an
already-terminal client as a no-op, or drive it straight to `Stopped`)
and changes behavior for every language, so it does not ride a Lean PR.
- **Forwarding `cfg.restart` through `ensure_server`** — read by - **Forwarding `cfg.restart` through `ensure_server`** — read by
`lua_to_lsp_spec`, never set by the spawn table, so silently dropped on `lua_to_lsp_spec`, never set by the spawn table, so silently dropped on
every auto-attach (found landing #161). Fixing it changes behavior for every auto-attach (found landing #161). Fixing it changes behavior for
@ -1722,9 +1740,13 @@ the blast radius.
channel a user can actually observe; a report added through channel a user can actually observe; a report added through
`pmacs.error` alone must fail this. `pmacs.error` alone must fail this.
- **37.** `textDocument/waitForDiagnostics` resolves through the response seam - **37.** `textDocument/waitForDiagnostics` resolves through the response seam
(Q#LN16). **PATH-and-success-gated live smoke:** if `lake serve` (Q#LN16), **carrying both `uri` and `version`** — Lean's
starts successfully a real elaboration completes and diagnostics `WaitForDiagnosticsParams` requires the document version, and a fake
arrive; skipped otherwise, never failed. server that echoes any payload will hide its absence, so the fixture
must reject a request that omits it.
**PATH-and-success-gated live smoke:** if `lake serve` starts
successfully a real elaboration completes and diagnostics arrive;
skipped otherwise, never failed.
These two sections are bulleted with explicit labels rather than These two sections are bulleted with explicit labels rather than
numbered, because the split leaves each stage's criteria non-contiguous numbered, because the split leaves each stage's criteria non-contiguous

View File

@ -483,6 +483,27 @@ fn main() {
} }
}); });
write_frame(&mut stdout, &echo); 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` // Also push a synthetic `publishDiagnostics`
// notification with two entries (one Error, one // notification with two entries (one Error, one
// Warning) so M4.6 tests can exercise the store. // Warning) so M4.6 tests can exercise the store.
@ -1062,6 +1083,39 @@ fn main() {
}); });
write_frame(&mut stdout, &resp); write_frame(&mut stdout, &resp);
} }
("textDocument/waitForDiagnostics", Some(idv)) => {
// Arc 8 Stage 3b: Lean's `WaitForDiagnosticsParams` is
// `{ uri, version }` (v4.9.0
// `src/Lean/Data/Lsp/Extra.lean`). Validated here rather
// than echoed, because the generic echo arm below
// accepts anything — which is exactly how a client
// sending only `uri` shipped looking correct. A client
// that omits `version`, or sends a non-integer, gets an
// InvalidParams error the way a real server would.
let uri_ok = params
.get("uri")
.and_then(serde_json::Value::as_str)
.is_some();
let version_ok = params
.get("version")
.and_then(serde_json::Value::as_i64)
.is_some();
let resp = if uri_ok && version_ok {
serde_json::json!({
"jsonrpc": "2.0", "id": idv, "result": serde_json::Value::Null
})
} else {
serde_json::json!({
"jsonrpc": "2.0",
"id": idv,
"error": {
"code": -32602,
"message": "waitForDiagnostics requires { uri, version }"
}
})
};
write_frame(&mut stdout, &resp);
}
(_, Some(idv)) => { (_, Some(idv)) => {
// Generic echo response. // Generic echo response.
let resp = serde_json::json!({ let resp = serde_json::json!({

View File

@ -436,6 +436,17 @@ impl EditorState {
include_str!("../builtin/runtime/lsp.lua"), include_str!("../builtin/runtime/lsp.lua"),
) )
.expect("load lsp builtin chunk"); .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 // Arc 1a: the in-buffer completion popup driver. Loaded after
// lsp.lua because it drives `pmacs.lsp.request_completion` / // lsp.lua because it drives `pmacs.lsp.request_completion` /
// `pmacs.lsp.attachment_for_request` and after the framework // `pmacs.lsp.attachment_for_request` and after the framework

File diff suppressed because it is too large Load Diff

View File

@ -305,36 +305,50 @@ fn acc11b_an_unknown_fence_name_still_injects_nothing() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[test] #[test]
fn acc12_stage1_ships_no_lsp_config_and_spawns_no_process() { fn acc12_opening_lean_spawns_no_process_without_a_server_config() {
// Stage 1 is grammar + Lua tables only. Opening a Lean file must not // **Superseded in half by Stage 3b.** This criterion originally also
// reach for `lake`, `lean`, or `elan` — the LSP arrives in Stage 3, and // asserted `pmacs.lsp.config.lean4 == nil`, guarding against a Stage-3
// even then it is fallible by design (Q#LN7). // 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. // Constructing an editor touches no process, even though the Lean
// The shared `editor()` helper wipes `pmacs.lsp.config` before any // config now exists and names `lake`.
// 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.
let pristine = EditorState::new(); let pristine = EditorState::new();
let no_lean_config: bool = eval(&pristine, "return pmacs.lsp.config.lean4 == nil"); let at_init: i64 = eval(&pristine, "return #pmacs.process.list()");
assert!( assert_eq!(
no_lean_config, at_init, 0,
"Stage 1 defines no `pmacs.lsp.config.lean4`; the LSP is Stage 3" "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!( assert!(
rust_config_exists, names_lake,
"the config table is populated, so the lean4 absence above is meaningful" "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 // And opening a Lean buffer with no server configured spawns nothing —
// value under the wiped config: a direct probe spawn from `lean.lua` // the `editor()` helper wipes `pmacs.lsp.config`, so this catches a
// would show up here whatever `pmacs.lsp.config` contains. // probe that fires off the mode rather than off an attachment.
let s = editor_visiting("Basic.lean", "def x : Nat := 1\n"); let s = editor_visiting("Basic.lean", "def x : Nat := 1\n");
let procs: i64 = eval(&s, "return #pmacs.process.list()"); 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"
);
} }