Merge pull request #204 from levineuwirth/journey-stage1b2-lsp-guidance
Journey Stage 1b-2: say when the language server did not start (step 6)
This commit is contained in:
commit
5376af14d4
104
COHERENCE.md
104
COHERENCE.md
|
|
@ -219,16 +219,31 @@ Automatic, background failures are swallowed. The canonical case, hit on
|
|||
**every file open** when a language server is preconfigured but not
|
||||
installed: `Command::spawn` ENOENT propagates up through
|
||||
`LspManager::spawn` and raises in Lua — where `ensure_server` `pcall`s
|
||||
it and returns nil (`builtin/runtime/lsp.lua:614-626`), and the
|
||||
`buffer.after-load` hook `pcall`s the whole attach
|
||||
(`builtin/runtime/lsp.lua:895-897`). Net user-visible result: nothing.
|
||||
No status message, no `*errors*` entry, no modeline marker (the LSP
|
||||
segment is gated on an attachment record existing, so absence is
|
||||
indistinguishable from "unsupported file type"). Working tree-sitter
|
||||
highlighting **actively masks** the failure — the user sees colored text
|
||||
and assumes language intelligence is on. Post-crash is the same shape:
|
||||
`LspEventKind::Crashed` is pushed (`src/lsp.rs:2394`) and no builtin
|
||||
subscriber surfaces it.
|
||||
it, and the `buffer.after-load` hook `pcall`s the whole attach. Net
|
||||
user-visible result used to be nothing: no status message, no `*errors*`
|
||||
entry, no modeline marker (the LSP segment is gated on an attachment
|
||||
record existing, so absence was indistinguishable from "unsupported file
|
||||
type"). Working tree-sitter highlighting **actively masks** the failure —
|
||||
the user sees colored text and assumes language intelligence is on.
|
||||
|
||||
**Journey Stage 1b-2 answers this specific case**
|
||||
(`docs/journey-stage1b2-lsp-guidance-framing.md`): the failure is
|
||||
reported once per `(language, root, command)` with the command, the
|
||||
language and the errno; `M-x lsp.status` renders a durable `*lsp*` panel;
|
||||
and the modeline says `LSP:!` instead of nothing. **The asymmetry itself
|
||||
is not retired** — the rule below still needs adopting site by site, and
|
||||
`pmacs.error` is still undefined.
|
||||
|
||||
Post-crash is the same shape and is **not** covered:
|
||||
`LspEventKind::Crashed` is pushed and no builtin subscriber surfaces it.
|
||||
A server that started and then died is a different failure with a
|
||||
different message.
|
||||
|
||||
*(Citation note: this paragraph carried three stale line references —
|
||||
`ensure_server` was cited at `:614-626` when the spawn `pcall` is at
|
||||
`:658-674`, and the `buffer.after-load` hook at `:895-897` when it is at
|
||||
`:1019-1021`. Symbols are authoritative per §25; the numbers are dropped
|
||||
rather than re-pinned.)*
|
||||
|
||||
This directly contradicts the product thesis (§23): the "without
|
||||
freezing" half is delivered; the "without becoming opaque" half is
|
||||
|
|
@ -258,10 +273,14 @@ wiring must log attributed failure, never discard it. Corollary from the
|
|||
above: report through a channel with a **test that observes it**, or the
|
||||
guard is indistinguishable from the silence it was meant to fix.
|
||||
|
||||
**Frequency note (PR #161):** per-root server affinity means the
|
||||
preconfigured-but-missing-server failure now fires **once per project
|
||||
root** rather than once per language per session. The silence is
|
||||
unchanged in kind; it is strictly more frequent. Surfacing it stays
|
||||
**Frequency note — corrected by Stage 1b-2.** This previously said the
|
||||
failure fires "once per project root". It did not: `LspManager::spawn`
|
||||
returns early *before* both `status_tracker.ensure` and
|
||||
`clients.insert`, so a failed spawn left **no record at all**,
|
||||
`pmacs.lsp.list()` could not see it, and `ensure_server`'s affinity loop
|
||||
re-spawned. The real rate was **once per file open** — strictly worse
|
||||
than recorded, and the reason the fix memoizes the *report* while still
|
||||
retrying the spawn. Surfacing it stays
|
||||
Priority 1 work with its own framing — it is a user-visible product
|
||||
behavior (what message, where, with what guidance), not a substrate fix
|
||||
to smuggle into an affinity PR.
|
||||
|
|
@ -375,10 +394,10 @@ Full verdict table:
|
|||
| 3 | Open real project | **Works at the CLI** | Journey Stage 1a: `resolve_target_buffer` answers `ResolvedTarget::Directory` before the EISDIR-producing load, and `EditorState::open` / the daemon bootstrap dispatch the `path.open-directory` chain, whose fallback is dired (#165's buffer, reached rather than duplicated). Startup no longer fails: an unreadable directory, a crashed resolver, and a cleared handler all report on the status line and leave the session running. Because the listing is async and the bootstrap is synchronous, the commit runs against a destination captured at request time (`pmacs.window.commit_to`) rather than against the ambient frontend |
|
||||
| 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) |
|
||||
| 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config |
|
||||
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose |
|
||||
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua`). **Journey Stage 1b-2 (PR open) ends the silence** for a server that fails to *start*: the status line names the command, language and errno once per `(language, root, command)`; the modeline reads `LSP:!` instead of nothing; and `M-x lsp.status` renders `*lsp*` over the `status_buffer_text()` renderer that had existed since M4.8 with no caller. **Still Partial**, and flips only on merge (§25): a server that starts and then *crashes* is still unsurfaced |
|
||||
| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
|
||||
| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and there is still **no close/kill command**, which is the remaining half of this step's discoverability gap. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
|
||||
| 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to the detected project root, and parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua`), and no `cargo build` suggestion despite `ProjectKind::Rust` existing (`src/project.rs:78` — **not** `Cargo`, see §24). **PR #203 (open) closes all three**: `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind (`pmacs.compile.defaults`, seeded `rust = "cargo build"`). The prompt **captures** its directory rather than re-resolving at accept time, so the command it offers and the directory it runs in cannot drift while the minibuffer waits. Named limitation it does not fix: after `pmacs <dir>` the active buffer is dired's and pathless, so the cwd falls back to the process cwd — §8's execution-location model owns that. **This row flips to Works when #203 merges**; per §25 a grade changes only on landed evidence |
|
||||
| 9 | Build / test | **Works** | Journey Stage 1b-1 (#203): `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind (`pmacs.compile.defaults`, seeded `rust = "cargo build"`, extensible from `init.lua`) via `ProjectKind::Rust` — **not** `Cargo`, see §24. The prompt **captures** its directory rather than re-resolving at accept time, so the command it offers and the directory it runs in cannot drift while the minibuffer waits. Still defaults cwd to the detected project root and parses Rust `-->` errors. Named limitation: after `pmacs <dir>` the active buffer is dired's and pathless, so the cwd falls back to the process cwd — §8's execution-location model owns that, and the degradation stays coherent (no suggestion is offered for a directory with no detected Cargo project) |
|
||||
| 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first |
|
||||
| 11 | See background work | **Works but undiscoverable** | `*workers*` view via `M-x editor.list-workers`; `C-c C-k` cancel-at-point. No keybinding, no statusline spinner/progress indicator anywhere (§9) |
|
||||
| 12 | Close + restore | **Partial** | Per-file cursor+scroll (saveplace), recent files, minibuffer history, autosave recovery all restore zero-config. Open-buffer set and window layout do **not**: desktop-save is opt-in (`pmacs.session.desktop_mode(true)`) *and* a documented no-op under a daemon (`src/desktop.rs:323-326`, `:353-356`, Q#DS9) |
|
||||
|
|
@ -388,14 +407,15 @@ A journey observation worth keeping verbatim from the audit:
|
|||
C-M-s` opens all folds, while opening a file, opening a terminal, and
|
||||
running a build have no bindings at all.
|
||||
|
||||
Two of that observation's three examples have been answered — opening a
|
||||
file by `C-x C-f` (#162) and opening a terminal by `C-c t` (#173).
|
||||
**Running a build still has no binding on `main`**; PR #203 is open and
|
||||
adds `C-c c`. **The quote stays as written either way**: it names a standing bias in how new work gets bound,
|
||||
not three isolated omissions, and two fixes do not retire a bias.
|
||||
**Running a build remains its uncontested golden-journey example until
|
||||
#203 lands.** A new surface that ships without a binding would be further
|
||||
evidence the pattern is live, and should be read that way.
|
||||
All three of that observation's examples have now been answered —
|
||||
opening a file by `C-x C-f` (#162), opening a terminal by `C-c t`
|
||||
(#173), and running a build by `C-c c` (Journey Stage 1b-1, #203).
|
||||
**The quote stays as written**: it names a standing bias in how new work
|
||||
gets bound, not three isolated omissions, and three fixes do not retire
|
||||
a bias. What has changed is that the bias no longer has an uncontested
|
||||
example in the golden journey — a new surface that ships without a
|
||||
binding would be evidence the pattern is live again, and should be read
|
||||
that way.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1543,19 +1563,19 @@ Establish the end-to-end workflow; treat regressions as release
|
|||
blockers. **State: runs to step 5; thin from step 6 (§2). Mostly wiring,
|
||||
and unusually cheap:** directory-argument handling (**done**: Journey
|
||||
Stage 1a); a find-file surface (**done**: #162 open-by-path, #165
|
||||
browsing); surfacing the LSP spawn failure with guidance (§1.2); a
|
||||
compile keybinding + `cargo build`/`test` default (**in flight**:
|
||||
Journey Stage 1b-1, PR #203, from the existing `ProjectKind::Rust` —
|
||||
**not** `Cargo`, see §24); a terminal keybinding (**done**: `C-c t`,
|
||||
#173); a welcome buffer. The journey acceptance suite (§19) is the ratchet that
|
||||
keeps it fixed — it **exists now** (`tests/journey_acceptance.rs`,
|
||||
Stage 1a), seeded with steps 2, 3, and 5.
|
||||
browsing); surfacing the LSP spawn failure with guidance (**in flight**:
|
||||
Journey Stage 1b-2, §1.2); a compile keybinding + `cargo build`/`test`
|
||||
default (**done**: Journey Stage 1b-1, #203, from the existing
|
||||
`ProjectKind::Rust` — **not** `Cargo`, see §24); a terminal keybinding
|
||||
(**done**: `C-c t`, #173); a welcome buffer. The journey acceptance
|
||||
suite (§19) is the ratchet that keeps it fixed — it **exists now**
|
||||
(`tests/journey_acceptance.rs`, Stage 1a), seeded with steps 2, 3 and 5,
|
||||
and carrying step 9 since #203.
|
||||
|
||||
Journey Stage 1b is the named remainder, and it splits: **1b-1 — the
|
||||
compile binding + project-kind defaults — is in flight as PR #203**;
|
||||
1b-2 (LSP spawn guidance, step 6) and 1b-3 (the welcome buffer, step 4)
|
||||
remain. The ratchet is seeded with steps 2, 3 and 5, and gains step 9
|
||||
when #203 lands.
|
||||
compile binding + project-kind defaults — landed as #203**; **1b-2**
|
||||
(LSP spawn guidance, step 6) is in flight; **1b-3**, the welcome buffer
|
||||
(step 4), remains.
|
||||
|
||||
### Priority 2: Make workspace and location explicit
|
||||
|
||||
|
|
@ -1626,10 +1646,10 @@ implementation — this list is direction, not commitment):
|
|||
`resolve_target_buffer` unification, the destination-scope substrate,
|
||||
and the first journey acceptance suite. It routes `pmacs .` into
|
||||
#165's dired buffer rather than growing a second directory surface.
|
||||
**Stage 1b-1 — in flight (PR #203)**: the compile binding and
|
||||
project-kind defaults, with the prompt capturing its directory rather
|
||||
than re-resolving it at accept time. **Stage 1b-2 / 1b-3 —
|
||||
remaining**: LSP-failure surfacing, welcome buffer.
|
||||
**Stage 1b-1 — landed (#203)**: the compile binding and project-kind
|
||||
defaults, with the prompt capturing its directory rather than
|
||||
re-resolving it at accept time. **Stage 1b-2 — in flight**:
|
||||
LSP-failure surfacing. **Stage 1b-3 — remaining**: welcome buffer.
|
||||
2. **Discovery surface** (P4): the describe/list/where-is command
|
||||
family, M-x rich rows, help unification, help prefix.
|
||||
3. **Transient keymap layer** (§6): the overlay scope + lifetime
|
||||
|
|
@ -1721,6 +1741,11 @@ available without being imposed.
|
|||
Found during the audit; fix opportunistically, ideally before this
|
||||
document is wired into CLAUDE.md/AGENTS.md as required reading:
|
||||
|
||||
- **§1.2's frequency note was wrong**, not merely stale: it recorded the
|
||||
missing-server failure as firing once per project root when the real
|
||||
rate was once per file open, because a failed spawn leaves no record
|
||||
for the affinity loop to find. Corrected in place by Journey Stage
|
||||
1b-2, along with three stale line citations in the same paragraph.
|
||||
- **This document named a `ProjectKind` variant that does not exist**,
|
||||
in two places: §2's step-9 row and §20 Priority 1 both said
|
||||
"`ProjectKind::Cargo` existing (`src/project.rs:77`)". Line 77 is the
|
||||
|
|
@ -1731,6 +1756,7 @@ document is wired into CLAUDE.md/AGENTS.md as required reading:
|
|||
`pmacs.project.detect` returns the **tag string** `"rust"`. Kept here
|
||||
rather than silently fixed, because a wrong type name in the document
|
||||
work is evaluated against costs a scout a real detour.
|
||||
|
||||
- `docs/keybindings.md` — every `src/editor.rs` line citation in §3 is
|
||||
stale by ~250–1000 lines despite a "last verified @ `f8096ff`
|
||||
(2026-07-20)" stamp; its shadow list also omits the terminal `C-c`
|
||||
|
|
|
|||
|
|
@ -613,6 +613,163 @@ end
|
|||
-- only after this module itself successfully spawns a server.
|
||||
local default_servers = {}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Spawn-failure reporting (journey Stage 1b-2; COHERENCE §1.2)
|
||||
-- ---------------------------------------------------------------------
|
||||
--
|
||||
-- `ensure_server`'s spawn `pcall` used to end in a bare `return nil`:
|
||||
-- the canonical background failure — a preconfigured server that is not
|
||||
-- installed — produced no status message, no record, and no modeline
|
||||
-- marker, while tree-sitter highlighting kept working and masked it.
|
||||
--
|
||||
-- The reporting SHAPE is not new here. `root_resolver_for` and
|
||||
-- `report_subscriber_error` in this same file already report through
|
||||
-- `pmacs.editor.set_status` with the `pmacs.error` arm riding along;
|
||||
-- this finishes that adoption at the site that matters most.
|
||||
|
||||
-- Lua strings are 8-bit clean, so \0 is a separator no language id,
|
||||
-- URI, or command can contain.
|
||||
--
|
||||
-- The `u` / `n` discriminator is what makes the markerless case
|
||||
-- expressible at all. `ensure_server`'s affinity key is `key_uri`, which
|
||||
-- is deliberately **nil** for a file with no project marker (so loose
|
||||
-- files share one server per language) — and `t[nil]` raises "table
|
||||
-- index is nil". A bare `key_uri or ""` would instead collide with an
|
||||
-- empty URI. One function for both tables, so the two encodings cannot
|
||||
-- drift apart.
|
||||
local function affinity_key(language, key_uri)
|
||||
return language .. "\0" .. (key_uri and ("u" .. key_uri) or "n")
|
||||
end
|
||||
|
||||
local function reported_key(language, key_uri, command)
|
||||
return affinity_key(language, key_uri) .. "\0" .. tostring(command)
|
||||
end
|
||||
|
||||
-- Session-scoped and NEVER cleared: has this exact (language, root,
|
||||
-- command) triple already been named on the status line? The command is
|
||||
-- part of the key so repointing config at a *different* missing
|
||||
-- executable is a new failure and reports again.
|
||||
local reported = {}
|
||||
|
||||
-- Current failure state per affinity key, CLEARED when a spawn for that
|
||||
-- key succeeds. This is what `lsp.status` renders, so recovery makes it
|
||||
-- go quiet. Separate from `reported` because one table cannot both
|
||||
-- dedupe forever and forget on recovery.
|
||||
local failures = {}
|
||||
|
||||
-- Per-buffer projection of `failures`, keyed by `tostring(buffer)`.
|
||||
--
|
||||
-- It exists so the modeline provider stays a PURE per-buffer lookup:
|
||||
-- that provider runs for every window on every paint, and deriving an
|
||||
-- affinity key inside it would invoke `project_root_for` — user root
|
||||
-- resolvers and project detection — during painting.
|
||||
--
|
||||
-- Each entry carries the affinity key that produced it, so a success can
|
||||
-- sweep every buffer sharing that key rather than only the one that
|
||||
-- succeeded, and the path it was recorded against, so a rename or delete
|
||||
-- can find it the way `attachments_under` finds attachments.
|
||||
local failed_attachments = {}
|
||||
|
||||
-- Drop one buffer's projection, releasing its removal callback. Safe to
|
||||
-- call for a buffer that has none.
|
||||
local function clear_failed_buffer(bkey)
|
||||
local entry = failed_attachments[bkey]
|
||||
if not entry then return end
|
||||
if entry.on_removed then
|
||||
pcall(function() entry.on_removed:remove() end)
|
||||
end
|
||||
failed_attachments[bkey] = nil
|
||||
end
|
||||
|
||||
-- A spawn for `key` succeeded: forget the failure and every buffer
|
||||
-- projecting it.
|
||||
--
|
||||
-- Assigning nil to the CURRENT key during `pairs` is explicitly allowed
|
||||
-- by Lua's `next` contract; adding a key would not be.
|
||||
local function clear_failure(key)
|
||||
failures[key] = nil
|
||||
for bkey, entry in pairs(failed_attachments) do
|
||||
if entry.key == key then clear_failed_buffer(bkey) end
|
||||
end
|
||||
end
|
||||
|
||||
-- Record and (at most once per identity) announce a spawn failure.
|
||||
-- Returns the affinity key so the caller can project it onto a buffer.
|
||||
local function report_spawn_failure(language, key_uri, command, err)
|
||||
local key = affinity_key(language, key_uri)
|
||||
failures[key] = {
|
||||
language = language,
|
||||
command = tostring(command),
|
||||
error = tostring(err),
|
||||
root_uri = key_uri,
|
||||
}
|
||||
local rkey = reported_key(language, key_uri, command)
|
||||
if reported[rkey] then return key end
|
||||
reported[rkey] = true
|
||||
-- The underlying error names NEITHER the program nor the language:
|
||||
-- `Command::spawn`'s io::Error becomes "spawn: No such file or
|
||||
-- directory (os error 2)". So the guidance is composed here, where
|
||||
-- both are still in scope, in the spirit of `src/main.rs`'s GPU
|
||||
-- missing-binary message. The errno is passed through verbatim rather
|
||||
-- than classified — EACCES is not "not installed".
|
||||
local msg = string.format(
|
||||
"LSP: %s for %s did not start (%s) — install it or set " ..
|
||||
"pmacs.lsp.config.%s.command in init.lua. M-x lsp.status for detail.",
|
||||
tostring(command), language, tostring(err), language)
|
||||
pcall(pmacs.editor.set_status, msg)
|
||||
if pmacs.error then pcall(pmacs.error, msg) end
|
||||
return key
|
||||
end
|
||||
|
||||
-- Project a failure onto the buffer that hit it, registering the
|
||||
-- teardown ONCE. `attach_buffer` is reachable more than once for the
|
||||
-- same buffer, so registering per failed attempt would stack callbacks
|
||||
-- on one buffer — the same unbounded-registrar leak, moved rather than
|
||||
-- fixed.
|
||||
--
|
||||
-- Without this registration the table would be bounded by nothing: a
|
||||
-- killed buffer's entry would outlive it for the session, and no
|
||||
-- existing cleanup could reach it, because `attachments_under` iterates
|
||||
-- `attachments` and a failed buffer has no attachment by construction.
|
||||
local function project_failure(buf, key, path)
|
||||
local bkey = tostring(buf)
|
||||
local existing = failed_attachments[bkey]
|
||||
if existing then
|
||||
existing.key = key
|
||||
existing.path = path
|
||||
return
|
||||
end
|
||||
local handle
|
||||
local ok, h = pcall(pmacs.buffer.on_removed, buf, function()
|
||||
-- The registry does `callbacks.take(id)` and then iterates an owned
|
||||
-- list, so the entry is already gone: clear the projection, and do
|
||||
-- NOT call `remove()` on our own handle from in here.
|
||||
failed_attachments[bkey] = nil
|
||||
end)
|
||||
if ok then handle = h end
|
||||
failed_attachments[bkey] = { key = key, path = path, on_removed = handle }
|
||||
end
|
||||
|
||||
--- Current LSP spawn failures, newest-first is not meaningful here so
|
||||
--- they are returned sorted by language for a stable render. Public
|
||||
--- getter (per API conventions).
|
||||
function pmacs.lsp.spawn_failures()
|
||||
local out = {}
|
||||
for _, f in pairs(failures) do
|
||||
out[#out + 1] = {
|
||||
language = f.language,
|
||||
command = f.command,
|
||||
error = f.error,
|
||||
root_uri = f.root_uri,
|
||||
}
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
if a.language ~= b.language then return a.language < b.language end
|
||||
return tostring(a.root_uri) < tostring(b.root_uri)
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
local function ensure_server(language, path)
|
||||
local cfg = pmacs.lsp.config[language]
|
||||
if not cfg or not cfg.command then return nil end
|
||||
|
|
@ -651,6 +808,8 @@ local function ensure_server(language, path)
|
|||
and info.root_uri == key_uri then
|
||||
local kind = info.state.kind
|
||||
if kind ~= "crashed" and kind ~= "stopped" then
|
||||
-- A live server for this key means there is no failure for it.
|
||||
clear_failure(affinity_key(language, key_uri))
|
||||
return info.id
|
||||
end
|
||||
end
|
||||
|
|
@ -668,9 +827,13 @@ local function ensure_server(language, path)
|
|||
})
|
||||
if ok then
|
||||
default_servers[tostring(sid)] = language
|
||||
clear_failure(affinity_key(language, key_uri))
|
||||
return sid
|
||||
end
|
||||
return nil
|
||||
-- `sid` holds the error on the failing branch. Second return value is
|
||||
-- the affinity key, so `attach_buffer` can project it onto the buffer
|
||||
-- without recomputing a root.
|
||||
return nil, report_spawn_failure(language, key_uri, cfg.command, sid)
|
||||
end
|
||||
|
||||
-- Internal ownership seam for builtins whose lifecycle follows the
|
||||
|
|
@ -853,8 +1016,17 @@ local function attach_buffer(buf)
|
|||
-- Path resolved before spawn so the server's `rootUri` can be
|
||||
-- derived from the file's project (see `project_root_for`).
|
||||
local path = active_buffer_path()
|
||||
local sid = ensure_server(language, path)
|
||||
if not sid then return nil end
|
||||
local sid, failure_key = ensure_server(language, path)
|
||||
if not sid then
|
||||
-- Journey Stage 1b-2: remember WHY there is no attachment, so the
|
||||
-- modeline can say "failed" instead of rendering nothing — which is
|
||||
-- indistinguishable from "this file type has no server".
|
||||
if failure_key then project_failure(buf, failure_key, path) end
|
||||
return nil
|
||||
end
|
||||
-- Any successful resolution clears this buffer's stale projection;
|
||||
-- `clear_failure` has already swept peers sharing the affinity.
|
||||
clear_failed_buffer(key)
|
||||
local uri = file_uri_for(path)
|
||||
if not uri then return nil end
|
||||
local rec = {
|
||||
|
|
@ -976,9 +1148,14 @@ pmacs.statusline.register {
|
|||
priority = 0,
|
||||
face = "ui.modeline.lsp",
|
||||
fn = function(ctx)
|
||||
local rec = attachments[tostring(ctx.buffer)]
|
||||
if not rec then return nil end
|
||||
return "LSP:" .. pmacs.lsp.modeline_label(rec.server)
|
||||
local bkey = tostring(ctx.buffer)
|
||||
local rec = attachments[bkey]
|
||||
if rec then return "LSP:" .. pmacs.lsp.modeline_label(rec.server) end
|
||||
-- Journey Stage 1b-2. A plain map lookup, deliberately: deriving an
|
||||
-- affinity key here would run root resolvers and project detection
|
||||
-- once per window per paint.
|
||||
if failed_attachments[bkey] then return "LSP:!" end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
|
||||
|
|
@ -2767,6 +2944,58 @@ end
|
|||
|
||||
-- Default commands + keymap entries --------------------------------------
|
||||
|
||||
-- Journey Stage 1b-2. `LspManager::status_buffer_text()` and
|
||||
-- `last_error` have existed and been exposed to Lua since M4.8, with no
|
||||
-- production caller and no buffer to render into — several doc comments
|
||||
-- in `src/lsp.rs` refer to "the `*lsp*` buffer" as though it existed.
|
||||
-- This is that buffer.
|
||||
--
|
||||
-- Opened through `pmacs.listview.open` rather than hand-rolled, which is
|
||||
-- what buys owned-handle identity (a foreign `*lsp*` is never adopted),
|
||||
-- `<2>` collision behaviour, an immutable generated buffer, `q`, and
|
||||
-- `g`. `on_refresh` is NOT optional: `listview.refresh` early-returns
|
||||
-- without one, which would leave `g` bound and silently dead.
|
||||
local function lsp_status_rows()
|
||||
local rows = {}
|
||||
local fails = pmacs.lsp.spawn_failures()
|
||||
if #fails > 0 then
|
||||
rows[#rows + 1] = { text = string.format("Failed to start (%d):", #fails) }
|
||||
for _, f in ipairs(fails) do
|
||||
rows[#rows + 1] = { text = string.format(" %s (%s) — %s",
|
||||
f.command, f.language, f.error) }
|
||||
if f.root_uri then
|
||||
rows[#rows + 1] = { text = " root: " .. f.root_uri }
|
||||
else
|
||||
rows[#rows + 1] = { text = " root: (none detected)" }
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = { text = "" }
|
||||
end
|
||||
rows[#rows + 1] = { text = "Servers:" }
|
||||
local ok, text = pcall(pmacs.lsp.status_buffer_text)
|
||||
if ok and type(text) == "string" then
|
||||
for line in (text .. "\n"):gmatch("([^\n]*)\n") do
|
||||
rows[#rows + 1] = { text = line }
|
||||
end
|
||||
else
|
||||
rows[#rows + 1] = { text = " (status unavailable)" }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
pmacs.command.define {
|
||||
name = "lsp.status",
|
||||
description = "Show language-server status and start failures in *lsp*.",
|
||||
fn = function()
|
||||
pmacs.listview.open {
|
||||
name = "*lsp*",
|
||||
header = "LSP status g refresh q quit",
|
||||
rows = lsp_status_rows(),
|
||||
on_refresh = lsp_status_rows,
|
||||
}
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "lsp.go-to-definition",
|
||||
description = "Jump to the definition of the symbol under the cursor (LSP).",
|
||||
|
|
@ -2942,6 +3171,22 @@ local function attachments_under(path)
|
|||
return out
|
||||
end
|
||||
|
||||
-- Journey Stage 1b-2: the same query for FAILED buffers, which have no
|
||||
-- attachment by construction and are therefore invisible to
|
||||
-- `attachments_under`. Matches on the path the projection was recorded
|
||||
-- against, exactly as the attachment query matches on the cached
|
||||
-- `rec.uri` — the buffer's own path has already been rebound by the time
|
||||
-- `resource.renamed` fires.
|
||||
local function failed_projections_under(path)
|
||||
local out = {}
|
||||
for bkey, entry in pairs(failed_attachments) do
|
||||
if entry.path and paths_related(entry.path, path) then
|
||||
out[#out + 1] = bkey
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- How many attributed failures one status line spells out before
|
||||
-- collapsing the rest into a count.
|
||||
local RESOURCE_REPORT_LIMIT = 2
|
||||
|
|
@ -3003,6 +3248,16 @@ end
|
|||
|
||||
pmacs.hook.add("resource.renamed", function(old_path, new_path)
|
||||
if type(old_path) ~= "string" or type(new_path) ~= "string" then return end
|
||||
-- A failed buffer's projection asserts "this buffer's server failed
|
||||
-- for affinity K". After a rename that is no longer known to hold —
|
||||
-- the new path may be in a different project, or none — so it is
|
||||
-- CLEARED rather than re-keyed. Re-keying would assert a failure at a
|
||||
-- location where none was observed. Clearing degrades to "we no longer
|
||||
-- know", which renders as no marker; the next attach re-establishes
|
||||
-- the truth.
|
||||
for _, bkey in ipairs(failed_projections_under(old_path)) do
|
||||
clear_failed_buffer(bkey)
|
||||
end
|
||||
local sink = failure_sink("resource.renamed")
|
||||
for _, hit in ipairs(attachments_under(old_path)) do
|
||||
local key, rec, old_uri = hit.key, hit.rec, hit.rec.uri
|
||||
|
|
@ -3073,6 +3328,11 @@ end)
|
|||
|
||||
pmacs.hook.add("resource.deleted", function(path)
|
||||
if type(path) ~= "string" then return end
|
||||
-- Same disposition as rename, for the stronger reason that the path is
|
||||
-- gone entirely.
|
||||
for _, bkey in ipairs(failed_projections_under(path)) do
|
||||
clear_failed_buffer(bkey)
|
||||
end
|
||||
local sink = failure_sink("resource.deleted")
|
||||
for _, hit in ipairs(attachments_under(path)) do
|
||||
local key, rec = hit.key, hit.rec
|
||||
|
|
|
|||
|
|
@ -62,17 +62,19 @@ lesson, §1 for the two framings).
|
|||
machine-local: `origin` may name this canonical URL, a release mirror,
|
||||
or something else, and therefore has no authority by name alone.
|
||||
- Canonical base at this snapshot:
|
||||
`githubsucks/main` @ `7586905` (the docs-only coherence listview
|
||||
correction #189, atop the docs-only landed-state refresh #185, the M4
|
||||
config-sink race fix #174, bottom-panel Stage 2B-1 #184, the
|
||||
Journey/GPU directory-target ratchet #183, Journey Stage 1a #182 and
|
||||
the previously recorded landed work).
|
||||
`githubsucks/main` @ `fbcf235` (the reap-ledger diagnostic #202, atop
|
||||
the test ambient-root isolation framing #201, the reap-ledger framing
|
||||
#200, the ledger absorption #199, the M5.5 protocol-version pin #198,
|
||||
the docs-only coherence listview correction #189, the docs-only
|
||||
landed-state refresh #185, the M4 config-sink race fix #174,
|
||||
bottom-panel Stage 2B-1 #184, the Journey/GPU directory-target ratchet
|
||||
#183, Journey Stage 1a #182 and the previously recorded landed work).
|
||||
**Protocol schema support is
|
||||
`v6..=v21`; the production server-first `Hello` still advertises
|
||||
v20** — two different facts, and #184 landed only the first. The
|
||||
previous snapshot named `0442d78`, and **the
|
||||
previous snapshot named `7586905`, and **the
|
||||
recovery floor advances with it**: the check below now requires
|
||||
`7586905` or newer, so a tree at `0442d78` no longer passes. That is
|
||||
`fbcf235` or newer, so a tree at `7586905` no longer passes. That is
|
||||
deliberate — the floor moves with the base, because a check that
|
||||
accepts an older commit than the declared base passes on a tree the
|
||||
rest of this file does not describe.
|
||||
|
|
@ -111,7 +113,7 @@ git worktree list
|
|||
git status --short --branch
|
||||
```
|
||||
|
||||
The `git log` command must expose `7586905` — the base named above — or a
|
||||
The `git log` command must expose `fbcf235` — the base named above — or a
|
||||
newer intentional main. Keep this threshold and the canonical-base line in
|
||||
step: a recovery check that accepts an older commit than the base it
|
||||
declares canonical will pass on a tree the rest of this file does not
|
||||
|
|
@ -253,98 +255,72 @@ If it does not, stop and repair the remote/fetch configuration.
|
|||
never been enforced. Any CI job that compiles the `crdt` targets has to
|
||||
fix them first or it will be red on arrival.
|
||||
|
||||
## Journey lane (P1) — STAGE 1a MERGED; STAGE 1b-1 IMPLEMENTED, PR OPEN
|
||||
## Journey lane (P1) — 1a and 1b-1 MERGED; 1b-2 PR OPEN; 1b-3 REMAINS
|
||||
|
||||
- **Branch `journey-stage1b1-compile-defaults`**, worktree
|
||||
`../pmacs-journey-1b1`, based on `githubsucks/main` @ `22df6ab`.
|
||||
`docs/journey-stage1b1-compile-defaults-framing.md` revision 2,
|
||||
approved after one review round (two blocking, two major, all
|
||||
accepted). **Implemented; PR open.**
|
||||
- **ON MERGE OF #203, flip four places to landed.** The PR deliberately
|
||||
ships them as *in flight*, because `COHERENCE.md` §25 says a grade
|
||||
changes only on landed evidence and the PR is open: `COHERENCE.md`
|
||||
§2's step-9 verdict row (Partial → **Works**), §2's post-table
|
||||
keybinding-inversion paragraph (third example answered; the build is
|
||||
no longer its uncontested example), §20 Priority 1 and its arc list (in
|
||||
flight → done), and
|
||||
`docs/agent-handoff.md` §1's arc bullet (IMPLEMENTED → LANDED).
|
||||
**Recorded here because an unowned doc flip is exactly how this
|
||||
ledger's drift starts** — the same rule-4 precondition that kept #176's
|
||||
lane alive past its merge.
|
||||
- **A lexical path expectation is wrong for anything detection touched.**
|
||||
`pmacs.project.detect` canonicalizes before walking
|
||||
(`canonicalize_or_passthrough`, `src/project.rs:509-511`) while the
|
||||
suite's `canon()` is lexical, so the compile-directory assertions
|
||||
passed on Ubuntu and **failed both macOS legs**, where `/var` is a
|
||||
symlink to `/private/var`. Fixed with a `detected_root()` expectation
|
||||
and pinned by a **symlinked fixture**, which reproduces the disagreement
|
||||
on any platform — a Linux-only bite could not have caught it.
|
||||
- **Bites found two vacuous pins of my own.** The nested-project pin
|
||||
passed with the keybinding removed, because `minibuffer.contents()` is
|
||||
`""` both for an empty prefill and for no minibuffer at all — `""`
|
||||
compared with `""`. And the hostile-`defaults` pin called
|
||||
`compile.run` directly, so it never consulted `defaults` and passed
|
||||
with the guard removed. Both now assert their precondition.
|
||||
- **Round 1's blocking finding is the Stage 1a lesson repeating.**
|
||||
Sharing one cwd resolver between the prompt and the run is *not*
|
||||
enough: `pmacs.minibuffer.read` is async, the active window can change
|
||||
while the prompt is open, and `run` re-resolved at accept time — so
|
||||
the prompt could offer `cargo build` for A and execute in B. The
|
||||
interactive command now **captures** `context()` and passes its `cwd`
|
||||
through to `run`, which is `commit_to`'s discipline on a smaller seam.
|
||||
The second blocker was that no pin crossed the accept boundary at all,
|
||||
so that defect passed every proposed pin.
|
||||
- **Why this lane exists.** `COHERENCE.md` §20 Priority 1 names Journey
|
||||
Stage 1b as the golden journey's remainder — "the compile binding +
|
||||
Cargo defaults, LSP spawn guidance, and the welcome buffer" — and it
|
||||
had no branch, no framing, and no lane. It is the only §20 priority
|
||||
with nothing in flight.
|
||||
- **What 1b-1 is.** Journey step 9 only: a global `C-c c` for
|
||||
`compile.run`, and a first prompt prefilled from the detected project
|
||||
kind instead of empty. Lua, tests, and docs; **no Rust change and no
|
||||
protocol change**. 1b-2 (LSP spawn guidance, step 6) and 1b-3 (welcome
|
||||
buffer, step 4) are separate stages — see the framing §7 for why the
|
||||
three are not one PR.
|
||||
- **`ProjectKind::Cargo` does not exist.** `COHERENCE.md` names it twice
|
||||
(§2's step-9 row and §20 Priority 1); the variant is
|
||||
`ProjectKind::Rust` (`src/project.rs:78`) and line 77 is its doc
|
||||
comment. The audit read the comment. Lua matches on the **tag string**
|
||||
`"rust"` that `pmacs.project.detect` returns, so no Rust primitive is
|
||||
needed. The PR corrects both COHERENCE sites.
|
||||
- **The suggestion and the run must share one cwd resolution.** Today
|
||||
the cwd is computed *inside* `pmacs.compile.run`
|
||||
(`builtin/runtime/compile.lua:765`), after the prompt has closed, so a
|
||||
suggestion computed in the command's `fn` would obey a different rule
|
||||
than the run. The stage extracts it and exposes
|
||||
`pmacs.compile.context()`, consumed by both.
|
||||
- **The trap the acceptance is designed around.** The last-resort cwd is
|
||||
`std::env::current_dir()` evaluated at call time
|
||||
(`pmacs-protocol/src/message.rs:1703-1706`), so in tests it is the
|
||||
**test runner's cwd — the pmacs repo root, itself a Cargo project**.
|
||||
`compile_mode_acceptance.rs:1721` already pins this. A pin asserting
|
||||
"no Cargo suggestion" that reaches the fallback would report pmacs's
|
||||
own `Cargo.toml`. Negative pins therefore use a fixture carrying a
|
||||
*different* marker so the fallback is never consulted;
|
||||
`set_search_boundary` does **not** help here, because it only clamps a
|
||||
walk that starts below the boundary.
|
||||
- **Known limitation, deliberately not fixed.** After `pmacs <dir>` the
|
||||
active buffer is dired's and **pathless** (`pmacs.buffer.create` never
|
||||
assigns a path; dired compensates through its own module-local
|
||||
`handle_for_buffer`, `dired.lua:205-217`), so the cwd falls through to
|
||||
the process cwd. Launched from elsewhere that is the wrong directory.
|
||||
The fix is `COHERENCE.md` §8 (First-Class Execution Locations), a
|
||||
model gap; reaching into dired's private table for one string would
|
||||
add an interaction island.
|
||||
**Rewritten, not removed, at #203's merge.** Rule 4 removes a lane when
|
||||
its ARC is done; the journey arc is not — 1b-2 is in flight and 1b-3 is
|
||||
unframed. Stage 1a (#182/#183) and Stage 1b-1 (#203) are on `main` and
|
||||
their durable facts are in `docs/agent-handoff.md` §1, which is rule 4's
|
||||
precondition satisfied rather than deferred.
|
||||
|
||||
**#203's merge obligation is DISCHARGED**: `COHERENCE.md` §2's step-9
|
||||
row now reads **Works**, §2's keybinding-inversion paragraph records all
|
||||
three examples answered (the quote itself deliberately unchanged), §20
|
||||
Priority 1 and its arc list say "landed", and the handoff bullet says
|
||||
LANDED. That flip rides *this* branch rather than a standalone docs PR,
|
||||
because #204 already touches all three files and a separate PR would
|
||||
re-conflict on every merge.
|
||||
|
||||
- **Branch `journey-stage1b2-lsp-guidance`**, worktree
|
||||
`../pmacs-journey-1b2`, based on `githubsucks/main` @ `fbcf235`,
|
||||
**integrated with `main` @ `1f290d5` (#203)**.
|
||||
`docs/journey-stage1b2-lsp-guidance-framing.md` revision 4, three
|
||||
review rounds closed (round 1: two blocking, three major, one minor;
|
||||
round 2: two blocking, two cleanups; round 3: one blocking; all
|
||||
accepted). **Implemented; PR #204 open.**
|
||||
- **What it is.** `COHERENCE.md` §1.2's canonical silence, journey step
|
||||
6: a preconfigured-but-missing language server now reports with
|
||||
guidance, marks the modeline `LSP:!`, and appears in `M-x lsp.status`.
|
||||
- **Half of it was already built and unwired.**
|
||||
`LspManager::status_buffer_text()` and `last_error()` have existed
|
||||
since M4.8, exposed to Lua and tested, with **no production caller**
|
||||
and no `*lsp*` buffer, while several `src/lsp.rs` and `src/project.rs`
|
||||
doc comments refer to that buffer as though it existed.
|
||||
- **The reporting shape was already adopted twice in `lsp.lua` itself**
|
||||
(root resolvers, notification subscribers). The canonical case was
|
||||
silent because nobody had converted it — this finishes an adoption.
|
||||
- **`COHERENCE.md` §1.2's frequency note was wrong, and it decided the
|
||||
design.** `LspManager::spawn` returns early *before* both
|
||||
`status_tracker.ensure` and `clients.insert`, so a failed spawn leaves
|
||||
**no record**, `pmacs.lsp.list()` cannot see it, and the affinity loop
|
||||
re-spawns: the real rate is **once per file open**, not once per
|
||||
project root. Hence **memoize the report, not the failure**.
|
||||
- **The affinity key is `(language, key_uri)`, and `key_uri` is nil for
|
||||
markerless files**, which deliberately share one server per language.
|
||||
Lua cannot index by nil (`t[nil]` raises), so one encoding function
|
||||
serves both tables with a `u`/`n` discriminator no URI can collide
|
||||
with.
|
||||
- **Three tables, three lifetimes**, plus a buffer-keyed projection for
|
||||
the modeline — that provider runs for every window on every paint, so
|
||||
deriving an affinity key inside it would invoke user root resolvers
|
||||
during painting. **A success sweeps every projection sharing the key**,
|
||||
and the projection has its own `pmacs.buffer.on_removed` teardown
|
||||
because nothing existing reaches it (`attachments_under` iterates
|
||||
`attachments`, and a failed buffer has none by construction).
|
||||
- **ON MERGE of #204, flip the step-6 grade.** §2's step-6 row stays
|
||||
**Partial** while the PR is open, per §25's landed-evidence rule, and
|
||||
says so in the row.
|
||||
- **Stage 1b-3 (welcome buffer, step 4) is unframed** — the last of the
|
||||
1b split.
|
||||
- Recovery from a clean checkout — **the two-argument form does not
|
||||
work** (`git worktree add <path> <remote-only-branch>` fails with
|
||||
`fatal: invalid reference`, because after a bare fetch no local branch
|
||||
exists):
|
||||
`fatal: invalid reference`):
|
||||
|
||||
```sh
|
||||
git fetch githubsucks
|
||||
git worktree add ../pmacs-journey-1b1 \
|
||||
-b journey-stage1b1-compile-defaults \
|
||||
githubsucks/journey-stage1b1-compile-defaults
|
||||
git worktree add ../pmacs-journey-1b2 \
|
||||
-b journey-stage1b2-lsp-guidance \
|
||||
githubsucks/journey-stage1b2-lsp-guidance
|
||||
```
|
||||
|
||||
## Generated-buffer immutability lane (Arc: workbench primitives) — STAGE 1 MERGED; STAGE 2 IS NEXT
|
||||
|
|
|
|||
|
|
@ -104,11 +104,9 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
interaction islands added, config-registry adoption, background-work
|
||||
attribution. Its §2 grades the golden journey; **Journey Stage 1a
|
||||
moved that grade off "broken at step 3"** — see the arc bullet below.
|
||||
- **Journey arc (P1) — Stage 1b-1 IMPLEMENTED, PR #203 OPEN**
|
||||
(`docs/journey-stage1b1-compile-defaults-framing.md`). Not landed: per
|
||||
`COHERENCE.md` §25 a grade changes only on landed evidence, so §2's
|
||||
step-9 row still reads **Partial** and flips at merge.
|
||||
Journey step 9 will move **Partial → Works**: `C-c c` runs `compile.run`,
|
||||
- **Journey arc (P1) — Stage 1b-1 LANDED (#203)**
|
||||
(`docs/journey-stage1b1-compile-defaults-framing.md`).
|
||||
Journey step 9 moves **Partial → Works**: `C-c c` runs `compile.run`,
|
||||
and the first prompt is prefilled from the detected project kind via
|
||||
`pmacs.compile.defaults` (seeded `rust = "cargo build"`, extensible
|
||||
from `init.lua`). Lua, tests and docs; no Rust change, no protocol
|
||||
|
|
@ -302,6 +300,45 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
disagree — and it still establishes no identity, because it is read
|
||||
inside the same read-then-act window and no portable mechanism closes
|
||||
that for a *group* (`pidfd` covers a process; macOS has neither).
|
||||
- **Journey arc (P1) — Stage 1b-2 IMPLEMENTED, PR open**
|
||||
(`docs/journey-stage1b2-lsp-guidance-framing.md`, rev 4, three review
|
||||
rounds). `COHERENCE.md` §1.2's canonical silence: a preconfigured
|
||||
language server that is not installed now reports with guidance, marks
|
||||
the modeline `LSP:!`, and appears in `M-x lsp.status`. Per §25 the
|
||||
step-6 grade flips only on merge.
|
||||
- **`status_buffer_text()` had existed since M4.8, exposed to Lua and
|
||||
tested, with no production caller and no `*lsp*` buffer** — several
|
||||
`src/lsp.rs` and `src/project.rs` doc comments referred to that
|
||||
buffer as though it existed. Half the stage was wiring dark matter.
|
||||
- **The reporting shape was already adopted twice in `lsp.lua`** (root
|
||||
resolvers, notification subscribers). The canonical case was silent
|
||||
because nobody had converted it, not for want of a mechanism.
|
||||
- **A failed spawn leaves NO record**: `LspManager::spawn` returns
|
||||
early before both `status_tracker.ensure` and `clients.insert`, so
|
||||
`pmacs.lsp.list()` cannot see it and the affinity loop re-spawns.
|
||||
The failure therefore recurs **once per file open**, not once per
|
||||
project root as COHERENCE recorded. Hence: **memoize the report, not
|
||||
the failure** — the spawn is still retried, so installing the binary
|
||||
mid-session recovers with nothing to invalidate.
|
||||
- **The affinity key is `(language, key_uri)` and `key_uri` is nil for
|
||||
markerless files**, which deliberately share one server per
|
||||
language. Lua cannot index by nil, so one encoding function serves
|
||||
both tables with a `u`/`n` discriminator no URI can collide with.
|
||||
- **Three tables, three lifetimes.** `reported` (never cleared,
|
||||
includes the command so repointing at another missing executable
|
||||
re-reports); `failures` (cleared on a successful spawn for that
|
||||
key); and a **buffer-keyed projection** for the modeline, because
|
||||
that provider runs for every window on every paint and deriving an
|
||||
affinity key inside it would invoke root resolvers during painting.
|
||||
- **A success must SWEEP the projections, not clear one.** Clearing
|
||||
only the succeeding buffer leaves an earlier buffer rendering
|
||||
`LSP:!` while `lsp.status` reports nothing wrong.
|
||||
- **A new per-buffer table needs its own teardown.** Nothing existing
|
||||
reaches it: the LSP resource reconciliation iterates `attachments`,
|
||||
and a failed buffer has none by construction. `pmacs.buffer.on_removed`
|
||||
is registered once per projection; rename and delete **clear** rather
|
||||
than re-key, because after a rename the failure is no longer known to
|
||||
apply at the new location.
|
||||
- **Reap-ledger silent failures — DIAGNOSTIC, in flight**
|
||||
(`docs/reap-ledger-silent-failures-framing.md`). The lane #200's
|
||||
framing §5 parked and its evidence unparked. **Four `kill(2)` results
|
||||
|
|
|
|||
|
|
@ -0,0 +1,781 @@
|
|||
# Journey Stage 1b-2 — say when language intelligence did not start
|
||||
|
||||
**Status: framing, rev 4 — awaiting review round 4.**
|
||||
**Serves `COHERENCE.md` §1.2 (the silence asymmetry), §2 (the golden
|
||||
journey, step 6), §19, §20 Priority 1.**
|
||||
|
||||
## 0. Revision history
|
||||
|
||||
- rev 4 (2026-07-30) — review round 3. One blocking lifecycle gap,
|
||||
accepted; the registry's dispatch order was read before specifying the
|
||||
fix.
|
||||
- **`failed_attachments` had no removal path.** It is keyed by
|
||||
`tostring(buf)` and rev 3 never said who deletes an entry, so killing
|
||||
a failed buffer leaked its projection **for the session**. That also
|
||||
made rev 3's sweep bound — "at most the number of open buffers" —
|
||||
false, since the table could exceed the number of buffers that
|
||||
exist.
|
||||
- **Nothing else would have cleaned it incidentally.** The LSP
|
||||
resource reconciliation iterates `attachments` via
|
||||
`attachments_under` (`lsp.lua:2934-2944`), and a failed buffer has
|
||||
no attachment **by construction** — that is the whole reason the
|
||||
projection exists. So the gap could not be closed by an existing
|
||||
subscriber; it needed its own registration.
|
||||
- Rev 4 adds a per-projection `pmacs.buffer.on_removed` registration
|
||||
(§2.5), a **stated disposition on rename and delete** (§2.6) — clear
|
||||
it, rather than leaving an old-path failure projected onto a changed
|
||||
buffer — an amended sweep bound that now *follows* from the cleanup
|
||||
rather than being asserted beside it, and acceptance rows 14–16.
|
||||
- rev 3 (2026-07-30) — review round 2. Two blocking, two cleanups; all
|
||||
four accepted, and the two blockers verified by running Lua rather
|
||||
than by reading it.
|
||||
- **Recovery was inconsistent across buffers that share an affinity.**
|
||||
Rev 2 cleared `failures[K]` on success but cleared only the
|
||||
*succeeding* buffer's projection. So: buffer A fails, buffer B
|
||||
succeeds for the same `(language, key_uri)` — `M-x lsp.status` says
|
||||
the failure is gone while **A's modeline still reads `LSP:!`**. Rev
|
||||
2's claim that the two tables are "written and cleared at the same
|
||||
moment" was simply false for the cross-buffer case, which is the
|
||||
normal case for a project with more than one file. Each buffer
|
||||
projection now carries its affinity key, and a success **sweeps every
|
||||
projection holding that key** (§2.5). Acceptance 9 pins it.
|
||||
- **The markerless key had no Lua representation.** `key_uri` is
|
||||
deliberately nil, and `t[nil] = v` raises *"table index is nil"* —
|
||||
confirmed by running it, in both LuaJIT and 5.4. So rev 2's central
|
||||
markerless criterion was literally unimplementable as written, and
|
||||
left to implementation would have invited two different ad-hoc
|
||||
encodings for the two tables. §2.2 now prescribes **one** key
|
||||
function, used by both.
|
||||
- **Acceptance 10 could not have observed what it claimed.** Making the
|
||||
command resolvable changes no state by itself; `failures` is cleared
|
||||
by a *successful spawn*, which needs an attach to occur. The pin now
|
||||
reattaches before pressing `g`.
|
||||
- The ledger heading still said revision 1.
|
||||
- rev 2 (2026-07-30) — review round 1. Two blocking, three major, one
|
||||
minor; all six accepted, all six verified in the code first.
|
||||
- **The affinity key was misstated** (§2.2). The runtime's reuse key
|
||||
is `(language, key_uri)`, and `key_uri` is deliberately **nil** for a
|
||||
markerless file — `ensure_server` sets it only when the root came
|
||||
from config or a marker walk (`lsp.lua:644-648`), so loose files
|
||||
across unrelated directories share **one** server per language, on
|
||||
purpose. Rev 1 said "(root, language)", which would have given every
|
||||
directory of loose files its own memo entry and re-reported the same
|
||||
shared failure once per directory. The stage now keys on the real
|
||||
affinity key and does not change that behaviour.
|
||||
- **Dedupe and current-failure state were conflated** (§2.2, §2.4).
|
||||
Rev 1 had one record and said nothing about recovery. One record
|
||||
cannot do both jobs: keep it and `*lsp*` shows a failure that has
|
||||
since been fixed; clear it and the per-session dedupe is lost, so
|
||||
the message returns on the next file open. They are now two records
|
||||
with different lifetimes, and **the reported identity includes the
|
||||
command**, so repointing config from one missing executable to
|
||||
another reports again. Recovery is pinned in both surfaces.
|
||||
- **The modeline's pure per-buffer projection had to be preserved**
|
||||
(§2.5). The provider reads `attachments[ctx.buffer]` specifically so
|
||||
a passive split reports its own buffer, and does no work while
|
||||
painting. Rev 1's "read the failure table" would have made the
|
||||
segment recompute an affinity key — invoking `project_root_for`,
|
||||
user root resolvers, and `pmacs.project.detect` **every frame, for
|
||||
every window**. The failure is now projected per buffer at attach
|
||||
time, and the segment stays a single map lookup.
|
||||
- **"Adopt listview's idiom" was too weak** (§2.3). It now requires
|
||||
`pmacs.listview.open` and names the guarantees that come with it,
|
||||
including `on_refresh` — without which `listview.refresh` early
|
||||
returns (`listview.lua:259`) and `g` is a **dead key** in the new
|
||||
panel.
|
||||
- **§19's journey ratchet was missing from acceptance** (§4). This
|
||||
stage makes step 6 real, and `tests/journey_acceptance.rs`'s stated
|
||||
rule is that steps 6–12 join as later stages make them real. The M4
|
||||
pins stay; an end-to-end journey row is added.
|
||||
- **The ledger's canonical-base anchor was stale** — it named
|
||||
`7586905` while `main` is `fbcf235`. Updated with the recovery
|
||||
floor, which moves with it.
|
||||
- rev 1 (2026-07-30) — first framing. Scouted against `githubsucks/main`
|
||||
@ `fbcf235` (reap-ledger #202).
|
||||
|
||||
## 1. Ground truth
|
||||
|
||||
Everything below was read in the tree at `fbcf235`. Where
|
||||
`COHERENCE.md`'s audit (2026-07-25) is now stale, §1.7 says so.
|
||||
|
||||
### 1.1 The canonical failure is still completely silent
|
||||
|
||||
`ensure_server` (`builtin/runtime/lsp.lua:616`) ends:
|
||||
|
||||
```lua
|
||||
local ok, sid = pcall(pmacs.lsp.spawn, { … })
|
||||
if ok then
|
||||
default_servers[tostring(sid)] = language
|
||||
return sid
|
||||
end
|
||||
return nil
|
||||
```
|
||||
|
||||
`return nil` is the whole error path. No status line, no record, no
|
||||
event. The `buffer.after-load` hook then swallows what is left:
|
||||
|
||||
```lua
|
||||
-- builtin/runtime/lsp.lua:1019-1021
|
||||
pmacs.hook.add("buffer.after-load", function()
|
||||
pcall(attach_buffer, pmacs.window.buffer())
|
||||
end)
|
||||
```
|
||||
|
||||
This is the case a new user hits first: `rust-analyzer` is
|
||||
preconfigured (`lsp.lua:44-52`) and, on most machines, not installed.
|
||||
|
||||
### 1.2 The reporting pattern is already established — in this same file, twice
|
||||
|
||||
The fix is not "invent a channel". `lsp.lua` already does exactly the
|
||||
right thing at two other sites:
|
||||
|
||||
| Site | Failure reported |
|
||||
|---|---|
|
||||
| `lsp.lua:570-585` | a root resolver that raised or returned a bad type |
|
||||
| `lsp.lua:1831-1836` (`report_subscriber_error`) | a notification subscriber that raised |
|
||||
|
||||
Both use the same shape, and both carry the reasoning in comments:
|
||||
|
||||
```lua
|
||||
pcall(pmacs.editor.set_status, msg)
|
||||
if pmacs.error then pcall(pmacs.error, msg) end
|
||||
```
|
||||
|
||||
`pmacs.editor.set_status` is the channel that exists; the `pmacs.error`
|
||||
arm rides along for when that channel is built. **So the canonical case
|
||||
is not silent for want of a mechanism — it is silent because the two
|
||||
sites that adopted the rule were the two that a review happened to
|
||||
touch.** That is worth stating plainly: this stage finishes an adoption,
|
||||
it does not start one.
|
||||
|
||||
### 1.3 The error string does not name the command
|
||||
|
||||
The failure text a caller receives is built at
|
||||
`src/process.rs:2061`:
|
||||
|
||||
```rust
|
||||
let mut child = cmd.spawn().map_err(|e| format!("spawn: {e}"))?;
|
||||
```
|
||||
|
||||
On a missing binary that renders as:
|
||||
|
||||
```
|
||||
spawn: No such file or directory (os error 2)
|
||||
```
|
||||
|
||||
**It names neither the program nor the language.** `std::io::Error` from
|
||||
`Command::spawn` carries no program name, and nothing between there and
|
||||
Lua adds one. So a message built by forwarding the error verbatim would
|
||||
tell a user nothing actionable — the guidance has to be composed at the
|
||||
site that still knows `language` and `cfg.command`.
|
||||
|
||||
The model to imitate is in this repo already, and `COHERENCE.md` §1.2
|
||||
calls it "the best missing-tool message in the codebase"
|
||||
(`src/main.rs:367-379`): it names the sibling path it tried *and* the
|
||||
PATH fallback, so the reader knows what was attempted and where to look.
|
||||
|
||||
### 1.4 There is no negative memo — the failure repeats per file open
|
||||
|
||||
`COHERENCE.md` §1.2's frequency note says the failure "fires **once per
|
||||
project root** rather than once per language per session". **That is not
|
||||
what the code does, and the difference decides this stage's hardest
|
||||
question.**
|
||||
|
||||
`LspManager::spawn` (`src/lsp.rs:1287-1297`) returns early on failure:
|
||||
|
||||
```rust
|
||||
let id = LspServerId::next();
|
||||
let mut client = LspClient::new(spec);
|
||||
self.start_generation(id, &mut client)?; // <-- returns here on ENOENT
|
||||
self.status_tracker.ensure(id, Instant::now());
|
||||
self.clients.insert(id, client);
|
||||
```
|
||||
|
||||
Both the status-tracker entry and the client insert are *after* the `?`.
|
||||
So a failed spawn leaves **nothing** — no client, no status record — and
|
||||
`pmacs.lsp.list()` cannot see it. `ensure_server`'s affinity loop scans
|
||||
exactly that list, finds nothing, and spawns again.
|
||||
|
||||
`attach_buffer` runs from `buffer.after-load`, so the real frequency is
|
||||
**once per file open of a matching language**, which is strictly more
|
||||
often than the audit recorded. A naive "report the failure" would put a
|
||||
status-line message on every single file open in a Rust project.
|
||||
|
||||
**This is why "when to speak" is the design question and not a detail.**
|
||||
|
||||
### 1.5 The modeline cannot distinguish "failed" from "not applicable"
|
||||
|
||||
```lua
|
||||
-- builtin/runtime/lsp.lua:973-983
|
||||
fn = function(ctx)
|
||||
local rec = attachments[tostring(ctx.buffer)]
|
||||
if not rec then return nil end
|
||||
return "LSP:" .. pmacs.lsp.modeline_label(rec.server)
|
||||
end,
|
||||
```
|
||||
|
||||
No attachment record means no segment at all. A `.rs` file whose server
|
||||
failed to spawn and a `.txt` file that never had one render identically,
|
||||
while tree-sitter highlighting keeps working — §1.2's "highlighting
|
||||
**actively masks** the failure".
|
||||
|
||||
### 1.6 The status surface is already built, and has no caller
|
||||
|
||||
**This is dark matter, and it makes half the stage free.**
|
||||
|
||||
- `LspManager::status_buffer_text()` (`src/lsp.rs:1257`) — doc comment:
|
||||
*"render the contents of the `*lsp*` status buffer"*.
|
||||
- `LspManager::last_error(sid)` (`src/lsp.rs:1251`).
|
||||
- Both are exposed to Lua: `pmacs.lsp.status_buffer_text()`
|
||||
(`src/lua_bindings/mod.rs:10949`) and `pmacs.lsp.last_error`
|
||||
(`:10851`), plus per-server `last_error` inside the status table
|
||||
(`:10931-10938`).
|
||||
- Both are **tested** (`tests/m4_acceptance.rs:2545`, `:2634-2640`).
|
||||
|
||||
And there is **no production caller of any of them**, no `*lsp*` buffer,
|
||||
and no interactive command: the twelve `lsp.*` commands
|
||||
(`lsp.lua:2771-2840`) are all feature actions — definition, hover,
|
||||
rename, format — and not one is diagnostic. Several doc comments across
|
||||
`src/lsp.rs` and `src/project.rs` refer to "the `*lsp*` buffer" as
|
||||
though it exists.
|
||||
|
||||
So `COHERENCE.md` §2 step 6's "No LSP status command exists to
|
||||
diagnose" is true, but understates the position in the stage's favour:
|
||||
the renderer exists, is exposed, and is tested. What is missing is a
|
||||
command and a buffer.
|
||||
|
||||
**One thing it will not show, however:** `status_buffer_text` renders
|
||||
from `self.clients`, and §1.4 established that a failed spawn inserts no
|
||||
client. **The durable surface cannot, today, display the very failure
|
||||
this stage exists to surface.** §2.4 decides what to do about that.
|
||||
|
||||
### 1.7 Stale citations in `COHERENCE.md` §1.2
|
||||
|
||||
Recorded so a later scout does not lose time, and corrected by this
|
||||
stage (§6):
|
||||
|
||||
- "`ensure_server` `pcall`s it and returns nil
|
||||
(`builtin/runtime/lsp.lua:614-626`)" — the function starts at `:616`
|
||||
and the spawn/`pcall` is at `:658-674`.
|
||||
- "the `buffer.after-load` hook `pcall`s the whole attach
|
||||
(`builtin/runtime/lsp.lua:895-897`)" — it is `:1019-1021`.
|
||||
- The frequency note is wrong in kind, not just in line number (§1.4).
|
||||
- "**Net user-visible result: nothing**" remains **true** for this
|
||||
failure, but the surrounding claim that no background failure is
|
||||
reported is now false — §1.2 above lists two sites that do.
|
||||
|
||||
## 2. Design
|
||||
|
||||
### 2.1 What to say
|
||||
|
||||
Composed where `language` and `cfg.command` are still in scope, in
|
||||
`src/main.rs`'s spirit — name what was tried, and what to do:
|
||||
|
||||
```
|
||||
LSP: rust-analyzer for rust did not start (spawn: No such file or
|
||||
directory (os error 2)) — install it or set
|
||||
pmacs.lsp.config.rust.command in init.lua. M-x lsp.status for detail.
|
||||
```
|
||||
|
||||
Three parts, each load-bearing:
|
||||
|
||||
1. **What was attempted** — the command and the language. §1.3 shows the
|
||||
underlying error supplies neither.
|
||||
2. **The underlying error, verbatim** — so a permissions failure or a
|
||||
bad interpreter is distinguishable from a missing file. The message
|
||||
must not *classify* the errno; §1.3's string is all we get, and
|
||||
guessing "it is not installed" would be wrong for `EACCES`.
|
||||
3. **The two things a user can do** — install it, or repoint the config
|
||||
— plus the durable surface.
|
||||
|
||||
### 2.2 When to say it — and two records, not one
|
||||
|
||||
Per §1.4 the failure recurs on every file open, so the report is
|
||||
memoized. Two things have to be got right, and rev 1 got both wrong.
|
||||
|
||||
**The key is `ensure_server`'s own affinity key, which is not the
|
||||
root.** `ensure_server` computes:
|
||||
|
||||
```lua
|
||||
-- builtin/runtime/lsp.lua:644-648
|
||||
local root, source = project_root_for(language, path)
|
||||
local key_uri = nil
|
||||
if source == "config" or source == "detected" then
|
||||
key_uri = file_uri_for(root)
|
||||
end
|
||||
```
|
||||
|
||||
`key_uri` is **nil** whenever the root came from the fallback (the
|
||||
file's own directory), and the reuse loop matches `info.root_uri ==
|
||||
key_uri`, nil matching nil. That is deliberate and documented in place:
|
||||
loose files in unrelated directories share **one** server per language,
|
||||
because keying them on their own directories "would give every directory
|
||||
of loose scratch files its own server, for every language".
|
||||
|
||||
So the memo key is `(language, key_uri)` — the same pair, nil included —
|
||||
and **not** the resolved root. Keying on the root would split what the
|
||||
runtime deliberately shares and re-report one shared failure once per
|
||||
directory. This stage does not change that behaviour; it matches it.
|
||||
|
||||
**Two records, because one cannot do both jobs.**
|
||||
|
||||
| Record | Key | Lifetime | Read by |
|
||||
|---|---|---|---|
|
||||
| `reported` | `(language, key_uri, command)` | never cleared, session-scoped | the status-line report only |
|
||||
| `failures` | `(language, key_uri)` | **cleared when a spawn for that key succeeds** | `*lsp*`, and the per-buffer projection |
|
||||
|
||||
**`key_uri` is nil, and Lua cannot index a table by nil.** `t[nil] = v`
|
||||
raises `table index is nil` — verified by running it under both LuaJIT
|
||||
and Lua 5.4, not inferred. Since the markerless case *is* the nil case,
|
||||
the central criterion of §4 acceptance 4 is unimplementable without an
|
||||
encoding, and leaving it to implementation would let the two tables
|
||||
diverge on how they spell it.
|
||||
|
||||
**One key function, used by both tables:**
|
||||
|
||||
```lua
|
||||
-- Lua strings are 8-bit clean (`#"a\0b" == 3`, checked), so \0 is a
|
||||
-- separator no path or language id can contain.
|
||||
--
|
||||
-- The `u`/`n` discriminator is what makes markerless unambiguous: a
|
||||
-- bare `key_uri or ""` would collide with a (pathological but legal)
|
||||
-- empty URI, and a sentinel like "markerless" is a string a URI could
|
||||
-- in principle equal. The prefix cannot collide with anything.
|
||||
local function affinity_key(language, key_uri)
|
||||
return language .. "\0" .. (key_uri and ("u" .. key_uri) or "n")
|
||||
end
|
||||
|
||||
local function reported_key(language, key_uri, command)
|
||||
return affinity_key(language, key_uri) .. "\0" .. tostring(command)
|
||||
end
|
||||
```
|
||||
|
||||
`false` would also be a legal Lua key, but the keys here are tuples, so
|
||||
a single encoded string is what both tables want anyway — and one
|
||||
function is what stops the two encodings drifting apart.
|
||||
|
||||
Rev 1 had one record and said nothing about recovery. One record forces
|
||||
a bad choice: keep it and `*lsp*` reports a failure the user has since
|
||||
fixed; clear it and the dedupe is gone, so the message returns on the
|
||||
next file open.
|
||||
|
||||
**`reported` includes the command.** A user who repoints
|
||||
`pmacs.lsp.config.rust.command` from one missing executable to another
|
||||
has a genuinely new failure and must hear about it; a key without the
|
||||
command would swallow it as a duplicate.
|
||||
|
||||
**Memoize the report, not the failure.** The spawn is still attempted on
|
||||
every file open, so a user who installs the binary mid-session gets a
|
||||
working server with no cache to invalidate — and that success is what
|
||||
clears `failures`. This asymmetry is the whole rule and the easiest
|
||||
thing here to get backwards: memoizing the *failure* would be a
|
||||
behaviour change, would need invalidation, and would make recovery
|
||||
require a restart.
|
||||
|
||||
### 2.3 Where — status line now, `*lsp*` for later
|
||||
|
||||
The status line is transient and can be overwritten before it is read;
|
||||
COHERENCE's rule is that an automatic failure must leave a **trace**,
|
||||
not a flash. So the same event also lands in `failures`, which
|
||||
`M-x lsp.status` renders.
|
||||
|
||||
**`*lsp*` is opened with `pmacs.listview.open`, not merely "in its
|
||||
idiom".** Naming the primitive is what buys the guarantees, and a
|
||||
hand-rolled panel would have to re-derive every one of them:
|
||||
|
||||
- **Owned-handle identity.** Found-by-name is *not* adoption
|
||||
(`listview.lua:157-167`): a foreign buffer already called `*lsp*` is
|
||||
never claimed, clobbered, or given an erroring intercept.
|
||||
- **Collision behaviour.** A taken name disambiguates `*lsp*<2>`…`<99>`
|
||||
and **raises** when exhausted rather than adopting.
|
||||
- **Immutable generated contents** — a read-only intercept with a named
|
||||
error, plus round-trip input so a semantic frontend cannot swallow the
|
||||
panel's single-key bindings.
|
||||
- **`q`** quits to the previous buffer.
|
||||
- **`on_refresh`, which is not optional here.** `listview.refresh`
|
||||
early-returns unless the panel has one (`listview.lua:259`), so
|
||||
omitting it makes **`g` a dead key** — a bound chord that silently
|
||||
does nothing. The panel's `on_refresh` recomputes *both* sections, so
|
||||
`g` after installing a server shows the recovery.
|
||||
|
||||
`status_buffer_text()` returns one string; the panel splits it into
|
||||
inert rows (no `on_visit`) beneath the failure section. Rows without a
|
||||
visit action are an ordinary listview shape, not a special case.
|
||||
|
||||
### 2.4 The durable record lives in Lua, and that is a deliberate limit
|
||||
|
||||
§1.6 established that `status_buffer_text` renders from `self.clients`,
|
||||
which a failed spawn never enters. Two ways to fix that:
|
||||
|
||||
- **Record failures in Rust** (`status_tracker`), so `*lsp*` shows them
|
||||
natively. Correct long-term, but it changes what a "server" is in the
|
||||
status model, touches typed state several consumers read, and turns a
|
||||
Lua stage into a Rust one.
|
||||
- **Record failures in Lua**, in this module, and have `lsp.status`
|
||||
render them as a section *above* `status_buffer_text()`'s output.
|
||||
|
||||
**This stage takes the second**, for the same reason 1b-1 kept its
|
||||
defaults in a Lua table: it is the smallest thing that makes the failure
|
||||
visible, and it does not commit the Rust status model to a shape before
|
||||
anyone has used the surface. The limitation is explicit — `*lsp*`'s
|
||||
native section still lists only servers that started — and §5 names
|
||||
promoting it as follow-on work rather than pretending it is done.
|
||||
|
||||
### 2.5 The modeline marker
|
||||
|
||||
§1.5 is the sharpest half of §1.2: highlighting masks the failure, so a
|
||||
user has no reason to *go looking* for a command. The segment therefore
|
||||
gains one branch — when the buffer has a recorded failure and no
|
||||
attachment, render a distinct label:
|
||||
|
||||
```
|
||||
LSP:! (vs LSP:ok / LSP:… / nothing at all)
|
||||
```
|
||||
|
||||
**The segment must stay a pure per-buffer projection.** Its comment says
|
||||
so — *"pure modeline projection … reads the private per-buffer
|
||||
attachment map directly so passive split windows report their own buffer
|
||||
… never attaches, flushes didChange, or issues a request"*
|
||||
(`lsp.lua:969-983`) — and the reason is structural, not stylistic: it
|
||||
runs for **every window, every paint**.
|
||||
|
||||
Rev 1 said the segment should "read the failure table", which is keyed
|
||||
by `(language, key_uri)`. Deriving that key from a buffer means calling
|
||||
`project_root_for`, which invokes **user-supplied root resolvers** and
|
||||
`pmacs.project.detect`. Per frame, per window. A resolver with a side
|
||||
effect, or one that raises, would then run inside painting.
|
||||
|
||||
So the failure is **projected per buffer at attach time**, beside the
|
||||
existing map, and **each projection carries the affinity key that
|
||||
produced it**:
|
||||
|
||||
```lua
|
||||
failed_attachments[tostring(buf)] = {
|
||||
key = affinity_key(language, key_uri), -- §2.2
|
||||
language = …,
|
||||
command = …,
|
||||
}
|
||||
```
|
||||
|
||||
**Clearing has to sweep, not touch one entry.** Rev 2 said the two
|
||||
tables were "written and cleared at the same moment"; that is false as
|
||||
soon as two buffers share an affinity, which is the normal case for a
|
||||
project with more than one file:
|
||||
|
||||
> A `.rs` file fails, so `failures[K]` and `failed_attachments[A]` are
|
||||
> both written. The user installs the server and opens a *second* `.rs`
|
||||
> file in the same project. That spawn succeeds for the same `K`, so
|
||||
> `failures[K]` is cleared and `*lsp*` reports nothing wrong — while
|
||||
> **A's modeline still reads `LSP:!`**, because only B's projection was
|
||||
> touched. The two surfaces now contradict each other, and the stale one
|
||||
> is the one the user is looking at.
|
||||
|
||||
So a success for key `K` clears `failures[K]` **and every projection
|
||||
whose `key == K`**. The sweep runs on a spawn success, never on a paint.
|
||||
|
||||
**The sweep's bound is a consequence of the cleanup below, not a
|
||||
separate claim.** Rev 3 asserted the table holds "at most the number of
|
||||
open buffers" while specifying no deletion path, which made the bound
|
||||
false: a killed buffer's projection would have outlived it, for the
|
||||
session. `failed_attachments` is bounded by the live buffer set **only
|
||||
because §2.5.1 removes an entry when its buffer goes away.**
|
||||
|
||||
#### 2.5.1 Removal — the entry has to have an owner
|
||||
|
||||
`pmacs.buffer.on_removed(buf, fn)` is registered **when a projection is
|
||||
created**, and its handle is stored in the projection:
|
||||
|
||||
```lua
|
||||
failed_attachments[key] = {
|
||||
key = affinity_key(language, key_uri),
|
||||
language = …, command = …,
|
||||
on_removed = pmacs.buffer.on_removed(buf, function() … end),
|
||||
}
|
||||
```
|
||||
|
||||
Three rules, each with a reason:
|
||||
|
||||
- **Register on creation, never on refresh.** `attach_buffer` is
|
||||
reachable more than once for the same buffer, so registering per
|
||||
failed *attempt* would stack callbacks on one buffer — the
|
||||
unbounded-registrar shape, with the leak simply moved.
|
||||
- **The removal callback clears the projection and does not call
|
||||
`handle:remove()`.** Dispatch does `callbacks.take(id)` and then
|
||||
iterates a local vector (`src/lua_bindings/mod.rs:1949-1957`), so the
|
||||
entry is already gone; removing from inside would be a no-op at best.
|
||||
(It is also why this is *not* a mutation-during-iteration hazard — the
|
||||
registry hands out an owned list before invoking anything.)
|
||||
- **The success sweep *does* call `handle:remove()`.** There the buffer
|
||||
is still alive, so an unreleased callback would linger for its whole
|
||||
lifetime and fire against a projection that no longer exists.
|
||||
|
||||
**Nothing existing would have done this for us.** The LSP resource
|
||||
reconciliation finds work through `attachments_under`
|
||||
(`lsp.lua:2934-2944`), which iterates `attachments` — and a failed
|
||||
buffer has no attachment by construction. The projection is invisible to
|
||||
every current cleanup path, which is exactly why it needs its own.
|
||||
|
||||
#### 2.6 Rename and delete — clear, do not re-key
|
||||
|
||||
`resource.renamed` and `resource.deleted` already have LSP subscribers
|
||||
(`lsp.lua:3004`, `:3074`), and they reconcile `attachments`. A failed
|
||||
buffer is not in that table, so those subscribers must **also** dispose
|
||||
of its projection.
|
||||
|
||||
**The disposition is to clear it.** The projection asserts one thing:
|
||||
*this buffer's server failed for affinity K*. After a rename, that claim
|
||||
is no longer known to hold — the new path may sit in a different
|
||||
project, under a different root, or under none. Re-keying would assert a
|
||||
failure at a location where none has been observed, which is the same
|
||||
error shape this arc has been correcting all along: concluding something
|
||||
about one entity from evidence about another.
|
||||
|
||||
Clearing degrades to "we no longer know", which renders as no modeline
|
||||
marker — the pre-stage behaviour, not a regression. The next attach for
|
||||
that buffer re-establishes the truth, and if it fails again the report
|
||||
memo (`reported`, §2.2) correctly treats the *new* affinity as a new
|
||||
failure worth naming.
|
||||
|
||||
Delete is the same, for the stronger reason that the path is gone.
|
||||
|
||||
**What must not happen** is the third option: leaving the old-path
|
||||
projection attached to a buffer whose path has changed, so the modeline
|
||||
reports `LSP:!` for a location the buffer no longer has. Acceptance 15
|
||||
and 16 pin the chosen behaviour rather than merely the absence of that
|
||||
one.
|
||||
|
||||
The segment becomes one more map lookup and computes nothing:
|
||||
|
||||
```lua
|
||||
local rec = attachments[key]
|
||||
if rec then return "LSP:" .. pmacs.lsp.modeline_label(rec.server) end
|
||||
if failed_attachments[key] then return "LSP:!" end
|
||||
return nil
|
||||
```
|
||||
|
||||
**It must not fabricate an attachment record.** `attachments` is read by
|
||||
`attachment_for_request`, the completion driver, and every request path,
|
||||
all of which treat a record as naming a live server; inventing one would
|
||||
route requests at a server that does not exist. The failure projection is
|
||||
a **separate** table for exactly that reason.
|
||||
|
||||
Two tables with two readers: `failures` (affinity-keyed, for `*lsp*`)
|
||||
and `failed_attachments` (buffer-keyed, carrying its affinity key, for
|
||||
the modeline). Neither can serve the other's reader without doing work
|
||||
in the wrong place — and, per the sweep above, they are cleared by the
|
||||
same *event* but not by the same *touch*.
|
||||
|
||||
### 2.6 What this stage does not do
|
||||
|
||||
- It does not classify errnos into causes (§2.1).
|
||||
- It does not add a retry, back-off, or auto-install.
|
||||
- It does not touch `pmacs.error`. Fifteen guarded call sites still
|
||||
report through a channel that does not exist; building it is its own
|
||||
lane (§5). This stage adds a sixteenth *only* in the ride-along form
|
||||
the two existing sites already use, so it upgrades for free and works
|
||||
today.
|
||||
- It does not surface `LspEventKind::Crashed`. A server that started and
|
||||
then died is a different failure with a different message, and no
|
||||
builtin subscriber handles it today. Named in §5.
|
||||
|
||||
## 3. Questions
|
||||
|
||||
- **Q#L1 — is per-session the right memo lifetime?** A user who
|
||||
uninstalls a server mid-session gets no second message. The
|
||||
alternative — re-report after N minutes — adds a clock to a path that
|
||||
has none. Recommended: per-session, revisit if it is ever a complaint.
|
||||
- **Q#L2 — should the message name `init.lua` explicitly?** It assumes
|
||||
the user has one; a user with no config has nothing to edit. The
|
||||
counter is that naming the file is what makes the advice actionable,
|
||||
and `pmacs.config` docs already assume it.
|
||||
- **Q#L3 — should `lsp.status` get a keybinding?** §20 Priority 4 is
|
||||
about exactly this class of command, and binding one diagnostic
|
||||
command ahead of that arc invites the inversion §2 warns about. This
|
||||
framing says **no binding**, reachable by `M-x`, and lets the
|
||||
discovery arc bind the family coherently.
|
||||
- **Q#L4 — does `LSP:!` belong in the modeline, or is it noise for a
|
||||
user who has deliberately not installed a server?** The case against
|
||||
§2.5. A user who never wants rust-analyzer sees `!` forever with no
|
||||
way to dismiss it short of clearing the config.
|
||||
|
||||
## 4. Acceptance
|
||||
|
||||
Labels per Stage 1a §6.0: **N** new behaviour, must fail on full
|
||||
revert; **P** preservation, falsified by a named mutation.
|
||||
|
||||
**The journey row comes first**, because it is the one that says the
|
||||
stage worked.
|
||||
|
||||
1. **N — journey step 6, end to end** (`tests/journey_acceptance.rs`).
|
||||
That file's rule is that steps 6–12 join as later stages make them
|
||||
real, and 1b-1 added step 9 the same way. Launch on a project whose
|
||||
configured server command does not exist, open a source file through
|
||||
the real dired `RET`, and assert the user is **told**: the status
|
||||
line names the command and the modeline reads `LSP:!` rather than
|
||||
nothing. This is the ratchet row; the M4-level pins below stay and
|
||||
cover the mechanism.
|
||||
2. **N — the failure is reported, through the real path.** The status
|
||||
line names the command, the language, and the underlying error.
|
||||
Driven through `buffer.after-load`, not by calling `ensure_server`
|
||||
directly — the hook's `pcall` is part of what is being tested.
|
||||
3. **N — reported once per `(language, key_uri, command)`.** Open a
|
||||
second file **in the same project** (same detected root, so the same
|
||||
non-nil `key_uri`): the spawn is attempted again — observable, and
|
||||
asserted — and the message is not repeated. Falsified by dropping the
|
||||
memo.
|
||||
4. **N — the markerless case shares one memo, as it shares one server.**
|
||||
Two loose files in *different* directories, neither under a project
|
||||
marker, both resolve `key_uri = nil` and report **once** between them.
|
||||
Falsified by keying the memo on the resolved root, which is rev 1's
|
||||
design: that reports twice. This pin exists because the root and the
|
||||
affinity key differ exactly here.
|
||||
5. **N — a different language, or a genuinely different root, reports
|
||||
again.** Falsified by keying on the language alone, and by keying on
|
||||
the language plus a constant.
|
||||
6. **N — a changed command reports again.** Repoint the config from one
|
||||
missing executable to another and open a file: a second message,
|
||||
naming the new command. Falsified by dropping `command` from the
|
||||
reported identity.
|
||||
7. **N — the memo is on the report, not the failure.** After a failed
|
||||
attach, make the command resolvable and open another file: a server
|
||||
attaches. Falsified by memoizing the failure instead — §2.2's whole
|
||||
claim, and it needs its own pin.
|
||||
8. **N — recovery clears both surfaces.** Continuing from 7 in the same
|
||||
session: the modeline for the recovered buffer reads its live label,
|
||||
and `M-x lsp.status` no longer lists that failure. Falsified by never
|
||||
clearing `failures` / `failed_attachments`, which is what one record
|
||||
would have forced. **Asserted in both surfaces**, because they read
|
||||
different tables (§2.5).
|
||||
9. **N — recovery reaches every buffer sharing the affinity.** Buffer A
|
||||
fails; the command becomes resolvable; buffer B in the **same
|
||||
project** attaches successfully. Assert **A's** modeline no longer
|
||||
reads `LSP:!` — not B's. Falsified by clearing only the succeeding
|
||||
buffer's projection, which is rev 2's design and leaves `*lsp*` and
|
||||
A's modeline contradicting each other.
|
||||
*This pin is the reason the projection carries its affinity key at
|
||||
all*, so it must assert on A: a version that checks B passes on the
|
||||
broken implementation.
|
||||
10. **N — `M-x lsp.status` renders a buffer** containing both the
|
||||
failure section and `status_buffer_text()`'s output. Asserts
|
||||
**content produced**, not that a buffer exists.
|
||||
11. **N — `g` refreshes the panel.** With the panel open: make the
|
||||
command resolvable **and then reattach** — open a file in the
|
||||
project so a spawn actually succeeds — then press `g`, and the
|
||||
failure section is gone. **The reattach is load-bearing**: making
|
||||
the command resolvable changes no state by itself, since `failures`
|
||||
is cleared by a successful spawn, so a version of this pin that
|
||||
only edits the config and presses `g` would assert nothing about
|
||||
refresh. Falsified by omitting `on_refresh`, which makes
|
||||
`listview.refresh` early-return (`listview.lua:259`) and leaves the
|
||||
panel stale while `g` appears bound.
|
||||
12. **N — a foreign `*lsp*` buffer is not adopted.** Create a buffer
|
||||
named `*lsp*` with user content, then run `lsp.status`: the user's
|
||||
bytes are untouched and the panel opens as `*lsp*<2>`. This is
|
||||
`listview.open`'s guarantee, and it is pinned here rather than
|
||||
assumed because "found by name is not adoption" is precisely the
|
||||
rule a hand-rolled panel loses.
|
||||
13. **N — the modeline distinguishes failed from not-applicable.** A
|
||||
source file with a failed spawn renders `LSP:!`; a plain-text buffer
|
||||
renders nothing. **Both halves asserted** — a pin that checks only
|
||||
the failing case passes if the segment renders `!` unconditionally.
|
||||
14. **N — a killed buffer's projection is removed.** Fail an attach,
|
||||
then kill the buffer; the projection is gone. Observable without
|
||||
reaching into module internals: open a *new* buffer for the same
|
||||
path, and assert the sweep on a later success still terminates and
|
||||
that no `LSP:!` is attributed to a buffer that never failed.
|
||||
Falsified by omitting the `on_removed` registration, which is rev
|
||||
3's state — the entry then outlives its buffer for the session.
|
||||
*This is the pin that makes §2.5's bound true rather than asserted;*
|
||||
without cleanup the table is bounded by nothing.
|
||||
15. **N — a rename clears the projection.** Fail an attach, then rename
|
||||
the file through the real `resource.renamed` path. The modeline no
|
||||
longer reads `LSP:!` for that buffer. Falsified by leaving the
|
||||
projection keyed to the old affinity, which would report a failure
|
||||
for a path the buffer no longer has.
|
||||
16. **N — a delete clears the projection.** Same shape through
|
||||
`resource.deleted`. Falsified the same way.
|
||||
*Both 15 and 16 assert the chosen behaviour, not merely the absence
|
||||
of the forbidden one:* a pin that only checked "does not show a
|
||||
stale path" would pass on an implementation that cleared nothing and
|
||||
happened to render nothing for an unrelated reason.
|
||||
17. **P — the segment does no work.** With a failure recorded, painting
|
||||
the modeline invokes neither a root resolver nor
|
||||
`pmacs.project.detect`. Pinned with a counting resolver installed
|
||||
through the real config; assert the count is unchanged across
|
||||
repeated renders. Targeted mutation: rev 1's design, which derives
|
||||
the affinity key inside the provider.
|
||||
18. **P — a working server is unaffected.** Attach, modeline label,
|
||||
requests: unchanged. Targeted mutation: making the new branch fire
|
||||
whenever an attachment is absent, which would mark every plain-text
|
||||
buffer failed.
|
||||
19. **P — the two existing report sites still report.** Root-resolver
|
||||
and subscriber failures keep their messages. Targeted mutation:
|
||||
refactoring the three sites onto a shared helper that drops one.
|
||||
20. **P — a fabricated attachment is never created.** After a failed
|
||||
spawn, `attachment_for_request` returns nil and no request is
|
||||
issued. Targeted mutation: §2.5's forbidden implementation.
|
||||
|
||||
**Fixture note.** The natural fixture points a config at a path that
|
||||
does not exist, which is reliable and hermetic. It must assert its own
|
||||
precondition — that the command really is absent — because a fixture
|
||||
that accidentally names a real binary would make every absence
|
||||
assertion vacuous. That is the shape that bit both #202's in-drain pin
|
||||
and 1b-1's nested-project pin.
|
||||
|
||||
**Ambient-root note.** These tests construct an editor, so they need the
|
||||
five bootstrap-storage variables controlled locally (#201 is framing
|
||||
only). A developer with a real `rust-analyzer` on PATH must not change
|
||||
the result — hence a configured non-existent command rather than
|
||||
relying on rust-analyzer's absence.
|
||||
|
||||
## 5. Deferred, named rather than implied
|
||||
|
||||
- **Building `pmacs.error`.** Fifteen dead call sites; its own lane.
|
||||
- **Promoting the failure record into Rust's status model**, so `*lsp*`
|
||||
shows failed spawns natively (§2.4).
|
||||
- **Surfacing `LspEventKind::Crashed`** — a started-then-died server
|
||||
(§2.6).
|
||||
- **A keybinding for `lsp.status`**, which belongs to §20 Priority 4's
|
||||
discovery family (Q#L3).
|
||||
- **Journey Stage 1b-3**, the welcome buffer (step 4).
|
||||
|
||||
## 6. Coherence impact
|
||||
|
||||
- **Journey steps touched:** 6 (Partial → Works for the failure case;
|
||||
the success case is already fine). Indirectly 10, which is gated on 6
|
||||
or 9 succeeding. 9 was closed by 1b-1. **The ratchet gains a step-6
|
||||
row** (§4 acceptance 1) — `tests/journey_acceptance.rs` states that
|
||||
steps 6–12 join as later stages make them real, so a stage that makes
|
||||
one real and adds no row leaves the ratchet describing a journey the
|
||||
editor has outgrown.
|
||||
- **Interaction islands: none added.** The message uses the existing
|
||||
status line; `*lsp*` is opened through **`pmacs.listview.open`**
|
||||
(§2.3), the primitive `COHERENCE.md` §14 records as the proven
|
||||
listview/panel shape — not a third hand-rolled read-only buffer. §14's
|
||||
complaint is precisely that each new panel re-invented ownership,
|
||||
read-only enforcement, and quit behaviour; naming the primitive is how
|
||||
this stage avoids being the next example.
|
||||
- **Config registry:** not adopted. Nothing here is a user-tunable
|
||||
scalar; the memo is session state, not configuration.
|
||||
- **Background-work attribution:** this *is* the attribution fix for one
|
||||
failure — §1.2's rule that anything failing automatically must leave a
|
||||
user-visible trace with a named owner.
|
||||
- **Doc updates riding this PR:** `COHERENCE.md` §1.2's stale citations
|
||||
and frequency note (§1.7), its step-6 verdict row, §20 Priority 1's
|
||||
remainder line, §24; `docs/keybindings.md` if Q#L3 is overturned;
|
||||
`docs/agent-handoff.md` §1; the ledger.
|
||||
|
||||
## 7. Ledger
|
||||
|
||||
Branch `journey-stage1b2-lsp-guidance`, worktree `../pmacs-journey-1b2`,
|
||||
based on `githubsucks/main` @ `fbcf235`. Framing only; no code, no PR.
|
||||
|
||||
Recovery from a clean checkout — the two-argument form of
|
||||
`git worktree add` does not work for a remote-only branch:
|
||||
|
||||
```sh
|
||||
git fetch githubsucks
|
||||
git worktree add ../pmacs-journey-1b2 \
|
||||
-b journey-stage1b2-lsp-guidance \
|
||||
githubsucks/journey-stage1b2-lsp-guidance
|
||||
```
|
||||
|
|
@ -1742,3 +1742,90 @@ fn journey_step9_the_compile_directory_is_detection_canonical() {
|
|||
"the header must name the directory detection resolved to;\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 6 — receive language intelligence (Journey Stage 1b-2)
|
||||
//
|
||||
// `COHERENCE.md` §2 graded this **Partial**: a preconfigured server that
|
||||
// is not installed failed silently, and working tree-sitter highlighting
|
||||
// masked it. This row pins the failure being *told*, end to end, through
|
||||
// the same walk a user takes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The `lsp` modeline segment for the active buffer, found by face —
|
||||
/// `EvaluatedStatuslineSegment` carries `provider_id`, not the
|
||||
/// registration's name.
|
||||
fn lsp_segment(s: &EditorState) -> Option<String> {
|
||||
let outcome = pmacs::statusline::evaluate_statusline(
|
||||
s.lua_host.lua(),
|
||||
&s.core,
|
||||
&s.statusline_registry,
|
||||
pmacs::statusline::StatuslineEvaluationTarget::Grid {
|
||||
frontend_id: FrontendId::LOCAL,
|
||||
},
|
||||
);
|
||||
let pmacs::statusline::StatuslineEvaluationOutcome::Ready(windows) = outcome.outcome else {
|
||||
return None;
|
||||
};
|
||||
windows
|
||||
.into_iter()
|
||||
.flat_map(|w| w.right)
|
||||
.find(|seg| seg.face == "ui.modeline.lsp")
|
||||
.map(|seg| seg.text)
|
||||
}
|
||||
|
||||
/// **N** — step 6: when language intelligence cannot start, the user is
|
||||
/// told, on the path `pmacs .` actually takes.
|
||||
///
|
||||
/// The configured command is one that cannot exist, so a developer with
|
||||
/// `rust-analyzer` installed gets the same result as CI — and the
|
||||
/// fixture asserts that precondition, or every assertion here would be
|
||||
/// vacuous.
|
||||
#[test]
|
||||
fn journey_step6_a_missing_language_server_is_reported_not_swallowed() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").expect("write toml");
|
||||
std::fs::write(td.path().join("main.rs"), b"fn main() {}\n").expect("write rs");
|
||||
|
||||
let absent = td.path().join("no-such-bin").join("rust-analyzer");
|
||||
assert!(
|
||||
!absent.exists(),
|
||||
"fixture precondition: the configured server must not exist"
|
||||
);
|
||||
|
||||
// Launch as `pmacs .` does — this lists the directory in dired.
|
||||
let mut s = launch(td.path());
|
||||
// `launch` clears `pmacs.lsp.config`, so configure after it.
|
||||
exec(
|
||||
&s,
|
||||
&format!(
|
||||
"pmacs.project.set_search_boundary({:?})
|
||||
pmacs.lsp.config.rust = {{ command = {:?} }}",
|
||||
td.path().display().to_string(),
|
||||
absent.display().to_string()
|
||||
),
|
||||
);
|
||||
|
||||
// Visit the source file with the real key, as step 5 does.
|
||||
let line = line_of(&s, "main.rs");
|
||||
exec(&s, &format!("pmacs.editor.move_to_line({line})"));
|
||||
press(&mut s, KeyCode::Enter);
|
||||
pump(&mut s);
|
||||
assert_eq!(
|
||||
active_name(&s),
|
||||
td.path().join("main.rs").display().to_string(),
|
||||
"precondition: the walk must actually open the source file"
|
||||
);
|
||||
|
||||
let msg = status(&s);
|
||||
assert!(
|
||||
msg.contains(&absent.display().to_string()),
|
||||
"the user is told which command did not start; got {msg:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
lsp_segment(&s).as_deref(),
|
||||
Some("LSP:!"),
|
||||
"and the modeline says so, rather than rendering nothing — which \
|
||||
is what made highlighting able to mask this"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,567 @@
|
|||
// tests/lsp_spawn_guidance_acceptance.rs --- journey Stage 1b-2.
|
||||
|
||||
//! `COHERENCE.md` §1.2's canonical silence: a preconfigured language
|
||||
//! server that is not installed used to fail with no status message, no
|
||||
//! record, and no modeline marker, while tree-sitter highlighting kept
|
||||
//! working and masked it.
|
||||
//!
|
||||
//! Pins are labelled **N** (new behaviour, must fail on full revert) or
|
||||
//! **P** (preservation, falsified by a named targeted mutation), per
|
||||
//! `docs/journey-stage1b2-lsp-guidance-framing.md` §4.
|
||||
//!
|
||||
//! **Every fixture points the config at a path that does not exist**,
|
||||
//! rather than relying on a real server's absence — a developer with
|
||||
//! `rust-analyzer` installed must get the same result as CI. Each
|
||||
//! fixture asserts that precondition, because a fixture that
|
||||
//! accidentally named a real binary would make every absence assertion
|
||||
//! here vacuous.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::FrontendId;
|
||||
use pmacs::statusline::{
|
||||
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
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 status(state: &EditorState) -> String {
|
||||
state.core.borrow().status.clone()
|
||||
}
|
||||
|
||||
fn lua_str(s: &str) -> String {
|
||||
format!("{s:?}")
|
||||
}
|
||||
|
||||
/// A command path that cannot exist. Asserted, not assumed: if this ever
|
||||
/// resolved, every "did not start" assertion below would be vacuous.
|
||||
fn absent_command(dir: &Path, name: &str) -> String {
|
||||
let p = dir.join("no-such-bin").join(name);
|
||||
assert!(
|
||||
!p.exists(),
|
||||
"fixture precondition: {} must not exist",
|
||||
p.display()
|
||||
);
|
||||
p.display().to_string()
|
||||
}
|
||||
|
||||
/// Point the default `rust` server at `command`.
|
||||
fn configure_rust(state: &EditorState, command: &str) {
|
||||
exec(
|
||||
state,
|
||||
&format!(
|
||||
"pmacs.lsp.config.rust = {{ command = {} }}",
|
||||
lua_str(command)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A Cargo project, so `key_uri` is non-nil (root detected by marker).
|
||||
fn cargo_project() -> TempDir {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").expect("write");
|
||||
td
|
||||
}
|
||||
|
||||
fn write_rs(dir: &Path, name: &str) -> PathBuf {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, b"fn main() {}\n").expect("write rs");
|
||||
p
|
||||
}
|
||||
|
||||
/// Open a file the way `buffer.after-load` sees it.
|
||||
fn open(state: &EditorState, path: &Path) {
|
||||
exec(
|
||||
state,
|
||||
&format!(
|
||||
"pmacs.buffer.find_or_open({})",
|
||||
lua_str(&path.display().to_string())
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn editor_for(dir: &Path) -> EditorState {
|
||||
let state = EditorState::new();
|
||||
// Clamp detection so a stray marker above the tempdir cannot leak in.
|
||||
exec(
|
||||
&state,
|
||||
&format!(
|
||||
"pmacs.project.set_search_boundary({})",
|
||||
lua_str(&dir.display().to_string())
|
||||
),
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
/// The `lsp` modeline segment's text for the active buffer, or `None`.
|
||||
fn lsp_segment(state: &EditorState) -> Option<String> {
|
||||
let outcome = evaluate_statusline(
|
||||
state.lua_host.lua(),
|
||||
&state.core,
|
||||
&state.statusline_registry,
|
||||
StatuslineEvaluationTarget::Grid {
|
||||
frontend_id: FrontendId::LOCAL,
|
||||
},
|
||||
);
|
||||
let StatuslineEvaluationOutcome::Ready(windows) = outcome.outcome else {
|
||||
return None;
|
||||
};
|
||||
// Found by face rather than by name: `EvaluatedStatuslineSegment`
|
||||
// carries `provider_id`, not the registration's name, and the face
|
||||
// is the stable public identity of this segment.
|
||||
windows
|
||||
.into_iter()
|
||||
.flat_map(|w| w.right)
|
||||
.find(|s| s.face == "ui.modeline.lsp")
|
||||
.map(|s| s.text)
|
||||
}
|
||||
|
||||
fn clear_status(state: &EditorState) {
|
||||
exec(state, "pmacs.editor.set_status('')");
|
||||
}
|
||||
|
||||
fn failure_count(state: &EditorState) -> i64 {
|
||||
eval(state, "return #pmacs.lsp.spawn_failures()")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **N** (framing acceptance 2) — the failure is reported, through the
|
||||
/// real `buffer.after-load` path, naming the command, the language and
|
||||
/// the underlying error.
|
||||
#[test]
|
||||
fn j1b2_a_missing_server_is_reported_with_guidance() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
let cmd = absent_command(td.path(), "rust-analyzer");
|
||||
configure_rust(&state, &cmd);
|
||||
open(&state, &write_rs(td.path(), "a.rs"));
|
||||
|
||||
let msg = status(&state);
|
||||
assert!(msg.contains(&cmd), "names the command; got {msg:?}");
|
||||
assert!(msg.contains("rust"), "names the language; got {msg:?}");
|
||||
assert!(
|
||||
msg.contains("No such file") || msg.contains("os error 2"),
|
||||
"passes the underlying error through; got {msg:?}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("init.lua"),
|
||||
"says what the user can do; got {msg:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** (3) — reported once per `(language, key_uri, command)`. The
|
||||
/// spawn is still attempted on the second open; only the message is
|
||||
/// suppressed.
|
||||
#[test]
|
||||
fn j1b2_a_repeat_failure_in_the_same_project_is_not_reannounced() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
open(&state, &write_rs(td.path(), "a.rs"));
|
||||
assert!(!status(&state).is_empty(), "first open reports");
|
||||
|
||||
clear_status(&state);
|
||||
open(&state, &write_rs(td.path(), "b.rs"));
|
||||
assert_eq!(
|
||||
status(&state),
|
||||
"",
|
||||
"a second file in the same project must not re-announce"
|
||||
);
|
||||
// The failure is still current — the memo is on the report, not the
|
||||
// failure — so the surface still knows about it.
|
||||
assert_eq!(failure_count(&state), 1);
|
||||
}
|
||||
|
||||
/// **N** (4) — the markerless case shares one memo, because it shares
|
||||
/// one server. Two loose files in *different* directories both resolve
|
||||
/// `key_uri = nil`.
|
||||
///
|
||||
/// Falsified by keying the memo on the resolved root, which reports
|
||||
/// twice. This is the pin where the root and the affinity key differ.
|
||||
#[test]
|
||||
fn j1b2_markerless_files_in_different_directories_report_once() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let one = td.path().join("one");
|
||||
let two = td.path().join("two");
|
||||
std::fs::create_dir_all(&one).expect("mkdir one");
|
||||
std::fs::create_dir_all(&two).expect("mkdir two");
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
|
||||
open(&state, &write_rs(&one, "a.rs"));
|
||||
assert!(!status(&state).is_empty(), "first markerless open reports");
|
||||
|
||||
clear_status(&state);
|
||||
open(&state, &write_rs(&two, "b.rs"));
|
||||
assert_eq!(
|
||||
status(&state),
|
||||
"",
|
||||
"a markerless file elsewhere shares the same (language, nil) key"
|
||||
);
|
||||
assert_eq!(
|
||||
failure_count(&state),
|
||||
1,
|
||||
"and therefore one failure, not two"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** (5) — a genuinely different root reports again.
|
||||
#[test]
|
||||
fn j1b2_a_different_project_root_reports_again() {
|
||||
let outer = tempfile::tempdir().expect("tempdir");
|
||||
let a = outer.path().join("a");
|
||||
let b = outer.path().join("b");
|
||||
std::fs::create_dir_all(&a).expect("mkdir a");
|
||||
std::fs::create_dir_all(&b).expect("mkdir b");
|
||||
std::fs::write(a.join("Cargo.toml"), b"[package]\nname=\"a\"\n").expect("w");
|
||||
std::fs::write(b.join("Cargo.toml"), b"[package]\nname=\"b\"\n").expect("w");
|
||||
let state = editor_for(outer.path());
|
||||
configure_rust(&state, &absent_command(outer.path(), "rust-analyzer"));
|
||||
|
||||
open(&state, &write_rs(&a, "a.rs"));
|
||||
clear_status(&state);
|
||||
open(&state, &write_rs(&b, "b.rs"));
|
||||
assert!(
|
||||
!status(&state).is_empty(),
|
||||
"a different detected root is a different affinity"
|
||||
);
|
||||
assert_eq!(failure_count(&state), 2);
|
||||
}
|
||||
|
||||
/// **N** (6) — a changed command reports again, because the reported
|
||||
/// identity includes it.
|
||||
#[test]
|
||||
fn j1b2_repointing_at_another_missing_command_reports_again() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
open(&state, &write_rs(td.path(), "a.rs"));
|
||||
|
||||
clear_status(&state);
|
||||
let second = absent_command(td.path(), "rust-analyzer-2");
|
||||
configure_rust(&state, &second);
|
||||
open(&state, &write_rs(td.path(), "b.rs"));
|
||||
let msg = status(&state);
|
||||
assert!(
|
||||
msg.contains(&second),
|
||||
"a different missing executable is a new failure; got {msg:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **N** (7, 8) — the memo is on the report, not the failure: the spawn
|
||||
/// is retried, so a resolvable command recovers with nothing to
|
||||
/// invalidate, and both surfaces go quiet.
|
||||
#[test]
|
||||
fn j1b2_recovery_clears_the_failure_surface() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
open(&state, &write_rs(td.path(), "a.rs"));
|
||||
assert_eq!(failure_count(&state), 1, "precondition: a failure exists");
|
||||
|
||||
// `/bin/sh` exists and is spawnable; it is not an LSP server, but
|
||||
// this pin is about the spawn succeeding, not about initialize.
|
||||
configure_rust(&state, "/bin/sh");
|
||||
open(&state, &write_rs(td.path(), "b.rs"));
|
||||
assert_eq!(
|
||||
failure_count(&state),
|
||||
0,
|
||||
"a successful spawn for the same affinity clears the failure"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** (9) — recovery reaches **every** buffer sharing the affinity,
|
||||
/// not just the one that succeeded.
|
||||
///
|
||||
/// Asserted on A, deliberately: a version of this pin that checked B
|
||||
/// passes on the broken implementation, where only the succeeding
|
||||
/// buffer's projection is cleared and A keeps rendering `LSP:!` while
|
||||
/// `lsp.status` reports nothing wrong.
|
||||
#[test]
|
||||
fn j1b2_recovery_reaches_every_buffer_sharing_the_affinity() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
let a = write_rs(td.path(), "a.rs");
|
||||
open(&state, &a);
|
||||
assert_eq!(
|
||||
lsp_segment(&state).as_deref(),
|
||||
Some("LSP:!"),
|
||||
"precondition: A is marked failed"
|
||||
);
|
||||
|
||||
configure_rust(&state, "/bin/sh");
|
||||
open(&state, &write_rs(td.path(), "b.rs"));
|
||||
|
||||
// Back to A — the buffer that never succeeded itself.
|
||||
open(&state, &a);
|
||||
assert_ne!(
|
||||
lsp_segment(&state).as_deref(),
|
||||
Some("LSP:!"),
|
||||
"A must stop claiming a failure that the shared affinity has resolved"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **N** (13) — the modeline distinguishes "failed" from "not
|
||||
/// applicable". Both halves asserted: a pin checking only the failing
|
||||
/// case passes if the segment renders `!` unconditionally.
|
||||
#[test]
|
||||
fn j1b2_the_modeline_distinguishes_failed_from_unsupported() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
|
||||
open(&state, &write_rs(td.path(), "a.rs"));
|
||||
assert_eq!(
|
||||
lsp_segment(&state).as_deref(),
|
||||
Some("LSP:!"),
|
||||
"a source file whose server failed says so"
|
||||
);
|
||||
|
||||
let txt = td.path().join("notes.txt");
|
||||
std::fs::write(&txt, b"plain\n").expect("write txt");
|
||||
open(&state, &txt);
|
||||
assert_eq!(
|
||||
lsp_segment(&state),
|
||||
None,
|
||||
"a file with no configured server renders nothing at all"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **N** (14) — a killed buffer's projection is removed.
|
||||
///
|
||||
/// Without the `pmacs.buffer.on_removed` registration the entry outlives
|
||||
/// its buffer for the session, and nothing else can reach it: the LSP
|
||||
/// resource reconciliation iterates `attachments`, and a failed buffer
|
||||
/// has none by construction.
|
||||
#[test]
|
||||
fn j1b2_a_killed_buffer_drops_its_failure_projection() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
let a = write_rs(td.path(), "a.rs");
|
||||
open(&state, &a);
|
||||
assert_eq!(lsp_segment(&state).as_deref(), Some("LSP:!"));
|
||||
|
||||
let before: i64 = eval(&state, "return #pmacs.buffer.list()");
|
||||
exec(&state, "pmacs.buffer.remove(pmacs.window.buffer())");
|
||||
let after: i64 = eval(&state, "return #pmacs.buffer.list()");
|
||||
assert!(
|
||||
after < before,
|
||||
"precondition: the buffer really was removed"
|
||||
);
|
||||
|
||||
// Re-open the same path: a fresh buffer that has never failed must
|
||||
// not inherit a marker, and the stale projection must not be what
|
||||
// answers for it.
|
||||
open(&state, &a);
|
||||
configure_rust(&state, "/bin/sh");
|
||||
open(&state, &write_rs(td.path(), "b.rs"));
|
||||
assert_eq!(
|
||||
failure_count(&state),
|
||||
0,
|
||||
"the sweep still terminates and clears with a killed buffer in play"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** (15) — a rename clears the projection rather than leaving an
|
||||
/// old-path failure attached to a changed buffer.
|
||||
#[test]
|
||||
fn j1b2_a_rename_clears_the_failure_projection() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
let a = write_rs(td.path(), "a.rs");
|
||||
open(&state, &a);
|
||||
assert_eq!(lsp_segment(&state).as_deref(), Some("LSP:!"));
|
||||
|
||||
let renamed = td.path().join("renamed.rs");
|
||||
exec(
|
||||
&state,
|
||||
&format!(
|
||||
"pmacs.hook.run('resource.renamed', {}, {})",
|
||||
lua_str(&a.display().to_string()),
|
||||
lua_str(&renamed.display().to_string())
|
||||
),
|
||||
);
|
||||
assert_ne!(
|
||||
lsp_segment(&state).as_deref(),
|
||||
Some("LSP:!"),
|
||||
"after a rename the projection no longer describes this buffer"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** (16) — a delete clears it too.
|
||||
#[test]
|
||||
fn j1b2_a_delete_clears_the_failure_projection() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
let a = write_rs(td.path(), "a.rs");
|
||||
open(&state, &a);
|
||||
assert_eq!(lsp_segment(&state).as_deref(), Some("LSP:!"));
|
||||
|
||||
exec(
|
||||
&state,
|
||||
&format!(
|
||||
"pmacs.hook.run('resource.deleted', {})",
|
||||
lua_str(&a.display().to_string())
|
||||
),
|
||||
);
|
||||
assert_ne!(
|
||||
lsp_segment(&state).as_deref(),
|
||||
Some("LSP:!"),
|
||||
"a deleted path leaves no failure to project"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The *lsp* panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn named_text(state: &EditorState, name: &str) -> String {
|
||||
eval(
|
||||
state,
|
||||
&format!(
|
||||
r#"
|
||||
for _, id in ipairs(pmacs.buffer.list()) do
|
||||
if pmacs.describe.buffer(id).name == {name:?} then
|
||||
return id:slice(0, id:len())
|
||||
end
|
||||
end
|
||||
return ""
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// **N** (10) — `M-x lsp.status` renders both sections. Asserts content
|
||||
/// produced, not that a buffer exists.
|
||||
#[test]
|
||||
fn j1b2_lsp_status_renders_failures_and_servers() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
let cmd = absent_command(td.path(), "rust-analyzer");
|
||||
configure_rust(&state, &cmd);
|
||||
open(&state, &write_rs(td.path(), "a.rs"));
|
||||
|
||||
exec(&state, "pmacs.command.invoke('lsp.status')");
|
||||
let text = named_text(&state, "*lsp*");
|
||||
assert!(
|
||||
text.contains(&cmd),
|
||||
"the failure section names the command; got:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Servers:"),
|
||||
"and `status_buffer_text` still renders beneath it; got:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** (11) — `g` refreshes. The **reattach is load-bearing**: making
|
||||
/// the command resolvable changes no state on its own, since `failures`
|
||||
/// is cleared by a successful spawn.
|
||||
#[test]
|
||||
fn j1b2_g_refreshes_the_lsp_panel_after_recovery() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
let cmd = absent_command(td.path(), "rust-analyzer");
|
||||
configure_rust(&state, &cmd);
|
||||
open(&state, &write_rs(td.path(), "a.rs"));
|
||||
exec(&state, "pmacs.command.invoke('lsp.status')");
|
||||
assert!(named_text(&state, "*lsp*").contains(&cmd));
|
||||
|
||||
// Resolve AND reattach, then refresh in place.
|
||||
configure_rust(&state, "/bin/sh");
|
||||
open(&state, &write_rs(td.path(), "b.rs"));
|
||||
exec(&state, "pmacs.command.invoke('lsp.status')");
|
||||
exec(&state, "pmacs.command.invoke('listview.refresh')");
|
||||
assert!(
|
||||
!named_text(&state, "*lsp*").contains(&cmd),
|
||||
"g must re-render, not leave the panel stale"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** (12) — a foreign `*lsp*` buffer is never adopted. This is
|
||||
/// `listview.open`'s guarantee, pinned rather than assumed because it is
|
||||
/// exactly what a hand-rolled panel loses.
|
||||
#[test]
|
||||
fn j1b2_a_foreign_lsp_buffer_is_not_adopted() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
exec(
|
||||
&state,
|
||||
"local b = pmacs.buffer.create('*lsp*') b:insert(0, 'user bytes')",
|
||||
);
|
||||
|
||||
exec(&state, "pmacs.command.invoke('lsp.status')");
|
||||
assert_eq!(
|
||||
named_text(&state, "*lsp*"),
|
||||
"user bytes",
|
||||
"the user's buffer is untouched"
|
||||
);
|
||||
assert!(
|
||||
named_text(&state, "*lsp*<2>").contains("Servers:"),
|
||||
"and the panel opens beside it"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preservation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **P** (20) — a failed spawn never fabricates an attachment record.
|
||||
/// Targeted mutation: recording the failure in `attachments`, which
|
||||
/// would route requests at a server that does not exist.
|
||||
#[test]
|
||||
fn j1b2_preservation_a_failed_spawn_creates_no_attachment() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
|
||||
open(&state, &write_rs(td.path(), "a.rs"));
|
||||
|
||||
assert!(
|
||||
eval::<bool>(&state, "return pmacs.lsp.attachment_for_request() == nil"),
|
||||
"no request may be issued against a server that failed to start"
|
||||
);
|
||||
}
|
||||
|
||||
/// **P** (18) — a spawnable server is unaffected: it attaches, and the
|
||||
/// modeline reports the server rather than a failure.
|
||||
#[test]
|
||||
fn j1b2_preservation_a_spawnable_server_still_attaches() {
|
||||
let td = cargo_project();
|
||||
let state = editor_for(td.path());
|
||||
configure_rust(&state, "/bin/sh");
|
||||
open(&state, &write_rs(td.path(), "a.rs"));
|
||||
|
||||
let seg = lsp_segment(&state);
|
||||
assert!(
|
||||
seg.is_some() && seg.as_deref() != Some("LSP:!"),
|
||||
"a started server keeps its own label; got {seg:?}"
|
||||
);
|
||||
assert_eq!(failure_count(&state), 0);
|
||||
}
|
||||
Loading…
Reference in New Issue