diff --git a/COHERENCE.md b/COHERENCE.md index 6182649..9f7b7d8 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -368,7 +368,7 @@ Full verdict table: | 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 | | 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 but undiscoverable** | Full PTY with scrollback + modeline segment — reachable only as `M-x terminal`, no keybinding. *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.* | +| 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). Named limitation: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there. *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 detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) | | 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) | @@ -379,6 +379,13 @@ 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 since 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**, and the underlying +inversion is a standing bias in how new work gets bound, not three +isolated omissions: the quote stays as written because it names the +pattern, and the pattern is not retired until step 9 is. + --- ## 3. A Strong Zero-Configuration State @@ -639,7 +646,7 @@ Everything funnels through one function: `EditorInstance::dispatch_key` | 3 | query-replace | `editor.rs:945` | `QueryReplaceKey::from_chord` (`editor.rs:2967`) | **full shadow** | | 4 | Minibuffer | `editor.rs:951` | `MinibufferAction::from_chord` (`src/minibuffer.rs:468`) | **full shadow** | | 5 | Completion popup | `editor.rs:958-971` | `CompletionPopupKey::from_chord` (`editor.rs:3056`) | **partial shadow** (control chords only; skipped while a multi-key prefix is pending) | -| 6 | Terminal transport + `C-c` escape | `editor.rs:973-1010` | `is_terminal_escape_chord` (`editor.rs:4355`) | **partial, transport-level** | +| 6 | Terminal transport + configurable escape | `editor.rs:973-1010` | `EditorState::terminal_escape_chord` → `TerminalManager::escape_chord` (`src/terminal/session.rs`) | **partial, transport-level** | | 7 | Ordinary dispatch | `editor.rs:1018-1032` | `KeymapStack::resolve` | the only inspectable layer | Facts that define the gap: @@ -647,8 +654,10 @@ Facts that define the gap: - **Full shadows eat every key**, including unrecognized ones (each decoder has an `Ignore`/`Dismiss` fallback arm). While a terminal buffer is focused and unescaped, *all* keys encode to the child — - `C-c`-leading user bindings are **structurally unreachable** in a - terminal buffer. + bindings led by the escape chord are **structurally unreachable** in + a terminal buffer. Since #173 that chord is `terminal.escape-key` + rather than a hardcoded `C-c`, so a user can *move* which prefix is + eaten; they cannot make the shadow stop eating one. - **No transient-keymap mechanism exists to migrate to.** `KeymapStack` has exactly three fixed scopes — `Buffer(BufferId)`, `Mode(String)`, `Global` (`src/keymap_stack.rs:37-44`); resolution order buffer → @@ -1013,20 +1022,29 @@ layering, provenance, and adoption have not followed.** `ConfigValue`s; `describe-setting`'s "Source:" names where `define()` ran. The inspection view sketched above is currently impossible to render. -- **Adoption is five settings**: `editing.auto-pair` (pair.lua), +- **Adoption is eight settings**: `editing.auto-pair` (pair.lua), `editing.trim-on-save` (editops.lua), `autosave.interval-ms` (autosave.lua), `window.panel-height` + `window.min-height` - (window.lua). Everything else a user might set — theme, fonts, LSP + (window.lua), and `terminal.default-profile` + + `terminal.scrollback-rows` + `terminal.escape-key` (terminal.lua, + #173). Everything else a user might set — theme, fonts, LSP server config, killring size, recentf/saveplace/desktop enables, pair sets, comment strings, `pmacs.parse.*` — lives in raw Lua outside the registry and is therefore invisible to `describe-setting` and any future settings UI. The migration list is already written: `docs/config-registry-framing.md` "named deferrals" (table-valued settings are the hard prerequisite for LSP/pair/comment tables). +- **The table-valued gap now has a named, shipped instance.** + `pmacs.terminal.profiles` (#173) is a raw Lua table sitting beside + three registered scalars *for the same feature*, because a profile is + inherently `{ command, args, cwd, env }` and the registry stores four + scalars. It is the clearest evidence yet that table-valued settings + are the blocking prerequisite: the terminal is now half-registered, + and no settings UI can render the half that matters most. - **No persistence**: settings changed at runtime do not survive restart (the `custom-file` split-brain question is a named deferral). - The three-level separation holds in principle today (registry / - hooks+keymaps / packages), but with five settings registered, level 1 + hooks+keymaps / packages), but with eight settings registered, level 1 is effectively empty — users need executable Lua for nearly every ordinary preference, which is the exact failure the section warns about. diff --git a/builtin/runtime/fs.lua b/builtin/runtime/fs.lua index 49c006e..39e3baa 100644 --- a/builtin/runtime/fs.lua +++ b/builtin/runtime/fs.lua @@ -325,4 +325,28 @@ function fs.watch(path, callback, opts) return watch end +-- pmacs.fs.canonicalize(path) -> string | nil +-- +-- Arc 8 Stage 3a (framing Q#LN20). The **only synchronous** function on +-- this module, and deliberately so: its consumer is a function-valued +-- `pmacs.lsp.config[lang].root`, invoked from `ensure_server` <- +-- `attach_buffer` <- the `buffer.after-load` hook, where there is no +-- coroutine and therefore nothing to `:await()` on. Every other +-- primitive here returns a Handle; this one cannot, or it would be +-- unusable at the one call site that needs it — the same trap +-- `pmacs.fs.stat` falls into for that caller. +-- +-- Resolves symlinks and `.` / `..`, returning an absolute path, or nil +-- if the path does not exist or cannot be resolved. Nil is a normal +-- answer, not an error: callers routinely ask about paths that may have +-- been deleted. +-- +-- Why it exists: a configured LSP root reaches `file_uri_for` verbatim +-- and that URI is the server-affinity key (PR #161), so one project +-- opened through a symlink and through its real path would otherwise +-- spawn two servers. `pmacs.editor.file_path()` collapses `.` and `..` +-- lexically but leaves symlinks intact, so the resolver cannot get a +-- canonical path any other way. +fs.canonicalize = pmacs._fs.canonicalize + pmacs.fs = fs diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua new file mode 100644 index 0000000..09ad280 --- /dev/null +++ b/builtin/runtime/lean.lua @@ -0,0 +1,750 @@ +-- builtin/runtime/lean.lua --- Arc 8 Stage 3b: the Lean 4 language server. +-- +-- Framing: `docs/lean4-mode-framing.md` Q#LN7 (lake serve + probe + +-- fallback latch), Q#LN8 (Lake-aware outermost root), Q#LN16 +-- (waitForDiagnostics). Stage 1 shipped the grammar, mode, comment +-- strings and pair set; Stage 3a shipped the notification/response +-- seams and `pmacs.fs.canonicalize` this file consumes. +-- +-- Loaded after `lsp.lua`, which owns `pmacs.lsp.config` and the drain. + +local M = {} + +-- Q#LN8 — the Lake-aware root ----------------------------------------- +-- +-- `pmacs.project.detect` cannot express this rule. It is innermost-wins +-- by construction, and a Lake package's `lean-toolchain` sits at the +-- OUTERMOST level: a file under `/.lake/packages/dep/Foo.lean` +-- belongs to ``'s server, not to `dep`'s, because `lake serve` is +-- bound to one package and analyzes its dependencies from inside it. +-- Inverting `detect` globally would change Rust/Go/Node roots for every +-- user, so the rule lives here as a function-valued `config.root` — +-- the generalization Stage 2 (#161) added for exactly this. + +-- The marker test, and the two ways to get it wrong. +-- +-- `pmacs.fs.stat` is UNUSABLE here: it returns an awaitable handle +-- (`fs.lua`), and this runs synchronously inside `ensure_server` <- +-- `attach_buffer` <- the `buffer.after-load` hook, where there is no +-- coroutine to await on. The Lua stdlib's `io.open` is the only +-- synchronous existence check available. +-- +-- But `io.open` alone is wrong in BOTH directions: +-- * it SUCCEEDS on a directory (probed), so a truthiness test would +-- accept a `lean-toolchain` directory as a marker; and +-- * requiring a non-nil read rejects an EMPTY `lean-toolchain`, which +-- is a legitimate marker — `locate-dominating-file` semantics are +-- existence, not content. +-- The discriminator is `read`'s SECOND return (probed on LuaJIT 2.1): +-- file with content -> "l", no error -> marker +-- empty file -> nil, NO error -> marker +-- directory -> nil, "Is a directory" -> decline +-- missing -> io.open returns nil -> decline +-- so: decline only on a non-nil `err`. This needs no per-platform +-- re-probe, because both directory behaviors are declines — a platform +-- whose `fopen` refuses directories fails at `io.open` instead. There +-- is no platform where a directory both opens and yields a byte. +local function has_toolchain(dir) + local f = io.open(dir .. "/lean-toolchain", "r") + if not f then return false end + local _, err = f:read(1) + f:close() + return err == nil +end + +local function parent_of(dir) + local up = dir:match("^(.*)/[^/]+$") + if up == nil or up == dir or up == "" then return nil end + return up +end + +-- The walk stops at `pmacs.project.search_boundary()`. Not politeness: +-- `detect_project_within` (`src/project.rs`) exists precisely so a +-- stray marker above a temp fixture cannot leak into detection, and a +-- Lua walk that ignored the boundary would break that contract — and +-- make acceptance 23's outermost assertion non-hermetic against any +-- `lean-toolchain` sitting above the test's tempdir. +local function within_boundary(dir, boundary) + if not boundary then return true end + return dir == boundary or dir:sub(1, #boundary + 1) == boundary .. "/" +end + +-- Returns the OUTERMOST ancestor holding a `lean-toolchain`, or nil to +-- decline (which falls through to `pmacs.project.detect`, then the +-- file's own directory). +-- +-- **The result is canonical, and must be.** A configured root — which +-- this is — reaches `file_uri_for` verbatim and that URI is the +-- server-affinity key (#161). `pmacs.editor.file_path()` collapses `.` +-- and `..` lexically but leaves symlinks intact, so one package opened +-- through a symlink and through its real path would otherwise spawn two +-- `lake serve` processes. Canonicalizing ONCE up front is enough: +-- every ancestor of a canonical path is itself canonical, since the +-- walk only strips trailing components. +-- +-- If canonicalization fails (deleted file, broken symlink) the resolver +-- declines rather than returning a path it cannot vouch for. +function M.root_for(path) + if type(path) ~= "string" then return nil end + local dir = path:match("^(.*)/[^/]*$") + if not dir then return nil end + dir = pmacs.fs.canonicalize(dir) + if not dir then return nil end + local boundary + local ok, b = pcall(pmacs.project.search_boundary) + if ok then boundary = b end + -- The boundary is canonicalized at set time (`set_search_boundary`), + -- so comparing it against a canonical `dir` is apples to apples. + local outermost = nil + local cur = dir + while cur and within_boundary(cur, boundary) do + if has_toolchain(cur) then outermost = cur end + cur = parent_of(cur) + end + return outermost +end + +-- Q#LN7 — `lake serve`, with a lazy probe and a one-shot latch -------- +-- +-- `pmacs.lsp.config.lean4` is declarative and must stay cheap: spawning +-- a process at startup for every user, Lean-using or not, is the cost +-- rev 1 refused. So no probe runs here — it runs on the first `.lean` +-- attach, below. +pmacs.lsp.config.lean4 = pmacs.lsp.config.lean4 or { + command = "lake", + args = { "serve" }, + root = M.root_for, + -- No `init_options`: `hasWidgets?` defaults to false, which is the + -- correct posture for a client reading plain goals out of standard + -- messages rather than driving the `$/lean/rpc/*` widget stack. +} + +-- Session state. The latch is one-shot and never re-arms: a user whose +-- toolchain is broken sees one fallback attempt, not a loop. +local probe = { + started = false, -- the `lake --version` probe has been spawned + latched = false, -- the fallback has fired (or been ruled out) + proc = nil, -- process id of the running probe + out = "", -- accumulated probe stdout + buf_key = nil, -- tostring() of the buffer that started this + watching = nil, -- sid still being polled for die-before-initialize + primary = nil, -- sid the probe's verdict applies to; NOT cleared + -- when the server initializes, because a late + -- version verdict still has to retire it + armed = false, -- the target buffer + primary have been captured + repaired = {}, -- buffer key -> repair attempted (at most once) + repair_attempts = 0, -- COUNT of attach attempts, not distinct buffers: + -- table cardinality cannot tell "once per buffer" + -- from "every tick for one buffer" + fallback_installed = false, + fallback_watches = {}, -- sid key -> sid, each polled die-before-init + fallback_done = {}, -- sid key -> initialized or terminally handled + saw_initialized = false, +} + +-- The command as configured, for status text. Hardcoding "lake serve" +-- was untruthful the moment the failure latch became command-agnostic: +-- a user whose `my-lean-wrapper` failed was told `lake serve` did. +local function configured_command() + local cfg = pmacs.lsp.config.lean4 + local cmd = cfg and cfg.command + if not cmd then return "the Lean server" end + local args = cfg.args or {} + if #args > 0 then + return "`" .. tostring(cmd) .. " " .. table.concat(args, " ") .. "`" + end + return "`" .. tostring(cmd) .. "`" +end + +-- The fallback command, for status text. +local function fallback_name() + local args = M._fallback.args or {} + if #args > 0 then + return "`" .. tostring(M._fallback.command) .. " " + .. table.concat(args, " ") .. "`" + end + return "`" .. tostring(M._fallback.command) .. "`" +end + +local function report(msg) + -- COHERENCE §1.2: background work must leave an attributed trace. + -- `pmacs.editor.set_status` is the channel that EXISTS; `pmacs.error` + -- is referenced by fifteen call sites and defined nowhere in + -- production, so it rides along rather than standing alone. + pcall(pmacs.editor.set_status, msg) + if pmacs.error then pcall(pmacs.error, msg) end +end + +-- `lake serve` below 3.1.0 starts a server that cannot answer, which is +-- worse than failing: `lean4-mode` probes for exactly this and falls +-- back to `lean --server`. Parses the leading `x.y` of a version line. +-- State kind for the server whose `tostring(id)` is `skey`, or nil if +-- the manager has forgotten it (which is itself a terminal answer). +local function server_state_kind_for_key(skey) + local ok, rows = pcall(pmacs.lsp.list) + if not ok or not rows then return nil end + for _, info in ipairs(rows) do + if tostring(info.id) == skey then + return info.state and info.state.kind + end + end + return nil +end + +local function server_state_kind(sid) + return server_state_kind_for_key(tostring(sid)) +end + +local function version_below_3_1(text) + local major, minor = text:match("(%d+)%.(%d+)") + if not major then return false end + major, minor = tonumber(major), tonumber(minor) + if major < 3 then return true end + return major == 3 and minor < 1 +end + +-- What the latch falls back TO. +-- +-- **Underscored: a test seam, not supported user configuration.** It is +-- a table only so the acceptance suite can point it at a stand-in server +-- and drive the real latch path end to end, instead of asserting on a +-- config mutation that proves nothing about whether a server ever +-- starts. Presenting it as public config would owe framing, +-- documentation, validation and mutation semantics that nothing here +-- provides; users configure Lean through `pmacs.lsp.config.lean4`. +M._fallback = { command = "lean", args = { "--server" } } + +local function same_args(a, b) + a, b = a or {}, b or {} + if #a ~= #b then return false end + for i = 1, #a do + if a[i] ~= b[i] then return false end + end + return true +end + +-- Swap `command`/`args` ONLY. A wholesale table replacement would +-- silently discard a user's `env` / `settings` / `init_options` / `root` +-- from `init.lua` at exactly the moment they are least likely to notice. +-- +-- The only guard is idempotence — already-the-fallback means nothing to +-- do. It deliberately does NOT refuse when the command is user-supplied: +-- the latch fires only when the configured Lean server actually failed +-- to start, and one visible fallback attempt beats leaving the user with +-- no server at all. `probe.latched` is what keeps it to exactly one. +local function swap_to_fallback() + local cfg = pmacs.lsp.config.lean4 + if not cfg then return false end + -- Idempotence compares command AND args: the same command with + -- different arguments is not "already applied", and treating it as + -- such would silently skip a swap that still needed to happen. + if cfg.command == M._fallback.command + and same_args(cfg.args, M._fallback.args) then + return false + end + cfg.command = M._fallback.command + cfg.args = M._fallback.args + return true +end + +-- Retire the failed server, swap the command, then rebuild the +-- attachment on the buffer that started this. +-- Retire `sid` so it cannot come back. **Which call to use depends on +-- the state, and using the wrong one is worse than doing nothing:** +-- +-- * TERMINAL (`crashed` / `stopped`) -> `forget`. It requires a +-- terminal state and removes the client outright, which also drops +-- the `next_restart_at` the crash scheduled. `stop` here would take +-- its not-initialized branch and set `ShuttingDown { .. None }` on +-- the premise that "the next exit observation cleans up" — but the +-- exit already happened, which is what made it `Crashed`. No +-- further event arrives, so it sits in `ShuttingDown` forever: +-- `server_is_live` reads that as LIVE so `attach_buffer` never +-- rebuilds, and `forget` then refuses it for not being terminal. +-- * NON-TERMINAL -> `stop`. `forget` rejects it, and `stop` disables +-- restart and drives the polite shutdown. +-- +-- Round 1 skipped the call entirely for terminal servers. That avoided +-- the corruption but left `next_restart_at` armed, so the crashed +-- primary respawned 500ms later and kept respawning underneath the +-- live fallback — invisible to a test that stopped ticking first. +local function retire_server(sid) + local kind = server_state_kind(sid) + if kind == nil then return end + if kind == "crashed" or kind == "stopped" then + pcall(pmacs.lsp.forget, sid) + else + pcall(pmacs.lsp.stop, sid) + end +end + +-- Retire EVERY Lean server, not just the one that failed. +-- +-- `pmacs.lsp.config.lean4` is a single global entry, so swapping its +-- command invalidates every server spawned from the old one — and +-- Q#LN15 gives one server per project root, so there can be several. +-- Retiring only the server that happened to fail left the others live +-- and every buffer attached to them stranded on a command the config no +-- longer names. +-- Only servers the config-driven path itself produced. A server's label, +-- language, command, and root are all caller-supplied public values; none +-- is an ownership discriminator. `lsp.lua` records the successful spawn +-- in a private origin table, which is the fact this lifecycle may act on. +local function is_derived_server(sid) + local ok, owned = pcall(pmacs.lsp._is_default_server, sid, "lean4") + return ok and owned == true +end + +local function retire_derived_lean_servers() + local ok, rows = pcall(pmacs.lsp.list) + if not ok or not rows then return end + local ids = {} + for _, info in ipairs(rows) do + if is_derived_server(info.id) then ids[#ids + 1] = info.id end + end + for _, id in ipairs(ids) do + -- These ids predate the fallback spawn. Mark them handled before + -- retirement so the discovery poll cannot mistake their terminal + -- state for a fallback that failed to initialize. + probe.fallback_done[tostring(id)] = true + retire_server(id) + end +end + +local function watch_fallback_server(sid) + if not sid or not is_derived_server(sid) then return end + local key = tostring(sid) + if probe.fallback_done[key] then return end + probe.fallback_watches[key] = sid +end + +-- Rebuild the ACTIVE buffer's attachment if it is Lean and stale. +-- +-- `_attach_buffer` is an active-buffer-only seam, so a global config +-- swap cannot be applied to every open buffer at once. It is applied +-- lazily instead: whenever a Lean buffer becomes the active one, if its +-- record points at a server that is gone or terminal, it is rebuilt. +-- +-- **At most one attempt per buffer.** Without that bound a fallback +-- that also fails to spawn would retry every tick forever with nothing +-- reported — the round-2 defect, which a general repair loop would +-- otherwise reintroduce for every buffer instead of just one. +-- +-- A `shutting-down` server is deliberately NOT treated as stale: it is +-- still live by `server_is_live`'s reckoning, so `attach_buffer` would +-- early-return the stale record and burn this buffer's single attempt +-- on a no-op. Skipping leaves the attempt for a later tick, once the +-- retirement has actually landed. +local function repair_active_if_stale() + -- **`fallback_installed`, not `latched`.** When the swap does not + -- happen — the config already names the fallback, or it vanished + -- before an asynchronous verdict landed — `fire_latch` returns early + -- but `latched` stays true. Gating repair on `latched` then retried + -- the UNCHANGED configuration and reported the result as a fallback + -- failure, which is both a second pointless spawn and a misleading + -- message. Repair exists to apply a swap; no swap, nothing to apply. + if not probe.fallback_installed then return end + local buf = pmacs.window.buffer() + if not buf then return end + local key = tostring(buf) + if probe.repaired[key] then return end + local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf) + if not ok_lang or lang ~= "lean4" then return end + + local rec = pmacs.lsp.active_attachment() + local stale + if not rec then + stale = true + else + local kind = server_state_kind(rec.server) + stale = (kind == nil or kind == "crashed" or kind == "stopped") + end + if not stale then return end + + probe.repaired[key] = true + probe.repair_attempts = probe.repair_attempts + 1 + local ok, fresh = pcall(pmacs.lsp._attach_buffer) + if not ok or not fresh then + report("LSP: lean4 fallback " .. fallback_name() + .. " did not start either") + return + end + -- **A successful SPAWN is not a successful START.** The once-per- + -- buffer bound stops `_attach_buffer` being called again, but it says + -- nothing about the server it produced. Arm this id immediately; the + -- poll below also discovers servers created through lsp.lua's own + -- after-load and command paths. + watch_fallback_server(fresh.server) +end + +-- Every fallback server gets its own die-before-initialize poll. A scalar +-- watch cannot cover Q#LN15's simultaneous per-root servers, and a server +-- may be created by lsp.lua's after-load or command path without passing +-- through `repair_active_if_stale`. Discovery from the private ownership +-- table closes both holes. +local function poll_fallbacks() + if not probe.fallback_installed then return end + local ok, rows = pcall(pmacs.lsp.list) + if not ok or not rows then return end + + local by_key = {} + for _, info in ipairs(rows) do + local key = tostring(info.id) + by_key[key] = info + if not probe.fallback_done[key] and is_derived_server(info.id) then + probe.fallback_watches[key] = info.id + end + end + + for key, sid in pairs(probe.fallback_watches) do + local info = by_key[key] + local kind = info and info.state and info.state.kind + if kind == "initialized" then + probe.fallback_watches[key] = nil + probe.fallback_done[key] = true + elseif info == nil or kind == "crashed" or kind == "stopped" then + probe.fallback_watches[key] = nil + probe.fallback_done[key] = true + if info ~= nil then retire_server(sid) end + report("LSP: lean4 fallback " .. fallback_name() + .. " started but did not stay up") + end + end +end + +local function fire_latch(sid, why) + if probe.latched then return end + probe.latched = true + probe.watching = nil + if not swap_to_fallback() then + report("LSP: lean4 " .. why) + -- No shared config changed, so only the server whose failure + -- triggered this verdict is invalid. Sweeping every root here stops + -- healthy instances of a root-sensitive command for no reason. + if sid and is_derived_server(sid) then retire_server(sid) end + return + end + retire_derived_lean_servers() + probe.fallback_installed = true + report("LSP: lean4 " .. why .. "; falling back to " .. fallback_name()) + -- Repair what is in front of the user now; everything else is + -- repaired lazily as it becomes active (see `repair_active_if_stale`). + repair_active_if_stale() +end + +local function drain_probe() + if not probe.proc then return end + local ok, evs = pcall(pmacs.process.events_take, probe.proc) + if not ok or not evs then return end + for _, ev in ipairs(evs) do + if ev.kind == "stdout" or ev.kind == "stderr" then + probe.out = probe.out .. tostring(ev.bytes) + elseif ev.kind == "exited" or ev.kind == "signaled" + or ev.kind == "crashed" then + local proc = probe.proc + probe.proc = nil + pcall(pmacs.process.forget, proc) + -- A non-zero exit is NOT a fallback trigger on its own. §2.9: elan + -- shims make `lake --version` exit non-zero with "no default + -- toolchain configured" on a machine where `lake serve` may still + -- be the right command — the server-failure latch covers that + -- case, and covers it better. The probe answers only the ONE + -- question failure detection would otherwise answer slowly: an + -- old-but-working lake that starts a useless server. + -- **`probe.primary`, NOT `probe.watching`.** `watching` is + -- failure-polling state and is cleared the moment the server + -- initializes. A slow `--version` that lands after a successful + -- initialize would then arrive with nil, and `fire_latch(nil)` + -- retires nothing: `_attach_buffer` finds the still-live primary + -- attachment, early-returns it, and the retry calls that success. + -- Status and config would say "fell back" while the buffer stayed + -- on the old server — the same silent no-op as round 1, reached + -- through a different event ordering. Initializing must stop the + -- failure poll, not erase the server the verdict has to retire. + if ev.kind == "exited" and ev.code == 0 + and version_below_3_1(probe.out) then + fire_latch(probe.primary, "lake is older than 3.1.0") + end + end + end +end + +-- The probe cannot gate the first attach. There is no blocking process +-- run (§2.9): `spawn` + `events_take` off a tick is the only shape +-- available, so the verdict arrives AFTER `ensure_server` has already +-- had to decide. Hence the optimistic `lake serve` spawn, with the +-- probe and the latch correcting it. +local function start_probe(root) + if probe.started then return end + probe.started = true + local cfg = pmacs.lsp.config.lean4 + if not cfg or not cfg.command then return end + -- **Only probe something actually named `lake`.** `version_below_3_1` + -- parses the first `x.y` it finds anywhere in the output, which is a + -- rule about LAKE's output contract and nothing else. Run against a + -- user's wrapper it is a category error: a working `my-lean-wrapper` + -- reporting "wrapper 1.0" would be replaced despite its server having + -- initialized fine. The FAILURE latch stays command-agnostic — that + -- one keys on the server actually not starting, which is true of any + -- command — but the version rule only applies where its contract + -- holds. + local base = cfg.command:match("([^/]+)$") or cfg.command + if base ~= "lake" then return end + -- Probe the binary we would actually run, not the literal string + -- "lake": a user pointing `command` at an absolute path to lake should + -- have THAT probed, not whatever `lake` resolves to on PATH. + local spec = { + -- COHERENCE §9: `ProcessSpec.label` is the only identity a process + -- carries, and it is what `pmacs.process.list` renders. A user + -- wondering why their editor touched `lake` finds an owner here. + label = "lean:lake-version-probe", + command = cfg.command, + args = { "--version" }, + stdin = "null", + } + if root then spec.cwd = root end + local ok, proc = pcall(pmacs.process.spawn, spec) + if ok then probe.proc = proc end + -- A probe that cannot even spawn says nothing the latch will not say + -- more reliably a moment later, so it is not reported here. +end + +-- How the latch observes server failure. +-- +-- There is no event for "died before initialize" — the drain ignores +-- state events. So this polls `pmacs.lsp.list()` on the +-- `process.after-tick` cadence and treats a terminal state reached +-- WITHOUT an intervening `initialized` as the trigger. Watching stops +-- as soon as the server initializes, so an ordinary later crash (a real +-- server dying on a real error) does not silently rewrite the command. +local function poll_latch() + local sid = probe.watching + if not sid or probe.latched then return end + local skey = tostring(sid) + local ok, rows = pcall(pmacs.lsp.list) + if not ok or not rows then return end + for _, info in ipairs(rows) do + if tostring(info.id) == skey then + local kind = info.state and info.state.kind + if kind == "initialized" then + -- Stop polling for failure; `probe.primary` deliberately + -- survives, because a later version verdict still needs it. + probe.saw_initialized = true + probe.watching = nil + return + end + if kind == "crashed" or kind == "stopped" then + fire_latch(sid, configured_command() .. " failed to start") + end + return + end + end + -- Gone from the manager entirely without ever initializing. + fire_latch(nil, configured_command() .. " failed to start") +end + +-- Q#LN16 — `textDocument/waitForDiagnostics` -------------------------- +-- +-- A plain request: no position, so Q#LN12's `outbound_position` concern +-- does not apply. Resolves when the server has finished elaborating. +-- Awaited through Stage 3a's response seam. +-- +-- **`version` is required, not optional.** Lean's +-- `WaitForDiagnosticsParams` is `{ uri, version }` (v4.9.0, +-- `src/Lean/Data/Lsp/Extra.lean`), and the request is how the client +-- says *which* revision of the document it wants elaboration for. +-- Sending only `uri` is a malformed request against a real server; it +-- happened to look fine here because the fake server echoes any +-- payload. Callers pass the attachment's current `version`. +-- +-- `fn(err)` is called with nil on success. Registering the one-shot +-- requires the server to have an attached buffer — see the note on +-- `pmacs.lsp.on_response`; every caller here comes from an attachment. +function M.wait_for_diagnostics(sid, uri, version, fn) + local ok, rid = pcall(pmacs.lsp.send_request, sid, + "textDocument/waitForDiagnostics", { uri = uri, version = version }) + if not ok then + if fn then pcall(fn, tostring(rid)) end + return nil + end + if fn then + pmacs.lsp.on_response(sid, rid, function(_, err) + fn(err and err.message or nil) + end) + end + return rid +end + +local function when_server_ready(sid, fn) + local function state_kind() + local ok, state = pcall(pmacs.lsp.status, sid) + if not ok or not state then return nil end + return state.kind + end + + local kind = state_kind() + if kind == "initialized" then + fn(nil) + return + end + if kind == nil or kind == "crashed" or kind == "stopped" then + fn("server did not initialize") + return + end + + -- A command may have just healed a dead attachment, in which case the + -- replacement is still starting. Requests are not queued before + -- initialize, so issue this one after the lifecycle reaches ready + -- rather than replacing the attachment and immediately failing on it. + pmacs.async(function() + for _ = 1, 300 do + pmacs.async.yield_to_next_tick() + kind = state_kind() + if kind == "initialized" then + fn(nil) + return + end + if kind == nil or kind == "crashed" or kind == "stopped" then + fn("server did not initialize") + return + end + end + fn("server initialization timed out") + end) +end + +pmacs.command.define { + name = "lean.wait-for-diagnostics", + description = "Wait for the Lean server to finish elaborating this file", + fn = function() + local rec = pmacs.lsp._attachment_for_command() + if not rec or rec.language ~= "lean4" then + pmacs.editor.set_status("lean: no Lean server for this buffer") + return + end + pmacs.editor.set_status("lean: elaborating…") + when_server_ready(rec.server, function(init_err) + if init_err then + pmacs.editor.set_status("lean: " .. tostring(init_err)) + return + end + M.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err) + if err then + pmacs.editor.set_status("lean: " .. tostring(err)) + else + pmacs.editor.set_status("lean: elaboration complete") + end + end) + end) + end, +} + +-- `$/lean/fileProgress` — the elaboration-in-flight signal. Stage 5's +-- goal view reads it to distinguish "no goals" from "not done yet"; +-- here it is recorded so that consumer has something to read and so the +-- notification seam has its first production subscriber. +M.file_progress = {} + +pmacs.lsp.on_notification("$/lean/fileProgress", function(_, params) + local uri = params and params.textDocument and params.textDocument.uri + if type(uri) ~= "string" then return end + M.file_progress[uri] = params.processing or {} +end) + +-- Wiring -------------------------------------------------------------- + +-- Runs after `lsp.lua`'s own `buffer.after-load` subscription. +-- +-- **Keyed on the buffer's LANGUAGE, not on an attachment existing.** +-- Round 1 keyed on `active_attachment()` and returned early when it was +-- nil — which silently excluded the single most likely real-world +-- failure: `lake` not installed. `ensure_server` pcalls the spawn and +-- returns nil on ENOENT, so `attach_buffer` produces no record at all, +-- so the probe never started and the latch never armed. The case the +-- fallback exists for was the one case it could not see. +pmacs.hook.add("buffer.after-load", function() + local buf = pmacs.window.buffer() + if not buf then return end + local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf) + if not ok_lang or lang ~= "lean4" then return end + + local rec = pmacs.lsp.active_attachment() + if rec and rec.language == "lean4" then + -- A matching-root server supplied by the user may be adopted by + -- `ensure_server`. Its lifecycle is not evidence about the + -- config-driven command, and neither the version probe nor fallback + -- latch may mutate config because that foreign server changed state. + if not is_derived_server(rec.server) then return end + if not probe.started then + local path = pmacs.editor.file_path() + start_probe(path and M.root_for(path) or nil) + end + -- **Arm ONCE, capturing buffer and server together.** Setting + -- `buf_key` on every Lean load meant a second Lean buffer opened + -- before the verdict silently became the rebuild target while the + -- latch still watched the FIRST buffer's server — so the rebuild + -- either repaired the wrong buffer or accepted the second buffer's + -- unrelated live server as success, stranding the first. The pair + -- (target buffer, primary server) is one fact and is captured as + -- one. + if not probe.armed and not probe.latched and not probe.saw_initialized then + probe.armed = true + probe.buf_key = tostring(buf) + probe.primary = rec.server + probe.watching = rec.server + end + return + end + + -- **Unconfigured is DISABLED, not failed.** A user who sets + -- `pmacs.lsp.config.lean4 = nil`, or clears its `command`, has turned + -- the Lean server off; reporting that "nil could not be started" is a + -- false alarm, and latching would poison the session so a later + -- configuration could never take effect. Only a CONFIGURED command + -- that produced no attachment is a failure. + local cfg = pmacs.lsp.config.lean4 + if not cfg or not cfg.command then return end + if not probe.started then + local path = pmacs.editor.file_path() + start_probe(path and M.root_for(path) or nil) + end + + -- No attachment for a Lean buffer with a configured command means + -- `ensure_server` could not spawn at all — a synchronous ENOENT, + -- already swallowed upstream. That is not something to wait for; it + -- is the failure itself, and the only place it is still observable. + if not probe.latched then + -- No server was ever created, so there is no primary to retire — + -- but the rebuild still needs a target buffer. + if not probe.armed then + probe.armed = true + probe.buf_key = tostring(buf) + end + fire_latch(nil, configured_command() .. " could not be started") + end +end) + +-- A buffer switch is the moment a stale Lean buffer becomes visible, so +-- repair immediately rather than waiting for the next tick. lsp.lua's +-- own `after-switch` subscription re-pushes views but does NOT rebuild a +-- stale attachment, so nothing else covers this. +pmacs.hook.add("buffer.after-switch", function() + repair_active_if_stale() +end) + +pmacs.hook.add("process.after-tick", function() + drain_probe() + poll_latch() + -- Repair the active buffer if the latch invalidated it. Cheap when + -- there is nothing to do, and bounded to one attempt per buffer. + repair_active_if_stale() + poll_fallbacks() +end) + +-- Test seam: acceptance drives the latch deterministically rather than +-- waiting on real process timing. Not part of the public surface. +M._probe = probe +M._fire_latch = fire_latch +M._version_below_3_1 = version_below_3_1 + +pmacs.lean = M diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 6021134..bf56a4c 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -607,6 +607,12 @@ local function project_root_for(language, path) return dir_of(path), "fallback" end +-- Servers created by the automatic config-driven path. This is the +-- ownership fact a caller-supplied `label` cannot provide: labels are +-- public, unreserved display strings, while entries here are written +-- only after this module itself successfully spawns a server. +local default_servers = {} + local function ensure_server(language, path) local cfg = pmacs.lsp.config[language] if not cfg or not cfg.command then return nil end @@ -660,7 +666,30 @@ local function ensure_server(language, path) cwd = root, root_uri = key_uri, }) - if ok then return sid end + if ok then + default_servers[tostring(sid)] = language + return sid + end + return nil +end + +-- Internal ownership seam for builtins whose lifecycle follows the +-- config-driven server set (currently Lean's one-shot fallback). A +-- user-managed server may deliberately use the same language id, label, +-- command, and root; none of those make it ours. +function pmacs.lsp._is_default_server(sid, language) + local owned_language = default_servers[tostring(sid)] + return owned_language ~= nil + and (language == nil or owned_language == language) +end + +local function server_state_kind(sid) + if not sid then return nil end + for _, info in ipairs(pmacs.lsp.list()) do + if tostring(info.id) == tostring(sid) then + return info.state and info.state.kind + end + end return nil end @@ -669,14 +698,8 @@ end -- forgotten, or was spawned against a now-replaced `pmacs.lsp.config` -- entry — get rebuilt on the next attach attempt. local function server_is_live(sid) - if not sid then return false end - for _, info in ipairs(pmacs.lsp.list()) do - if tostring(info.id) == tostring(sid) then - local kind = info.state and info.state.kind - return kind ~= "crashed" and kind ~= "stopped" - end - end - return false + local kind = server_state_kind(sid) + return kind ~= nil and kind ~= "crashed" and kind ~= "stopped" end local function server_is_initialized(sid) @@ -811,6 +834,15 @@ local function attach_buffer(buf) local existing = attachments[key] if existing and server_is_live(existing.server) then return existing end if existing then + local kind = server_state_kind(existing.server) + if kind == "crashed" or kind == "stopped" then + -- A terminal OnCrash client may still have `next_restart_at` + -- armed. Spawning beside it creates two same-root servers when + -- the old id restarts. `forget` is the terminal-state operation: + -- it removes the client and cancels that pending restart before + -- the replacement is created. + pcall(pmacs.lsp.forget, existing.server) + end attachments[key] = nil -- Unsent edits targeted the dead attachment; the did_open below -- carries the full current text, superseding them. @@ -871,6 +903,21 @@ local function attached_for_active() if not buf then return nil end local key = tostring(buf) local rec = attachments[key] + -- A record whose server is dead is worse than no record: every + -- command below issues requests against it and gets silence. Rebuild + -- instead, which is what `attach_buffer` does for a stale attachment + -- anyway — this just stops the dead record short-circuiting that. + -- + -- Load-bearing for anything that retires a server out from under open + -- buffers (Arc 8 Stage 3b's fallback latch retires every Lean server + -- at once). Buffers in OTHER frontends get no `buffer.after-switch` + -- in this one, so an eager repair sweep keyed on the ambient active + -- buffer cannot reach them; healing at the point of USE is + -- frontend-agnostic, because whichever frontend runs the command is + -- the active one while it runs. + if rec and not server_is_live(rec.server) then + rec = nil + end if rec then -- Every interactive command resolves its attachment here before -- issuing requests; flushing now means the server answers those @@ -881,6 +928,14 @@ local function attached_for_active() return attach_buffer(buf) end +-- Internal command-path resolver for builtin request producers outside +-- this module. Unlike `active_attachment` it may replace a dead record; +-- unlike `attachment_for_request` it is called only from an explicit +-- user command, where attach-on-use is the intended policy. +function pmacs.lsp._attachment_for_command() + return attached_for_active() +end + -- Pure, side-effect-free attachment lookup for the active buffer: -- returns the live record (with `.uri`) when a server is already -- attached, else nil. Unlike `attached_for_active`, it never *triggers* @@ -893,6 +948,24 @@ function pmacs.lsp.active_attachment() return attachments[tostring(buf)] end +-- Re-run the attach for the ACTIVE buffer, rebuilding it against the +-- current `pmacs.lsp.config`. +-- +-- Exists for the Arc 8 Stage 3b fallback latch (Q#LN7): after that latch +-- stops a server that failed to start and rewrites `config.lean4`, +-- something has to actually spawn the replacement and re-point the +-- buffer at it. Nothing else does — `attach_buffer` early-returns for a +-- live attachment, and no hook re-fires on a config change, so without +-- this the buffer stays bound to the stopped server and the "fallback" +-- is a config edit with no effect. +-- +-- Deliberately keyed on the active buffer, matching `attach_buffer`'s +-- own use of `active_buffer_path()`; it is not a general re-attach for +-- arbitrary buffers and must not be used as one. +function pmacs.lsp._attach_buffer() + return attach_buffer(pmacs.window.buffer()) +end + -- Arc 4 stage 3: pure modeline projection. This reads the private -- per-buffer attachment map directly so passive split windows report their -- own buffer instead of the focused window. It never attaches, flushes @@ -924,6 +997,19 @@ function pmacs.lsp.attachment_for_request() local key = tostring(buf) local rec = attachments[key] if not rec then return nil end + -- Same liveness rule as `attached_for_active`: a record naming a dead + -- server is worse than none, because the caller issues a request + -- against it and waits for a reply that cannot come. Unlike that + -- function this one is deliberately non-attaching (it must not + -- perturb LSP state), so a dead record reads as "no attachment" + -- rather than triggering a rebuild. + if not server_is_live(rec.server) then + -- Preserve the record. A crashed OnCrash server may restart under + -- the SAME id; clearing the map here would orphan that recovered + -- server, while this non-attaching lookup has no authority to + -- cancel the restart or create a replacement. + return nil + end flush_did_change(key) return rec end @@ -1546,6 +1632,186 @@ end -- itself is unaffected. Server ids are snapshotted before the loop -- because `apply_workspace_edit` → `find_or_open` can attach a new -- buffer mid-iteration (mutating `attachments`). +-- Server-originated notification / response seams (framing Q#LN9) ------- +-- +-- Before this, `handle_server_requests` handled five `request` methods +-- and `initialized`, and dropped every `notification` and `response` on +-- the floor. Dropping responses made `pmacs.lsp.send_request` a +-- write-only API from Lua: the reply was drained and discarded, so +-- nothing outside Rust's typed stores could ever consume one. +-- +-- Both seams route through the *existing* drain. A second +-- `events_take` caller would steal events from this one — `take_events` +-- removes the queue — so any new consumer must extend this loop rather +-- than open its own. +-- +-- method -> array of subscriber fns. Persistent; `pmacs.hook` has no +-- `remove` and neither does this, deliberately matching it. +local notification_subs = {} +-- tostring(sid) -> { [request_id] = { fn = fn, attempt = n } }. One-shot. +local pending_responses = {} + +local function report_subscriber_error(what, err) + local msg = string.format("LSP: %s subscriber failed: %s", what, + tostring(err)) + -- COHERENCE §1.2: a pcall around background wiring must report, not + -- discard. `pmacs.editor.set_status` is the channel that exists; + -- `pmacs.error` is referenced by fifteen call sites and defined + -- nowhere in production, so it rides along rather than standing alone. + pcall(pmacs.editor.set_status, msg) + if pmacs.error then pcall(pmacs.error, msg) end +end + +-- Current spawn attempt for `sid`, or nil if the manager has forgotten +-- it. A restart reuses the sid but bumps the attempt, which is how a +-- pending one-shot tells "my server is still here" from "my server died +-- and a new generation took its id". +local function server_attempt(sid) + local skey = tostring(sid) + for _, info in ipairs(pmacs.lsp.list()) do + if tostring(info.id) == skey then + return info.attempt or 0 + end + end + return nil +end + +-- fn(sid, params); persistent, fires for every server. +function pmacs.lsp.on_notification(method, fn) + if type(method) ~= "string" or type(fn) ~= "function" then + error("pmacs.lsp.on_notification(method, fn): want string, function") + end + local subs = notification_subs[method] + if not subs then + subs = {} + notification_subs[method] = subs + end + subs[#subs + 1] = fn +end + +-- fn(result, err); ONE-SHOT, keyed to the exact request. +-- `request_id` is what `pmacs.lsp.send_request` returned. +-- +-- **Register only against a server with an attached buffer.** The drain +-- that delivers replies visits only sids present in `attachments`, so a +-- one-shot on an unattached server will not fire on its reply — the +-- reply sits in that server's queue and the handler is invoked only when +-- the purge below decides the server is gone. That is fire-on-death, not +-- fire-on-reply, and it looks exactly like a hung request while +-- debugging. The attach path is the ordinary way to get a sid; a +-- hand-spawned one from `init.lua` is the case to watch. +function pmacs.lsp.on_response(sid, request_id, fn) + if not sid or type(request_id) ~= "number" or type(fn) ~= "function" then + error("pmacs.lsp.on_response(sid, request_id, fn): want sid, number, function") + end + local skey = tostring(sid) + local pend = pending_responses[skey] + if not pend then + pend = {} + pending_responses[skey] = pend + end + -- The attempt is captured at registration so a restart under the same + -- sid purges this entry rather than leaving it waiting on a reply the + -- dead generation was going to send. + pend[request_id] = { fn = fn, attempt = server_attempt(sid) or 0 } +end + +local function dispatch_notification(sid, ev) + local subs = notification_subs[ev.method] + if not subs then return end + -- Length captured up front: a subscriber that registers another one + -- must not be able to extend the list being walked. + local n = #subs + for i = 1, n do + local ok, err = pcall(subs[i], sid, ev.params) + if not ok then + report_subscriber_error("notification " .. tostring(ev.method), err) + end + end +end + +local function deliver_response(sid, ev) + local skey = tostring(sid) + local pend = pending_responses[skey] + if not pend then return end + local entry = pend[ev.request_id] + if not entry then return end + -- Removed UNCONDITIONALLY, so a handler that raises is still retired + -- and cannot be invoked a second time by the purge. Removing first is + -- the defensive order and costs nothing, but it is not what defends + -- against re-invocation: `pcall` catches the raise either way, so + -- before-vs-after is unobservable without a re-entrant drain. The + -- reachable bug is gating removal on a clean return, which acceptance + -- 32 bites (2 != 1). + pend[ev.request_id] = nil + if next(pend) == nil then pending_responses[skey] = nil end + local ok, err = pcall(entry.fn, ev.result, ev.error) + if not ok then + report_subscriber_error("response " .. tostring(ev.method), err) + end +end + +-- Settle every one-shot whose server can no longer answer it. +-- +-- Deliberately driven off `pmacs.lsp.list()` and NOT off a death event +-- observed in the drain, because the drain cannot be relied on to reach +-- the server in question: `handle_server_requests` builds its sid list +-- from `attachments`, and a sid leaves that table whenever +-- `attach_buffer` finds it dead and rebuilds the attachment against a +-- fresh server. So the very event that should trigger the purge — +-- `crashed` / `stopped` — is the one most likely to go undrained. A +-- one-shot settled only by the drain would leak exactly when it matters. +-- +-- `pmacs.lsp.list()` enumerates the manager directly and is unaffected +-- by attachment bookkeeping, which is what makes it the right authority. +local function purge_dead_pending() + if next(pending_responses) == nil then return end + local ok, rows = pcall(pmacs.lsp.list) + -- A failed enumeration is not evidence that every server died; leaving + -- the registrations alone is the safe read of "we don't know". + if not ok or not rows then return end + local alive = {} + for _, info in ipairs(rows) do + local kind = info.state and info.state.kind + if kind ~= "crashed" and kind ~= "stopped" then + alive[tostring(info.id)] = info.attempt or 0 + end + end + for skey, pend in pairs(pending_responses) do + local attempt = alive[skey] + local dead = {} + for rid, entry in pairs(pend) do + -- Absent or terminal, or the same sid running a NEW generation: + -- in every case the request this entry awaits is unanswerable. + -- + -- The generation half is **defensive and not covered by the + -- acceptance suite**, stated plainly rather than left to look + -- tested. Reaching it requires a crash and its restart to both + -- fall inside a gap with no `_async.tick` — the crash backoff is + -- 500ms (`src/lsp.rs:1007`), so any tick during that window sees + -- `crashed` and the absent-or-terminal test above fires first. A + -- stalled or idle editor can produce such a gap, and then this is + -- the only thing standing between a one-shot and waiting forever + -- on a reply the dead generation owed. Every attempt to stage it + -- deterministically ended up exercising the `crashed` path + -- instead, so it is kept as insurance and labelled as such. + if attempt == nil or attempt ~= entry.attempt then + dead[#dead + 1] = rid + end + end + for _, rid in ipairs(dead) do + local entry = pend[rid] + pend[rid] = nil + local ok_h, err = pcall(entry.fn, nil, + { message = "server gone before response" }) + if not ok_h then + report_subscriber_error("response purge", err) + end + end + if next(pend) == nil then pending_responses[skey] = nil end + end +end + local function handle_server_requests() local sids, seen = {}, {} for _, rec in pairs(attachments) do @@ -1598,6 +1864,10 @@ local function handle_server_requests() -- LSP spells the field "unregisterations". pcall(unregister_file_watchers, sid, ev.params and ev.params.unregisterations) + elseif ev.kind == "notification" then + dispatch_notification(sid, ev) + elseif ev.kind == "response" then + deliver_response(sid, ev) elseif ev.kind == "initialized" then -- Buffers attach before the server finishes initializing, so -- the pulls in `attach_buffer` are no-ops for the FIRST file @@ -1620,6 +1890,10 @@ if pmacs._async and pmacs._async.tick then pmacs._async.tick = function(...) local ret = _prior_async_tick(...) pcall(handle_server_requests) + -- After the drain, so a response delivered this tick settles its + -- one-shot normally rather than being purged as "server gone" in the + -- same pass when the server died right after answering. + pcall(purge_dead_pending) pcall(flush_due_did_changes) return ret end diff --git a/builtin/runtime/pair.lua b/builtin/runtime/pair.lua index 9ed9d1f..5869dce 100644 --- a/builtin/runtime/pair.lua +++ b/builtin/runtime/pair.lua @@ -4,20 +4,30 @@ -- next char is already `)` steps over it instead of doubling it. The -- carrier is a `buffer.after-edit` reaction (Q#AP1): the opener stays -- a genuine single-codepoint self-insert — the classification --- signature help depends on — and this hook inserts (or swallows) the --- closer as a second edit. Provenance is the exact one-shot typed-edit --- record (`pmacs.editor.take_typed_edit()`, Q#AP9), not buffer-text --- inference: pastes, programmatic edits, manual hook runs, and a stale --- `this_command` have no record and never pair, and a transformed, --- relocated, or context-switching source self-insert fails closed. +-- signature help depends on — and this reaction inserts (or swallows) +-- the closer as a second edit. Provenance is the exact one-shot +-- typed-edit record (`pmacs.editor.take_typed_edit()`, Q#AP9), not +-- buffer-text inference: pastes, programmatic edits, manual hook runs, +-- and a stale `this_command` have no record and never pair, and a +-- transformed, relocated, or context-switching source self-insert fails +-- closed. -- --- This chunk loads BEFORE lsp.lua (Q#AP7): registration order is hook --- execution order, and lsp.lua's after-edit callback synchronously --- flushes didChange on the signature-trigger path — the closer must --- already be in the buffer when that callback runs. Everything under --- `pmacs.lsp` is therefore looked up lazily at callback time. +-- Since Arc 8 Stage 4a (Q#LN10) pairing no longer subscribes to +-- `buffer.after-edit` itself. It registers on the typed-edit chain +-- (`builtin/runtime/typed_edit.lua`), which owns the single subscriber +-- and the single one-shot read. Everything above still holds — the +-- record is the same record — but the chain, not this file, decides +-- who sees it and in what order. -- --- Framing: docs/auto-pairing-framing.md. +-- This chunk loads AFTER typed_edit.lua (it registers into it) and +-- BEFORE lsp.lua (Q#AP7): registration order is hook execution order, +-- and lsp.lua's after-edit callback synchronously flushes didChange on +-- the signature-trigger path — the closer must already be in the +-- buffer when that callback runs. Everything under `pmacs.lsp` is +-- therefore looked up lazily at callback time. +-- +-- Framing: docs/auto-pairing-framing.md; Stage 4a in +-- docs/lean4-mode-framing.md Q#LN10. pmacs.pair = pmacs.pair or {} @@ -40,7 +50,7 @@ local ed = pmacs.editor -- Per-buffer on/off switch (Q#CR8's flagship adopter). Read against the -- SOURCE buffer of the typed edit, never the currently active one — see --- the hook body below, which resolves it the same way `set_for` resolves +-- the consumer body below, which resolves it the same way `set_for` resolves -- the buffer's pair set (round 2, finding 2): `rec.buffer`, not -- `pmacs.window.buffer()`. pmacs.config.define { @@ -214,28 +224,36 @@ end -- Acceptance tests flip `_capture_records` on; each fan-out then -- publishes the record it observed (or nil) to `_last_record`, which -- is how tests read the exact codepoint / effective triple and prove --- one-shot-ness (this callback registers first and consumes it). +-- one-shot-ness (the chain takes the record before any other +-- `buffer.after-edit` subscriber can, and hands it here). pmacs.pair._capture_records = false -pmacs.hook.add("buffer.after-edit", function() +-- The typed-edit consumer (Arc 8 Stage 4a, Q#LN10). `rec` is the one +-- record `typed_edit.lua` read for this fan-out — possibly nil, which +-- is why the capture seam below is updated before the nil guard. +-- Returns whether pairing CLAIMED the keystroke: true once it has +-- committed to reacting (a skip-over or a closer insert, landed or +-- intercept-rejected), false on every decline. Pairing is last of the +-- builtin consumers, so nothing currently observes that value; it is +-- stated correctly so it stays correct when something does. +local function on_typed_edit(rec) -- One-shot provenance (Q#AP9). Absence — paste, programmatic edit, -- manual hook run, rejected insert, a post-insert mutation by the -- command, stale `this_command` — is a silent non-event; only a -- live record for a pair-set character that then fails a gate -- reports. - local rec = ed.take_typed_edit and ed.take_typed_edit() if pmacs.pair._capture_records then pmacs.pair._last_record = rec end - if not rec then return end - if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end + if not rec then return false end + if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return false end -- The master switch, per-buffer (Q#CR4): the SOURCE buffer of the -- typed edit, resolved buffer-local -> global -> default(true). A -- second buffer of the same language is untouched by a buffer-local -- override here (acceptance 29). - if not pmacs.config.get("editing.auto-pair", rec.buffer) then return end + if not pmacs.config.get("editing.auto-pair", rec.buffer) then return false end local buf = pmacs.window.buffer() - if not buf then return end + if not buf then return false end -- Relevance first (PR #110 round 1, finding 2): pairing has no -- interest in characters outside the set, so a transformed or @@ -247,14 +265,14 @@ pmacs.hook.add("buffer.after-edit", function() -- Rust. local ch = rec.char local openers, closers = maps_for(set_for(rec.buffer)) - if not (openers[ch] or closers[ch]) then return end + if not (openers[ch] or closers[ch]) then return false end -- Fail closed on a transformed source self-insert (Q#AP3): the -- intercept's positional result stands as produced; pairing on top -- of a relocated or expanded opener would compound it. if not rec.clean then ed.set_status("auto-pair skipped: source self-insert transformed") - return + return false end -- Fail closed when the source edit's context is no longer current: -- an intercept switched window/buffer, or something moved the @@ -268,14 +286,14 @@ pmacs.hook.add("buffer.after-edit", function() or pmacs.window.current() ~= rec.window or ed.cursor() ~= rec.post_cursor then ed.set_status("auto-pair skipped: source context changed") - return + return false end -- Region guard (Q#AP3/Q#AP6): on the dispatch route type-over has -- already consumed and cleared the region. A region surviving the -- edit means the TUI's selection-blind optimistic gate let a custom -- pair char through (named deferral) — reacting would pile a closer -- onto an unconsumed region. - if ed.region() ~= nil then return end + if ed.region() ~= nil then return false end local cursor = rec.post_cursor @@ -294,19 +312,19 @@ pmacs.hook.add("buffer.after-edit", function() if not ok then -- The duplicate stays (e.g. `())`); report, no retry. ed.set_status("auto-pair skip rejected by buffer intercept") - return + return true end if estart ~= cursor or estop ~= cursor + #ch or einserted ~= 0 then ed.set_status("auto-pair skip altered by buffer intercept") repair_cursor(win0, buf, cursor, estart, estop, einserted) end - return + return true end end local closer = openers[ch] - if not closer then return end - if not should_pair(buf, cursor, closers) then return end + if not closer then return false end + if not should_pair(buf, cursor, closers) then return false end local win0 = pmacs.window.current() local ok, estart, estop, einserted = pcall(function() @@ -315,7 +333,7 @@ pmacs.hook.add("buffer.after-edit", function() if not ok then -- Nothing landed; the opener stands alone. ed.set_status("auto-pair closer rejected by buffer intercept") - return + return true end if estart ~= cursor or estop ~= cursor or einserted ~= #closer then ed.set_status("auto-pair closer altered by buffer intercept") @@ -324,4 +342,11 @@ pmacs.hook.add("buffer.after-edit", function() -- Clean path: no cursor motion — the insert landed at the cursor -- and Lua mutators move no cursors, so it already sits between the -- pair; the daemon's per-tick CursorByte re-grounds both frontends. -end) + return true +end + +pmacs.typed_edit.add_consumer { + name = "auto-pair", + priority = 100, + fn = on_typed_edit, +} diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua index 6be0987..143a663 100644 --- a/builtin/runtime/terminal.lua +++ b/builtin/runtime/terminal.lua @@ -3,6 +3,38 @@ local terminal = assert(pmacs.terminal, "pmacs.terminal raw bindings are required") local raw_open = assert(terminal._open, "pmacs.terminal._open is required") +-- Q#TC2a. Every default reproduces today's behavior exactly, so a tree +-- with no settings written and no profiles registered behaves as before. +pmacs.config.define { + name = "terminal.default-profile", + type = "string", + default = "", + allow_empty = true, + mutability = "live", + description = "Profile name from pmacs.terminal.profiles to open by default. " .. + "Empty means no profile: fall back to $SHELL.", +} + +pmacs.config.define { + name = "terminal.scrollback-rows", + type = "integer", + default = 10000, + min = 0, + max = 4000000, + mutability = "live", + description = "Rows of scrollback retained per terminal. " .. + "0 retains no history.", +} + +pmacs.config.define { + name = "terminal.escape-key", + type = "string", + default = "C-c", + mutability = "live", + description = "Chord that escapes to the editor from a terminal. " .. + "Pressing it twice sends the chord itself to the child.", +} + local function bind_terminal_keys(buffer) local function bind(sequence, command) pmacs.keymap.bind { @@ -19,22 +51,145 @@ local function bind_terminal_keys(buffer) bind("M->", "terminal.scroll-bottom") end +-- Q#TC1: profiles are a raw Lua table, not a config setting. The +-- registry stores four scalars and has no table kind, so a profile — +-- inherently `{ command, args, cwd, env }` — lives here beside +-- `pmacs.lsp.config` and `pmacs.pair.sets` until table-valued settings +-- exist. +terminal.profiles = terminal.profiles or {} + +local PROFILE_FIELDS = { + command = "string", + args = "table", + cwd = "string", + env = "table", +} + +-- Every diagnostic below renders a caller- or user-supplied value, so +-- rendering must never be the thing that fails. `%q` is partial — it +-- raises on a table or function — and a profile name arrives straight +-- from `open { profile = ... }`. +local function describe_name(name) + if type(name) == "string" then return string.format("%q", name) end + return string.format("<%s %s>", type(name), tostring(name)) +end + +local function validate_profile(name, profile) + local shown = describe_name(name) + if type(profile) ~= "table" then + error(string.format("terminal profile %s must be a table", shown), 0) + end + for key, value in pairs(profile) do + local expected = PROFILE_FIELDS[key] + if not expected then + error(string.format("terminal profile %s: unknown field %q", shown, tostring(key)), 0) + end + if type(value) ~= expected then + error(string.format( + "terminal profile %s: field %q must be a %s, got %s", + shown, key, expected, type(value)), 0) + end + end + return profile +end + +-- `terminal.profiles` is a raw user table, so its keys are whatever the +-- user wrote. Sorting them directly raises "attempt to compare number +-- with string" the moment the table holds both a string and a numeric +-- key — and it raises on the UNKNOWN-PROFILE path, replacing the very +-- error this list exists to explain with an opaque one. Sorting DISPLAY +-- strings is total over every key type, so the diagnostic survives a +-- malformed table. +local function known_profile_names() + local names = {} + for name in pairs(terminal.profiles) do names[#names + 1] = tostring(name) end + table.sort(names) + return names +end + +-- Q#TC2 / Q#TC3a: resolve a profile by name, or nil when none is +-- selected. An explicitly requested profile that does not exist is an +-- error even when `terminal.default-profile` is valid — a typo must not +-- silently fall back to the default. +local function resolve_profile(requested) + local name = requested + if name == nil then + local configured = pmacs.config.get("terminal.default-profile") + if configured == nil or configured == "" then return nil end + name = configured + end + local profile = terminal.profiles[name] + if profile == nil then + local known = known_profile_names() + local listed = #known > 0 and table.concat(known, ", ") or "(none defined)" + error(string.format( + "terminal profile %s is not defined; known profiles: %s", + describe_name(name), listed), 0) + end + return validate_profile(name, profile) +end + +-- Q#TC3a merge order, per field: explicit open field, then the profile's +-- field, then the scalar setting, then the built-in fallback. `env` is +-- the one field where "first wins" would be wrong, so it MERGES with +-- explicit entries overriding the profile's — any other reading silently +-- drops half a user's environment. +local function merge_env(profile_env, explicit_env) + if profile_env == nil then return explicit_env end + local merged = {} + for key, value in pairs(profile_env) do merged[key] = value end + for key, value in pairs(explicit_env or {}) do merged[key] = value end + return merged +end + function terminal.open(spec) - local buffer = raw_open(spec) + spec = spec or {} + local resolved = {} + for key, value in pairs(spec) do + if key ~= "profile" then resolved[key] = value end + end + + local profile = resolve_profile(spec.profile) + if profile then + for key in pairs(PROFILE_FIELDS) do + if key ~= "env" and resolved[key] == nil then resolved[key] = profile[key] end + end + resolved.env = merge_env(profile.env, spec.env) + end + + -- The two open-time settings resolve through the GLOBAL chain + -- (Q#TC2b): they are read before the identity buffer exists, so there + -- is no terminal to resolve a buffer-local against. + if resolved.scrollback_rows == nil then + resolved.scrollback_rows = pmacs.config.get("terminal.scrollback-rows") + end + if resolved.command == nil then + resolved.command = os.getenv("SHELL") or "/bin/sh" + end + + local buffer = raw_open(resolved) bind_terminal_keys(buffer) return buffer end pmacs.command.define { name = "terminal", - description = "Open a terminal running $SHELL (or /bin/sh).", - fn = function() - return terminal.open { - command = os.getenv("SHELL") or "/bin/sh", - } + description = "Open a terminal running the configured profile, or $SHELL.", + fn = function(profile) + return terminal.open { profile = profile } end, } +-- Q#TC10: the opening binding. `COHERENCE.md` Priority 1 names a +-- terminal keybinding as part of protecting the golden journey, and §2 +-- step 8 grades the terminal "works but undiscoverable". `C-c` is +-- already a live global prefix (fold's `C-c @ ...`), so this is a new +-- leaf under it rather than a shadow. +-- +-- Named limitation: unreachable from INSIDE a terminal window, where +-- `C-c` is consumed as the escape. `M-x terminal` still works there. +pmacs.keymap.bind { scope = "global", sequence = "C-c t", command = "terminal" } + pmacs.command.define { name = "terminal.copy-selection", description = "Copy the active terminal selection.", diff --git a/builtin/runtime/typed_edit.lua b/builtin/runtime/typed_edit.lua new file mode 100644 index 0000000..6baf5f8 --- /dev/null +++ b/builtin/runtime/typed_edit.lua @@ -0,0 +1,183 @@ +-- typed_edit.lua --- the typed-character consumer chain (Arc 8 Stage 4a). +-- +-- `pmacs.editor.take_typed_edit()` is ONE-SHOT and per-frontend (Q#AP9): +-- the first `buffer.after-edit` callback to call it clears the slot, and +-- every later callback in the same fan-out --- including a nested manual +-- `pmacs.hook.run` --- sees nil. That was survivable only because +-- auto-pairing was the sole consumer, which was never a property anyone +-- chose. A second independent caller gets nil or steals the record from +-- pairing depending on hook registration order, and registration order +-- is not a contract. +-- +-- This module makes it one. It owns the single `buffer.after-edit` +-- subscriber that reads the record, and offers that one read to +-- consumers registered through `pmacs.typed_edit.add_consumer`: +-- +-- local handle = pmacs.typed_edit.add_consumer { +-- name = "auto-pair", -- for error reporting +-- priority = 100, -- LOWEST runs FIRST +-- fn = function(rec) ... return claimed end, +-- } +-- pmacs.typed_edit.remove_consumer(handle) -- -> true if it was live +-- +-- A consumer returns whether it CLAIMED the edit; the first that claims +-- stops the chain. "Claimed" means the chain stops, not that an edit was +-- made --- Stage 4b's abbreviation expander claims every keystroke that +-- extends a pending abbreviation precisely so that auto-pairing does not +-- also react to it (Q#LN22). +-- +-- Priority is an explicit number rather than load-order-implied, because +-- the ordering is load-bearing (Q#LN22: 64 Lean abbreviation keys +-- contain a character in the `lean4` pair set, and pairing running first +-- corrupts them) and a reader must be able to check it without +-- reconstructing `src/editor.rs`'s include list. +-- +-- ORDERING CONTRACT: this chunk loads BEFORE pair.lua, which registers +-- into it, and therefore before lsp.lua. That preserves Q#AP7 --- see +-- pair.lua's header and the load site in `src/editor.rs`. +-- +-- Framing: docs/lean4-mode-framing.md Q#LN10. + +pmacs.typed_edit = pmacs.typed_edit or {} + +-- Consumers in run order: lowest `priority` first, registration order +-- breaking ties. Maintained by ordered INSERTION rather than +-- `table.sort`, which is not stable in Lua --- equal priorities would +-- otherwise resolve arbitrarily, and "ties broken by registration +-- order" is part of the stated contract, not an incidental property. +local consumers = {} + +-- Handles are opaque to callers; only identity matters. An integer +-- counter is enough because nothing ever reuses one. +local next_handle = 0 + +-- `math.huge` is the only portable spelling of infinity available in +-- both LuaJIT and 5.4, and NaN is the only value not equal to itself. +local INT32_MIN, INT32_MAX = -2147483648, 2147483647 + +-- Register a typed-edit consumer; returns an opaque handle for +-- `remove_consumer`. Argument errors throw: registration happens at +-- chunk-load or config-load time, where a throw is a visible startup +-- failure rather than a silently missing feature. Nothing in the +-- after-edit path throws --- see the fan-out below. +function pmacs.typed_edit.add_consumer(spec) + if type(spec) ~= "table" then + error("pmacs.typed_edit.add_consumer: spec must be a table", 2) + end + local name, priority, fn = spec.name, spec.priority, spec.fn + if type(name) ~= "string" or name == "" then + error("pmacs.typed_edit.add_consumer: name must be a non-empty string", 2) + end + -- A bare `type(priority) == "number"` admits NaN and the infinities, + -- and EVERY ordered comparison against NaN is false --- so a NaN + -- consumer silently lands wherever the insertion scan happens to give + -- up, and the lowest-first contract other consumers depend on stops + -- holding. Bounded integers match `pmacs.completion.register`, whose + -- priority is an i32 on the Rust side. + if type(priority) ~= "number" or priority ~= priority + or priority == math.huge or priority == -math.huge + or priority % 1 ~= 0 + or priority < INT32_MIN or priority > INT32_MAX then + error("pmacs.typed_edit.add_consumer: " .. name .. + ": priority must be a finite integer in [-2147483648, 2147483647]", 2) + end + if type(fn) ~= "function" then + error("pmacs.typed_edit.add_consumer: " .. name .. + ": fn must be a function", 2) + end + + -- STRICTLY-greater comparison, so a new consumer lands AFTER every + -- already-registered consumer of equal priority. That is exactly the + -- registration-order tiebreak; `>=` here would silently reverse it. + local at = #consumers + 1 + for i, c in ipairs(consumers) do + if c.priority > priority then + at = i + break + end + end + next_handle = next_handle + 1 + local handle = next_handle + table.insert(consumers, at, + { handle = handle, name = name, priority = priority, fn = fn }) + return handle +end + +-- Unregister a consumer by the handle `add_consumer` returned. Returns +-- true if it was registered, false otherwise (so a double-remove is a +-- reportable no-op rather than a throw). Without this, re-evaluating a +-- config or reloading a package accumulates callbacks permanently --- +-- the leak COHERENCE.md §13 already records against `pmacs.hook.add`, +-- which this chain would otherwise inherit and spread. +function pmacs.typed_edit.remove_consumer(handle) + for i, c in ipairs(consumers) do + if c.handle == handle then + table.remove(consumers, i) + return true + end + end + return false +end + +pmacs.hook.add("buffer.after-edit", function() + local ed = pmacs.editor + -- ONE read for the whole fan-out (Q#AP9). The record may be nil --- + -- paste, programmatic mutation, manual hook run, a replicated CRDT + -- op, a stale `this_command` --- and consumers are called ANYWAY, + -- with nil. That is deliberate: "this fan-out carried no typed edit" + -- is information a consumer acts on. Auto-pairing's test seam + -- observes the non-event through it, and Stage 4b abandons a pending + -- abbreviation that an unrelated edit invalidated. Skipping the + -- fan-out on nil would leave both reading stale state. + local rec = ed.take_typed_edit and ed.take_typed_edit() + + -- Iterate a SNAPSHOT. A consumer may register or remove consumers + -- while the chain is running, and `table.insert`/`table.remove` on + -- the live array shifts indices under `ipairs` --- a consumer that + -- registers a lower-priority one shifts itself forward and runs + -- twice, and repeating that is unbounded. Registrations and removals + -- made during a fan-out therefore take effect on the NEXT fan-out. + local snapshot = {} + for i, c in ipairs(consumers) do + snapshot[i] = c + end + + for _, c in ipairs(snapshot) do + -- Each consumer gets its OWN copy of the record. The table handed + -- out is plain Lua data, so a declining consumer could otherwise + -- edit `rec.char` in place and the next consumer would act on the + -- forged value --- auto-pairing reads `rec.char` to decide what to + -- close, so a rewritten `char` makes it insert a pair the user + -- never typed. Every field is a scalar or an opaque id, so a + -- shallow copy is a complete snapshot. + local mine = nil + if rec ~= nil then + mine = {} + for k, v in pairs(rec) do + mine[k] = v + end + end + + -- Contain the consumer. A throw here would skip every LATER + -- consumer in the chain and mark the whole `buffer.after-edit` run + -- failed; the other subscribers still run, because all-must-succeed + -- collects errors and continues (`src/hook.rs`'s + -- `run_all_must_succeed`), but one broken consumer must not be able + -- to silently disable the ones behind it. This matches pair.lua's + -- existing never-throw-from-after-edit discipline. + local ok, claimed = pcall(c.fn, mine) + if not ok then + -- Rendering is itself protected: a Lua error may be any value, + -- including a table whose `__tostring` throws, and an escaping + -- error here would defeat the containment above. + local shown, rendered = pcall(tostring, claimed) + if not shown or type(rendered) ~= "string" then + rendered = "" + end + pcall(ed.set_status, + "typed-edit consumer '" .. c.name .. "' failed: " .. rendered) + elseif claimed then + return + end + end +end) diff --git a/docs/active-work.md b/docs/active-work.md index 1ad3bdc..547927a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-25.** This file records volatile work that has not +**Snapshot: 2026-07-26.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -23,12 +23,13 @@ here too until #172 removed it — that is the update those two owe.) 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` @ `c93f9ee` (the bottom-panel Stage 2 framing #175 - atop the CRDT undo repro #157, the inline-math landed-doc refresh #172, - the bottom-panel landed-doc refresh #156, the inline-math slice #158, - dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 Stage 2 - #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean 4 - Stage 1 #160, and the minimap blank-slab fix #159; protocol v20). + `githubsucks/main` @ `d400f30` (Lean 4 Stage 3b #170 atop Stage 3a + #167, the bottom-panel landed-doc refresh #156, the inline-math slice + #158, dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 + Stage 2 #161, the dired framing #164, COHERENCE.md #163, find-file + #162, Lean 4 Stage 1 #160, and the minimap blank-slab fix #159; + protocol v20). The previous snapshot named `d152120`; the recovery + check below accepts it or anything newer. **Lanes below that name an older base have not been re-based; derive their integration surface from `git diff ..main`.** - On the transfer source, `origin/main` named a release mirror at @@ -71,139 +72,172 @@ declares canonical will pass on a tree the rest of this file does not describe. If it does not, stop and repair the remote/fetch configuration. -## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161) +## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b MERGED; Stage 4a IN REVIEW -- Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review - round, all twelve checks green). Branch `githubsucks/lean4-stage1` - retained; it was worked in the shared checkout (no sibling worktree). -- Approved framing: `docs/lean4-mode-framing.md` revision 4, committed as - the branch's first commit (`a382965`) after three review rounds. **Seven - stages**, 19 decisions (Q#LN1–19), 64 acceptance criteria. North star: - match or exceed VS Code's Lean support. -- **Stage 1 implemented; no wire change (protocol stays v20), no LSP, no - frontend change.** Four commits: framing, grammar, theme captures, - editing surface + acceptance. - - `Cargo.toml` + `src/syntax.rs`: `arborium-lean` 2.18 and one - `BUILTIN_LANGUAGES` entry named **`lean4`** (Q#LN2 — the name becomes - the `didOpen` language_id), claiming `.lean` only. - - `src/highlight.rs`: four capture entries — `constructor`, `character`, - `keyword.conditional`, `warning`. - - `builtin/runtime/{comment,pair,syntax}.lua`: `--` comments, the - `⟨⟩ ⦃⦄ ⟮⟯` pair set, the `lean` → `lean4` modeline alias. - - `tests/lean4_stage1_acceptance.rs` plus unit tests in `syntax.rs` / - `highlight.rs`: 12 criteria, 17 tests. -- **Q#LN1's open obligation is discharged.** `tree-sitter-lean4` is - unusable (depends on `tree-sitter ^0.25` directly against our 0.26, - exports no `LANGUAGE` const despite its README, packages no queries); - `arborium-lean` rides `tree-sitter-language 0.1` with a pre-generated - ABI-15 parser. `cargo tree -d` shows no duplicate core. The parse smoke - pins the failure mode that matters: `→`/`∀`/`≥` must produce - `(arrow)`/`(forall)`/`(comparison)`, since a mismatched-core build - degrades silently on exactly those characters rather than failing loudly. -- **Q#LN4 is a deliberate retro-paint of seven language entries**, not - four: `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated - base-first into javascriptreact/typescript/typescriptreact. Its shape is - "every capitalized identifier" (`#match? "^[A-Z]"`) plus every Lua table - brace — not "constructors". Pinned in both directions per #146. -- Implementation findings not in the framing: - - `warning` had to move from bold red to bold **bright** red: `number` - is plain `fg(1)`, so `sorry` and an adjacent numeric literal were the - same colour. Found by writing the test. - - `Some(1)` is **not** `@constructor` — in call position a narrower - `@function` pattern wins. Only bare or pattern-position capitalized - identifiers reach it. Pinned so the blast-radius claim stays honest. - - Lean node kinds nest: `module > declaration > def|theorem`. - - `pmacs.parse.injection_aliases` is a documented **write-only** Lua - proxy (canonical map is Rust-side), so fence tests must drive - `_parse_now` and inspect layer languages, never read the table back. -- **Review round 1 addressed.** The finding: acc12's server-list assertion - could not fail for the regression it named — the shared `editor()` - helper wipes `pmacs.lsp.config` before any buffer opens, so - `#pmacs.lsp.list() == 0` holds for every language regardless of what - Stage 1 ships. It now asserts against a **pristine** `EditorState` that - `pmacs.lsp.config.lean4` is nil, with a non-vacuity check that the same - lookup finds `rust`; bite-verified by adding a `lean4` config to - `lsp.lua` and watching it fail. Also fixed a stale column in a - `highlight.rs` comment. -- Verification on this branch: `cargo fmt --check` clean; strict workspace - Clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1 - 9/9; comment toggle 14; auto-pair 45; injection 4; M4 121; required GPU - 152; **isolated-config workspace sweep 3,150 across 90 suites**; - `git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME` - for the reason recorded in the bottom-panel lane below. -### Stage 2 — multi-root LSP server affinity (Q#LN15) +- **Stages 1, 2, 3a and 3b are MERGED** — #160 (`main` @ `0827dd1`), + #161 (`46a1b8f`), #167 (`6f348c9`), #170 (`d400f30`). Their full + histories were pruned from this ledger in round 6, per this file's own + instruction to remove entries when their PR merges; the durable facts + now live in `docs/agent-handoff.md` §1's Lean 4 bullet, which is where + a fresh machine should read them. `docs/lean4-mode-framing.md` rev 8 + carries the decisions. -- Portable branch: `githubsucks/lsp-multi-root-affinity`, shared checkout, - based on `githubsucks/main` @ `0827dd1`. Named for the substrate, not - for Lean: **the diff contains no Lean content**, because `ensure_server` - is the one server-affinity function every LSP language shares and a - cross-cutting change to it must not be reviewable only as a Lean - feature. -- Three files, no protocol change: `src/lua_bindings/mod.rs` (the - `lsp.list()` row builder gains `root_uri` + `cwd`), - `builtin/runtime/lsp.lua` (`project_root_for` returns `root, source`; - `ensure_server` hoists it above the reuse loop and matches on it), - `tests/lsp_multi_root_acceptance.rs` (9 tests, acceptance 13–21). -- **The rule that keeps this from regressing every other language: the - affinity key is the root only when a root was actually FOUND.** - `project_root_for` never returns nil for a file with a path — its last - resort is the file's own directory — so a naive `(language_id, root)` - key gives every directory of loose scratch files its own server, for - every language. `source` is `"config" | "detected" | "fallback"` and - only the first two become a key. -- **Wire-identical for the fallback case, and that is provable rather - than hoped.** Matching is on the spawned spec's `root_uri` (nil matching - nil), so the fallback spawn passes `root_uri = nil`; `cwd` still carries - the directory and `build_initialize` derives the identical `rootUri` - from `cwd` when the field is None, using a percent-encoder with the same - allowed set as Lua's `file_uri_for`. `build_initialize` (`src/lsp.rs`) - is the **only** reader of `spec.root_uri` in the tree. -- Deliberate behavior change, asserted not discovered: a server - hand-spawned from `init.lua` with only `cwd` set also reads back nil, so - a root-bearing attach will not adopt it. -- `config[language].root` may now be a `function(path) -> string|nil`, - memoized per directory — needed because the hoist puts root resolution - on every attach rather than every spawn. The memo is keyed **weakly by - the resolver function itself**, so replacing `config[lang].root` cannot - serve a root the previous resolver computed. This is Q#LN8's - generalization landing early; the Lean resolver that uses it is Stage 3. -- Bite-verified three ways: 5/9 fail against the pre-change `lsp.lua`, - 8/9 against the pre-change `mod.rs`, and — the one that matters most — - installing the naive always-key-on-root variant fails acceptance 20 and - 21 exactly as Q#LN15 part 2 predicts. The four that survive the first - bite (13, 15, 16, 19) are the regression pins; passing on both sides is - their job. -- Every fixture sets `pmacs.project.set_search_boundary` at its own - tempdir root. Without it the marker walk climbs to the filesystem root - and a stray `.git` above the temp directory turns the markerless cases - into detected ones — the assertions would still pass while testing - nothing. -- **Found but not fixed here (pre-existing, own lane):** `ensure_server` - never forwards `cfg.restart` to `pmacs.lsp.spawn`, so a - `restart = "never"` in `pmacs.lsp.config[lang]` is silently dropped on - the auto-attach path. At least one existing test sets it believing it - takes effect. Out of scope for a PR whose acceptance 16 pins existing - attach behavior as unchanged. -- **Review round 1 addressed.** The blocker was process, not design: the - test file was committed *before* `cargo fmt` ran, so the fix sat - uncommitted in the working tree and the branch as pushed failed the - first gate. The reported "fmt clean" described the worktree, not the - branch — gate results are only meaningful when run against the pushed - tree. Also added the two pins review asked for (a **string** `config - .root` as an affinity key — acc17 only covered the function form; and - `root = false` reading as unset), each bite-verified against exactly - the mutation it targets and neither against the other. And documented - the canonicalization obligation: the `"detected"` arm is canonicalized - for free, a **configured** root is not, so on macOS a resolver - returning `/var/…` and a detected `/private/var/…` are different keys - for one directory. Stage 3's Lean resolver is the first real consumer, - so the obligation is written at the point of use. -- Verification on this branch: `cargo fmt --check` clean; strict - workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; - multi-root 11/11; M4 121; statusline 7; completion popup 9; auto-pair - 45; required GPU 155; **isolated-config workspace sweep 3,164 across 91 - suites**; `git diff --check` clean. The sweep needs an isolated - `XDG_CONFIG_HOME` and `-- --skip basedpyright`. +### Stage 4 — framing rev 8, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`) + +- Stages 3a and 3b **merged as #167** (`main` @ `6f348c9`) and **#170** + (`main` @ `d400f30`), 2026-07-26. Both were integrated against a main + that had advanced 50 commits mid-review; the only conflict either time + was this ledger's own lane headings, resolved by keeping both sides. +- Worktree `../pmacs-lean-stage4`, branched off `main` @ `d400f30`. + Framing-only so far: `docs/lean4-mode-framing.md` **revision 8**. No + code. Awaiting user approval before implementation, per the workflow. +- **Round 6 review found five P1s, four of them internal to rev 6** — + facts about pmacs the revision asserted without checking, while its + external (upstream) facts held. Fixed in rev 7: Stage 4a's footprint + omitted the test file its own acceptance requires; pending + abbreviation state was keyed by buffer when pmacs is **multi-frontend** + (`EditorCore.views` is per-`FrontendId`, `take_typed_edit` is already + frontend-keyed, and `buffer.after-switch` fires with NO arguments, so + a buffer-keyed clear lets any frontend discard another's pending + state); the shortest-match rule was missing its **tie-break by source + declaration order**, which 101 prefixes depend on and a `pairs`- + iterated Lua map cannot express; and the generator's "abort on keys + needing escaping" rule **rejects the real table** (`\` is a key, `"` + begins eleven). +- **A 404 on a guessed path is not evidence of absence.** Rev 6 declared + the upstream package ships no README after fetching the package root, + with the directory listing showing `src/README.md` already in hand. + The README states the tie rule in one sentence. +- **Round 7 review found one remaining P1 in acceptance 45i.** Rev 7 + required A's pending abbreviation to survive B editing the same + buffer, while Q#LN22 also required an exact buffer-revision advance. + Those cannot both hold: revisions are buffer-global and every edit + bumps them. Rev 8 keeps the conservative guard and separates + ownership from survival — B cannot consume A's record, but B editing + the shared buffer invalidates A lazily; B switching buffers or + detaching remains frontend-scoped when no shared-buffer edit + intervenes. +- **Round 5 re-scout split Stage 4 into 4a (substrate) and 4b (Lean).** + 4a is the typed-edit consumer chain — `builtin/runtime/typed_edit.lua` + plus `pair.lua` re-expressed as one registered consumer, no behavior + change. 4b is the input method. The split is forced by §4's own rule, + which Stage 4's risk column ("refactors `pair.lua`'s provenance read") + broke while the prose called the stage Lean-only. +- **This is the SECOND consecutive re-scout to find that rule broken** + (round 4 found it for Stage 3). Rev 5 had even noticed the shape and + answered it with a commit boundary. **A commit boundary is not a review + boundary.** Re-check every remaining stage against §4 at scout time; + the rule is not self-enforcing. +- **Rev 5's expansion semantics were wrong in three ways**, found by + reading `leanprover/vscode-lean4` @ `17d1d08` rather than inferring + from behavior. Resolution is *shortest key having the input as a + prefix* (`\al` → `∀` from `all`, not `alpha`); there is **no + terminator list** (`'+ '` is a key, so space extends after `\+`; `'\'` + is a key, so `\\` → `\`); and an unmatchable tail is **appended**, + not dropped (`\alp7` → `α7`). +- **There is no cursor-motion hook**, so rev 5's acceptance 43 ("moving + the cursor out abandons it") was not buildable. Abandonment is lazy — + validated at the next typed edit — and the criterion now asserts what + pmacs can actually detect. Upstream drives this off `changeSelections`; + that seam does not exist here. +- **`dispatch_key` is only half the production path for 4b.** The + auto-pair suite gets away with dispatch-only because Q#AP1 removed the + pair chars from the optimistic classifiers; `\` and the letters are + NOT excluded, so on a CRDT frontend the optimistic producer is the real + path. That producer is `#[cfg(feature = "crdt")]` and CI never enables + `crdt`, and the gate list runs `--features crdt` only for `--lib` — a + crdt-gated integration test is **dark twice over**. +- The whole expansion has cross-peer-degraded undo (Q#LN21): six + source-peer optimistic inserts replaced by one daemon-peer op. + `set_round_trip_input` would fix it and is rejected — it also disables + `dispatch_idle`, so RET stops inserting a newline. +- Table facts re-derived at `17d1d08`: 1,855 entries, 36,861 bytes, all + keys ASCII, **64** keys carry a `lean4` pair-set char, **305** keys are + proper prefixes of another (so 1,550 expand eagerly), **26** values + carry `$CURSOR`, and **119** are multi-codepoint — the 26 + `$CURSOR`-bearing values plus 93 others. +- Citation sweep per COHERENCE §25: five live citations moved in the 50 + commits since rev 5 — `take_typed_edit` 12827→12990, + `handle_server_requests` 1549→1815, `fs.stat` 93→133, + `detect_buffer_language` 452→457, `send_request`/`send_notification` + 9342/9361→9507/9527. +### Stage 4a — the typed-edit consumer chain (IMPLEMENTED, same branch) + +- Footprint exactly as Q#LN10 declares it: `builtin/runtime/typed_edit.lua` + (new), `pair.lua` re-expressed as one consumer, + `src/editor.rs` +15 (the `include_str!` and its ordering comment), and + `tests/typed_edit_chain_acceptance.rs` (new, 13 tests). + **`tests/auto_pair_acceptance.rs` is UNCHANGED — `git diff --stat + main...HEAD -- tests/auto_pair_acceptance.rs` is empty.** That is + criterion 46 checked at the diff, which is the only way it means + anything. +- **The chain calls consumers even when the record is nil.** This is a + decision, not an implementation detail: three existing auto-pairing + tests assert `pmacs.pair._last_record == nil` after a record-less + fan-out (paste, programmatic insert, nested manual `hook.run`), so + skipping consumers on nil fails them. Stage 4b needs the same + delivery to abandon a pending abbreviation an unrelated edit + invalidated. +- **Ordered insertion, not `table.sort`** — Lua's sort is not stable, and + "ties broken by registration order" is a stated contract. +- **The chain `pcall`s each consumer** and reports through + `set_status`. Rev 7 justified this by claiming an uncontained throw + would fail the fan-out for every other subscriber including lsp.lua's + didChange flush; **that is wrong** — `run_all_must_succeed` + (`src/hook.rs:332`) collects errors and continues, so the other + subscribers still run. The real consequence is narrower and still + worth containing: the throw skips every LATER consumer in the chain. + The rendering is protected too, because a Lua error may be a table + whose `__tostring` throws. +- **Round 8 (review) findings, all fixed on this branch:** each consumer + now gets its **own shallow copy** of the record (the same table let a + declining consumer rewrite `rec.char`, which pairing reads — typing + `x` could produce `x)`); the fan-out iterates a **snapshot** (a + consumer registering a lower-priority one shifted itself forward under + `ipairs` and ran twice, unbounded if repeated); `tostring` moved + inside the containment; **non-finite and non-integer priorities are + rejected** (NaN is a number and every ordered comparison with it is + false, so it landed wherever the insertion scan gave up and silently + voided the ordering contract); and `add_consumer` now returns a handle + with `remove_consumer` beside it, so re-evaluating a config no longer + leaks callbacks the way `pmacs.hook.add` does (COHERENCE §13). +- **Every acceptance test is bite-verified by mutation**, per the + standing rule that a test is not evidence until the mutation it + targets has been shown to fail it: + + | Mutation | Tests it fails | + |---|---| + | append instead of ordered insert | 5 chain | + | `>=` instead of `>` in the insert scan | 1 chain (tiebreak) | + | re-take the record per consumer | 4 chain | + | ignore the claim return value | 1 chain | + | drop the `pcall` | 1 chain | + | skip consumers when `rec == nil` | 1 chain + **3 auto-pair** | + | load `typed_edit.lua` after `lsp.lua` | 1 chain + **2 auto-pair** (Q#AP7) | + | hand every consumer the same record table | 1 chain (46f) | + | iterate the live array instead of a snapshot | 1 chain (46g) | + | render the error outside the `pcall` | 1 chain (46d) | + | accept any Lua number as a priority | 1 chain (46h) | + | make `remove_consumer` a no-op | 2 chain (46g, 46h) | + + The first attempt at the last bite was WORTHLESS as written: moving + only `typed_edit.lua` past `lsp.lua` left `pair.lua` calling a nil + `add_consumer`, so the runtime failed to load and all 9 tests died — + loud, but not a test of the flush-ordering property. Moving + `typed_edit.lua` AND `pair.lua` past `lsp.lua` is the faithful + falsification: registration succeeds, the hook lands late, and exactly + the three ordering tests fail. **A bite that kills everything has not + isolated anything.** +- Verification on this branch (commit-then-gate, so this describes the + pushed tree): `cargo fmt --check` clean; strict workspace Clippy + clean; 1,832 default + 2,009 CRDT library tests; auto-pair 45/45; + typed-edit chain 13/13 (and 13/13 again under `--no-default-features + --features lua54`, since the fixes touch `math.huge`, `%`, and + `__tostring` behavior that differs between the backends); M4 121; + required GPU 202; **isolated-config workspace sweep 3,332 across 97 + suites, zero failures** with `grep -c basedpyright` = 0; `git diff + --check` clean. +- Stage 4b (the input method) is NOT in this PR and not started. ## GPU terminal input lane — IN REVIEW @@ -257,10 +291,166 @@ If it does not, stop and repair the remote/fetch configuration. **isolated-config workspace sweep 3,177 across 92 suites, zero failures**; `git diff --check` clean. Gates were run against the committed tree. -## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 IN FRAMING +## Terminal config + copy mode arc — Stage 1 IN REVIEW -Stage 1 is on `main`. **Stage 2 is in framing**, no implementation in -flight. +- Approved framing: `docs/terminal-config-and-copy-mode-framing.md` + **revision 4** (four review rounds), committed as the first commit of + Stage 1's branch. Two stages, two branches, two PRs; **no protocol + change**. +- **Stage 1 = `githubsucks/terminal-config`**, worktree + `../pmacs-terminal-config`, based on `githubsucks/main` @ `d152120` + and merged up to `c93f9ee` during review round 1. Profiles, + scrollback, escape key, and the `C-c t` opening binding. +- **Stage 2 = `terminal-copy-mode`, not started.** Branch it off `main` + after Stage 1 merges: no dependency, but both edit + `builtin/runtime/terminal.lua`. +- Load-bearing decisions, each forced by scouted ground truth: + - profiles are a **raw Lua table** — `ConfigValue` is four scalars with + no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`; + - the **two open-time settings resolve through the global chain**, + because they are read before the identity buffer exists; only + `terminal.escape-key` resolves per buffer; + - the escape cache lives on **`TerminalSession`** so its lifetime is + the terminal's. `value_epoch` alone is not a sufficient key: it does + not advance when focus moves between terminals with different + buffer-local values; + - repeating the escape sends **that chord**, not a hardcoded `0x03`. +- **Four bites, each against a different plausible wrong + implementation** — hardcoded ETX fails acc6/9; epoch-only cache key + fails acc7; single last-entry cache fails acc8's parse count; removing + the invalid-value fallback fails acc10. The first version of acc7 + passed against the epoch-only bite because it asserted only that + terminal A still worked; the discriminating assertion is that **each** + terminal honors its own chord and not the other's. +- Test instruments worth reusing: `cat -v` is the echo probe, because the + screen rejects C0 controls before they reach cells so a raw echoed + `Ctrl-X` is invisible; and the probe **counts occurrences** rather than + testing presence, because a single-character probe collides with the + child's own banner text. +- **Review round 1 (2026-07-25) — five findings, all real, all fixed.** + One blocker and two majors were the same failure in three places: a + claim asserted somewhere cheaper than where it lives. + - *Blocker — `COHERENCE.md` was stale in four places, not the three + reported.* Step 8 still read "no keybinding"; §11 still read "five + settings"; and §6's dispatch table still cited + `is_terminal_escape_chord`, **a symbol this PR deletes**. §25 makes + that update ride the PR. A PR that changes audited ground truth has + to re-grep the audit for its own symbols, not only for its topic. + - *Major — acceptance 5 was vacuous.* It asserted a registry + round-trip, so it stayed green with the setting's **only** consumer + deleted. It now opens a real terminal whose child overflows the + 24-row screen, scrolls the view to its oldest retained row, and + asserts `LINE001` is present at 10,000 and absent at 0. **Asserting + a value was stored is not asserting anything reads it.** + - *Major — acceptance 8a asserted the session count, not the cache.* + An editor-side map with no purge hook — the exact rejected design — + leaks *while* sessions drain, so it passed. Fixed with a + `TerminalManager::escape_caches()` seam. **A lifecycle claim needs a + lifecycle observable.** + - *Moderate — `table.sort` over user-controlled profile keys.* A + table holding both a string and a numeric key raised `attempt to + compare number with string` **on the unknown-profile path**, + replacing the diagnostic being asked for; `%q` raised likewise on a + non-string `profile` argument. Both are partial functions applied to + user input **on a diagnostic path** — the error reporter was the + thing that failed. + - *Minor — the committed framing still said "not yet approved".* +- **Three new bites, each falsified by revert**: deleting the scrollback + consumer fails acc5 (and only acc5); restoring the raw-key sort + reproduces `attempt to compare string with number` verbatim; and + implementing the rejected editor-side map fails the new acc8a at + `left: 2, right: 1` **while passing the old session-count version** — + which is the review finding demonstrated rather than argued. +- Verification after the round-1 fixes, on the tree merged with + `githubsucks/main` @ `c93f9ee`: `cargo fmt --check` clean; strict + workspace Clippy clean; 1,832 default + 2,009 CRDT library tests; + `terminal_config_acceptance` **12/12 in both configurations**; vterm + Stage 1/2 9+10 / 6+6; config registry 16+16; bottom-panel Stage 1 + 46+46; M4 121; required GPU 202; `git diff --check` clean. + - `compile_mode_acceptance` fails 11/67 against the **real** user + config and passes 67/67 with an isolated `XDG_CONFIG_HOME` — the + known pre-existing trap, not this branch. + - **`vterm_stage3_acceptance::a37` fails on this machine — and fails + identically on the PR's own base `d152120`**, so it is not this + branch's regression. It is load-sensitive: it passed at `d152120` + once and failed at that same commit twenty minutes later, with a + second agent saturating the machine with `rustc` in between. Two + ways it lies, both worth knowing: it **silently returns `ok` when + `pmacs-gpu` is not built** in the same target dir (only + `PMACS_REQUIRE_GPU=1` promotes that skip to a failure, and the gate + list applies that flag to `-p pmacs-gpu`, a *different* package), and + it is **crdt-gated, so CI has never run it at all**. A green a37 in + a gate log means nothing unless the binary was built and the flag + was set. Needs its own lane; see the CI `crdt`-coverage lane on #168. + - `pmacs-gpu` itself failed 201/202 once under the same load and passed + 202/202 on immediate rerun. + +## Bottom-panel lane (Arc 7) — Stage 1 + framing MERGED; Stage 2A IN REVIEW + +Stage 1 and the Stage 2 framing are on `main`. **Stage 2A is +implemented and in review.** + +- **Stage 2A — portable branch `githubsucks/bottom-panel-stage2a`**, + worktree `../pmacs-bp-stage2a`, **canonical `main` @ `cf54270` + integrated** (review round 1, finding 4 — the terminal-config lane + #173 also changes `src/editor.rs`, so gates were rerun on the merge + result, not the old combination). Five commits: the classified census + routing, the painter extraction + acceptance, the lane record, then + the round-1, round-2 and round-3 review fixes. **No protocol change; no behavior + change for any frontend today** — with `panel_capable = false` for + semantic sessions, `primary_document_window` returns `view.active` + in every existing configuration, so this is seam adoption that + becomes load-bearing in 2B. +- Verification on the merge result: `cargo fmt --check` clean; strict + workspace Clippy clean; **1,832 default + 2,015 CRDT** library tests; + `bottom_panel_stage2a_acceptance` **17**; bottom-panel Stage 1 46; + statusline segments 8 CRDT; m11_5 semantic 2 CRDT; GPU initial target + 14 CRDT; terminal config 12 CRDT; vterm Stage 1/2 10 / 6; folding + Stage 2 48; M4 121; required GPU 202; `git diff --check` clean. +- **Every routed producer is now pinned at a seam its production caller + uses, and each pin was falsified by revert**: #1 follow, #2 lazy CRDT + upgrade, #3 `CursorByte`, #5 decorations, #7 `Viewport` (aligns + without focusing), #8 `Pointer` (aligns and focuses), #9 the + terminal-context gate, #12 statusline, #21 the publication filter, + plus the focus-class negatives. #1/#3/#21 required extracting three + named helpers, because their only production caller is + `dispatcher_loop`, which no test can drive. +- **Three lessons about the TESTS, not the code, all from review:** + (a) a *structural* test comparing the two authorities directly does + **not** catch a misrouted consumer — only consumer-level assertions + do; (b) a daemon-path test must `register_session` or the event is + dropped at the uninstalled-session check before reaching the code + under test; (c) a discriminating fixture must make the two routings + DISAGREE — comparing two non-terminal buffers, or two windows with no + selection, yields the same answer either way and proves nothing. + Round 2 found four of my own pins vacuous by exactly these shapes, and + round 3 found two more problems of the same family: a pin placed at a + HELPER while production called it from a producer (reverting only the + producer's call site left every test green), and a socket-pair + assertion whose blocking read made a regression HANG instead of fail. + Both now assert at the producer, with read timeouts on every read. +- **Review round 1 closed: 4 P1 + 2 P2, all real.** The P1s were a + stale-`Pointer` focus steal (the failed-alignment arm returned the + window, so #8's activation focused it before `dispatch_pointer` + rejected the buffer), the missing A2A-2 two-context fan-out, a census + suite that asserted the AUTHORITY rather than the CONSUMERS, and the + missing main integration. **Two of the new pins were themselves + vacuous on the first attempt** — the dispatcher test passed because an + unregistered session is dropped at `daemon.rs:1962` before reaching + the aligner, and the painter test was a fixed-point check that + survived deleting `text_view.render`. Both now fail under their own + bite. +- **`vterm_stage3_acceptance::a37` is a pre-existing flake here**, not a + Stage 2A regression: measured **6/8 failures on the base commit** and + **7/8 on the branch** in matched isolated samples. It needs a real + daemon + real PTY + headless GPU and is documented load-sensitive. + It also silently returns `ok` unless `pmacs-gpu` has been built, and + is `crdt`-gated so CI never runs it at all. +- **Two suites are dark without `--features crdt`**: + `m11_5_semantic_acceptance` reports **0 tests** and + `gpu_initial_target_acceptance` reports **1** in the default config. + Both are semantic-census suites, so Stage 2A must be gated with the + feature on or its most relevant coverage never executes. - Stage 1 merged as **#155** (`main` @ `e745068`, 2026-07-24, after two review rounds). No protocol change. Durable substrate facts live in diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index bee1d6f..02c9dcb 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,11 +1,13 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-25, after the CRDT undo repro (#157) and the -inline-math landed-doc refresh (#172) — following the bottom-panel -landed-doc refresh (#156), the inline-math slice (#158), dired Stage 1 -(#165) — the directory view — the GPU terminal input fix (#166), Lean 4 -Stage 2 (#161), the dired framing (#164), find-file (#162), the dired -arc's Stage 0, and COHERENCE.md (#163), Lean 4 Stage 1 (#160), the +**Last updated: 2026-07-26, after Lean 4 stages 3a and 3b (#167, #170) +landed — pmacs' first Lean language server — following the CRDT undo +repro (#157), the inline-math landed-doc refresh (#172), the +bottom-panel landed-doc refresh (#156), the inline-math slice (#158), +the first mathematical typesetting in pmacs, dired Stage 1 (#165) — the +directory view — the GPU terminal input fix (#166), Lean 4 Stage 2 +(#161), the dired framing (#164), and find-file (#162), +the dired arc's Stage 0, and COHERENCE.md (#163), Lean 4 Stage 1 (#160), the minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the GPU initial-target doc refresh (#152); and before that GPU @@ -28,16 +30,16 @@ reads it the way you just did. For volatile branches, checkpoints, verification, and recovery commands, read `docs/active-work.md` immediately after this file. -## 1. Where the project stands (2026-07-25) +## 1. Where the project stands (2026-07-26) -- `main` @ `ccf29e3` (the CRDT undo repro #157 atop the inline-math - landed-doc refresh #172, the bottom-panel landed-doc refresh #156, the - inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix - #166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163, - find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159, - bottom-panel Stage 1 #155). Protocol unchanged at **v20**. The bullets - below describe the arcs in their own terms; this line is the - head-of-`main` anchor. +- `main` @ `d400f30` (Lean 4 Stage 3b #170 atop Stage 3a #167, the CRDT + undo repro #157, the inline-math landed-doc refresh #172, the + bottom-panel landed-doc refresh #156, the inline-math slice #158, + dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 Stage 2 + #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean + 4 Stage 1 #160, minimap blank-slab #159, bottom-panel Stage 1 #155). + Protocol unchanged at **v20**. The bullets below describe the arcs in + their own terms; this line is the head-of-`main` anchor. - **`COHERENCE.md` is now required reading and a required framing input — #163.** It carries the product-coherence thesis, an audited scorecard, per-concern gaps, and §20's priority order, and it is the @@ -46,6 +48,72 @@ 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 **broken at step 3** (`pmacs .` exits 1). +- **Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b LANDED** + (`docs/lean4-mode-framing.md`; #160, #161, #167, #170; merge + `d400f30`). pmacs edits Lean 4: `arborium-lean` highlighting, a + `lean4` major mode, `⟨⟩ ⦃⦄ ⟮⟯` pairs, and a `lake serve` language + server with a Lake-aware outermost root, a lazy toolchain probe, a + one-shot `lean --server` fallback, and `waitForDiagnostics`. **No + protocol change in any stage** (still v20). + - **Two of the four stages contained no Lean at all**, and that is the + arc's organizing rule: *no PR mixes a cross-cutting substrate change + with Lean feature content.* Stage 2 made LSP server affinity + per-project-root (`ensure_server` had been reusing one server across + roots — a correctness bug for every language, not just Lean). Stage + 3a added notification/response subscription seams to + `handle_server_requests`, the single shared LSP event drain, plus + `pmacs.fs.canonicalize`. + - **Two consecutive re-scouts found that rule broken by the stage + being scouted** — Stage 3 in round 4, Stage 4 in round 5, each time + by a risk column that contradicted its own prose. The rule is not + self-enforcing. Re-check every remaining stage's risk column at + scout time. + - **A configured LSP root must be a canonical absolute path.** It + reaches `file_uri_for` verbatim and that URI is the affinity key, so + one package opened by two spellings spawns two servers. Stage 3a's + `pmacs.fs.canonicalize` is the primitive; it returns nil rather than + a lossy path for non-UTF-8 input. + - **`LspManager::stop` on an already-terminal client strands it in + `ShuttingDown` forever** — `server_is_live` then counts it live so + nothing rebuilds against it, and `forget` refuses it for not being + terminal. *Stopping a dead server is what makes it un-replaceable.* + Stage 3b works around it by dispatching on state (`forget` when + terminal, `stop` when live); merely skipping the call leaves + `next_restart_at` armed. The real fix is unframed substrate work. + - **`elan` shims lie**: `lake --version` and `lean --version` can both + fail ("no default toolchain configured") on a machine where Lean + otherwise works, so `command -v lake` is worthless as a capability + check. Lean acceptance is fake-server; live smokes must be PATH- + **and** success-gated. + - Stage 3b took six review rounds, and **the same defect appeared four + times**: "the fallback silently doesn't happen," as no re-attach, + then re-attach cleared by an unrelated buffer, then satisfied by the + very server being replaced, then repairing one buffer while the rest + stayed stale. Each fix was locally right; none asked what a *global* + config swap invalidates. The durable lesson is to heal at + **consumption** — the point where a stale record is handed out — not + at the moment of the swap. + - **Stage 4a (typed-edit consumer chain) is implemented and in review + as PR #179** (branch `lean4-stage4a-typed-edit-chain`, framing rev + 8). It is substrate only: `builtin/runtime/typed_edit.lua` owns the + single `buffer.after-edit` subscriber and the single one-shot read, + `pair.lua` becomes its first registered consumer, and + `tests/auto_pair_acceptance.rs` is unchanged by zero lines + (criterion 46, verified at the diff). No protocol change, no Lean + content. The three decisions that turned out load-bearing rather + than stylistic: consumers are called **even when the record is + nil** (three existing auto-pair tests assert the non-event through + it, and 4b abandons stale pending state on it); each consumer gets + its **own copy** of the record, because pairing reads `rec.char` + and a declining consumer could otherwise forge it; and the fan-out + iterates a **snapshot**, because a consumer that registers a + lower-priority one shifts itself forward under `ipairs` and runs + twice. + - Remaining: Stage 4b (the Unicode input method) is framed and + awaiting approval — not started; stages 5 (goal panel), 6 (`#eval` + output channel), and 7 (module hierarchy) are framed but not + scouted against current `main`. + - **Inline math LANDED — #158** (`docs/inline-math-slice-framing.md` rev 3; merge `5aa9044`). pmacs renders `$…$` as typeset mathematics in the GPU frontend. **No protocol change (still v20); the whole slice lives in diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index e1fe060..c4a32e5 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -6,8 +6,9 @@ pmacs has no Lean support of any kind: `grep -rin lean` over `*.rs`, plain buffer — no grammar, no major mode, no comment syntax, no pair set, no server. -This lane closes that in seven stages. Stage boundaries are drawn where +This lane closes that in nine stages. Stage boundaries are drawn where the *substrate* changes, not where the feature list does — see §4. +§9 states the lane's coherence impact per `COHERENCE.md` §20. ## 0. Why this lane, why now @@ -20,11 +21,12 @@ the *substrate* changes, not where the feature list does — see §4. #144 (LaTeX), #146 (HTML+CSS). Stage 1 is that pattern almost exactly. - Stages 2 and 4–6 are **not** that pattern, and none should be mistaken for a one-liner. Stage 2 changes `ensure_server`, shared by every LSP - language. Stage 4 builds the editor's first input method. Stage 5 is the + language. Stage 4a changes how typed-character provenance is consumed + and Stage 4b builds the editor's first input method. Stage 5 is the first consumer of a non-standard LSP method family. Stage 6 adds a severity-routing policy to `LspServerSpec`. - The user's stated north star is **matching or exceeding what VS Code - does with Lean**. §5's bet 6 scores honestly how close seven stages get + does with Lean**. §5's bet 6 scores honestly how close the nine stages get and names precisely what is still missing. Parallel-safety: Stage 1 touches `Cargo.toml`, `src/syntax.rs`, @@ -34,14 +36,17 @@ Stage 3 (the other open lane) touches `pmacs-gpu/*` and `src/semantic_render.rs`. None of the three footprints overlap; the only file Stage 1 shares with anything is `Cargo.toml`, at one line. -Stages 1 and 2 are independent of each other and **can** run as sibling -worktrees — they share no file. Per the #126/#127 lesson, that split is -recorded here, before either starts, rather than discovered during a -rebase. +Stages 1 and 2 were independent of each other and could have run as +sibling worktrees — they shared no file. Both have since landed (#160, +#161). **Stages 3a and 3b are not independent**: 3b's subscriber is +written against the seam 3a adds, and both touch +`builtin/runtime/lsp.lua`. They are strictly sequential — recorded here, +per the #126/#127 lesson, before either starts rather than discovered +during a rebase. ## 0.1 Revision history -Revision 1 — initial. +Revision 1 — initial. Current revision: **8**. ### Round 1 (rev 1 → rev 2) @@ -81,7 +86,7 @@ round 2 renumbered the stages, so a rev-1 "Stage 4" is now Stage 5.)* 5. **Q#LN8's resolver must honor the search boundary.** A Lua `lean-toolchain` walk that ignores `pmacs.project.search_boundary()` breaks the contract `detect_project_within` exists to enforce and makes - the Stage 3 outermost-root test non-hermetic. + the Stage 3b outermost-root test non-hermetic. ### Round 2 (rev 2 → rev 3) — scope expansion @@ -168,11 +173,341 @@ Six findings against the round-2 expansion. All revision edits. preserving user-supplied `env`/`settings`/`init_options`/`root`. 6. Wording: `\{}` expands to `{$CURSOR}`; `⦃⦄` comes from `\{{}}`. +### Round 4 (rev 4 → rev 5) — Stage 3 re-scout and split + +Stages 1 and 2 landed (#160, #161). Re-scouting Stage 3 against `main` +@ `46a1b8f` — six merged PRs past the rev-4 snapshot (#159–#164) — +produced three findings that change the plan and four that confirm it. +Every fact below was verified in a worktree at that commit; the two +marked *probed* were established by running Lua in a fresh +`EditorState`, not by grep. + +1. **Stage 3 violated this document's own splitting rule.** §4 says "no + PR in this arc mixes a cross-cutting substrate change with Lean + feature content" and "a reviewer looking at Stage 3 sees only Lean" — + while §4's own risk column for Stage 3 read *"two `lsp.lua` + generalizations."* Those cannot both be true. One of the two landed + as Stage 2; the other is Q#LN9's dispatch seams, which modify + `handle_server_requests` — confirmed the **only** production drain of + LSP events (`LspManager::take_all_events` has no non-test caller). By + the same test that justified splitting Stage 2 out, that is + cross-cutting substrate. **Stage 3 is now 3a (substrate, no Lean) and + 3b (Lean).** +2. **The Lean resolver could not satisfy the contract Stage 2 + documented.** #161 established that a configured root — string or + resolver return — must be a canonical absolute path, because it + reaches `file_uri_for` verbatim and that URI is the affinity key. + *Probed:* `pmacs.editor.file_path()` is **not** canonical. Opening + `/linkpkg/sub/./../sub/a.lean`, where `linkpkg` symlinks to + `pkg`, yields `/linkpkg/sub/a.lean` — lexical `.`/`..` collapse + only, symlinks unresolved. No canonicalize binding is exposed to Lua, + and `pmacs.project.detect` canonicalizes but returns nil without a + marker. So a Lean resolver walking up from the buffer's path returns + a non-canonical root, and one package opened by two spellings spawns + two `lake serve` processes — reintroducing precisely the bug Stage 2 + exists to prevent. New Q#LN20 adds `pmacs.fs.canonicalize`; it rides + 3a because it is substrate, and it retires the footgun for every + future function-valued root rather than only Lean's. +3. **`pmacs.fs.stat` is unusable in the resolver.** It is asynchronous — + `fs.lua:93` returns an awaitable handle — and the resolver runs + synchronously inside `ensure_server` ← `attach_buffer` ← the + `buffer.after-load` hook, where there is no coroutine to await on. + *Probed:* the `io` and `os` stdlib **are** exposed in the sandbox + (`type(io.open) == "function"`; `terminal.lua` already uses + `os.getenv`), and `io.open` returns nil for a missing path. So the + marker walk is implementable, but through the Lua stdlib rather than + the pmacs fs API — the opposite of what a reader would assume. + Q#LN8 now says so, with the one edge that matters: `io.open` + **succeeds on a directory**, so a bare existence check would accept + a `lean-toolchain` *directory* as a marker. + +Confirmations, recorded because each was load-bearing and unverified: + +4. **Q#LN7's "stop the failing server first" is necessary, not + defensive.** The spec default is `LspRestartPolicy::OnCrash`, and the + termination handler calls `should_restart(policy)` + (`matches!(OnCrash | Always)`) — which, unlike the + `termination_warrants_restart` helper beside it, never consults the + exit code. `maybe_restart` re-fires on every elapsed backoff with **no + attempt ceiling**, so a broken `lake` respawns forever. `stop()` sets + `restart = Never` (`src/lsp.rs:1349`), which is exactly what disarms + it. Acceptance 36 pins a real mechanism. +5. **The response seam works as designed.** `Response` events are pushed + unconditionally (`src/lsp.rs:2652`) — the typed-store absorb above + does not consume them — and reach Lua as `{kind = "response", + request_id = , method, result, error}`, with + `pmacs.lsp.send_request` returning that same numeric id. So + `on_response(sid, request_id, fn)` is keyable as specified. +6. **The seams' contract is narrower than rev 4 implied, and the + narrowing is load-bearing.** `handle_server_requests` builds its sid + list from `attachments`, and `push_event` appends with no cap. So a + subscriber fires only for a server with a live attachment, and an + unattached server's event queue grows unboundedly. + + *Corrected during implementation (rev 5, round 2).* Rev 5 first + claimed the reachable leak was a killed buffer. **That was wrong.** + The Rust core fires exactly five hooks — `buffer.after-edit`, + `buffer.after-load`, `buffer.after-switch`, `frontend.detached`, + `process.after-tick` — and **there is no buffer-kill hook at all**, + so `lsp.lua` never tears an attachment down and the drain keeps + reaching that server. The premise was right and the inference was + not: it needed attachments to be removed on kill, and nothing + removes them. + + The reachable leak is a different path with the same root cause. + `attach_buffer` drops a sid from `attachments` the moment + `server_is_live` reports false, rebuilding against a fresh server — + so the `crashed` / `stopped` event that should trigger a purge is + **precisely the one most likely to go undrained**. An event-driven + purge leaks exactly when it matters. Q#LN9 therefore drives the + purge off `pmacs.lsp.list()`, which enumerates the manager directly + and is unaffected by attachment bookkeeping. +7. **The `cfg.restart` gap is still open** (recorded landing #161): + `ensure_server` never forwards `pmacs.lsp.config[lang].restart` to + `pmacs.lsp.spawn`, so the field is silently dropped on auto-attach. + Stage 3b is the first stage that would benefit from setting it, and + Q#LN7 now records why it deliberately does not need it. + +Citation drift repaired per COHERENCE §25. Round 4's first pass stated +the `project_root_for` correction in this section without editing the +citation in §2.5 — the correction and the fix are different acts, and +noting one is not doing the other. Review caught a second stale citation +(`handle_server_requests`), which prompted a full sweep of every +`file:line` from §2.4 onward; it found four more. All six: +`project_root_for` 513 → **592** (and it now returns `root, source` +rather than a bare root), `ensure_server` 527 → **610**, +`handle_server_requests` 1448 → **1549**, `take_typed_edit` +12798 → **12827**, `pair.lua` 213 → **229**, and `compile.lua` +264 → **266**. Verified good and left alone: `listview.lua:138`, +`src/lsp.rs:264`, `src/diag.rs:50`, `src/process.rs:193`, +`src/project.rs:145`, and the `mod.rs` binding-block citations. The +pre-#161 line numbers inside Q#LN15 are left as written: that stage has +landed and its citations are historical record, not navigation. + +### Round 5 (rev 5 → rev 6) — Stage 4 re-scout and split + +Stages 3a and 3b landed (#167, #170). Re-scouting Stage 4 against `main` +@ `d400f30` produced **six findings that change the plan** and three +that confirm it. (Round 6 found five more, four of them internal to this +revision; read that section too before trusting a rev-6 statement.) The pmacs-side facts were verified in a worktree at +that commit; the upstream facts were verified by downloading and reading +`leanprover/vscode-lean4` at commit `17d1d08` (2026-05-29) — the +algorithm, not its documentation, since the `lean4-unicode-input` +package ships no README. *(Round 6: it does, at `src/README.md` — see +that section. Corrections to round 5's own numbers are marked inline +below rather than rewritten, per the standing rule that revision +entries are record, not navigation.)* + +1. **Stage 4 violated this document's own splitting rule — the same way + Stage 3 did.** §4 says "no PR in this arc mixes a cross-cutting + substrate change with Lean feature content," and §4's own risk column + for Stage 4 read *"refactors `pair.lua`'s provenance read."* + `pair.lua` is every language's auto-pairing; the refactor is + cross-cutting substrate by exactly the test that split out stages 2 + and 3a. Rev 5 already conceded the shape without acting on it — + Q#LN10 said the refactor "lands *first*, as its own commit with no + behavior change, so a regression bisects cleanly." A commit boundary + is not a review boundary. **Stage 4 is now 4a (the typed-edit + consumer chain, no Lean) and 4b (the input method).** Confirmed + `pair.lua:226` is still the **only** production `take_typed_edit` + caller; the other eight call sites are all in + `tests/auto_pair_acceptance.rs`. +2. **The expansion semantics in rev 5's Q#LN10 were wrong in three + ways.** Reading `AbbreviationProvider.ts` and `TrackedAbbreviation.ts` + rather than inferring from behavior: + - Rev 5 said expansion fires on "a unique complete match that no + longer key extends." Upstream's rule is + `findSymbolsByAbbreviationPrefix(abbrev)[0]` — the symbol of the + **shortest key having `abbrev` as a prefix**. `\alp` + space is not + a failure; it yields `α`, because `alpha` is the shortest key + starting with `alp`. Verified against the table: `\al` → `∀`, from + `all`, not from `alpha`. + - Rev 5 named "an explicit terminator (space, tab, RET, or a second + `\`)." **There is no terminator list upstream.** A character + terminates iff extending the pending key by it leaves zero prefix + matches. Space usually does — but `'+ '` **is a key** (one of + 1,855), so after `\+` a space extends rather than terminates. And a + second `\` is not a terminator either: `'\'` is a key mapping to + `\`, so `\\` extends, matches uniquely, and expands to a single + backslash. It terminates only when the pending key is non-empty and + no key extends it. + - Rev 5 did not carry the suffix rule at all. When no key has + `abbrev` as a prefix, upstream recurses on `abbrev` minus its last + character and **appends the leftover**: `\alp7` → `α7`. Dropping + this makes a large class of real input silently unexpandable. +3. **There is no cursor-motion hook, so acceptance 43 as written cannot + be built.** The Rust core fires exactly eight named hooks + (`builtin/hooks/default.lua`): `buffer.before-save`, + `buffer.after-load`, `buffer.after-edit`, `buffer.after-switch`, + `buffer.after-save`, `editor.before-quit`, `frontend.detached`, + `process.after-tick`. Upstream drives abandonment off + `changeSelections`, a seam pmacs does not have. Abandonment must + therefore be **lazy** — validated at the next typed edit against the + pending region — which changes what acceptance 43 can assert. Q#LN22 + states the state machine this forces. +4. **`dispatch_key` is only half of Stage 4b's production path.** Rev 5 + inherited the auto-pairing suite's dispatch-driven harness without + noticing why that harness is sufficient *there*: Q#AP1 removed the + pair characters from both optimistic classifiers, so for pair chars + dispatch **is** production. `\` and the ASCII letters are not + excluded — `classify_key` returns `Insert(c)` for them + (`src/optimistic.rs:144`: `Char(c) if !c.is_control() && + !is_builtin_pair_char(c)`), so on a CRDT frontend an abbreviation is + typed entirely through the *optimistic* producer, which arms the same + record from `handle_remote_crdt_op` (`src/daemon.rs:3965` pins the + classification). A dispatch-only Stage 4b suite would pin the path + real users do not take. The trap underneath: that producer is + `#[cfg(feature = "crdt")]`, and CI never enables `crdt` — so a + crdt-gated integration test is dark twice over, since the required + gate list runs `--features crdt` only for `--lib`. Q#LN22 and §7 say + what to do about it instead of discovering it in review. +5. **The whole expansion has cross-peer-degraded undo, and it is a + larger bite than `⟨⟩`'s.** Q#LN6 already accepts this for three + bracket pairs. But there the mismatch is one optimistic opener + against one daemon-peer closer; here the user's `\alpha` is six + source-peer optimistic inserts and the expansion is a single + daemon-peer `replace` **over all six**. Q#LN21 takes the decision — + including why `pmacs.buffer.set_round_trip_input`, which already + exists and would fix it, is the wrong instrument. +6. **The table's shape is sharper than "1,855 entries."** Re-counted at + `17d1d08`: 1,855 entries, all `string → string`, **all keys ASCII**, + longest key 25 characters, 36,861 bytes of JSON. **64** keys contain + a `lean4` pair-set character (rev 5's number, reproduced exactly). + Three numbers rev 5 did not have and the algorithm needs: **305** + keys are proper prefixes of another key (so 1,550 are eager-expandable + on uniqueness and 305 are not), **26** values carry `$CURSOR` (not + just `\<>`), and **93** values are multi-codepoint. Two values contain + a backslash — `n` → `\n` and `setminus` → `\` — which is why upstream + needs a `doNotTrackNewAbbr` guard and why §2.11 records that pmacs + does not. + + *Corrected in round 6.* **119** symbols are multi-codepoint, of which + 26 carry `$CURSOR`; "93" was the non-`$CURSOR` subset stated as a + total. **Three** values contain a backslash — the `\` → `\` identity + entry was missed. And this entry's biggest omission is not a number: + the shortest-key rule needs a **tie-break by source declaration + order**, which the README round 5 said did not exist states outright. + §2.11 and Q#LN11 carry the corrected facts. + +Confirmations, recorded because each was load-bearing and unverified: + +7. **`take_typed_edit`'s one-shot contract is unchanged** + (`src/editor_core.rs:4047`): per-frontend, cleared by the producer + when the fan-out returns, nil to a nested manual `hook.run`. The + hazard rev 5 built Q#LN10 around is real and still the reason 4a + exists. +8. **Load order still constrains the chain.** `pair.lua` loads at + `src/editor.rs:430` and `lsp.lua` at `:436`, and Q#AP7's reason + holds: `lsp.lua`'s `buffer.after-edit` callback synchronously flushes + `didChange` on the signature-trigger path. An expansion that landed + after that flush would send the server the unexpanded text. +9. **Embedding the table needs no special machinery.** Every builtin + runtime chunk is an `include_str!`, and `lsp.lua` is already 111 KB + of the 414 KB total. A ~45 KB generated Lua table is within the + existing practice, so Q#LN11 embeds it rather than inventing a + lazy-load path. + +Citation drift repaired per COHERENCE §25, on the same terms as round +4's sweep. Five live citations moved in the 50 commits since rev 5: +`take_typed_edit` 12827 → **12990**, `handle_server_requests` 1549 → +**1815**, `fs.stat` 93 → **133**, `detect_buffer_language` 452 → +**457**, and `send_request`/`send_notification` 9342/9361 → +**9507**/**9527**. Left as written: the pre-#161 numbers inside Q#LN15 +and the revision-history entries above, which are historical record +rather than navigation. + +### Round 6 (rev 6 → rev 7) + +The 4a/4b split held; five P1s against the revision's own content, all +real, all reproduced. Four share a root: **rev 6 verified its external +facts and under-verified its internal ones.** + +1. **Stage 4a's declared footprint excluded the tests its acceptance + required.** Q#LN10 listed three production files while 46a–46e demand + chain-specific tests that cannot live in + `tests/auto_pair_acceptance.rs` — criterion 46 requires that file + byte-identical. Footprint now names + `tests/typed_edit_chain_acceptance.rs` and adds it to the PR's gates. +2. **Pending state had the wrong owner.** §2.11 reasoned "no + multi-cursor, therefore one point" and Q#LN22 keyed pending + abbreviations by buffer. pmacs is multi-frontend: `EditorCore.views` + is per-`FrontendId` with its own active window, `take_typed_edit` is + *already* frontend-keyed, and `pmacs.frontend.id()` exists. Two + frontends on one Lean buffer — the TUI-plus-GPU case this project + ships — would share one slot. Worse, `buffer.after-switch` takes no + arguments, so a buffer-keyed clear-on-switch lets any frontend + discard another's pending abbreviation. Now keyed + `(frontend, buffer)` with a window check, frontend-scoped + after-switch clearing, a `frontend.detached` purge, and acceptance + 45i — which the buffer-keyed design passes every other criterion + without. +3. **The shortest-match rule was missing its tie-break, and rev 6's + research method is why.** Upstream keeps declaration order among + equal-length shortest keys. The README states it in one sentence — + and rev 6 asserted "the package ships no README" after a 404 on the + package root, without checking the directory listing it had already + fetched, which shows `README.md` under `src/`. **A 404 on a guessed + path is not evidence of absence.** The rule is load-bearing: 101 + prefixes have equal-shortest candidates resolving to *different* + symbols (`f` → `f<` not `f>`; `"` picks `"A` from eleven). A `pairs`- + iterated Lua map cannot express it, so Q#LN11 now emits an ordered + sequence and Q#LN22 sorts by `(#key, source rank)`. +4. **The generator's rejection rule rejected the current table.** "Abort + on keys needing Lua escaping" would reject `\` and the eleven `"X` + keys — and acceptance 45d requires `\` to work. Replaced with + canonical lossless escaping; the generator aborts only on duplicate + keys, invalid UTF-8, and a failed self-round-trip. Relatedly, 45g + claimed the suite compares against `abbreviations.json`, which is not + shipped; it now pins self-consistency properties and leaves + source fidelity to the generator, where the source is in hand. +5. **Durable and volatile state were not reconciled** — + `docs/agent-handoff.md` still anchored `main` at `d152120` with + neither #167 nor #170, while `docs/active-work.md` kept full merged + Stage 3a/3b histories against its own instruction to remove merged + entries, under a stale July 25 snapshot date. Round 5 updated the + ledger and skipped the handoff; per CLAUDE.md both are required + reading, and the one that outranks the other was the one left wrong. + +Corrections carried in the same revision, each verified against the +data: the README exists (finding 3); there are **119** multi-codepoint +symbols, of which 26 carry `$CURSOR` — rev 6's "93" was the +non-`$CURSOR` subset reported as a total; **three** values contain a +backslash (`\`, `n`, `setminus`), not two; Q#LN22 now states the rule +acceptance 45d depended on, that an unclaimed terminating `\` is +reprocessed as a new leader; acceptance 38 now says the terminator is +retained, so undo restores `\alpha ` with its space; the coherence +section cites golden-journey **step 5** ("Edit immediately"), not step +4; and §8's config-registry prior art points at Q#LN22, where the gate +now lives. + +### Round 7 (rev 7 → rev 8) + +One P1 remained in the new multi-frontend acceptance, plus two +documentation cleanups. + +1. **Acceptance 45i contradicted Q#LN22's conservative abandonment + rule.** It required frontend A's pending abbreviation to survive + frontend B editing the same buffer, but `buffer:revision()` is + buffer-global and advances on every edit. B's first edit therefore + invalidates A's record under the exact-revision guard. The criterion + now separates the two contracts: another frontend cannot consume A's + record, but any intervening edit to their shared buffer invalidates + it lazily; navigation and detachment remain frontend-scoped when no + shared-buffer edit intervenes. The pending record now names its + `expected_revision` explicitly so the validation rule is buildable. + Preserving A's record through peer edits would require translating + and validating its span across arbitrary edits, a substantially + larger substrate change that Stage 4b does not take on. +2. **The volatile ledger retained rev 6's undercount.** Its table facts + now say 119 multi-codepoint symbols — 26 `$CURSOR`-bearing and 93 + others — matching §2.11 and Q#LN11. +3. **§9.1's revision label was stale.** It now names rev 8. ## 1. What ships -Seven stages. The north star is VS Code parity; the honest statement of -where that lands is in §5, bet 6. +Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The +north star is VS Code parity; the honest statement of where that lands +is in §5, bet 6. **Stage 1 — grammar, mode, and the editing table stakes.** `.lean` files highlight, carry a `lean4` major mode, and get comment-toggle and @@ -185,18 +520,35 @@ Independently valuable for every language pmacs supports; a prerequisite for Lean being usable across more than one Lake package. Split out precisely *because* it is cross-cutting — see §4. -**Stage 3 — the Lean language server.** `pmacs.lsp.config.lean4` drives -`lake serve` with a Lake-aware outermost root, a lazy toolchain probe and -a one-shot `lean --server` fallback, and a notification-subscription seam -so `$/lean/fileProgress` has an owner. Adds -`textDocument/waitForDiagnostics`. Diagnostics, hover, completion, -goto-definition, document symbols, and semantic tokens all arrive through -the existing typed surfaces. +**Stage 3a — LSP dispatch seams and a path canonicalizer.** Pure +substrate, no Lean content, split from Stage 3 in round 4 for the reason +Stage 2 was: it changes machinery every language runs through. +`handle_server_requests` gains notification and response arms with a +pending-response purge, so a `send_request` reply is no longer drained +and dropped; `pmacs.fs.canonicalize` gives Lua the one primitive a +function-valued `config.root` needs to honor the canonical-path contract +#161 could only document. -**Stage 4 — the Unicode input method.** Typing `\alpha` produces `α`, +**Stage 3b — the Lean language server.** `pmacs.lsp.config.lean4` drives +`lake serve` with a Lake-aware outermost root, a lazy toolchain probe and +a one-shot `lean --server` fallback, and subscribes `$/lean/fileProgress` +on 3a's seam. Adds `textDocument/waitForDiagnostics`. Diagnostics, hover, +completion, goto-definition, document symbols, and semantic tokens all +arrive through the existing typed surfaces. + +**Stage 4a — the typed-edit consumer chain.** Pure substrate, no Lean +content, split from Stage 4 in round 5 for the reason stages 2 and 3a +were: it changes machinery every language runs through. The one-shot +`take_typed_edit()` record stops being auto-pairing's private property +and becomes a small ordered chain that reads it once and offers it to +registered consumers. `pair.lua` becomes the chain's first and only +consumer, with no behavior change. + +**Stage 4b — the Unicode input method.** Typing `\alpha` produces `α`, `\to` produces `→`, `\<>` produces `⟨⟩` with the point between them. -1,855 abbreviations vendored from vscode-lean4. This is the stage that -makes Lean actually typable in pmacs. +1,855 abbreviations vendored from vscode-lean4, registered as a chain +consumer ahead of auto-pairing. This is the stage that makes Lean +actually typable in pmacs. **Stage 5 — the goal view.** A `*lean-goal*` panel that renders `$/lean/plainGoal` at the point, refreshed on a debounced tick and on @@ -215,6 +567,13 @@ panel. ## 2. Ground truth (scouted 2026-07-24, `main` @ `e745068`) +Stage 3's facts were **re-verified 2026-07-25 against `main` @ +`46a1b8f`**, six merged PRs later; what changed is recorded in §0.1's +round 4 rather than rewritten in place, so a reader can see which +claims moved. Facts for stages 4–7 still carry the 2026-07-24 date and +should be re-scouted before those stages are framed for +implementation. + ### 2.1 Crate facts (external, verified by downloading and reading both) Two candidate grammar crates exist. They are not close in quality. @@ -276,7 +635,7 @@ injections_query }`. Adding a grammar is one entry plus one `Cargo.toml` line; the doc comment at `src/syntax.rs:756` says exactly this and it has held for every grammar since. -`builtin/runtime/syntax.lua:452` `detect_buffer_language` resolves, in +`builtin/runtime/syntax.lua:457` `detect_buffer_language` resolves, in order: modeline → `pmacs.parse.language_for_path` (the grammar extension table) → `pmacs.lsp.filetypes[ext]` → `pmacs.parse.language_from_filename` → shebang. A grammar entry claiming `lean` therefore resolves `.lean` @@ -357,13 +716,13 @@ and pin it.* - `pmacs.lsp` already exposes generic `send_request(id, method, params)` → request id and `send_notification(id, method, params)` - (`src/lua_bindings/mod.rs:9342`, `:9361`). Non-standard methods need no + (`src/lua_bindings/mod.rs:9507`, `:9527`). Non-standard methods need no new Rust to *send*. - `LspEventKind` (`src/lsp.rs:264`) has generic `Notification { method, params }` and `Response { id, result, error, method }` variants. Unknown server methods are delivered, not dropped. - **But `events_take` has exactly one consumer**: `handle_server_requests` - at `builtin/runtime/lsp.lua:1448`, driven off `pmacs._async.tick`. It + at `builtin/runtime/lsp.lua:1815`, driven off `pmacs._async.tick`. It `take`s — a drain. Its `if/elseif` chain handles five `request` methods and `initialized`, and **ignores every `notification` and every `response`**. A second module calling `events_take` would steal events @@ -379,7 +738,7 @@ and pin it.* ### 2.5 Project-root detection -`project_root_for` (`builtin/runtime/lsp.lua:513`) resolves: +`project_root_for` (`builtin/runtime/lsp.lua:592`) resolves: `pmacs.lsp.config[language].root` → `pmacs.project.detect` → the file's own directory. Two gaps for Lean: @@ -407,7 +766,7 @@ directory. Two gaps for Lean: then reports import errors for the whole file. Third, and the reason Stage 2 exists: `ensure_server` -(`builtin/runtime/lsp.lua:527`) reuses any live server with a matching +(`builtin/runtime/lsp.lua:610`) reuses any live server with a matching `language_id` regardless of the new file's project, so **the first `.lean` file opened fixes the root for every later `.lean` file.** For most languages that is an inconvenience; for Lean, where `lake serve` is bound @@ -424,10 +783,10 @@ changes loose-file behavior for every language. `builtin/runtime/pair.lua` is the whole precedent for "react to a typed character": subscribe to `buffer.after-edit`, gate on -`ed.this_command() == "buffer.self-insert"` (`pair.lua:213`), then take the +`ed.this_command() == "buffer.self-insert"` (`pair.lua:229`), then take the exact provenance record. -`pmacs.editor.take_typed_edit()` (`src/lua_bindings/mod.rs:12798`) returns +`pmacs.editor.take_typed_edit()` (`src/lua_bindings/mod.rs:12990`) returns `{ buffer, window, codepoint, char, requested_start, requested_end, effective_start, effective_end, inserted_len, post_cursor, clean }` — or nil. Its doc comment is explicit: @@ -441,7 +800,19 @@ it on every self-insert. A Lean abbreviation expander that independently calls `take_typed_edit()` in the same `buffer.after-edit` fan-out gets nil or steals it from auto-pairing, depending on hook order — and hook order is not a contract. This is the single load-bearing constraint on Stage 4 and -the reason Stage 4 is its own PR rather than a rider on Stage 1. +the reason Stage 4 is its own PR rather than a rider on Stage 1 — and, +after round 5, the reason its substrate half is Stage 4a rather than a +first commit on a Lean branch. + +Re-verified at `d400f30`: `pair.lua:226` remains the **only** production +caller. The eight other call sites in the tree are all in +`tests/auto_pair_acceptance.rs`. So the chain Stage 4a introduces has +exactly one consumer to migrate, which is what makes a no-behavior-change +substrate PR possible at all. + +Two producers arm the record, not one, and §2.11 is where that matters: +the dispatch fallback and — under `#[cfg(feature = "crdt")]` — the +optimistic CRDT arm reached from `handle_remote_crdt_op`. Related, from `pair.lua:30`'s Q#AP1 note: only the nine built-in pair chars `()[]{}"'` and backtick are excluded from the frontends' optimistic @@ -456,7 +827,7 @@ cross-peer-degraded**. Lean's `⟨⟩` is outside that set. shows the adopter shape, gated on `spec.display == "panel"`. `pmacs.window.params()` and `pmacs.window.quit()` complete the surface. - Read-only generated buffers use the listview idiom, documented at - `builtin/runtime/compile.lua:264`: an erroring `pmacs.buffer.add_intercept` + `builtin/runtime/compile.lua:266`: an erroring `pmacs.buffer.add_intercept` for user edits, with module writes passing `{ bypass_intercept = true }`. - **Note for whoever picks this up on another machine:** the ledgers are stale about this. `docs/active-work.md:57` still heads the lane "Stage 1 @@ -528,7 +899,7 @@ PATH, both are executable, and both fail. So: old, and lake working but the directory is not a Lake package. Only the third is a *version* question. - **Acceptance cannot assume a working Lean toolchain exists.** Every - Stage 3+ test runs against the fake LSP server; a live `lake serve` + Stage 3b+ test runs against the fake LSP server; a live `lake serve` smoke is PATH-gated *and* success-gated, following the #123 JSON/YAML provider-smoke pattern. @@ -551,6 +922,129 @@ The publish path absorbs into the Rust store *and* still delivers the notification to `events_take`, so Lua can observe them; but suppressing them from the store needs a Rust-side policy, not a Lua filter. Q#LN18. +### 2.11 The upstream input method (external, verified by reading it) + +Scouted 2026-07-26 against `leanprover/vscode-lean4` @ `17d1d08`, +package `lean4-unicode-input`, files `AbbreviationProvider.ts`, +`TrackedAbbreviation.ts`, `AbbreviationRewriter.ts`, +`AbbreviationConfig.ts`, `abbreviations.json`, and — round 6 — the +package README at `lean4-unicode-input/src/README.md`. Apache-2.0. + +**Rev 6 first claimed this package ships no README. It does**, at +`src/README.md` rather than the package root, and the 404 on the root +path was taken as absence without checking the directory listing that +was already in hand. That cost the tie rule below: the README states it +in one sentence, and reading only the code left it as an inference from +`Array.prototype.sort`'s stability rather than a documented contract. + +**Resolution.** `findSymbolsByAbbreviationPrefix(p)` collects every key +having `p` as a prefix, sorts them by **key length ascending**, and maps +to symbols. `getReplacementText(a)`: + +1. If any key has `a` as a prefix, return the shortest such key's symbol. +2. Otherwise recurse on `a` minus its last character; if that yields + something, return it **with the dropped character appended**. +3. Otherwise undefined — no expansion. + +Verified against the table: `alpha` → `α`, `alp` → `α` (via `alpha`), +`al` → `∀` (via `all`, *not* `alpha` — shortest wins, and this is +surprising enough to be worth an acceptance criterion), `alp7` → `α7` +via rule 2, `a` → `α` (`a` is itself a key, among 29 prefix matches). + +**The tie rule, and why it is a constraint on the vendored format.** +When several shortest keys have equal length, upstream takes **the one +declared first in `abbreviations.json`**. The README says so outright; +the code achieves it because `Object.keys()` yields JSON insertion order +and `Array.prototype.sort` is stable. Ties are not rare: **101 prefixes +have equal-shortest candidates that resolve to *different* symbols**. +`f` picks `f<` → `‹` over `f>` → `›`; `"` picks `"A` → `Ä` from eleven +equal-length candidates; `(` picks `()` over `(=`, `(b`, `((`, `([`. + +A Lua table iterated with `pairs` has no order at all, so **a generated +`{ [key] = symbol }` map cannot express this contract** — it would +resolve these 101 prefixes nondeterministically, and worse, *stably +wrong* per build. Q#LN11 therefore carries source rank alongside the +symbol. + +**Two things the README explains that the code does not.** `Tab` is the +manual early-replacement trigger upstream binds, which is why +`getReplacementText`'s shortest-prefix rule is user-visible at all +rather than an internal detail. And the `[]_`/`{}_` entries in the table +are not symbols anyone types — they are **decoys**, added so that `\[` +is not uniquely-and-completely matching and therefore does not eagerly +expand before the user can type the second `[`. That is the same +collision Q#LN22 handles from the pairing side, solved upstream by +editing the data. Anyone regenerating the table must not "clean up" +those entries. + +**Tracking.** The leader `\` is inserted into the buffer like any other +character, and the tracked range starts after it; the replaced range +spans the leader inclusive (`abbreviationRange.moveKeepEnd(-1)`). So the +buffer literally shows `\alpha` until expansion, then that whole span +becomes `α`. + +**Termination.** There is no terminator set. On each typed character +`c`, if `findSymbolsByAbbreviationPrefix(a .. c)` is empty the +abbreviation is marked `finished`, **`c` is not absorbed into it**, and +the pending text expands before `c` lands. Otherwise `c` extends the +key. Two consequences the obvious "space ends it" model gets wrong: + +- `'+ '` is a key, so after `\+` a space **extends**. Space is a + terminator by consequence, never by rule. +- `'\'` is a key (→ `\`), so `\\` extends, is uniquely complete, and + eagerly expands to one backslash. A second `\` terminates only when + the pending key is non-empty and unextendable — at which point the + rewriter starts a *new* tracked abbreviation on it. + +**Eager expansion.** When `eagerReplacementEnabled`, an abbreviation +expands the moment it is *unique and complete*: exactly one key has it +as a prefix, and it is itself a key. 1,550 of the 1,855 keys qualify; +the other 305 are proper prefixes of some other key and must wait for +termination. `\to` is in the first group — it expands with no terminator +typed, which is why acceptance 41 is meaningful and not a restatement of +38. + +**Cursor placement.** `$CURSOR` is stripped from the symbol and its +index becomes the post-expansion point, applied only when the point sat +at the end of the abbreviation. 26 values carry it. + +**Abandonment.** Upstream expands on `changeSelections` — any tracked +abbreviation the cursor has left. pmacs has no cursor-motion hook +(round-5 finding 3), so this seam does not exist here and Q#LN22 makes +abandonment lazy instead. + +**The re-arm guard pmacs does not need.** Three values contain a +backslash — `\` → `\`, `n` → `\n`, and `setminus` → `\` (rev 6 first +said two, dropping the `\` → `\` identity entry) — so an expansion can +insert a backslash; upstream sets +`doNotTrackNewAbbr` across the replace so that backslash does not open a +new abbreviation. In pmacs the expansion is a programmatic `buf:replace` +that arms no typed-edit record, so the chain sees nothing and cannot +re-arm. The guard is unnecessary here **because of** the provenance +contract, not by accident — and the acceptance must pin it, because a +future consumer that inferred from buffer text rather than provenance +would reintroduce the bug. + +**What pmacs does not have to carry.** Multi-cursor within a frontend. +Upstream tracks a `Set` and sorts changes bottom-up +for that reason; pmacs has one point per frontend view. + +**What pmacs has instead, and rev 6 got wrong.** Rev 6 read "no +multi-cursor" as "one point" and keyed pending state by buffer alone. +**pmacs is multi-frontend**: `EditorCore.views` is a +`HashMap`, each with its own active window and +cursor; `take_typed_edit` is already keyed by frontend +(`typed_edit_armed: Option<(FrontendId, TypedEditRecord)>`, matched +against `active_frontend`); the record carries `window` as well as +`buffer`; and `pmacs.frontend.id()` is exposed to Lua. Two frontends +editing the same Lean buffer — the ordinary TUI-plus-GPU case, not an +exotic one — would share a single buffer-keyed pending slot, so one +could extend, expand, or silently clear the other's half-typed +abbreviation. `buffer.after-switch` makes it worse: it fires with no +arguments, so a buffer-keyed clear-on-switch would let *any* frontend's +navigation discard a pending abbreviation belonging to another. Q#LN22 +keys the state accordingly. + ## 3. Decisions ### Q#LN1 — Bundle `arborium-lean` 2.18; reject `tree-sitter-lean4` @@ -706,6 +1200,27 @@ consulted before configuring. the failing server *first*, then swaps the config, then spawns — the fallback is a fresh server, not a restart of the old one. + Round 4 verified this is necessary rather than defensive. The spec + default is `LspRestartPolicy::OnCrash` (`src/lsp.rs:165`), and the + termination handler calls `should_restart(policy)` — which, unlike the + `termination_warrants_restart` helper beside it, never consults the + exit code. `maybe_restart` re-fires on every elapsed backoff with **no + attempt ceiling**, so a broken `lake` respawns indefinitely. + `pmacs.lsp.stop` sets `restart = Never` on the way out + (`src/lsp.rs:1349`), which is precisely what disarms it. Acceptance 36 + is pinning a live mechanism, not a hypothetical one. + + **Why the latch does not just set `restart = "never"` on the spawn.** + It cannot: `ensure_server` never forwards `cfg.restart` to + `pmacs.lsp.spawn` — `lua_to_lsp_spec` reads the key but the spawn + table never sets it — so the field is silently dropped on every + auto-attach today. That gap was found landing #161 and is not Stage + 3's to close (it changes behavior for every language that has set + `restart` believing it worked; `statusline_segments_acceptance` a12 is + one such caller). The stop-then-spawn ordering is correct regardless of + how that gap is eventually resolved, which is the reason to prefer it + over a fix that depends on the gap closing first. + **The swap is a field update, not a table replacement.** It rewrites only `command` and `args`, preserving any user-supplied `env`, `settings`, `init_options`, and `root` on `pmacs.lsp.config.lean4`. A @@ -732,18 +1247,87 @@ fallback. That is a one-line status message, once per session, and it buys not blocking every other user's first attach behind a process round-trip. +**Attribution (COHERENCE §9).** The probe is background work that spawns +an OS process, and `ProcessSpec.label` is the only identity a process +carries — caller-supplied and unvalidated, but it is what +`pmacs.process.list` renders. The probe spawns as `lean:lake-version-probe` +rather than inheriting a default, so a user who looks at the process list +while wondering why their editor touched `lake` finds an answer with an +owner in it. Both the probe's verdict and the latch firing report through +`pmacs.editor.set_status` — the channel that exists — per §1.2's rule and +its corollary: each is pinned by a test that observes the channel, since a +report through `pmacs.error` would be a dead sixteenth call site. + No `init_options`. Per §2.8, `hasWidgets?` defaults to false and that is the correct value for a client that reads plain goals out of standard messages. ### Q#LN8 — Lake-aware root via a **function-valued** `config.root` -Generalize `project_root_for` (`builtin/runtime/lsp.lua:513`) so -`pmacs.lsp.config[lang].root` may be a `function(path) -> string|nil` as -well as a string, and implement Lean's resolver in -`builtin/runtime/lean.lua`: walk up from the file's directory collecting -every ancestor containing `lean-toolchain`, and return the **outermost**; -fall back to `pmacs.project.detect`, then the file's directory. +**The generalization landed in Stage 2 (#161).** `project_root_for` is +now `builtin/runtime/lsp.lua:592` and returns `root, source`; +`config[lang].root` already accepts a `function(path) -> string|nil`, +with per-directory memoization keyed weakly on the resolver itself. What +remains for Stage 3b is Lean's resolver in `builtin/runtime/lean.lua`: +walk up from the file's directory collecting every ancestor containing +`lean-toolchain`, and return the **outermost**; decline (return nil) when +there is none, which falls through to `pmacs.project.detect` and then the +file's directory. + +**How the walk tests for the marker — and why not the obvious way.** +`pmacs.fs.stat` is asynchronous: it returns an awaitable handle +(`builtin/runtime/fs.lua:133`) that only settles under `:await()` inside a +coroutine. The resolver has no coroutine. It runs synchronously inside +`ensure_server` ← `attach_buffer` ← the `buffer.after-load` hook, so +awaiting is not merely slow there, it is unavailable — and blocking the +attach on filesystem I/O is the cost rev 1 refused for the probe. The +walk therefore uses the **Lua stdlib**: `io.open(dir .. "/lean-toolchain", +"r")`, which returns nil for a missing path. Round 4 probed that `io` and +`os` are exposed in the sandbox rather than assuming it; `terminal.lua` +already depends on `os.getenv`. + +One edge, probed: **`io.open` succeeds on a directory** (the handle opens; +`read` returns nil without raising). A `lean-toolchain` *directory* would +therefore read as a marker under an `io.open` truth test — wrong, and +wrong silently. + +The fix is **not** "read a byte and require it to be non-nil", which was +this section's first answer and is wrong in the other direction: an +**empty** `lean-toolchain` file also reads nil at EOF, so that rule +declines a marker that exists. Marker semantics here are `lean4-mode`'s +`locate-dominating-file` semantics — *existence*, not content — and a +`lean-toolchain` can legitimately be empty. The discriminator is +`read`'s **second** return, probed on LuaJIT 2.1: + +| Path | `io.open` | `f:read(1)` | Verdict | +|---|---|---|---| +| file with content | handle | `"l"`, no error | marker | +| **empty file** | handle | `nil`, **no error** | **marker** | +| directory | handle | `nil`, `"Is a directory"` | decline | +| missing | `nil` | — | decline | + +So: `local data, err = f:read(1)` and decline only on a non-nil `err`. +The rule is robust across platforms without needing to be re-probed on +each, because both directory behaviors are declines — a platform whose +`fopen` refuses a directory outright fails at `io.open`, and one that +opens it fails at `read`. There is no platform on which a directory both +opens and yields a byte. + +Acceptance 24a and 24b pin the two halves, and each must be shown to +fail against the implementation that satisfies only the other — +otherwise "handles directories" is satisfiable by the version that +breaks empty files, which is exactly how this section's first answer got +written. + +**The result must be canonical.** #161's contract: a configured root +reaches `file_uri_for` verbatim and that URI is the affinity key, so two +spellings of one package are two servers. The path handed to the resolver +is *not* canonical (round 4, finding 2), and Lua had no canonicalizer — +hence Q#LN20. The resolver canonicalizes the file's directory **once**, +before the walk, and strips components from there: every ancestor of a +canonical path is itself canonical, so one call suffices. If +canonicalization fails (a deleted file, a broken symlink), the resolver +declines rather than returning a path it cannot vouch for. **The walk stops at `pmacs.project.search_boundary()`.** This is not optional politeness: `detect_project_within` (`src/project.rs:213`) exists @@ -777,7 +1361,7 @@ write-only API from Lua.** Rev 2 specified only the notification half. That was a hole, since Q#LN16 (`waitForDiagnostics`), Q#LN19 (`imports` / `importedBy`), and Q#LN12's typed goal request all await replies. Both halves ship in -Stage 3. +Stage 3a. ```lua pmacs.lsp.on_notification(method, fn) -- fn(sid, params); persistent @@ -808,90 +1392,370 @@ directions: a Lean subscriber must not cause `workspace/applyEdit` to be missed, and a raising subscriber must not stop later events in the same drain. -Stage 3 registers `$/lean/fileProgress` on the notification seam and +**The seam's contract, stated because round 4 found it narrower than rev +4 implied: subscribers fire only for servers with a live buffer +attachment.** `handle_server_requests` builds its sid list from +`attachments`, so a server with no attached buffer is never drained — and +`push_event` appends with no cap, so that server's queue grows +unboundedly. Both facts are pre-existing and neither is Stage 3a's to +fix. What they change is where the purge may be wired. + +**The purge must not ride the drain.** `attach_buffer` removes a sid +from `attachments` as soon as `server_is_live` reports false and rebuilds +the attachment against a fresh server, so a `crashed` / `stopped` event +is the event *least* likely to be drained — the drain stops visiting +that server at almost exactly the moment the event is queued. A purge +triggered by observing that event therefore leaks in the case it exists +to handle. + +So the purge polls **`pmacs.lsp.list()`** after each drain instead. That +call enumerates the manager directly and is unaffected by attachment +bookkeeping, which is what makes it the right authority: a sid that is +absent, terminal, or running a new generation settles its pending +one-shots with an error, whether or not anything ever drained it. +Acceptance 34's second half exercises a server that is in **no** +attachment, because that is the shape an event-driven purge fails and a +polled one survives. + +The uncapped queue is recorded as a named deferral (§6) rather than fixed +here: bounding it is a policy question about which events may be dropped, +and answering it inside a seam PR would be the kind of smuggling §4 +forbids. + +Stage 3b registers `$/lean/fileProgress` on the notification seam and `waitForDiagnostics` on the response seam; stages 5 and 7 use the response seam for `plainGoal` and the hierarchy calls. -### Q#LN10 — Stage 4 mechanism: one shared provenance read, not two +### Q#LN20 — `pmacs.fs.canonicalize` (Stage 3a) + +A synchronous binding wrapping `std::fs::canonicalize`, returning the +resolved absolute path or nil. Roughly fifteen lines. + +It exists because #161 documented an obligation Lua cannot discharge. A +configured root — string or resolver return — is fed to `file_uri_for` +verbatim, and that URI is the server-affinity key; the `"detected"` arm is +canonicalized for free because `pmacs.project.detect` canonicalizes before +walking, but the `"config"` arm is not. Round 4 probed that +`pmacs.editor.file_path()` collapses `.` and `..` lexically while leaving +symlinks intact, so a resolver walking up from it returns a non-canonical +root. Opening one Lake package through a symlinked path and through the +real path would spawn two `lake serve` processes — the bug Stage 2 was +built to prevent, re-entered through Stage 3b's door. + +**Synchronous, deliberately, and this is the one thing to get right.** +The whole reason `pmacs.fs.stat` cannot serve here is that it is async +(Q#LN8), so a canonicalizer that returned an awaitable would fail for the +same reason and leave the obligation undischarged. It is one `stat`-class +syscall on a path the editor is already opening; `pmacs.project.detect` +performs the same work synchronously today, on the same hook, so this +adds no blocking class that the attach path does not already have. + +Why this rather than the two alternatives considered in round 4: + +- *Accept it as a named degradation* — document that a symlinked open + spawns a second server and pin the behavior. Rejected: it reopens the + defect Stage 2 closed, and the failure is invisible (two servers, both + apparently working, twice the memory, diagnostics split between them). +- *Anchor the walk on `pmacs.project.detect`'s canonical root* — free, no + new surface. Rejected as incorrect, not merely inelegant: `detect` is + innermost-wins over its own marker set, so with `.git` at `~/code` and + the Lake package at `~/code/proj`, anchoring at `~/code` and walking + *up* never sees `~/code/proj/lean-toolchain`. It resolves the wrong root + in a layout that is entirely ordinary. + +The binding is general, not Lean-shaped: it serves every future +function-valued `root`, and it is what lets #161's doc comment stop +warning about a footgun and start naming a fix. + +### Q#LN10 — Stage 4a: one shared provenance read, not two The hazard is §2.6 — `take_typed_edit()` is one-shot and `pair.lua` -already consumes it. +already consumes it. A second independent caller in the same +`buffer.after-edit` fan-out gets nil or steals the record, depending on +hook order, and hook order is not a contract. Decision: **`pair.lua` stops being the sole consumer.** Extract the provenance read into a single `buffer.after-edit` subscriber owned by a -small shared module, which takes the record once and passes it to an -ordered list of typed-edit consumers (auto-pair, Lean abbreviation). -Consumers return whether they handled the edit; the first that does stops -the chain. +small shared module — `builtin/runtime/typed_edit.lua`, loaded +immediately before `pair.lua` — which takes the record once and offers +it to registered consumers in a defined order. A consumer returns +whether it **claimed** the edit; the first that claims stops the chain. -Two consequences worth stating up front: +`pmacs.typed_edit.add_consumer { name = , priority = , +fn = function(rec) ... end }`, lowest priority first, ties broken by +registration order. Priority is an explicit number rather than +load-order-implied because Q#LN22's collision makes ordering +load-bearing, and rev 5's "the abbreviation consumer runs first" is a +claim a reader must be able to check without reconstructing +`src/editor.rs`'s include list. -- This touches `pair.lua`, which is load-bearing for auto-pairing - acceptance. The full pairing suite is a required gate for Stage 4, and - the refactor lands *first*, as its own commit with no behavior change, - so a regression bisects cleanly. -- Ordering is a contract, not an accident, and the collision is real: - **64 of the 1,855 abbreviation keys contain a character in the proposed - `lean4` pair set** — `\[[]]` → `⟦⟧`, `\(())` → `⸨⸩`, `\{{}}` → `⦃⦄`, - `\{}` → `{$CURSOR}`. With pairing first, typing `\[` inserts `[]` - with the point between, so the pending key is corrupted to `\[]` before - the second `[` is ever typed and `\[[]]` becomes unreachable. The - abbreviation consumer runs first. +**Stage 4a ships this and nothing else.** Its whole content is: - (Rev 1 justified this with `\<>`, which was wrong: `<` is not in the - pair set per Q#LN6, so that key is safe under either order.) +| File | Change | +|---|---| +| `builtin/runtime/typed_edit.lua` | new — the chain owner | +| `builtin/runtime/pair.lua` | re-expressed as one registered consumer | +| `src/editor.rs` | one `include_str!` line, before `pair.lua`'s | +| `tests/typed_edit_chain_acceptance.rs` | new — criteria 46a–46h | +| `tests/auto_pair_acceptance.rs` | **unchanged, zero lines** | -**The contract that collision exposes:** the abbreviation consumer must -claim a self-insert that **extends an open pending abbreviation**, not -only one that completes an expansion. A consumer that only claims -completed expansions hands every intermediate keystroke to auto-pairing, -which is exactly how `\[` gets corrupted. "Claimed" here means the chain -stops, not that an edit was made. +Rev 6 listed only the first three and then required criteria 46a–46e, +which no existing suite can host: the auto-pairing suite must stay +untouched (that is the whole point of criterion 46), so the chain's own +behavior — take-once, priority order, claim-stops-chain, throw +containment — has nowhere to live. A declared footprint that excludes +the tests its own acceptance demands is not a footprint. The new suite +joins the required gate list for this PR alongside +`tests/auto_pair_acceptance.rs`. -Expansion semantics (matching vscode-lean4 and `lean4-input`): +Round 5's finding 1 is why this is a PR and not a first commit — +`pair.lua` is every language's auto-pairing, and a reviewer looking at a +Lean PR should not have to also review a rewrite of it. -- `\` opens a pending abbreviation, tracked per buffer with its start - offset. Every subsequent self-insert that extends it is claimed. The - pending state is abandoned on any non-self-insert command, buffer - switch, or cursor move away from the pending region. -- Expansion fires on a unique complete match that no longer key extends, - or on an explicit terminator (space, tab, RET, or a second `\`). -- The vendored table's `$CURSOR` placeholder becomes the point position - after the replace — this is how `\<>` yields `⟨|⟩`. -- The whole expansion is **one `buf:replace`** — one undo step, one CRDT - op, one effective-edit verification. Same discipline as - `comment.lua`'s Q#CT5. -- Gated by `pmacs.config.define{ name = "lean.abbrev", type = "boolean", - default = true, mutability = "live" }`, read against the *source* buffer - of the typed edit — the `editing.auto-pair` precedent (`pair.lua:44`), - including its round-2 correction to resolve `rec.buffer` rather than - `pmacs.window.buffer()`. +**The no-behavior-change claim must be pinned, not asserted.** The full +`tests/auto_pair_acceptance.rs` suite is a required gate for 4a and must +pass **unmodified** — a suite edited to accommodate the refactor proves +nothing (the recorded lesson: what a test suite pins is its assertions). +Three assertions the existing suite already makes are the load-bearing +ones, because they are what a chain could plausibly break: that a second +`take_typed_edit()` in the same fan-out yields nil, that pairing still +sees the exact record via `_capture_records`, and that the Q#AP7 ordering +against `lsp.lua`'s `didChange` flush still holds. -### Q#LN11 — Stage 4 data: vendor the table, generated, attributed +**What 4a deliberately does not do.** It does not change the `all-must- +succeed` contract. What that contract actually does on a throw was +stated wrongly through rev 7 and is corrected here, because this +paragraph is the authority the module comment, criterion 46d, the test, +and the ledger all descend from: `run_all_must_succeed` +(`src/hook.rs:332`) **collects** the error and continues to the hook's +remaining subscribers, marking only the run as failed. An uncontained +throw inside the chain therefore does **not** stop `lsp.lua` from +flushing `didChange`. What it does stop is every LATER consumer in the +chain — the chain is one subscriber, and a throw abandons the rest of +its loop. + +That is a narrower consequence than rev 7 claimed and still worth +containing, because the failure is silent in the direction that matters: +a consumer that throws disables the consumers behind it with no signal +at the seam where they were registered. The chain owner therefore +`pcall`s each consumer and reports through `pmacs.editor.set_status`, +matching `pair.lua`'s existing never-throw-from-after-edit discipline — +this is behavior-preserving for pairing (which already never throws) and +is the guardrail 4b needs. The **rendering** of the caught error is +protected the same way: a Lua error may be any value, including a table +whose `__tostring` throws, so `tostring` outside the `pcall` would +reintroduce the escape the containment exists to prevent. + +### Q#LN11 — Stage 4b data: vendor the table, generated, attributed `abbreviations.json` in `leanprover/vscode-lean4` is a flat -`string → string` object of **1,855 entries** (counted, not estimated), -of which **64 contain a character in the `lean4` pair set** — the -collision Q#LN10's ordering exists to handle. vscode-lean4 is Apache-2.0. +`string → string` object of **1,855 entries**, verified at commit +`17d1d08` (2026-05-29), 36,861 bytes, all keys ASCII, longest key 25 +characters. The counts the algorithm depends on, all re-derived from the +file rather than estimated: + +| Count | What it drives | +|---|---| +| 64 keys containing a `lean4` pair-set char | Q#LN22's ordering | +| 305 keys that are proper prefixes of another | which keys can expand eagerly | +| 1,550 keys uniquely-and-completely matching | the eager-expansion set | +| 26 values containing `$CURSOR` | point placement | +| 119 multi-codepoint symbols (26 of them `$CURSOR`-bearing) | the replace is not one-char-for-many | +| 101 prefixes with disagreeing equal-shortest ties | why the format carries source rank | + +(Rev 6 gave the multi-codepoint figure as 93, which was the count +*excluding* the `$CURSOR` entries — a subset reported as a total.) + +vscode-lean4 is Apache-2.0. + +**Format: an ordered array, not a map.** §2.11's tie rule makes source +order semantic, and a Lua `{ [key] = symbol }` table iterated with +`pairs` cannot carry it. The generated file emits a **sequence** — +`{ {key, symbol}, ... }` in `abbreviations.json` order — plus a derived +`key → index` lookup built at load time for the exact-match case. +Resolution sorts candidates by `(#key, index)`, so the 101 ties resolve +the way upstream resolves them and the file's own line order is the +audit trail. A map-shaped emit would be nondeterministic across builds +and, once a hash order happened to be stable, *stably wrong*. Vendor it as a generated `builtin/runtime/lean_abbrev.lua` with a header -recording source repo, commit, license, and the regeneration command — -the `builtin/queries/latex/highlights.scm` precedent (#144) for -third-party data, extended with provenance because this is a much larger -artifact under a named license. +recording source repo, commit, license, entry count, and the +regeneration command — the `builtin/queries/latex/highlights.scm` +precedent (#144) for third-party data, extended with provenance because +this is a much larger artifact under a named license. -Not fetched at runtime, not a package-manager dependency: the input method -must work offline and on first launch. +Not fetched at runtime, not a package-manager dependency: the input +method must work offline and on first launch. -**Upkeep is a documented manual process, not code.** There is no automatic -sync and none is wanted — an editor that silently re-downloads its input -method has a supply-chain problem, not a feature. The generator script -lives at `scripts/regen-lean-abbrev`, takes a vscode-lean4 commit as its -argument, and rewrites the file including its provenance header. The -header records source commit, license, entry count, and the regeneration -command, so the file is self-describing to whoever next touches it. A -refresh is an ordinary PR with a visible diff — which is the point: the -diff is the review. +**Embedded, not lazily loaded.** ~45 KB of generated Lua joins the 414 KB +of builtin runtime already compiled in by `include_str!`, of which +`lsp.lua` alone is 111 KB. Inventing a lazy-load path for an 11% increase +would be new machinery bought with no measurement, and the arithmetic is +stated here so a reviewer can disagree with it on numbers. + +**Upkeep is a documented manual process, not code.** There is no +automatic sync and none is wanted — an editor that silently re-downloads +its input method has a supply-chain problem, not a feature. The generator +script lives at `scripts/regen-lean-abbrev`, takes a vscode-lean4 commit +as its argument, and rewrites the file including its provenance header, +so the file is self-describing to whoever next touches it. A refresh is +an ordinary PR with a visible diff — which is the point: the diff is the +review. + +**Escaping is canonical and lossless, not a rejection trigger.** Rev 6 +said the generator aborts on "a key containing a character the emitted +Lua would have to escape." **That rule rejects the current table**: `\` +is a key, `"` begins eleven keys (`"A` → `Ä` …), and acceptance 45d +requires `\` to work. The generator instead emits every key and symbol +through one canonical Lua string escaper — `\\`, `\"`, `\n`, `\r`, +`\t`, and `\ddd` for any other control byte, everything else literal +UTF-8 — chosen so the emit is byte-deterministic across runs. + +What the generator *does* abort on, because these are real corruption +rather than syntax: + +- a duplicate key after decoding (JSON permits it; the table must not), +- a key or symbol that is not well-formed UTF-8, +- a round-trip mismatch: the generator re-parses its own output and + compares the full ordered sequence against the source, entry for + entry, and fails if they differ anywhere. + +That last check is what makes the artifact trustworthy, and it belongs +in the generator rather than in the acceptance suite — the suite cannot +see `abbreviations.json`, which is not shipped. Same discipline as +Q#LN20's refusal to hand back a lossy path: refuse rather than emit +something plausible. + +### Q#LN21 — Stage 4b: the expansion's undo is cross-peer-degraded; ship it, name it + +`classify_key` (`src/optimistic.rs:144`) returns `Insert(c)` for `\` and +for every ASCII letter — only the nine built-in pair chars are excluded +(Q#AP1). So on a CRDT frontend the user's `\alpha` arrives as six +**source-peer** optimistic inserts, while the expansion is a single +**daemon-peer** `buf:replace` spanning all six. Undo across that boundary +is not chronologically arbitrated; this is the same defect Q#LN6 already +accepts for `⟨⟩`, `⦃⦄`, `⟮⟯`, one order of magnitude wider. + +Considered and rejected: `pmacs.buffer.set_round_trip_input(buf, true)`, +which exists, is per-buffer, and would fix this exactly. Its six current +callers are all read-only generated buffers — listview, compile, dired, +terminal — and it does considerably more than disable optimistic insert: +per `src/editor_core.rs:505`, `dispatch_idle` reports false, so RET +reaches buffer-local bindings instead of inserting a newline. Turning it +on for every ordinary editable Lean source file would trade a known undo +degradation for an unknown behavior change across the whole editing +surface, and would make Lean the one language whose typing has a +different latency profile. + +Also rejected: adding `\` to the always-round-trip set. It is +frontend-side and language-blind, so this would tax LaTeX, C, shell, and +every string literal in the editor to fix one language. + +Decision: **accept the degradation, name it in the module comment, and +do not paper over it.** The general fix is chronological cross-peer undo +arbitration — already on the standing backlog, and the same fix Q#LN6 +points at. What Stage 4b owes is honesty about scope: this is not "a few +brackets," it is every abbreviation the user types on a CRDT frontend. + +### Q#LN22 — Stage 4b mechanism: lazy abandonment, explicit ordering + +**Ordering.** The abbreviation consumer registers ahead of auto-pairing. +The collision is real: 64 keys contain a `lean4` pair-set character — +`\[[]]` → `⟦⟧`, `\(())` → `⸨⸩`, `\{{}}` → `⦃⦄`, `\{}` → `{$CURSOR}`. +With pairing first, typing `\[` inserts `[]` with the point between, so +the pending key is corrupted to `\[]` before the second `[` is typed and +`\[[]]` becomes unreachable. + +(Rev 1 justified this with `\<>`, which was wrong: `<` is not in the pair +set per Q#LN6, so that key is safe under either order.) + +**The contract the collision exposes:** the consumer must claim a +self-insert that **extends an open pending abbreviation**, not only one +that completes an expansion. A consumer that claims only completed +expansions hands every intermediate keystroke to auto-pairing, which is +exactly how `\[` gets corrupted. "Claimed" means the chain stops, not +that an edit was made. + +**State machine**, per §2.11's ground truth rather than rev 5's +reconstruction of it: + +- `\` typed in a `lean4` buffer opens a pending abbreviation: `{ buffer, + window, start_offset, text = "", expected_revision }`, keyed on + **`(frontend, buffer)`** — see below. `expected_revision` is the + buffer's revision after that leader edit. +- A subsequent self-insert `c` is claimed iff at least one key has + `text .. c` as a prefix; then `text = text .. c`. If it is also + uniquely-and-completely matching (one of the 1,550), expand now. +- If no key extends `text .. c`, expand `text` **first**, then let `c` + land normally — the chain does *not* claim `c`. +- **A terminating `c` that is itself `\` is then reprocessed as a new + leader**, opening a fresh pending abbreviation at its position. This + is the rule acceptance 45d depends on (`\alpha\to` → `α→`) and rev 6 + specified the acceptance without specifying the rule; upstream gets it + from `processChange`, where a `finished` abbreviation reports + `isAffected = false` and so does not suppress the new-leader branch. + Note this is *not* the `\\` case: there the pending text is empty, `\` + extends rather than terminates, and the result is one literal + backslash with no pending state left open. +- Expansion resolves through §2.11's rules — shortest key wins, ties + broken by source rank, unmatchable tail appended (`\alp7` → `α7`). +- `$CURSOR` is stripped from the symbol and its index becomes the point. + +**Ownership is per frontend, not per buffer** (§2.11). The key is +`(pmacs.frontend.id(), rec.buffer)`, and the stored `window` must still +match `rec.window` for the state to be usable — a frontend that moved +the same buffer into a different window is no longer typing where the +pending span is. Two consequences the buffer-only design got wrong: + +- `buffer.after-switch` fires with **no arguments**, so it cannot say + whose switch it was. The subscriber reads `pmacs.frontend.id()` at + callback time — documented as "the frontend that produced the most + recent dispatched input event" — and clears **only that frontend's** + entries. A blanket clear would let one frontend's navigation discard + another's half-typed abbreviation. +- `frontend.detached` fires with the raw frontend id and is the purge + seam, exactly as `killring.lua` uses it (Q#KR11). Without it a + detached frontend's pending state leaks for the life of the session. + +This costs one table level and buys correctness in the ordinary +TUI-plus-GPU configuration, which is not an exotic setup — it is the +one this project ships two frontends for. + +**Abandonment is lazy, because there is no cursor-motion hook** (round-5 +finding 3). Pending state is validated at the next typed edit and +discarded when any of these no longer holds: the record's buffer and +window are the pending ones; `rec.effective_start` equals `start_offset ++ 1 + #text` (the point is still at the end of the pending span); and +the buffer's `revision()` equals `expected_revision + 1`, meaning the +current typed edit is the only edit since this frontend last extended +the pending abbreviation. A claimed extension stores the current +revision as the new `expected_revision`. This is deliberately +conservative across frontends: any intervening edit to the shared +buffer invalidates the pending record even if it occurred elsewhere. +Keeping the record alive would require translating and validating its +span through arbitrary peer edits, substrate Stage 4b does not add. +`buffer.after-switch` clears the acting frontend's entries eagerly, +since that hook *does* exist. The +practical difference from upstream: a user who clicks away mid-`\alp` +and types elsewhere gets the pending state dropped rather than expanded. +Upstream expands it. **This is a deliberate divergence** — expanding +into a region the user has left is the worse failure, and pmacs cannot +detect the departure at the moment it happens. + +**One `buf:replace`** for the whole expansion — one undo step, one CRDT +op, one effective-edit verification, with the same +rejected/altered-by-intercept reporting as `comment.lua`'s Q#CT5 and +`pair.lua`. A rejection drops the pending state; it does not retry. + +**Gate:** `pmacs.config.define{ name = "lean.abbrev", type = "boolean", +default = true, mutability = "live" }`, read against the **source** +buffer of the typed edit — the `editing.auto-pair` precedent +(`pair.lua:46`), including its round-2 correction to resolve +`rec.buffer` rather than `pmacs.window.buffer()`. + +**Language gate:** the consumer opens no pending abbreviation outside a +`lean4` buffer, resolved from `rec.buffer` for the same reason. `\` in a +Rust buffer is an ordinary character and `\[` there still pairs. ### Q#LN12 — Stage 5 sends `$/lean/plainGoal` through a typed Rust request @@ -922,8 +1786,9 @@ stage numbers and was wrong three ways): | Stage | Rust | |---|---| | 1 | `Cargo.toml` + `BUILTIN_LANGUAGES` entry + Q#LN4's four capture entries | -| 2 | `lsp.list()` row builder (`mod.rs:9919`) | -| 3 | **none** — Lua only | +| 2 | `lsp.list()` row builder (`mod.rs:9926`) | +| 3a | `pmacs.fs.canonicalize` (Q#LN20) — the seams themselves are Lua only | +| 3b | **none** — Lua only | | 4 | **none** — Lua only | | 5 | `request_plain_goal` + its binding | | 6 | `LspServerSpec` severity-policy field and its publish-path honoring | @@ -978,7 +1843,7 @@ rough edge but a correctness failure: `lake serve` is bound to one Lake package, so the second package a user opens gets a server that cannot resolve its imports. -The change is small and spans two files: +The change was small and spanned two files (Stage 2, landed as #161): - **`src/lua_bindings/mod.rs:9919`** — the `lsp.list()` row builder sets `id`/`label`/`language_id`/`command`/`state`/`attempt`. Add `root_uri` @@ -1045,7 +1910,7 @@ elaboration is memory-hungry. rust-analyzer has the same property and no editor caps it by default. No cap ships here; `pmacs.lsp.stop` is the manual escape, and an LRU reaping policy is named in §6. -### Q#LN16 — `textDocument/waitForDiagnostics` (Stage 3) +### Q#LN16 — `textDocument/waitForDiagnostics` (Stage 3b) A plain request (no position, so no `outbound_position` concern — Q#LN12 does not apply). It resolves when the server has finished elaborating the @@ -1125,28 +1990,69 @@ never lands. |---|---|---|---| | 1 | grammar, mode, comments, pairs, md fences | new crate; **global capture table** | — | | 2 | multi-root server affinity | **`ensure_server`, shared by every language** | — | -| 3 | `lake serve` + probe/latch, Lake root, notification seam, `waitForDiagnostics` | two `lsp.lua` generalizations | 1, 2 | -| 4 | Unicode input method | **refactors `pair.lua`'s provenance read** | 1 | -| 5 | goal panel | new typed LSP request; panel adopter | 3 | -| 6 | `#eval` / `#check` output channel | **new `LspServerSpec` policy field** | 3, 5 | -| 7 | module hierarchy | listview adopter + one typed Rust request | 3 | +| 3a | notification/response seams + purge; `pmacs.fs.canonicalize` | **the shared event drain, run by every language** | — | +| 3b | `lake serve` + probe/latch, Lake root, `waitForDiagnostics` | none — Lean-only files plus one config entry | 1, 2, 3a | +| 4a | typed-edit consumer chain | **refactors `pair.lua`'s provenance read, shared by every language** | — | +| 4b | Unicode input method | none — Lean-only files plus one chain consumer | 1, 4a | +| 5 | goal panel | new typed LSP request; panel adopter | 3a, 3b | +| 6 | `#eval` / `#check` output channel | **new `LspServerSpec` policy field** | 3b, 5 | +| 7 | module hierarchy | listview adopter + one typed Rust request | 3a, 3b | -Three of the seven carry risk that is *not* about Lean — stages 1, 2, and -6 each change something every language touches. That is the organizing -principle of the split: **no PR in this arc mixes a cross-cutting -substrate change with Lean feature content.** A reviewer looking at Stage -2 sees only `ensure_server`; a reviewer looking at Stage 3 sees only Lean. +Five of the nine carry risk that is *not* about Lean — stages 1, 2, 3a, +4a, and 6 each change something every language touches. That is the +organizing principle of the split: **no PR in this arc mixes a +cross-cutting substrate change with Lean feature content.** A reviewer +looking at Stage 2 sees only `ensure_server`; a reviewer looking at Stage +3b sees only Lean. + +Round 4 found Stage 3 breaking that rule while stating it — the row above +used to read "two `lsp.lua` generalizations" for a stage the prose called +Lean-only. One generalization shipped as Stage 2; extracting the other as +3a is what makes the claim true again. The rule is only worth writing +down if it survives contact with a stage that is inconvenient to split. + +Round 5 found the *same* rule broken again, by Stage 4, whose risk column +read "refactors `pair.lua`'s provenance read" — every language's +auto-pairing — for a stage described as the Lean input method. Rev 5 had +noticed the shape and answered it with a commit boundary; a commit +boundary is not a review boundary. Twice in two re-scouts is the +interesting part: **this rule is not self-enforcing, and a stage only +looks Lean-only until someone re-reads its own risk column.** Every +remaining stage should be re-checked against it at scout time, not +assumed. Ordering notes: - **Stage 2 has no Lean in it and could ship independently of this arc.** It is sequenced here because Lean is the language that makes its absence - a correctness bug rather than an inconvenience, and because Stage 3's + a correctness bug rather than an inconvenience, and because Stage 3b's acceptance would otherwise have to encode the broken behavior. -- **Stage 4 does not depend on stages 2–3** and could run in parallel, but - should not: both touch `lsp.lua`/`pair.lua`-adjacent runtime files, and - the #126/#127 lesson is that parallel-safety requires the file split be - agreed *before* either lane starts. Sequential is cheaper. +- **Stage 3a likewise has no Lean in it**, and the same reasoning applies + one level down: the response seam is a hole in `send_request` for every + language — Lean is merely the first caller that needs a reply. It is + sequenced before 3b because 3b's `waitForDiagnostics` and file-progress + subscription both consume it, and because a Lean PR that also rewrote + the shared drain could not be reviewed on either axis. +- **3a and 3b cannot run as sibling worktrees.** 3b's Lean subscriber is + written against the seam 3a adds, and both touch + `builtin/runtime/lsp.lua`. Unlike stages 1 and 2, this pair is strictly + sequential — recorded here, per the #126/#127 lesson, rather than + discovered in a rebase. +- **Stage 4a depends on nothing in this arc** — not even Stage 1. It is + a pure runtime-substrate change whose only content is `pair.lua` and a + new module beside it, and it would be worth landing if the Lean arc + were abandoned tomorrow, because "the typed-edit record has exactly + one consumer forever" is not a property anyone chose. +- **4a and 4b cannot run as sibling worktrees**, for the 3a/3b reason: + 4b's consumer is written against the registration API 4a adds. Strictly + sequential, recorded before either starts. +- **Stage 4b depends on stages 1 and 4a and on nothing else** — not on + 2, 3a, or 3b. The input method is useful with no language server at + all, which is the honest ordering argument for putting it this early: + a user with no Lean toolchain installed still gets a Lean editor that + can type Lean. It could run in parallel with the 5/6/7 lane, but + should not, per the #126/#127 lesson that parallel-safety requires the + file split be agreed *before* either lane starts. - **Stage 6 depends on Stage 5** only for the read-only generated-buffer and panel machinery, which Stage 5 establishes. If Stage 5 slips, Stage 6 can carry that machinery itself at the cost of duplicating it. @@ -1185,7 +2091,22 @@ Stated so they can be scored, per house style. inside `buffer.after-edit` re-enters the hook in a way pairing does not already survive. Confidence: medium — pairing does the same thing, but over a single codepoint rather than a multi-byte span. -6. **These seven stages reach rough VS Code parity for everything except +5a. **Lazy abandonment is good enough without a cursor-motion hook** + (rev 6, Q#LN22). Falsified if a user in normal editing hits a case + where stale pending state produces a *wrong* expansion rather than a + dropped one — the failure mode this design chooses. Confidence: + medium-high, because every path that can invalidate the state either + goes through `buffer.after-edit` (where it is checked) or through + `buffer.after-switch` (where it is cleared), and the residual is a + cursor move with no intervening edit, which the next typed edit + catches by position. If it fails, the fix is a cursor-motion hook — + substrate work with its own framing, not a patch to this stage. +5b. **Stage 4a is behavior-preserving.** Falsified by any change to + `tests/auto_pair_acceptance.rs` being needed to make it pass. + Confidence: high, and cheap to score — it is a diff-level check, not + a judgment call. This bet is stated separately from bet 5 because it + is the one a reviewer can falsify in ten seconds. +6. **These nine stages reach rough VS Code parity for everything except the interactive infoview.** Scored honestly rather than aspirationally. What lands: highlighting, goal view, Unicode input, diagnostics, hover, completion, goto-definition, symbols, semantic tokens, `#eval` @@ -1217,14 +2138,60 @@ What remains deferred: - **GPU goal band** — blocked on bottom-panel Stage 2 (Q#LN14). The panel is grid-only until then. - **A `cursor.after-move` hook** — there is none (Q#LN13), so Stage 5 - polls off `process.after-tick`. A real motion hook would serve the goal - view, `completion.lua`'s cursor-delta heuristic, and the outline/hover - panels alike; it is substrate work that should not be invented inside a - language lane. + polls off `process.after-tick` and Stage 4b abandons pending + abbreviations lazily rather than on departure (Q#LN22, round-5 finding + 3). A real motion hook would serve the goal view, the input method, + `completion.lua`'s cursor-delta heuristic, and the outline/hover panels + alike; it is substrate work that should not be invented inside a + language lane. Two consumers in this arc now want it, which is worth + recording as evidence for whoever frames it. +- **Chronological cross-peer undo arbitration** — the general fix for + Q#LN6's bracket pairs and Q#LN21's abbreviation expansions alike. + Already on the standing backlog; named again here because Stage 4b + widens the exposure from three pair characters to every abbreviation a + user types on a CRDT frontend, which changes how often the existing + defect is met without changing what it is. +- **Per-buffer optimistic-apply policy** — the narrower thing Q#LN21 + actually wanted and did not build. `set_round_trip_input` is the only + existing lever and it is too blunt (it also changes RET dispatch); a + frontend-side, language-aware round-trip character set would fix the + undo degradation for Lean without taxing every other language, and + would retire Q#AP1's limitation too. Frontend + protocol work, so + Q#LN14's no-protocol-change rule keeps it out of this arc entirely. - **LSP server reaping / LRU** — Q#LN15's per-root affinity makes unbounded `lake serve` growth possible. No editor caps this by default and pmacs will not either in this arc, but the policy question is now live in a way it was not before. +- **The uncapped LSP event queue** — `push_event` appends without a + bound, and `handle_server_requests` drains only servers with a live + buffer attachment, so an unattached server's events accumulate for the + life of the session (round 4, finding 6). Bounding it means deciding + which events may be dropped, which is a policy question with + user-visible consequences for diagnostics and progress; Stage 3a states + the seam's contract around the behavior rather than changing it. +- **`LspManager::stop` on an already-terminal server strands it.** The + not-initialized branch terminates the (already-dead) process and sets + `ShuttingDown { shutdown_request_id: None }` on the premise that "the + next exit observation cleans up" — but for a `Crashed` client the exit + has already been observed, which is what produced that state. No + further event arrives, so the client sits in `ShuttingDown` + permanently: `server_is_live` counts it as live (neither crashed nor + stopped), so `attach_buffer` never rebuilds against it, and + `LspManager::forget` refuses it for not being terminal. **Stopping a + dead server is what makes it un-replaceable.** Found implementing + Stage 3b's latch, which works around it by dispatching on state: + `forget` for a terminal server (it requires terminal state, and + removing the client also drops the `next_restart_at` the crash armed), + `stop` for a live one. Merely *skipping* the call is not enough — that + leaves the restart timer running and the broken command respawns + underneath the fallback. The fix belongs in `stop` (treat an + already-terminal client as a no-op, or drive it straight to `Stopped`) + and changes behavior for every language, so it does not ride a Lean PR. +- **Forwarding `cfg.restart` through `ensure_server`** — read by + `lua_to_lsp_spec`, never set by the spawn table, so silently dropped on + every auto-attach (found landing #161). Fixing it changes behavior for + every language whose config sets the field believing it works. Q#LN7 is + designed not to need it. - **Block-comment toggle** (`/- -/`) and **docstring awareness** (`/-- -/`) — confirmed as owned by the comment arc's framing, not this one. @@ -1309,65 +2276,191 @@ What remains deferred: the markerless one's server carries the fallback directory as `cwd` while matching on a nil affinity key. -**Stage 3 — the Lean language server** +**Stage 3a — dispatch seams and the canonicalizer (no Lean content)** -22. Opening a `.lean` file inside a Lake package spawns one server with - `cwd` and `rootUri` at the package root. -23. **Outermost-root pin:** a file under - `/.lake/packages/dep/…` whose ancestor chain contains two - `lean-toolchain` files resolves to ``, not to `dep`. Run with - `pmacs.project.set_search_boundary` at the fixture root so the - assertion is hermetic. -24. **Boundary pin:** with the search boundary set at the fixture root, a - `lean-toolchain` planted in an ancestor *above* the boundary is not - reached — the resolver stops at the boundary rather than walking past - it. -25. A string-valued `pmacs.lsp.config.lean4.root` still works — the Q#LN8 - generalization is strictly additive. -26. `didOpen` carries `languageId = "lean4"`. -27. **Fallback-latch pin (Q#LN7):** a `lake` stub that exits non-zero — - reproducing §2.9's shimmed-elan state — causes exactly **one** restart - against `lean --server`, and a second failure surfaces an error rather - than looping. The latch does not re-arm within the session. -28. **Probe pin:** a `lake` stub reporting version 3.0.0 triggers the - fallback; one reporting 3.1.0 does not. A stub that never exits does - not block the attach — the optimistic `lake serve` spawn proceeds. -29. A `$/lean/fileProgress` notification delivered through the fake server - reaches a registered `on_notification` subscriber. -30. **Dispatch-integrity pin:** with a Lean subscriber registered, a - `workspace/applyEdit` request in the same drain is still handled — no - event is stolen. -31. A subscriber that raises does not prevent later events in the same - drain from being processed. -32. **Response-seam pin (Q#LN9).** A `send_request` reply reaches its - registered `on_response` one-shot, and the one-shot is **removed - before** invocation — a raising handler is not re-entered. Bites - against rev 2, where no Lua consumed `ev.kind == "response"` at all - and the reply was dropped. -33. **Response dispatch-integrity pin.** With a response subscriber - registered, `workspace/applyEdit` in the same drain is still handled; - a raising response handler does not stop later events in that drain. - Mirrors the notification-side pins above. -34. **Pending-purge pin.** A server that dies with a response outstanding - invokes the pending one-shot with an error and clears it — the - registration does not leak and the awaiting caller does not hang. -35. **Config-preservation pin (Q#LN7).** After the fallback latch fires, - user-supplied `env` / `settings` / `init_options` / `root` on - `pmacs.lsp.config.lean4` survive; only `command` and `args` change. -36. **No-respawn-loop pin.** The latch stops the failing server before - spawning the fallback, so `RestartPolicy` does not respawn the broken - command underneath it. -37. `textDocument/waitForDiagnostics` resolves through the response seam - (Q#LN16). **PATH-and-success-gated live smoke:** if `lake serve` - starts successfully a real elaboration completes and diagnostics - arrive; skipped otherwise, never failed. +Driven against `pmacs_fake_lsp` through an already-shipped language, for +the same reason Stage 2's suite was: the drain is shared by every +language, and a suite that reaches it only through Lean would understate +the blast radius. -**Stage 4 — the Unicode input method** +- **29.** A notification delivered through the fake server reaches a registered + `on_notification` subscriber. +- **30.** **Dispatch-integrity pin:** with a subscriber registered, a + `workspace/applyEdit` request in the same drain is still handled — no + event is stolen. +- **31.** A subscriber that raises does not prevent later events in the same + drain from being processed. +- **32.** **Response-seam pin (Q#LN9).** A `send_request` reply reaches its + registered `on_response` one-shot, and the one-shot is **removed + before** invocation — a raising handler is not re-entered. Bites + against rev 2, where no Lua consumed `ev.kind == "response"` at all + and the reply was dropped. +- **33.** **Response dispatch-integrity pin.** With a response subscriber + registered, `workspace/applyEdit` in the same drain is still handled; + a raising response handler does not stop later events in that drain. + Mirrors the notification-side pins above. +- **34.** **Pending-purge pin, both edges.** A server that dies with a + response outstanding invokes the pending one-shot with an error and + clears it. **And** a server that is in **no attachment** does the + same, rather than stranding the registration behind a drain that never + visits it. The second half must be shown to fail against a purge + wired to a death event seen in the drain; otherwise this criterion is + satisfied by the implementation that leaks. (Rev 5 first worded the + second edge as a killed buffer; there is no buffer-kill hook, so + nothing removes the attachment and that path does not leak. Corrected + in round 2 — see §0.1 finding 6.) +- **34a.** **Canonicalizer pin (Q#LN20).** `pmacs.fs.canonicalize` resolves a + symlinked and dot-segmented path to the same string as the real path, + and returns nil for a nonexistent one. Fixture builds the symlink + rather than assuming one exists. +- **34b.** **Affinity-through-canonicalization pin.** With a function-valued + `root` that canonicalizes, the same project opened by its real path + and through a symlink reuses **one** server. Falsified by a resolver + that returns the path verbatim, which yields two — this is the + regression Q#LN20 exists to prevent, so it is asserted at the + affinity layer, not just at the binding. -38. `\alpha` + space yields `α`; the whole expansion is a single undo step. +**Stage 3b — the Lean language server** + +- **22.** Opening a `.lean` file inside a Lake package spawns one server with + `cwd` and `rootUri` at the package root. +- **23.** **Outermost-root pin:** a file under + `/.lake/packages/dep/…` whose ancestor chain contains two + `lean-toolchain` files resolves to ``, not to `dep`. Run with + `pmacs.project.set_search_boundary` at the fixture root so the + assertion is hermetic. +- **24.** **Boundary pin:** with the search boundary set at the fixture root, a + `lean-toolchain` planted in an ancestor *above* the boundary is not + reached — the resolver stops at the boundary rather than walking past + it. +- **24a.** **Marker-is-a-file pin (Q#LN8).** A `lean-toolchain` + *directory* does not mark a root. Bites against the bare `io.open` + truth test, which round 4 probed succeeds on directories — the shape + that would pass every other criterion here while being wrong. +- **24b.** **Empty-marker pin (Q#LN8).** An **empty** `lean-toolchain` + file *does* mark a root — marker semantics are existence, not content. + Bites against the read-a-byte-and-require-non-nil rule, which declines + it at EOF. 24a and 24b must each be shown to fail against the + implementation that satisfies only the other; a suite carrying just + one of them is satisfied by a resolver that is silently wrong for the + other case. +- **25.** A string-valued `pmacs.lsp.config.lean4.root` still works — the Q#LN8 + generalization is strictly additive. +- **26.** `didOpen` carries `languageId = "lean4"`. +- **27.** **Fallback-latch pin (Q#LN7):** a `lake` stub that exits non-zero — + reproducing §2.9's shimmed-elan state — causes exactly **one** restart + against `lean --server`, and a second failure surfaces an error rather + than looping. The latch does not re-arm within the session. +- **28.** **Probe pin:** a `lake` stub reporting version 3.0.0 triggers the + fallback; one reporting 3.1.0 does not. A stub that never exits does + not block the attach — the optimistic `lake serve` spawn proceeds. +- **35.** **Config-preservation pin (Q#LN7).** After the fallback latch fires, + user-supplied `env` / `settings` / `init_options` / `root` on + `pmacs.lsp.config.lean4` survive; only `command` and `args` change. +- **36.** **No-respawn-loop pin.** The latch stops the failing server before + spawning the fallback, so `RestartPolicy` does not respawn the broken + command underneath it. +- **36a.** **Attribution pin (COHERENCE §9/§1.2).** The probe process + appears in `pmacs.process.list` under a Lean-owned label, and the + latch firing leaves a status-line trace. Both assert through the + channel a user can actually observe; a report added through + `pmacs.error` alone must fail this. +- **37.** `textDocument/waitForDiagnostics` resolves through the response seam + (Q#LN16), **carrying both `uri` and `version`** — Lean's + `WaitForDiagnosticsParams` requires the document version, and a fake + server that echoes any payload will hide its absence, so the fixture + must reject a request that omits it. + **PATH-and-success-gated live smoke:** if `lake serve` starts + successfully a real elaboration completes and diagnostics arrive; + skipped otherwise, never failed. + +These two sections are bulleted with explicit labels rather than +numbered, because the split leaves each stage's criteria non-contiguous +(3b runs 22–28 then 35–37) and a markdown ordered list renumbers from +its first item regardless of what is written. Keeping the labels literal +means **every rev-4 number still denotes what it denoted in rev 4** — +"acceptance 34", "acceptance 27" — and the four criteria added in this +revision take letter suffixes rather than displacing anything. Round 3's +finding 4 was stale cross-references surviving a renumber; not +renumbering is the cheaper way to not repeat it. + +**Stage 4a — the typed-edit consumer chain** + +Criterion 46 keeps its number and moves here — it was always the +substrate pin, filed under Stage 4 only because Stage 4 was one stage. +Per the no-renumbering rule above, round 5's additions take letter +suffixes on both sides of the split. + +46a–46h live in a **new `tests/typed_edit_chain_acceptance.rs`**, which +is part of Stage 4a's declared footprint (Q#LN10) and a required gate +for its PR. They cannot live in `tests/auto_pair_acceptance.rs`, which +criterion 46 requires to stay byte-identical. + +46. **Provenance-refactor pin:** the full `tests/auto_pair_acceptance.rs` + suite passes **unmodified**. A suite edited to accommodate the + refactor proves nothing; the diff for 4a must show zero lines + changed in that file. +46a. The chain reads the record exactly once: with two consumers + registered, a `take_typed_edit()` from inside either observes nil, + and both consumers receive the *same* record fields. Bites against a + chain that re-takes per consumer (which would hand the second one + nil in production and pass a single-consumer test). +46b. Ordering is by declared priority, not registration order: two + consumers registered low-priority-last still run + low-priority-first. Bites against a chain that "works" only because + `include_str!` order happens to agree with intent. +46c. A claiming consumer stops the chain — a later consumer does not + run — and a non-claiming one does not. +46d. A consumer that throws is contained: the later consumers still + run, and the failure reports through `set_status`. Bites against a + chain where one bad consumer silently disables every consumer + behind it. (Round 8 correction: an uncontained throw would *not* + take the fan-out's other subscribers down — `run_all_must_succeed` + in `src/hook.rs` collects errors and continues, so `lsp.lua` still + flushes. Rev 7 claimed otherwise. The containment is still + required; the reason is narrower than stated.) Rendering the error + is itself protected: a Lua error may be any value, including a + table whose `__tostring` throws, and reporting outside the + containment reintroduces the escape it exists to prevent. +46e. **Q#AP7 ordering survives.** The existing `sighelp` fake-server + test — pairing's closer must be in the buffer before `lsp.lua` + flushes `didChange` — still holds with pairing behind the chain. + Falsified by moving the chain's registration after `lsp.lua`'s. +46f. **Each consumer's record is its own.** A declining consumer that + mutates the record it was handed cannot change what a later + consumer sees. Bites against handing every consumer the same + mutable table: pairing decides what to close from `rec.char`, so a + forged `char` makes it insert a pair the user never typed. Every + field is a scalar or an opaque id, so a shallow copy is a complete + snapshot. +46g. **The fan-out iterates a snapshot.** A consumer may register or + remove consumers while the chain runs; both take effect on the next + fan-out. Bites against iterating the live array, where a consumer + that registers a lower-priority one shifts itself forward under + `ipairs` and runs twice — unbounded if it re-registers each time. +46h. **The registrar has a lifecycle.** `add_consumer` returns an + opaque handle; `remove_consumer` unregisters it and reports whether + it was live, so a double-remove is a no-op rather than a throw. + Without it, re-evaluating a config or reloading a package + accumulates callbacks permanently — the leak `COHERENCE.md` §13 + already records against `pmacs.hook.add`, which a teardown-less + chain would inherit and spread to every consumer. Priority is + validated as a **finite integer in i32 range**, matching + `pmacs.completion.register`: NaN is a number and every ordered + comparison with it is false, so a bare type check lets it land + wherever the insertion scan gives up and silently voids 46b. + +**Stage 4b — the Unicode input method** + +38. `\alpha` + space yields `α ` — the space lands first and the + expansion runs in the following `buffer.after-edit`, so the + terminator is **retained**, not consumed. The expansion is a single + undo step: one undo restores `\alpha ` (with its space), not + `\alph`. Rev 6 wrote the post-undo text as `\alpha`, which would be + true only if the terminator were swallowed. 39. `\<>` yields `⟨⟩` with the point between them, from the `$CURSOR` placeholder. -40. **Pair-collision pin (Q#LN10).** `\[[]]` yields `⟦⟧`: each `[` is +40. **Pair-collision pin (Q#LN22).** `\[[]]` yields `⟦⟧`: each `[` is claimed as an extension of the pending abbreviation, so auto-pairing never inserts a closing `]` into the pending key. Bites against an ordering where pairing runs first, and against a consumer that claims @@ -1377,15 +2470,87 @@ What remains deferred: 41. `\to` yields `→` eagerly on uniqueness, with no terminator typed. 42. A prefix with no match (`\zzzz` + space) is left as literal text; no edit is made. -43. Moving the cursor out of a pending abbreviation abandons it. +43. **Lazy abandonment (Q#LN22).** Because there is no cursor-motion + hook, this asserts what pmacs can actually detect: after `\alp`, an + explicit `goto_byte` elsewhere followed by typing `h` inserts a + plain `h` and leaves the `\alp` text untouched — the pending state + is dropped, not expanded. Plus: `buffer.after-switch` clears pending + state eagerly. **Rev 5's version of this criterion was not + buildable**; recorded so the change is visible rather than silent. 44. `pmacs.config.set("lean.abbrev", false)` disables expansion; the setting is read against the typed edit's **source** buffer. 45. Expansion does not fire in a non-`lean4` buffer — including that a pending abbreviation is never opened there, so `\[` in a Rust buffer still pairs normally. -46. **Provenance-refactor pin:** the full auto-pairing acceptance suite - passes unchanged, and a bite against the pre-refactor `pair.lua` - confirms the shared-consumer commit is behavior-preserving. +45a. **Shortest-key resolution (§2.11).** `\alp` + space yields `α`, and + `\al` + space yields `∀` — from `all`, not `alpha`. The second is + the one that bites: a "longest match" or "unique match only" + implementation passes the first and fails this. +45b. **Suffix rule.** `\alp7` + space yields `α7`. Bites against an + implementation that drops unmatchable trailing characters or + abandons the whole abbreviation. +45c. **There is no terminator list.** `\+` followed by space extends + rather than terminating, because `'+ '` is a key. Bites against any + implementation with a hardcoded space/tab/RET terminator set — which + is what rev 5 specified. +45d. **`\\` yields a single `\`**, by extension-and-eager-match rather + than by treating the second `\` as a terminator. And after a + *non-empty* pending key, a second `\` does terminate and open a new + abbreviation: `\alpha\to` + space yields `α→`. +45e. **No re-arm through inserted text (§2.11).** `\setminus` + space + yields a literal `\`, and typing an ordinary letter after it inserts + that letter — the inserted backslash opens no pending abbreviation, + because the expansion is a programmatic replace that arms no record. + Bites against a future consumer that infers pending state from + buffer text instead of provenance. +45i. **Pending state is per frontend, with conservative shared-buffer + invalidation (Q#LN22).** Two frontends share a `lean4` buffer at + distinct points. A types `\al`; B types `p`. B's `p` lands normally + at B's point rather than extending A's record. Because that edit + advances the shared buffer's revision, A then typing `l` + space + leaves literal `\all ` rather than expanding: A's stale record is + abandoned lazily. In a fresh setup, A types `\al`, B switches + buffers **without editing the shared buffer**, and A typing `l` + + space still yields `∀`; B's switch clears only B's entries. Finally, + `frontend.detached` for B purges B's entries only and does not clear + a still-valid A record. Bites both against the buffer-keyed design + rev 6 specified and against the impossible rev-7 promise that + pending state survives arbitrary peer edits. +45f. **Both producers, and the CI-darkness stated.** The dispatch path + is pinned by the criteria above. The optimistic CRDT producer + (round-5 finding 4) is pinned by a separate criterion driving + `handle_remote_crdt_op`, which is `#[cfg(feature = "crdt")]` and + therefore **dark in CI and dark in the required gate list**, since + that list runs `--features crdt` only for `--lib`. The PR must + either land that coverage as a `--lib` test where the gate reaches + it, or state in its description that the optimistic path was + verified only locally and name the command. Silence here is the + failure mode — a green CI would otherwise read as covering the path + most users take. +45g. **Table integrity — what the suite can actually check.** + `abbreviations.json` is not shipped, so the suite cannot diff + against it and rev 6's "matches byte-for-byte" was unbuildable; a + count plus seven spot entries could not prove 1,855 round-trip + anyway. The full source-fidelity check belongs to the generator + (Q#LN11: re-parse own output, compare the ordered sequence entry for + entry, fail on any difference). What the suite pins instead are + self-consistency properties that a corrupt emit breaks: + - the loaded sequence's length equals the header's declared count, + and equals the declared count for the recorded upstream commit; + - every key is unique, and the derived `key → index` lookup has the + same cardinality as the sequence (a collision would silently drop + entries); + - every key and symbol is well-formed UTF-8, and no symbol contains + `$CURSOR` more than once; + - the resolution spot-set behaves: `alpha`, `to`, `<>`, `+ `, `\`, + `n`, `setminus`, and the tie cases from 45h. +45h. **Tie-break by source order (§2.11).** `\f` + space yields `‹` — + `f<` and `f>` are both length 2, and `f<` is declared first. Same + for `\"` + space → `Ä`, first of eleven equal-length candidates. + **This is the criterion that bites a map-shaped vendored table**: + with `pairs` iteration it passes or fails by hash order, so it must + also be run against a deliberately reversed sequence and shown to + fail. 101 prefixes are exposed to this rule. **Stage 5 — the goal view** @@ -1447,13 +2612,13 @@ What remains deferred: - **#146 (HTML+CSS)** — the global capture table, and the requirement to pin retro-paint in both directions. Q#LN4 is that lesson applied. - **#123 (JSON/YAML)** — declarative `pmacs.lsp.config` entries with a - fake-server delivery proof plus PATH-gated live smokes. Stage 3 follows + fake-server delivery proof plus PATH-gated live smokes. Stage 3b follows it, with the extra success-gate §2.9 forces. - **#110 (auto-pairing)** — `take_typed_edit()` provenance, the fail-closed discipline on transformed source edits, and Q#AP1's optimistic-classifier - limitation. Stage 4 is built on all three. + limitation. Stage 4a generalizes the first; 4b is built on all three. - **#127 (config registry)** — `pmacs.config.define` and the - source-buffer-resolution correction. Q#LN10's gate follows + source-buffer-resolution correction. Q#LN22's gate follows `editing.auto-pair` exactly. - **#129 (mode system)** — mode-scoped keymaps for Stage 5. - **#155 (bottom panel)** — `pmacs.window.display` and the panel adopter @@ -1465,3 +2630,127 @@ What remains deferred: which Q#LN17 registers into. - **#94/#95 (LSP panels)** — `pmacs.listview.open` and the references/outline panel shape that Stage 7 reuses wholesale. + +## 9. Coherence impact (COHERENCE §20) + +Required of every framing since #163. Stated for stages 3a and 3b, the +work this revision authorizes; the earlier stages predate the rule and +are not retrofitted here. + +**Sections served.** §1.2 (the silence asymmetry) primarily, and §7 +(first-class workspaces) indirectly — per-root affinity is the workspace +concern arriving one language at a time. §9 (worker identity) is touched +but not advanced. + +**Golden journey (§2).** No step is touched. Neither stage changes what +happens between launching pmacs and editing a file; Lean is not on the +journey's critical path, and 3a is invisible to a user who has no Lean +installed. Stage 3b does make §2's step-3 grade slightly *worse* in one +narrow way, and it is honest to say so: a preconfigured-but-missing +`lake` is one more instance of the silent-spawn-failure class, on a +toolchain many users will not have. Q#LN7's status-line reports on the +probe verdict and the latch cover the Lean-specific paths, but they do +not fix the general failure — that remains Priority 1 work with its own +framing, as §1.2's frequency note already records. + +**Interaction islands (§6).** None added. Stage 3b introduces no keymap, +no modal surface, and no dispatch shadow. Its one user-facing command +(`M-x lean-wait-for-diagnostics`, Q#LN16) registers through the ordinary +command table and is reachable from `M-x` like everything else. + +**Config registry (§11).** Neither stage adds a `pmacs.config` option. +`pmacs.lsp.config.lean4` joins the existing declarative server table +alongside sixteen other languages — deliberately *not* the typed registry, +because moving one language's entry there while the other sixteen stay +put would fragment the surface rather than unify it. Migrating +`pmacs.lsp.config` wholesale is a config-arc concern; this lane must not +create a precedent that makes it harder. Stage 4b's `lean.abbrev` gate is +where this arc does enter the registry, and Q#LN22 already commits to the +`editing.auto-pair` shape. + +**Background-work attribution (§9).** Three pieces of background work, +each with a named owner and an observable trace: + +| Work | Identity | Trace | +|---|---|---| +| `lake --version` probe | `ProcessSpec.label = "lean:lake-version-probe"`, visible in `pmacs.process.list` | status line on a verdict that triggers fallback | +| the fallback latch | the server it stops/spawns is already in `pmacs.lsp.list()` | status line on firing | +| root resolution | none — synchronous, inside the attach | status line on resolver failure (shipped #161) | + +This is attribution within the identity layer §9 says is absent, not a +fix for its absence: the probe carries a label because +`ProcessSpec.label` is the only field available, and §9's own ground +truth calls that "caller-supplied, unvalidated convention." Owner/purpose +/parent fields remain unbuilt, and nothing here joins the four activity +planes. What this lane commits to is not *worsening* the ratio — every +background action it adds is nameable in some user-visible view on the +day it ships. + +**Debt this revision retires.** Q#LN20 closes the gap #161 could only +document: a configured root reaching `file_uri_for` uncanonicalized. That +was coherence debt of exactly §1.3's compounding kind — a correct +substrate with a footgun the next caller was expected to disarm by +reading a comment. + +**Debt this revision names rather than pays.** Three, all in §6: the +uncapped event queue, the dropped `cfg.restart`, and — unchanged from +#161 — surfacing the spawn failure itself. Each is a behavior change for +languages other than Lean, and §4's rule is what keeps them out of a Lean +PR. + +### 9.1 Coherence impact — stages 4a and 4b (rev 8) + +**Sections served.** §6 (interaction islands) primarily, and in the +*preventing* direction rather than the fixing one — see below. §11 +(config registry) secondarily, by adding one option in the established +shape rather than a new switch mechanism. + +**Golden journey (§2).** No step is touched by 4a. 4b improves **step 5 +("Edit immediately")** for Lean specifically and changes nothing for any +other language — rev 6 cited step 4, which is "Understand the visible +interface" and is untouched by both stages: the pending-abbreviation state exists only in `lean4` buffers. +Neither stage changes launch, open, or attach. + +**Interaction islands (§6).** **None added, and this is the load-bearing +claim of Stage 4b.** An input method is the archetypal island: a modal +state where ordinary keys mean something else, usually with its own +keymap, its own escape, and its own set of commands that only work +inside it. Stage 4b deliberately has none of those. There is no keymap, +no dispatch shadow, no mode line indicator, no command that only works +mid-abbreviation, and no key that exits. The pending state is invisible +to every other subsystem, is abandoned by ordinary editing, and its +worst failure is that the user's literal text stays literal. The +`lean.abbrev` switch is an ordinary registry boolean, not an island +toggle. + +Stage 4a's chain is the mechanism that makes that possible, and it also +retires a smaller island risk: today the only way for a second feature to +react to a typed character is to compete with `pair.lua` for a one-shot +record, and the natural workaround — inferring from buffer text — is how +input methods grow their own private state and, eventually, their own +modal surface. + +**Config registry (§11).** One option, `lean.abbrev`, in exactly the +`editing.auto-pair` shape (boolean, `mutability = "live"`, resolved +against the typed edit's source buffer). This is the arc entering the +registry as §9's earlier text predicted, and it is a genuine adoption +rather than a new surface. Stage 4a adds none. + +**Background-work attribution (§9).** Neither stage does background work. +Both are synchronous inside an existing hook fan-out; no process is +spawned, no timer armed, no request issued. There is nothing to attribute +and nothing to worsen — recorded explicitly because "none" is an answer +this section should be able to give without ambiguity. + +**Debt this revision retires.** The unowned assumption that +`take_typed_edit()` has exactly one consumer forever. That was never a +decision — it was the shape of the only caller — and every future +typed-character feature would have had to rediscover it. Stage 4a turns +an accident into an API with a stated ordering contract. + +**Debt this revision names rather than pays.** One, and it is real: +Q#LN21's cross-peer undo degradation, now covering every abbreviation +rather than three bracket pairs. The fix is chronological cross-peer undo +arbitration, already on the standing backlog and already blocking Q#LN6. +Stage 4b makes the existing gap more visible without widening the class +of defect — but "more visible" is the honest word, not "unchanged." diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md new file mode 100644 index 0000000..48b75d8 --- /dev/null +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -0,0 +1,658 @@ +# Terminal configuration and copy mode + +**Revision 4 — scouted against canonical `main` @ `b889873` (protocol v20), +2026-07-25. APPROVED after four review rounds. Stage 1 is implemented on +branch `terminal-config` (PR #173); Stage 2 (`terminal-copy-mode`) is +framed but not started, and branches off `main` after Stage 1 merges.** + +Revision 4 gives the escape-key cache an owner and a lifecycle (Q#TC4c) — +revision 3 named the key but not the storage, and two implementations +satisfied its acceptance while behaving differently on A→B→A. It also corrects +the read-only deferral, which understated the substrate required: the bypass +path is `ensure_writable`-guarded too, so genuine immutability alone would +break every generated buffer that refreshes. + +Revision 3 corrects two design errors and decides the chords. The +round-trip failure shape in revision 2 was **wrong in the reporter's favour**: +a Lua intercept does not set `Buffer::read_only`, and there is no Lua binding +that does, so an optimistic `CrdtOp` bypasses the intercept *and* passes +`ensure_writable()` — the daemon buffer mutates too, rather than the mirror +diverging alone (Q#TC6a). Revision 2 also had all three settings resolving +against the terminal identity buffer, which is impossible for the two read +*before* that buffer exists (Q#TC2b). Chords are now decided and +collision-scouted rather than deferred to implementation (Q#TC10, Q#TC8a). + +Revision 2 answered seven review findings. Four were load-bearing: the settings +are `Live`, so the registry **accepts buffer-local overrides whether or not we +want them**, and `value_epoch()` does not move on a buffer switch — an +epoch-only cache can serve the wrong terminal's escape chord (Q#TC4); the +double-escape byte is a hardcoded `0x03`, so a configured escape would still +send Ctrl-C and make its own literal chord unreachable (Q#TC4b); the snapshot +buffer needs `set_round_trip_input`, not only a read-only intercept, or a +semantic frontend can optimistically edit it before daemon dispatch (Q#TC6); +and the two stages must be two branches and two PRs. Revision 1's +materialized-copy reframe is unchanged. + +Two stages, one arc, no protocol change: + +- **Stage 1 — configuration.** Terminal profiles, scrollback, and the escape + key become configurable. Today the terminal has **zero** configuration + surface: the `terminal` command hardcodes `os.getenv("SHELL") or "/bin/sh"`, + `scrollback_rows` is a per-open argument only, and the escape chord is a + literal in Rust. +- **Stage 2 — copy mode and search over scrollback.** A command that turns + the retained terminal screen and scrollback into an ordinary buffer, where + isearch, motion, selection, and the kill ring already work. + +Explicitly **not** in this arc: the panel terminal (blocked on bottom-panel +Stage 2), and shell integration (cwd tracking, prompt marks, command zones) — +the keystone that unlocks the VS Code-style cluster, which needs its own +security framing because it decides what a child process may make the editor +do. + +## Branch and PR plan + +**Two branches, two PRs.** Configuration and copy mode are independently +releasable and have no dependency on each other; one framing covers the arc, +but the one-feature/one-branch/one-PR rule governs the implementation. + +1. `terminal-config` — Stage 1. Also carries the **terminal opening + keybinding** (Q#TC10). +2. `terminal-copy-mode` — Stage 2, branched off `main` after Stage 1 merges. + +Sequencing is not a dependency but avoids a conflict: both stages edit +`builtin/runtime/terminal.lua`. + +## Ground truth (measured, not recalled) + +Three facts constrain the design, and two of them rule out the obvious plan. + +### 1. Terminal profiles cannot be a config-registry setting + +`ConfigValue` is **four scalars** — `Bool`, `Int`, `Num`, `Str` +(`src/config_registry.rs:312`) — and its own doc comment says they "are never +stored --- only these four scalars (Q#CR3)". `ConfigKind` adds `Enum`, which +is physically a string validated against choices fixed at `define` time +(`src/config_registry.rs:115-145`). There is no table, list, or map kind. + +A terminal profile is inherently a table: `{ command, args, cwd, env }` per +name. **Table-valued settings are an existing named deferral of the config +registry arc** — the same gap that keeps `pmacs.lsp.config`, +`pmacs.pair.sets`, `pmacs.comment.strings`, and the `pmacs.parse.*` proxies as +raw Lua. Profiles join that list rather than forcing that deferral open here. + +### 2. Search cannot reuse isearch in place over a terminal + +`SearchStore::set(buffer_id, query, matches: Vec)` +(`src/search.rs:99`) keys matches by buffer and addresses them as **byte +ranges into that buffer's rope**; the painting path materializes the source +with `buf.snapshot_rope().slice(0, buf.len(), ..)` (`src/search.rs:435`). + +A terminal identity buffer is **empty and read-only** by construction. Its +content lives in `TerminalScreen` as cells addressed by `(row, col)` across +history plus visible rows — there are no rope bytes to range over. Searching a +terminal in place therefore means a second, parallel search facility with its +own match store and its own highlight path, because terminal painting consumes +owned cells and not document style spans. + +### 3. An in-place copy mode would be the seventh dispatch shadow + +`dispatch_key`'s terminal-transport arm intercepts **every** key before +ordinary keymap dispatch whenever `active_terminal_key` is `Some`, which keys +purely on `is_terminal(window.buffer_id)` (`src/editor.rs:1098-1107`, +`973-1016`). A mode that keeps the terminal buffer focused while rebinding +keys to motion/selection must therefore add a new precedence rung. + +`COHERENCE.md` §6 grades that ladder **weak, "and growing by one island per +modal feature"**, records that **no transient-keymap mechanism exists to +migrate to** (`KeymapStack` has exactly three fixed scopes, no layer stack, no +push/pop, no lifetime), and notes that `describe-key` already lies while a +shadow is active. It also names the counter-example: the entire picker/panel +family uses ordinary **buffer-local keymaps** and is inspectable and +rebindable. + +### 4. What already exists and is reusable + +- `retained_rows(projection)` (`src/terminal/view.rs:539`) iterates history + plus visible rows; `copy_selection_bytes(rows, selection)` + (`src/terminal/view.rs:849`) serializes a range with the fidelity Stage 2 + criterion 21 already pins — soft wraps joined, hard rows separated, trailing + default blanks trimmed, wide glyphs and combining clusters copied once. +- `ConfigRegistry::value_epoch()` (`src/config_registry.rs:1127`) is public and + monotonic — cheap invalidation for a hot-path cache. +- The Lua surface is `define` / `get` / `set` / `set_local` / `on_change` with + a disposable handle (`src/lua_bindings/config.rs`). +- `pmacs.terminal.open` already accepts + `command, args, cwd, env, name, rows, cols, scrollback_rows, display, + window`. **`display = "panel"` already works** (bottom-panel Stage 1) — the + panel terminal is blocked on rendering, not on this surface. +- Terminal buffers already carry buffer-local bindings (`M-w`, `M-v`, `C-v`, + `M-<`, `M->`) installed by `terminal.open` in `builtin/runtime/terminal.lua`. + +## Stage 1 — configuration + +**Q#TC1 — Profiles are a raw Lua table, not a setting.** +`pmacs.terminal.profiles` maps a name to a spec table, exactly following the +`pmacs.lsp.config` precedent. The registry holds only scalars. Rejected +alternative: widening `ConfigValue` with a table kind — that is the config +arc's own named deferral, it is cross-cutting (persistence, `describe-setting` +rendering, the `custom-file` question all key on the scalar assumption), and +smuggling it into a terminal PR would be the wrong place to decide it. + +**Q#TC2 — `terminal.default-profile` is `String`, not `Enum`.** `Enum` +choices are frozen at `define` time; profiles are user-extensible from +`init.lua` and later. Validation happens at open time, and an unknown name +must produce a pointed error that **names the known profiles**, not a bare +"unknown profile". + +**Q#TC2a — the exact settings, defaults, and bounds.** All three are `Live` +(see Q#TC2b), and every default reproduces today's behavior exactly, so a tree +with no settings written behaves identically (acceptance 12). + +| name | kind | default | bounds | +|---|---|---|---| +| `terminal.default-profile` | `String { allow_empty: true }` | `""` | — | +| `terminal.scrollback-rows` | `Integer` | `10_000` (`DEFAULT_TERMINAL_SCROLLBACK_ROWS`) | `0 ..= 4_000_000` (`MAX_TERMINAL_HISTORY_CELLS`) | +| `terminal.escape-key` | `String { allow_empty: false }` | `"C-c"` | parsed as a chord | + +**Zero is a legal scrollback value meaning "retain no history".** The core's +own validation rejects only values *above* `MAX_TERMINAL_HISTORY_CELLS` +(`src/terminal/session.rs:114`), so `scrollback_rows = 0` is accepted through +`terminal.open` today. A `1` minimum here would invent an asymmetry between the +setting and the per-open field for no reason. + +`""` is the **"no default profile" sentinel**: an empty string means "fall +through to `$SHELL`", not "a profile named empty". `allow_empty: true` exists +precisely to express it, and the open path treats empty and unset identically. + +**Q#TC2b — the settings are `Live`, and the registry therefore accepts +buffer-local overrides. That is specified rather than accidental.** +`ConfigRegistry::set_local` refuses only `StartupOnly` definitions +(`src/config_registry.rs:949`); a `Live` setting can be pinned per buffer by +anyone. Declaring these global-only is **not currently expressible** — a +`scope = "global"` define flag is one of the config registry's own named +deferrals, and `autosave.interval-ms` already has the same latent problem. + +Making them `StartupOnly` instead would buy enforcement at the cost of the +feature: the escape key could never be changed mid-session, which kills Q#TC4's +whole point. So they stay `Live`, and resolution is defined **per setting, +because the three are not read at the same moment**: + +| setting | read when | resolution | +|---|---|---| +| `terminal.escape-key` | every keystroke in a terminal (cached) | `get(name, terminal_buffer)` — **buffer-local → global → default** | +| `terminal.default-profile` | once, **before** the terminal exists | `get(name)` — **global chain only** | +| `terminal.scrollback-rows` | once, **before** the terminal exists | `get(name)` — **global chain only** | + +The split is forced, not stylistic. The two open-time settings are consumed by +`_open` **before it creates the identity buffer**, so there is no terminal +buffer to resolve against — and no caller could have pinned a local override on +a buffer that does not yet exist. `pmacs.config.get(name)` with no buffer +argument already means exactly "the global chain, never an ambient buffer", so +this is the registry's existing semantic rather than a new rule. + +Consequences, stated so they are not discovered later: + +- a per-terminal escape key is a supported feature, not a bug; +- `set_local` on `terminal.default-profile` or `terminal.scrollback-rows` is + **always inert**, for any buffer, because the open path never consults a + buffer chain. This is deliberate; the alternative — resolving against + whichever buffer happened to be current at open time — would make a + terminal's scrollback depend on what the user was looking at when they + pressed the key. + +Rejected alternative: resolving the open-time settings against the *target +window's pre-open buffer*. It is expressible, but it makes an ambient buffer +load-bearing for a value the user set globally, which is the trap +`pmacs.config`'s two-argument/one-argument split exists to avoid. + +**Q#TC3 — `terminal.scrollback-rows` is `Integer` with bounds, and an explicit +per-open `scrollback_rows` still wins.** The precedence is +**explicit argument over global setting** — there is no ambient buffer in this +chain at all (Q#TC2b resolves it through `get(name)`), so the rule is simply +that what a caller passes to `terminal.open` beats what the user configured +globally. The bounds above come from the existing validation, so the setting +cannot express a value the core will reject. + +**Q#TC3a — profile resolution order, field by field.** `profile` is accepted +by **`pmacs.terminal.open` as well as the command**, so a Lua caller is not +forced through the command to use one. For each field, the first source that +supplies it wins: + +1. an explicit `pmacs.terminal.open` field; +2. the named profile's field — `profile` argument, else + `terminal.default-profile` when non-empty; +3. the scalar setting, where one exists (`scrollback_rows` only); +4. the built-in fallback (`command` = `$SHELL`, else `/bin/sh`). + +`env` is the one field where "first wins" is ambiguous, so it is stated: +profile `env` and explicit `env` are **merged**, with explicit entries +overriding profile entries of the same name. Any other reading silently drops +half a user's environment. + +An explicitly passed `profile` that does not exist is an error even when +`terminal.default-profile` is valid — a typo must not silently fall back to +the default. + +**Q#TC4 — `terminal.escape-key` is a `String` chord spelling, parsed once and +cached by `(buffer_id, value_epoch)`.** `is_terminal_escape_chord` +(`src/editor.rs:4413`) currently compares against a literal `C-c`. Reading and +parsing a setting on **every keystroke in a terminal** is not acceptable in +that path. + +**The cache key must include the buffer.** `value_epoch()` advances only on +`set` / `set_local` / removal (`src/config_registry.rs:918`, `970`, `1011`, +`1029`) — **it does not move when the focused terminal changes**. An +epoch-only cache therefore serves terminal A's escape chord to terminal B for +as long as no setting is written, which is exactly the case where nothing looks +wrong. Keying on `(buffer_id, value_epoch)` is the minimum correct identity. + +**Q#TC4c — the cache lives on `TerminalSession`, so its lifecycle is the +terminal's.** Revision 3 named the key `(buffer_id, value_epoch)` but not the +storage, and the two obvious storages behave differently on A→B→A: + +- a **single last-entry cache** reparses on every switch between two + terminals, and re-reports an invalid value each time — a status line that + scolds you for a setting you already know about, forever; +- an **editor-side map** preserves "parsed and reported once" but **leaks an + entry per terminal** unless something purges it, and that purge is a second + thing to get wrong. + +`TerminalSession` (`src/terminal/session.rs:215`) is created in +`TerminalManager::open` and dropped on kill/prune, so putting the cache there +gets the lifecycle for free with no purge hook to forget. It carries the parsed +chord, the `value_epoch` it was parsed at, and whether the current invalid +value has already been reported. + +**"Reports once" means once per terminal, per effective invalid value.** +A→B→A must not re-report. Changing the setting from one invalid value to a +*different* invalid value **does** re-report, because that is new information +about a new mistake. + +**The reporting channel is `EditorCore::status`** — the same channel +`send_terminal_bytes` already uses for terminal failures +(`src/editor.rs:1122`). Explicitly **not** `pmacs.error`: it is not installed +as a module anywhere in `src/lua_bindings`, so its call sites across the +runtime are dead, and a report sent there would be a report nobody sees. + +**Q#TC4a — an unparseable escape key must not brick terminal input.** A bad +value falls back to `C-c` and reports once. The failure mode this avoids is +severe: with no escape chord, every key goes to the child and the user cannot +reach any editor binding to fix the setting that broke it. + +**Q#TC4b — repeating the configured escape sends THAT chord to the child, not +Ctrl-C.** The double-escape arm currently writes a hardcoded +`&[0x03]` (`src/editor.rs:988`). With `terminal.escape-key = "C-x"`, `C-x C-x` +would send Ctrl-C — and literal Ctrl-X would become unreachable, since the +first `C-x` is always consumed as the escape. The repeat arm must encode the +**configured** chord through the existing `crate::terminal::input::encode_key` +path, which is also how it inherits application-cursor and modifier handling +rather than growing a second encoder. + +Corollary worth pinning: after changing the escape away from `C-c`, an ordinary +`C-c` must reach the child as `0x03` like any other unescaped key. + +**Q#TC5 — the `terminal` command gains an optional profile argument** and +otherwise keeps its current behavior; `$SHELL` remains the fallback when no +profile is configured. No existing invocation changes meaning. + +**Q#TC10 — the terminal opening keybinding is pulled forward into Stage 1.** +`COHERENCE.md` Priority 1 names "a terminal keybinding" as part of protecting +the golden journey, §2 step 8 grades the terminal "works but undiscoverable", +and this stage already edits `terminal.lua`. Panel rendering imposes no +dependency on binding a command that already exists. Close/kill semantics stay +with the panel work, where the entry and exit points get designed together. + +The chord is **decided and scouted, not deferred**: `C-c t`, global. See +Q#TC8a for the collision evidence and for why binding under the existing `C-c` +prefix is a new leaf rather than a shadow. + +## Stage 2 — copy mode and search + +**Q#TC6 — copy mode MATERIALIZES into an ordinary buffer. It does not add a +dispatch shadow.** + +`M-x terminal.copy-mode` snapshots the retained rows into a read-only, +path-less buffer (`*terminal-copy: NAME*`) and displays it. That buffer is an +ordinary document buffer, so: + +- **isearch works, with no new search substrate** — it is a rope, so + `SearchStore` and the existing match-painting path apply unchanged. Ground + truth 2 is answered by not fighting it. +- **motion, selection, `M-w`, the kill ring, even `M-x occur`-style consumers + work** — everything that operates on a buffer. +- **The "keys must not reach the child" problem dissolves structurally.** + `active_terminal_key` keys on `is_terminal(window.buffer_id)`; the snapshot + buffer is not a terminal, so the transport arm never fires. No new guard, no + new precedence rung, and ground truth 3's coherence cost is avoided rather + than paid. +- **`describe-key` stays truthful**, because the bindings are buffer-local and + inspectable — the idiom `COHERENCE.md` §6 identifies as the right side of + the line. + +**Q#TC6a — the snapshot is BOTH intercept-read-only AND round-trip-marked, +and `set_round_trip_input` is the ONLY thing standing between a replica +frontend and unauthorized mutation.** + +The established idiom is two calls: `listview.lua:106` and `compile.lua:272` +each pair `pmacs.buffer.add_intercept` with +`pmacs.buffer.set_round_trip_input(buf, true)`. Revision 2 described the +intercept as the guard and round-trip as defence in depth. **That was wrong, +and the correction matters:** + +- A Lua intercept guards the **dispatch/edit** path only. It does **not** set + `Buffer::read_only`, which is "deliberately independent of edit intercepts" + (`src/buffer.rs:493-500`) — that flag is what makes terminal identity buffers + reject rope, undo/redo, and remote-CRDT mutation alike. +- **No Lua binding sets `read_only` at all.** The whole `src/lua_bindings` + tree only ever *reads* it (`fold.rs:313`). A Lua-created "read-only" buffer + is therefore read-only against dispatch and nothing else. +- So an optimistic `CrdtOp` from a semantic frontend bypasses the intercept + **and passes `ensure_writable()`**. It is applied. The daemon buffer mutates + in lockstep with the mirror — the user silently edits a buffer the editor + told them is read-only. There is no divergence to notice, which is worse + than divergence. + +`set_round_trip_input` prevents this at the only point it can be prevented: it +makes `dispatch_idle_for` report false while the buffer is focused, so the +frontend never applies optimistically and never emits the op. It is not +hardening — it is the guard. + +Two things follow, and both are recorded rather than fixed here: + +- **The same exposure exists today** for every Lua-created read-only buffer — + listview panels and `*compilation*` included. They are correct only because + they call `set_round_trip_input`. This arc must not be the place that + unilaterally changes that substrate. +- **Exposing `Buffer::set_read_only` to Lua** would make these buffers + genuinely immutable at the rope/CRDT boundary the way terminal identity + buffers are, turning round-trip back into real defence in depth. That is a + substrate change affecting listview and compile as much as this snapshot, so + it is named in Deferred with its own lane. + +**Q#TC7 — the materializer reuses the existing serializer.** A whole-range +variant of `copy_selection_bytes` over `retained_rows` inherits the criterion +21 fidelity rather than re-deriving soft-wrap, wide-glyph, and trailing-blank +behavior. Writing a second serializer would guarantee the two drift. + +**Q#TC8 — one snapshot buffer per terminal, reused on re-invoke.** Re-running +the command against the same terminal replaces the contents in place rather +than accumulating buffers. It is killed with its terminal; killing the +snapshot alone leaves the terminal untouched. + +**Q#TC8a — the chords, decided and collision-scouted.** + +Worth stating first because it is easy to get backwards: in a terminal window +every **unescaped** key goes to the child, so terminal-local bindings are +reached as ` `. The existing `M-w` copy is physically `C-c M-w`. +The escape consumes itself and the next key starts a fresh ordinary sequence, +which is also why `C-c`-leading bindings are structurally unreachable *inside* +a terminal. + +| action | scope | binding | physically typed | +|---|---|---|---| +| open a terminal (Q#TC10) | global | `C-c t` | `C-c t` | +| enter copy mode | terminal buffer | `C-t` | `C-c C-t` | +| refresh snapshot | snapshot buffer | `g` | `g` | +| return to terminal | snapshot buffer | `q` | `q` | + +Scouted against the real keymaps: + +- **`C-c t` is free.** No bare global `C-c` binding exists; `C-c` is already a + live global prefix from `fold.lua:48-52` (`C-c @ …`), and `C-c C-k` is + buffer-scoped in compile/async. `C-c t` is a new leaf under an existing + prefix, not a shadow. +- **`C-t` is globally `edit.transpose-chars`** (`editops.lua:909`), and binding + it **buffer-locally is legitimate**: `keymap.bind`'s strictness rejects + binding a *prefix* of an existing sequence within a scope + (`keymap_bind_conflict_surfaces_at_bind_time` — "would shadow"), not + cross-scope shadowing, which is what scopes are for. Listview already binds + `n`/`p`/`g`/`q`/`RET`/`SPC` buffer-locally. Transpose-chars is meaningless in + a read-only terminal buffer. +- `C-c C-t` matches emacs-libvterm's own `vterm-copy-mode` chord, so the muscle + memory transfers. +- `g` / `q` in the snapshot follow listview's precedent exactly. + +**Named limitation:** `C-c t` cannot open a terminal *from inside* a terminal, +because `C-c` is consumed as the escape there. `M-x terminal` still works. This +is the documented consequence of Stage 2 criterion 19, not a new defect. + +These are what make acceptance 21's `describe-key` claim testable: named +bindings, in named buffers, that introspection must report truthfully. + +**Q#TC9 — the live-terminal keys stay.** `M-w`, `M-v`, `C-v`, `M-<`, `M->` on +the terminal buffer are the live affordances and do not change. Copy mode is +additive, on its own binding, and does not replace scroll-and-select. + +## Bets + +- **B1.** Materializing gives search for free: no second match store, no + second highlight path, no terminal-specific search UI. *Scored by Stage 2 + landing with zero changes under `src/search.rs`.* +- **B2.** Point-in-time is sufficient for read-back/search/copy. *Scored by + use; if false, the live frozen mode in Deferred becomes the real feature and + this becomes its snapshot fallback.* +- **B3.** No protocol change. The snapshot is an ordinary buffer, so both + frontends render it with existing machinery. *Scored by the diff.* +- **B4.** The escape-key cache keyed by `(buffer_id, value_epoch)` never + becomes stale in a way a user can observe. *Scored by two acceptances, not + one: changing the setting mid-session (8) and two terminals with different + buffer-local values and no write between them (7). Revision 1's epoch-only + cache would pass the first and fail the second, which is why the bet now + names both.* +- **B5.** Buffer-local escape keys are a feature rather than a hazard. + *Unscored and honestly so: the registry cannot express global-only, so this + is what we get either way. If per-terminal escapes turn out to confuse more + than they help, the fix is the config registry's `scope = "global"` deferral, + not a terminal change.* + +## Deferred (named) + +- **Live frozen copy mode** (true `vterm-copy-mode` semantics: freeze the + terminal in place, navigate it, resume). Strictly larger; needs either the + transient-keymap primitive `COHERENCE.md` §6 specifies or a deliberate + seventh shadow. +- **Shell integration** — cwd tracking, prompt marks, command zones, and the + VS Code cluster downstream of it (command decorations, exit-code markers, + rerun, sticky scroll, terminal IntelliSense). Its own arc, with a security + framing. +- **Table-valued settings** — the config registry's own deferral. This arc + adds a **second** blocked adopter (after `pmacs.lsp.config` / + `pmacs.pair.sets`); worth recording as evidence when that deferral is + ranked. +- **A `scope = "global"` define flag** — also the config registry's own + deferral, and this arc is its second live case after `autosave.interval-ms`. + Until it exists, `set_local` on any `Live` setting is accepted whether or not + the owner wants it, so Q#TC2b specifies the behavior instead of pretending + it is prevented. +- **Panel terminal** — blocked on bottom-panel Stage 2 (semantic frontends are + not `panel_capable`). `display = "panel"` already exists and works on the + grid frontend. +- OSC 8 hyperlinks, images (sixel/kitty), `faint`/`blink`/`conceal`/ + `strikethrough` (needs a shared `Style` widening, so a protocol bump), + cursor shape/blink, kitty keyboard protocol. +- Terminal session persistence/reconnect across editor restart. +- **A terminal close/kill command** — the remaining half of `COHERENCE.md` + §2 step 8's discoverability gap. It belongs with the panel-terminal work, + where entry and exit points get designed together. The *opening* keybinding + is **no longer deferred**: Stage 1 carries it as Q#TC10. +- **Genuine immutability for generated buffers — and it is bigger than a Lua + setter.** Today no Lua binding sets `read_only` (`src/lua_bindings` only + reads it, `fold.rs:313`), so every Lua-created "read-only" buffer — listview + panels, `*compilation*`, and this snapshot — is read-only against dispatch + alone and relies entirely on `set_round_trip_input` (Q#TC6a). + + Merely **exposing `set_read_only` would break all three.** The + intercept-bypass path is `ensure_writable`-guarded too: + `apply_edit_skip_intercepts` calls it first (`src/buffer.rs:994`), and that + is exactly the primitive an owner uses to rewrite its own generated buffer. + Flipping the flag would stop listview refreshing, `*compilation*` streaming, + and this snapshot refreshing — the very operations those buffers exist for. + + So the lane needs **two** things, not one: genuine immutability at the + rope/CRDT boundary, *and* an owner-authorized update path that is not simply + "skip the intercepts". Naming only the setter would have made it look like a + one-line follow-up. + +## Acceptance + +### Stage 1 — `terminal-config` + +1. `pmacs.terminal.profiles` accepts a strict spec table per name and rejects + unknown fields before anything is spawned, matching `terminal.open`'s + existing transactional contract. +2. `terminal.default-profile` naming an unknown profile fails at open with an + error that **lists the known profile names**, and creates no buffer, + session, or process. An explicitly passed unknown `profile` fails the same + way **even when `terminal.default-profile` is valid** (Q#TC3a). +2a. That diagnostic is **total over a malformed profiles table** (review round + 1). `pmacs.terminal.profiles` is a raw user table, so listing its names must + not assume its keys are comparable and rendering a requested name must not + assume it is a string: a table holding both a string and a numeric key made + `table.sort` raise `attempt to compare number with string` *on the + unknown-profile path*, replacing the exact error being asked for, and `%q` + raises on a non-string `profile` argument. Both are partial functions + applied to user input on a diagnostic path — the failure class is + "the error reporter is the thing that fails". +3. Field-by-field resolution follows Q#TC3a: explicit open field beats profile + field beats scalar setting beats `$SHELL`. `env` **merges**, with explicit + entries overriding profile entries of the same name. +4. `""` in `terminal.default-profile` means "no profile" and is + indistinguishable from unset (Q#TC2a). +5. `terminal.scrollback-rows` takes effect for a terminal opened without an + explicit `scrollback_rows`; an explicit per-open value overrides it; values + outside `0 ..= 4_000_000` are rejected by the registry rather than by the + core, and `0` is accepted as "retain no history". +6. `terminal.escape-key` changes which chord escapes to the editor, observed + through the **real dispatch path**, not by calling the predicate directly. +7. **Two terminals with different buffer-local escape keys each honor their + own**, with no setting written in between (Q#TC4/Q#TC2b). Driven as + **A→B→A**, asserting both directions. This is the pin an epoch-only cache + fails. +8. Across that same **A→B→A** switch with no setting written, the parse count + does **not** increase after each terminal's first keystroke (Q#TC4c) — + pinned by counting parses, not by timing. This is the pin a single + last-entry cache fails while still satisfying 7. +8a. A terminal's cache does not outlive it: killing a terminal and opening a + new one does not serve the dead terminal's chord, and no per-terminal cache + entry survives its session (Q#TC4c). This is the pin an unpurged + editor-side map fails. +9. With `terminal.escape-key = "C-x"`: `C-x C-x` sends **Ctrl-X** to the child, + and an ordinary `C-c` reaches the child as `0x03` like any other unescaped + key (Q#TC4b). Bite: against the hardcoded `&[0x03]`, the first assertion + fails. +10. An unparseable `terminal.escape-key` falls back to `C-c`, reports through + `EditorCore::status`, and leaves the terminal usable (Q#TC4a). Bite: with + the fallback removed, the terminal becomes unescapable. +10a. "Reports once" is once per terminal per effective invalid value + (Q#TC4c): an **A→B→A** switch with the same invalid value reports **once**, + while changing it to a *different* invalid value reports again. The report + count is asserted, not the message text. +11. The terminal opening keybinding invokes the existing command, and is + verified to have shadowed nothing (Q#TC10). +12. Existing `terminal` invocations and every existing terminal test behave + identically with no settings defined and no profiles registered. + +### Stage 2 — `terminal-copy-mode` + +13. `terminal.copy-mode` produces a read-only buffer whose text is + byte-identical to serializing the full retained range through the existing + copy path (Q#TC7) — pinned against the serializer, so the two cannot drift. +14. Soft wraps, hard rows, wide glyphs, combining clusters, and trailing + default blanks appear in the snapshot exactly as Stage 2 criterion 21 pins + them for selection copy. +15. isearch over the snapshot finds content that is **only in scrollback** + (scrolled off the visible screen), with no change to `src/search.rs` (B1). +16. **Ungated, runs in CI:** focusing the snapshot buffer makes + `dispatch_idle_for` report **false**. This is the whole mechanism Q#TC6a + depends on, it needs no CRDT, and it fails the moment + `set_round_trip_input` is dropped — so the load-bearing regression is + caught by the default configuration rather than only by a `crdt`-gated + test that CI never compiles. +17. **Through a semantic frontend** (this one does need CRDT): keys typed in + the snapshot buffer reach ordinary dispatch and never the child, and + **neither the daemon buffer nor the frontend's mirror is mutated** + (Q#TC6a). Bite: with `set_round_trip_input` removed, the optimistic op is + emitted, bypasses the Lua intercept, passes `ensure_writable()`, and + mutates **both sides** — a buffer the editor calls read-only silently + accepts an edit. +18. Re-invoking against the same terminal refreshes in place; the buffer count + does not grow (Q#TC8). Killing the snapshot leaves the terminal running; + killing the terminal removes the snapshot. +19. `C-t` in a terminal buffer (physically `C-c C-t`) enters copy mode; `g` + refreshes the snapshot from the live terminal and `q` returns to the source + terminal (Q#TC8a). +20. The live terminal's own keys are unchanged while a snapshot exists + (Q#TC9), and the terminal keeps following its tail. +21. The dispatch-shadow count is **unchanged at six** — pinned by asserting + `describe-key` reports the truth for the snapshot buffer's `g` and `q`, + which is the observable difference between the buffer-local idiom and a + shadow. + +## Coherence impact (`COHERENCE.md` §20) + +- **§6 Interaction islands — this arc deliberately adds none.** It is the + first modal-feeling terminal feature that resolves to the buffer-local + keymap idiom §6 identifies as correct, rather than a seventh rung on the + precedence ladder. The shadow count stays at six and `describe-key` stays + truthful (acceptance 21). Worth recording in §6 as a worked example that the + idiom scales to a case that looks modal. +- **§11 Configuration as typed, layered data** — the terminal gains its first + settings, and produces a second blocked adopter for **two** distinct registry + deferrals: the missing table-valued kind (profiles) and the missing + `scope = "global"` flag (the **two open-time settings** — + `terminal.escape-key` deliberately supports buffer-locals, so only + `default-profile` and `scrollback-rows` want an enforcement the registry + cannot express). §11's ground truth should + record both, because the argument for prioritizing them is now cumulative + rather than hypothetical. +- **§2 golden journey, step 8 — partially closed here.** Stage 1 carries the + **terminal opening keybinding** that Priority 1 explicitly names (Q#TC10), + which is the larger half of "works but undiscoverable". Close/kill stays with + the panel work so the entry and exit points are designed together, and is + named in Deferred rather than silently skipped. +- **§5 Unify discovery** — the new commands must carry real descriptions so + M-x rows are useful; no new introspection surface is added. +- No background-work attribution change; no new activity view; no protocol + change. + +## Verification plan + +Full gate suite per `CLAUDE.md` for each PR separately, plus: + +- **The touched terminal suites in BOTH configurations** — default and + `--features crdt` — not only the CRDT one. `vterm_stage1_acceptance`, + `vterm_stage2_acceptance`, and `vterm_stage3_acceptance` all carry tests in + each, and acceptance 12 is a claim about the default configuration too. +- `cargo test --test config_registry_acceptance` for the new settings. +- New suites: `tests/terminal_config_acceptance.rs` (Stage 1) and + `tests/terminal_copy_mode_acceptance.rs` (Stage 2). +- Every behavioral claim bite-verified. The bites that matter most: + **7/8/8a** — three pins that fail against three *different* wrong cache + implementations (epoch-only key, single last-entry, unpurged map), which is + why one pin was not enough; **9** (a hardcoded `0x03` makes the configured + chord unreachable); **10** (its failure mode is a terminal nobody can + escape); and **16/17** (a read-only buffer that silently accepts an edit on + both sides). +- **The observation seams the cache pins need are `escape_parses` (how often) + and `escape_caches` (how many are still held).** Neither is inferable from + behavior: for a *valid* setting a correct per-session cache and a leaking + editor-side map produce identical keystroke results, and both leave the + session count draining normally. Review round 1 caught 8a asserting the + session count instead — which the unpurged-map bite passes, since a map with + no purge hook leaks *while* sessions drain. A lifecycle claim needs a + lifecycle observable; the count of live sessions is not one. +- **Criterion 5 must open a real terminal and read back retained history.** + Round 1 caught it asserting a registry round-trip instead, which is a test of + the registry: it stays green with the setting's only consumer deleted. The + same shape to watch for anywhere — *asserting that a value was stored is not + asserting that anything reads it*. +- **Do not gate the new suites on `#[cfg(feature = "crdt")]` unless a test + genuinely needs CRDT.** CI never enables that feature, so a suite gated that + way is written and then never run — 264 tests are currently dark for exactly + this reason. That measurement and its lane live on **PR #168**, which is open + and unmerged; it is not yet in `docs/active-work.md` on `main`. + Acceptance 17 does need a semantic frontend, so that one test is gated — but + acceptance 16 pins the same mechanism ungated, so the regression is caught in + CI regardless. That pairing is the pattern to reuse whenever a claim's + end-to-end proof needs CRDT. diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index 5d50b19..67d6620 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -483,6 +483,27 @@ fn main() { } }); write_frame(&mut stdout, &echo); + // Arc 8 Stage 3b: `leanprogress` mode emits one + // `$/lean/fileProgress` covering line 0, so the Lean + // subscriber can be pinned end-to-end through the real + // drain rather than by calling its handler directly. + if mode == "leanprogress" && uri.is_string() { + let progress = serde_json::json!({ + "jsonrpc": "2.0", + "method": "$/lean/fileProgress", + "params": { + "textDocument": { "uri": uri, "version": 1 }, + "processing": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 1, "character": 0 } + }, + "kind": 1 + }] + } + }); + write_frame(&mut stdout, &progress); + } // Also push a synthetic `publishDiagnostics` // notification with two entries (one Error, one // Warning) so M4.6 tests can exercise the store. @@ -1062,6 +1083,39 @@ fn main() { }); write_frame(&mut stdout, &resp); } + ("textDocument/waitForDiagnostics", Some(idv)) => { + // Arc 8 Stage 3b: Lean's `WaitForDiagnosticsParams` is + // `{ uri, version }` (v4.9.0 + // `src/Lean/Data/Lsp/Extra.lean`). Validated here rather + // than echoed, because the generic echo arm below + // accepts anything — which is exactly how a client + // sending only `uri` shipped looking correct. A client + // that omits `version`, or sends a non-integer, gets an + // InvalidParams error the way a real server would. + let uri_ok = params + .get("uri") + .and_then(serde_json::Value::as_str) + .is_some(); + let version_ok = params + .get("version") + .and_then(serde_json::Value::as_i64) + .is_some(); + let resp = if uri_ok && version_ok { + serde_json::json!({ + "jsonrpc": "2.0", "id": idv, "result": serde_json::Value::Null + }) + } else { + serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "error": { + "code": -32602, + "message": "waitForDiagnostics requires { uri, version }" + } + }) + }; + write_frame(&mut stdout, &resp); + } (_, Some(idv)) => { // Generic echo response. let resp = serde_json::json!({ diff --git a/src/daemon.rs b/src/daemon.rs index 9ac8256..84716eb 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1145,10 +1145,7 @@ fn dispatcher_loop( .session_state(*fid) .is_some_and(|s| s.negotiated_capabilities.semantic_render) { - let active_now = { - let core = editor.core.borrow(); - core.active_window_for(*fid).map(|w| w.buffer_id) - }; + let active_now = document_buffer_to_follow(editor, *fid); if let Some(active_now) = active_now && last_active_buffer_sent.get(fid) != Some(&active_now) { @@ -1429,17 +1426,15 @@ fn dispatcher_loop( && session_registry .session_state(*fid) .is_some_and(|s| s.negotiated_capabilities.crdt_replica) + && let Some((buffer_id, byte_pos)) = document_cursor_byte(editor, *fid) { - let core = editor.core.borrow(); - if let Some(window) = core.active_window_for(*fid) { - let cursor_byte_msg = InstanceMessage::CursorByte { - buffer_id: window.buffer_id, - byte_pos: window.cursor, - }; - if let Err(e) = write_message(stream, &cursor_byte_msg) { - eprintln!("pmacs: write CursorByte for {fid:?} failed: {e}"); - write_failed = true; - } + let cursor_byte_msg = InstanceMessage::CursorByte { + buffer_id, + byte_pos, + }; + if let Err(e) = write_message(stream, &cursor_byte_msg) { + eprintln!("pmacs: write CursorByte for {fid:?} failed: {e}"); + write_failed = true; } } } @@ -2038,12 +2033,17 @@ fn handle_dispatcher_event( // straight back off it. The declared buffer is // checked too — a terminal has no byte viewport to // honor from any direction. + // Bottom-panel §1.3 #9 — Projection. The gate asks + // "is this frontend's DOCUMENT surface a terminal", + // so it tests the primary document window. A focused + // TERMINAL PANEL must not suppress the still-visible + // document's viewport. let terminal_context = { let manager = editor.terminal_manager.borrow(); let core = editor.core.borrow(); let active = core - .active_window_for(source) - .is_some_and(|window| manager.is_terminal(window.buffer_id)); + .primary_document_buffer(source) + .is_some_and(|document| manager.is_terminal(document)); active || manager.is_terminal(buffer_id) }; if semantic_states.contains_key(&source) && !terminal_context { @@ -2056,7 +2056,11 @@ fn handle_dispatcher_event( // LOCAL's attach-time buffer (often a scratch the // user isn't viewing), so arrow keys moved an // off-screen cursor and the caret never tracked. - align_semantic_window_to_buffer(editor, source, buffer_id); + // Bottom-panel §1.3 #7 — Projection, and it must + // NOT move focus. Routing this through the + // focused window would let an ordinary document + // viewport overwrite a focused panel's buffer. + align_primary_document_window(editor, source, buffer_id); if let Some(sem) = semantic_states.get_mut(&source) { sem.set_viewport(buffer_id, visible, generation); } @@ -2121,7 +2125,11 @@ fn handle_dispatcher_event( // aligns to the buffer the frontend says it was // displaying: a click can race a buffer switch. if semantic_states.contains_key(&source) { - align_semantic_window_to_buffer(editor, source, buffer_id); + // Bottom-panel §1.3 #8 — Projection + focus. A + // click in the DOCUMENT area means "work here", + // so unlike `Viewport` (#7) this one also takes + // focus out of a panel. + align_and_activate_primary_document_window(editor, source, buffer_id); if kind == PointerKind::Context { // Q#CM1 — right-click opens the context menu // at the hit byte (needs the Lua builder, so @@ -2359,9 +2367,15 @@ fn ensure_active_buffer_crdt_backed( editor: &EditorState, fid: FrontendId, ) -> Option { + // Bottom-panel §1.3 #2 — Projection, and the sharpest case in the + // census. The upgrade BROADCASTS a `BufferSnapshot` to every + // replica, so keying it on focus would mean focusing a fresh + // generated panel buffer swaps every peer's document mirror to it. + // A panel buffer that genuinely needs CRDT backing gets it when it + // is displayed as a document, not as a side effect of focus. let buffer_id_opt = { let core = editor.core.borrow(); - core.active_window_for(fid).map(|w| w.buffer_id) + core.primary_document_buffer(fid) }; let buffer_id = buffer_id_opt?; let core = editor.core.borrow(); @@ -2493,11 +2507,7 @@ fn publish_buffer_snapshot_to_replicas( continue; } if session.negotiated_capabilities.semantic_render { - let displays_buffer = editor - .core - .borrow() - .active_window_for(*peer_id) - .is_some_and(|window| window.buffer_id == buffer_id); + let displays_buffer = peer_displays_buffer_as_document(editor, *peer_id, buffer_id); if !displays_buffer { continue; } @@ -2936,32 +2946,88 @@ fn handle_remote_crdt_op( /// whole switch. This is the input/display alignment fix for B1: the /// frontend's *declared* buffer becomes the buffer its keys edit and /// its `CursorByte` reports. -fn align_semantic_window_to_buffer( +/// The buffer a semantic frontend DISPLAYS AS ITS DOCUMENT — the +/// buffer-follow / `BufferSnapshot` re-send target (bottom-panel §1.3 +/// #1, Projection). +/// +/// Not the focused buffer: focusing a panel must re-send no snapshot and +/// must never swap the replica's document mirror. Named as its own +/// function so the rule is pinnable — its only caller is +/// `dispatcher_loop`, which no test can drive. +#[cfg(feature = "crdt")] +fn document_buffer_to_follow( + editor: &EditorState, + fid: FrontendId, +) -> Option { + editor.core.borrow().primary_document_buffer(fid) +} + +/// The `(buffer, byte)` a semantic replica's authoritative `CursorByte` +/// describes (bottom-panel §1.3 #3, Projection). +/// +/// Q#BP14's vocabulary split: "active buffer" in the replica is a +/// DOCUMENT-SURFACE term, not an input-focus term, so a focused panel +/// must not retarget the document caret at the panel's buffer. +fn document_cursor_byte( + editor: &EditorState, + fid: FrontendId, +) -> Option<(crate::buffer::BufferId, u64)> { + let core = editor.core.borrow(); + let win_id = core.primary_document_window(fid)?; + let window = core.windows.get(&win_id)?; + Some((window.buffer_id, window.cursor)) +} + +/// Whether `peer_id` displays `buffer_id` on its DOCUMENT surface — the +/// `BufferSnapshot` publication recipient filter (bottom-panel §1.3 #21, +/// Projection). +/// +/// Testing the focused window instead would both miss a buffer visible +/// in the document while a panel holds focus, and replace the peer's +/// document mirror for a buffer visible only in a panel. +fn peer_displays_buffer_as_document( + editor: &EditorState, + peer_id: FrontendId, + buffer_id: crate::buffer::BufferId, +) -> bool { + editor.core.borrow().primary_document_buffer(peer_id) == Some(buffer_id) +} + +/// Align a semantic frontend's **primary document window** to the +/// buffer it declared (bottom-panel §1.3 #7, Q#BP14). +/// +/// **Never touches `view.active`.** This is why rejecting panel-named +/// events does not fix the *document* event: with a panel focused, an +/// ordinary document `Viewport` routed through the focused window would +/// overwrite the panel's buffer with the document buffer. Returns the +/// window it aligned so the `Pointer` path (#8) can activate it. +fn align_primary_document_window( editor: &mut EditorState, fid: FrontendId, buffer_id: crate::buffer::BufferId, -) { +) -> Option { use crate::text_view::TextView; - let text_view = { + let (win_id, text_view) = { let core = editor.core.borrow(); - let Some(win_id) = core.views.get(&fid).map(|v| v.active) else { - return; - }; + let win_id = core.primary_document_window(fid)?; if core.windows.get(&win_id).map(|w| w.buffer_id) == Some(buffer_id) { - return; // Already displaying this buffer. + return Some(win_id); // Already displaying this buffer. } let reg = core.registry.borrow(); let Ok(buf) = reg.get(buffer_id) else { - return; // Unknown buffer — leave the window as-is. + // Unknown buffer — leave the window as-is, and report + // FAILURE. Returning the window here would let a stale or + // forged `Pointer` naming a dead buffer take focus out of a + // panel via #8's activation, *before* `dispatch_pointer` + // rejects the mismatched buffer. Alignment did not happen, + // so no caller may treat this as a document gesture. + return None; }; - TextView::new(buf) + (win_id, TextView::new(buf)) }; let mut core = editor.core.borrow_mut(); - let Some(win_id) = core.views.get(&fid).map(|v| v.active) else { - return; - }; if let Some(win) = core.windows.get_mut(&win_id) { win.buffer_id = buffer_id; win.text_view = text_view; @@ -2969,6 +3035,24 @@ fn align_semantic_window_to_buffer( win.selection = None; win.overlays.clear(); } + Some(win_id) +} + +/// Align the primary document window **and take focus to it** +/// (bottom-panel §1.3 #8, Q#BP14). +/// +/// A click in the document area means "work here", so it moves focus +/// out of a panel. This is the one place projection and focus +/// legitimately move together — every other Projection consumer must +/// use [`align_primary_document_window`] alone. +fn align_and_activate_primary_document_window( + editor: &mut EditorState, + fid: FrontendId, + buffer_id: crate::buffer::BufferId, +) { + if let Some(win_id) = align_primary_document_window(editor, fid, buffer_id) { + editor.core.borrow_mut().focus_window(fid, win_id); + } } fn build_fresh_frontend_view( @@ -3399,6 +3483,96 @@ mod tests { ); } + /// Bottom-panel §1.3 #21 through the REAL producer (round 3). + /// + /// A semantic peer with a FOCUSED PANEL must still receive the + /// snapshot for the buffer on its DOCUMENT surface, and must NOT + /// receive one for a buffer visible only in its panel. Asserting the + /// helper alone was insufficient: reverting the producer's call site + /// to focused-window routing left every helper-level test green. + #[cfg(feature = "crdt")] + #[test] + fn snapshot_publication_follows_the_document_under_a_focused_panel() { + let (editor, fid, document, panel) = panel_focused_semantic_fixture(); + let (doc_buf, panel_buf) = { + let core = editor.core.borrow(); + ( + core.windows[&document].buffer_id, + core.windows[&panel].buffer_id, + ) + }; + assert_ne!(doc_buf, panel_buf, "fixture: distinct buffers"); + + let caps = crate::protocol::NegotiatedCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + }; + let mut registry = SessionRegistry::new(); + registry.register_session( + fid, + crate::presence::SessionState::new(PROTOCOL_VERSION, caps, 0), + ); + + // The DOCUMENT buffer's snapshot must be delivered. + { + let (server, mut client) = UnixStream::pair().expect("socketpair"); + // A read timeout on the DELIVERY read too. Without it a + // regression that suppresses the snapshot makes this test + // HANG rather than fail, which is strictly worse than a red + // assertion — found by biting this very test. + client + .set_read_timeout(Some(Duration::from_millis(500))) + .expect("delivery timeout"); + let mut streams = HashMap::from([(fid, server)]); + let message = InstanceMessage::BufferSnapshot { + buffer_id: doc_buf, + crdt_snapshot: vec![1, 2, 3], + }; + publish_buffer_snapshot_to_replicas( + &editor, + doc_buf, + &message, + ®istry, + &mut streams, + &mut HashMap::new(), + ); + let delivered: InstanceMessage = + read_message(&mut client).expect("the document snapshot must arrive"); + assert_eq!( + delivered, message, + "#21: a buffer on the DOCUMENT surface must still be published while a \ + panel holds focus" + ); + } + + // The PANEL-only buffer's snapshot must NOT be delivered. + { + let (server, mut client) = UnixStream::pair().expect("socketpair"); + let mut streams = HashMap::from([(fid, server)]); + let message = InstanceMessage::BufferSnapshot { + buffer_id: panel_buf, + crdt_snapshot: vec![4, 5, 6], + }; + publish_buffer_snapshot_to_replicas( + &editor, + panel_buf, + &message, + ®istry, + &mut streams, + &mut HashMap::new(), + ); + client + .set_read_timeout(Some(Duration::from_millis(50))) + .expect("timeout"); + assert!( + read_message::(&mut client).is_err(), + "#21: a buffer visible only in a PANEL must not replace the peer's \ + document mirror" + ); + } + } + // ---- GPU terminal input: the double terminal-layout sync ------------- // // These drive `sync_terminal_layouts_for_tick` — the REAL dispatcher loop @@ -4385,7 +4559,7 @@ mod tests { /// B1 input/display alignment: a semantic frontend's window is bound /// to LOCAL's attach-time buffer, but the buffer it *displays* is - /// the one it declares via `Viewport`. `align_semantic_window_to_buffer` + /// the one it declares via `Viewport`. `align_primary_document_window` /// re-points the window so keys edit the displayed buffer — without /// it, arrow keys moved an off-screen cursor in the wrong buffer and /// the caret never tracked. @@ -4423,7 +4597,9 @@ mod tests { ); // The frontend declares it is displaying the file buffer. - align_semantic_window_to_buffer(&mut editor, fid, file); + // Bottom-panel §1.3 #7: `Viewport` takes the projection-only + // aligner, which never touches `view.active`. + align_primary_document_window(&mut editor, fid, file); assert_eq!( editor .core @@ -4582,4 +4758,381 @@ mod tests { "the panel was not overwritten with the target" ); } + + /// Bottom-panel §1.3 #8, review round 1 finding 1: a STALE document + /// `Pointer` must not steal focus out of a panel. + /// + /// Driven through `handle_dispatcher_event` — the real dispatcher + /// seam — because the defect lived in the *pair* of alignment and + /// activation, not in either alone. `align_primary_document_window` + /// once returned the window even when the named buffer was gone, so + /// #8's activation focused the document before `dispatch_pointer` + /// ever rejected the mismatched buffer. + #[cfg(feature = "crdt")] + #[test] + fn a_stale_document_pointer_does_not_steal_focus_from_a_panel() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; + use pmacs_protocol::{Modifiers, PointerKind}; + + let mut editor = EditorState::new(); + let fid = FrontendId(88); + + // One document window + one focused bottom panel. + let (document, panel, dead_buffer) = { + let mut core = editor.core.borrow_mut(); + let doc_buf = core.active_window().buffer_id; + let panel_buf = core.registry.borrow_mut().create("*panel*"); + // A buffer id that names nothing: the stale-pointer payload. + let dead_buffer = crate::buffer::BufferId::from_raw(999_999); + + let document = crate::window::WindowId::next(); + let panel_id = crate::window::WindowId::next(); + let (doc_view, panel_view) = { + let reg = core.registry.borrow(); + ( + crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")), + crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")), + ) + }; + core.windows + .insert(document, Window::new(document, doc_buf, doc_view)); + let mut panel = Window::new(panel_id, panel_buf, panel_view); + let mut params = WindowParams::default(); + params.side = Some(crate::window::Side::Bottom); + params.fixed_rows = Some(4); + panel.params = params; + core.windows.insert(panel_id, panel); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout { + root: LayoutNode::Split { + orientation: Orientation::Horizontal, + children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel_id)], + weights: vec![1, 1], + }, + }, + active: panel_id, + fold_projection: false, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + (document, panel_id, dead_buffer) + }; + + let mut render_states = HashMap::new(); + let mut semantic_states = HashMap::new(); + semantic_states.insert(fid, crate::semantic_render::SemanticRenderState::new(fid)); + let mut streams = HashMap::new(); + let mut term_sizes = HashMap::new(); + term_sizes.insert(fid, CellSize::new(24, 80)); + let mut last_idle = HashMap::new(); + let mut last_active = HashMap::new(); + let mut bells = HashMap::new(); + // The dispatcher drops any event from an UNINSTALLED session + // (#148's defense-in-depth membership check), so the session must + // be registered or this test passes for the wrong reason — it did, + // on the first attempt. + let mut registry = SessionRegistry::new(); + registry.register_session( + fid, + crate::presence::SessionState { + negotiated_protocol_version: pmacs_protocol::PROTOCOL_VERSION, + negotiated_capabilities: crate::protocol::NegotiatedCapabilities { + semantic_render: true, + crdt_replica: true, + ..Default::default() + }, + color_slot: 0, + }, + ); + + handle_dispatcher_event( + DispatcherEvent::FrontendEvent { + source: fid, + event: FrontendEvent::Pointer { + frontend_id: fid, + buffer_id: dead_buffer, + byte: 0, + kind: PointerKind::Down, + mods: Modifiers::default(), + }, + }, + &mut editor, + &mut render_states, + &mut semantic_states, + &mut streams, + &mut term_sizes, + &mut last_idle, + &mut last_active, + &mut bells, + &mut registry, + ); + + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "a stale Pointer naming a dead buffer must NOT move focus out of the panel" + ); + assert_ne!( + editor.core.borrow().views[&fid].active, + document, + "non-vacuity: the document window is a real, distinct focus target" + ); + } + + /// Bottom-panel §1.3 #1/#3/#21 — the three Projection producers whose + /// only production caller is `dispatcher_loop`, pinned at the named + /// seams that loop calls. Round 2 finding: reverting any of them to + /// `active_window_for` previously left every test green. + #[cfg(feature = "crdt")] + #[test] + fn tick_producers_describe_the_document_while_a_panel_is_focused() { + let (editor, fid, document, panel) = panel_focused_semantic_fixture(); + let (doc_buf, panel_buf, doc_cursor) = { + let core = editor.core.borrow(); + ( + core.windows[&document].buffer_id, + core.windows[&panel].buffer_id, + core.windows[&document].cursor, + ) + }; + assert_ne!(doc_buf, panel_buf, "fixture: distinct buffers"); + + // #1 buffer-follow / BufferSnapshot re-send target. + assert_eq!( + document_buffer_to_follow(&editor, fid), + Some(doc_buf), + "#1: the follow target must be the DOCUMENT buffer, not the focused panel's" + ); + + // #3 CursorByte. + assert_eq!( + document_cursor_byte(&editor, fid), + Some((doc_buf, doc_cursor)), + "#3: CursorByte must describe the DOCUMENT surface" + ); + + // #21 is deliberately NOT asserted here. Round 3: pinning it at + // this helper left the real producer free to regress — reverting + // the call site inside `publish_buffer_snapshot_to_replicas` + // kept both this test and the existing socket-pair test green. + // It is pinned through the producer instead, in + // `snapshot_publication_follows_the_document_under_a_focused_panel`. + let _ = panel_buf; + } + + /// Bottom-panel §1.3 #2 — the sharpest census case: the lazy CRDT + /// upgrade BROADCASTS a snapshot, so keying it on focus would let + /// focusing a fresh generated panel buffer swap every peer's mirror. + #[cfg(feature = "crdt")] + #[test] + fn lazy_crdt_upgrade_never_targets_a_focused_panel_buffer() { + let (editor, fid, document, panel) = panel_focused_semantic_fixture(); + let (doc_buf, panel_buf) = { + let core = editor.core.borrow(); + ( + core.windows[&document].buffer_id, + core.windows[&panel].buffer_id, + ) + }; + + let upgraded = ensure_active_buffer_crdt_backed(&editor, fid); + assert_eq!( + upgraded, + Some(doc_buf), + "#2: the upgrade must target the DOCUMENT buffer" + ); + assert_ne!( + upgraded, + Some(panel_buf), + "#2: focusing a panel must never trigger its buffer's upgrade+broadcast" + ); + } + + /// Bottom-panel §1.3 #7 vs #8 — `Viewport` aligns WITHOUT moving + /// focus; only `Pointer` activates. Driven through the real + /// dispatcher seam. + #[cfg(feature = "crdt")] + #[test] + fn viewport_aligns_the_document_without_taking_focus_from_the_panel() { + let (mut editor, fid, document, panel) = panel_focused_semantic_fixture(); + let other = { + let mut core = editor.core.borrow_mut(); + core.registry.borrow_mut().create("*other*") + }; + + dispatch_one_semantic_event( + &mut editor, + fid, + FrontendEvent::Viewport { + frontend_id: fid, + buffer_id: other, + visible: pmacs_protocol::ByteRange { start: 0, end: 0 }, + generation: 0, + }, + ); + + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "#7: a document Viewport must NOT move focus out of the panel" + ); + assert_eq!( + editor.core.borrow().windows[&document].buffer_id, + other, + "#7: it must still have ALIGNED the document window to the declared buffer" + ); + } + + /// Shared fixture: a semantic frontend with a document window and a + /// FOCUSED bottom panel. `panel_capable` is set explicitly because + /// Stage 1 ships `false` for semantic sessions and 2B flips it for a + /// v21-negotiated peer. + #[cfg(feature = "crdt")] + fn panel_focused_semantic_fixture() -> ( + crate::editor::EditorState, + FrontendId, + crate::window::WindowId, + crate::window::WindowId, + ) { + use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; + + let editor = crate::editor::EditorState::new(); + let fid = FrontendId(91); + let (document, panel) = { + let mut core = editor.core.borrow_mut(); + let doc_buf = core.active_window().buffer_id; + let panel_buf = core.registry.borrow_mut().create("*panel*"); + let document = crate::window::WindowId::next(); + let panel = crate::window::WindowId::next(); + let (doc_view, panel_view) = { + let reg = core.registry.borrow(); + ( + crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")), + crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")), + ) + }; + core.windows + .insert(document, Window::new(document, doc_buf, doc_view)); + let mut panel_window = Window::new(panel, panel_buf, panel_view); + let mut params = WindowParams::default(); + params.side = Some(crate::window::Side::Bottom); + params.fixed_rows = Some(4); + panel_window.params = params; + core.windows.insert(panel, panel_window); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout { + root: LayoutNode::Split { + orientation: Orientation::Horizontal, + children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)], + weights: vec![1, 1], + }, + }, + active: panel, + fold_projection: false, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + (document, panel) + }; + editor.sync_frame_geometry(fid, CellSize::new(24, 80)); + (editor, fid, document, panel) + } + + /// Drive ONE authenticated semantic event through the real + /// dispatcher. The session must be registered or the event is + /// dropped at the uninstalled-session check before reaching any + /// handler. + #[cfg(feature = "crdt")] + fn dispatch_one_semantic_event( + editor: &mut crate::editor::EditorState, + fid: FrontendId, + event: FrontendEvent, + ) { + let mut render_states = HashMap::new(); + let mut semantic_states = HashMap::new(); + semantic_states.insert(fid, crate::semantic_render::SemanticRenderState::new(fid)); + let mut streams = HashMap::new(); + let mut term_sizes = HashMap::new(); + term_sizes.insert(fid, CellSize::new(24, 80)); + let mut last_idle = HashMap::new(); + let mut last_active = HashMap::new(); + let mut bells = HashMap::new(); + let mut registry = SessionRegistry::new(); + registry.register_session( + fid, + crate::presence::SessionState { + negotiated_protocol_version: pmacs_protocol::PROTOCOL_VERSION, + negotiated_capabilities: crate::protocol::NegotiatedCapabilities { + semantic_render: true, + crdt_replica: true, + ..Default::default() + }, + color_slot: 0, + }, + ); + handle_dispatcher_event( + DispatcherEvent::FrontendEvent { source: fid, event }, + editor, + &mut render_states, + &mut semantic_states, + &mut streams, + &mut term_sizes, + &mut last_idle, + &mut last_active, + &mut bells, + &mut registry, + ); + } + + /// Bottom-panel §1.3 #9 — Projection. The `Viewport` terminal-context + /// gate asks "is this frontend's DOCUMENT surface a terminal", so a + /// focused TERMINAL PANEL must not suppress the still-visible + /// document's viewport. + #[cfg(feature = "crdt")] + #[test] + fn a_focused_terminal_panel_does_not_suppress_the_document_viewport() { + use crate::terminal::TerminalSpec; + + let (mut editor, fid, document, panel) = panel_focused_semantic_fixture(); + let other = editor.core.borrow().registry.borrow_mut().create("*other*"); + + // A REAL terminal in the focused panel. + let mut spec = TerminalSpec::new("/bin/sh"); + spec.rows = 10; + spec.cols = 40; + let term_buf = editor.open_terminal(spec).expect("a real terminal"); + editor + .core + .borrow_mut() + .install_buffer_in_window(panel, term_buf) + .expect("terminal into the panel"); + editor.core.borrow_mut().focus_window(fid, panel); + + dispatch_one_semantic_event( + &mut editor, + fid, + FrontendEvent::Viewport { + frontend_id: fid, + buffer_id: other, + visible: pmacs_protocol::ByteRange { start: 0, end: 0 }, + generation: 0, + }, + ); + + assert_eq!( + editor.core.borrow().windows[&document].buffer_id, + other, + "#9: a focused TERMINAL panel must not suppress the document viewport — the document window should still have aligned to the declared buffer" + ); + } } diff --git a/src/editor.rs b/src/editor.rs index 1db5c3a..a19971e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -415,6 +415,18 @@ impl EditorState { include_str!("../builtin/runtime/listview.lua"), ) .expect("load listview builtin chunk"); + // The typed-edit consumer chain (Arc 8 Stage 4a, Q#LN10) — + // ORDERING CONTRACT: typed_edit.lua must load BEFORE pair.lua, + // which registers a consumer into it, and therefore before + // lsp.lua. It owns the single `buffer.after-edit` subscriber + // that reads the one-shot typed-edit record, so its + // registration position is what preserves Q#AP7 below. + lua_host + .eval( + Some("@pmacs/builtin/runtime/typed_edit.lua"), + include_str!("../builtin/runtime/typed_edit.lua"), + ) + .expect("load typed_edit builtin chunk"); // Auto-pairing (Arc 2, Q#AP7) — ORDERING CONTRACT: pair.lua // must load BEFORE lsp.lua. Hook callbacks run in registration // order, and lsp.lua's `buffer.after-edit` callback flushes @@ -424,6 +436,9 @@ impl EditorState { // the closer stays unsynchronized until the next edit (hook // edits don't re-fire the hook). pair.lua's `pmacs.lsp.*` // lookups are lazy and nil-guarded for the same reason. + // Since Stage 4a the closer is inserted from the chain's + // subscriber rather than pair.lua's own, which is registered + // one chunk earlier — strictly safer for this contract. lua_host .eval( Some("@pmacs/builtin/runtime/pair.lua"), @@ -436,6 +451,17 @@ impl EditorState { include_str!("../builtin/runtime/lsp.lua"), ) .expect("load lsp builtin chunk"); + // Arc 8 Stage 3b: the Lean 4 language server. Loaded after + // lsp.lua because it registers `pmacs.lsp.config.lean4`, + // subscribes on the Stage 3a notification seam, and adds a + // `buffer.after-load` hook that must run AFTER lsp.lua's own + // (it reads the attachment lsp.lua creates). + lua_host + .eval( + Some("@pmacs/builtin/runtime/lean.lua"), + include_str!("../builtin/runtime/lean.lua"), + ) + .expect("load lean builtin chunk"); // Arc 1a: the in-buffer completion popup driver. Loaded after // lsp.lua because it drives `pmacs.lsp.request_completion` / // `pmacs.lsp.attachment_for_request` and after the framework @@ -989,19 +1015,28 @@ impl EditorState { .get(&frontend_id) .is_some_and(|state| state.terminal_escape); if let Some(view_key) = terminal_key { + // Q#TC4: the escape chord is per terminal, resolved through + // `terminal.escape-key` and cached on the session so this + // hot path parses at most once per (terminal, config epoch). + let escape_chord = self.terminal_escape_chord(view_key.buffer_id); if escaped { self.dispatchers .entry(frontend_id) .or_default() .terminal_escape = false; - if chord.is_some_and(is_terminal_escape_chord) { + if chord == Some(escape_chord) { + // Q#TC4b: repeating the escape sends THAT chord to the + // child, not a hardcoded ETX. With a configured escape + // of `C-x`, sending Ctrl-C here would both surprise the + // user and make literal Ctrl-X unreachable, since the + // first press is always consumed as the escape. self.claim_terminal_controller(view_key); - self.send_terminal_bytes(view_key.buffer_id, &[0x03]); + self.send_terminal_escape_literal(view_key, escape_chord); return; } // The post-escape key starts a fresh ordinary sequence below. } else if !dispatcher_pending { - if chord.is_some_and(is_terminal_escape_chord) { + if chord == Some(escape_chord) { let state = self.dispatchers.entry(frontend_id).or_default(); state.terminal_escape = true; state.dispatcher = KeyDispatcher::new(); @@ -1117,6 +1152,54 @@ impl EditorState { .then_some(key) } + /// This terminal's effective escape chord (Q#TC4). + /// + /// Resolution is `get("terminal.escape-key", terminal_buffer)` — + /// buffer-local, then global, then default — because unlike the two + /// open-time settings this one is read while the terminal exists, so + /// a per-terminal escape is expressible and supported (Q#TC2b). + /// + /// The parse and the once-per-terminal invalid-value report both live + /// in [`crate::terminal::TerminalManager::escape_chord`]; this method + /// only supplies the resolved spelling and the epoch that keys the + /// cache, and surfaces any report through the status line — the same + /// channel `send_terminal_bytes` uses for terminal failures. + fn terminal_escape_chord(&self, buffer_id: crate::buffer::BufferId) -> Chord { + let lua = self.lua_host.lua(); + let (spelling, epoch) = crate::lua_bindings::config_string_and_epoch( + lua, + "terminal.escape-key", + Some(buffer_id), + crate::terminal::DEFAULT_TERMINAL_ESCAPE_KEY, + ); + let (chord, report) = self + .terminal_manager + .borrow_mut() + .escape_chord(buffer_id, epoch, &spelling); + if let Some(message) = report { + self.core.borrow_mut().status = message; + } + chord + } + + /// Send the configured escape chord to the child as literal input + /// (Q#TC4b), through the same encoder ordinary keys use so it + /// inherits application-cursor and modifier handling. + fn send_terminal_escape_literal(&self, key: TerminalViewKey, chord: Chord) { + let event = KeyEvent::new(chord.code, chord.modifiers); + let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(event) else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + if let Some(bytes) = crate::terminal::input::encode_key(terminal_key, modifiers, modes) { + self.send_terminal_bytes(key.buffer_id, &bytes); + } + } + fn claim_terminal_controller(&self, key: TerminalViewKey) { let mut manager = self.terminal_manager.borrow_mut(); let _ = manager.register_view(key); @@ -1341,9 +1424,16 @@ impl EditorState { frontend_id: FrontendId, buffer_id: crate::buffer::BufferId, ) -> Option { + // Bottom-panel §1.3 #6/#10/#11 — Projection. The full-window + // semantic terminal declaration, its snapshot/sync, and its + // frame suppression all describe the frontend's PRIMARY DOCUMENT + // surface, never a panel band: panel terminals get `PanelFrame` + // / `PanelPointer` in Stage 2B instead. Resolving through + // `view.active` would let a focused panel terminal both claim + // the document declaration and suppress the document pass. let core = self.core.borrow(); - let view = core.views.get(&frontend_id)?; - let window = core.windows.get(&view.active)?; + let win_id = core.primary_document_window(frontend_id)?; + let window = core.windows.get(&win_id)?; if window.buffer_id != buffer_id { return None; } @@ -1472,6 +1562,16 @@ impl EditorState { if coord.row >= size.rows || coord.col >= size.cols { return false; } + // Bottom-panel §1.3 #11 — Projection + focus. A non-hover + // gesture on the DOCUMENT terminal means "work here", so it + // takes focus back out of a panel before the gesture replays; + // bare hover neither focuses nor claims the controller. + if !matches!(kind, TerminalMouseKind::Move) { + let mut core = self.core.borrow_mut(); + if let Some(win_id) = core.primary_document_window(frontend_id) { + core.focus_window(frontend_id, win_id); + } + } self.core.borrow_mut().active_frontend = frontend_id; self.apply_terminal_gesture(key, size, coord, kind, mods, (coord.row, coord.col)); true @@ -3152,6 +3252,170 @@ impl CompletionPopupKey { } } +/// Scroll one window so its cursor stays visible, reckoning in +/// **visible** lines when a fold map is supplied (Arc 6 Q#FD18). +/// +/// Extracted from `paint_frame` for bottom-panel Stage 2 (Q#BP8): the +/// panel band runs this for its own window when that window owns focus, +/// against the same supplied map, and leaves a passive panel's +/// `view_top` untouched. +/// +/// **The fold map is a parameter, never built here (Q#BP17).** A panel +/// painted for a frontend whose `fold_projection` is false must pass +/// `None`; `EditorCore::fold_map_for_window` is the wrong source there +/// because it gates on the **active** frontend, which is right for +/// command-time reckoning and wrong for painting another frontend's +/// panel. +fn prepare_window_cursor_visible( + window: &mut crate::window::Window, + buf: &crate::buffer::Buffer, + inner_rows: u32, + folds: Option<&crate::fold_view::VisibleLineMap>, +) { + let cursor_row = window + .text_view + .pos_to_display(buf, window.cursor) + .map_or(0, |d| d.row as usize); + match folds { + // The logical cursor may sit on a hidden line (a shared fold, or + // goto-line into one); the row that actually renders — and so + // the row to scroll to — is its visible head (Q#FD16/FD18, + // framing acceptance 8). + Some(map) => { + let anchor = map.visible_head_of(cursor_row); + let top = map.clamp_view_top(window.view_top); + window.view_top = if anchor < top { + anchor + } else if inner_rows > 0 && map.visible_rows_between(top, anchor) >= inner_rows as usize + { + map.nth_visible_back(anchor, inner_rows as usize - 1) + } else { + top + }; + } + None => { + if cursor_row < window.view_top { + window.view_top = cursor_row; + } else if inner_rows > 0 && cursor_row >= window.view_top + inner_rows as usize { + window.view_top = cursor_row + 1 - inner_rows as usize; + } + } + } +} + +/// Paint one window's document content: text, gutter, overlays, +/// selection, and its mode line. +/// +/// Extracted from `paint_frame`'s per-window loop for bottom-panel +/// Stage 2 (Q#BP8) — the panel band paints its window into a +/// panel-sized grid at the same origin-agnostic `Viewport`, so this is +/// that body lifted out rather than a second painter. No concrete +/// text/gutter/overlay/mode-line painter forks (Bet B2'). +/// +/// **`folds` is a parameter, never built here (Q#BP17).** Folding's +/// "a semantic session never enters `paint_frame`" premise is what the +/// panel band breaks; the panel path passes `None` when the owning +/// frontend's `fold_projection` is false, and must not call +/// `EditorCore::fold_map_for_window`, which gates on the **active** +/// frontend. +#[allow(clippy::too_many_arguments)] +fn paint_window_content( + grid: &mut crate::cell::CellGrid<'_>, + window: &mut crate::window::Window, + buf: &crate::buffer::Buffer, + placement: WindowPlacement, + folds: Option<&crate::fold_view::VisibleLineMap>, + focused: bool, + theme: &crate::highlight::Theme, + statusline: Option<&crate::statusline::StatuslineWindowSegments>, + diag_store: &std::sync::Arc>, +) { + let rect = placement.outer; + let inner_rows = placement.content.size.rows; + if let Some(map) = folds { + window.view_top = map.clamp_view_top(window.view_top); + } + let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0); + // UX gutter (Q#UX2): reserve a left strip for line numbers and + // shrink+shift the text area into the remainder, so every + // viewport-relative painter (text, syntax, diagnostics, search) + // stays gutter-agnostic. A window too narrow for the gutter falls + // back to no gutter this frame rather than starving the text. + let gutter_w = { + let w = window.gutter_width(); + if w >= rect.size.cols { 0 } else { w } + }; + let viewport = Viewport { + buffer_start: viewport_buffer_start, + buffer_end: buf.len(), + cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w), + cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w), + gutter_w, + folds, + }; + // Composition (T M2.9): base text_view paints first, then the + // gutter numbers — before the overlays, so a diagnostic overlay + // can draw its severity sign into the gutter's leading column + // without the gutter's own blank pass erasing it — then each + // overlay in attach order. See [`crate::view::View`]. + window.text_view.render(buf, viewport, grid); + if gutter_w > 0 { + paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, folds, theme); + } + for overlay in &mut window.overlays { + overlay.render(buf, viewport, grid); + } + paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w, folds, theme); + // Mode line for this window. Painted last so the line + // itself is always visible regardless of overlay activity. + let coord = window + .text_view + .pos_to_display(buf, window.cursor) + .unwrap_or_default(); + // Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in + // VISIBLE-line space — a buffer whose remainder is collapsed + // reads "All", not "Top". The cursor's ordinal anchors on its + // visible head, since that is the row it renders on. + let (ind_top, ind_total, ind_cursor) = match folds { + Some(map) => ( + map.visible_rows_between(0, window.view_top), + map.visible_line_count(window.text_view.line_count()), + map.visible_rows_between(0, map.visible_head_of(coord.row as usize)), + ), + None => ( + window.view_top, + window.text_view.line_count(), + coord.row as usize, + ), + }; + let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor); + // Lock scoped to the summary computation only: the overlay + // renders above include `DiagnosticView`, which takes this + // same mutex — holding the guard across the loop deadlocked + // the daemon on the first frame after a file (and thus a + // diagnostic overlay) was opened. + let diags = { + let guard = diag_store.lock().expect("diag store mutex poisoned"); + diag_mode_line_summary(&guard, buf) + }; + let custom = statusline; + paint_mode_line( + grid, + &rect, + buf.name(), + buf.is_modified(), + focused, + coord.row, + coord.col, + &scroll, + &diags, + mode_line_style(theme), + custom.map_or(&[], |segments| segments.left.as_slice()), + custom.map_or(&[], |segments| segments.right.as_slice()), + theme, + ); +} + /// Paint one full frame into `grid` and return the desired terminal /// cursor position. /// @@ -3261,6 +3525,11 @@ pub fn paint_frame( // Arc 6 Stage 2 (Q#FD18): the auto-scroll clamp reckons in // VISIBLE lines. Built from the active window itself, before // the mutable borrow below. + // + // Bottom-panel Q#BP17: built HERE and passed in, because the + // panel path (Stage 2B) must supply `None` for a frontend + // whose `fold_projection` is false. Building it inside the + // clamp would hard-wire the grid's answer. let folds = core .windows .get(&active) @@ -3268,36 +3537,7 @@ pub fn paint_frame( let aw = core.windows.get_mut(&active).expect( "invariant: active_window_id always references a live window in core.windows", ); - let cursor_row = aw - .text_view - .pos_to_display(buf, aw.cursor) - .map_or(0, |d| d.row as usize); - match folds.as_ref() { - // The logical cursor may sit on a hidden line (a shared - // fold, or goto-line into one); the row that actually - // renders — and so the row to scroll to — is its visible - // head (Q#FD16/FD18, framing acceptance 8). - Some(map) => { - let anchor = map.visible_head_of(cursor_row); - let top = map.clamp_view_top(aw.view_top); - aw.view_top = if anchor < top { - anchor - } else if inner_rows > 0 - && map.visible_rows_between(top, anchor) >= inner_rows as usize - { - map.nth_visible_back(anchor, inner_rows as usize - 1) - } else { - top - }; - } - None => { - if cursor_row < aw.view_top { - aw.view_top = cursor_row; - } else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize { - aw.view_top = cursor_row + 1 - inner_rows as usize; - } - } - } + prepare_window_cursor_visible(aw, buf, inner_rows, folds.as_ref()); } } @@ -3349,114 +3589,17 @@ pub fn paint_frame( let Ok(buf) = reg.get(window.buffer_id) else { continue; }; - // Arc 6 Stage 2 (Q#FD12, round-2 F2): ONE visible-line map per - // rendered document window, keyed on that window's own buffer and - // line offsets. A split may show different buffers with only one - // folded, so a per-frame singleton would leak one pane's folds - // into the other. `None` when this buffer has no folds — the - // unfolded path then paints exactly as before. let folds = crate::fold_view::map_for_window(&state.fold_registry, window); - // `view_top` stays a source-line index (Bet B5) but must never - // rest on a hidden line: clamp BACKWARD so a fold at the top of - // the viewport shows its head (Q#FD18, acceptance 8). - if let Some(map) = folds.as_ref() { - window.view_top = map.clamp_view_top(window.view_top); - } - let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0); - // UX gutter (Q#UX2): reserve a left strip for line numbers and - // shrink+shift the text area into the remainder, so every - // viewport-relative painter (text, syntax, diagnostics, search) - // stays gutter-agnostic. A window too narrow for the gutter falls - // back to no gutter this frame rather than starving the text. - let gutter_w = { - let w = window.gutter_width(); - if w >= rect.size.cols { 0 } else { w } - }; - let viewport = Viewport { - buffer_start: viewport_buffer_start, - buffer_end: buf.len(), - cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w), - cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w), - gutter_w, - folds: folds.as_ref(), - }; - // Composition (T M2.9): base text_view paints first, then the - // gutter numbers — before the overlays, so a diagnostic overlay - // can draw its severity sign into the gutter's leading column - // without the gutter's own blank pass erasing it — then each - // overlay in attach order. See [`crate::view::View`]. - window.text_view.render(buf, viewport, grid); - if gutter_w > 0 { - paint_line_number_gutter( - grid, - window, - &rect, - inner_rows, - gutter_w, - folds.as_ref(), - &theme, - ); - } - for overlay in &mut window.overlays { - overlay.render(buf, viewport, grid); - } - paint_local_selection( + paint_window_content( grid, - buf, window, - &rect, - inner_rows, - gutter_w, + buf, + placement, folds.as_ref(), - &theme, - ); - // Mode line for this window. Painted last so the line - // itself is always visible regardless of overlay activity. - let coord = window - .text_view - .pos_to_display(buf, window.cursor) - .unwrap_or_default(); - // Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in - // VISIBLE-line space — a buffer whose remainder is collapsed - // reads "All", not "Top". The cursor's ordinal anchors on its - // visible head, since that is the row it renders on. - let (ind_top, ind_total, ind_cursor) = match folds.as_ref() { - Some(map) => ( - map.visible_rows_between(0, window.view_top), - map.visible_line_count(window.text_view.line_count()), - map.visible_rows_between(0, map.visible_head_of(coord.row as usize)), - ), - None => ( - window.view_top, - window.text_view.line_count(), - coord.row as usize, - ), - }; - let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor); - // Lock scoped to the summary computation only: the overlay - // renders above include `DiagnosticView`, which takes this - // same mutex — holding the guard across the loop deadlocked - // the daemon on the first frame after a file (and thus a - // diagnostic overlay) was opened. - let diags = { - let guard = diag_store.lock().expect("diag store mutex poisoned"); - diag_mode_line_summary(&guard, buf) - }; - let custom = statusline_by_window.get(id); - paint_mode_line( - grid, - &rect, - buf.name(), - buf.is_modified(), *id == active, - coord.row, - coord.col, - &scroll, - &diags, - mode_line_style(&theme), - custom.map_or(&[], |segments| segments.left.as_slice()), - custom.map_or(&[], |segments| segments.right.as_slice()), &theme, + statusline_by_window.get(id), + &diag_store, ); } drop(reg); @@ -4421,10 +4564,6 @@ fn sanitize_single_line(s: &str) -> String { .collect() } -fn is_terminal_escape_chord(chord: Chord) -> bool { - chord.code == KeyCode::Char('c') && chord.modifiers == KeyModifiers::CONTROL -} - fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> { let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers); let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 3482879..a43a195 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -668,6 +668,33 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option, fallback: } } +/// Read a `String` setting plus the registry epoch that keys any cache +/// built from it (Q#TC4c). +/// +/// The epoch is returned WITH the value deliberately: a caller caching a +/// parsed form needs both, and reading them in two calls would let a +/// `set` land between them and produce a cache stamped with the wrong +/// epoch. `fallback` covers a bare core whose runtime never defined the +/// setting, matching [`config_u32`]. +#[must_use] +pub fn config_string_and_epoch( + lua: &Lua, + name: &str, + buffer_id: Option, + fallback: &str, +) -> (String, u64) { + let Some(registry) = lua.app_data_ref::() else { + return (fallback.to_owned(), 0); + }; + let borrowed = registry.borrow(); + let epoch = borrowed.value_epoch(); + let value = match borrowed.get(name, buffer_id) { + Ok(crate::config_registry::ConfigValue::Str(v)) => v.clone(), + _ => fallback.to_owned(), + }; + (value, epoch) +} + /// Short-circuit a binding when the init phase has completed. /// /// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+) @@ -6594,6 +6621,54 @@ pub fn install_async( ) -> mlua::Result<()> { lua.set_app_data(runtime.clone()); let pmacs: Table = lua.globals().get("pmacs")?; + + // Arc 8 Stage 3a (framing Q#LN20): the one *synchronous* filesystem + // primitive Lua has. `pmacs.fs` is otherwise an async, handle- + // returning surface built in `builtin/runtime/fs.lua`, so this + // arrives through a private table that file re-exports rather than + // joining the `_dispatch_fs_*` family it would not belong to. + // + // Installed here, alongside those dispatchers, purely for load + // order: `make_async_runtime` runs before `fs.lua` is evaluated, + // whereas `install_project` — the other plausible home — runs after + // it, so a canonicalizer placed there is nil when `fs.lua` reads it. + // + // Synchronous on purpose, and that is the whole point. The consumer + // is a function-valued `pmacs.lsp.config[lang].root`, which + // `project_root_for` calls from `ensure_server` <- `attach_buffer` + // <- the `buffer.after-load` hook — no coroutine, nothing to await + // on. An awaitable canonicalizer would be unusable there for exactly + // the reason `pmacs.fs.stat` already is, leaving #161's + // canonical-root obligation undischarged. The cost is one syscall on + // a path the editor is already opening; `pmacs.project.detect` + // canonicalizes synchronously on the same hook today. + { + let fs_priv = lua.create_table()?; + fs_priv.set( + "canonicalize", + lua.create_function(|_, path: String| { + // nil rather than an error for a path that cannot be + // resolved: asking about a deleted file or a broken + // symlink is ordinary, and raising would surface through + // `resolve_root_fn`'s pcall as a config bug, which it is + // not. + // + // `to_str`, NOT `display()`. A resolution that lands on + // non-UTF-8 bytes has no faithful string form, and + // `display()` would substitute U+FFFD and hand back a + // path that does not exist on disk — strictly worse than + // nil here, because this value becomes a server-affinity + // key via `file_uri_for` and would silently fail to + // round-trip. Unrepresentable is a decline, matching how + // the fs layer already treats non-UTF-8 symlink targets. + Ok(std::fs::canonicalize(&path) + .ok() + .and_then(|p| p.to_str().map(str::to_owned))) + })?, + )?; + pmacs.set("_fs", fs_priv)?; + } + let async_mod = lua.create_table()?; { diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 65750d3..db96fc4 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -625,6 +625,22 @@ impl SemanticRenderState { // post-evaluation face inventory must then precede the authoritative // segment replacement in this same frame. Unsupported peers skip the // evaluator entirely and therefore pay no Lua callback/dynamic-face cost. + // Bottom-panel A2A-2, round 3: the document identity used to + // FILTER the results must be the PRE-CALLBACK one. Both outcome + // arms carry phase-1 contexts, and a provider that closes the + // primary document split changes `primary_document_window` + // mid-evaluation — reading it after the fact would compare + // phase-1 contexts against a replacement identity, match + // nothing, and silently suppress the authoritative clear. + let statusline_document_window = self + .peer_knows_statusline_segments + .then(|| { + state + .core + .borrow() + .primary_document_window(self.frontend_id) + }) + .flatten(); let statusline_evaluation = self.peer_knows_statusline_segments.then(|| { evaluate_statusline( state.lua_host.lua(), @@ -810,7 +826,7 @@ impl SemanticRenderState { out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. if let Some(evaluation) = statusline_evaluation { - self.emit_statusline_segments(evaluation, &mut out); + self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } out } @@ -855,6 +871,16 @@ impl SemanticRenderState { // Evaluate callbacks before `ThemeFacts` for the same reason the // document path does: a callback may register a face, and the // face inventory must precede the segment text that names it. + // Same pre-callback capture as the document path (round 3). + let statusline_document_window = self + .peer_knows_statusline_segments + .then(|| { + state + .core + .borrow() + .primary_document_window(self.frontend_id) + }) + .flatten(); let statusline_evaluation = self.peer_knows_statusline_segments.then(|| { evaluate_statusline( state.lua_host.lua(), @@ -881,7 +907,12 @@ impl SemanticRenderState { // a verdict we hold. if self.last_terminal_frame.as_ref() == Some(&frame) { self.terminal_error_latched = false; - out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation)); + out.extend(self.terminal_chrome( + state, + buffer_id, + statusline_evaluation, + statusline_document_window, + )); return Some(out); } match frame.validate() { @@ -905,7 +936,12 @@ impl SemanticRenderState { } } - out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation)); + out.extend(self.terminal_chrome( + state, + buffer_id, + statusline_evaluation, + statusline_document_window, + )); Some(out) } @@ -920,6 +956,7 @@ impl SemanticRenderState { state: &EditorState, buffer_id: BufferId, statusline_evaluation: Option, + statusline_document_window: Option, ) -> Vec { let mut out = Vec::new(); out.extend(self.status_facts_msg(state, buffer_id)); @@ -929,7 +966,7 @@ impl SemanticRenderState { out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. if let Some(evaluation) = statusline_evaluation { - self.emit_statusline_segments(evaluation, &mut out); + self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } out } @@ -941,6 +978,7 @@ impl SemanticRenderState { fn emit_statusline_segments( &mut self, evaluation: StatuslineEvaluation, + document_window: Option, out: &mut Vec, ) { let to_wire = |segments: Vec| { @@ -955,10 +993,16 @@ impl SemanticRenderState { let frontend_id = self.frontend_id; match evaluation.outcome { StatuslineEvaluationOutcome::Ready(windows) => { - if let Some(window) = windows - .into_iter() - .find(|window| window.context.frontend_id == frontend_id) - { + // Bottom-panel A2A-2: the fan-out now yields the primary + // document AND the visible side window, so the wire + // segments must be selected by WINDOW IDENTITY. Taking + // "the first context for my frontend" would silently + // depend on capture order and could ship the panel's + // mode-line text as the document status band. + if let Some(window) = windows.into_iter().find(|window| { + window.context.frontend_id == frontend_id + && Some(window.context.window_id) == document_window + }) { self.emit_statusline_payload( window.context.buffer_id, to_wire(window.left), @@ -970,10 +1014,18 @@ impl SemanticRenderState { StatuslineEvaluationOutcome::Invalidated { authoritative_empty, } => { - for context in authoritative_empty - .into_iter() - .filter(|context| context.frontend_id == frontend_id) - { + // Bottom-panel A2A-2: the clear must be filtered by + // DOCUMENT WINDOW exactly like the Ready arm. The + // semantic peer has ONE statusline slot, so publishing + // the panel context's clear here replaces the document's + // payload with the panel's — the same misrouting the + // Ready arm was fixed for, on the clear path. + // + // A panel's own clear belongs to the future panel + // painter (`PanelFrame`, Stage 2B), not to this wire. + for context in authoritative_empty.into_iter().filter(|context| { + context.frontend_id == frontend_id && Some(context.window_id) == document_window + }) { self.emit_statusline_payload(context.buffer_id, Vec::new(), Vec::new(), out); } } @@ -1345,9 +1397,13 @@ impl SemanticRenderState { state: &EditorState, buffer_id: BufferId, ) -> Option { + // Bottom-panel §1.3 #4 — Projection. `LineNumbers` describes the + // replica's DOCUMENT surface; a focused panel must not replace + // the document's gutter mode with the panel window's. let mode = { let core = state.core.borrow(); - core.active_window_for(self.frontend_id) + core.primary_document_window(self.frontend_id) + .and_then(|win_id| core.windows.get(&win_id)) .map_or(crate::window::LineNumberMode::Off, |w| w.line_numbers) }; if self.last_line_numbers == Some(mode) { @@ -1703,7 +1759,12 @@ impl SemanticRenderState { // Emitting CurrentLine here forced a whole-buffer line table on // every frame even though pmacs-gpu ignores its own current-line // wash. - if let Some(win) = core.active_window_for(self.frontend_id) + // Bottom-panel §1.3 #5 — Projection. Selection decorations + // belong to the document surface the viewport describes; a + // selection made inside a focused panel must not paint into it. + if let Some(win) = core + .primary_document_window(self.frontend_id) + .and_then(|win_id| core.windows.get(&win_id)) && win.buffer_id == vp.buffer_id && let Some((lo, hi)) = win.region() && let Some(range) = clip_to_viewport(lo, hi, vp) @@ -2916,6 +2977,7 @@ mod tests { ), new_failures: Vec::new(), }, + None, &mut stale, ); assert!(stale.is_empty(), "phase-1 stale evaluation emits nothing"); @@ -2924,11 +2986,15 @@ mod tests { "stale evaluation retains the prior baseline until snapshot reset" ); + // Bottom-panel A2A-2: the clear is filtered by DOCUMENT window + // identity, so the context under test must BE the document + // window — passing `None` here would assert nothing. + let document_window = crate::window::WindowId::next(); let invalidated = || StatuslineEvaluation { outcome: StatuslineEvaluationOutcome::Invalidated { authoritative_empty: vec![crate::statusline::StatuslineContext { frontend_id: FrontendId::LOCAL, - window_id: crate::window::WindowId::next(), + window_id: document_window, buffer_id, active: true, }], @@ -2936,13 +3002,13 @@ mod tests { new_failures: Vec::new(), }; let mut replacement = Vec::new(); - semantic.emit_statusline_segments(invalidated(), &mut replacement); + semantic.emit_statusline_segments(invalidated(), Some(document_window), &mut replacement); assert_eq!( statusline_of(&replacement), Some((buffer_id, Vec::new(), Vec::new())) ); let mut unchanged = Vec::new(); - semantic.emit_statusline_segments(invalidated(), &mut unchanged); + semantic.emit_statusline_segments(invalidated(), Some(document_window), &mut unchanged); assert!( unchanged.is_empty(), "the empty invalidation became baseline" diff --git a/src/statusline.rs b/src/statusline.rs index d11c885..3eb4ef6 100644 --- a/src/statusline.rs +++ b/src/statusline.rs @@ -215,10 +215,25 @@ pub enum StatuslineEvaluationTarget { /// Frontend whose entire visible layout is evaluated. frontend_id: FrontendId, }, - /// Only the frontend's active window, iff it still displays the declared - /// semantic viewport buffer. + /// The frontend's **primary document window**, iff it still displays + /// the declared semantic viewport buffer, **plus its visible side + /// window** when one exists (bottom-panel Q#BP8 / A2A-2). + /// + /// Two contexts, not one: the document result feeds the semantic + /// `StatuslineSegments` wire, while the side result paints in the + /// panel's own mode line. Unprojected document splits run no + /// callbacks, and a derived-hidden side (Q#BP2b) is omitted because + /// it has no mode line to paint this frame. + /// + /// The document context is captured **first**; consumers must still + /// select by window identity rather than position, since only one of + /// the two may reach the single semantic statusline slot. + /// + /// `active` on each context reports **actual focus**, so a document + /// provider truthfully observes `active = false` while a panel owns + /// focus (Q#BP14, parent acceptance 42). Semantic { - /// Frontend whose focused daemon window is evaluated. + /// Frontend whose document (and visible side) window is evaluated. frontend_id: FrontendId, /// Buffer declared by the semantic viewport. declared_buffer: BufferId, @@ -639,9 +654,20 @@ fn capture_target_contexts( .views .get(&frontend_id) .ok_or(StatuslineNoMessageReason::ContextUnavailable)?; + // Bottom-panel §1.3 #12 — Projection. This LOOKUP resolves + // the primary document window: with a panel focused, + // `view.active` would name the panel and the declared-buffer + // check would clear the document's statusline. + // + // `active` is NOT rerouted with it (Q#BP14/parent 42): it + // reports ACTUAL focus, so a document provider truthfully + // observes `active = false` while the panel owns focus. + let window_id = core + .primary_document_window(frontend_id) + .ok_or(StatuslineNoMessageReason::ContextUnavailable)?; let window = core .windows - .get(&view.active) + .get(&window_id) .ok_or(StatuslineNoMessageReason::ContextUnavailable)?; if buffers.get(window.buffer_id).is_err() { return Err(StatuslineNoMessageReason::BufferUnavailable); @@ -649,12 +675,46 @@ fn capture_target_contexts( if window.buffer_id != declared_buffer { return Err(StatuslineNoMessageReason::DeclaredBufferMismatch); } - Ok(vec![StatuslineContext { + let mut contexts = vec![StatuslineContext { frontend_id, window_id: window.id, buffer_id: window.buffer_id, - active: true, - }]) + active: window.id == view.active, + }]; + // Bottom-panel Q#BP8 / A2A-2 — the semantic fan-out is the + // primary document PLUS the frontend's visible side window, + // and nothing else: unprojected document splits run no + // callbacks. The document result feeds the semantic + // `StatuslineSegments`; the side result paints in the panel's + // own mode line. + // + // A derived-hidden side window is omitted (Q#BP2b): it has no + // mode line to paint this frame, so evaluating providers for + // it would invoke callbacks for a surface nobody can see. + if !view.panel_hidden { + for side_id in view.layout.iter_ids() { + if side_id == window.id { + continue; + } + let Some(side) = core.windows.get(&side_id) else { + return Err(StatuslineNoMessageReason::ContextUnavailable); + }; + if !side.is_side() { + continue; + } + if buffers.get(side.buffer_id).is_err() { + return Err(StatuslineNoMessageReason::BufferUnavailable); + } + contexts.push(StatuslineContext { + frontend_id, + window_id: side.id, + buffer_id: side.buffer_id, + // Same rule as the document context: ACTUAL focus. + active: side.id == view.active, + }); + } + } + Ok(contexts) } } } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 27ee96c..11272e4 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -34,6 +34,10 @@ pub use pmacs_protocol::terminal::{ /// Configuration-time, not a wire bound: history never crosses the /// protocol, so this stays core-owned. pub const DEFAULT_TERMINAL_SCROLLBACK_ROWS: usize = 10_000; + +/// Default `terminal.escape-key`, and the fallback an unparseable value +/// falls back to (Q#TC4a). +pub const DEFAULT_TERMINAL_ESCAPE_KEY: &str = "C-c"; /// Maximum retained main-screen history cells. Core-owned for the same /// reason as [`DEFAULT_TERMINAL_SCROLLBACK_ROWS`]. pub const MAX_TERMINAL_HISTORY_CELLS: usize = 4_000_000; diff --git a/src/terminal/session.rs b/src/terminal/session.rs index ee96923..c731fb0 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -12,6 +12,7 @@ use crate::ansi::AnsiParserProfile; use crate::buffer::{Buffer, BufferId}; use crate::cell::{Cell, CellCoord, CellSize}; use crate::editor_core::EditorCore; +use crate::key::{Chord, parse_chord}; use crate::process::{ ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor, RestartPolicy, StdinMode, TerminalMode, @@ -218,12 +219,40 @@ pub(super) struct TerminalSession { pub(super) screen: TerminalScreen, pub(super) process: TerminalProcessState, pub(super) annotated: bool, + /// Resolved `terminal.escape-key` for this terminal (Q#TC4c). + /// + /// The cache lives HERE, not in an editor-side map, because a + /// session is created in [`TerminalManager::open`] and dropped on + /// kill/prune — so its lifetime is exactly the cache's, with no + /// purge hook to forget. An editor-side map would leak an entry per + /// terminal; a single last-entry cache would reparse (and re-report + /// an invalid value) every time focus alternates between two + /// terminals. + pub(super) escape: Option, +} + +/// One terminal's parsed escape chord, valid for one config epoch. +pub(super) struct EscapeCache { + /// The `ConfigRegistry::value_epoch` this was parsed at. The key is + /// `(this session, epoch)`: the epoch alone is not enough, because + /// it does not advance when focus moves between terminals with + /// different buffer-local values. + pub(super) epoch: u64, + /// The effective chord — the parsed spelling, or the `C-c` fallback. + pub(super) chord: Chord, + /// The invalid spelling already reported for this terminal, if any. + /// Reporting is once per terminal per effective invalid value: an + /// unchanged bad value stays quiet, a *different* bad value reports + /// again because it is a new mistake. + pub(super) reported_invalid: Option, } /// Owns the one-buffer/one-process/one-screen terminal registry. #[derive(Default)] pub struct TerminalManager { pub(super) sessions: HashMap, + /// Total escape-key parses performed (Q#TC4c observability). + escape_parses: u64, process_to_buffer: HashMap, /// Removed buffers whose children are still being reaped. Their events /// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch. @@ -331,6 +360,7 @@ impl TerminalManager { screen, process: TerminalProcessState::Running, annotated: false, + escape: None, }, ); debug_assert!(previous.is_none(), "fresh BufferId collided"); @@ -538,6 +568,89 @@ impl TerminalManager { .map_err(TerminalError::Process) } + /// Resolve this terminal's effective escape chord, parsing at most + /// once per `(terminal, config epoch)` (Q#TC4c). + /// + /// `spelling` is the caller-resolved `terminal.escape-key` value and + /// `epoch` the registry's `value_epoch()` it was read at. Returns the + /// effective chord plus, at most once per terminal per effective + /// invalid value, a message the caller should surface. + /// + /// An unparseable spelling falls back to `C-c` rather than leaving the + /// terminal with no escape at all (Q#TC4a): without one, every key goes + /// to the child and the user cannot reach the binding that would fix + /// the setting that broke it. + pub fn escape_chord( + &mut self, + buffer_id: BufferId, + epoch: u64, + spelling: &str, + ) -> (Chord, Option) { + let fallback = default_escape_chord(); + if let Some(session) = self.sessions.get(&buffer_id) + && let Some(cache) = session.escape.as_ref() + && cache.epoch == epoch + { + return (cache.chord, None); + } + self.escape_parses = self.escape_parses.saturating_add(1); + let Some(session) = self.sessions.get_mut(&buffer_id) else { + return (fallback, None); + }; + let previously_reported = session + .escape + .as_ref() + .and_then(|cache| cache.reported_invalid.clone()); + let (chord, reported_invalid, report) = match parse_chord(spelling) { + Ok(chord) => (chord, None, None), + Err(error) => { + let already = previously_reported.as_deref() == Some(spelling); + let message = (!already).then(|| { + format!( + "terminal.escape-key {spelling:?} is not a valid chord ({error}); using C-c" + ) + }); + (fallback, Some(spelling.to_owned()), message) + } + }; + session.escape = Some(EscapeCache { + epoch, + chord, + reported_invalid, + }); + (chord, report) + } + + /// How many escape-key spellings this manager has parsed. + /// + /// An observability seam for Q#TC4c's cache contract, which is + /// otherwise unpinnable for a VALID setting: a correct per-session + /// cache and a single last-entry cache produce identical behavior + /// there and differ only in how often they parse. Counting reports + /// covers the invalid case; this covers the valid one. + #[must_use] + pub fn escape_parses(&self) -> u64 { + self.escape_parses + } + + /// How many terminals currently hold a cached escape chord. + /// + /// The LIFETIME half of Q#TC4c's cache contract, which `escape_parses` + /// cannot cover: parse counting says a valid setting is read once, but + /// says nothing about whether the cache is ever released. Because the + /// cache lives on [`TerminalSession`], this count falls with the + /// session set by construction — which is exactly the property worth + /// pinning, since the rejected alternative (an editor-side + /// `HashMap`) has no purge hook and would hold + /// this at its high-water mark while sessions drained. + #[must_use] + pub fn escape_caches(&self) -> usize { + self.sessions + .values() + .filter(|session| session.escape.is_some()) + .count() + } + /// Resize a terminal screen and its PTY after validating shared limits. pub fn resize( &mut self, @@ -730,3 +843,12 @@ fn sanitize_metadata(value: &str) -> String { } clean } + +/// The built-in terminal escape chord, and the fallback for an +/// unparseable `terminal.escape-key` (Q#TC4a). +pub(super) fn default_escape_chord() -> Chord { + Chord::new( + crossterm::event::KeyCode::Char('c'), + crossterm::event::KeyModifiers::CONTROL, + ) +} diff --git a/src/window.rs b/src/window.rs index b66499e..2ac0087 100644 --- a/src/window.rs +++ b/src/window.rs @@ -558,8 +558,19 @@ pub struct FrontendView { /// store. Reckoning in visible lines unconditionally would make that /// GPU session's cursor skip lines it is still showing, so every /// command/event-time visible-line reckoning is gated on the - /// **acting** frontend's flag. Render-time clamps need no gate: a - /// semantic session never enters `paint_frame`. + /// **acting** frontend's flag. + /// + /// **Render-time clamps used to need no gate, on the premise that a + /// semantic session never enters `paint_frame`. The bottom-panel + /// band breaks that premise** (Q#BP17): the daemon projects a + /// semantic frontend's side window through the same per-window + /// painter. So the extracted painters + /// (`prepare_window_cursor_visible`, `paint_window_content`) take the + /// visible-line map as a **parameter**, and the panel path passes + /// `None` when the *owning* frontend's `fold_projection` is false. + /// That path must not call `EditorCore::fold_map_for_window`, which + /// gates on the **active** frontend — correct for command-time + /// reckoning, wrong for painting another frontend's panel. /// /// Set at attach from the negotiated selected-render bit (grid ⇒ /// `true`, semantic ⇒ `false`), cleared with the view at detach, and diff --git a/tests/bottom_panel_stage2a_acceptance.rs b/tests/bottom_panel_stage2a_acceptance.rs new file mode 100644 index 0000000..d39d804 --- /dev/null +++ b/tests/bottom_panel_stage2a_acceptance.rs @@ -0,0 +1,898 @@ +// bottom_panel_stage2a_acceptance.rs --- bottom-panel Stage 2A +// (docs/bottom-panel-stage2-framing.md, criteria A2A-1 / A2A-2 / A2A-3). + +//! Classified §1.3 census routing + the per-window painter extraction. +//! No wire change. +//! +//! **The negative half is the load-bearing half.** A suite that only +//! proved "the document surface is used" would pass with the focus, +//! focus-chrome, and focus/session consumers *wrongly* rerouted to the +//! document — which is the defect the framing spent three review rounds +//! eliminating, and which would break remote-op validation, +//! `DispatchIdle`, presence, focused search/menu/completion routing, and +//! terminal bell ownership. So every Projection assertion here is paired +//! with a focus-class assertion taken in the *same* state. + +use pmacs::cell::{CellGrid, CellSize}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::window::{Side, WindowId}; + +const ROWS: u32 = 24; +const COLS: u32 = 60; + +fn editor() -> EditorState { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + s +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn side_window_of(core: &pmacs::editor_core::EditorCore, fid: FrontendId) -> Option { + core.views[&fid].layout.iter_ids().into_iter().find(|id| { + core.windows + .get(id) + .is_some_and(|w| w.params.side.is_some()) + }) +} + +fn side_window(s: &EditorState) -> Option { + let core = s.core.borrow(); + core.views[&FrontendId::LOCAL] + .layout + .iter_ids() + .into_iter() + .find(|id| { + core.windows + .get(id) + .is_some_and(|w| w.params.side.is_some()) + }) +} + +/// Open a bottom panel and leave it FOCUSED — the state in which every +/// classification difference becomes observable. +fn focused_panel(s: &EditorState) -> (WindowId, WindowId) { + let document = s.core.borrow().views[&FrontendId::LOCAL].active; + exec( + s, + "PANEL_BUF = pmacs.buffer.create(\"*panel*\") + PANEL_WIN = pmacs.window.display(PANEL_BUF, \ + { side = \"bottom\", height = 4 })", + ); + let panel = side_window(s).expect("panel exists"); + s.core.borrow_mut().focus_window(FrontendId::LOCAL, panel); + assert_eq!( + s.core.borrow().views[&FrontendId::LOCAL].active, + panel, + "fixture precondition: the panel must own focus" + ); + (document, panel) +} + +fn render(s: &EditorState) { + let size = CellSize::new(ROWS, COLS); + let mut cells = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + let _ = pmacs::editor::paint_frame( + s, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid, + size, + ); +} + +// --------------------------------------------------------------------------- +// A2A-1 — the Projection class resolves the document surface +// --------------------------------------------------------------------------- + +#[test] +fn projection_resolves_the_document_window_while_a_panel_is_focused() { + let s = editor(); + let (document, panel) = focused_panel(&s); + let core = s.core.borrow(); + + assert_eq!( + core.primary_document_window(FrontendId::LOCAL), + Some(document), + "Projection consumers must resolve the document window, not the focused panel" + ); + assert_ne!(document, panel); +} + +#[test] +fn projection_buffer_is_the_document_buffer_not_the_panel_buffer() { + let s = editor(); + let (document, _panel) = focused_panel(&s); + let core = s.core.borrow(); + + let document_buffer = core.windows[&document].buffer_id; + assert_eq!( + core.primary_document_buffer(FrontendId::LOCAL), + Some(document_buffer), + "the replica's document mirror must not follow panel focus" + ); + assert_ne!( + core.primary_document_buffer(FrontendId::LOCAL), + Some(core.windows[&core.views[&FrontendId::LOCAL].active].buffer_id), + "non-vacuity: the focused window's buffer differs, so this test can fail" + ); +} + +// --------------------------------------------------------------------------- +// A2A-1 — the NEGATIVE half: focus classes still resolve focus +// --------------------------------------------------------------------------- + +#[test] +fn focus_class_dispatch_idle_still_tracks_the_focused_window() { + let s = editor(); + let (_document, _panel) = focused_panel(&s); + + // §1.3 #14 — Focus. Q#BP14a: optimistic input is gated per WINDOW. + // A panel that owns focus must suppress `DispatchIdle` even though + // the *document* projection is unaffected. + assert!( + !s.dispatch_idle_for(FrontendId::LOCAL), + "a focused side window must gate optimistic input off (#14)" + ); +} + +#[test] +fn focus_class_gate_lifts_when_focus_returns_to_the_document() { + let s = editor(); + let (document, _panel) = focused_panel(&s); + s.core + .borrow_mut() + .focus_window(FrontendId::LOCAL, document); + + assert!( + s.dispatch_idle_for(FrontendId::LOCAL), + "non-vacuity: the gate must lift with focus, or the test above proves nothing" + ); +} + +#[test] +fn focus_and_projection_disagree_in_the_same_state() { + // The single most important assertion in this suite: in ONE state, + // the two classes must resolve DIFFERENT windows. If a future change + // routes the focus class through `primary_document_window`, this + // fails even though every Projection test above still passes. + let s = editor(); + let (document, panel) = focused_panel(&s); + let core = s.core.borrow(); + + let focused = core.views[&FrontendId::LOCAL].active; + let projected = core + .primary_document_window(FrontendId::LOCAL) + .expect("a document window exists"); + + assert_eq!(focused, panel, "focus authority must name the panel"); + assert_eq!( + projected, document, + "projection authority must name the document" + ); + assert_ne!( + focused, projected, + "the two authorities must be genuinely distinct in this state" + ); +} + +// --------------------------------------------------------------------------- +// A2A-2 — the statusline split: lookup reroutes, `active` does not +// --------------------------------------------------------------------------- + +#[test] +fn statusline_document_context_reports_active_false_under_a_focused_panel() { + use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, + }; + + let s = editor(); + let (document, _panel) = focused_panel(&s); + let declared = s.core.borrow().windows[&document].buffer_id; + + let evaluation = evaluate_statusline( + s.lua_host.lua(), + &s.core, + &s.statusline_registry, + StatuslineEvaluationTarget::Semantic { + frontend_id: FrontendId::LOCAL, + declared_buffer: declared, + }, + ); + + match evaluation.outcome { + StatuslineEvaluationOutcome::Ready(windows) => { + // A2A-2: the semantic fan-out captures the primary document + // AND the visible side window — two contexts, not one. + assert_eq!( + windows.len(), + 2, + "the semantic-layout target must capture document + visible side window" + ); + let side = windows + .iter() + .find(|w| w.context.window_id != document) + .expect("a side-window context"); + assert!( + side.context.active, + "the focused panel's own context reports active = true" + ); + let context = windows + .first() + .map(|segments| segments.context) + .expect("one document context"); + // The LOOKUP rerouted: it resolved the document window even + // though the panel is focused (§1.3 #12). + assert_eq!( + context.window_id, document, + "the semantic target must resolve the primary document window" + ); + // `active` did NOT reroute (parent acceptance 42): a document + // provider observes the truth, that it is not focused. + assert!( + !context.active, + "a document provider must observe active = false while the panel owns focus" + ); + } + other => panic!("expected a ready evaluation, got {other:?}"), + } +} + +#[test] +fn statusline_document_context_is_active_when_the_document_is_focused() { + use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, + }; + + // Non-vacuity for the assertion above: with focus on the document, + // the same context must report `active = true`. + let s = editor(); + let (document, _panel) = focused_panel(&s); + s.core + .borrow_mut() + .focus_window(FrontendId::LOCAL, document); + let declared = s.core.borrow().windows[&document].buffer_id; + + let evaluation = evaluate_statusline( + s.lua_host.lua(), + &s.core, + &s.statusline_registry, + StatuslineEvaluationTarget::Semantic { + frontend_id: FrontendId::LOCAL, + declared_buffer: declared, + }, + ); + + match evaluation.outcome { + StatuslineEvaluationOutcome::Ready(windows) => { + let context = windows.first().map(|s| s.context).expect("one context"); + assert!(context.active, "a focused document context must be active"); + } + other => panic!("expected a ready evaluation, got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// A2A-3 — the painter extraction preserves grid behavior +// --------------------------------------------------------------------------- + +#[test] +fn extraction_preserves_cells_cursor_and_focused_view_top() { + // The extraction must preserve four things, not just cells: a clamp + // that silently moved to the WRONG window would leave the painted + // cells identical on a single-window frame. + let s = editor(); + exec( + &s, + "local b = pmacs.buffer.create(\"*doc*\") + b:insert(0, string.rep(\"line\\n\", 200)) + pmacs.window.display(b, {})", + ); + + // The gutter only paints when line numbers are on, so turn them on + // rather than dropping the assertion. + { + let mut core = s.core.borrow_mut(); + let active = core.views[&FrontendId::LOCAL].active; + core.windows.get_mut(&active).unwrap().line_numbers = + pmacs::window::LineNumberMode::Absolute; + } + + let size = CellSize::new(ROWS, COLS); + let mut cells_a = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize]; + let mut grid_a = CellGrid { + cells: &mut cells_a, + stride: size.cols, + size, + }; + let cursor_a = pmacs::editor::paint_frame( + &s, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid_a, + size, + ); + let active = s.core.borrow().views[&FrontendId::LOCAL].active; + let view_top_a = s.core.borrow().windows[&active].view_top; + + // A second identical paint is a fixed point: same cells, same + // returned cursor, same `view_top`. + let mut cells_b = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize]; + let mut grid_b = CellGrid { + cells: &mut cells_b, + stride: size.cols, + size, + }; + let cursor_b = pmacs::editor::paint_frame( + &s, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid_b, + size, + ); + let view_top_b = s.core.borrow().windows[&active].view_top; + + assert_eq!(cells_a, cells_b, "painted cells must be stable"); + assert_eq!(cursor_a, cursor_b, "the returned cursor must be stable"); + assert_eq!(view_top_a, view_top_b, "focused view_top must be stable"); + + // Review round 1, finding 5: a fixed-point check alone is VACUOUS — + // deleting `text_view.render` leaves it green. Assert the extracted + // painter actually produced each of its four outputs. + let rows = |cells: &[pmacs::cell::Cell]| -> Vec { + (0..ROWS as usize) + .map(|r| { + (0..COLS as usize) + .map(|c| match &cells[r * COLS as usize + c].glyph { + pmacs::cell::Glyph::Char(ch) => *ch, + _ => ' ', + }) + .collect::() + }) + .collect() + }; + let painted = rows(&cells_a); + + // TEXT: the buffer's content reached the grid. + assert!( + painted.iter().any(|row| row.contains("line")), + "the extracted painter must paint buffer TEXT; got {painted:?}" + ); + // GUTTER: line numbers were painted beside it. + assert!( + painted.iter().any(|row| row.trim_start().starts_with('1')), + "the extracted painter must paint the line-number GUTTER" + ); + // MODE LINE: the window's mode line names its buffer. + assert!( + painted.iter().any(|row| row.contains("*doc*")), + "the extracted painter must paint the window MODE LINE" + ); + // CURSOR: a real caret position came back, not None. + assert!( + cursor_a.is_some(), + "the extraction must still return a caret position" + ); +} + +#[test] +fn extraction_leaves_a_passive_window_view_top_untouched() { + // The auto-scroll clamp runs for the FOCUSED window only. A passive + // window's scroll state must survive a frame it did not own. + let s = editor(); + exec( + &s, + "local b = pmacs.buffer.create(\"*doc*\") + b:insert(0, string.rep(\"line\\n\", 200)) + pmacs.window.display(b, {}) + pmacs.window.split_horizontal()", + ); + render(&s); + + let (passive, before) = { + let core = s.core.borrow(); + let view = &core.views[&FrontendId::LOCAL]; + let passive = view + .layout + .iter_ids() + .into_iter() + .find(|id| *id != view.active) + .expect("a second window exists"); + (passive, core.windows[&passive].view_top) + }; + + // Scroll the passive window somewhere the clamp would "fix" if it + // ever ran against the wrong window. + s.core + .borrow_mut() + .windows + .get_mut(&passive) + .unwrap() + .view_top = 120; + render(&s); + + assert_eq!( + s.core.borrow().windows[&passive].view_top, + 120, + "a passive window's view_top must not be clamped by another window's frame" + ); + assert_ne!(before, 120, "non-vacuity: the value actually changed"); +} + +// --------------------------------------------------------------------------- +// Fixture integrity +// --------------------------------------------------------------------------- + +#[test] +fn the_panel_fixture_really_builds_a_side_window() { + // Every test above is worthless if `focused_panel` silently produced + // an ordinary split, so pin the fixture's own precondition. + let s = editor(); + let (_document, panel) = focused_panel(&s); + let core = s.core.borrow(); + assert_eq!( + core.windows[&panel].params.side, + Some(Side::Bottom), + "the fixture must produce a real bottom side window" + ); +} + +// --------------------------------------------------------------------------- +// A2A-1 at the CONSUMER seam — review round 1, finding 3. +// +// The tests above assert the *authority* (`primary_document_window`). +// That is not sufficient: restoring a producer to `active_window_for` +// leaves every one of them green. These drive the real producers through +// `SemanticRenderState::render_frame` with a panel focused, so a reverted +// routing fails here. +// --------------------------------------------------------------------------- + +/// A semantic frontend that CAN hold a panel. Stage 1 ships +/// `panel_capable = false` for semantic sessions and 2B flips it for a +/// v21-negotiated peer; until then the projection is only reachable with +/// a test-only capable view, which is exactly what the framing's §7.2 +/// says 2B must replace with the real capability flip. +fn semantic_frontend_with_focused_panel( + s: &EditorState, +) -> (FrontendId, WindowId, WindowId, pmacs::buffer::BufferId) { + use pmacs::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; + + let fid = FrontendId(77); + let (doc_win, panel_win, doc_buf) = { + let mut core = s.core.borrow_mut(); + let doc_buf = core.active_window().buffer_id; + let panel_buf = core.registry.borrow_mut().create("*panel*"); + + let doc_win = WindowId::next(); + let panel_win = WindowId::next(); + let doc_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(doc_buf).expect("document buffer")) + }; + let panel_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(panel_buf).expect("panel buffer")) + }; + core.windows + .insert(doc_win, Window::new(doc_win, doc_buf, doc_view)); + let mut panel = Window::new(panel_win, panel_buf, panel_view); + let mut params = WindowParams::default(); + params.side = Some(Side::Bottom); + params.fixed_rows = Some(4); + panel.params = params; + core.windows.insert(panel_win, panel); + + core.register_frontend_view( + fid, + FrontendView { + layout: Layout { + root: LayoutNode::Split { + orientation: Orientation::Horizontal, + children: vec![LayoutNode::Leaf(doc_win), LayoutNode::Leaf(panel_win)], + weights: vec![1, 1], + }, + }, + // The panel owns focus; the document is the projection. + active: panel_win, + fold_projection: false, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + (doc_win, panel_win, doc_buf) + }; + s.sync_frame_geometry(fid, CellSize::new(ROWS, COLS)); + (fid, doc_win, panel_win, doc_buf) +} + +#[test] +fn consumer_line_numbers_follow_the_document_not_the_focused_panel() { + use pmacs::protocol::{ByteRange, InstanceMessage}; + use pmacs::semantic_render::SemanticRenderState; + use pmacs::window::LineNumberMode; + + let s = editor(); + let (fid, doc_win, panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + + // Make the two windows DISAGREE, so the emitted mode identifies + // which window the producer read (§1.3 #4). + { + let mut core = s.core.borrow_mut(); + core.windows.get_mut(&doc_win).unwrap().line_numbers = LineNumberMode::Absolute; + core.windows.get_mut(&panel_win).unwrap().line_numbers = LineNumberMode::Off; + } + + let mut sem = SemanticRenderState::new(fid); + sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0); + let msgs = sem.render_frame(&s); + + let mode = msgs.iter().find_map(|m| match m { + InstanceMessage::LineNumbers { mode, .. } => Some(*mode), + _ => None, + }); + assert_eq!( + mode, + Some(pmacs::protocol::LineNumberMode::Absolute), + "LineNumbers must describe the DOCUMENT window's mode, not the focused panel's" + ); +} + +#[test] +fn consumer_statusline_segments_carry_the_document_payload_not_the_panel() { + use pmacs::protocol::{ByteRange, InstanceMessage}; + use pmacs::semantic_render::SemanticRenderState; + + // §1.3 #12 / A2A-2 at the WIRE. Round 2 finding: the previous + // version discarded `render_frame`'s output and only reasserted + // `primary_document_window`, so restoring the producer's + // "first context for my frontend" selector left it green. + // + // The peer must negotiate v18 or no `StatuslineSegments` is emitted + // at all and the assertion would be vacuous a second way. + let s = editor(); + let (fid, _doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + let panel_buf = { + let core = s.core.borrow(); + let panel = side_window_of(&core, fid).expect("panel"); + core.windows[&panel].buffer_id + }; + + // One provider so a payload exists to misroute. + exec( + &s, + "pmacs.statusline.register({ name = \"probe\", side = \"left\", + face = \"ui.modeline\", fn = function(ctx) return \"X\" end })", + ); + + let mut sem = SemanticRenderState::for_peer(fid, 18); + sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0); + let msgs = sem.render_frame(&s); + + let targets: Vec<_> = msgs + .iter() + .filter_map(|m| match m { + InstanceMessage::StatuslineSegments { buffer_id, .. } => Some(*buffer_id), + _ => None, + }) + .collect(); + + assert!( + !targets.is_empty(), + "non-vacuity: a v18 peer with a registered provider must emit StatuslineSegments" + ); + assert!( + targets.iter().all(|b| *b == doc_buf), + "every StatuslineSegments must target the DOCUMENT buffer; got {targets:?} (document {doc_buf:?}, panel {panel_buf:?})" + ); + assert!( + !targets.contains(&panel_buf), + "the panel's context must never reach the document statusline wire" + ); +} + +#[test] +fn consumer_terminal_declaration_resolves_the_document_not_the_focused_panel() { + use pmacs::terminal::TerminalSpec; + + // §1.3 #6/#10/#11 through the real guard. Round 2 finding: the + // previous version compared two NON-terminal buffers, so both the + // old and new routings returned `false` and it could not + // discriminate. Make the DOCUMENT window hold a real terminal: the + // document routing then answers `true` while the old `view.active` + // routing (which names the focused panel) answers `false`. + let mut s = editor(); + let (fid, doc_win, _panel_win, _doc_buf) = semantic_frontend_with_focused_panel(&s); + + let mut spec = TerminalSpec::new("/bin/sh"); + spec.rows = 10; + spec.cols = 40; + let term_buf = s.open_terminal(spec).expect("a real terminal session"); + + // Install the terminal in the DOCUMENT window; the panel keeps its + // own non-terminal buffer and keeps focus. + { + let mut core = s.core.borrow_mut(); + core.install_buffer_in_window(doc_win, term_buf) + .expect("install the terminal in the document window"); + } + let panel_buf = { + let core = s.core.borrow(); + let panel = side_window_of(&core, fid).expect("panel"); + core.windows[&panel].buffer_id + }; + + assert!( + s.semantic_terminal_declaration_is_active(fid, term_buf), + "the DOCUMENT window's terminal must be declarable while the panel owns focus" + ); + assert!( + !s.semantic_terminal_declaration_is_active(fid, panel_buf), + "the focused panel's own buffer must never claim the document declaration" + ); +} + +#[test] +fn invalidated_statusline_clears_only_the_document_not_the_panel() { + use pmacs::protocol::{ByteRange, InstanceMessage}; + use pmacs::semantic_render::SemanticRenderState; + + // Round 2 finding 1. The `Invalidated` arm emits an + // authoritative-empty payload for EVERY context of the frontend. + // Once A2A-2's fan-out yields document + panel, that publishes two + // clears on a wire with ONE statusline slot, so the panel's payload + // replaces the document's. This is the live, observable half of the + // routing bug — the `Ready` arm happens to be safe today only + // because the document context is captured first. + let s = editor(); + let (fid, _doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + let panel_buf = { + let core = s.core.borrow(); + let panel = side_window_of(&core, fid).expect("panel"); + core.windows[&panel].buffer_id + }; + assert_ne!(doc_buf, panel_buf, "fixture: the two buffers must differ"); + + // A provider that unregisters itself mid-evaluation is the canonical + // registry-mutation invalidation. + exec( + &s, + r"_G.SL_SELF = pmacs.statusline.register { + name='self-remove', side='left', priority=100, + fn=function() pmacs.statusline.unregister(SL_SELF); return 'STALE' end, + }", + ); + + let mut sem = SemanticRenderState::for_peer(fid, 18); + sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0); + let msgs = sem.render_frame(&s); + + let targets: Vec<_> = msgs + .iter() + .filter_map(|m| match m { + InstanceMessage::StatuslineSegments { buffer_id, .. } => Some(*buffer_id), + _ => None, + }) + .collect(); + + assert!( + !targets.contains(&panel_buf), + "an invalidated evaluation must not clear the PANEL's context on the \ + document statusline wire; got {targets:?} (document {doc_buf:?}, \ + panel {panel_buf:?})" + ); +} + +#[test] +fn the_semantic_fan_out_captures_the_document_first() { + use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, + }; + + // The `Ready` arm selects by window identity, so capture order is not + // load-bearing for correctness — but it IS load-bearing for the + // falsifiability of that selector, so pin it explicitly rather than + // leaving a silent dependency. If a future change reorders the + // fan-out, this fails and whoever reads it learns why it mattered. + let s = editor(); + let (fid, doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + + let evaluation = evaluate_statusline( + s.lua_host.lua(), + &s.core, + &s.statusline_registry, + StatuslineEvaluationTarget::Semantic { + frontend_id: fid, + declared_buffer: doc_buf, + }, + ); + + match evaluation.outcome { + StatuslineEvaluationOutcome::Ready(windows) => { + assert_eq!(windows.len(), 2, "document + visible side window"); + assert_eq!( + windows[0].context.window_id, doc_win, + "the DOCUMENT context must be captured first" + ); + } + other => panic!("expected Ready, got {other:?}"), + } +} + +#[test] +fn consumer_decorations_follow_the_document_selection_not_the_panel() { + use pmacs::protocol::{ByteRange, InstanceMessage}; + use pmacs::semantic_render::SemanticRenderState; + + // §1.3 #5 — Projection. A selection made inside a FOCUSED PANEL must + // not paint selection decorations into the document's viewport. + // + // To DISCRIMINATE, the panel must display the SAME buffer the + // viewport declares and hold a NON-EMPTY selection while the + // document holds none. With different buffers (the first attempt) + // both routings emit nothing and the test proves nothing. + let s = editor(); + let (fid, doc_win, panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + + exec(&s, "PROBE = pmacs.buffer.list()[1]"); + { + let mut core = s.core.borrow_mut(); + // Put real text in the document buffer so a span exists. + { + let reg = core.registry.borrow(); + let _ = reg.get(doc_buf).expect("doc"); + } + // The panel shows the document's buffer and selects a range. + core.install_buffer_in_window(panel_win, doc_buf) + .expect("panel shows the document buffer"); + let panel = core.windows.get_mut(&panel_win).expect("panel"); + panel.selection = Some(pmacs::window::Selection { anchor: 0 }); + panel.cursor = 4; + // The document window selects nothing. + let doc = core.windows.get_mut(&doc_win).expect("doc"); + doc.selection = None; + doc.cursor = 0; + } + + let mut sem = SemanticRenderState::for_peer(fid, 18); + sem.set_viewport(doc_buf, ByteRange { start: 0, end: 8 }, 0); + let msgs = sem.render_frame(&s); + + let selection_decorations: usize = msgs + .iter() + .filter_map(|m| match m { + InstanceMessage::Decorations { segments, .. } => Some( + segments + .iter() + .map(|seg| seg.decorations.len()) + .sum::(), + ), + _ => None, + }) + .sum(); + assert_eq!( + selection_decorations, 0, + "a selection living in the focused PANEL must not decorate the document viewport" + ); +} + +#[test] +fn a_provider_closing_the_document_split_still_clears_the_statusline() { + use pmacs::protocol::{ByteRange, InstanceMessage}; + use pmacs::semantic_render::SemanticRenderState; + + // Round 3 finding 1. `authoritative_empty` carries PHASE-1 contexts, + // so the identity used to filter them must be the PRE-CALLBACK one. + // A provider that closes the primary document split changes + // `primary_document_window` mid-evaluation; reading it afterwards + // compares phase-1 contexts against a replacement identity, matches + // nothing, and silently suppresses the authoritative clear — leaving + // stale statusline text on screen forever. + // + // Driven on LOCAL, because the Lua window API acts on the ACTIVE + // FRONTEND: a synthetic semantic view would be untouched by + // `pmacs.window.close()` and the identity would never change, which + // is exactly how the first version of this test came back vacuous. + // TWO document windows plus the panel: closing the only document + // window is structurally refused (Q#BP6 forbids a lone side window + // as a resting state), so the first attempt could not change the + // identity at all. Distinct buffers make the target selectable from + // a Lua provider, which has no focus-by-id. + let s = editor(); + exec( + &s, + "DOC_A = pmacs.buffer.create(\"*doc-a*\") + DOC_B = pmacs.buffer.create(\"*doc-b*\") + pmacs.window.display(DOC_A, {}) + pmacs.window.split_horizontal() + pmacs.window.focus_next() + pmacs.window.display(DOC_B, {})", + ); + let (_origin, panel) = focused_panel(&s); + let (document, doc_buf) = { + let core = s.core.borrow(); + let win = core + .primary_document_window(FrontendId::LOCAL) + .expect("a primary document window"); + (win, core.windows[&win].buffer_id) + }; + assert_ne!(document, panel); + + let mut sem = SemanticRenderState::for_peer(FrontendId::LOCAL, 18); + sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0); + + // Seed a baseline payload so a CLEAR is observable as a change. + exec( + &s, + r"_G.SL_SEED = pmacs.statusline.register { + name='seed', side='left', priority=10, + fn=function() return 'OLD' end, + }", + ); + let seeded = sem.render_frame(&s); + assert!( + seeded + .iter() + .any(|m| matches!(m, InstanceMessage::StatuslineSegments { .. })), + "non-vacuity: a baseline payload must exist before we test its clear" + ); + + // A provider that unregisters itself (making the evaluation + // Invalidated) AND closes the captured document window. `close()` + // closes the ACTIVE window and Lua has no focus-by-id, so step + // around the ring until the captured buffer is current. + s.lua_host + .lua() + .globals() + .set("TARGET_BUF", pmacs::lua_bindings::BufferIdLua(doc_buf)) + .expect("expose the target buffer"); + exec( + &s, + r"_G.SL_CLOSER = pmacs.statusline.register { + name='closer', side='left', priority=100, + fn=function() + pmacs.statusline.unregister(SL_CLOSER) + for _ = 1, 8 do + if pmacs.window.buffer() == TARGET_BUF then break end + pmacs.window.focus_next() + end + pmacs.window.close() + return 'STALE' + end, + }", + ); + + let msgs = sem.render_frame(&s); + + // The fixture must actually have changed the identity, or this test + // discriminates nothing. + assert_ne!( + s.core.borrow().primary_document_window(FrontendId::LOCAL), + Some(document), + "fixture: the callback must really have changed the document identity" + ); + + let cleared = msgs.iter().any(|m| match m { + InstanceMessage::StatuslineSegments { + buffer_id, + left, + right, + .. + } => *buffer_id == doc_buf && left.is_empty() && right.is_empty(), + _ => false, + }); + assert!( + cleared, + "an invalidated evaluation must still publish the authoritative EMPTY clear for \ + the phase-1 document identity, even when a callback closed that window; got {msgs:?}" + ); +} diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs new file mode 100644 index 0000000..86be1d5 --- /dev/null +++ b/tests/lean4_server_acceptance.rs @@ -0,0 +1,1882 @@ +//! Arc 8 Stage 3b acceptance — the Lean 4 language server. +//! +//! `docs/lean4-mode-framing.md` Q#LN7, Q#LN8, Q#LN16; acceptance 22–28, +//! 24a/24b, 35, 36, 36a, 37. +//! +//! No live toolchain required. The server side is `pmacs_fake_lsp` +//! configured under the `lean4` language id; the probe and latch are +//! driven through shell stubs the fixture writes, so nothing here needs +//! `lake`, `lean`, or an elan toolchain on PATH (§2.9). +//! +//! Every fixture sets `pmacs.project.set_search_boundary` at its own +//! tempdir root. Without it the `lean-toolchain` walk climbs to the +//! filesystem root and acceptance 23's outermost assertion stops being +//! hermetic. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use pmacs::editor::EditorState; + +fn exec(state: &EditorState, source: &str) { + state.lua_host.lua().load(source.to_owned()).exec().unwrap(); +} + +fn eval(state: &EditorState, source: &str) -> T { + state.lua_host.lua().load(source.to_owned()).eval().unwrap() +} + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +fn lua_str(path: &Path) -> String { + path.display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(dir.path()).unwrap(); + Self { _dir: dir, root } + } + + fn write(&self, rel: &str, contents: &str) -> PathBuf { + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, contents).unwrap(); + path + } + + fn mkdir(&self, rel: &str) -> PathBuf { + let path = self.root.join(rel); + std::fs::create_dir_all(&path).unwrap(); + path + } + + fn dir(&self, rel: &str) -> PathBuf { + self.root.join(rel) + } + + /// A `lean-toolchain` marker file. Content is irrelevant to the + /// resolver by design (existence semantics), which 24b pins. + fn toolchain(&self, rel_dir: &str, body: &str) { + self.write(&format!("{rel_dir}/lean-toolchain"), body); + } + + fn bind(&self, state: &EditorState) { + exec( + state, + &format!( + "pmacs.project.set_search_boundary(\"{}\")", + lua_str(&self.root) + ), + ); + } +} + +/// A fresh editor with every shipped language config cleared, then the +/// `lean4` entry rebuilt against the fake server while KEEPING the real +/// resolver. That combination is the point: the root rule under test is +/// production code, only the command is a stand-in. +fn editor(fx: &Fixture) -> EditorState { + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4 = {{ + command = "{}", + args = {{}}, + root = pmacs.lean.root_for, + }} + "#, + fake_lsp_path() + ), + ); + fx.bind(&state); + state +} + +fn settle(state: &mut EditorState) { + for _ in 0..10 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(2)); + } +} + +fn open(state: &EditorState, path: &Path) { + exec( + state, + &format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)), + ); +} + +/// `language_id|root_uri|cwd` for every live server, sorted. +fn rows(state: &EditorState) -> Vec { + let joined: String = eval( + state, + r#" + local out = {} + for _, s in ipairs(pmacs.lsp.list()) do + out[#out + 1] = table.concat({ + s.language_id or "", s.root_uri or "", s.cwd or "", + }, "|") + end + table.sort(out) + return table.concat(out, "\n") + "#, + ); + if joined.is_empty() { + Vec::new() + } else { + joined.lines().map(str::to_owned).collect() + } +} + +fn resolved_root(state: &EditorState, file: &Path) -> String { + eval( + state, + &format!( + "return tostring(pmacs.lean.root_for(\"{}\"))", + lua_str(file) + ), + ) +} + +// --------------------------------------------------------------------------- +// Acceptance 22 — a Lean file in a Lake package spawns one server rooted +// at the package. +// --------------------------------------------------------------------------- + +#[test] +fn acc22_lean_file_in_a_lake_package_spawns_one_server_at_the_package_root() { + let fx = Fixture::new(); + fx.toolchain("pkg", "leanprover/lean4:v4.9.0\n"); + let file = fx.write("pkg/Pkg/Basic.lean", "def x : Nat := 1\n"); + let mut state = editor(&fx); + open(&state, &file); + settle(&mut state); + + let pkg = fx.dir("pkg").display().to_string(); + assert_eq!( + rows(&state), + vec![format!("lean4|file://{pkg}|{pkg}")], + "one server, rooted and cwd'd at the Lake package" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 23 — outermost wins. +// +// The case `pmacs.project.detect` cannot express: it is innermost-wins by +// construction, so a dependency vendored under `.lake/packages` would get +// its own server and its own (wrong) view of the world. +// --------------------------------------------------------------------------- + +#[test] +fn acc23_nested_toolchains_resolve_to_the_outermost_package() { + let fx = Fixture::new(); + fx.toolchain("pkg", "leanprover/lean4:v4.9.0\n"); + fx.toolchain("pkg/.lake/packages/dep", "leanprover/lean4:v4.8.0\n"); + let inner = fx.write( + "pkg/.lake/packages/dep/Dep/Core.lean", + "def dep : Nat := 2\n", + ); + let state = editor(&fx); + + assert_eq!( + resolved_root(&state, &inner), + fx.dir("pkg").display().to_string(), + "a file under .lake/packages/dep belongs to the outer package" + ); + // Non-vacuity: the inner marker really exists, so "outermost" is a + // choice between two candidates rather than the only one found. + assert!(fx.dir("pkg/.lake/packages/dep/lean-toolchain").exists()); +} + +// --------------------------------------------------------------------------- +// Acceptance 24 — the walk stops at the search boundary. +// --------------------------------------------------------------------------- + +#[test] +fn acc24_walk_stops_at_the_search_boundary() { + let fx = Fixture::new(); + // Boundary is the fixture root; this marker sits INSIDE it. + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + // And this one sits AT the fixture root, i.e. above `pkg` but still + // within the boundary — it must win, being outermost. + fx.toolchain(".", "v4.7.0\n"); + let state = editor(&fx); + assert_eq!( + resolved_root(&state, &file), + fx.root.display().to_string(), + "within the boundary, the outermost marker wins" + ); + + // Now move the boundary IN to `pkg`. The root-level marker is above + // it and must not be reached. + exec( + &state, + &format!( + "pmacs.project.set_search_boundary(\"{}\")", + lua_str(&fx.dir("pkg")) + ), + ); + assert_eq!( + resolved_root(&state, &file), + fx.dir("pkg").display().to_string(), + "a marker above the boundary is not consulted" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 24a / 24b — the marker test, both directions. +// +// These two must each fail against the implementation that satisfies only +// the other. 24a bites the bare `io.open` truth test (which succeeds on a +// directory); 24b bites the read-a-byte-and-require-non-nil rule (which +// rejects an empty file at EOF). +// --------------------------------------------------------------------------- + +#[test] +fn acc24a_a_lean_toolchain_directory_is_not_a_marker() { + let fx = Fixture::new(); + fx.mkdir("pkg/lean-toolchain"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let state = editor(&fx); + assert_eq!( + resolved_root(&state, &file), + "nil", + "a `lean-toolchain` DIRECTORY must not mark a root" + ); +} + +#[test] +fn acc24b_an_empty_lean_toolchain_file_is_a_marker() { + let fx = Fixture::new(); + fx.toolchain("pkg", ""); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let state = editor(&fx); + assert_eq!( + resolved_root(&state, &file), + fx.dir("pkg").display().to_string(), + "marker semantics are existence, not content — an empty \ + `lean-toolchain` still marks the package" + ); + // Non-vacuity: the file really is empty. + assert_eq!( + std::fs::read(fx.dir("pkg/lean-toolchain")).unwrap().len(), + 0 + ); +} + +#[test] +fn acc24_resolver_declines_when_no_marker_exists() { + let fx = Fixture::new(); + let file = fx.write("loose/A.lean", "def a := 1\n"); + let state = editor(&fx); + assert_eq!( + resolved_root(&state, &file), + "nil", + "no marker anywhere is a decline, which falls through to \ + `pmacs.project.detect`" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 25 — a string-valued root still works. +// --------------------------------------------------------------------------- + +#[test] +fn acc25_string_valued_root_still_works() { + let fx = Fixture::new(); + let pkg = fx.mkdir("elsewhere"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + exec( + &state, + &format!("pmacs.lsp.config.lean4.root = \"{}\"", lua_str(&pkg)), + ); + open(&state, &file); + settle(&mut state); + + let want = pkg.display().to_string(); + assert_eq!( + rows(&state), + vec![format!("lean4|file://{want}|{want}")], + "the Q#LN8 generalization is additive; a plain string still wins" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 26 — didOpen carries languageId = "lean4". +// --------------------------------------------------------------------------- + +#[test] +fn acc26_did_open_carries_the_lean4_language_id() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + open(&state, &file); + settle(&mut state); + + let lang: String = eval( + &state, + "return tostring(pmacs.lsp.list()[1] and pmacs.lsp.list()[1].language_id)", + ); + assert_eq!( + lang, "lean4", + "the grammar entry name is the didOpen language id (Q#LN2)" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 27 / 28 / 35 / 36 — the probe and the fallback latch. +// +// **Driven through the production path**, not by calling internals. +// Round 1's versions poked `_fire_latch` directly and asserted on config +// mutation, which proved nothing about whether a server ever starts — +// and acceptance 36 went further and asserted every server was terminal, +// pinning the ABSENCE of the fallback it claimed to test. These go +// `buffer.after-load` -> ticks -> probe drain -> latch -> re-attach, and +// assert the originally opened buffer ends up on a LIVE server. +// +// The stubs are real executables the fixture writes. `M.fallback` is a +// table precisely so it can point at `pmacs_fake_lsp` here. +// --------------------------------------------------------------------------- + +impl Fixture { + /// An executable shell stub. `serve` sleeps (so the "server" does not + /// die and only the named failure mode is under test); `--version` + /// prints `version_line`. + fn lake_stub(&self, rel: &str, version_line: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt as _; + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo '{version_line}'\n exit 0\nfi\nexec sleep 300\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } +} + +/// Point `command` at `lake_cmd` and the latch's fallback at the fake +/// LSP server, so a fallback that fires produces a server that works. +fn with_fallback(state: &EditorState, lake_cmd: &Path) { + exec( + state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(lake_cmd), + fake_lsp_path() + ), + ); +} + +/// The active buffer's attached server id, or "none". +fn attached_sid(state: &EditorState) -> String { + eval( + state, + r#" + local rec = pmacs.lsp.active_attachment() + return rec and tostring(rec.server) or "none" + "#, + ) +} + +/// State kind of the active buffer's attached server, or "none". +fn attached_state(state: &EditorState) -> String { + eval( + state, + r#" + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.state and s.state.kind) + end + end + return "gone" + "#, + ) +} + +#[test] +fn acc28_version_predicate_triggers_only_below_3_1() { + let fx = Fixture::new(); + let state = editor(&fx); + let check = |v: &str| -> bool { + eval( + &state, + &format!("return pmacs.lean._version_below_3_1(\"{v}\")"), + ) + }; + assert!(check("Lake version 3.0.0"), "3.0.0 is below 3.1"); + assert!(!check("Lake version 3.1.0"), "3.1.0 is not below 3.1"); + assert!(!check("Lake version 5.0.0-abc"), "5.0.0 is not below 3.1"); + assert!(check("Lake version 2.9.9"), "2.9.9 is below 3.1"); + assert!( + !check("no default toolchain configured"), + "an unparseable line must NOT trigger the fallback — that is the \ + elan-shim case, which the failure latch handles better" + ); +} + +#[test] +fn acc28_an_old_lake_falls_back_and_the_buffer_lands_on_a_live_server() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let old_lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &old_lake); + + open(&state, &file); + settle(&mut state); + // The stub's `serve` sleeps rather than dying, so ONLY the probe can + // have caused a fallback here. That isolation is the point. + for _ in 0..40 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + if attached_state(&state) == "initialized" { + break; + } + } + + assert_eq!( + attached_state(&state), + "initialized", + "an old lake must leave the buffer on a LIVE fallback server, not \ + merely rewrite the config" + ); + let cmd: String = eval(&state, "return pmacs.lsp.config.lean4.command"); + assert_eq!(cmd, fake_lsp_path(), "the fallback command is in effect"); +} + +#[test] +fn acc28_a_current_lake_does_not_trigger_the_fallback() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let new_lake = fx.lake_stub("bin/lake", "Lake version 3.1.0"); + let mut state = editor(&fx); + with_fallback(&state, &new_lake); + + open(&state, &file); + for _ in 0..20 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + } + + // Non-vacuity against the test above: same harness, same stub shape, + // only the version differs — so a latch that fired unconditionally + // would be caught here. + let cmd: String = eval(&state, "return pmacs.lsp.config.lean4.command"); + assert_eq!( + cmd, + new_lake.display().to_string(), + "a current lake keeps its command; the probe must not fall back" + ); + let latched: bool = eval(&state, "return pmacs.lean._probe.latched"); + assert!(!latched, "the latch did not arm"); +} + +#[test] +fn acc27_a_missing_lake_falls_back_and_the_buffer_lands_on_a_live_server() { + // The case round 1 could not see at all: `ensure_server` swallows a + // synchronous ENOENT and returns nil, so there is no attachment to + // key off. This is also the most likely real-world failure — a user + // with `lean` but no `lake`. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + + open(&state, &file); + for _ in 0..40 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + if attached_state(&state) == "initialized" { + break; + } + } + + assert_eq!( + attached_state(&state), + "initialized", + "a missing `lake` must fall back to a live server and re-attach \ + the buffer that was already open" + ); + let status = state.core.borrow().status.clone(); + assert!( + status.contains("lean4"), + "and it says so on the status line; saw {status:?}" + ); +} + +#[test] +fn acc27_the_latch_is_one_shot_and_does_not_re_arm() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + + open(&state, &file); + settle(&mut state); + let after_first: String = eval(&state, "return pmacs.lsp.config.lean4.command"); + assert_eq!(after_first, fake_lsp_path(), "the fallback fired once"); + + // A user who deliberately sets something else after the fallback must + // not have it silently replaced by a second firing. + exec(&state, "pmacs.lsp.config.lean4.command = \"user-choice\""); + exec(&state, "pmacs.lean._fire_latch(nil, \"a second failure\")"); + assert_eq!( + eval::(&state, "return pmacs.lsp.config.lean4.command"), + "user-choice", + "the latch never re-arms within a session" + ); +} + +#[test] +fn acc35_latch_preserves_user_config_and_swaps_only_command_and_args() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + exec( + &state, + r" + pmacs.lsp.config.lean4.settings = { lean = { verbose = true } } + pmacs.lsp.config.lean4.init_options = { hasWidgets = false } + _G.root_before = pmacs.lsp.config.lean4.root + ", + ); + + open(&state, &file); + settle(&mut state); + + let after: String = eval( + &state, + r#" + local c = pmacs.lsp.config.lean4 + return table.concat({ + tostring(c.settings and c.settings.lean and c.settings.lean.verbose), + tostring(c.init_options and c.init_options.hasWidgets), + tostring(c.root == _G.root_before), + }, "|") + "#, + ); + assert_eq!( + after, "true|false|true", + "settings, init_options and root survive the swap; only \ + command/args change" + ); +} + +#[test] +fn acc36_latch_stops_the_failing_server_before_spawning_the_fallback() { + // A stub whose `serve` exits immediately: the server dies before + // `initialize` completes, which is the failure the latch polls for. + // `RestartPolicy::OnCrash` would otherwise respawn it forever + // underneath the latch, with no attempt ceiling. + use std::os::unix::fs::PermissionsExt as _; + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let dying = fx.root.join("bin/dying-lake"); + std::fs::create_dir_all(dying.parent().unwrap()).unwrap(); + std::fs::write( + &dying, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'Lake version 9.9.9'; exit 0; fi\nexit 3\n", + ) + .unwrap(); + std::fs::set_permissions(&dying, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut state = editor(&fx); + with_fallback(&state, &dying); + open(&state, &file); + for _ in 0..60 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + if attached_state(&state) == "initialized" { + break; + } + } + + // The load-bearing assertion: the buffer ends up on a LIVE server. + assert_eq!( + attached_state(&state), + "initialized", + "the failing server is stopped and the buffer re-attached to the \ + fallback — not left terminal" + ); + // And the dead one really is stopped, so nothing is respawning it. + let dying_still_running: bool = eval( + &state, + r#" + local live = tostring(pmacs.lsp.active_attachment().server) + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) ~= live then + local k = s.state and s.state.kind + if k ~= "stopped" and k ~= "crashed" then return true end + end + end + return false + "#, + ); + assert!( + !dying_still_running, + "the failing server is not respawning underneath the latch" + ); + assert_ne!(attached_sid(&state), "none"); +} + +// --------------------------------------------------------------------------- +// Acceptance 36a — attribution (COHERENCE §9 / §1.2). +// --------------------------------------------------------------------------- + +#[test] +fn acc36a_latch_leaves_a_status_line_trace() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + + open(&state, &file); + settle(&mut state); + + let status = state.core.borrow().status.clone(); + assert!( + status.contains("lean4") && status.contains("falling back"), + "the fallback names the language and says it fell back; saw {status:?}" + ); + // The channel assertion is the point (COHERENCE §1.2): a report made + // only through `pmacs.error` — undefined in production — would leave + // this empty while the fallback itself still worked, so the user + // would silently be on a different server than they configured. + assert!(!status.is_empty()); +} + +#[test] +fn acc36a_probe_carries_a_lean_owned_process_label() { + // `ProcessSpec.label` is the only identity a process has, and it is + // what `pmacs.process.list` renders. Asserted on the spec the module + // builds rather than on a live `lake`, which CI does not have. + let fx = Fixture::new(); + let state = editor(&fx); + let src = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("builtin/runtime/lean.lua"), + ) + .unwrap(); + assert!( + src.contains("label = \"lean:lake-version-probe\""), + "the probe process is attributed to Lean by label" + ); + // And it is genuinely lazy: no probe without an attachment. + let procs: i64 = eval(&state, "return #pmacs.process.list()"); + assert_eq!(procs, 0, "configuring Lean does not start the probe"); +} + +// --------------------------------------------------------------------------- +// Acceptance 37 — waitForDiagnostics resolves through the response seam. +// --------------------------------------------------------------------------- + +#[test] +fn acc37_wait_for_diagnostics_resolves_through_the_response_seam() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a : Nat := 1\n"); + let mut state = editor(&fx); + open(&state, &file); + settle(&mut state); + + exec( + &state, + r#" + _G.settled = "never" + local rec = pmacs.lsp.active_attachment() + pmacs.lean.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err) + _G.settled = tostring(err) + end) + "#, + ); + settle(&mut state); + + assert_eq!( + eval::(&state, "return _G.settled"), + "nil", + "the reply reaches the callback with no error — this is the \ + Stage 3a response seam carrying its first production caller" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 29, Lean's side — `$/lean/fileProgress` reaches the module. +// +// Driven end-to-end through the real drain: the fake server's +// `leanprogress` mode emits the notification on didOpen. Calling the +// handler directly would pin nothing about the wiring, which is the only +// part that can break. +// --------------------------------------------------------------------------- + +#[test] +fn file_progress_notification_is_recorded_for_its_document() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + exec( + &state, + "pmacs.lsp.config.lean4.env = { PMACS_FAKE_LSP_MODE = \"leanprogress\" }", + ); + + // Nothing recorded before the server speaks — so the assertion below + // cannot pass on a pre-populated table. + let before: i64 = eval( + &state, + "local n = 0 for _ in pairs(pmacs.lean.file_progress) do n = n + 1 end return n", + ); + assert_eq!(before, 0); + + open(&state, &file); + settle(&mut state); + + let uri: String = eval( + &state, + r#" + for k, v in pairs(pmacs.lean.file_progress) do + if type(v) == "table" and v[1] and v[1].range then return k end + end + return "none" + "#, + ); + assert!( + uri.starts_with("file://") && uri.ends_with("A.lean"), + "the subscriber recorded the processing ranges under the \ + document uri; saw {uri:?}" + ); +} + +// --------------------------------------------------------------------------- +// Q#LN20 in the Lean resolver — a symlinked open reuses one server. +// --------------------------------------------------------------------------- + +#[test] +fn lean_root_is_canonical_so_a_symlinked_open_reuses_one_server() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let real = fx.write("pkg/A.lean", "def a := 1\n"); + std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap(); + let linked = fx.dir("linkpkg").join("A.lean"); + + let mut state = editor(&fx); + open(&state, &real); + settle(&mut state); + assert_eq!(rows(&state).len(), 1, "the real path spawns one server"); + + open(&state, &linked); + settle(&mut state); + assert_eq!( + rows(&state).len(), + 1, + "the symlinked path reuses it — the resolver canonicalizes, so \ + both spellings produce the same affinity key" + ); +} + +// --------------------------------------------------------------------------- +// Round-2 review findings. Each of these fails against the code as it +// stood at cdaea66, where the focused suite was already 20/20 — the +// lifecycle defects were invisible to it. +// --------------------------------------------------------------------------- + +/// Tick for at least `ms`, so a 500ms restart backoff actually elapses. +/// The round-2 defect was invisible precisely because the suite stopped +/// ticking as soon as the fallback initialized, ~300ms in. +fn tick_for(state: &mut EditorState, ms: u64) { + let deadline = std::time::Instant::now() + Duration::from_millis(ms); + while std::time::Instant::now() < deadline { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn r2_crashed_primary_does_not_respawn_underneath_the_fallback() { + // The crash schedules `next_restart_at`; `maybe_restart` fires after + // the 500ms backoff with no attempt ceiling. Skipping the retire + // call (round 2) left that armed, so the broken command kept + // respawning under the live fallback — forever, unobserved. + use std::os::unix::fs::PermissionsExt as _; + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let dying = fx.root.join("bin/dying-lake"); + std::fs::create_dir_all(dying.parent().unwrap()).unwrap(); + std::fs::write(&dying, "#!/bin/sh\nexit 3\n").unwrap(); + std::fs::set_permissions(&dying, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut state = editor(&fx); + with_fallback(&state, &dying); + open(&state, &file); + // Well past one backoff. + tick_for(&mut state, 1400); + + // **`attempt`, not liveness.** A respawning server spends most of + // its life in `crashed` waiting out the backoff, so "no live + // non-fallback server" is satisfied while it loops forever — that + // weaker assertion passed against the round-2 code and caught + // nothing. `attempt` increments on every spawn, so it counts the + // respawns directly. A retired server is absent from the list + // entirely (`forget` removes the client); one left with + // `next_restart_at` armed climbs past 1. + let worst_attempt: i64 = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + local live = rec and tostring(rec.server) or "" + local worst = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) ~= live then + local a = s.attempt or 0 + if a > worst then worst = a end + end + end + return worst + "#, + ); + assert_eq!( + worst_attempt, 0, + "the retired primary is gone from the manager, not respawning after the backoff (attempt > 0 means it is still there; > 1 means it respawned)" + ); + assert_eq!( + attached_state(&state), + "initialized", + "and the buffer is on the live fallback" + ); +} + +#[test] +fn r2_reattach_targets_the_originating_buffer_not_whatever_is_active() { + // `_attach_buffer` is an active-buffer-only seam and the latch's + // verdict arrives asynchronously. Round 2 accepted "some attachment + // now names a different server", which an unrelated Rust buffer + // satisfies — clearing the retry and stranding the Lean buffer. + // + // **Driven through the PROBE**, not through a missing executable: a + // missing command fails synchronously inside `buffer.after-load`, + // where the Lean buffer is still active and the rebuild happens + // inline, so the race cannot occur and the test proves nothing. The + // probe's verdict lands on a later tick, which is the whole point. + // The stub's `serve` sleeps, so only the probe can trigger anything. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + fx.write("pkg/Cargo.toml", "[package]\nname = \"p\"\n"); + let lean_file = fx.write("pkg/A.lean", "def a := 1\n"); + let rust_file = fx.write("pkg/src/main.rs", "fn main() {}\n"); + let old_lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + + let mut state = editor(&fx); + with_fallback(&state, &old_lake); + // A working Rust server, so switching away lands on a real + // attachment with a different server id — the decoy. + exec( + &state, + &format!( + "pmacs.lsp.config.rust = {{ command = \"{}\" }}", + fake_lsp_path() + ), + ); + + open(&state, &lean_file); + exec(&state, "_G.lean_buf = pmacs.window.buffer()"); + // Switch away before the probe's verdict can land. + open(&state, &rust_file); + tick_for(&mut state, 500); + + // Come back with a buffer SWITCH, not `find_or_open`. Re-opening + // fires `buffer.after-load`, which re-runs lsp.lua's own attach and + // would repair the record no matter what the latch did. + exec(&state, "pmacs.window.switch_buffer(_G.lean_buf)"); + tick_for(&mut state, 400); + + let lang: String = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + return rec and tostring(rec.language) or "none" + "#, + ); + assert_eq!(lang, "lean4", "we are back on the Lean buffer"); + + // The observable that discriminates: WHICH command the Lean buffer's + // server is running. A retry cleared by the decoy leaves it on the + // original `lake` stub. + let cmd: String = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.command) + end + end + return "gone" + "#, + ); + assert_eq!( + cmd, + fake_lsp_path(), + "the ORIGINATING Lean buffer ends up on the fallback — a decoy \ + Rust attachment must not satisfy the retry" + ); +} + +#[test] +fn r2_a_failing_fallback_is_reported_once_and_does_not_retry_forever() { + // Acceptance 27 promises a second failure surfaces rather than + // loops. Round 2 retried `_attach_buffer` every tick with nothing + // reported. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent_primary = fx.dir("bin/no-such-lake"); + let absent_fallback = fx.dir("bin/no-such-lean"); + + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent_primary), + lua_str(&absent_fallback) + ), + ); + + open(&state, &file); + tick_for(&mut state, 300); + + let status = state.core.borrow().status.clone(); + assert!( + status.contains("did not start either"), + "a failing fallback surfaces rather than retrying silently; saw \ + {status:?}" + ); + // And the repair was ATTEMPTED and recorded, so it is bounded rather + // than spinning. Asserting on a field that no longer exists would + // read as nil and pass for nothing — the vacuity shape this branch + // keeps producing, so the assertion is on a positive count. + let attempted: i64 = eval( + &state, + "local n = 0 for _ in pairs(pmacs.lean._probe.repaired) do n = n + 1 end return n", + ); + assert_eq!( + attempted, 1, + "exactly one repair attempt was made and recorded, so a failing \ + fallback cannot retry every tick forever" + ); +} + +#[test] +fn r2_a_working_wrapper_is_not_version_probed_as_lake() { + // `version_below_3_1` encodes LAKE's output contract. Applying it to + // an arbitrary wrapper is a category error: a working wrapper + // reporting its own "wrapper 1.0" would be replaced despite its + // server initializing fine. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + // Named something other than `lake`, reporting a sub-3.1 version, + // but which serves fine. + let wrapper = fx.lake_stub("bin/my-lean-wrapper", "wrapper 1.0"); + let mut state = editor(&fx); + with_fallback(&state, &wrapper); + + open(&state, &file); + tick_for(&mut state, 400); + + let cmd: String = eval(&state, "return pmacs.lsp.config.lean4.command"); + assert_eq!( + cmd, + wrapper.display().to_string(), + "a wrapper's own version string is not Lake's; the version probe \ + must not run against it" + ); + let latched: bool = eval(&state, "return pmacs.lean._probe.latched"); + assert!(!latched, "and the latch stayed disarmed"); +} + +#[test] +fn r2_an_unconfigured_lean_server_is_disabled_not_failed() { + // Setting `pmacs.lsp.config.lean4 = nil` means "off". Reporting that + // `nil` could not start is a false alarm, and latching poisons the + // session so a later configuration can never take effect. + let fx = Fixture::new(); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + exec(&state, "pmacs.lsp.config.lean4 = nil"); + exec(&state, "pmacs.editor.set_status(\"\")"); + + open(&state, &file); + settle(&mut state); + + assert_eq!( + state.core.borrow().status.clone(), + "", + "an unconfigured Lean server reports nothing — it is disabled" + ); + let latched: bool = eval(&state, "return pmacs.lean._probe.latched"); + assert!( + !latched, + "and the session is not poisoned: a later config must still work" + ); +} + +// --------------------------------------------------------------------------- +// Round-3 review findings — asynchronous correlation. +// +// Both fail against 3377db0, where the suite was 25/25. +// --------------------------------------------------------------------------- + +impl Fixture { + /// A `lake` whose `serve` really works (it execs the fake LSP) but + /// whose `--version` answers slowly with an old version. This is the + /// ordering the previous fixtures could not produce: the primary + /// INITIALIZES before the version verdict arrives. + fn slow_version_lake(&self, rel: &str, server: &str, version_line: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt as _; + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n sleep 0.6\n echo '{version_line}'\n exit 0\nfi\nexec '{server}'\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } +} + +/// The command backing the active buffer's attached server. +fn attached_command(state: &EditorState) -> String { + eval( + state, + r#" + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.command) + end + end + return "gone" + "#, + ) +} + +#[test] +fn r3_a_late_version_verdict_still_retires_an_initialized_primary() { + // `probe.watching` is cleared the moment the server initializes. A + // verdict arriving after that used to call `fire_latch(nil)`, which + // retires nothing — `_attach_buffer` then returns the still-live + // primary and the retry calls it success. Status and config would + // say "fell back" while the buffer stayed put. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let lake = fx.slow_version_lake("bin/lake", &fake_lsp_path(), "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &lake); + + open(&state, &file); + // Let the primary initialize first — the ordering that matters. + tick_for(&mut state, 300); + assert_eq!( + attached_state(&state), + "initialized", + "precondition: the primary really did come up before the verdict" + ); + assert_eq!( + attached_command(&state), + lake.display().to_string(), + "precondition: and the buffer is on it" + ); + + // Now let the slow `--version` land and the fallback complete. + tick_for(&mut state, 1200); + + assert_eq!( + attached_command(&state), + fake_lsp_path(), + "a late version verdict must actually move the buffer to the \ + fallback, not just rewrite the config and claim it did" + ); + // And the retired primary is not left running or respawning. + let stale: i64 = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + local live = rec and tostring(rec.server) or "" + local n = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) ~= live then + local k = s.state and s.state.kind + if k ~= "stopped" and k ~= "crashed" then n = n + 1 end + end + end + return n + "#, + ); + assert_eq!(stale, 0, "the initialized primary was retired, not left up"); +} + +#[test] +fn r3_a_second_lean_buffer_does_not_steal_the_rebuild_target() { + // `buf_key` was written on every Lean `buffer.after-load`, so a + // second Lean file opened before the verdict became the rebuild + // target while the latch still watched the FIRST buffer's server. + // + // Both files live in the SAME Lake package, so they share one server + // and one root — which is what makes the mis-targeting observable as + // a stranded buffer rather than as two independent servers. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let first = fx.write("pkg/A.lean", "def a := 1\n"); + let second = fx.write("pkg/B.lean", "def b := 2\n"); + let lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &lake); + + open(&state, &first); + exec(&state, "_G.first_buf = pmacs.window.buffer()"); + // A second Lean buffer, opened before the probe's verdict lands. + open(&state, &second); + tick_for(&mut state, 500); + + // The armed target must still be the FIRST buffer. + let target_is_first: bool = eval( + &state, + "return pmacs.lean._probe.buf_key == tostring(_G.first_buf)", + ); + assert!( + target_is_first, + "the rebuild target is captured once, when the latch arms — a \ + later Lean buffer must not silently become the target" + ); + + // And the first buffer really does end up on the fallback. + exec(&state, "pmacs.window.switch_buffer(_G.first_buf)"); + tick_for(&mut state, 600); + assert_eq!( + attached_command(&state), + fake_lsp_path(), + "the originating buffer is the one repaired" + ); +} + +#[test] +fn r3_a_failing_wrapper_is_named_truthfully_not_as_lake_serve() { + // The failure latch is command-agnostic, so its message must be too. + // Telling a user that `lake serve` failed when they configured + // `my-lean-wrapper` sends them to debug the wrong thing. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/my-lean-wrapper"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + + open(&state, &file); + settle(&mut state); + + let status = state.core.borrow().status.clone(); + assert!( + status.contains("my-lean-wrapper"), + "the status names the command the user actually configured; saw \ + {status:?}" + ); + assert!( + !status.contains("lake serve"), + "and does not attribute the failure to `lake serve`; saw {status:?}" + ); +} + +// --------------------------------------------------------------------------- +// Round-4 review — the config swap is GLOBAL, so one repaired buffer is +// not a fallback. Both fail against 73587b0. +// --------------------------------------------------------------------------- + +#[test] +fn r4_every_open_lean_buffer_is_repaired_not_just_the_armed_one() { + // `pmacs.lsp.config.lean4` is a single entry; swapping its command + // invalidates every buffer attached to the old one. Round 3 repaired + // exactly `probe.buf_key` and cleared the retry, leaving every other + // open Lean buffer on the retired server while status and config + // both said "fell back". + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let first = fx.write("pkg/A.lean", "def a := 1\n"); + let second = fx.write("pkg/B.lean", "def b := 2\n"); + let lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &lake); + + open(&state, &first); + exec(&state, "_G.first_buf = pmacs.window.buffer()"); + open(&state, &second); + exec(&state, "_G.second_buf = pmacs.window.buffer()"); + tick_for(&mut state, 700); + + // The armed (first) buffer. + exec(&state, "pmacs.window.switch_buffer(_G.first_buf)"); + tick_for(&mut state, 500); + assert_eq!( + attached_command(&state), + fake_lsp_path(), + "the armed buffer is repaired" + ); + + // And the OTHER one, which round 3 stranded. + exec(&state, "pmacs.window.switch_buffer(_G.second_buf)"); + tick_for(&mut state, 500); + assert_eq!( + attached_command(&state), + fake_lsp_path(), + "every open Lean buffer ends up on the fallback — repairing only \ + the armed target leaves this one on the retired server" + ); +} + +#[test] +fn r4_a_second_project_roots_server_is_also_retired() { + // Q#LN15 gives one server per project root, so a swap can invalidate + // several. `probe.primary` names only the first; retiring only that + // leaves the second root's server live on a command the config no + // longer names. + let fx = Fixture::new(); + fx.toolchain("one", "v4.9.0\n"); + fx.toolchain("two", "v4.9.0\n"); + let a = fx.write("one/A.lean", "def a := 1\n"); + let b = fx.write("two/B.lean", "def b := 2\n"); + let lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &lake); + + open(&state, &a); + open(&state, &b); + // Two roots, two servers, before any verdict lands. + let before: i64 = eval(&state, "return #pmacs.lsp.list()"); + assert_eq!(before, 2, "precondition: one server per root"); + + tick_for(&mut state, 900); + + // No server may still be running the retired command. + let stale_live: i64 = eval( + &state, + &format!( + r#" + local n = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.command) == "{}" then + local k = s.state and s.state.kind + if k ~= "stopped" and k ~= "crashed" then n = n + 1 end + end + end + return n + "#, + lua_str(&lake) + ), + ); + assert_eq!( + stale_live, 0, + "every Lean server spawned from the old command is retired, not \ + just the one the probe happened to name" + ); +} + +#[test] +fn r4_attribution_names_the_exact_command_and_its_arguments() { + // Round 3 implemented argument-inclusive attribution but pinned only + // "contains my-lean-wrapper" and "does not contain lake serve" — a + // mutation dropping every argument still passed. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/my-lean-wrapper"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + exec( + &state, + "pmacs.lsp.config.lean4.args = { \"serve\", \"--quiet\" }", + ); + + open(&state, &file); + settle(&mut state); + + let status = state.core.borrow().status.clone(); + let expected = format!("`{} serve --quiet`", absent.display()); + assert!( + status.contains(&expected), + "the status names the exact configured command AND its arguments;\n \ + want substring: {expected}\n saw: {status:?}" + ); +} + +// --------------------------------------------------------------------------- +// Round-5 review. All fail against 7c37bdc. +// --------------------------------------------------------------------------- + +#[test] +fn r5_a_fallback_that_dies_after_spawning_is_bounded_and_reported() { + // The once-per-buffer guard bounds calls to `_attach_buffer`, not + // the server it produced. `ensure_server` never forwards + // `cfg.restart`, so the fallback inherits `OnCrash` and a binary + // that exits before `initialize` is respawned forever — silently, + // because `latched` has already disabled the primary's poll. The + // prior failing-fallback test used a NONEXISTENT executable, which + // only exercises synchronous ENOENT. + use std::os::unix::fs::PermissionsExt as _; + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent_primary = fx.dir("bin/no-such-lake"); + let dying_fallback = fx.root.join("bin/dying-lean"); + std::fs::create_dir_all(dying_fallback.parent().unwrap()).unwrap(); + std::fs::write(&dying_fallback, "#!/bin/sh\nexit 4\n").unwrap(); + std::fs::set_permissions(&dying_fallback, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent_primary), + lua_str(&dying_fallback) + ), + ); + + open(&state, &file); + tick_for(&mut state, 1600); + + // Nothing may be respawning: `attempt` counts spawns per server. + let worst_attempt: i64 = eval( + &state, + r" + local worst = 0 + for _, s in ipairs(pmacs.lsp.list()) do + local a = s.attempt or 0 + if a > worst then worst = a end + end + return worst + ", + ); + assert!( + worst_attempt <= 1, + "a dying fallback must not be respawned indefinitely; saw \ + attempt {worst_attempt}" + ); + let status = state.core.borrow().status.clone(); + assert!( + status.contains("did not stay up") || status.contains("did not start"), + "and the second failure is reported; saw {status:?}" + ); +} + +#[test] +fn r5_a_user_spawned_lean_server_is_not_retired_by_the_fallback() { + // Language id AND label are public caller-supplied values. Even a + // user server that deliberately collides with the automatic path's + // `default-lean4` display label is not derived from + // `pmacs.lsp.config.lean4` and must not be stopped. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + exec( + &state, + &format!( + r#" + _G.mine = pmacs.lsp.spawn({{ + label = "default-lean4", + language_id = "lean4", + command = "{}", + args = {{}}, + }}) + "#, + fake_lsp_path() + ), + ); + settle(&mut state); + + open(&state, &file); + tick_for(&mut state, 600); + + let mine_alive: bool = eval( + &state, + r#" + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(_G.mine) then + local k = s.state and s.state.kind + return k ~= "stopped" and k ~= "crashed" + end + end + return false + "#, + ); + assert!( + mine_alive, + "a user-spawned Lean server survives a config-driven fallback — \ + it was never derived from that config" + ); +} + +#[test] +fn r5_no_swap_means_no_repair_attempts() { + // When the config already names the fallback, `swap_to_fallback` + // returns false and `fire_latch` returns early — but `latched` is + // true, so a repair gated on `latched` retried the UNCHANGED + // configuration and reported it as a fallback failure. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lean"); + let mut state = editor(&fx); + // Config and fallback are the SAME missing command, so no swap is + // possible. + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{}} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent), + lua_str(&absent) + ), + ); + + open(&state, &file); + tick_for(&mut state, 400); + + let attempts: i64 = eval(&state, "return pmacs.lean._probe.repair_attempts"); + assert_eq!( + attempts, 0, + "no swap happened, so there is nothing to apply and no repair \ + should be attempted" + ); + let status = state.core.borrow().status.clone(); + assert!( + !status.contains("falling back"), + "and nothing claims a fallback occurred; saw {status:?}" + ); +} + +#[test] +fn r5_repair_is_attempted_at_most_once_per_buffer_by_count() { + // Counting keys in the `repaired` table cannot distinguish + // "once per buffer" from "every tick for one buffer" — the + // cardinality stays 1 either way. Count the ATTEMPTS. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent_primary = fx.dir("bin/no-such-lake"); + let absent_fallback = fx.dir("bin/no-such-lean"); + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent_primary), + lua_str(&absent_fallback) + ), + ); + + open(&state, &file); + // Many ticks; a per-tick retry would climb without bound. + tick_for(&mut state, 900); + + let attempts: i64 = eval(&state, "return pmacs.lean._probe.repair_attempts"); + assert_eq!( + attempts, 1, + "exactly one repair attempt across many ticks for one buffer" + ); +} + +#[test] +fn r5_a_dead_attachment_is_never_handed_to_a_command() { + // Buffers live in other frontends get no `buffer.after-switch` here, + // so an eager sweep keyed on the ambient active buffer cannot reach + // them. Healing at the point of USE is frontend-agnostic: + // `attached_for_active` must not return a record whose server is + // gone. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + // A working primary, so we get a live attachment first. + exec( + &state, + &format!("pmacs.lsp.config.lean4.command = \"{}\"", fake_lsp_path()), + ); + open(&state, &file); + settle(&mut state); + let first: String = attached_sid(&state); + assert_ne!(first, "none", "precondition: attached"); + + // Retire it out from under the buffer, as the latch does globally, + // WITHOUT any switch or repair tick. + exec( + &state, + r" + local rec = pmacs.lsp.active_attachment() + pcall(pmacs.lsp.stop, rec.server) + ", + ); + for _ in 0..40 { + state.tick_processes(); + state.tick_lsp(); + std::thread::sleep(Duration::from_millis(5)); + } + + // Now a command resolves its attachment. It must not get the dead + // one; it must rebuild. + // `attachment_for_request` is deliberately non-attaching, so a dead + // record must read as "no attachment" rather than being handed over. + let for_request: String = eval( + &state, + r#" + local rec = pmacs.lsp.attachment_for_request() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.state and s.state.kind) + end + end + return "gone" + "#, + ); + assert_eq!( + for_request, "none", + "a non-attaching resolve must not hand back a dead server" + ); + + // And the attaching path rebuilds rather than returning the corpse. + let rebuilt: String = eval( + &state, + r#" + pmacs.lsp._attach_buffer() + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.state and s.state.kind) + end + end + return "gone" + "#, + ); + assert!( + rebuilt != "stopped" && rebuilt != "crashed" && rebuilt != "gone" && rebuilt != "none", + "the attaching path rebuilds against a live server; saw \ + {rebuilt:?}" + ); +} + +// --------------------------------------------------------------------------- +// Round-6 review. Each is a direct counterexample against 19f48d4. +// --------------------------------------------------------------------------- + +#[test] +fn r6_the_shipped_lean_command_rebuilds_a_dead_attachment() { + // The round-5 test called `attachment_for_request` and + // `_attach_buffer` directly, while the shipped Lean command read the + // raw `active_attachment` and still handed its request to a stopped + // server. Drive the production command this time. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + open(&state, &file); + settle(&mut state); + + exec( + &state, + r" + local rec = pmacs.lsp.active_attachment() + assert(rec) + pmacs.lsp.stop(rec.server) + ", + ); + tick_for(&mut state, 200); + + exec( + &state, + r#"pmacs.command.invoke("lean.wait-for-diagnostics")"#, + ); + let kind: String = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.state and s.state.kind) + end + end + return "gone" + "#, + ); + assert!( + kind != "stopped" && kind != "crashed" && kind != "gone" && kind != "none", + "the shipped command must resolve through the command-safe \ + attachment path; saw {kind:?}" + ); + tick_for(&mut state, 500); + let status = state.core.borrow().status.clone(); + assert_eq!( + status, "lean: elaboration complete", + "the rebuilt command path must deliver the request, not merely \ + replace the attachment" + ); +} + +#[test] +fn r6_every_spawned_fallback_server_is_bounded() { + // A scalar fallback watch covers only one Q#LN15 root. The second + // server can also be created directly by lsp.lua's after-load path, + // bypassing `repair_active_if_stale` entirely. + use std::os::unix::fs::PermissionsExt as _; + + let fx = Fixture::new(); + fx.toolchain("one", "v4.9.0\n"); + fx.toolchain("two", "v4.9.0\n"); + let first = fx.write("one/A.lean", "def a := 1\n"); + let second = fx.write("two/B.lean", "def b := 2\n"); + let absent_primary = fx.dir("bin/no-such-lake"); + let dying_fallback = fx.root.join("bin/dying-lean"); + std::fs::create_dir_all(dying_fallback.parent().unwrap()).unwrap(); + std::fs::write(&dying_fallback, "#!/bin/sh\nexit 4\n").unwrap(); + std::fs::set_permissions(&dying_fallback, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent_primary), + lua_str(&dying_fallback) + ), + ); + + open(&state, &first); + open(&state, &second); + tick_for(&mut state, 1600); + + let worst_attempt: i64 = eval( + &state, + r" + local worst = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if s.language_id == 'lean4' and (s.attempt or 0) > worst then + worst = s.attempt + end + end + return worst + ", + ); + assert!( + worst_attempt <= 1, + "every fallback server must be bounded; an unwatched root \ + reached attempt {worst_attempt}" + ); +} + +#[test] +fn r6_point_of_use_healing_does_not_duplicate_a_restarting_server() { + // A crashed OnCrash server still has `next_restart_at` armed. + // Spawning a fresh id beside it produces two same-root servers when + // the old one restarts. Use Rust so this pins the general lsp.lua + // seam independently of Lean's fallback lifecycle. + let fx = Fixture::new(); + let file = fx.write("A.rs", "fn main() {}\n"); + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.rust = {{ + command = "{}", + args = {{}}, + env = {{ PMACS_FAKE_LSP_MODE = "crash" }}, + }} + "#, + fake_lsp_path() + ), + ); + open(&state, &file); + + let mut crashed = false; + for _ in 0..100 { + state.tick_processes(); + state.tick_lsp(); + crashed = eval( + &state, + r#" + for _, s in ipairs(pmacs.lsp.list()) do + if s.language_id == "rust" + and s.state and s.state.kind == "crashed" then + return true + end + end + return false + "#, + ); + if crashed { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(crashed, "precondition: the attached server crashed"); + + exec(&state, "pmacs.lsp.hover_at_cursor()"); + let rust_servers: i64 = eval( + &state, + r#" + local n = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if s.language_id == "rust" then n = n + 1 end + end + return n + "#, + ); + assert_eq!( + rust_servers, 1, + "healing must cancel the old id's armed restart before spawning \ + its replacement" + ); +} + +#[test] +fn r6_no_swap_retires_only_the_failed_root() { + // When config already equals the fallback, no shared config changed. + // One root's failure must not globally retire another root's healthy + // instance of the same cwd-sensitive command. + use std::os::unix::fs::PermissionsExt as _; + + let fx = Fixture::new(); + fx.toolchain("bad", "v4.9.0\n"); + fx.toolchain("good", "v4.9.0\n"); + let bad = fx.write("bad/A.lean", "def a := 1\n"); + let good = fx.write("good/B.lean", "def b := 2\n"); + let wrapper = fx.root.join("bin/root-sensitive-lean"); + std::fs::create_dir_all(wrapper.parent().unwrap()).unwrap(); + std::fs::write( + &wrapper, + format!( + "#!/bin/sh\ncase \"$PWD\" in */bad) exit 4;; esac\nexec \"{}\"\n", + fake_lsp_path() + ), + ) + .unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{}} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&wrapper), + lua_str(&wrapper) + ), + ); + open(&state, &bad); + open(&state, &good); + tick_for(&mut state, 700); + + let good_alive: bool = eval( + &state, + r#" + for _, s in ipairs(pmacs.lsp.list()) do + if s.cwd and s.cwd:match("/good$") then + local k = s.state and s.state.kind + return k ~= "stopped" and k ~= "crashed" + end + end + return false + "#, + ); + assert!( + good_alive, + "one root's failure must not stop another root when no config \ + swap occurred" + ); +} diff --git a/tests/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs index d48a86c..9aafcab 100644 --- a/tests/lean4_stage1_acceptance.rs +++ b/tests/lean4_stage1_acceptance.rs @@ -305,36 +305,50 @@ fn acc11b_an_unknown_fence_name_still_injects_nothing() { // --------------------------------------------------------------------------- #[test] -fn acc12_stage1_ships_no_lsp_config_and_spawns_no_process() { - // Stage 1 is grammar + Lua tables only. Opening a Lean file must not - // reach for `lake`, `lean`, or `elan` — the LSP arrives in Stage 3, and - // even then it is fallible by design (Q#LN7). +fn acc12_opening_lean_spawns_no_process_without_a_server_config() { + // **Superseded in half by Stage 3b.** This criterion originally also + // asserted `pmacs.lsp.config.lean4 == nil`, guarding against a Stage-3 + // front-run. Stage 3b *is* Stage 3: `builtin/runtime/lean.lua` now ships + // that config deliberately, and its shape is pinned by + // `tests/lean4_server_acceptance.rs`. Asserting the absence here would + // now pin the opposite of the intended behavior, so it is gone rather + // than weakened. + // + // What survives is the half that was always about *restraint*, and it + // matters more now than it did in Stage 1 — it is what holds Q#LN7's + // "not at init" promise. `pmacs.lsp.config` is a declarative table, and + // spawning a process at startup for every user, Lean-using or not, is + // the cost rev 1 refused. Both the `lake serve` spawn and the + // `lake --version` probe are gated on a real Lean attachment. - // The load-bearing assertion, and it must run against a PRISTINE editor. - // The shared `editor()` helper wipes `pmacs.lsp.config` before any - // buffer opens, so an assertion about the server list under that harness - // holds for every language regardless of what Stage 1 ships — it could - // not fail for the regression it names. This checks the real claim - // directly: no builtin runtime file defines a Lean server config. A - // Stage-3 front-run adding `pmacs.lsp.config.lean4` fails here. + // Constructing an editor touches no process, even though the Lean + // config now exists and names `lake`. let pristine = EditorState::new(); - let no_lean_config: bool = eval(&pristine, "return pmacs.lsp.config.lean4 == nil"); - assert!( - no_lean_config, - "Stage 1 defines no `pmacs.lsp.config.lean4`; the LSP is Stage 3" + let at_init: i64 = eval(&pristine, "return #pmacs.process.list()"); + assert_eq!( + at_init, 0, + "constructing an editor must not probe or spawn for Lean" + ); + // Non-vacuity for the assertion above: the config really is present and + // really does name a command, so "nothing spawned" is restraint rather + // than an empty table having nothing to act on. + let names_lake: bool = eval( + &pristine, + "return pmacs.lsp.config.lean4 ~= nil and pmacs.lsp.config.lean4.command == \"lake\"", ); - // Non-vacuity: the same lookup finds the configs that DO ship, so this - // is not passing because `pmacs.lsp.config` is empty or absent. - let rust_config_exists: bool = eval(&pristine, "return pmacs.lsp.config.rust ~= nil"); assert!( - rust_config_exists, - "the config table is populated, so the lean4 absence above is meaningful" + names_lake, + "Stage 3b ships a lean4 config naming `lake`, so the no-spawn \ + assertion above is meaningful" ); - // And nothing is spawned by opening the file. This half retains its - // value under the wiped config: a direct probe spawn from `lean.lua` - // would show up here whatever `pmacs.lsp.config` contains. + // And opening a Lean buffer with no server configured spawns nothing — + // the `editor()` helper wipes `pmacs.lsp.config`, so this catches a + // probe that fires off the mode rather than off an attachment. let s = editor_visiting("Basic.lean", "def x : Nat := 1\n"); let procs: i64 = eval(&s, "return #pmacs.process.list()"); - assert_eq!(procs, 0, "opening a Lean buffer spawns no child process"); + assert_eq!( + procs, 0, + "with no server configured, opening a Lean buffer spawns nothing" + ); } diff --git a/tests/lsp_dispatch_seams_acceptance.rs b/tests/lsp_dispatch_seams_acceptance.rs new file mode 100644 index 0000000..f644367 --- /dev/null +++ b/tests/lsp_dispatch_seams_acceptance.rs @@ -0,0 +1,724 @@ +//! Arc 8 Stage 3a acceptance — LSP notification/response dispatch seams +//! and `pmacs.fs.canonicalize`. +//! +//! `docs/lean4-mode-framing.md` Q#LN9 and Q#LN20, acceptance 29–34 plus +//! 34a/34b. +//! +//! This suite deliberately contains **no Lean content**. +//! `handle_server_requests` (`builtin/runtime/lsp.lua`) is the single +//! LSP event drain for every language in pmacs, so the change is +//! exercised through an already-shipped language driven against +//! `pmacs_fake_lsp`. A suite that reached the drain only through Lean +//! would understate the blast radius — the same reasoning that shaped +//! Stage 2's suite. +//! +//! Every fixture calls `pmacs.project.set_search_boundary` at its own +//! tempdir root, so a stray marker above the temp directory cannot make +//! a "markerless" case silently detected. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use pmacs::editor::EditorState; + +fn exec(state: &EditorState, source: &str) { + state.lua_host.lua().load(source.to_owned()).exec().unwrap(); +} + +fn eval(state: &EditorState, source: &str) -> T { + state.lua_host.lua().load(source.to_owned()).eval().unwrap() +} + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +/// A fresh editor with the shipped language configs cleared, so the only +/// server any test can spawn is the fake one it configures itself. +fn editor() -> EditorState { + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + state +} + +fn lua_str(path: &Path) -> String { + path.display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(dir.path()).unwrap(); + Self { _dir: dir, root } + } + + fn write(&self, rel: &str, contents: &str) -> PathBuf { + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, contents).unwrap(); + path + } + + fn dir(&self, rel: &str) -> PathBuf { + self.root.join(rel) + } + + fn bind(&self, state: &EditorState) { + exec( + state, + &format!( + "pmacs.project.set_search_boundary(\"{}\")", + lua_str(&self.root) + ), + ); + } +} + +fn configure(state: &EditorState, language: &str) { + exec( + state, + &format!( + "pmacs.lsp.config.{language} = {{ command = \"{}\" }}", + fake_lsp_path() + ), + ); +} + +fn open(state: &EditorState, path: &Path) { + exec( + state, + &format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)), + ); +} + +/// `tick_async` is what drives the drain: `handle_server_requests` is +/// wrapped onto `pmacs._async.tick`, so a settle loop without it moves +/// the LSP state machine while never delivering a single event to Lua. +fn settle(state: &mut EditorState) { + for _ in 0..8 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// A rust project with one file, an attached fake server, and the +/// probes below installed. Returns the opened file's path. +fn attached_rust(state: &mut EditorState, fx: &Fixture) -> PathBuf { + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let file = fx.write("proj/src/main.rs", "fn main() {}\nlet x = 1;\n"); + fx.bind(state); + configure(state, "rust"); + open(state, &file); + settle(state); + file +} + +/// The sid of the single live server, as a Lua expression fragment. +const THE_SID: &str = "pmacs.lsp.list()[1].id"; + +// --------------------------------------------------------------------------- +// Acceptance 29 — a notification reaches a registered subscriber. +// --------------------------------------------------------------------------- + +#[test] +fn acc29_notification_reaches_a_registered_subscriber() { + let fx = Fixture::new(); + let mut state = editor(); + // Registered BEFORE the open, so the didOpen-triggered `pmacs/echo` + // is in the first drain. + exec( + &state, + r#" + _G.seen = {} + pmacs.lsp.on_notification("pmacs/echo", function(sid, params) + _G.seen[#_G.seen + 1] = tostring(params and params.uri) + end) + "#, + ); + attached_rust(&mut state, &fx); + + let n: i64 = eval(&state, "return #_G.seen"); + assert!( + n >= 1, + "expected at least one pmacs/echo notification, got {n}" + ); + let first: String = eval(&state, "return _G.seen[1]"); + assert!( + first.starts_with("file://") && first.ends_with("main.rs"), + "subscriber got the document uri; saw {first:?}" + ); +} + +#[test] +fn acc29_subscriber_for_an_unsent_method_does_not_fire() { + let fx = Fixture::new(); + let mut state = editor(); + exec( + &state, + r#" + _G.hits = 0 + pmacs.lsp.on_notification("pmacs/never", function() _G.hits = _G.hits + 1 end) + "#, + ); + attached_rust(&mut state, &fx); + + // Non-vacuity for acc29: the seam is method-keyed, not a firehose. + // Without this, a subscriber invoked for every notification would + // pass the test above while being wrong. + let hits: i64 = eval(&state, "return _G.hits"); + assert_eq!(hits, 0, "a subscriber must only fire for its own method"); +} + +// --------------------------------------------------------------------------- +// Acceptance 30 + 33 — dispatch integrity: with subscribers registered, +// a `workspace/applyEdit` request in the same drain is still handled. +// +// The fake server writes the applyEdit request and the executeCommand +// response back to back, so both land in one `events_take` batch. That +// co-occurrence is the point: a seam that consumed the batch, or that +// returned early, would starve the `request` arms that share it. +// --------------------------------------------------------------------------- + +fn drive_apply_edit(state: &mut EditorState, file: &Path) { + exec( + state, + &format!( + r#" + local sid = {THE_SID} + local uri = "file://{}" + _G.rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{ + command = "pmacs.fake.applyEdit", + arguments = {{ uri }}, + }}) + _G.response_hits = 0 + pmacs.lsp.on_response(sid, _G.rid, function(result, err) + _G.response_hits = _G.response_hits + 1 + end) + "#, + lua_str(file) + ), + ); + settle(state); +} + +fn buffer_text(state: &EditorState) -> String { + eval( + state, + "local b = pmacs.window.buffer() return b:slice(0, b:len())", + ) +} + +#[test] +fn acc30_apply_edit_still_handled_with_a_notification_subscriber() { + let fx = Fixture::new(); + let mut state = editor(); + exec( + &state, + r#" + _G.notes = 0 + pmacs.lsp.on_notification("pmacs/echo", function() _G.notes = _G.notes + 1 end) + "#, + ); + let file = attached_rust(&mut state, &fx); + assert!( + eval::(&state, "return _G.notes") >= 1, + "precondition: the notification subscriber is actually firing" + ); + + drive_apply_edit(&mut state, &file); + + assert!( + buffer_text(&state).contains("ED2"), + "workspace/applyEdit must still be applied with a subscriber \ + registered; buffer was {:?}", + buffer_text(&state) + ); +} + +#[test] +fn acc33_apply_edit_still_handled_with_a_response_subscriber() { + let fx = Fixture::new(); + let mut state = editor(); + let file = attached_rust(&mut state, &fx); + drive_apply_edit(&mut state, &file); + + // Both halves in one drain: the response was delivered to its + // one-shot AND the server-originated request was serviced. + assert_eq!( + eval::(&state, "return _G.response_hits"), + 1, + "the executeCommand response reaches its one-shot" + ); + assert!( + buffer_text(&state).contains("ED2"), + "workspace/applyEdit must still be applied with a response \ + subscriber registered; buffer was {:?}", + buffer_text(&state) + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 31 — a raising subscriber does not stop later events in the +// same drain (and does not stop the `request` arms either). +// --------------------------------------------------------------------------- + +#[test] +fn acc31_raising_notification_subscriber_does_not_stop_the_drain() { + let fx = Fixture::new(); + let mut state = editor(); + exec( + &state, + r#" + _G.second_hits = 0 + pmacs.lsp.on_notification("pmacs/echo", function() + error("subscriber blew up") + end) + pmacs.lsp.on_notification("pmacs/echo", function() + _G.second_hits = _G.second_hits + 1 + end) + "#, + ); + let file = attached_rust(&mut state, &fx); + + assert!( + eval::(&state, "return _G.second_hits") >= 1, + "a raising subscriber must not starve the ones after it" + ); + + // And the shared `request` arms still run in a later drain. + drive_apply_edit(&mut state, &file); + assert!( + buffer_text(&state).contains("ED2"), + "a raising subscriber must not stop workspace/applyEdit" + ); +} + +#[test] +fn acc33_raising_response_handler_does_not_stop_the_drain() { + let fx = Fixture::new(); + let mut state = editor(); + let file = attached_rust(&mut state, &fx); + exec( + &state, + &format!( + r#" + local sid = {THE_SID} + _G.notes_after = 0 + pmacs.lsp.on_notification("pmacs/echo", function() + _G.notes_after = _G.notes_after + 1 + end) + local rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{ + command = "pmacs.fake.applyEdit", + arguments = {{ "file://{}" }}, + }}) + pmacs.lsp.on_response(sid, rid, function() error("handler blew up") end) + "#, + lua_str(&file) + ), + ); + settle(&mut state); + + assert!( + buffer_text(&state).contains("ED2"), + "a raising response handler must not stop workspace/applyEdit in \ + the same drain" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 32 — the one-shot is removed exactly once, whether or not +// the handler raises. +// +// Named for what it pins rather than for the framing's wording. Q#LN9 +// specifies removal *before* invocation, and the implementation does +// that — but bite-testing showed the before/after ordering is not +// observable on its own: `pcall` catches the raise either way, so +// removal after the call is behaviorally identical unless a handler +// re-enters the drain, which nothing does. What IS observable, and what +// this pins, is that removal is **unconditional**: the bite that moves +// it inside `if ok then` fails here 2 != 1, because the surviving +// registration gets invoked a second time by the purge. +// --------------------------------------------------------------------------- + +#[test] +fn acc32_response_one_shot_is_removed_even_when_the_handler_raises() { + let fx = Fixture::new(); + let mut state = editor(); + attached_rust(&mut state, &fx); + exec( + &state, + &format!( + r#" + local sid = {THE_SID} + _G.calls = 0 + local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 1 }}) + pmacs.lsp.on_response(sid, rid, function(result, err) + _G.calls = _G.calls + 1 + error("handler raises after being removed") + end) + "# + ), + ); + settle(&mut state); + assert_eq!( + eval::(&state, "return _G.calls"), + 1, + "the one-shot fires exactly once for its reply" + ); + + exec(&state, &format!("pmacs.lsp.stop({THE_SID})")); + settle(&mut state); + assert_eq!( + eval::(&state, "return _G.calls"), + 1, + "a delivered one-shot must not be re-invoked by the purge — \ + removal is unconditional, not gated on a clean return" + ); +} + +#[test] +fn acc32_response_carries_the_servers_result() { + let fx = Fixture::new(); + let mut state = editor(); + attached_rust(&mut state, &fx); + exec( + &state, + &format!( + r#" + local sid = {THE_SID} + _G.echoed = nil + _G.saw_err = "unset" + local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 42 }}) + pmacs.lsp.on_response(sid, rid, function(result, err) + _G.echoed = result and result.echo and result.echo.v + _G.saw_err = tostring(err) + end) + "# + ), + ); + settle(&mut state); + + // Non-vacuity: without this the seam could "fire" with nil payloads + // and every count-based assertion above would still pass. + assert_eq!( + eval::(&state, "return _G.echoed or -1"), + 42, + "the handler receives the server's result payload" + ); + assert_eq!( + eval::(&state, "return _G.saw_err"), + "nil", + "a successful reply passes nil for err" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 34 — the pending purge, driven off `pmacs.lsp.list()` and +// NOT off a death event seen in the drain. +// +// The second test is the load-bearing one. `handle_server_requests` +// builds its sid list from `attachments`, so a server that is in no +// attachment is never drained — and its `stopped` event is therefore +// never seen. A purge wired to that event leaks exactly there. +// --------------------------------------------------------------------------- + +#[test] +fn acc34_purge_settles_a_pending_one_shot_when_the_server_dies() { + let fx = Fixture::new(); + let mut state = editor(); + attached_rust(&mut state, &fx); + exec( + &state, + &format!( + r#" + local sid = {THE_SID} + _G.err_msg = "never called" + -- A method the fake server answers only after a delay would + -- be ideal; instead the server is stopped in the same breath, + -- so the reply can never arrive. + local rid = pmacs.lsp.send_request(sid, "test/slow", {{}}) + pmacs.lsp.on_response(sid, rid, function(result, err) + _G.err_msg = tostring(err and err.message) + end) + pmacs.lsp.stop(sid) + "# + ), + ); + settle(&mut state); + + let msg: String = eval(&state, "return _G.err_msg"); + assert!( + msg.contains("server gone") || msg == "nil", + "a pending one-shot must be settled, not left waiting; saw {msg:?}" + ); + assert_ne!( + msg, "never called", + "the one-shot was never settled — it leaked" + ); +} + +#[test] +fn acc34_purge_reaches_a_server_that_is_in_no_attachment() { + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + // Spawned directly, never attached to a buffer. `attachments` is + // empty, so `handle_server_requests` never visits this sid and its + // `stopped` event is never drained. + exec( + &state, + &format!( + r#" + _G.settled = "never called" + local sid = pmacs.lsp.spawn({{ + label = "orphan", + language_id = "rust", + command = "{}", + args = {{}}, + }}) + _G.orphan = sid + "#, + fake_lsp_path() + ), + ); + settle(&mut state); + + exec( + &state, + r#" + local rid = pmacs.lsp.send_request(_G.orphan, "test/slow", {}) + pmacs.lsp.on_response(_G.orphan, rid, function(result, err) + _G.settled = tostring(err and err.message) + end) + pmacs.lsp.stop(_G.orphan) + "#, + ); + settle(&mut state); + + let settled: String = eval(&state, "return _G.settled"); + assert_ne!( + settled, "never called", + "the purge must not depend on the drain reaching this server — \ + it is in no attachment, so the drain never does" + ); + assert!( + settled.contains("server gone"), + "settled with the purge's error; saw {settled:?}" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 34a — `pmacs.fs.canonicalize` (Q#LN20). +// --------------------------------------------------------------------------- + +#[test] +#[cfg(unix)] +fn acc34a_canonicalize_resolves_symlinks_and_dot_segments() { + let fx = Fixture::new(); + fx.write("pkg/sub/a.txt", "x\n"); + // Built here rather than assumed: the whole point is the symlink. + std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap(); + let state = editor(); + + let noncanon = format!("{}/sub/./../sub/a.txt", fx.dir("linkpkg").display()); + let got: String = eval( + &state, + &format!("return tostring(pmacs.fs.canonicalize(\"{noncanon}\"))"), + ); + let want = fx.root.join("pkg/sub/a.txt").display().to_string(); + assert_eq!(got, want, "symlink and dot segments both resolved"); + + // Falsification for 34b: the uncanonicalized spelling really is + // different, so the affinity test below is not vacuous. + assert_ne!(noncanon, want); +} + +#[test] +fn acc34a_canonicalize_returns_nil_for_a_missing_path() { + let fx = Fixture::new(); + let state = editor(); + let missing = fx.dir("nope/not-here").display().to_string(); + let got: String = eval( + &state, + &format!("return tostring(pmacs.fs.canonicalize(\"{missing}\"))"), + ); + assert_eq!( + got, "nil", + "a nonexistent path declines rather than raising" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 34b — affinity survives a symlinked open. +// +// Asserted at the affinity layer, not just at the binding: the +// regression Q#LN20 exists to prevent is *two servers for one project*, +// and only this shape observes it. +// --------------------------------------------------------------------------- + +fn server_count(state: &EditorState) -> i64 { + eval(state, "return #pmacs.lsp.list()") +} + +#[test] +#[cfg(unix)] +fn acc34b_canonicalizing_resolver_reuses_one_server_across_a_symlink() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let real = fx.write("proj/src/main.rs", "fn main() {}\n"); + std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap(); + let linked = fx.dir("linkproj").join("src/main.rs"); + + let mut state = editor(); + fx.bind(&state); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.rust = {{ + command = "{}", + root = function(path) + local dir = path:match("^(.*)/[^/]*$") + if not dir then return nil end + -- Walk up to the directory holding Cargo.toml, then + -- canonicalize — the Q#LN8 shape Stage 3b will use. + while dir and #dir > 0 do + local f = io.open(dir .. "/Cargo.toml", "r") + if f then + f:close() + return pmacs.fs.canonicalize(dir) + end + dir = dir:match("^(.*)/[^/]*$") + end + return nil + end, + }} + "#, + fake_lsp_path() + ), + ); + + open(&state, &real); + settle(&mut state); + assert_eq!(server_count(&state), 1, "the real path spawns one server"); + + open(&state, &linked); + settle(&mut state); + assert_eq!( + server_count(&state), + 1, + "the symlinked path must reuse the same server — two here is the \ + exact regression Q#LN20 exists to prevent" + ); +} + +#[test] +#[cfg(unix)] +fn acc34b_falsified_by_a_resolver_that_skips_canonicalization() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let real = fx.write("proj/src/main.rs", "fn main() {}\n"); + std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap(); + let linked = fx.dir("linkproj").join("src/main.rs"); + + let mut state = editor(); + fx.bind(&state); + // Same resolver, minus the canonicalize call. This is the bite: if + // it also produced one server, the test above would be vacuous and + // `pmacs.fs.canonicalize` would be doing nothing. + exec( + &state, + &format!( + r#" + pmacs.lsp.config.rust = {{ + command = "{}", + root = function(path) + local dir = path:match("^(.*)/[^/]*$") + while dir and #dir > 0 do + local f = io.open(dir .. "/Cargo.toml", "r") + if f then f:close() return dir end + dir = dir:match("^(.*)/[^/]*$") + end + return nil + end, + }} + "#, + fake_lsp_path() + ), + ); + + open(&state, &real); + settle(&mut state); + open(&state, &linked); + settle(&mut state); + assert_eq!( + server_count(&state), + 2, + "without canonicalization the two spellings key differently and \ + spawn two servers — this is what 34b's positive case rules out" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 34a, non-UTF-8 arm — an unrepresentable resolution declines +// rather than returning a lossy string. +// +// Review finding on PR #167: `display().to_string()` substitutes U+FFFD, +// which would hand back a path that does not exist on disk. That is +// strictly worse than nil here, because the value becomes a +// server-affinity key via `file_uri_for` and would silently fail to +// round-trip. Bites against the `display()` form, which returns a +// non-nil string for this fixture. +// +// **Linux-gated, and `cfg(unix)` was not enough** — CI caught that. +// APFS enforces valid UTF-8 in filenames, so on macOS the `write` below +// fails with EILSEQ ("Illegal byte sequence") before the code under test +// is ever reached: the fixture cannot be built there. That is a +// filesystem refusing to represent the case, not a behavioral +// difference — the subject itself, `to_str()` returning None, is +// platform-independent Rust. Gated explicitly rather than skipped at +// runtime, so a future failure here is a real failure and not a silent +// no-op. +// --------------------------------------------------------------------------- + +#[test] +#[cfg(target_os = "linux")] +fn acc34a_canonicalize_declines_a_non_utf8_resolution() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt as _; + + let fx = Fixture::new(); + // 0xFF is not valid UTF-8 in any position. + let raw = OsStr::from_bytes(b"bad-\xffname"); + let target = fx.root.join(raw); + std::fs::write(&target, "x\n").unwrap(); + // Reached through an ASCII symlink, so the *input* is representable + // and only the resolved output is not — which is the case + // `to_str()` has to catch and a UTF-8-only input check would miss. + let link = fx.dir("ascii-link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let state = editor(); + let got: String = eval( + &state, + &format!( + "return tostring(pmacs.fs.canonicalize(\"{}\"))", + lua_str(&link) + ), + ); + assert_eq!( + got, "nil", + "a resolution that lands on non-UTF-8 bytes must decline, not \ + return a U+FFFD-substituted path that exists nowhere" + ); +} diff --git a/tests/terminal_config_acceptance.rs b/tests/terminal_config_acceptance.rs new file mode 100644 index 0000000..ceeb8fe --- /dev/null +++ b/tests/terminal_config_acceptance.rs @@ -0,0 +1,746 @@ +//! Terminal configuration acceptance (Stage 1 of +//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 1-12). +//! +//! Deliberately NOT `#[cfg(feature = "crdt")]`: CI never enables that +//! feature, so a gated suite is written and then never run. + +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use mlua::Value; +use pmacs::cell::{CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::terminal::TerminalViewKey; +use pmacs::window::WindowId; + +fn exec(state: &EditorState, src: &str) { + state + .lua_host + .lua() + .load(src) + .exec() + .unwrap_or_else(|e| panic!("lua failed: {src}\n{e}")); +} + +fn eval_err(state: &EditorState, src: &str) -> String { + let result: mlua::Result = state.lua_host.lua().load(src).eval(); + match result { + Ok(_) => panic!("expected an error from: {src}"), + Err(e) => e.to_string(), + } +} + +/// The viewport every test projects through. Deliberately SHORTER than +/// the 24-row screen a terminal opens with, so "scroll to the oldest +/// retained row" has somewhere to go even when nothing is retained — +/// which is what makes the two scrollback arms differ by content rather +/// than by whether scrolling was possible at all. +fn viewport() -> CellSize { + CellSize::new(10, 40) +} + +fn cells_to_text(cells: &[pmacs::cell::Cell]) -> String { + let mut text = String::new(); + for cell in cells { + match &cell.glyph { + Glyph::Char(c) => text.push(*c), + Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)), + Glyph::Continuation => {} + } + } + text +} + +fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String { + let manager = state.terminal_manager.borrow(); + let Some(snapshot) = manager.snapshot(buffer) else { + return String::new(); + }; + cells_to_text(&snapshot.cells) +} + +/// Text a view actually shows, which is where retained history is +/// visible at all — the live `screen_text` above always reads the tail. +fn view_text(state: &EditorState, key: TerminalViewKey) -> String { + let mut manager = state.terminal_manager.borrow_mut(); + manager + .snapshot_for_view(key, viewport()) + .map(|snapshot| cells_to_text(&snapshot.cells)) + .unwrap_or_default() +} + +/// Scroll a view to its OLDEST retained row and read it back. +fn oldest_view_text(state: &EditorState, key: TerminalViewKey) -> String { + state + .terminal_manager + .borrow_mut() + .scroll_view(key, viewport(), i32::MAX); + view_text(state, key) +} + +fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + state.tick_processes(); + if screen_text(state, buffer).contains(needle) { + return true; + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(20)); + } +} + +/// Give LOCAL a window on `buffer` and register/claim its terminal view, +/// which is what makes `dispatch_key`'s terminal arm reachable. +fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> WindowId { + state.core.borrow_mut().switch_active_buffer(buffer).ok(); + let window = state.core.borrow().active_window_id(); + let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer); + let mut manager = state.terminal_manager.borrow_mut(); + manager.register_view(key); + manager.claim_controller(key); + let _ = manager.snapshot_for_view(key, viewport()); + window +} + +fn terminal_buffers(state: &EditorState) -> Vec { + let manager = state.terminal_manager.borrow(); + state + .core + .borrow() + .registry + .borrow() + .ids() + .iter() + .copied() + .filter(|id| manager.is_terminal(*id)) + .collect() +} + +/// Open a terminal from Lua and return the identity buffer it created. +/// +/// The id is derived by diffing the manager's terminal set rather than +/// returned through Lua: `BufferIdLua` exposes no id accessor, and +/// diffing also asserts in passing that exactly one terminal appeared. +fn open_cat_terminal(state: &EditorState, lua_spec: &str) -> pmacs::buffer::BufferId { + let before = terminal_buffers(state); + exec( + state, + &format!("TERM_BUF = pmacs.terminal.open {{ {lua_spec} }}"), + ); + let after = terminal_buffers(state); + let mut fresh: Vec<_> = after + .into_iter() + .filter(|id| !before.contains(id)) + .collect(); + assert_eq!(fresh.len(), 1, "exactly one terminal must have opened"); + fresh.remove(0) +} + +/// `cat -v` is the echo instrument, deliberately: the terminal screen +/// rejects C0/C1 controls before they enter cells (Vterm Stage 1 +/// criterion 2), so a raw echoed `Ctrl-X` would be invisible and a test +/// probing for it could never pass. `-v` renders it as the printable +/// two-character `^X`, which is what makes "the configured chord reached +/// the child" observable at all. +const CAT_PROFILE: &str = r#" +pmacs.terminal.profiles.echo = { + command = "/bin/sh", + args = { "-c", "printf 'READY\r\n'; exec cat -v" }, +} +"#; + +/// Did the last key ARM the terminal escape? +/// +/// Observed behaviorally rather than through an accessor: while the +/// escape is armed the next key goes to ordinary dispatch, so it never +/// reaches the child. `cat` echoes anything that does reach it, which +/// makes "the probe character did not appear" the exact observable for +/// "that chord was consumed as the escape". +fn escape_was_armed(state: &mut EditorState, buffer: pmacs::buffer::BufferId, probe: char) -> bool { + // Count occurrences rather than testing for presence: the screen + // already holds the child's own output, and a single-character probe + // like 'R' collides with the "READY" banner. Only an INCREASE proves + // this keystroke reached the child. + let before = screen_text(state, buffer).matches(probe).count(); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char(probe), KeyModifiers::NONE), + ); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + state.tick_processes(); + if screen_text(state, buffer).matches(probe).count() > before { + return false; + } + if Instant::now() >= deadline { + return true; + } + thread::sleep(Duration::from_millis(20)); + } +} + +/// Acceptance 1: a profile spec is strict, and rejects before anything spawns. +#[test] +fn acc1_profile_specs_are_strict_and_reject_before_spawning() { + let state = EditorState::new(); + let before = state.core.borrow().registry.borrow().ids().len(); + + exec( + &state, + r#"pmacs.terminal.profiles.bad = { command = "/bin/sh", nonsense = true }"#, + ); + let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "bad" }"#); + assert!( + err.contains("unknown field") && err.contains("nonsense"), + "the error must name the offending field: {err}" + ); + + exec(&state, "pmacs.terminal.profiles.wrong = { command = 42 }"); + let err = eval_err( + &state, + r#"return pmacs.terminal.open { profile = "wrong" }"#, + ); + assert!(err.contains("must be a string"), "typed field error: {err}"); + + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + before, + "a rejected profile must create no buffer" + ); + assert_eq!(state.terminal_manager.borrow().len(), 0); +} + +/// Acceptance 2: an unknown profile names the known ones and creates nothing. +#[test] +fn acc2_unknown_profile_lists_known_names_and_creates_nothing() { + let state = EditorState::new(); + exec(&state, CAT_PROFILE); + exec( + &state, + r#"pmacs.terminal.profiles.other = { command = "/bin/sh" }"#, + ); + let before = state.core.borrow().registry.borrow().ids().len(); + + // Via the default setting. + exec( + &state, + r#"pmacs.config.set("terminal.default-profile", "ghost")"#, + ); + let err = eval_err(&state, "return pmacs.terminal.open {}"); + assert!(err.contains("ghost"), "names the missing profile: {err}"); + assert!( + err.contains("echo") && err.contains("other"), + "must LIST the known profiles: {err}" + ); + + // An explicit bad profile fails even though the default is now valid — + // a typo must not silently fall back (Q#TC3a). + exec( + &state, + r#"pmacs.config.set("terminal.default-profile", "echo")"#, + ); + let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "typo" }"#); + assert!(err.contains("typo"), "explicit bad profile errors: {err}"); + + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + before, + "no buffer, session, or process is created" + ); + assert_eq!(state.terminal_manager.borrow().len(), 0); +} + +/// Acceptance 2 (malformed table): `pmacs.terminal.profiles` is a raw +/// user table, so a diagnostic that walks its keys must be total over +/// them. A table holding both a string and a numeric key made +/// `table.sort` raise "attempt to compare number with string" — on the +/// unknown-profile path, replacing the exact error being asked for. +#[test] +fn acc2_malformed_profile_keys_do_not_mask_the_unknown_profile_error() { + let state = EditorState::new(); + exec(&state, CAT_PROFILE); + exec( + &state, + r#"pmacs.terminal.profiles[1] = { command = "/bin/sh" }"#, + ); + + let err = eval_err( + &state, + r#"return pmacs.terminal.open { profile = "ghost" }"#, + ); + assert!( + err.contains("ghost") && err.contains("echo"), + "the unknown-profile error must survive a malformed table: {err}" + ); + assert!( + !err.contains("attempt to compare"), + "listing known profiles must not raise: {err}" + ); + + // Rendering the REQUESTED name is partial too: `%q` raises on a + // table, and the name arrives straight from the caller. + let err = eval_err(&state, r"return pmacs.terminal.open { profile = {} }"); + assert!( + err.contains("is not defined") && err.contains("known profiles"), + "a non-string profile name must render, not raise: {err}" + ); + + assert_eq!(state.terminal_manager.borrow().len(), 0); +} + +/// Acceptance 3: explicit beats profile beats setting beats `$SHELL`, and +/// `env` MERGES rather than replacing. +#[test] +fn acc3_field_resolution_order_and_env_merge() { + let mut state = EditorState::new(); + exec( + &state, + r#" + pmacs.terminal.profiles.merged = { + command = "/bin/sh", + args = { "-c", "printf 'PROFILE:%s:%s\r\n' \"$FROM_PROFILE\" \"$SHARED\"; exec cat" }, + env = { FROM_PROFILE = "p", SHARED = "profile" }, + } + "#, + ); + let buffer = open_cat_terminal( + &state, + r#"profile = "merged", env = { SHARED = "explicit" }"#, + ); + assert!( + tick_until(&mut state, "PROFILE:p:explicit", buffer), + "profile env survives and explicit env overrides the same key: {:?}", + screen_text(&state, buffer) + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 3 (explicit command wins) and 4 (`""` means no profile). +#[test] +fn acc3_acc4_explicit_command_wins_and_empty_default_means_no_profile() { + let mut state = EditorState::new(); + exec(&state, CAT_PROFILE); + exec( + &state, + r#"pmacs.config.set("terminal.default-profile", "echo")"#, + ); + + // Explicit command beats the profile's. + let explicit = open_cat_terminal( + &state, + r#"command = "/bin/sh", args = { "-c", "printf 'EXPLICIT\r\n'; exec cat" }"#, + ); + assert!(tick_until(&mut state, "EXPLICIT", explicit)); + + // `""` is the no-profile sentinel: falls through to $SHELL. + exec( + &state, + r#"pmacs.config.set("terminal.default-profile", "")"#, + ); + let bare = open_cat_terminal(&state, ""); + let spec_ok = state.terminal_manager.borrow().is_terminal(bare); + assert!(spec_ok, "an empty default must open a $SHELL terminal"); + assert!( + !screen_text(&state, bare).contains("READY"), + "the echo profile must NOT have been applied" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// A child that overflows the 24-row screen and then goes quiet, so its +/// early output can only still be found in RETAINED HISTORY. Zero-padded +/// so `LINE001` is not a substring of `LINE100`. +const FILL_PROFILE: &str = r#" +pmacs.terminal.profiles.fill = { + command = "/bin/sh", + args = { "-c", + "i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" }, +} +"#; + +/// Acceptance 5: the scrollback SETTING reaches the screen's retained +/// history, an explicit spec value overrides it, and `0` is legal. +/// +/// Asserted end to end, through a real child and a real view, rather +/// than by reading the value back out of the registry: a registry +/// round-trip is a test of the registry, and would stay green with the +/// setting's only consumer (`terminal.lua`'s `resolved.scrollback_rows` +/// fallback) deleted outright. +#[test] +fn acc5_scrollback_setting_reaches_retained_history() { + let mut state = EditorState::new(); + exec(&state, FILL_PROFILE); + + // Arm 1: `0` is legal, and means the early rows are GONE. + exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#); + let none = open_cat_terminal(&state, r#"profile = "fill""#); + assert!(tick_until(&mut state, "DONE", none), "child finished"); + let window = focus_terminal(&state, none); + let none_key = TerminalViewKey::new(FrontendId::LOCAL, window, none); + let oldest = oldest_view_text(&state, none_key); + assert!( + !oldest.contains("LINE001"), + "with scrollback 0 the oldest retained row must not be the \ + child's first line: {oldest:?}" + ); + + // Arm 2: a large setting retains it, reachable by scrolling back. + exec( + &state, + r#"pmacs.config.set("terminal.scrollback-rows", 10000)"#, + ); + let kept = open_cat_terminal(&state, r#"profile = "fill""#); + assert!(tick_until(&mut state, "DONE", kept), "child finished"); + let window = focus_terminal(&state, kept); + let kept_key = TerminalViewKey::new(FrontendId::LOCAL, window, kept); + let oldest = oldest_view_text(&state, kept_key); + assert!( + oldest.contains("LINE001"), + "with scrollback 10000 the first line must survive in history: \ + {oldest:?}" + ); + + // Arm 3: an explicit spec value beats the setting, which is still 10000. + let overridden = open_cat_terminal(&state, r#"profile = "fill", scrollback_rows = 0"#); + assert!(tick_until(&mut state, "DONE", overridden), "child finished"); + let window = focus_terminal(&state, overridden); + let overridden_key = TerminalViewKey::new(FrontendId::LOCAL, window, overridden); + let oldest = oldest_view_text(&state, overridden_key); + assert!( + !oldest.contains("LINE001"), + "an explicit scrollback_rows = 0 must beat the setting: {oldest:?}" + ); + + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 5 (bounds): the registered range rejects out-of-range +/// values, and `0` is inside it rather than a disabled sentinel. +#[test] +fn acc5_scrollback_bounds() { + let state = EditorState::new(); + exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#); + assert_eq!( + state + .lua_host + .lua() + .load(r#"return pmacs.config.get("terminal.scrollback-rows")"#) + .eval::() + .unwrap(), + 0, + "0 is a legal scrollback value meaning 'retain no history'" + ); + + let err = eval_err( + &state, + r#"return pmacs.config.set("terminal.scrollback-rows", -1)"#, + ); + assert!( + err.contains("-1") || err.contains("min"), + "below range: {err}" + ); + let err = eval_err( + &state, + r#"return pmacs.config.set("terminal.scrollback-rows", 4000001)"#, + ); + assert!( + err.contains("4000001") || err.contains("max"), + "above range: {err}" + ); +} + +/// Acceptance 6 and 9: the configured chord escapes, repeating it sends +/// THAT chord to the child, and an ordinary `C-c` still reaches the child. +#[test] +fn acc6_acc9_configured_escape_chord_and_literal_repeat() { + let mut state = EditorState::new(); + exec(&state, CAT_PROFILE); + let buffer = open_cat_terminal(&state, r#"profile = "echo""#); + assert!(tick_until(&mut state, "READY", buffer)); + focus_terminal(&state, buffer); + + exec(&state, r#"pmacs.config.set("terminal.escape-key", "C-x")"#); + + // `C-x C-x` must send Ctrl-X (0x18), which `cat` echoes back. Against + // the pre-change hardcoded `&[0x03]` this sends Ctrl-C instead. + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + assert!( + tick_until(&mut state, "^X", buffer), + "C-x C-x must send literal Ctrl-X: {:?}", + screen_text(&state, buffer) + ); + + // With the escape moved, an ordinary C-c is just another key. + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!( + tick_until(&mut state, "^C", buffer), + "plain C-c must reach the child once the escape moved: {:?}", + screen_text(&state, buffer) + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 7, 8 and 8a: per-terminal escape resolution, an A→B→A parse +/// count that does not grow, and a cache that dies with its terminal. +#[test] +fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() { + let mut state = EditorState::new(); + exec(&state, CAT_PROFILE); + let a = open_cat_terminal(&state, r#"profile = "echo""#); + exec(&state, "TERM_A = TERM_BUF"); + let b = open_cat_terminal(&state, r#"profile = "echo""#); + exec(&state, "TERM_B = TERM_BUF"); + assert!(tick_until(&mut state, "READY", a)); + assert!(tick_until(&mut state, "READY", b)); + + // Different buffer-local escapes, then NO further writes. + exec( + &state, + r#"pmacs.config.set_local(TERM_A, "terminal.escape-key", "C-x")"#, + ); + exec( + &state, + r#"pmacs.config.set_local(TERM_B, "terminal.escape-key", "C-b")"#, + ); + + // Prime both caches. Each priming press ARMS the escape, so it is + // consumed with a probe — otherwise the next chord would be read as + // the escape repeat rather than a fresh escape. + focus_terminal(&state, a); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + assert!(escape_was_armed(&mut state, a, 'M'), "A primes on its C-x"); + focus_terminal(&state, b); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), + ); + assert!(escape_was_armed(&mut state, b, 'N'), "B primes on its C-b"); + let primed = state.terminal_manager.borrow().escape_parses(); + assert_eq!( + state.terminal_manager.borrow().escape_caches(), + 2, + "each primed terminal holds its own cache" + ); + + // Acceptance 7 — BOTH directions. Asserting only that A still works + // after A->B->A is not enough: an epoch-only cache hands whichever + // entry it finds to every terminal, so A keeps working by accident + // while B silently inherits A's chord. The discriminating assertion + // is that EACH terminal honors its OWN chord and NOT the other's. + focus_terminal(&state, b); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), + ); + assert!( + escape_was_armed(&mut state, b, 'R'), + "terminal B must escape on its own C-b" + ); + // ...and A's chord must be ordinary input in B, not an escape. + focus_terminal(&state, b); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + assert!( + !escape_was_armed(&mut state, b, 'S'), + "terminal A's C-x must NOT escape terminal B" + ); + + focus_terminal(&state, a); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + assert!( + escape_was_armed(&mut state, a, 'Q'), + "terminal A must still escape on its own C-x after A->B->A" + ); + + // Acceptance 8: that round trip parsed nothing new. A single + // last-entry cache would have reparsed twice. + assert_eq!( + state.terminal_manager.borrow().escape_parses(), + primed, + "A->B->A with no setting written must not reparse" + ); + + // Acceptance 8a: the cache dies with its terminal. + // + // Waiting for the SESSION count to fall is not the assertion — a + // session set that drains while an editor-side `HashMap` keeps its entry (the rejected implementation named + // in Q#TC4c, which has no purge hook) satisfies it exactly. The + // discriminating observable is the CACHE count, which such a map + // would hold at its high-water mark of 2. + let sessions_before = state.terminal_manager.borrow().len(); + exec(&state, "pmacs.terminal.terminate(TERM_A)"); + exec(&state, "pmacs.buffer.kill(TERM_A)"); + // Pruning is tick-driven (the manager reaps on the process tick), so + // the session outlives the kill call by design. + let deadline = Instant::now() + Duration::from_secs(5); + while state.terminal_manager.borrow().len() >= sessions_before { + state.tick_processes(); + assert!( + Instant::now() < deadline, + "killing the terminal must remove its session" + ); + thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + state.terminal_manager.borrow().escape_caches(), + 1, + "killing terminal A must drop ITS cache, not merely its session" + ); + + // ...and the surviving cache is B's, so the right one was dropped. + focus_terminal(&state, b); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), + ); + assert!( + escape_was_armed(&mut state, b, 'T'), + "terminal B must still escape on its own C-b after A was killed" + ); + assert_eq!( + state.terminal_manager.borrow().escape_parses(), + primed, + "B's surviving cache must not have been reparsed" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 10 and 10a: an unparseable value falls back, reports through +/// the status line, and reports once per terminal per effective bad value. +#[test] +fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() { + let mut state = EditorState::new(); + exec(&state, CAT_PROFILE); + let buffer = open_cat_terminal(&state, r#"profile = "echo""#); + assert!(tick_until(&mut state, "READY", buffer)); + focus_terminal(&state, buffer); + + exec( + &state, + r#"pmacs.config.set("terminal.escape-key", "not-a-chord")"#, + ); + state.core.borrow_mut().status.clear(); + + // Acceptance 10: falls back to C-c, so the terminal stays escapable. + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + // Read the report BEFORE probing: `status` is a single slot, and the + // probe key's own rejected self-insert would overwrite it. + let reported = state.core.borrow().status.clone(); + assert!( + reported.contains("terminal.escape-key") && reported.contains("not-a-chord"), + "the report must name the setting and the bad value: {reported:?}" + ); + assert!( + escape_was_armed(&mut state, buffer, 'Q'), + "an invalid escape-key must fall back to C-c, not leave the \ + terminal unescapable" + ); + + // Acceptance 10a: the same bad value does not report again. + state.core.borrow_mut().status.clear(); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!( + state.core.borrow().status.is_empty(), + "an unchanged invalid value must not re-report: {:?}", + state.core.borrow().status + ); + let _ = escape_was_armed(&mut state, buffer, 'W'); + + // A DIFFERENT bad value is new information, so it reports again. + exec( + &state, + r#"pmacs.config.set("terminal.escape-key", "also-bad")"#, + ); + state.core.borrow_mut().status.clear(); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!( + state.core.borrow().status.contains("also-bad"), + "a different invalid value must report: {:?}", + state.core.borrow().status + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 11: the opening binding exists, resolves to the command, and +/// shadowed nothing (`keymap.bind` is strict, so loading the runtime at all +/// proves the second half). +#[test] +fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() { + let state = EditorState::new(); + let command: Option = state + .lua_host + .lua() + .load(r#"local d = pmacs.describe.key("C-c t"); return d and d.command"#) + .eval() + .expect("describe.key"); + assert_eq!( + command.as_deref(), + Some("terminal"), + "C-c t must open a terminal" + ); +} + +/// Acceptance 12: with no settings written and no profiles registered, the +/// defaults reproduce the pre-arc behavior. +#[test] +fn acc12_defaults_reproduce_prior_behavior() { + let state = EditorState::new(); + let lua = state.lua_host.lua(); + assert_eq!( + lua.load(r#"return pmacs.config.get("terminal.default-profile")"#) + .eval::() + .unwrap(), + "" + ); + assert_eq!( + lua.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#) + .eval::() + .unwrap(), + 10_000 + ); + assert_eq!( + lua.load(r#"return pmacs.config.get("terminal.escape-key")"#) + .eval::() + .unwrap(), + "C-c" + ); + assert!( + lua.load("return next(pmacs.terminal.profiles) == nil") + .eval::() + .unwrap(), + "no profiles are registered by default" + ); +} diff --git a/tests/typed_edit_chain_acceptance.rs b/tests/typed_edit_chain_acceptance.rs new file mode 100644 index 0000000..8ff9c13 --- /dev/null +++ b/tests/typed_edit_chain_acceptance.rs @@ -0,0 +1,735 @@ +//! Typed-edit consumer chain acceptance (Arc 8 Stage 4a, +//! docs/lean4-mode-framing.md Q#LN10, criteria 46a–46h). +//! +//! The chain owns the single `buffer.after-edit` subscriber that reads +//! the one-shot typed-edit record (Q#AP9) and offers it to consumers in +//! priority order. These tests pin the chain's OWN behavior — take-once, +//! priority ordering, claim-stops-chain, throw containment, per-consumer +//! record isolation, snapshot iteration under re-entrant registration, +//! the registration lifecycle, and the Q#AP7 flush ordering it inherited +//! from `pair.lua`. +//! +//! They deliberately do not re-test auto-pairing: criterion 46 requires +//! `tests/auto_pair_acceptance.rs` to pass byte-identical, and that +//! suite is the no-behavior-change pin. Pairing appears here only as +//! the chain's last consumer, which is how 46c observes that a claim +//! really stopped the chain. +//! +//! Dispatch-driven throughout: `dispatch_key` is the producer that arms +//! the record for a grid frontend. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; +use pmacs::protocol::FrontendId; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +fn fresh_state_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-typededit-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +fn buffer_text(s: &EditorState) -> String { + let b: mlua::String = eval( + s, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + String::from_utf8_lossy(&b.as_bytes()).into_owned() +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +/// Fresh scratch-buffer editor, cursor at 0. Scratch pairing uses the +/// `default` set, so `(` pairs — which is what 46c reads. +fn editor_with(body: &str) -> EditorState { + let s = EditorState::new(); + if !body.is_empty() { + exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); + } + exec(&s, "pmacs.editor.goto_byte(0)"); + s +} + +// --------------------------------------------------------------------------- +// 46a — one read for the whole fan-out +// --------------------------------------------------------------------------- + +#[test] +fn chain_reads_the_record_once_and_hands_the_same_one_to_every_consumer() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.seen = {} + local function spy(tag) + return function(rec) + -- Each consumer independently attempts its own take. Under + -- the pre-chain design this is exactly what a second + -- consumer would have done, and exactly what would have + -- returned nil (or stolen the record from pairing). + local own = pmacs.editor.take_typed_edit() + _G.seen[#_G.seen + 1] = { + tag = tag, + char = rec and rec.char, + post_cursor = rec and rec.post_cursor, + clean = rec and rec.clean, + own_take_was_nil = (own == nil), + } + return false + end + end + pmacs.typed_edit.add_consumer { name = "spy-a", priority = 1, fn = spy("a") } + pmacs.typed_edit.add_consumer { name = "spy-b", priority = 2, fn = spy("b") } + "#, + ); + + type_str(&mut s, "x"); + + let (n, a_char, b_char, a_pc, b_pc, a_clean, b_clean, a_nil, b_nil): ( + i64, + String, + String, + i64, + i64, + bool, + bool, + bool, + bool, + ) = eval( + &s, + " + local a, b = _G.seen[1], _G.seen[2] + return #_G.seen, a.char, b.char, a.post_cursor, b.post_cursor, + a.clean, b.clean, a.own_take_was_nil, b.own_take_was_nil + ", + ); + + assert_eq!(n, 2, "both consumers ran for one typed character"); + // The same record, not two reads of a slot that only one could win. + assert_eq!(a_char, "x"); + assert_eq!(b_char, "x", "the second consumer sees the record too"); + assert_eq!((a_pc, b_pc), (1, 1), "identical post_cursor"); + assert!(a_clean && b_clean, "identical clean verdict"); + // ...and the chain, not the consumers, did the taking. + assert!( + a_nil && b_nil, + "a consumer's own take_typed_edit() observes nil — the chain \ + already consumed the one-shot slot (Q#AP9)" + ); +} + +#[test] +fn consumers_run_when_the_fan_out_carries_no_record() { + // The chain calls consumers with nil rather than skipping them. + // Three tests in the auto-pairing suite depend on this (they assert + // `_last_record == nil` after a record-less fan-out), so it is a + // load-bearing decision and not an implementation detail. + let s = editor_with(""); + exec( + &s, + r#" + _G.calls, _G.nil_calls = 0, 0 + pmacs.typed_edit.add_consumer { + name = "nil-spy", priority = 1, + fn = function(rec) + _G.calls = _G.calls + 1 + if rec == nil then _G.nil_calls = _G.nil_calls + 1 end + return false + end, + } + "#, + ); + + // A manual fan-out arms no record. + exec(&s, "pmacs.hook.run(\"buffer.after-edit\")"); + + let (calls, nil_calls): (i64, i64) = eval(&s, "return _G.calls, _G.nil_calls"); + assert_eq!(calls, 1, "the consumer ran"); + assert_eq!(nil_calls, 1, "and was handed nil, not skipped"); +} + +// --------------------------------------------------------------------------- +// 46b — priority order, not registration order +// --------------------------------------------------------------------------- + +#[test] +fn consumers_run_in_priority_order_not_registration_order() { + let mut s = editor_with(""); + // Registered HIGH priority first. If the chain honored registration + // order (or `include_str!` order, which is the same failure dressed + // differently), the observed order would be the registration order. + exec( + &s, + r#" + _G.order = {} + local function mark(tag) + return function() _G.order[#_G.order + 1] = tag; return false end + end + pmacs.typed_edit.add_consumer { name = "late", priority = 30, fn = mark("late") } + pmacs.typed_edit.add_consumer { name = "early", priority = 10, fn = mark("early") } + pmacs.typed_edit.add_consumer { name = "mid", priority = 20, fn = mark("mid") } + "#, + ); + + type_str(&mut s, "x"); + + let order: String = eval(&s, "return table.concat(_G.order, ',')"); + assert_eq!( + order, "early,mid,late", + "lowest priority runs first, regardless of when it registered" + ); +} + +#[test] +fn equal_priorities_break_by_registration_order() { + // The stated tiebreak. Lua's `table.sort` is not stable, so this + // bites an implementation that sorts instead of inserting in place. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.order = {} + local function mark(tag) + return function() _G.order[#_G.order + 1] = tag; return false end + end + pmacs.typed_edit.add_consumer { name = "first", priority = 5, fn = mark("first") } + pmacs.typed_edit.add_consumer { name = "second", priority = 5, fn = mark("second") } + pmacs.typed_edit.add_consumer { name = "third", priority = 5, fn = mark("third") } + "#, + ); + + type_str(&mut s, "x"); + + let order: String = eval(&s, "return table.concat(_G.order, ',')"); + assert_eq!(order, "first,second,third"); +} + +// --------------------------------------------------------------------------- +// 46c — a claim stops the chain +// --------------------------------------------------------------------------- + +#[test] +fn a_claiming_consumer_stops_the_chain() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.later_ran = false + pmacs.typed_edit.add_consumer { + name = "claimer", priority = 1, fn = function() return true end, + } + pmacs.typed_edit.add_consumer { + name = "later", priority = 2, + fn = function() _G.later_ran = true; return false end, + } + "#, + ); + + type_str(&mut s, "("); + + let later_ran: bool = eval(&s, "return _G.later_ran"); + assert!(!later_ran, "a later consumer must not run after a claim"); + // Pairing is the chain's last consumer at priority 100, so the + // claim is observable in the buffer: no closer was inserted. This + // is the assertion that makes the criterion about behavior rather + // than about a bookkeeping flag. + assert_eq!( + buffer_text(&s), + "(", + "auto-pairing never ran, so the opener stands alone" + ); +} + +#[test] +fn a_non_claiming_consumer_does_not_stop_the_chain() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.later_ran = false + pmacs.typed_edit.add_consumer { + name = "passer", priority = 1, fn = function() return false end, + } + pmacs.typed_edit.add_consumer { + name = "later", priority = 2, + fn = function() _G.later_ran = true; return false end, + } + "#, + ); + + type_str(&mut s, "("); + + let later_ran: bool = eval(&s, "return _G.later_ran"); + assert!(later_ran, "a declining consumer passes the edit along"); + assert_eq!( + buffer_text(&s), + "()", + "and pairing, still last in the chain, reacted normally" + ); +} + +// --------------------------------------------------------------------------- +// 46d — a throwing consumer is contained +// --------------------------------------------------------------------------- + +#[test] +fn a_throwing_consumer_is_contained_reported_and_does_not_stop_the_chain() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.later_ran = false + pmacs.typed_edit.add_consumer { + name = "boom", priority = 1, + fn = function() error("consumer exploded") end, + } + pmacs.typed_edit.add_consumer { + name = "later", priority = 2, + fn = function() _G.later_ran = true; return false end, + } + "#, + ); + + // An uncontained throw would abandon every LATER consumer in the + // chain and mark the whole `buffer.after-edit` run failed. It would + // NOT stop the hook's other subscribers — all-must-succeed collects + // errors and keeps going (`src/hook.rs`'s `run_all_must_succeed`) — + // so what this pins is that one broken consumer cannot silently + // disable the ones behind it. + type_str(&mut s, "("); + + let later_ran: bool = eval(&s, "return _G.later_ran"); + assert!(later_ran, "a throwing consumer must not stop the chain"); + assert_eq!( + buffer_text(&s), + "()", + "and pairing still ran — the fan-out survived the throw" + ); + let st = status(&s); + assert!( + st.contains("boom") && st.contains("consumer exploded"), + "the failure is reported by consumer name and message, got {st:?}" + ); +} + +#[test] +fn add_consumer_rejects_malformed_registrations() { + let s = editor_with(""); + for (src, want) in [ + ( + "pmacs.typed_edit.add_consumer(\"nope\")", + "spec must be a table", + ), + ( + "pmacs.typed_edit.add_consumer{ priority = 1, fn = function() end }", + "name must be a non-empty string", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", fn = function() end }", + "priority must be a finite integer", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = 1 }", + "fn must be a function", + ), + // NaN is a number and every ordered comparison with it is + // false, so a bare type check lets it land wherever the + // insertion scan gives up — and the lowest-first contract the + // Lean expander depends on quietly stops holding. The + // infinities and non-integers go with it: priority matches + // `pmacs.completion.register`'s i32. + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = 0/0, \ + fn = function() end }", + "priority must be a finite integer", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = math.huge, \ + fn = function() end }", + "priority must be a finite integer", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = -math.huge, \ + fn = function() end }", + "priority must be a finite integer", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = 1.5, \ + fn = function() end }", + "priority must be a finite integer", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = 4e9, \ + fn = function() end }", + "priority must be a finite integer", + ), + ] { + let err = s + .lua_host + .lua() + .load(src.to_string()) + .exec() + .expect_err("malformed registration must throw"); + let msg = err.to_string(); + assert!( + msg.contains(want), + "expected {want:?} in the error for {src:?}, got {msg:?}" + ); + } +} + +#[test] +fn an_error_whose_rendering_throws_is_still_contained() { + // A Lua error may be any value, including a table whose + // `__tostring` throws. Rendering it outside the containment is a + // second, uncontained throw — the chain would stop at exactly the + // consumer it was trying to report. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.later_ran = false + local hostile = setmetatable({}, { + __tostring = function() error("rendering exploded") end, + }) + pmacs.typed_edit.add_consumer { + name = "boom", priority = 1, fn = function() error(hostile) end, + } + pmacs.typed_edit.add_consumer { + name = "later", priority = 2, + fn = function() _G.later_ran = true; return false end, + } + "#, + ); + + type_str(&mut s, "("); + + let later_ran: bool = eval(&s, "return _G.later_ran"); + assert!( + later_ran, + "an unrenderable error must not escape the containment" + ); + assert_eq!(buffer_text(&s), "()", "and pairing still ran"); + let st = status(&s); + assert!( + st.contains("boom") && st.contains(""), + "the consumer is still named, with a placeholder body, got {st:?}" + ); +} + +// --------------------------------------------------------------------------- +// The record a consumer sees is its own +// --------------------------------------------------------------------------- + +#[test] +fn a_consumers_mutation_of_the_record_cannot_reach_the_next_consumer() { + // The record is plain Lua data. Handing every consumer the same + // table lets a DECLINING consumer rewrite provenance for the ones + // behind it — and pairing decides what to close from `rec.char`, + // so a forged `char` makes it insert a pair the user never typed. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.downstream_char = "unset" + pmacs.typed_edit.add_consumer { + name = "vandal", priority = 1, + fn = function(rec) + if rec then rec.char = "("; rec.codepoint = 40 end + return false + end, + } + pmacs.typed_edit.add_consumer { + name = "witness", priority = 2, + fn = function(rec) + _G.downstream_char = rec and rec.char or "nil" + return false + end, + } + "#, + ); + + type_str(&mut s, "x"); + + let downstream: String = eval(&s, "return _G.downstream_char"); + assert_eq!( + downstream, "x", + "the next consumer sees the real typed character" + ); + assert_eq!( + buffer_text(&s), + "x", + "and pairing, reading the same field, did not close a forged opener" + ); +} + +// --------------------------------------------------------------------------- +// Re-entrant registration, and the consumer lifecycle +// --------------------------------------------------------------------------- + +#[test] +fn registering_or_removing_during_a_fan_out_takes_effect_on_the_next_one() { + // The fan-out iterates a snapshot. Iterating the live array instead + // lets a consumer that registers a LOWER-priority one shift itself + // forward under `ipairs` and run twice in a single fan-out — and + // repeating the registration makes that unbounded. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.order = {} + local function mark(tag) + return function() _G.order[#_G.order + 1] = tag; return false end + end + _G.doomed = pmacs.typed_edit.add_consumer { + name = "doomed", priority = 50, fn = mark("doomed"), + } + _G.did_register = false + pmacs.typed_edit.add_consumer { + name = "a", priority = 10, + fn = function() + _G.order[#_G.order + 1] = "a" + if not _G.did_register then + _G.did_register = true + pmacs.typed_edit.add_consumer { name = "b", priority = 5, fn = mark("b") } + pmacs.typed_edit.remove_consumer(_G.doomed) + end + return false + end, + } + "#, + ); + + type_str(&mut s, "x"); + let first: String = eval(&s, "return table.concat(_G.order, ',')"); + assert_eq!( + first, "a,doomed", + "`a` runs once even though it registered ahead of itself, and \ + `doomed` still runs in the fan-out it was removed during" + ); + + exec(&s, "_G.order = {}"); + type_str(&mut s, "y"); + let second: String = eval(&s, "return table.concat(_G.order, ',')"); + assert_eq!( + second, "b,a", + "both the registration and the removal land on the next fan-out" + ); +} + +#[test] +fn remove_consumer_unregisters_and_reports_whether_it_was_live() { + // Without removal, re-evaluating a config or reloading a package + // accumulates callbacks permanently — the leak COHERENCE.md §13 + // already records against `pmacs.hook.add`. A chain with no + // teardown would inherit it and spread it to every consumer. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.runs = 0 + _G.h = pmacs.typed_edit.add_consumer { + name = "temporary", priority = 1, + fn = function() _G.runs = _G.runs + 1; return false end, + } + "#, + ); + + type_str(&mut s, "x"); + let runs: i64 = eval(&s, "return _G.runs"); + assert_eq!(runs, 1, "registered consumers run"); + + let first_removal: bool = eval(&s, "return pmacs.typed_edit.remove_consumer(_G.h)"); + let second_removal: bool = eval(&s, "return pmacs.typed_edit.remove_consumer(_G.h)"); + assert!(first_removal, "removing a live consumer reports true"); + assert!( + !second_removal, + "a double-remove is a reportable no-op, not a throw" + ); + + type_str(&mut s, "y"); + let runs: i64 = eval(&s, "return _G.runs"); + assert_eq!(runs, 1, "the removed consumer no longer runs"); + // Removal is surgical: the chain itself, and pairing on it, survive. + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "xy()", + "the rest of the chain is untouched" + ); +} + +// --------------------------------------------------------------------------- +// 46e — the Q#AP7 flush ordering the chain inherited +// --------------------------------------------------------------------------- + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool { + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + let done: bool = state + .lua_host + .lua() + .load(format!("return ({flag}) == true")) + .eval() + .unwrap_or(false); + if done { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// The `text` of every `textDocument/didChange` line in the sink, in +/// arrival order. +fn did_change_texts(sink: &std::path::Path) -> Vec { + let Ok(raw) = std::fs::read_to_string(sink) else { + return Vec::new(); + }; + raw.lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange")) + .filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned)) + .collect() +} + +#[test] +fn a_chain_consumers_edit_reaches_the_first_did_change() { + // Q#AP7 generalized from pairing to the chain: lsp.lua's after-edit + // callback flushes didChange SYNCHRONOUSLY on the signature-trigger + // path, so every reaction to a typed character must already be in + // the buffer when it runs. The auto-pairing suite pins this for + // pairing; this pins it for the chain itself, which is what now + // owns the registration position. + // + // Falsified by loading typed_edit.lua after lsp.lua in + // `src/editor.rs`: the consumer's text would then arrive in the + // SECOND didChange, or not at all. + let dir = fresh_state_dir(); + let sink = dir.join("changes.jsonl"); + let sink_disp = sink.display().to_string(); + let fake = fake_lsp_path(); + + let mut s = EditorState::new(); + s.lua_host.lua().remove_app_data::(); + s.lua_host.lua().set_app_data(StateDir(dir.clone())); + exec(&s, "pmacs.lsp.config = {}"); + exec( + &s, + &format!( + "pmacs.lsp.config.rust = {{ + command = '{fake}', + env = {{ + PMACS_FAKE_LSP_MODE = 'sighelp', + PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}', + }}, + }}" + ), + ); + + // A consumer that appends a marker of its own, ahead of pairing. + // It declines the claim so pairing still runs — the assertion is + // about ordering against the flush, not about claiming. + exec( + &s, + r#" + pmacs.typed_edit.add_consumer { + name = "marker", priority = 1, + fn = function(rec) + if not rec then return false end + if rec.char ~= "(" then return false end + local buf = pmacs.window.buffer() + buf:insert(buf:len(), "Z") + return false + end, + } + "#, + ); + + let f = dir.join("a.rs"); + std::fs::write(&f, "\n").unwrap(); + let fd = f.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + let initialized = "(function() \ + for _,r in ipairs(pmacs.lsp.list()) do \ + if r.state and r.state.kind=='initialized' then return true end \ + end \ + return false \ + end)()"; + assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init"); + + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "()\nZ", + "both the chain consumer's marker and pairing's closer landed" + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let changes = loop { + s.tick_processes(); + s.tick_lsp(); + s.tick_async(); + let c = did_change_texts(&sink); + if !c.is_empty() { + break c; + } + assert!( + Instant::now() < deadline, + "no didChange reached the fake server" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!( + changes[0], "()\nZ", + "the FIRST didChange carries BOTH reactions — the chain ran \ + before lsp.lua's synchronous flush (Q#AP7)" + ); +}