diff --git a/COHERENCE.md b/COHERENCE.md index 9fe85f0..e997614 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -95,7 +95,7 @@ remain open to them. | § | Concern | Grade | One-line state | |---|---|---|---| -| 2 | Golden product journey | **Broken at entry** | `pmacs .` exits 1; only "launch" and "edit" pass cleanly zero-config | +| 2 | Golden product journey | **Runs to step 5** | `pmacs .` opens the directory (Journey Stage 1a); thin from step 6 on | | 3 | Zero-configuration state | **Partial** | Defaults genuinely strong; missing-tool failure is silent, not graceful | | 4 | Progressive disclosure | **Inverted** | The advanced level is real; the beginner level is the missing one | | 5 | Unified discoverability | **Substrate without surface** | Best-in-class registration metadata; almost no way for a user to reach it | @@ -109,10 +109,10 @@ remain open to them. | 13 | Package lifecycle UX | **Resolution without lifecycle** | Mature resolver/lockfile; init-only install; no uninstall/disable/search | | 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real shared primitive; bottom panel landed (#155) | | 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all | -| 16 | Semantic frontend | **Strong** | v6..=v20 negotiated protocol; degradation practiced; TUI/GPU share the model | +| 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced | | 17 | Distribution | **Missing** | CI is test-only; no binaries, channels, checksums, or update path | | 18 | Onboarding | **Missing** | No welcome, no tutorial; `C-h` deletes a word; `M-x` is the only door in | -| 19 | Coherence acceptance tests | **Missing (culture ready)** | Superb per-arc acceptance discipline; zero cross-subsystem journey tests | +| 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` exists (steps 2, 3, 5); the other five scenarios are still unwritten | Three cross-cutting patterns explain most of the table; they are detailed in §1.1–§1.3: **substrate without surface**, **the silence @@ -120,7 +120,7 @@ asymmetry**, and **per-arc coherence debt**. Coherence-shaped work already in flight at audit time: find-file / dired Stage 0 (`C-x C-f`, merged #162, `docs/dired-framing.md`) and its -Stage 1 directory view (PR #165), bottom panel Stage 1 (merged #155), +Stage 1 directory view (merged #165), bottom panel Stage 1 (merged #155), multi-root LSP affinity (merged #161), the config registry foundation (merged #127). @@ -195,7 +195,7 @@ working, unreachable capability: time; a complete 1,384-line dired existed only as a frozen test fixture (`tests/fixtures/pmacs-dired/init.lua`). **Fixed:** dired Stage 0 opens a path (`C-x C-f`, merged #162) and Stage 1 ships the - browsing view as a builtin (`C-x d` / `C-x C-j`, PR #165). The fixture + browsing view as a builtin (`C-x d` / `C-x C-j`, merged #165). The fixture stays frozen — its `install_local` + `require` routing *is* the M8 package-universality proof (Q#DR1) — and shrinking it is scheduled after Stage 3. @@ -339,7 +339,8 @@ the journey. ### Ground truth: the journey today -**Grade: broken at step 3.** Verified empirically at audit time: +**Grade: reaches step 5; thin from step 6 on.** Was **broken at step 3** +at audit time: ``` $ ./target/release/pmacs . @@ -347,15 +348,23 @@ pmacs: Is a directory (os error 21) EXIT=1 ``` -The literal first arrow of the diagram above fails. `load_file` +The literal first arrow of the diagram above failed. `load_file` (`src/file_io.rs:81-87`) does `File::open` (succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so -`EditorState::open` returns `Err` and `main` prints and exits -(`src/main.rs:411-414`). Multiple file arguments are also rejected -(`"multiple files not yet supported"`, `src/main.rs:227`). Everything -from step 6 onward is gated on a file being open, and the only -zero-config way to open one is naming it on the command line — which -requires already knowing the path. +`EditorState::open` returned `Err` and `main` printed and exited. + +**Journey Stage 1a fixed that arrow** (`docs/journey-stage1a-framing.md`). +`resolve_target_buffer` now answers `ResolvedTarget::Directory` *ahead* +of the load, `pmacs .` lists the directory in dired, `RET` visits a +file, and a self-insert lands in it — steps 3 and 5 run end to end, +pinned by `tests/journey_acceptance.rs`. Which surface opens a directory +is a `path.open-directory` chain with dired as a replaceable fallback, +so this did not grow a second directory surface. + +Still true: multiple file arguments are rejected (`"multiple files not +yet supported"`, `src/main.rs:227`), and everything from step 6 onward +is gated on a file being open — but the zero-config way to open one is +no longer "already know the path". Full verdict table: @@ -363,12 +372,12 @@ Full verdict table: |---|---|---|---| | 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime | | 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on | -| 3 | Open real project | **Missing at the CLI** | `pmacs .` still exits 1 (above): `load_file` does `File::open` (which succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s create-a-`[new file]` arm never fires. Dired Stage 1 (PR #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface | +| 3 | Open real project | **Works at the CLI** | Journey Stage 1a: `resolve_target_buffer` answers `ResolvedTarget::Directory` before the EISDIR-producing load, and `EditorState::open` / the daemon bootstrap dispatch the `path.open-directory` chain, whose fallback is dired (#165's buffer, reached rather than duplicated). Startup no longer fails: an unreadable directory, a crashed resolver, and a cleared handler all report on the status line and leave the session running. Because the listing is async and the bootstrap is synchronous, the commit runs against a destination captured at request time (`pmacs.window.commit_to`) rather than against the ambient frontend | | 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) | | 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config | | 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | -| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing PR #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.* | +| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI | +| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and there is still **no close/kill command**, which is the remaining half of this step's discoverability gap. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* | | 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to 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 +388,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 @@ -460,7 +476,7 @@ level is the one missing. Audited level-by-level: **Beginner** (should see: files, buffers, search, diagnostics, terminal, build actions, menus, missing-tool guidance): -- files ✓ since #162 / PR #165 (`C-x C-f` opens a path, `C-x d` browses; +- files ✓ since #162 / #165 (`C-x C-f` opens a path, `C-x d` browses; neither is advertised anywhere but the keymap) · buffers ✓ (`C-x b`, `*buffer-list*`) · search ✓ (`C-s`/`C-r`/`C-M-s`; project.search is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but @@ -639,7 +655,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 +663,29 @@ 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. +- **A worked example that a modal-*looking* feature need not become a + shadow.** Terminal copy mode (Stage 2 of the terminal-config arc) is + the case that most invited a seventh rung: it wants motion, search and + its own `g`/`q` inside a surface where every unescaped key otherwise + goes to a child process. It resolves to the buffer-local keymap idiom + instead, by **materializing** the retained scrollback into an ordinary + read-only document buffer. The keys-must-not-reach-the-child problem + then dissolves structurally rather than being guarded: the transport + arm keys on `is_terminal(buffer_id)`, and a snapshot buffer is not a + terminal, so the arm never fires. No new precedence rung, no new + hand-synced guard-list entry, and `describe-key` keeps reporting the + truth — pinned by asserting exactly that for the snapshot's `g` and + `q`, which is the observable difference between the idiom and a + shadow. **The count stays at six.** + + The transferable rule: when a feature wants a keymap over *content*, + ask whether the content can become a buffer. The shadows that exist + are the cases where it genuinely cannot (a minibuffer prompt, a + live search prompt) — not the cases where nobody tried. - **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 +1050,45 @@ 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 nine 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), `terminal.default-profile` + + `terminal.scrollback-rows` + `terminal.escape-key` (terminal.lua, + #173), and `lean.abbrev` (lean_input.lua, Arc 8 Stage 4b) — a + `live` boolean read against the typed edit's SOURCE buffer, the + `editing.auto-pair` shape including its correction to resolve + `rec.buffer` rather than the active buffer. 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. +- **The missing `scope = "global"` flag has its second live case.** After + `autosave.interval-ms`, the terminal's two *open-time* settings — + `terminal.default-profile` and `terminal.scrollback-rows` — are read + before their terminal's identity buffer exists, so a buffer-local + override can never be consulted. The registry accepts `set_local` on + them anyway, because `Live` mutability is all it can express. Nothing + breaks; the setting simply has no effect, which is the worst shape a + configuration surface can take. `terminal.escape-key` is the contrast + that shows this is a real distinction rather than a blanket wish: it + *deliberately* supports buffer-locals, and per-terminal escapes are a + feature. So the argument for both deferrals is now **cumulative and + concrete** rather than hypothetical — two adopters, two distinct + missing primitives, one feature. - **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 nine 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. @@ -1164,7 +1226,31 @@ Primitive-by-primitive against the list above: rebindable (§6's counter-example). - **Output channel** ✓ — the compile-mode `*compilation*` model (streamed, intercept-read-only, error-rule parsing), reused by grep - and shell-command. + and shell-command. **Caveat found in terminal copy mode's review + (Stage 2): "intercept-read-only" is not read-only.** `Buffer::undo` + reaches the rope through `ensure_writable` without consulting the + intercept chain, so `M-x buffer.undo` empties such a buffer — and + rebinding the undo *chords* buffer-locally does not close it, as + `compile.lua`'s own comment admits ("command/menu undo stays + dispatchable"). `Buffer::set_generated_contents` (write + discard + history + assert `read_only`, in one authorized call) now fixes this + for the terminal snapshot; **four writer mechanisms have not yet adopted + it and remain emptiable** — listview panels; `compile.lua`'s + `ensure_slot`, which serves `*compilation*` **and** `*shell-command*`; + the independent `*search-results*` panel in + `builtin/commands/default.lua`; and dired buffers. All pair an erroring + intercept with `bypass_intercept` writes over a still-writable rope. + (`*workers*`, `*help*` and `*buffer-list*` are generated but do not use + this idiom.) **A second half of the same + caveat, found in round 3: a rope write is only half of an edit.** The + owner-authorized write must be fanned out to the windows showing the + buffer and queued for replica mirrors, or the displaying window keeps + a line index describing the previous contents and the next paint + indexes the new rope with stale ranges. Adoption is therefore not a + one-line swap — and the three appending buffers (`*compilation*`, + `*shell-command*`, `*search-results*`) need a streaming variant of the + primitive that does not exist yet. Listview and dired already write + whole-buffer replaces and are the cheap half. - **Diagnostics collection** ✓ — `DiagnosticStore` + signs + unified `error.next` source. - **Transient selector** ✓ — the minibuffer (though its `source` @@ -1183,7 +1269,7 @@ Primitive-by-primitive against the list above: hierarchy, package dependency graph, worker trees, git status) will each need it; building it once *before* dired's directory view and the workers tree harden their own conventions is exactly this - section's point. Dired Stage 1 (PR #165) landed **without** inventing + section's point. Dired Stage 1 (merged #165) landed **without** inventing one: its listing is flat (Emacs parity), and the recursive in-buffer case — `i` insert-subdirectory — is a named deferral in `docs/dired-framing.md` §13, which is where a shared tree primitive @@ -1254,9 +1340,13 @@ facto privileged implementation. **Grade: strong — the healthiest concern in this document, and most of its asks are already practiced.** -- Versioned, negotiated protocol `SUPPORTED=[6..=20]` with deliberate +- Versioned protocol schema `SUPPORTED=[6..=21]` with deliberate encoding-breaking bumps, both-frontends support required per bump, - and byte-pin discipline for appended variants (handoff §4). + and byte-pin discipline for appended variants (handoff §4). The v21 + bottom-panel family is reserved but dark in Stage 2B-1: because + `Hello` is server-first, the production daemon still advertises v20 + so shipped v20 clients remain attachable; compatible v21 activation + belongs to Stage 2B-3. - Two genuine frontends share the conceptual model; CRDT concurrent editing with presence across them; remote attach + reconnect. - **Graceful per-frontend degradation is practiced, not aspirational**: @@ -1347,7 +1437,8 @@ greets a new user says nothing (`EditorCore::new` sets an empty status). Note the dependency: five of the ten onboarding steps above currently -lead somewhere broken or invisible (find a file — in flight; inspect a +lead somewhere broken or invisible (find a file — the mechanism is fixed +since #162/#165 but is advertised nowhere except the keymap; inspect a diagnostic — silent-failure risk; view workers — undiscoverable; setting provenance — unanswerable). Onboarding is correctly sequenced *after* the P1/P4 fixes, but the cheap floor — a welcome buffer in @@ -1377,20 +1468,24 @@ subsystems, complementing (not replacing) subsystem tests: ### Ground truth -**Grade: missing — but the culture that would make them excellent is the -project's strongest process asset.** +**Grade: started — the first suite exists; five of the six scenarios +above do not.** -Zero cross-subsystem journey tests exist. Every acceptance suite in the -tree pins one subsystem's contract (superbly — bite-verified, -falsified-by-revert, vacuity-checked). Several of the scenarios above -are currently *untestable* because the behavior doesn't exist (install -in-session, disable, open a directory); the ones that are testable -(first launch, command discovery, worker cancellation, remote -attach/reconnect) could be written today and would immediately pin the -journey against regression. The first coherence acceptance suite should -be the §2 journey itself, growing a step at a time as steps become -real — that is how "the journey is a release gate" stops being -aspirational. +At audit time zero cross-subsystem journey tests existed. **Journey +Stage 1a created `tests/journey_acceptance.rs`**, the §2 journey itself, +seeded with steps 2 (launch unconfigured), 3 (open a real project), and +5 (edit immediately), and declared a ratchet: stages add rows, none +removes them. That is the "first launch" scenario, partially — missing +tools still have no actionable guidance to assert. + +The rest is unchanged. Every other acceptance suite in the tree pins one +subsystem's contract (superbly — bite-verified, falsified-by-revert, +vacuity-checked). Command discovery, workspace lifecycle, worker +ownership, package lifecycle, and remote execution have no +cross-subsystem suite; several remain *untestable* because the behavior +doesn't exist (install in-session, disable). Steps 6–12 join +`journey_acceptance.rs` as later stages make them real — that is how +"the journey is a release gate" stops being aspirational. (Related lesson already in the handoff: `compile_mode_acceptance` accidentally reads the real user config — an *unintentional* @@ -1408,14 +1503,18 @@ missing runtime entity — a real arc). ### Priority 1: Protect the golden product journey Establish the end-to-end workflow; treat regressions as release -blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually -cheap:** directory-argument handling (the remaining half of step 3 — -dired Stage 1 landed the buffer it should resolve to); a find-file -surface (**done**: #162 open-by-path, PR #165 browsing); surfacing the -LSP spawn failure with guidance (§1.2); a +blockers. **State: runs to step 5; thin from step 6 (§2). Mostly wiring, +and unusually cheap:** directory-argument handling (**done**: Journey +Stage 1a); a find-file surface (**done**: #162 open-by-path, #165 +browsing); surfacing the LSP spawn failure with guidance (§1.2); a compile keybinding + `cargo build`/`test` default from the existing -`ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The -journey acceptance suite (§19) is the ratchet that keeps it fixed. +`ProjectKind::Cargo`; a terminal keybinding (**done**: `C-c t`, #173); a +welcome buffer. The journey acceptance suite (§19) is the ratchet that +keeps it fixed — it **exists now** (`tests/journey_acceptance.rs`, +Stage 1a), seeded with steps 2, 3, and 5. + +Journey Stage 1b is the named remainder: the compile binding + Cargo +defaults, LSP spawn guidance, and the welcome buffer. ### Priority 2: Make workspace and location explicit @@ -1479,9 +1578,13 @@ Candidate arc cuts, honoring one-feature-one-branch-one-PR and the framing workflow (each needs its own scout + framing before any implementation — this list is direction, not commitment): -1. **Journey Stage 1** (P1): directory open + compile defaults + - LSP-failure surfacing + bindings + welcome buffer + the first - journey acceptance suite. Rides alongside the in-flight dired arc. +1. **Journey Stage 1** (P1): split at the new-Rust-primitive line. + **Stage 1a — landed**: directory open, the `EditorState::open` → + `resolve_target_buffer` unification, the destination-scope substrate, + and the first journey acceptance suite. It routes `pmacs .` into + #165's dired buffer rather than growing a second directory surface. + **Stage 1b — remaining**: compile defaults, LSP-failure surfacing, + bindings, welcome buffer. 2. **Discovery surface** (P4): the describe/list/where-is command family, M-x rich rows, help unification, help prefix. 3. **Transient keymap layer** (§6): the overlay scope + lifetime diff --git a/builtin/hooks/default.lua b/builtin/hooks/default.lua index 7fe3a0a..4fabfe9 100644 --- a/builtin/hooks/default.lua +++ b/builtin/hooks/default.lua @@ -61,6 +61,21 @@ define { kind = "all-must-succeed", } +define { + name = "path.open-directory", + description = "Fired when a directory path is opened (Journey Stage 1a). " .. + "Receives the canonical absolute path and an opaque " .. + "destination. Return false to CLAIM the directory and stop " .. + "the fan-out; return nothing to decline. No builtin " .. + "subscribes -- because hook callbacks only ever append, a " .. + "subscribing builtin would always claim before any user " .. + "listener could run, so this hook is the user's chain and " .. + "pmacs.path.directory_handler is the default surface it " .. + "falls back to. A callback that RAISES stops the chain and " .. + "suppresses that fallback.", + kind = "short-circuit", +} + define { name = "editor.before-quit", description = "Fired before the editor exits. Return false to veto.", diff --git a/builtin/runtime/async.lua b/builtin/runtime/async.lua index 94555c7..af74cc1 100644 --- a/builtin/runtime/async.lua +++ b/builtin/runtime/async.lua @@ -77,6 +77,17 @@ end -- inside a coroutine spawned by pmacs.async --- a bare call from main -- thread will raise on the first yield. function Handle:await() + -- Journey Stage 1a (Q#JR14b): `pmacs.window.commit_to` scopes the + -- acting frontend for the dynamic extent of its callback, using an + -- RAII guard on the Rust stack. Yielding out of that extent would + -- restore the scope while this coroutine is still parked, so the rest + -- of the commit would resume ambient -- silently reintroducing the + -- misrouting the scope exists to prevent. Do the awaiting BEFORE + -- entering the commit, which is what dired does with its listing. + if async_mod._in_commit_scope() then + error("await: cannot await inside pmacs.window.commit_to; " .. + "await first, then commit") + end if not async_mod._is_complete(self._id) then -- Yield self so pmacs.async's step() can park us. R46 carve-out: -- this `coroutine.yield` is runtime code; package code uses diff --git a/builtin/runtime/dired.lua b/builtin/runtime/dired.lua index 9c6bc92..c8054fe 100644 --- a/builtin/runtime/dired.lua +++ b/builtin/runtime/dired.lua @@ -571,7 +571,15 @@ end -- deliberately so (Q#DR10): the next directory is the same kind of -- thing as the current one and belongs in the same slot, while a file -- is not a dired buffer and belongs in the document area. -local function display(handle, opts, departed) +-- +-- `captured` (Journey Stage 1a, Q#JR14) is the destination window a +-- background open must land in. It is NOT the same as "wherever the +-- scoped frontend is looking now": the scope fixes the *frontend*, and +-- within one frontend the selected window can still have moved to +-- another split while the listing was in flight. The preflight cannot +-- catch that -- the captured window is still live and still holds its +-- captured buffer -- so honoring it is this function's job. +local function display(handle, opts, departed, captured) local side = nil if departed ~= nil then -- Dired's own window, not the request's: walking a tree in a side @@ -587,6 +595,11 @@ local function display(handle, opts, departed) -- both the substrate's documented policy and Emacs's, so dired does -- not try to unpin the user's panel. pmacs.window.display(handle.buf, { side = side, select = true }) + elseif captured ~= nil then + -- `select = true` because the rest of the commit -- seat_cursor via + -- `pmacs.editor.move_to_line` -- acts on the frontend's ACTIVE + -- window, so the seat would land in the wrong window otherwise. + pmacs.window.display(handle.buf, { window = captured, select = true }) else pmacs.window.switch_buffer(handle.buf) end @@ -598,7 +611,7 @@ end pmacs.dired = pmacs.dired or {} -local OPEN_OPTS = { display = true, select_name = true } +local OPEN_OPTS = { display = true, select_name = true, dest = true } -- Open `path`'s dired buffer, replacing `departed` (a handle) in the -- window it occupies when this is a navigation rather than a fresh @@ -629,36 +642,76 @@ local function open_directory(path, opts, departed) local sort_mode = (handle_for_path(canonical) or {}).sort_mode or SORT_MODES[1] local entries, errors = read_listing(canonical, sort_mode) - local handle = claim_handle(canonical) - handle.entries = entries - handle.errors = errors - handle.sort_mode = sort_mode + -- Everything from here down MUTATES: it claims or finds a handle, + -- creates a buffer, reads the ambient buffer for `prev`, and paints. + -- None of it is undoable, and none of it may run against a + -- destination that has gone away -- so when the caller captured one + -- (Journey Stage 1a, Q#JR14), the whole commit runs inside + -- `pmacs.window.commit_to`, which validates the destination BEFORE + -- invoking this and scopes the acting frontend for its extent. + -- + -- Note the await above is deliberately OUTSIDE the commit: awaiting + -- inside it is refused (Q#JR14b), because a yield would restore the + -- scope while this coroutine is still parked. + local function commit() + -- The captured window, read once. Everything below that would + -- otherwise consult "the active window" must consult THIS instead: + -- the scope pins the frontend, not the selected window, and a split + -- or panel can take focus within that frontend while the listing is + -- in flight (Q#JR14). + local captured = opts.dest ~= nil and opts.dest:window() or nil - -- `q` returns to the buffer you came from, never to another dired - -- buffer (which would trap `q` walking back down the tree); on a - -- descent the arriving buffer inherits the departing one's origin. - if departed ~= nil then - handle.prev = departed.prev - else - local active = pmacs.window.buffer() - if active ~= nil and handle_for_buffer(active) == nil then - handle.prev = active + local handle = claim_handle(canonical) + handle.entries = entries + handle.errors = errors + handle.sort_mode = sort_mode + + -- `q` returns to the buffer you came from, never to another dired + -- buffer (which would trap `q` walking back down the tree); on a + -- descent the arriving buffer inherits the departing one's origin. + if departed ~= nil then + handle.prev = departed.prev + else + local active + if captured ~= nil then + active = pmacs.window.buffer(captured) + else + active = pmacs.window.buffer() + end + if active ~= nil and handle_for_buffer(active) == nil then + handle.prev = active + end end + + paint(handle) + display(handle, opts, departed, captured) + -- Seating happens after the display: `switch_buffer` zeroes the + -- window cursor, so an earlier seat would be discarded. + seat_cursor(handle, opts.select_name, 1) + kill_departed(departed, handle) + return handle.buf end - paint(handle) - display(handle, opts, departed) - -- Seating happens after the display: `switch_buffer` zeroes the - -- window cursor, so an earlier seat would be discarded. - seat_cursor(handle, opts.select_name, 1) - kill_departed(departed, handle) - return handle.buf + if opts.dest == nil then + -- Interactive path (`C-x d`, tree descent, refresh): the acting + -- frontend is still ambient a tick later, which is what dired has + -- always relied on. Migrating these onto a captured destination too + -- is a named deferral, not this stage's work. + return commit() + end + + local ok, result = pmacs.window.commit_to(opts.dest, commit) + if not ok then + error(string.format("destination is gone (%s)", tostring(result))) + end + return result end function pmacs.dired.open(path, opts) return open_directory(path, opts, nil) end + -- Every interactive entry point funnels through here: spawn the -- coroutine the await needs, and turn a failure into a status message -- rather than an uncaught raise inside `pmacs.async` (which would land @@ -670,6 +723,20 @@ local function open_async(path, opts, departed, where) end) end +-- Journey Stage 1a (Q#JR7): dired is the DEFAULT directory surface, not +-- a `path.open-directory` subscriber. +-- +-- It cannot be a subscriber and still be replaceable. `HookRegistry.add` +-- only appends, and builtins load before `init.lua`, so a dired +-- subscription would always run first and always claim -- no user +-- listener could ever win. The hook is therefore the user's chain and +-- this slot is the fallback the editor consults when that chain +-- declines. Replace it to change what opens a directory; set it to nil +-- to disable directory opening entirely. +pmacs.path.set_directory_handler(function(path, dest) + open_async(path, { dest = dest }, nil, "dired") +end) + -- --------------------------------------------------------------------------- -- Commands -- --------------------------------------------------------------------------- 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/lean_abbrev.lua b/builtin/runtime/lean_abbrev.lua new file mode 100644 index 0000000..769c9d4 --- /dev/null +++ b/builtin/runtime/lean_abbrev.lua @@ -0,0 +1,1883 @@ +-- lean_abbrev.lua --- VENDORED DATA. Do not edit by hand. +-- +-- The Lean 4 abbreviation table, generated from: +-- +-- repo: https://github.com/leanprover/vscode-lean4 +-- path: lean4-unicode-input/src/abbreviations.json +-- commit: 17d1d08 +-- license: Apache-2.0 +-- entries: 1855 (26 carry $CURSOR) +-- source: 36861 bytes +-- +-- Regenerate with: +-- +-- scripts/regen-lean-abbrev 17d1d08 +-- +-- An ORDERED SEQUENCE, not a map: upstream resolves equal-length ties +-- by source declaration order (101 prefixes depend on it), and a +-- `pairs`-iterated Lua map cannot express that. The file's own line +-- order is the audit trail. Consumers must not reorder it. +-- +-- Not fetched at runtime and not a package dependency: the input method +-- has to work offline and on first launch. Upkeep is a documented +-- manual process — see docs/lean4-mode-framing.md Q#LN11. + +pmacs = pmacs or {} + +pmacs.lean_abbrev = { + { "{}", "{$CURSOR}" }, + { "{}_", "{$CURSOR}_" }, + { "{{}}", "⦃$CURSOR⦄" }, + { "[]", "[$CURSOR]" }, + { "[]_", "[$CURSOR]_" }, + { "[[]]", "⟦$CURSOR⟧" }, + { "<>", "⟨$CURSOR⟩" }, + { "()", "($CURSOR)" }, + { "()_", "($CURSOR)_" }, + { "^()", "⁽$CURSOR⁾" }, + { "_()", "₍$CURSOR₎" }, + { "([])'", "⟮$CURSOR⟯" }, + { "(())", "⸨$CURSOR⸩" }, + { "f<>", "‹$CURSOR›" }, + { "f<<>>", "«$CURSOR»" }, + { "h<>", "❰$CURSOR❱" }, + { "[--]", "⁅$CURSOR⁆" }, + { "||||", "‖$CURSOR‖" }, + { "nnnorm", "‖$CURSOR‖₊" }, + { "norm", "‖$CURSOR‖" }, + { "floor", "⌊$CURSOR⌋" }, + { "ceil", "⌈$CURSOR⌉" }, + { "nfloor", "⌊$CURSOR⌋₊" }, + { "nceil", "⌈$CURSOR⌉₊" }, + { "s[]", "⦋$CURSOR⦌" }, + { "simplex", "⦋$CURSOR⦌" }, + { "\\", "\\" }, + { "a", "α" }, + { "b", "β" }, + { "c", "χ" }, + { "d", "↓" }, + { "e", "ε" }, + { "g", "γ" }, + { "i", "∩" }, + { "m", "μ" }, + { "n", "\\n" }, + { "o", "∘" }, + { "p", "Π" }, + { "t", "▸" }, + { "r", "→" }, + { "u", "↑" }, + { "v", "∨" }, + { "x", "×" }, + { "-", "⁻¹" }, + { "~", "∼" }, + { ".", "·" }, + { "*", "⋆" }, + { "!", "¬" }, + { "?", "¿" }, + { "1", "₁" }, + { "2", "₂" }, + { "3", "₃" }, + { "4", "₄" }, + { "5", "₅" }, + { "6", "₆" }, + { "7", "₇" }, + { "8", "₈" }, + { "9", "₉" }, + { "0", "₀" }, + { "l", "←" }, + { "<", "⟨" }, + { ">", "⟩" }, + { "O", "Ø" }, + { "&", "⅋" }, + { "A", "𝔸" }, + { "C", "ℂ" }, + { "D", "Δ" }, + { "F", "𝔽" }, + { "G", "Γ" }, + { "H", "ℍ" }, + { "I", "⋂" }, + { "I0", "⋂₀" }, + { "K", "𝕂" }, + { "L", "Λ" }, + { "N", "ℕ" }, + { "P", "Π" }, + { "Q", "ℚ" }, + { "R", "ℝ" }, + { "S", "Σ" }, + { "U", "⋃" }, + { "U0", "⋃₀" }, + { "Z", "ℤ" }, + { "#", "♯" }, + { ":", "∶" }, + { "|", "∣" }, + { "rw", "▸" }, + { "coe", "↑" }, + { "be", "β" }, + { "ga", "γ" }, + { "de", "δ" }, + { "ep", "ε" }, + { "ze", "ζ" }, + { "et", "η" }, + { "th", "θ" }, + { "io", "ι" }, + { "ka", "κ" }, + { "la", "λ" }, + { "mu", "μ" }, + { "nu", "ν" }, + { "xi", "ξ" }, + { "pi", "π" }, + { "rh", "ρ" }, + { "vsi", "ς" }, + { "si", "σ" }, + { "ta", "τ" }, + { "ph", "φ" }, + { "ch", "χ" }, + { "ps", "ψ" }, + { "om", "ω" }, + { "`A", "À" }, + { "'A", "Á" }, + { "^{A}", "Â" }, + { "~A", "Ã" }, + { "\"A", "Ä" }, + { "-{A}", "Ā" }, + { "cC", "Ç" }, + { "`E", "È" }, + { "'E", "É" }, + { "^{E}", "Ê" }, + { "\"E", "Ë" }, + { "-{E}", "Ē" }, + { "`I", "Ì" }, + { "'I", "Í" }, + { "^{I}", "Î" }, + { "\"I", "Ï" }, + { "-{I}", "Ī" }, + { "~N", "Ñ" }, + { "`O", "Ò" }, + { "'O", "Ó" }, + { "^{O}", "Ô" }, + { "~O", "Õ" }, + { "\"O", "Ö" }, + { "/O", "Ø" }, + { "-{O}", "Ō" }, + { "`U", "Ù" }, + { "'U", "Ú" }, + { "^{U}", "Û" }, + { "\"U", "Ü" }, + { "-{U}", "Ū" }, + { "'Y", "Ý" }, + { "`a", "à" }, + { "'a", "á" }, + { "^{a}", "â" }, + { "~a", "ã" }, + { "\"a", "ä" }, + { "-{a}", "ā" }, + { "cc", "ç" }, + { "`e", "è" }, + { "'e", "é" }, + { "^{e}", "ê" }, + { "\"e", "ë" }, + { "-{e}", "ē" }, + { "`i", "ì" }, + { "'i", "í" }, + { "^{i}", "î" }, + { "\"i", "ï" }, + { "-{i}", "ī" }, + { "~{n}", "ñ" }, + { "`o", "ò" }, + { "'o", "ó" }, + { "^{o}", "ô" }, + { "~o", "õ" }, + { "\"o", "ö" }, + { "/o", "ø" }, + { "-{o}", "ō" }, + { "`u", "ù" }, + { "'u", "ú" }, + { "^{u}", "û" }, + { "\"u", "ü" }, + { "-{u}", "ū" }, + { "'y", "ý" }, + { "\"y", "ÿ" }, + { "/L", "Ł" }, + { "note", "♩" }, + { "not", "¬" }, + { "notin", "∉" }, + { "notlt", "≮" }, + { "nomisma", "𐆎" }, + { "nin", "∉" }, + { "nni", "∌" }, + { "ni", "∋" }, + { "nattrans", "⟹" }, + { "nat_trans", "⟹" }, + { "natural", "♮" }, + { "nat", "ℕ" }, + { "naira", "₦" }, + { "nabla", "∇" }, + { "napprox", "≉" }, + { "numero", "№" }, + { "nLeftarrow", "⇍" }, + { "nLeftrightarrow", "⇎" }, + { "nRightarrow", "⇏" }, + { "nVDash", "⊯" }, + { "nVdash", "⊮" }, + { "ncong", "≇" }, + { "nearrow", "↗" }, + { "neg", "¬" }, + { "nequiv", "≢" }, + { "neq", "≠" }, + { "nexists", "∄" }, + { "ne", "≠" }, + { "ngeqq", "≱" }, + { "ngeqslant", "≱" }, + { "ngeq", "≱" }, + { "ngtr", "≯" }, + { "nleftarrow", "↚" }, + { "nleftrightarrow", "↮" }, + { "nleqq", "≰" }, + { "nleqslant", "≰" }, + { "nleq", "≰" }, + { "nless", "≮" }, + { "nmid", "∤" }, + { "nparallel", "∦" }, + { "npreceq", "⋠" }, + { "nprec", "⊀" }, + { "nrightarrow", "↛" }, + { "nshortmid", "∤" }, + { "nsimeq", "≄" }, + { "nsim", "≁" }, + { "nsubseteqq", "⊈" }, + { "nsubseteq", "⊈" }, + { "nsubset", "⊄" }, + { "nsucceq", "⋡" }, + { "nsucc", "⊁" }, + { "nsupseteqq", "⊉" }, + { "nsupseteq", "⊉" }, + { "nsupset", "⊅" }, + { "ntrianglelefteq", "⋬" }, + { "ntriangleleft", "⋪" }, + { "ntrianglerighteq", "⋭" }, + { "ntriangleright", "⋫" }, + { "nvDash", "⊭" }, + { "nvdash", "⊬" }, + { "nwarrow", "↖" }, + { "eqn", "≠" }, + { "equiv", "≃" }, + { "eqcirc", "≖" }, + { "eqcolon", "≕" }, + { "eqslantgtr", "⋝" }, + { "eqslantless", "⋜" }, + { "entails", "⊢" }, + { "en", "–" }, + { "exn", "∄" }, + { "exists", "∃" }, + { "ex", "∃" }, + { "emptyset", "∅" }, + { "empty", "∅" }, + { "em", "—" }, + { "epsilon", "ε" }, + { "eps", "ε" }, + { "euro", "€" }, + { "eta", "η" }, + { "ell", "ℓ" }, + { "iso", "≅" }, + { "in", "∈" }, + { "inn", "∉" }, + { "inter", "∩" }, + { "intercal", "⊺" }, + { "intersection", "∩" }, + { "integral", "∫" }, + { "integral-", "⨍" }, + { "int", "ℤ" }, + { "inv", "⁻¹" }, + { "increment", "∆" }, + { "inf", "⊓" }, + { "infi", "⨅" }, + { "infty", "∞" }, + { "iff", "↔" }, + { "imp", "→" }, + { "imath", "ı" }, + { "iota", "ι" }, + { "=n", "≠" }, + { "==n", "≢" }, + { "===", "≣" }, + { "==>", "⟹" }, + { "==", "≡" }, + { "=:", "≕" }, + { "=o", "≗" }, + { "=>n", "⇏" }, + { "=>", "⇒" }, + { "~n", "≁" }, + { "~~n", "≉" }, + { "~~~", "≋" }, + { "~~-", "≊" }, + { "~~", "≈" }, + { "~-n", "≄" }, + { "~-", "≃" }, + { "~=n", "≇" }, + { "~=", "≅" }, + { "homotopy", "∼" }, + { "hom", "⟶" }, + { "hori", "ϩ" }, + { "hookleftarrow", "↩" }, + { "hookrightarrow", "↪" }, + { "hryvnia", "₴" }, + { "heta", "ͱ" }, + { "heartsuit", "♥" }, + { "hbar", "ℏ" }, + { ":~", "∻" }, + { ":=", "≔" }, + { "::-", "∺" }, + { "::", "∷" }, + { "-~", "≂" }, + { "-|", "⊣" }, + { "-1", "⁻¹" }, + { "^-1", "⁻¹" }, + { "-2", "⁻²" }, + { "-3", "⁻³" }, + { "-:", "∹" }, + { "->n", "↛" }, + { "->", "→" }, + { "-->", "⟶" }, + { "---", "─" }, + { "--=", "═" }, + { "--_", "━" }, + { "--.", "╌" }, + { "-o", "⊸" }, + { ".=.", "≑" }, + { ".=", "≐" }, + { ".+", "∔" }, + { ".-", "∸" }, + { "...", "⋯" }, + { "(=", "≘" }, + { "(b", "⟅" }, + { "and=", "≙" }, + { "and", "∧" }, + { "an", "∧" }, + { "angle", "∠" }, + { "rightangle", "∟" }, + { "angstrom", "Å" }, + { "all", "∀" }, + { "allf", "∀ᶠ" }, + { "all^f", "∀ᶠ" }, + { "allm", "∀ᵐ" }, + { "all^m", "∀ᵐ" }, + { "alpha", "α" }, + { "aleph", "ℵ" }, + { "aleph0", "ℵ₀" }, + { "asterisk", "⁎" }, + { "ast", "∗" }, + { "asymp", "≍" }, + { "apl", "⌶" }, + { "approxeq", "≊" }, + { "approx", "≈" }, + { "aa", "å" }, + { "ae", "æ" }, + { "austral", "₳" }, + { "amalg", "∐" }, + { "average", "⨍" }, + { "-int", "⨍" }, + { "or=", "≚" }, + { "ordfeminine", "ª" }, + { "ordmasculine", "º" }, + { "or", "∨" }, + { "oplus", "⊕" }, + { "od", "ᵒᵈ" }, + { "orderdual", "ᵒᵈ" }, + { "addopposite", "ᵃᵒᵖ" }, + { "aop", "ᵃᵒᵖ" }, + { "mulopposite", "ᵐᵒᵖ" }, + { "mop", "ᵐᵒᵖ" }, + { "opposite", "ᵒᵖ" }, + { "op", "ᵒᵖ" }, + { "o+", "⊕" }, + { "o--", "⊖" }, + { "o-", "⊝" }, + { "ox", "⊗" }, + { "o/", "⊘" }, + { "o.", "⊙" }, + { "oo", "⊚" }, + { "o*", "∘*" }, + { "o=", "⊜" }, + { "oe", "œ" }, + { "octagonal", "🛑" }, + { "ohm", "Ω" }, + { "ounce", "℥" }, + { "omega", "ω" }, + { "omicron", "ο" }, + { "ominus", "⊖" }, + { "odot", "⊙" }, + { "oint", "∮" }, + { "oiint", "∯" }, + { "oslash", "⊘" }, + { "otimes", "⊗" }, + { "tensorproduct", "⊗" }, + { "pitensorproduct", "⨂" }, + { "tensorpower", "⨂" }, + { "pd", "∂" }, + { "*=", "≛" }, + { "t=", "≜" }, + { "tint", "∯" }, + { "transport", "▹" }, + { "trans", "▹" }, + { "triangledown", "▿" }, + { "trianglelefteq", "⊴" }, + { "triangleleft", "◃" }, + { "triangleq", "≜" }, + { "trianglerighteq", "⊵" }, + { "triangleright", "▹" }, + { "triangle", "▵" }, + { "tr", "⬝" }, + { "tb", "◂" }, + { "twoheadleftarrow", "↞" }, + { "twoheadrightarrow", "↠" }, + { "tw", "◃" }, + { "tie", "⁀" }, + { "times", "×" }, + { "theta", "θ" }, + { "therefore", "∴" }, + { "thickapprox", "≈" }, + { "thicksim", "∼" }, + { "telephone", "℡" }, + { "tenge", "₸" }, + { "textmusicalnote", "♪" }, + { "textmu", "µ" }, + { "textfractionsolidus", "⁄" }, + { "textbaht", "฿" }, + { "textdied", "✝" }, + { "textdiscount", "⁒" }, + { "textcolonmonetary", "₡" }, + { "textcircledP", "℗" }, + { "textwon", "₩" }, + { "textnaira", "₦" }, + { "textnumero", "№" }, + { "textpeso", "₱" }, + { "textpertenthousand", "‱" }, + { "textlira", "₤" }, + { "textlquill", "⁅" }, + { "textrecipe", "℞" }, + { "textreferencemark", "※" }, + { "textrquill", "⁆" }, + { "textinterrobang", "‽" }, + { "textestimated", "℮" }, + { "textopenbullet", "◦" }, + { "tugrik", "₮" }, + { "tau", "τ" }, + { "top", "⊤" }, + { "to", "→" }, + { "to0", "→₀" }, + { "r0", "→₀" }, + { "to_0", "→₀" }, + { "r_0", "→₀" }, + { "finsupp", "→₀" }, + { "to1", "→₁" }, + { "r1", "→₁" }, + { "to_1", "→₁" }, + { "r_1", "→₁" }, + { "l1", "→₁" }, + { "to1s", "→₁ₛ" }, + { "r1s", "→₁ₛ" }, + { "to_1s", "→₁ₛ" }, + { "r_1s", "→₁ₛ" }, + { "l1simplefunc", "→₁ₛ" }, + { "toa", "→ₐ" }, + { "ra", "→ₐ" }, + { "to_a", "→ₐ" }, + { "r_a", "→ₐ" }, + { "alghom", "→ₐ" }, + { "tob", "→ᵇ" }, + { "rb", "→ᵇ" }, + { "to^b", "→ᵇ" }, + { "r^b", "→ᵇ" }, + { "boundedcontinuousfunction", "→ᵇ" }, + { "tol", "→ₗ" }, + { "rl", "→ₗ" }, + { "to_l", "→ₗ" }, + { "r_l", "→ₗ" }, + { "linearmap", "→ₗ" }, + { "tosl", "→ₛₗ" }, + { "rsl", "→ₛₗ" }, + { "to_sl", "→ₛₗ" }, + { "r_sl", "→ₛₗ" }, + { "semilinearmap", "→ₛₗ" }, + { "tom", "→ₘ" }, + { "rm", "→ₘ" }, + { "to_m", "→ₘ" }, + { "r_m", "→ₘ" }, + { "aeeqfun", "→ₘ" }, + { "rp", "→ₚ" }, + { "to_p", "→ₚ" }, + { "r_p", "→ₚ" }, + { "dfinsupp", "→ₚ" }, + { "tos", "→ₛ" }, + { "rs", "→ₛ" }, + { "to_s", "→ₛ" }, + { "r_s", "→ₛ" }, + { "simplefunc", "→ₛ" }, + { "heyting", "⇨" }, + { "himp", "⇨" }, + { "hnot", "¬" }, + { "covers", "⋖" }, + { "covby", "⋖" }, + { "wcovby", "⩿" }, + { "wcovers", "⩿" }, + { "def=", "≝" }, + { "defs", "≙" }, + { "degree", "°" }, + { "dei", "ϯ" }, + { "delta", "δ" }, + { "doteqdot", "≑" }, + { "doteq", "≐" }, + { "dotplus", "∔" }, + { "dotsquare", "⊡" }, + { "dot", "·" }, + { "dong", "₫" }, + { "downarrow", "↓" }, + { "downdownarrows", "⇊" }, + { "downleftharpoon", "⇃" }, + { "downrightharpoon", "⇂" }, + { "dr-", "↘" }, + { "dr=", "⇘" }, + { "drachma", "₯" }, + { "dr", "↘" }, + { "dl-", "↙" }, + { "dl=", "⇙" }, + { "dl", "↙" }, + { "d-2", "⇊" }, + { "d-u-", "⇵" }, + { "d-|", "↧" }, + { "d-", "↓" }, + { "d==", "⟱" }, + { "d=", "⇓" }, + { "dd-", "↡" }, + { "ddagger", "‡" }, + { "ddag", "‡" }, + { "ddots", "⋱" }, + { "dz", "↯" }, + { "dib", "◆" }, + { "diw", "◇" }, + { "di.", "◈" }, + { "die", "⚀" }, + { "division", "÷" }, + { "divideontimes", "⋇" }, + { "div", "÷" }, + { "diameter", "⌀" }, + { "diamondsuit", "♢" }, + { "diamond", "⋄" }, + { "digamma", "ϝ" }, + { "di", "◆" }, + { "dagger", "†" }, + { "dag", "†" }, + { "daleth", "ℸ" }, + { "dashv", "⊣" }, + { "dh", "ð" }, + { "dvd", "∣" }, + { "m=", "≞" }, + { "meet", "⊓" }, + { "member", "∈" }, + { "mem", "∈" }, + { "measuredangle", "∡" }, + { "ma", "↦" }, + { "mapsto", "↦" }, + { "male", "♂" }, + { "maltese", "✠" }, + { "manat", "₼" }, + { "mathscr{I}", "ℐ" }, + { "minus", "−" }, + { "mill", "₥" }, + { "micro", "µ" }, + { "mid", "∣" }, + { "multiplication", "×" }, + { "multimap", "⊸" }, + { "mho", "℧" }, + { "models", "⊧" }, + { "mp", "∓" }, + { "?=", "≟" }, + { "??", "⁇" }, + { "?!", "‽" }, + { "prohibited", "🛇" }, + { "prod", "∏" }, + { "propto", "∝" }, + { "precapprox", "≾" }, + { "preceq", "≼" }, + { "precnapprox", "⋨" }, + { "precnsim", "⋨" }, + { "precsim", "≾" }, + { "prec", "≺" }, + { "preim", "⁻¹'" }, + { "preimage", "⁻¹'" }, + { "prime", "′" }, + { "pr", "↣" }, + { "powerset", "𝒫" }, + { "pounds", "£" }, + { "pound", "£" }, + { "pab", "▰" }, + { "paw", "▱" }, + { "partnership", "㉐" }, + { "partial", "∂" }, + { "paragraph", "¶" }, + { "parallel", "∥" }, + { "pa", "▰" }, + { "pm", "±" }, + { "perp", "⟂" }, + { "^perp", "ᗮ" }, + { "permil", "‰" }, + { "per", "⅌" }, + { "peso", "₱" }, + { "peseta", "₧" }, + { "pilcrow", "¶" }, + { "pitchfork", "⋔" }, + { "psi", "ψ" }, + { "phi", "φ" }, + { "leqn", "≰" }, + { "leqq", "≦" }, + { "leqslant", "≤" }, + { "leq", "≤" }, + { "len", "≰" }, + { "leadsto", "↝" }, + { "leftarrowtail", "↢" }, + { "leftarrow", "←" }, + { "leftharpoondown", "↽" }, + { "leftharpoonup", "↼" }, + { "leftleftarrows", "⇇" }, + { "leftrightarrows", "⇆" }, + { "leftrightarrow", "↔" }, + { "leftrightharpoons", "⇋" }, + { "leftrightsquigarrow", "↭" }, + { "leftthreetimes", "⋋" }, + { "lessapprox", "≲" }, + { "lessdot", "⋖" }, + { "lesseqgtr", "⋚" }, + { "lesseqqgtr", "⋚" }, + { "lessgtr", "≶" }, + { "lesssim", "≲" }, + { "le", "≤" }, + { "lub", "⊔" }, + { "lr--", "⟷" }, + { "lr-n", "↮" }, + { "lr-", "↔" }, + { "lr=n", "⇎" }, + { "lr=", "⇔" }, + { "lr~", "↭" }, + { "lrcorner", "⌟" }, + { "lr", "↔" }, + { "l-2", "⇇" }, + { "l-r-", "⇆" }, + { "l--", "⟵" }, + { "l-n", "↚" }, + { "l-|", "↤" }, + { "l->", "↢" }, + { "l-", "←" }, + { "l==", "⇚" }, + { "l=n", "⇍" }, + { "l=", "⇐" }, + { "l~", "↜" }, + { "ll-", "↞" }, + { "llcorner", "⌞" }, + { "llbracket", "〚" }, + { "ll", "≪" }, + { "lbag", "⟅" }, + { "lambda", "λ" }, + { "lamda", "λ" }, + { "lam", "λ" }, + { "lari", "₾" }, + { "langle", "⟨" }, + { "lira", "₤" }, + { "lceil", "⌈" }, + { "ldots", "…" }, + { "ldq", "“" }, + { "ldata", "《" }, + { "lfloor", "⌊" }, + { "lf", "⧏" }, + { "<|", "⧏" }, + { "lhd", "◁" }, + { "lnapprox", "⋦" }, + { "lneqq", "≨" }, + { "lneq", "≨" }, + { "lnsim", "⋦" }, + { "lnot", "¬" }, + { "longleftarrow", "⟵" }, + { "longleftrightarrow", "⟷" }, + { "longrightarrow", "⟶" }, + { "looparrowleft", "↫" }, + { "looparrowright", "↬" }, + { "lozenge", "✧" }, + { "lq", "‘" }, + { "ltimes", "⋉" }, + { "lvertneqq", "≨" }, + { "geqn", "≱" }, + { "geqq", "≧" }, + { "geqslant", "≥" }, + { "geq", "≥" }, + { "gen", "≱" }, + { "gets", "←" }, + { "ge", "≥" }, + { "glb", "⊓" }, + { "glqq", "„" }, + { "glq", "‚" }, + { "guarani", "₲" }, + { "gangia", "ϫ" }, + { "gamma", "γ" }, + { "ggg", "⋙" }, + { "gg", "≫" }, + { "gimel", "ℷ" }, + { "gnapprox", "⋧" }, + { "gneqq", "≩" }, + { "gneq", "≩" }, + { "gnsim", "⋧" }, + { "gtrapprox", "≳" }, + { "gtrdot", "⋗" }, + { "gtreqless", "⋛" }, + { "gtreqqless", "⋛" }, + { "gtrless", "≷" }, + { "gtrsim", "≳" }, + { "gvertneqq", "≩" }, + { "grqq", "“" }, + { "grq", "‘" }, + { "<=n", "≰" }, + { "<=>n", "⇎" }, + { "<=>", "⇔" }, + { "<=", "≤" }, + { "<~nn", "≴" }, + { "<~n", "⋦" }, + { "<~", "≲" }, + { "<:", "⋖" }, + { ":>", "⋗" }, + { "<->n", "↮" }, + { "<->", "↔" }, + { "<-->", "⟷" }, + { "<--", "⟵" }, + { "<-n", "↚" }, + { "<-", "←" }, + { "<<", "⟪" }, + { ">=n", "≱" }, + { ">=", "≥" }, + { ">n", "≯" }, + { ">~nn", "≵" }, + { ">~n", "⋧" }, + { ">~", "≳" }, + { ">>", "⟫" }, + { "root", "√" }, + { "scissor", "✂" }, + { "ssubn", "⊄" }, + { "ssub", "⊂" }, + { "ssupn", "⊅" }, + { "ssup", "⊃" }, + { "ssqub", "⊏" }, + { "ssqup", "⊐" }, + { "ss", "⊆" }, + { "subn", "⊈" }, + { "subseteqq", "⊆" }, + { "subseteq", "⊆" }, + { "subsetneqq", "⊊" }, + { "subsetneq", "⊊" }, + { "subset", "⊆" }, + { "ssubset", "⊂" }, + { "sub", "⊆" }, + { "supn", "⊉" }, + { "supseteqq", "⊇" }, + { "supseteq", "⊇" }, + { "supsetneqq", "⊋" }, + { "supsetneq", "⊋" }, + { "supset", "⊇" }, + { "ssupset", "⊃" }, + { "sUnion", "⋃₀" }, + { "sInter", "⋂₀" }, + { "sup", "⊔" }, + { "supr", "⨆" }, + { "surd3", "∛" }, + { "surd4", "∜" }, + { "surd", "√" }, + { "succapprox", "≿" }, + { "succcurlyeq", "≽" }, + { "succeq", "≽" }, + { "succnapprox", "⋩" }, + { "succnsim", "⋩" }, + { "succsim", "≿" }, + { "succ", "≻" }, + { "sum", "∑" }, + { "specializes", "⤳" }, + { "~>", "⤳" }, + { "squbn", "⋢" }, + { "squb", "⊑" }, + { "squpn", "⋣" }, + { "squp", "⊒" }, + { "square", "□" }, + { "squigarrowright", "⇝" }, + { "sqb", "■" }, + { "sqw", "□" }, + { "sq.", "▣" }, + { "sqo", "▢" }, + { "sqcap", "⊓" }, + { "sqcup", "⊔" }, + { "sqrt", "√" }, + { "sqsubseteq", "⊑" }, + { "sqsubset", "⊏" }, + { "sqsupseteq", "⊒" }, + { "sqsupset", "⊐" }, + { "sq", "◾" }, + { "sy", "⁻¹" }, + { "symmdiff", "∆" }, + { "st4", "✦" }, + { "st6", "✶" }, + { "st8", "✴" }, + { "st12", "✹" }, + { "stigma", "ϛ" }, + { "star", "⋆" }, + { "straightphi", "φ" }, + { "st", "⋆" }, + { "spesmilo", "₷" }, + { "span", "∙" }, + { "spadesuit", "♠" }, + { "sphericalangle", "∢" }, + { "section", "§" }, + { "searrow", "↘" }, + { "setminus", "\\" }, + { "san", "ϻ" }, + { "sampi", "ϡ" }, + { "shortmid", "∣" }, + { "sho", "ϸ" }, + { "shima", "ϭ" }, + { "shei", "ϣ" }, + { "sharp", "♯" }, + { "sigma", "σ" }, + { "simeq", "≃" }, + { "sim", "∼" }, + { "sbs", "﹨" }, + { "smallamalg", "∐" }, + { "smallsetminus", "∖" }, + { "smallsmile", "⌣" }, + { "smile", "⌣" }, + { "smul", "•" }, + { "swarrow", "↙" }, + { "Tr", "◀" }, + { "Tb", "◀" }, + { "Tw", "◁" }, + { "Tau", "Τ" }, + { "Theta", "Θ" }, + { "TH", "Þ" }, + { "union", "∪" }, + { "undertie", "‿" }, + { "uncertainty", "⯑" }, + { "un", "∪" }, + { "u+", "⊎" }, + { "u.", "⊍" }, + { "ud-|", "↨" }, + { "ud-", "↕" }, + { "ud=", "⇕" }, + { "ud", "↕" }, + { "ul-", "↖" }, + { "ul=", "⇖" }, + { "ulcorner", "⌜" }, + { "ul", "↖" }, + { "ur-", "↗" }, + { "ur=", "⇗" }, + { "urcorner", "⌝" }, + { "ur", "↗" }, + { "u-2", "⇈" }, + { "u-d-", "⇅" }, + { "u-|", "↥" }, + { "u-", "↑" }, + { "u==", "⟰" }, + { "u=", "⇑" }, + { "uu-", "↟" }, + { "upsilon", "υ" }, + { "uparrow", "↑" }, + { "updownarrow", "↕" }, + { "upleftharpoon", "↿" }, + { "uplus", "⊎" }, + { "uprightharpoon", "↾" }, + { "upuparrows", "⇈" }, + { "And", "⋀" }, + { "AA", "Å" }, + { "AE", "Æ" }, + { "Alpha", "Α" }, + { "Or", "⋁" }, + { "O+", "⨁" }, + { "directsum", "⨁" }, + { "Ox", "⨂" }, + { "O.", "⨀" }, + { "O*", "⍟" }, + { "OE", "Œ" }, + { "Omega", "Ω" }, + { "Omicron", "Ο" }, + { "Int", "ℤ" }, + { "Inter", "⋂" }, + { "bInter", "⋂" }, + { "Iota", "Ι" }, + { "Im", "ℑ" }, + { "Un", "⋃" }, + { "Union", "⋃" }, + { "bUnion", "⋃" }, + { "U+", "⨄" }, + { "U.", "⨃" }, + { "Upsilon", "Υ" }, + { "Uparrow", "⇑" }, + { "Updownarrow", "⇕" }, + { "Gl-", "ƛ" }, + { "Gl", "λ" }, + { "Gangia", "Ϫ" }, + { "Gamma", "Γ" }, + { "Glb", "⨅" }, + { "Ga", "α" }, + { "GA", "Α" }, + { "Gb", "β" }, + { "GB", "Β" }, + { "Gg", "γ" }, + { "GG", "Γ" }, + { "Gd", "δ" }, + { "GD", "Δ" }, + { "Ge", "ε" }, + { "GE", "Ε" }, + { "Gz", "ζ" }, + { "GZ", "Ζ" }, + { "Gth", "θ" }, + { "Gt", "τ" }, + { "GTH", "Θ" }, + { "GT", "Τ" }, + { "Gi", "ι" }, + { "GI", "Ι" }, + { "Gk", "κ" }, + { "GK", "Κ" }, + { "GL", "Λ" }, + { "Gm", "μ" }, + { "GM", "Μ" }, + { "Gn", "ν" }, + { "GN", "Ν" }, + { "Gx", "ξ" }, + { "GX", "Ξ" }, + { "Gr", "ρ" }, + { "GR", "Ρ" }, + { "Gs", "σ" }, + { "GS", "Σ" }, + { "Gu", "υ" }, + { "GU", "Υ" }, + { "Gf", "φ" }, + { "GF", "Φ" }, + { "Gc", "χ" }, + { "GC", "Χ" }, + { "Gp", "ψ" }, + { "GP", "Ψ" }, + { "Go", "ω" }, + { "GO", "Ω" }, + { "Inf", "⨅" }, + { "Join", "⨆" }, + { "Lub", "⨆" }, + { "Lambda", "Λ" }, + { "Lamda", "Λ" }, + { "Leftarrow", "⇐" }, + { "Leftrightarrow", "⇔" }, + { "Letter", "✉" }, + { "Lleftarrow", "⇚" }, + { "Ll", "⋘" }, + { "Longleftarrow", "⇐" }, + { "Longleftrightarrow", "⇔" }, + { "Longrightarrow", "⇒" }, + { "Meet", "⨅" }, + { "Sup", "⨆" }, + { "Sqcap", "⨅" }, + { "Sqcup", "⨆" }, + { "Lsh", "↰" }, + { "|-n", "⊬" }, + { "|-", "⊢" }, + { "|=n", "⊭" }, + { "|=", "⊨" }, + { "|->", "↦" }, + { "|=>", "⇰" }, + { "||-n", "⊮" }, + { "||-", "⊩" }, + { "||=n", "⊯" }, + { "||=", "⊫" }, + { "|||-", "⊪" }, + { "||", "‖" }, + { "fuzzy", "‖" }, + { "|n", "∤" }, + { "Com", "ℂ" }, + { "Chi", "Χ" }, + { "Cap", "⋒" }, + { "Cup", "⋓" }, + { "cul", "⌜" }, + { "cuL", "⌈" }, + { "currency", "¤" }, + { "curlyeqprec", "⋞" }, + { "curlyeqsucc", "⋟" }, + { "curlypreceq", "≼" }, + { "curlyvee", "⋎" }, + { "curlywedge", "⋏" }, + { "curvearrowleft", "↶" }, + { "curvearrowright", "↷" }, + { "cur", "⌝" }, + { "cuR", "⌉" }, + { "cup", "∪" }, + { "cu", "⌜" }, + { "cll", "⌞" }, + { "clL", "⌊" }, + { "clr", "⌟" }, + { "clR", "⌋" }, + { "clubsuit", "♣" }, + { "cl", "⌞" }, + { "construction", "🚧" }, + { "cong", "≅" }, + { "con", "⬝" }, + { "compl", "ᶜ" }, + { "complement", "ᶜ" }, + { "complementprefix", "∁" }, + { "Complement", "∁" }, + { "comp", "∘" }, + { "com", "ℂ" }, + { "coloneq", "≔" }, + { "colon", "₡" }, + { "copyright", "©" }, + { "cdots", "⋯" }, + { "cdot", "·" }, + { "cib", "●" }, + { "ciw", "○" }, + { "ci..", "◌" }, + { "ci.", "◎" }, + { "ciO", "◯" }, + { "circeq", "≗" }, + { "circlearrowleft", "↺" }, + { "circlearrowright", "↻" }, + { "circledR", "®" }, + { "circledS", "Ⓢ" }, + { "circledast", "⊛" }, + { "circledcirc", "⊚" }, + { "circleddash", "⊝" }, + { "circ", "∘" }, + { "ci", "●" }, + { "centerdot", "·" }, + { "cent", "¢" }, + { "cedi", "₵" }, + { "celsius", "℃" }, + { "ce", "ȩ" }, + { "checkmark", "✓" }, + { "chi", "χ" }, + { "cruzeiro", "₢" }, + { "caution", "☡" }, + { "cap", "∩" }, + { "qed", "∎" }, + { "quot", "⧸" }, + { "bigsolidus", "⧸" }, + { "/", "⧸" }, + { "+ ", "⊹" }, + { "b+", "⊞" }, + { "b-", "⊟" }, + { "bx", "⊠" }, + { "b.", "⊡" }, + { "bn", "ℕ" }, + { "bz", "ℤ" }, + { "bq", "ℚ" }, + { "brokenbar", "¦" }, + { "br", "ℝ" }, + { "bc", "ℂ" }, + { "bp", "ℙ" }, + { "bb", "𝔹" }, + { "bsum", "⅀" }, + { "b0", "𝟘" }, + { "b1", "𝟙" }, + { "b2", "𝟚" }, + { "b3", "𝟛" }, + { "b4", "𝟜" }, + { "b5", "𝟝" }, + { "b6", "𝟞" }, + { "b7", "𝟟" }, + { "b8", "𝟠" }, + { "b9", "𝟡" }, + { "sb0", "𝟬" }, + { "sb1", "𝟭" }, + { "sb2", "𝟮" }, + { "sb3", "𝟯" }, + { "sb4", "𝟰" }, + { "sb5", "𝟱" }, + { "sb6", "𝟲" }, + { "sb7", "𝟳" }, + { "sb8", "𝟴" }, + { "sb9", "𝟵" }, + { "bub", "•" }, + { "buw", "◦" }, + { "but", "‣" }, + { "bumpeq", "≏" }, + { "bu", "•" }, + { "biohazard", "☣" }, + { "bihimp", "⇔" }, + { "bigcap", "⋂" }, + { "bigcirc", "◯" }, + { "bigcoprod", "∐" }, + { "bigcup", "⋃" }, + { "bigglb", "⨅" }, + { "biginf", "⨅" }, + { "bigjoin", "⨆" }, + { "biglub", "⨆" }, + { "bigmeet", "⨅" }, + { "bigsqcap", "⨅" }, + { "bigsqcup", "⨆" }, + { "bigstar", "★" }, + { "bigsup", "⨆" }, + { "bigtriangledown", "▽" }, + { "bigtriangleup", "△" }, + { "bigvee", "⋁" }, + { "bigwedge", "⋀" }, + { "beta", "β" }, + { "beth", "ℶ" }, + { "between", "≬" }, + { "because", "∵" }, + { "backcong", "≌" }, + { "backepsilon", "∍" }, + { "backprime", "‵" }, + { "backsimeq", "⋍" }, + { "backsim", "∽" }, + { "barwedge", "⊼" }, + { "blacklozenge", "✦" }, + { "blacksquare", "▪" }, + { "blacksmiley", "☻" }, + { "blacktriangledown", "▾" }, + { "blacktriangleleft", "◂" }, + { "blacktriangleright", "▸" }, + { "blacktriangle", "▴" }, + { "bot", "⊥" }, + { "^bot", "ᗮ" }, + { "bowtie", "⋈" }, + { "boxminus", "⊟" }, + { "boxmid", "◫" }, + { "hcomp", "◫" }, + { "boxplus", "⊞" }, + { "boxtimes", "⊠" }, + { "join", "⊔" }, + { "r-2", "⇉" }, + { "r-3", "⇶" }, + { "r-l-", "⇄" }, + { "r--", "⟶" }, + { "r-n", "↛" }, + { "r-|", "↦" }, + { "r->", "↣" }, + { "r-o", "⊸" }, + { "r-", "→" }, + { "r==", "⇛" }, + { "r=n", "⇏" }, + { "r=", "⇒" }, + { "r~", "↝" }, + { "rr-", "↠" }, + { "reb", "▬" }, + { "rew", "▭" }, + { "real", "ℝ" }, + { "registered", "®" }, + { "re", "▬" }, + { "rbag", "⟆" }, + { "rat", "ℚ" }, + { "radioactive", "☢" }, + { "rrbracket", "〛" }, + { "rangle", "⟩" }, + { "rq", "’" }, + { "rightarrowtail", "↣" }, + { "rightarrow", "→" }, + { "rightharpoondown", "⇁" }, + { "rightharpoonup", "⇀" }, + { "rightleftarrows", "⇄" }, + { "rightleftharpoons", "⇌" }, + { "rightrightarrows", "⇉" }, + { "rightthreetimes", "⋌" }, + { "risingdotseq", "≓" }, + { "ruble", "₽" }, + { "rupee", "₨" }, + { "rho", "ρ" }, + { "rhd", "▷" }, + { "rceil", "⌉" }, + { "rfloor", "⌋" }, + { "rtimes", "⋊" }, + { "rdq", "”" }, + { "rdata", "》" }, + { "functor", "⥤" }, + { "fun", "λ" }, + { "f<<", "«" }, + { "f>>", "»" }, + { "f<", "‹" }, + { "f>", "›" }, + { "h<", "❰" }, + { "h>", "❱" }, + { "finprod", "∏ᶠ" }, + { "finsum", "∑ᶠ" }, + { "frac12", "½" }, + { "frac13", "⅓" }, + { "frac14", "¼" }, + { "frac15", "⅕" }, + { "frac16", "⅙" }, + { "frac18", "⅛" }, + { "frac1", "⅟" }, + { "frac23", "⅔" }, + { "frac25", "⅖" }, + { "frac34", "¾" }, + { "frac35", "⅗" }, + { "frac38", "⅜" }, + { "frac45", "⅘" }, + { "frac56", "⅚" }, + { "frac58", "⅝" }, + { "frac78", "⅞" }, + { "frac", "¼" }, + { "frown", "⌢" }, + { "frqq", "»" }, + { "frq", "›" }, + { "female", "♀" }, + { "fei", "ϥ" }, + { "facsimile", "℻" }, + { "fallingdotseq", "≒" }, + { "flat", "♭" }, + { "flqq", "«" }, + { "flq", "‹" }, + { "forall", "∀" }, + { ")b", "⟆" }, + { "[[", "⟦" }, + { "]]", "⟧" }, + { "{{", "⦃" }, + { "}}", "⦄" }, + { "((", "⸨" }, + { "))", "⸩" }, + { "([", "⟮" }, + { "])", "⟯" }, + { "Xi", "Ξ" }, + { "Nat", "ℕ" }, + { "Nu", "Ν" }, + { "Zeta", "Ζ" }, + { "Rat", "ℚ" }, + { "Real", "ℝ" }, + { "Re", "ℜ" }, + { "Rho", "Ρ" }, + { "Rightarrow", "⇒" }, + { "Rrightarrow", "⇛" }, + { "Rsh", "↱" }, + { "Fei", "Ϥ" }, + { "Frowny", "☹" }, + { "Hori", "Ϩ" }, + { "Heta", "Ͱ" }, + { "Khei", "Ϧ" }, + { "Koppa", "Ϟ" }, + { "Kappa", "Κ" }, + { "^a", "ᵃ" }, + { "^b", "ᵇ" }, + { "^c", "ᶜ" }, + { "^d", "ᵈ" }, + { "^e", "ᵉ" }, + { "^f", "ᶠ" }, + { "^g", "ᵍ" }, + { "^h", "ʰ" }, + { "^i", "ⁱ" }, + { "^j", "ʲ" }, + { "^k", "ᵏ" }, + { "^l", "ˡ" }, + { "^m", "ᵐ" }, + { "^n", "ⁿ" }, + { "^o", "ᵒ" }, + { "^p", "ᵖ" }, + { "^r", "ʳ" }, + { "^s", "ˢ" }, + { "^t", "ᵗ" }, + { "^u", "ᵘ" }, + { "^v", "ᵛ" }, + { "^w", "ʷ" }, + { "^x", "ˣ" }, + { "^y", "ʸ" }, + { "^z", "ᶻ" }, + { "^A", "ᴬ" }, + { "^B", "ᴮ" }, + { "^D", "ᴰ" }, + { "^E", "ᴱ" }, + { "^G", "ᴳ" }, + { "^H", "ᴴ" }, + { "^I", "ᴵ" }, + { "^J", "ᴶ" }, + { "^K", "ᴷ" }, + { "^L", "ᴸ" }, + { "^M", "ᴹ" }, + { "^N", "ᴺ" }, + { "^O", "ᴼ" }, + { "^P", "ᴾ" }, + { "^R", "ᴿ" }, + { "^T", "ᵀ" }, + { "^U", "ᵁ" }, + { "^V", "ⱽ" }, + { "^W", "ᵂ" }, + { "^0", "⁰" }, + { "^1", "¹" }, + { "^2", "²" }, + { "^3", "³" }, + { "^4", "⁴" }, + { "^5", "⁵" }, + { "^6", "⁶" }, + { "^7", "⁷" }, + { "^8", "⁸" }, + { "^9", "⁹" }, + { "^)", "⁾" }, + { "^(", "⁽" }, + { "^=", "⁼" }, + { "^+", "⁺" }, + { "^o_", "º" }, + { "^-", "⁻" }, + { "^a_", "ª" }, + { "^uhook", "ꭟ" }, + { "^ubar", "ᶶ" }, + { "^upsilon", "ᶷ" }, + { "^ltilde", "ꭞ" }, + { "^ls", "ꭝ" }, + { "^lhook", "ᶪ" }, + { "^lretroflexhook", "ᶩ" }, + { "^oe", "ꟹ" }, + { "^heng", "ꭜ" }, + { "^hhook", "ʱ" }, + { "^hwithhook", "ʱ" }, + { "^Hstroke", "ꟸ" }, + { "^theta", "ᶿ" }, + { "^turnedv", "ᶺ" }, + { "^turnedmleg", "ᶭ" }, + { "^turnedm", "ᵚ" }, + { "^turnedh", "ᶣ" }, + { "^turnedalpha", "ᶛ" }, + { "^turnedae", "ᵆ" }, + { "^turneda", "ᵄ" }, + { "^turnedi", "ᵎ" }, + { "^turnede", "ᵌ" }, + { "^turnedrhook", "ʵ" }, + { "^turnedrwithhook", "ʵ" }, + { "^turnedr", "ʴ" }, + { "^twithpalatalhook", "ᶵ" }, + { "^otop", "ᵔ" }, + { "^ezh", "ᶾ" }, + { "^esh", "ᶴ" }, + { "^eth", "ᶞ" }, + { "^eng", "ᵑ" }, + { "^zcurl", "ᶽ" }, + { "^zretroflexhook", "ᶼ" }, + { "^vhook", "ᶹ" }, + { "^Ismall", "ᶦ" }, + { "^Lsmall", "ᶫ" }, + { "^Nsmall", "ᶰ" }, + { "^Usmall", "ᶸ" }, + { "^Istroke", "ᶧ" }, + { "^Rinverted", "ʶ" }, + { "^ccurl", "ᶝ" }, + { "^chi", "ᵡ" }, + { "^shook", "ᶳ" }, + { "^gscript", "ᶢ" }, + { "^schwa", "ᵊ" }, + { "^usideways", "ᵙ" }, + { "^phi", "ᶲ" }, + { "^obarred", "ᶱ" }, + { "^beta", "ᵝ" }, + { "^obottom", "ᵕ" }, + { "^nretroflexhook", "ᶯ" }, + { "^nlefthook", "ᶮ" }, + { "^mhook", "ᶬ" }, + { "^jtail", "ᶨ" }, + { "^iota", "ᶥ" }, + { "^istroke", "ᶤ" }, + { "^ereversedopen", "ᶟ" }, + { "^stop", "ˤ" }, + { "^varphi", "ᵠ" }, + { "^vargamma", "ᵞ" }, + { "^gamma", "ˠ" }, + { "^ain", "ᵜ" }, + { "^alpha", "ᵅ" }, + { "^oopen", "ᵓ" }, + { "^eopen", "ᵋ" }, + { "^Ou", "ᴽ" }, + { "^Nreversed", "ᴻ" }, + { "^Ereversed", "ᴲ" }, + { "^Bbarred", "ᴯ" }, + { "^Ae", "ᴭ" }, + { "^SM", "℠" }, + { "^TEL", "℡" }, + { "^TM", "™" }, + { "_a", "ₐ" }, + { "_e", "ₑ" }, + { "_h", "ₕ" }, + { "_i", "ᵢ" }, + { "_j", "ⱼ" }, + { "_k", "ₖ" }, + { "_l", "ₗ" }, + { "_m", "ₘ" }, + { "_n", "ₙ" }, + { "_o", "ₒ" }, + { "_p", "ₚ" }, + { "_r", "ᵣ" }, + { "_s", "ₛ" }, + { "_t", "ₜ" }, + { "_u", "ᵤ" }, + { "_v", "ᵥ" }, + { "_x", "ₓ" }, + { "_0", "₀" }, + { "_1", "₁" }, + { "_2", "₂" }, + { "_3", "₃" }, + { "_4", "₄" }, + { "_5", "₅" }, + { "_6", "₆" }, + { "_7", "₇" }, + { "_8", "₈" }, + { "_9", "₉" }, + { "_)", "₎" }, + { "_(", "₍" }, + { "_=", "₌" }, + { "_+", "₊" }, + { "_-", "₋" }, + { "!!", "‼" }, + { "!?", "⁉" }, + { "San", "Ϻ" }, + { "Sampi", "Ϡ" }, + { "Sho", "Ϸ" }, + { "Shima", "Ϭ" }, + { "Shei", "Ϣ" }, + { "Stigma", "Ϛ" }, + { "Sigma", "Σ" }, + { "Subset", "⋐" }, + { "Supset", "⋑" }, + { "Smiley", "☺" }, + { "Psi", "Ψ" }, + { "Phi", "Φ" }, + { "Pi", "Π" }, + { "Pi0", "Π₀" }, + { "P0", "Π₀" }, + { "Pi_0", "Π₀" }, + { "P_0", "Π₀" }, + { "bfA", "𝐀" }, + { "bfB", "𝐁" }, + { "bfC", "𝐂" }, + { "bfD", "𝐃" }, + { "bfE", "𝐄" }, + { "bfF", "𝐅" }, + { "bfG", "𝐆" }, + { "bfH", "𝐇" }, + { "bfI", "𝐈" }, + { "bfJ", "𝐉" }, + { "bfK", "𝐊" }, + { "bfL", "𝐋" }, + { "bfM", "𝐌" }, + { "bfN", "𝐍" }, + { "bfO", "𝐎" }, + { "bfP", "𝐏" }, + { "bfQ", "𝐐" }, + { "bfR", "𝐑" }, + { "bfS", "𝐒" }, + { "bfT", "𝐓" }, + { "bfU", "𝐔" }, + { "bfV", "𝐕" }, + { "bfW", "𝐖" }, + { "bfX", "𝐗" }, + { "bfY", "𝐘" }, + { "bfZ", "𝐙" }, + { "bfa", "𝐚" }, + { "bfb", "𝐛" }, + { "bfc", "𝐜" }, + { "bfd", "𝐝" }, + { "bfe", "𝐞" }, + { "bff", "𝐟" }, + { "bfg", "𝐠" }, + { "bfh", "𝐡" }, + { "bfi", "𝐢" }, + { "bfj", "𝐣" }, + { "bfk", "𝐤" }, + { "bfl", "𝐥" }, + { "bfm", "𝐦" }, + { "bfn", "𝐧" }, + { "bfo", "𝐨" }, + { "bfp", "𝐩" }, + { "bfq", "𝐪" }, + { "bfr", "𝐫" }, + { "bfs", "𝐬" }, + { "bft", "𝐭" }, + { "bfu", "𝐮" }, + { "bfv", "𝐯" }, + { "bfw", "𝐰" }, + { "bfx", "𝐱" }, + { "bfy", "𝐲" }, + { "bfz", "𝐳" }, + { "MiA", "𝐴" }, + { "MiB", "𝐵" }, + { "MiC", "𝐶" }, + { "MiD", "𝐷" }, + { "MiE", "𝐸" }, + { "MiF", "𝐹" }, + { "MiG", "𝐺" }, + { "MiH", "𝐻" }, + { "MiI", "𝐼" }, + { "MiJ", "𝐽" }, + { "MiK", "𝐾" }, + { "MiL", "𝐿" }, + { "MiM", "𝑀" }, + { "MiN", "𝑁" }, + { "MiO", "𝑂" }, + { "MiP", "𝑃" }, + { "MiQ", "𝑄" }, + { "MiR", "𝑅" }, + { "MiS", "𝑆" }, + { "MiT", "𝑇" }, + { "MiU", "𝑈" }, + { "MiV", "𝑉" }, + { "MiW", "𝑊" }, + { "MiX", "𝑋" }, + { "MiY", "𝑌" }, + { "MiZ", "𝑍" }, + { "Mia", "𝑎" }, + { "Mib", "𝑏" }, + { "Mic", "𝑐" }, + { "Mid", "𝑑" }, + { "Mie", "𝑒" }, + { "Mif", "𝑓" }, + { "Mig", "𝑔" }, + { "Mii", "𝑖" }, + { "Mij", "𝑗" }, + { "Mik", "𝑘" }, + { "Mil", "𝑙" }, + { "Mim", "𝑚" }, + { "Min", "𝑛" }, + { "Mio", "𝑜" }, + { "Mip", "𝑝" }, + { "Miq", "𝑞" }, + { "Mir", "𝑟" }, + { "Mis", "𝑠" }, + { "Mit", "𝑡" }, + { "Miu", "𝑢" }, + { "Miv", "𝑣" }, + { "Miw", "𝑤" }, + { "Mix", "𝑥" }, + { "Miy", "𝑦" }, + { "Miz", "𝑧" }, + { "MIA", "𝑨" }, + { "MIB", "𝑩" }, + { "MIC", "𝑪" }, + { "MID", "𝑫" }, + { "MIE", "𝑬" }, + { "MIF", "𝑭" }, + { "MIG", "𝑮" }, + { "MIH", "𝑯" }, + { "MII", "𝑰" }, + { "MIJ", "𝑱" }, + { "MIK", "𝑲" }, + { "MIL", "𝑳" }, + { "MIM", "𝑴" }, + { "MIN", "𝑵" }, + { "MIO", "𝑶" }, + { "MIP", "𝑷" }, + { "MIQ", "𝑸" }, + { "MIR", "𝑹" }, + { "MIS", "𝑺" }, + { "MIT", "𝑻" }, + { "MIU", "𝑼" }, + { "MIV", "𝑽" }, + { "MIW", "𝑾" }, + { "MIX", "𝑿" }, + { "MIY", "𝒀" }, + { "MIZ", "𝒁" }, + { "MIa", "𝒂" }, + { "MIb", "𝒃" }, + { "MIc", "𝒄" }, + { "MId", "𝒅" }, + { "MIe", "𝒆" }, + { "MIf", "𝒇" }, + { "MIg", "𝒈" }, + { "MIh", "𝒉" }, + { "MIi", "𝒊" }, + { "MIj", "𝒋" }, + { "MIk", "𝒌" }, + { "MIl", "𝒍" }, + { "MIm", "𝒎" }, + { "MIn", "𝒏" }, + { "MIo", "𝒐" }, + { "MIp", "𝒑" }, + { "MIq", "𝒒" }, + { "MIr", "𝒓" }, + { "MIs", "𝒔" }, + { "MIt", "𝒕" }, + { "MIu", "𝒖" }, + { "MIv", "𝒗" }, + { "MIw", "𝒘" }, + { "MIx", "𝒙" }, + { "MIy", "𝒚" }, + { "MIz", "𝒛" }, + { "McA", "𝒜" }, + { "McB", "ℬ" }, + { "McC", "𝒞" }, + { "McD", "𝒟" }, + { "McE", "ℰ" }, + { "McF", "ℱ" }, + { "McG", "𝒢" }, + { "McH", "ℋ" }, + { "McI", "ℐ" }, + { "McJ", "𝒥" }, + { "McK", "𝒦" }, + { "McL", "ℒ" }, + { "McM", "ℳ" }, + { "McN", "𝒩" }, + { "McO", "𝒪" }, + { "McP", "𝒫" }, + { "McQ", "𝒬" }, + { "McR", "ℛ" }, + { "McS", "𝒮" }, + { "McT", "𝒯" }, + { "McU", "𝒰" }, + { "McV", "𝒱" }, + { "McW", "𝒲" }, + { "McX", "𝒳" }, + { "McY", "𝒴" }, + { "McZ", "𝒵" }, + { "Mca", "𝒶" }, + { "Mcb", "𝒷" }, + { "Mcc", "𝒸" }, + { "Mcd", "𝒹" }, + { "Mce", "ℯ" }, + { "Mcf", "𝒻" }, + { "Mcg", "ℊ" }, + { "Mch", "𝒽" }, + { "Mci", "𝒾" }, + { "Mcj", "𝒿" }, + { "Mck", "𝓀" }, + { "Mcl", "𝓁" }, + { "Mcm", "𝓂" }, + { "Mcn", "𝓃" }, + { "Mco", "ℴ" }, + { "Mcp", "𝓅" }, + { "Mcq", "𝓆" }, + { "Mcr", "𝓇" }, + { "Mcs", "𝓈" }, + { "Mct", "𝓉" }, + { "Mcu", "𝓊" }, + { "Mcv", "𝓋" }, + { "Mcw", "𝓌" }, + { "Mcx", "𝓍" }, + { "Mcy", "𝓎" }, + { "Mcz", "𝓏" }, + { "MCA", "𝓐" }, + { "MCB", "𝓑" }, + { "MCC", "𝓒" }, + { "MCD", "𝓓" }, + { "MCE", "𝓔" }, + { "MCF", "𝓕" }, + { "MCG", "𝓖" }, + { "MCH", "𝓗" }, + { "MCI", "𝓘" }, + { "MCJ", "𝓙" }, + { "MCK", "𝓚" }, + { "MCL", "𝓛" }, + { "MCM", "𝓜" }, + { "MCN", "𝓝" }, + { "MCO", "𝓞" }, + { "MCP", "𝓟" }, + { "MCQ", "𝓠" }, + { "MCR", "𝓡" }, + { "MCS", "𝓢" }, + { "MCT", "𝓣" }, + { "MCU", "𝓤" }, + { "MCV", "𝓥" }, + { "MCW", "𝓦" }, + { "MCX", "𝓧" }, + { "MCY", "𝓨" }, + { "MCZ", "𝓩" }, + { "MCa", "𝓪" }, + { "MCb", "𝓫" }, + { "MCc", "𝓬" }, + { "MCd", "𝓭" }, + { "MCe", "𝓮" }, + { "MCf", "𝓯" }, + { "MCg", "𝓰" }, + { "MCh", "𝓱" }, + { "MCi", "𝓲" }, + { "MCj", "𝓳" }, + { "MCk", "𝓴" }, + { "MCl", "𝓵" }, + { "MCm", "𝓶" }, + { "MCn", "𝓷" }, + { "MCo", "𝓸" }, + { "MCp", "𝓹" }, + { "MCq", "𝓺" }, + { "MCr", "𝓻" }, + { "MCs", "𝓼" }, + { "MCt", "𝓽" }, + { "MCu", "𝓾" }, + { "MCv", "𝓿" }, + { "MCw", "𝔀" }, + { "MCx", "𝔁" }, + { "MCy", "𝔂" }, + { "MCz", "𝔃" }, + { "MfA", "𝔄" }, + { "MfB", "𝔅" }, + { "MfC", "ℭ" }, + { "MfD", "𝔇" }, + { "MfE", "𝔈" }, + { "MfF", "𝔉" }, + { "MfG", "𝔊" }, + { "MfH", "ℌ" }, + { "MfI", "ℑ" }, + { "MfJ", "𝔍" }, + { "MfK", "𝔎" }, + { "MfL", "𝔏" }, + { "MfM", "𝔐" }, + { "MfN", "𝔑" }, + { "MfO", "𝔒" }, + { "MfP", "𝔓" }, + { "MfQ", "𝔔" }, + { "MfR", "ℜ" }, + { "MfS", "𝔖" }, + { "MfT", "𝔗" }, + { "MfU", "𝔘" }, + { "MfV", "𝔙" }, + { "MfW", "𝔚" }, + { "MfX", "𝔛" }, + { "MfY", "𝔜" }, + { "MfZ", "ℨ" }, + { "Mfa", "𝔞" }, + { "Mfb", "𝔟" }, + { "Mfc", "𝔠" }, + { "Mfd", "𝔡" }, + { "Mfe", "𝔢" }, + { "Mff", "𝔣" }, + { "Mfg", "𝔤" }, + { "Mfh", "𝔥" }, + { "Mfi", "𝔦" }, + { "Mfj", "𝔧" }, + { "Mfk", "𝔨" }, + { "Mfl", "𝔩" }, + { "Mfm", "𝔪" }, + { "Mfn", "𝔫" }, + { "Mfo", "𝔬" }, + { "Mfp", "𝔭" }, + { "Mfq", "𝔮" }, + { "Mfr", "𝔯" }, + { "Mfs", "𝔰" }, + { "Mft", "𝔱" }, + { "Mfu", "𝔲" }, + { "Mfv", "𝔳" }, + { "Mfw", "𝔴" }, + { "Mfx", "𝔵" }, + { "Mfy", "𝔶" }, + { "Mfz", "𝔷" }, + { "yen", "¥" }, + { "varrho", "ϱ" }, + { "varkappa", "ϰ" }, + { "varkai", "ϗ" }, + { "varnothing", "∅" }, + { "varpi", "ϖ" }, + { "varphi", "ϕ" }, + { "varprime", "′" }, + { "varpropto", "∝" }, + { "vartheta", "ϑ" }, + { "vartriangleleft", "⊲" }, + { "vartriangleright", "⊳" }, + { "varbeta", "ϐ" }, + { "varsigma", "ς" }, + { "veebar", "⊻" }, + { "vee", "∨" }, + { "ve", "ě" }, + { "vE", "Ě" }, + { "vdash", "⊢" }, + { "vdots", "⋮" }, + { "vd", "ď" }, + { "vDash", "⊨" }, + { "vD", "Ď" }, + { "vc", "č" }, + { "vC", "Č" }, + { "koppa", "ϟ" }, + { "kip", "₭" }, + { "ki", "į" }, + { "kI", "Į" }, + { "kelvin", "K" }, + { "kappa", "κ" }, + { "khei", "ϧ" }, + { "warning", "⚠" }, + { "won", "₩" }, + { "wedge", "∧" }, + { "wp", "℘" }, + { "wr", "≀" }, + { "Dei", "Ϯ" }, + { "Delta", "Δ" }, + { "Digamma", "Ϝ" }, + { "Diamond", "◇" }, + { "Downarrow", "⇓" }, + { "DH", "Ð" }, + { "zeta", "ζ" }, + { "Eta", "Η" }, + { "Epsilon", "Ε" }, + { "Beta", "Β" }, + { "Box", "□" }, + { "Bumpeq", "≎" }, + { "bbA", "𝔸" }, + { "bbB", "𝔹" }, + { "bbC", "ℂ" }, + { "bbD", "𝔻" }, + { "bbE", "𝔼" }, + { "bbF", "𝔽" }, + { "bbG", "𝔾" }, + { "bbH", "ℍ" }, + { "bbI", "𝕀" }, + { "bbJ", "𝕁" }, + { "bbK", "𝕂" }, + { "bbL", "𝕃" }, + { "bbM", "𝕄" }, + { "bbN", "ℕ" }, + { "bbO", "𝕆" }, + { "bbP", "ℙ" }, + { "bbQ", "ℚ" }, + { "bbR", "ℝ" }, + { "bbS", "𝕊" }, + { "bbT", "𝕋" }, + { "bbU", "𝕌" }, + { "bbV", "𝕍" }, + { "bbW", "𝕎" }, + { "bbX", "𝕏" }, + { "bbY", "𝕐" }, + { "bbZ", "ℤ" }, + { "bba", "𝕒" }, + { "bbb", "𝕓" }, + { "bbc", "𝕔" }, + { "bbd", "𝕕" }, + { "bbe", "𝕖" }, + { "bbf", "𝕗" }, + { "bbg", "𝕘" }, + { "bbh", "𝕙" }, + { "bbi", "𝕚" }, + { "bbj", "𝕛" }, + { "bbk", "𝕜" }, + { "bbl", "𝕝" }, + { "bbm", "𝕞" }, + { "bbn", "𝕟" }, + { "bbo", "𝕠" }, + { "bbp", "𝕡" }, + { "bbq", "𝕢" }, + { "bbr", "𝕣" }, + { "bbs", "𝕤" }, + { "bbt", "𝕥" }, + { "bbu", "𝕦" }, + { "bbv", "𝕧" }, + { "bbw", "𝕨" }, + { "bbx", "𝕩" }, + { "bby", "𝕪" }, + { "bbz", "𝕫" }, + { "Rge0", "ℝ≥0" }, + { "R>=0", "ℝ≥0" }, + { "nnreal", "ℝ≥0" }, + { "ennreal", "ℝ≥0∞" }, + { "enat", "ℕ∞" }, + { "Zsqrt", "ℤ√" }, + { "zsqrtd", "ℤ√" }, + { "liel", "⁅" }, + { "bracketl", "⁅" }, + { "lier", "⁆" }, + { "[-", "⁅" }, + { "-]", "⁆" }, + { "lsimplex", "⦋" }, + { "rsimplex", "⦌" }, + { "bracketr", "⁆" }, + { "nhds", "𝓝" }, + { "nbhds", "𝓝" }, + { "X", "⨯" }, + { "vectorproduct", "⨯" }, + { "crossproduct", "⨯" }, + { "xs", "×ˢ" }, + { "coprod", "⨿" }, + { "sigmaobj", "∐" }, + { "xf", "×ᶠ" }, + { "exf", "∃ᶠ" }, + { "Yot", "Ϳ" }, + { "goal", "⊢" }, + { "Vdash", "⊩" }, + { "Vert", "‖" }, + { "Vvdash", "⊪" }, + { "tiny", "⧾" }, + { "miny", "⧿" }, + { "heq", "≍" }, + { "r!", "¡" }, +} diff --git a/builtin/runtime/lean_input.lua b/builtin/runtime/lean_input.lua new file mode 100644 index 0000000..4079de0 --- /dev/null +++ b/builtin/runtime/lean_input.lua @@ -0,0 +1,517 @@ +-- lean_input.lua --- the Lean 4 Unicode input method (Arc 8 Stage 4b). +-- +-- Typing `\alpha` gives `α`; `\<>` gives `⟨⟩` with the point between. +-- The table is vendored in lean_abbrev.lua, generated from +-- vscode-lean4 — see that file's header and Q#LN11. +-- +-- This is a typed-edit consumer (Stage 4a, Q#LN10) registered AHEAD of +-- auto-pairing at priority 50. The ordering is load-bearing, not +-- cosmetic: 64 abbreviation keys contain a character in the `lean4` +-- pair set (`\[[]]` → `⟦⟧`, `\{{}}` → `⦃⦄`), so with pairing first, +-- typing `\[` would insert `[]` with the point between and corrupt the +-- pending key to `\[]` before the second `[` arrives — `\[[]]` becomes +-- unreachable. Priority, not load order, is what decides this; that is +-- the whole reason Stage 4a exists. +-- +-- The consumer therefore claims every keystroke that EXTENDS an open +-- pending abbreviation, not merely one that completes an expansion. A +-- consumer that claimed only completed expansions would hand each +-- intermediate `[` to pairing, which is the same corruption by a +-- different route. "Claimed" means the chain stops, not that an edit +-- was made (Q#LN22). +-- +-- UNDO IS CROSS-PEER-DEGRADED, and this is accepted rather than papered +-- over (Q#LN21). `classify_key` (src/optimistic.rs) returns `Insert(c)` +-- for `\` and for every ASCII letter — only the nine built-in pair +-- chars are excluded — so on a CRDT frontend `\alpha` arrives as six +-- SOURCE-peer optimistic inserts while the expansion is a single +-- DAEMON-peer 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: it is every +-- abbreviation the user types, not a few brackets. The general fix is +-- chronological cross-peer undo arbitration, named substrate work. +-- `set_round_trip_input` would fix it and is rejected — it also makes +-- `dispatch_idle` report false, so RET would stop inserting a newline. +-- +-- Framing: docs/lean4-mode-framing.md Q#LN11, Q#LN21, Q#LN22. + +pmacs.lean_input = pmacs.lean_input or {} + +local ed = pmacs.editor + +local LEADER = "\\" +local CURSOR = "$CURSOR" + +pmacs.config.define { + name = "lean.abbrev", + description = "Expand \\-prefixed abbreviations into Unicode symbols in Lean 4 buffers.", + type = "boolean", + default = true, + mutability = "live", +} + +-- --------------------------------------------------------------------- +-- The table, and the two indexes derived from it at load time +-- --------------------------------------------------------------------- + +-- `best[p]` is the symbol for the shortest key having `p` as a prefix, +-- ties broken by the key's position in the vendored sequence. Both +-- halves matter: 101 prefixes have equal-shortest candidates that +-- resolve to DIFFERENT symbols (`f` → `‹` from `f<`, not `›` from +-- `f>`), and the sequence's order is the only place that tie is +-- recorded. `pairs` over a map-shaped table could not express it. +-- +-- `eager[k]` marks the 1,550 keys that are complete and have no longer +-- key extending them — the ones that expand the moment they are typed, +-- with no terminator. `to` is NOT one of them (`top`, `to0`, `toa`), +-- which is exactly the case that reads as eager until the table is +-- consulted. +local best, eager = {}, {} + +do + local seq = pmacs.lean_abbrev + if type(seq) ~= "table" then seq = {} end + local extended = {} + for i = 1, #seq do + local entry = seq[i] + local key, symbol = entry[1], entry[2] + -- Walk every prefix of the key, including the key itself. Iterating + -- the sequence in order and only overwriting on a STRICTLY shorter + -- key is what makes the source-order tiebreak fall out: an equal + -- length arriving later loses to the one already recorded. + for n = 1, #key do + local p = key:sub(1, n) + local cur = best[p] + if cur == nil or #key < cur.len then + best[p] = { symbol = symbol, len = #key } + end + if n < #key then extended[p] = true end + end + end + for i = 1, #seq do + local key = seq[i][1] + if not extended[key] then eager[key] = true end + end +end + +-- Test seam (leading underscore = not stable API). Acceptance 45g reads +-- these to pin self-consistency properties a corrupt emit would break — +-- it cannot diff against `abbreviations.json`, which is not shipped. +function pmacs.lean_input._resolve(text) + local hit = best[text] + return hit and hit.symbol or nil +end + +function pmacs.lean_input._is_eager(key) + return eager[key] == true +end + +-- --------------------------------------------------------------------- +-- Pending state: one record per FRONTEND (Q#LN22) +-- --------------------------------------------------------------------- + +-- Keyed by frontend id, with the buffer stored inside and compared by +-- value. Q#LN22 specifies the key as `(frontend, buffer)`; a per- +-- frontend slot is equivalent here and avoids inventing a scalar +-- buffer key (`BufferId`'s inner value is deliberately private, R22). +-- The generality a two-level map would add is unreachable: a frontend +-- has one point, and `buffer.after-switch` clears that frontend's slot, +-- so no frontend can hold pending state in a buffer it is not in. +-- +-- Per-frontend rather than per-buffer is NOT a refinement — a buffer- +-- keyed table lets either frontend consume or discard the other's +-- half-typed abbreviation in a shared buffer, which is the ordinary +-- TUI-plus-GPU configuration this project ships. +local pending = {} + +-- Expansions the chain consumer decided on but did NOT perform, keyed +-- the same way. See `run_deferred` below for why they wait. +local deferred = {} + +local function frontend_id() + local ok, id = pcall(function() return pmacs.frontend.id() end) + if ok then return id end + return nil +end + +-- Is `rec` a typed edit that continues `p` exactly? Conservative by +-- construction (Q#LN22): abandonment is LAZY because pmacs has no +-- cursor-motion hook, so every guard that would have been checked at +-- the moment the user left is checked here instead, at the next typed +-- edit. +local function still_valid(p, rec, buf) + if p.buffer ~= rec.buffer or p.window ~= rec.window then return false end + -- The point must still be at the end of the pending span: the leader, + -- plus what has been typed into it, plus the character that just + -- landed. + if rec.effective_start ~= p.start_offset + 1 + #p.text then return false end + -- Exactly one edit since this frontend last extended the pending + -- abbreviation — the one being processed now. Deliberately strict + -- across frontends: `revision()` is BUFFER-GLOBAL, so a peer editing + -- the shared buffer invalidates this record even though it edited + -- elsewhere. Keeping it alive would mean translating and validating + -- the span through arbitrary peer edits, substrate Stage 4b does not + -- add. + local ok, rev = pcall(function() return buf:revision() end) + if not ok or rev ~= p.expected_revision + 1 then return false end + return true +end + +-- --------------------------------------------------------------------- +-- Expansion +-- --------------------------------------------------------------------- + +-- Right-gravity translation of `pos` through the effective edit — +-- pair.lua's shape, for the same reason: the point sits AFTER the +-- replaced span (on the terminator, or on a closer pairing inserted) +-- and has to move with it. +local function translate(pos, estart, estop, einserted) + if pos < estart then return pos end + if pos > estop then return pos - (estop - estart) + einserted end + return estart + einserted +end + +-- Replace the pending span (leader + typed text) with `symbol`. +-- +-- The span deliberately STOPS BEFORE the terminator. Including the +-- terminator would make the expansion and the terminator one edit, but +-- it would also swallow whatever auto-pairing did with that terminator +-- — and a pair character is a legal terminator (`\alp(`). One undo +-- restores the same text either way, because the terminator was its own +-- insert to begin with. +-- +-- ONE `buf:replace`: one undo step, one CRDT op, one effective-edit +-- verification. A rejection drops the pending state and does not retry, +-- the same discipline as comment.lua's Q#CT5 and pair.lua. +local function expand(buf, start, span_end, symbol) + local cursor_at = symbol:find(CURSOR, 1, true) + local text = cursor_at and (symbol:gsub("%$CURSOR", "", 1)) or symbol + + -- The context to compare against AFTER the edit. A buffer intercept + -- may switch window or buffer while the replace runs; the point in + -- whatever it switched to is not ours to move. + local win0 = pmacs.window.current() + local point0 = ed.cursor() + + local ok, estart, estop, einserted = pcall(function() + return buf:replace(start, span_end, text) + end) + if not ok then + ed.set_status("lean abbreviation rejected by buffer intercept") + return nil + end + if estart ~= start or estop ~= span_end or einserted ~= #text then + ed.set_status("lean abbreviation altered by buffer intercept") + return nil + end + + -- The point MUST be placed explicitly. Unlike pairing's at-cursor + -- insert, this replace SHRINKS the buffer — `\alpha` (6 bytes) + -- becomes `α` (2) — and a point left at the pre-edit offset is past + -- the new end. Every later self-insert is then silently rejected and + -- the editor looks dead. There is no daemon re-grounding that covers + -- this; that only holds for an edit that lands at the cursor. + -- + -- Context-guarded exactly as pair.lua's `repair_cursor` is: if the + -- intercept switched us elsewhere, `goto_byte` would move the point + -- of a buffer that has nothing to do with this expansion. + if pmacs.window.current() == win0 and pmacs.window.buffer() == buf then + if cursor_at then + ed.goto_byte(start + cursor_at - 1) + else + ed.goto_byte(translate(point0, estart, estop, einserted)) + end + end + return start + #text +end + +-- --------------------------------------------------------------------- +-- The consumer +-- --------------------------------------------------------------------- + +-- Chain invocations not yet matched by a `run_deferred`. +-- +-- `buffer.after-edit` fan-outs NEST: the typed-edit contract explicitly +-- supports a consumer calling `pmacs.hook.run("buffer.after-edit")`, +-- and a nested run re-enters every subscriber — including this module's +-- deferred-expansion subscriber, while the OUTER chain is still walking +-- its consumer list and pairing has not yet seen the terminator. A +-- nested run that performed the expansion would reproduce exactly the +-- bug deferring exists to fix: pairing resumes afterwards holding a +-- record the replace has invalidated, declines, and the closer is lost. +-- +-- Counting has to happen INSIDE the chain and BEFORE any consumer that +-- might start a nested fan-out. A subscriber registered alongside +-- `run_deferred` is too late — the whole nested fan-out completes +-- inside the outer chain's subscriber, before either of them runs. And +-- counting in the expander itself is not enough: a lower-priority +-- consumer may CLAIM and stop the chain before the expander is +-- reached, so a nested pass would go uncounted while its +-- `run_deferred` still ran (round 11's fix, round 12's defect). +-- +-- Hence a separate no-op consumer at the minimum priority, which runs +-- first in every chain invocation that reaches any consumer at all. +-- Its guarantee is exactly the ordering contract the chain already +-- rests on, and it degrades safely: the only thing that can skip it is +-- a claim ahead of it, which skips the expander too, so nothing is +-- queued in that fan-out either. +local depth = 0 + +local function count_fan_out() + depth = depth + 1 + return false +end + +local function on_typed_edit(rec) + local fid = frontend_id() + if fid == nil then return false end + + -- A fan-out carrying no record is still information: a paste, + -- programmatic edit or replicated op landed, so whatever this + -- frontend had pending no longer describes the buffer. Drop it and + -- decline — this is why the chain calls consumers with nil rather + -- than skipping them (Q#LN10). + if not rec then + pending[fid] = nil + return false + end + if not (ed.this_command and ed.this_command() == "buffer.self-insert") then + pending[fid] = nil + return false + end + + -- Both gates resolve against the SOURCE buffer of the typed edit, not + -- the active one — a context-switching command may have replaced it + -- by callback time (pair.lua round 2, finding 2). + if not pmacs.config.get("lean.abbrev", rec.buffer) then + pending[fid] = nil + return false + end + local lang + if pmacs.lsp and pmacs.lsp.buffer_language then + local ok, l = pcall(pmacs.lsp.buffer_language, rec.buffer) + if ok then lang = l end + end + if lang ~= "lean4" then + -- No pending abbreviation is ever OPENED outside a `lean4` buffer: + -- `\` in Rust is an ordinary character and `\[` there still pairs. + pending[fid] = nil + return false + end + + local buf = pmacs.window.buffer() + if not buf or buf ~= rec.buffer or pmacs.window.current() ~= rec.window then + pending[fid] = nil + return false + end + -- Fail closed on a transformed source self-insert, as pairing does: + -- expanding on top of a relocated or rewritten character compounds + -- the intercept's result. + if not rec.clean then + pending[fid] = nil + return false + end + -- ...and on a source edit whose context is no longer current. The + -- buffer and window matching is not enough: a redefined self-insert + -- can insert the character and THEN move the point, and expanding + -- over a span the user has left teleports them back into it. Pairing + -- makes the same three-part check for the same reason. + if ed.cursor() ~= rec.post_cursor then + pending[fid] = nil + return false + end + + local revision + do + local ok, rev = pcall(function() return buf:revision() end) + if not ok then + pending[fid] = nil + return false + end + revision = rev + end + + local p = pending[fid] + if p and not still_valid(p, rec, buf) then + p = nil + pending[fid] = nil + end + + local ch = rec.char + + -- No pending abbreviation: only the leader opens one. + if not p then + if ch == LEADER then + pending[fid] = { + buffer = rec.buffer, + window = rec.window, + start_offset = rec.effective_start, + text = "", + expected_revision = revision, + } + -- Claimed: the leader belongs to the abbreviation, and pairing + -- has no interest in it either way. + return true + end + return false + end + + -- Pending: does any key still have `text .. ch` as a prefix? + local extended = p.text .. ch + if best[extended] then + p.text = extended + p.expected_revision = revision + if eager[extended] then + pending[fid] = nil + deferred[fid] = { + buffer = rec.buffer, + window = rec.window, + start_offset = p.start_offset, + text = extended, + symbol = best[extended].symbol, + re_arm = false, + } + end + -- Claimed either way: an extension that has not yet completed must + -- NOT reach auto-pairing (`\[` in `\[[]]`), and a completing one is + -- part of the abbreviation, not a character pairing should react to. + return true + end + + -- `ch` does not extend the abbreviation: it TERMINATES it, and a + -- terminator is an ordinary character that auto-pairing is entitled + -- to react to (`\alp(` must give `α()`). So the expansion is + -- DEFERRED to the subscriber below and this returns false, leaving + -- pairing a record whose offsets still describe the buffer. + -- + -- Expanding here and returning false would not do: the replace makes + -- pairing's copy of the record stale, so pairing declines and the + -- closer is silently lost. Expanding here and returning true is + -- worse — it is what shipped in the first revision of this file, and + -- it makes every pair-character terminator silently unpaired. + pending[fid] = nil + if best[p.text] and #p.text > 0 then + deferred[fid] = { + buffer = rec.buffer, + window = rec.window, + start_offset = p.start_offset, + text = p.text, + symbol = best[p.text].symbol, + -- A terminating `\` re-arms as a NEW leader at its own position + -- (`\al\to` → `∀→`). Upstream gets this from `processChange`, + -- where a finished abbreviation reports `isAffected = false` and + -- so does not suppress the new-leader branch. This is NOT the + -- `\\` case: there the pending text is empty, `\` EXTENDS, and + -- the result is one literal backslash with nothing left open. + re_arm = ch == LEADER, + } + elseif ch == LEADER then + -- Nothing to expand, but the leader still opens a fresh + -- abbreviation where it landed. + pending[fid] = { + buffer = rec.buffer, + window = rec.window, + start_offset = rec.effective_start, + text = "", + expected_revision = revision, + } + return true + end + + return false +end + +-- The deferred expansion, on its own `buffer.after-edit` subscriber. +-- +-- It runs AFTER the whole typed-edit chain — this chunk loads after +-- typed_edit.lua, and hook callbacks run in registration order — so +-- auto-pairing has already reacted to the terminator by the time the +-- expansion rewrites the text in front of it. Pairing's closer lands +-- after the terminator, outside the replaced span, so it survives. +-- +-- It must also run BEFORE lsp.lua's subscriber (Q#AP7): that one +-- flushes `didChange` synchronously on the signature-trigger path, and +-- a server told about `\alp ` instead of `α ` stays wrong until the +-- next edit. This chunk loads before lsp.lua for exactly that reason. +-- +-- A claim by ANY chain consumer stops the chain but not this — which +-- is the point. Pairing claims the terminator it reacts to. +local function run_deferred() + -- Match off this fan-out's chain invocation. `> 1` means the outer + -- chain is still mid-list — pairing has not had the terminator yet — + -- so the queued expansion stays queued for the outer pass. The clamp + -- keeps this honest if a claim beat the counting consumer, in which + -- case nothing was queued in that fan-out either. + local level = depth + if depth > 0 then depth = depth - 1 end + if level > 1 then return end + + local fid = frontend_id() + if fid == nil then return end + local d = deferred[fid] + deferred[fid] = nil + if not d then return end + + local buf = pmacs.window.buffer() + if not buf or buf ~= d.buffer or pmacs.window.current() ~= d.window then + return + end + + -- The span must still hold exactly what was typed into it. Pairing + -- only edits at the point, which is past this span, so in practice + -- this holds; a buffer intercept is not obliged to be so polite. + local span_end = d.start_offset + 1 + #d.text + local ok, actual = pcall(function() + return buf:slice(d.start_offset, span_end) + end) + if not ok or actual ~= LEADER .. d.text then return end + + local after = expand(buf, d.start_offset, span_end, d.symbol) + if after and d.re_arm then + local rev_ok, rev = pcall(function() return buf:revision() end) + if rev_ok then + pending[fid] = { + buffer = d.buffer, + window = d.window, + start_offset = after, + text = "", + expected_revision = rev, + } + end + end +end + +-- Q#KR11's seam: a detached frontend's pending state must not outlive +-- it. Ids are monotonic, so this table would otherwise grow for the +-- life of the session. +pmacs.hook.add("frontend.detached", function(fid) + pending[fid] = nil + deferred[fid] = nil +end) + +pmacs.hook.add("buffer.after-edit", run_deferred) + +-- `buffer.after-switch` fires with NO arguments, so it cannot say whose +-- switch it was. The acting frontend is the one that produced the most +-- recent dispatched input event, which is what `pmacs.frontend.id()` +-- reports at callback time. Clearing every entry instead would let one +-- frontend's navigation discard another's half-typed abbreviation. +pmacs.hook.add("buffer.after-switch", function() + local fid = frontend_id() + if fid ~= nil then pending[fid] = nil end +end) + +-- Runs first in every chain invocation that reaches a consumer at all, +-- which is what makes the nesting count trustworthy — see `depth`. It +-- declines, always: it observes, it does not participate. +pmacs.typed_edit.add_consumer { + name = "lean-abbrev-fan-out-counter", + priority = -2147483648, + fn = count_fan_out, +} + +pmacs.typed_edit.add_consumer { + name = "lean-abbrev", + priority = 50, + fn = on_typed_edit, +} 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..ef2fea4 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 { @@ -17,21 +49,398 @@ local function bind_terminal_keys(buffer) bind("C-v", "terminal.page-down") bind("M-<", "terminal.scroll-oldest") bind("M->", "terminal.scroll-bottom") + -- Q#TC8a/Q#TC9: copy mode is ADDITIVE. The live keys above are + -- unchanged; this is one more leaf beside them. `C-t` is globally + -- `edit.transpose-chars`, which is meaningless in a read-only + -- terminal buffer, and binding it buffer-locally is the scoped + -- idiom rather than a shadow — `keymap.bind`'s strictness rejects + -- binding a PREFIX of an existing sequence within a scope, not + -- cross-scope shadowing. + -- + -- Physically typed as `C-c C-t`: in a terminal every unescaped key + -- goes to the child, so terminal-local bindings are reached through + -- the escape. That also matches emacs-libvterm's own chord. + bind("C-t", "terminal.copy-mode") +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).", + 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" } + +-- === Copy mode (Stage 2, Q#TC6) ========================================= +-- +-- `terminal.copy-mode` MATERIALIZES the retained rows into an ordinary +-- read-only document buffer instead of adding a modal state to the +-- terminal. That choice is the whole design: +-- +-- * isearch, motion, selection, `M-w` and the kill ring all work with no +-- new substrate — the snapshot is a rope, so `SearchStore` and the +-- existing match painting apply unchanged; +-- * "keys must not reach the child" dissolves structurally rather than +-- being guarded: the transport arm keys on `is_terminal(buffer)`, and +-- a snapshot buffer is not a terminal, so it never fires; +-- * the dispatch-shadow count stays at SIX (`COHERENCE.md` §6) and +-- `describe-key` keeps telling the truth, because the bindings are +-- buffer-local and inspectable. + +local raw_copy_retained = assert(terminal._copy_retained, + "pmacs.terminal._copy_retained is required") + +-- An ARRAY of `{ terminal = , buffer = }`, scanned linearly and +-- compared with `==`, following dired's handle table (F7). +-- +-- Not `snapshots[name]`, and not `snapshots[buf]`, for two separate +-- reasons — both of which were live defects in review round 1: +-- +-- * **A terminal name is not a unique key.** `TerminalManager::open` +-- uniquifies only the DERIVED name; an explicitly passed +-- `name = "*same*"` is inserted verbatim +-- (`src/terminal/session.rs`, `if spec.name.is_some()`). Two valid +-- terminals can therefore share a name, and a name-keyed table gives +-- them one snapshot between them: the second invocation silently +-- retargets it, `q` returns to the wrong terminal, and killing either +-- one removes the shared buffer. +-- * **A buffer handle is not a stable table key.** `BufferIdLua` +-- implements `__eq` but each wrapper is a distinct table key, so +-- `snapshots[buf]` would miss on a freshly minted handle for the same +-- buffer. Comparison works; hashing does not. Hence the scan. +local handles = {} + +-- Compact dead entries first, so a command in a killed snapshot sees +-- "not in copy mode" rather than operating on dead state. +local function live_handles() + local live = {} + for _, h in ipairs(handles) do + local term_ok, term_valid = pcall(h.terminal.is_valid, h.terminal) + local snap_ok, snap_valid = pcall(h.buffer.is_valid, h.buffer) + if term_ok and term_valid and snap_ok and snap_valid then + live[#live + 1] = h + end + end + handles = live + return live +end + +local function handle_for_terminal(term_buf) + if term_buf == nil then return nil end + for _, h in ipairs(live_handles()) do + if h.terminal == term_buf then return h end + end + return nil +end + +local function handle_for_snapshot(buf) + if buf == nil then return nil end + for _, h in ipairs(live_handles()) do + if h.buffer == buf then return h end + end + return nil +end + +local function buffer_name(buf) + local ok, described = pcall(pmacs.describe.buffer, buf) + if ok and described then return described.name end + return nil +end + +local function buffer_named(name) + for _, id in ipairs(pmacs.buffer.list()) do + local ok, described = pcall(pmacs.describe.buffer, id) + if ok and described and described.name == name then return id end + end + return nil +end + +-- `*terminal:bash*` -> `*terminal-copy: terminal:bash*`. The surrounding +-- asterisks are stripped before nesting so the result reads as one +-- generated-buffer name rather than two. +local function snapshot_base_name(term_buf) + local name = buffer_name(term_buf) or "terminal" + return string.format("*terminal-copy: %s*", (name:gsub("^%*", ""):gsub("%*$", ""))) +end + +-- How far the `<2>`, `<3>`, ... disambiguation walks before giving up. +local NAME_VARIANT_LIMIT = 99 + +-- `pmacs.buffer.create` takes any caller-chosen name, so a foreign buffer +-- may already be called `*terminal-copy: sh*` — and two same-named +-- terminals legitimately produce the same base name. Painting into a +-- buffer we did not create would clobber a user's data through +-- `bypass_intercept`, so **found-by-name is NOT adoption**: ownership +-- means "this buffer is in the handle table above", exactly as in dired. +local function unique_snapshot_name(term_buf) + local name = snapshot_base_name(term_buf) + if buffer_named(name) == nil then return name end + for i = 2, NAME_VARIANT_LIMIT do + local candidate = string.format("%s<%d>", name, i) + if buffer_named(candidate) == nil then return candidate end + end + error(string.format( + "terminal.copy-mode: %s is taken and no free variant remains", name), 0) +end + +-- Q#TC7: the snapshot text comes from the SAME serializer selection-copy +-- uses, so soft wraps, wide glyphs, clusters and trailing blanks cannot +-- drift between the two. +local function render_snapshot(record) + local text = raw_copy_retained(record.terminal) or "" + -- The owner-authorized write, and the ONLY one this buffer accepts. + -- + -- Not `delete`+`insert` with `bypass_intercept` (review round 2): that + -- leaves the buffer writable at the rope, and it leaves undo history + -- behind. `Buffer::undo` reaches the rope through `ensure_writable` + -- without consulting the intercept chain, so a single `C-/` — or + -- `M-x buffer.undo`, which no buffer-local rebinding can take away — + -- replaced a freshly rendered snapshot with an empty buffer. + -- `set_generated_contents` writes, discards the history, and leaves + -- `read_only` asserted, so undo/redo and remote CRDT imports are all + -- refused at the rope. Its binding also fans the resulting edit out to + -- the windows showing this buffer and to replica mirrors (review round + -- 3) — a rope write alone leaves a displaying window indexing the new + -- contents with stale line offsets. + pmacs.buffer.set_generated_contents(record.buffer, text) +end + +local function claim_snapshot(term_buf) + -- Q#TC8: re-invoking against the same terminal refreshes IN PLACE. + -- Identity is the terminal BUFFER, so two same-named terminals get two + -- snapshots and neither can retarget the other's. + local existing = handle_for_terminal(term_buf) + if existing then return existing end + + local name = unique_snapshot_name(term_buf) + local buf = pmacs.buffer.create(name) + local record = { terminal = term_buf, buffer = buf } + handles[#handles + 1] = record + + -- Q#TC6a — BOTH calls, and the protection is now LAYERED. Review + -- round 2 changed what each one is for. + -- + -- `set_generated_contents` leaves `read_only` asserted at the rope, so + -- on the DAEMON side undo, redo, ordinary edits and imported CRDT ops + -- are all refused by `ensure_writable()`. The intercept below is no + -- longer the daemon's guard; it survives to give a dispatching edit a + -- named error instead of a bare refusal. + -- + -- `set_round_trip_input` still guards the half `read_only` cannot + -- reach: a semantic frontend applies optimistically in its own MIRROR + -- before the daemon ever sees the op. `dispatch_idle_for` reports + -- false while this buffer is focused, so the mirror never mutates and + -- no op is emitted to be refused. That is the layering — rope-level + -- read-only protects the daemon copy, round-trip input protects the + -- replica copy — and neither substitutes for the other. + pmacs.buffer.add_intercept(buf, function() + error(name .. " is read-only") + end) + pmacs.buffer.set_round_trip_input(buf, true) + + pmacs.keymap.bind { scope = "buffer", buffer = buf, + sequence = "g", command = "terminal.copy-refresh" } + pmacs.keymap.bind { scope = "buffer", buffer = buf, + sequence = "q", command = "terminal.copy-quit" } + + -- Q#TC8 lifecycle, both directions. Killing the terminal takes ITS + -- snapshot with it — `record`, captured here, not "whatever is + -- currently filed under this name"; killing the snapshot alone leaves + -- the terminal running, and `live_handles` compacts the entry out so a + -- later invoke rebuilds. + -- + -- `on_removed` is sound here because every user-facing kill path + -- routes through `pmacs.buffer.kill`, which fires the callbacks. The + -- terminal manager's own `prune` does not — but it never removes a + -- buffer either; it REACTS to one already gone from the registry. A + -- child exiting therefore leaves both the terminal and its snapshot + -- alive, which is what makes reading back a finished command's output + -- work at all. + pcall(pmacs.buffer.on_removed, term_buf, function() + local ok, valid = pcall(record.buffer.is_valid, record.buffer) + if ok and valid then pcall(pmacs.buffer.kill, record.buffer) end + end) + + return record +end + +-- The snapshot record whose buffer the active window shows, or nil. +local function snapshot_for_current_buffer() + return handle_for_snapshot(pmacs.window.buffer()) +end + +function terminal.copy_mode(term_buf) + term_buf = term_buf or pmacs.window.buffer() + assert(term_buf, "terminal.copy-mode: no active buffer") + if not terminal.is_terminal(term_buf) then + error("terminal.copy-mode: the current buffer is not a terminal", 0) + end + local record = claim_snapshot(term_buf) + render_snapshot(record) + pmacs.window.switch_buffer(record.buffer) + return record.buffer +end + +pmacs.command.define { + name = "terminal.copy-mode", + description = "Open a searchable read-only snapshot of this terminal's scrollback.", + fn = function() return terminal.copy_mode() end, +} + +pmacs.command.define { + name = "terminal.copy-refresh", + description = "Re-snapshot the source terminal into this copy buffer.", fn = function() - return terminal.open { - command = os.getenv("SHELL") or "/bin/sh", - } + local record = snapshot_for_current_buffer() + if not record then return end + if not record.terminal:is_valid() then + pmacs.editor.set_status("terminal.copy-refresh: the source terminal is gone") + return + end + render_snapshot(record) + end, +} + +pmacs.command.define { + name = "terminal.copy-quit", + description = "Return to the terminal this copy buffer was taken from.", + fn = function() + local record = snapshot_for_current_buffer() + if not record then return end + if record.terminal:is_valid() then + pmacs.window.switch_buffer(record.terminal) + end end, } 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 e031b4a..6a47763 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,10 +1,23 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-25.** This file records volatile work that has not +**Snapshot: 2026-07-28.** 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. +**One lane below is retained past its merge, and says so at its own +head**: the PTY terminate diagnostic (#176), because no landed-doc PR +owns moving its facts to `docs/agent-handoff.md` yet, and rule 4 removes +a lane only *after* that move. Every other merged lane has been removed — +the Lean 4 and GPU-terminal-input headers this paragraph used to +disclaim are gone, as are the inline-math (#172), dired (#169), and +terminal config + copy mode lanes — the last of these was #180's work, +folded into #182 so two open PRs would stop re-conflicting in this file. + +**Trust the canonical-base line below over any lane header**: if a PR +number appears in `git log --first-parent githubsucks/main`, it has +landed regardless of what a lane says. + ## Repository authority - Canonical development URL: @@ -14,11 +27,16 @@ backlog. 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` @ `d152120` (the bottom-panel landed-doc refresh #156 - atop 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` @ `7fd646d` (Journey/GPU directory-target ratchet + #183, atop Journey Stage 1a #182 and the previously recorded landed + work; protocol v20). The previous snapshot named `c2d56ff`, and **the + recovery floor advances with it**: the check below now requires + `7fd646d` or newer, so a tree at `c2d56ff` no longer passes. That is + deliberate — the floor moves with the base, because a check that + accepts an older commit than the declared base passes on a tree the + rest of this file does not describe. + **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 `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -52,365 +70,487 @@ git worktree list git status --short --branch ``` -The `git log` command must expose `d152120` or a newer intentional main. +The `git log` command must expose `7fd646d` — the base named above — or a +newer intentional main. Keep this threshold and the canonical-base line in +step: a recovery check that accepts an older commit than the base it +declares canonical will pass on a tree the rest of this file does not +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) +## PTY terminate diagnostic lane — MERGED (PR #176) -- 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) +> **Lane retained deliberately, and it is the next one to close.** #176 +> merged into `main` @ `bf8878f` (2026-07-26); rule 4 below removes a +> merged lane, but only after its durable facts reach +> `docs/agent-handoff.md`. **That absorption is unowned** — no landed-doc +> PR exists for #176 — so removing the lane now would delete the record +> instead of moving it. Whoever opens that PR removes this section. -- 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`. +- Portable branch: `githubsucks/pty-terminate-eperm`; worktree + `../pmacs-math-slice`. **PR #176**, base `main`, based on `ccf29e3` + with `c93f9ee` (#175) merged in. +- Approved framing: `docs/process-signal-tolerance-framing.md` + **revision 4**, after three review rounds. +- **Diagnostic only. No disposition change.** Every call that failed + before still fails, with no state transition and no reap-ledger + arming. `src/process.rs` is the only source file touched. +- **Why nothing is fixed:** revisions 1–3 each proposed a *tolerance* + rule and all three were rejected as unsound in the same way — each + concluded something about a process from something that was not about + that process. Rev 1 from an errno alone (EPERM means the caller lacks + permission, not that the id was recycled); rev 2 from `try_wait`, + which observes the spawned **leader** while a PTY signal targets + `-tcgetpgrp(...)`, entities that diverge exactly when job control has + moved the terminal; rev 3 from group-directed **ESRCH**, which proves + only that the selected foreground group vanished. +- **Two facts that killed the original argument.** `group = true` is + *rejected* for PTY mode at spawn (`src/process.rs:1428-1429`), so the + reap ledger never applies to the PTY path at all; and the ledger + comment (`:1075`) says EPERM "cannot happen for our own children" and + drops the entry for **bounded growth** — not a ruling that EPERM means + dead. +- **The CI evidence never established the child had exited.** The probe's + last source statement is a file write and CPython teardown does not + synchronise with it, so no tolerance rule could even be shown to fix + the symptom. That is the whole reason the lane is diagnostic. +- What ships: a failing `kill` now reports five separate facts — target + source, target kind/value, spawn-time group, errno, and the leader's + real `try_wait` state. The test seam injects the **kill result only**, + never the observation, so the real `ChildHandle::try_wait` runs against + the real child. +- **Not "strictly additive".** `try_wait` reaps and caches, so an exited + child may be reaped earlier than otherwise. Safe because + `portable-pty` 0.9.0 returns a `std::process::Child` on Unix and + delegates `try_wait` to it, so `poll_one` still sees the cached + status — pinned by an exactly-one-terminal-event test rather than + assumed. +- Round-1 review fixes: the exited-child tests no longer use a fixed + sleep as proof of exit (nix's `waitid` is unavailable on macOS and + `libc::waitid` needs `unsafe`, which the crate forbids), instead + driving the production diagnostic in a bounded loop until it observes + the exit; and every assertion is now exact message equality built from + the kernel-assigned pid, since the substring forms would have accepted + a hardcoded target or a wrong exit code. +- Bites, all verified rather than assumed: tolerating the failure fails + the disposition test; stubbing the leader observation fails three + tests including the one-event pin; a hardcoded target fails four; a + wrong exit code fails two. +- **The sweep found a real defect in these tests, not a flake.** + `observing_the_leader_does_not_consume_the_exit_event` failed with + "process ProcessId(26) is not running": the pid helper drained for + `Started`, and **`drain_until` ticks**. A tick can observe an + immediately-exiting child and move the record out of `Running`, after + which `signal` never reaches the diagnostic at all, so the bounded + loop spun to its limit. It passed standalone because the drain + returned on `Started` before `poll_one` saw the exit; only load lost + the race. Fast-exiting children now read the pid straight from the + supervisor record (no tick), and the loop fails fast if the record + left `Running`. **Verified under matched load: 0/15 with all 16 cores + saturated, while the old ticking helper fails 1/10 — the fix is + load-bearing.** +- Verification: fmt, `git diff --check`, strict workspace clippy clean; + lib 1,838 + CRDT 2,015 (both +6, exactly the new tests); GPU 202; M4 + 121; bottom-panel 46; compile-mode 67; vterm 9/6/5; **isolated-config + `--no-fail-fast` sweep 3,258 across 93 suites, zero failures**. + Earlier sweeps on this branch showed two failures and then one; the + totals reconcile (3,256/2 → 3,257/1 → 3,258/0, same test count). The + two that were genuinely unrelated — + `read_dir_supersede_cancels_in_flight_predecessor` (known + pre-existing) and + `headless_snapshot_round_trip_summary_restores_the_minimap` — are + load-contention flakes; the second is structurally unreachable from + this diff, since `pmacs-gpu` depends on `pmacs-protocol` and never on + `pmacs`. +- **Parked, each with its reason:** all tolerance rules (need the + evidence this PR produces); `terminate` idempotence for an + already-reaped process (independent fix, different failure, one + feature per PR); and `signal_target`'s read-then-kill of `tcgetpgrp` + — still the most likely real fix site. +- **The lane closes when this merges.** It does not wait for the flake + to recur; the next occurrence carries its own evidence under whoever's + PR, and a Stage B framing follows then. -## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) +## The CRDT half of the test corpus is dark in CI — NEEDS A LANE -- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the - approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1 - implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3 - (wdired) each get their own detailed framing after the prior stage lands. -- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`, - 2026-07-25, one review round, 12/12 CI green). Durable facts moved to - `docs/agent-handoff.md` §1 per rule 3 below. -- **Stage 1 branch: `githubsucks/dired-stage1`**, worktree - `../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the - framing merge #164). **A fresh cut, not a rebase:** the older `dired` - branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the - superseded `0827dd1` and carried only the framing content #164 already - put on `main`, so merging it would have reconciled two histories of one - document. It is left untouched and carries nothing unmerged. -- **Stage 1 implemented; no wire change (protocol stays v20).** What - landed on the branch: - - `builtin/runtime/dired.lua`: one buffer per directory named - `*dired:*` with the handle-table ownership check; - read-only intercept + `set_round_trip_input`; the `dired` major mode - and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`); - basename cursor re-seating across every wholesale repaint; - `display_file` for file visits and same-window reuse for directory - descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting. - Loaded after `window.lua`. - - `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`, - and one walk that either fails on a per-entry condition or records it - (Q#DR6). `src/async_runtime.rs` carries the listing in - `ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs` - keys the Lua result **shape** on `errors.is_some()`, so the bare array - the frozen M8.2 fixture consumes with `ipairs` is untouched; - `builtin/runtime/fs.lua` validates read-op opts and **rejects unknown - keys** (a typo'd `tolerant` used to degrade silently to fatal). - - `src/editor_core.rs` + `src/lua_bindings/mod.rs`: - `normalize_buffer_path` is `pub` and exposed as - `pmacs.path.canonicalize` — Q#DR2's preferred end state, so no Lua - mirror exists and Stage 2 owes no mirror removal. This makes B2 - ("tolerant `read_dir` is the only Rust change") false by one small - binding, deliberately. - - `tests/dired_acceptance.rs`: 22 tests over framing items 1–16, - dispatch-driven; item 17 is the m8_1/m8_2/m8_3 additivity gate. -- **The framing claim the substrate falsified (S1-2):** R2-3 expected a - dedicated dired panel to carry its dedication across a descent. - `display_buffer` never replaces the buffer in a slot dedicated to - another one — it discards every side-specific parameter and falls back - to the document window (Q#BP3 2.iii), and the exact-window arm errors. - Dired does not unpin the user's panel; both arms are pinned. -- **The vacuity the bites found (S1-3):** acceptance 3c cannot pin the - descent *routing*. Dired holds focus in its own panel, so a raw - `switch_buffer` lands in the same window and every 3c assertion holds - either way. Dedication is the only discriminator, so the - dedicated-panel test is the real pin — and the vacuity is documented at - the assertion rather than relabelled. -- **The pre-existing test dired's first mode-scoped binding broke - (S1-4):** `describe_key_identifies_every_default_binding` asserted every - binding in the stack resolves through `describe.key` context-free, which - held only while the modes table was empty. It now sets the effective - context per binding and explicitly *clears* the mode for global ones, - because a leaked mode legitimately shadows a global chord of the same - name (dired's `RET` shadows `edit.newline-and-indent`). -- Durable substrate facts, independent of this arc: - - `pmacs.buffer.kill` (not `remove`) redirects windows off a doomed - buffer before removal, so `kill-when-opening` kills **after** the - replacement is displayed. - - Interactive origin does **not** survive an await: work resumed in - `tick_async` sees no `InteractiveCommandOrigin`, so `pmacs.window.*` - acts for the *ambient* active frontend (S1-9). - - Kinds are lstat-based in both `read_dir` and `stat`, so nothing in an - entry says whether a symlink points at a directory; `RET` probes by - trying to list it (S1-8). - - A path-backed buffer's *name* is its full path, not its basename — - worth knowing before writing any name assertion. - - `C-x d` takes **no** completion source on purpose (S1-5): with one, - RET on an empty field opens whatever sorts first, and - RET-on-where-you-are is the gesture the binding exists for. The field - is prefilled instead. -- **Bite verification:** 15 claims, each mutated in place and required to - fail the test that names it. `dired.lua` is new, so `scripts/bite`'s - file swap does not apply; every mutation was applied and reverted with - `git checkout --`. One came back VACUOUS and is recorded above. -- **Review round 1 addressed** (framing rev 7, S1-10…S1-12). Three - behavioral fixes, each bite-verified: `dired.revert`'s re-seat is - guarded on the active buffer (an ambient `move_to_line` after an await - moved an unrelated buffer's cursor — the buffer-level instance of - S1-9); `fmt_size` keeps the column width past ten digits, because - `_layout` is a contract Stage 3 is planned against; and the symlink - descent dropped its probe, since `open_directory`'s - changed-nothing-on-failure invariant *is* the probe (it was listing the - target directory twice). Plus a consecutive-`readdir`-error cap, because - **nothing cancels a dired listing** — it carries no supersede key, so - cancellation was never the backstop the tolerant loop implicitly relied - on. Naming/comment findings taken as-is. - - Durable process lesson, hit twice now: a mutation-bite helper restores - with `git checkout --`, which reverts to **HEAD** — so a fix must be - committed *before* it is bitten. Round 1's fixes were briefly wiped by - exactly that. -- **Canonical main integrated twice** — at `46a1b8f` (multi-root LSP - affinity #161) and again at `b889873` (GPU terminal input #166), both - merged rather than rebased per the #135/#137 precedent so the review - anchors stay addressable. Each conflict was a single doc hunk resolved - as the union: this lane owns COHERENCE's journey step 7 file half, #161 - owns the in-flight list, #166 owns step 8's GPU-terminal addendum. - Three things worth carrying: - - **A conflicting PR silently stops running CI.** GitHub builds - `pull_request` runs against the merge ref, which does not exist while - the PR conflicts, so no run is created and nothing reports a - failure — the checks list simply stays as it was. Three pushes to - this branch produced no CI at all before the cause was found. Watch - `mergeable` on a long-lived lane, not just the check list. - - #161's own COHERENCE finding **falsified a claim in this lane's - module doc**: `pmacs.error` is never defined in production, so an - uncaught raise inside a `pmacs.async` coroutine does not reach - `*errors*` as the comment said. It reaches a bare `error()` inside - `pmacs._async.tick()`, whose result `tick_async` discards with - `let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` + - `set_status` load-bearing rather than tidy, and the comment now says - so. - - **A lane in review against a fast-moving `main` needs its gates rerun - per integration, not per push.** Main advanced twice inside this - review round, and the second time landed while the first - integration's sweep was still running. The numbers below describe the - twice-merged tree. -- Verification on the twice-merged tree (`main` @ `b889873`): - `cargo fmt --check` clean; strict workspace Clippy clean; **1,832 - default + 2,009 CRDT** library tests; dired acceptance **25 default + - 25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; multi-root 13 and - vterm Stage 3 5 (both suites main added, green under this lane's - `mod.rs` and `editor.rs` changes); M4 121; required GPU 155; - **isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 passed across 93 - suites, zero failures**; `git diff --check` clean. The sweep needs the - isolated config for the reason recorded in the bottom-panel lane - below. -- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` §20 - Priority 1, which names this work explicitly; journey step 7's file half - goes from no surface to a surface; **adds no interaction island** — keys - are a mode-scoped keymap, and wdired will be a mode swap; adopts - `pmacs.config` for `dired.kill-when-opening`; inherits §9's - worker-attribution gap for its `read_dir` jobs without worsening it. The - audited claims this changes are updated in `COHERENCE.md` itself, per its - §25. -- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut - 1): CLI directory-argument handling (`pmacs .` exits 1) belongs there, - not here — Stage 1 does **not** fix it. The two meet at - `resolve_target_buffer`; dired supplies the buffer a directory should - resolve *to*, and `pmacs .` should route into it rather than growing a - second directory surface. +- **No branch, no framing yet.** Found while gating #166, then measured + properly during the vterm as-framed audit. Deliberately kept out of #166 so + a CI change would not arrive after review approval. +- **Root cause:** `.github/workflows/ci.yml` never enables the `crdt` feature + anywhere — zero hits across the workflow directory. The `test` job runs + `cargo test --all-targets --no-default-features --features luajit|lua54`. + Every `#[cfg(feature = "crdt")]` test is therefore **not compiled** in CI, + not merely skipped. +- **Measured, `--list` under CI's exact flags versus the same flags plus + `crdt`: 3,176 vs 3,449 — 273 tests dark.** Re-measured at `74301d1` + (2026-07-26; at `fe8b8ba` it read 3,170 vs 3,443, the same 273 dark — + #176 added six tests, none of them `crdt`-gated). **The number moves + with every merge and must be + re-measured, not quoted.** #168 reported 3,024 vs 3,288 — 264 dark, + 177 in the library — at `1b6a084`; #178 then added CRDT-only + generated-buffer coverage, and other lanes landed CRDT tests in + between. Per target: -## GPU terminal input lane — IN REVIEW + | dark | CI | full | target | + |---:|---:|---:|---| + | 185 | 1,848 | 2,033 | **the library itself** (`src/lib.rs`) | + | 21 | 15 | 36 | `m5_5_acceptance` | + | 13 | 1 | 14 | `gpu_invocation_acceptance` | + | 13 | 1 | 14 | `gpu_initial_target_acceptance` | + | 8 | 0 | 8 | `m10_11_acceptance` | + | 6 | 0 | 6 | `auto_pair_crdt_acceptance` | + | 6 | 0 | 6 | `m10_2_perf` | + | 4 | 5 | 9 | `vterm_stage3_acceptance` | + | 4 | 0 | 4 | `m10_10_perf` | + | 3 | 0 | 3 | `compile_mode_crdt_acceptance` | + | 2 | 22 | 24 | `theme_faces_acceptance` | + | 2 | 0 | 2 | `m11_5_semantic_acceptance` | + | 1 | 14 | 15 | `terminal_copy_mode_acceptance` | + | 1 | 9 | 10 | `vterm_stage1_acceptance` | + | 1 | 7 | 8 | `statusline_segments_acceptance` | + | 1 | 10 | 11 | `gpu_font_acceptance` | + | 1 | 0 | 1 | `auto_indent_crdt_acceptance` | + | 1 | 0 | 1 | `m10_11_perf` | -- Portable branch: `githubsucks/gpu-terminal-input`, worktree - `../pmacs-gui-term-input`, based on `githubsucks/main` @ `46a1b8f`. -- Approved framing: `docs/gpu-terminal-input-framing.md` revision 2, - committed as the branch's first commit (`9a0df21`). Bug fix, not a - feature; **no protocol change (stays v20)**. -- Reported as "text input within the terminal doesn't work on GUI, this is - fine in TUI". Root cause: the dispatcher applied **both** terminal-layout - syncs to **every** attached frontend, and a semantic session satisfies both - conditions (a `term_sizes` entry from `AttachRequest` *and* a terminal - declaration). Its PTY was resized twice per tick forever — grid arm installs - the TUI placement size, semantic arm installs the declared content - rectangle, each arm's idempotence guard seeing only what the other just - wrote — so the child took a `SIGWINCH` storm at tick cadence. -- **The fix is a split, not a guard.** The grid arm is also the only per-tick - controller-liveness release a semantic frontend gets, and - `sync_semantic_terminal_layout` cannot take that over: the buffer-follow - snapshot clears the viewport declaration (`on_buffer_snapshot_sent`), so - that arm stops running in exactly the switch-away case that needs the - release. `sync_terminal_layout` is therefore split into a - frontend-kind-neutral half (panel reconcile + liveness) and a grid-only - geometry half, with the loop body extracted to - `sync_terminal_layouts_for_tick` so the exclusivity is structural and tests - drive the real thing. -- **Trap for anyone touching this again:** the release at the "no - `window_placements` entry" arm reads like liveness and is grid geometry. A - semantic frontend has no placement entry at all, so moving it into the - neutral half releases a GPU controller every tick. -- Bite-verified against **two** pre-images, because the naive guard fixes the - storm and introduces the leak: + The rows sum to 273; the table is the whole census, not its head. - | pin | `main` | naive guard | the split | - |---|---|---|---| - | settle (acc 2+3) | FAIL | pass | pass | - | controller release (acc 6) | pass | FAIL | pass | - | grid still resizes (acc 5) | pass | pass | pass | +- **The single worst line is the library.** `cargo test --lib --features crdt` + is a REQUIRED local gate in `CLAUDE.md`, and CI has never run it. 185 + library tests — the whole CRDT half — are developer-machine-only, and + that count grows with every merged branch that adds a `crdt`-gated + unit test. +- **Ten suites run zero or one test in CI**, including `gpu_initial_target` + (#148's entire acceptance, 1/14), `gpu_invocation` (#141's, 1/14), and + `a37`, the Vterm Stage 3 real-daemon/real-PTY/real-wgpu path that #135 + built specifically because "a decoded-message fixture would prove none of + the three fit together". +- **⚠ `a37` will report green in the new job without running, unless the + job builds `pmacs-gpu` AND sets `PMACS_REQUIRE_GPU=1`.** Measured + 2026-07-26 while gating #173. `a37_real_daemon_real_pty_and_headless_gpu_ + render_one_terminal_session` derives its sibling binary path from + `CARGO_BIN_EXE_pmacs`, and on a missing binary it `eprintln!`s a skip and + **returns `ok`**. A fresh worktree running + `cargo test --features crdt --test vterm_stage3_acceptance` reports **9/9 + in 0.17 s having never run it**; a real run takes ~4 s. Only + `PMACS_REQUIRE_GPU=1` promotes that skip to a failure, and `CLAUDE.md` + applies that flag to `cargo test -p pmacs-gpu` — a **different package**, + so the required local gate does not cover a37 either. The `gpu-render` + job already sets the flag, which is what makes fix-shape part 2 sound; + state it as a **requirement** of that job rather than inheriting it by + luck, because a `crdt` leg added to the plain `test` job would run a37 + vacuously. +- **`a37` is also load-sensitive, which changes how to read the expected + first-run failures.** It passed at `d152120` and failed at that *same + commit* twenty minutes later, with a second agent saturating the machine + with `rustc` in between; it then failed identically on `d152120`, + `04c5ad1`, and the #173 merge commit, which is how #173 established the + failure was not its own. The signature is `last_frame_text` all spaces + with `rendered_nonuniform_frames` nonzero — frames arrive, content does + not. `pmacs-gpu`'s own suite flaked the same way under the same load + (201/202, then 202/202 on immediate rerun). **So a red a37 on the first + CI run is ambiguous by construction**: before treating it as a real + failure, run the same command on the merge base, and prefer serialized + execution for this suite over retry-until-green. +- **Sort deliberate from accidental before proposing a fix.** Some of the 264 + are perf suites that are `#[ignore]`d by default and belong to their own + jobs (`m10_2_perf` 6, `m10_11_perf` 1). `m10_10_perf` has **no** `#[ignore]` + and no CI job naming it, so it looks accidental. This classification is not + finished and is the lane's first task. +- **Fix shape, two parts** (the flag combination is verified to work: + `--no-default-features --features luajit,crdt` lists 10 vterm Stage 1 tests + versus 9 without): + 1. a `crdt` leg on the `test` job for the non-GPU suites and the library; + 2. the GPU-requiring `crdt` suites onto the existing `gpu-render` job, which + already has lavapipe and `PMACS_REQUIRE_GPU=1` — + `vterm_stage3_acceptance`, `gpu_invocation_acceptance`, + `gpu_initial_target_acceptance`, `gpu_font_acceptance`. +- **Expect first-run failures, and budget for them.** These would execute in + CI for the first time ever: real PTY timing on CI runners, wgpu under + lavapipe, and daemon-socket tests at unfamiliar concurrency. Start + ubuntu-only and decide about macOS from evidence. A red first run is the + lane working, not the lane failing. +- Mitigating fact, verified rather than assumed: #166's three unit pins are + **not** `crdt`-gated and do run under CI's exact flags, including the + controller-release pin whose only job is catching the plausible wrong fix. +- **This lane also owns a `--lib --features crdt` flake, observed and + scoped without overclaiming its cause** (inherited from #178's gating, + where the terminal lane recorded it). `cargo test --lib --features + crdt` failed ~1 run in 5 on + `process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` + — `active_reader_probe` returning `None` at `process.rs:3179` ("live + runtime probe"). **Pre-existing and unrelated to #178:** that branch + did not touch `src/process.rs` at all, and the test passed 10/10 + standalone; the observed + failures were during parallel full-suite runs. That localizes the + trigger to suite load or interaction, but does **not** distinguish + parallelism from another full-suite effect — no serial full-suite bite + was run. The leading code-path explanation is the known `drain_until` + trap: draining for `Started` also ticks, and a tick can reap the leader + before the following `active_reader_probe`. That is an inference from + the failure site and control flow, not yet a falsified root cause. + Discriminating it belongs here. Two unnamed CRDT failures in #178's + round-2 gating are a plausible match but remain **unattributed** — no + test names were captured. +- **A second standing obstacle for this lane:** `cargo clippy --workspace + --all-targets --features crdt -- -D warnings` **fails on `main`** — + measured at `74301d1`: seven errors before the build aborts, four in + `src/daemon.rs` (`useless_conversion` at 3996, missing doc backticks at + 4076, `too_many_lines` 112/100 at 4083, an unneeded `mut` at 4965) and + three in `tests/vterm_stage3_acceptance.rs` (`too_many_lines` at 637 + and 793, a redundant `continue` at 843). **Treat that as a lower + bound, not an inventory:** Clippy abandons the remaining targets once + one fails, and a run on an older tree surfaced a further doc-backticks + error in `tests/auto_indent_crdt_acceptance.rs:42` that this run never + reached. The + standing gate list runs Clippy without `crdt`, so these lints have + never been enforced. Any CI job that compiles the `crdt` targets has to + fix them first or it will be red on arrival. -- Real-path evidence: a quiet child trapping `SIGWINCH` reports **144 frames - in 4 s and `WINCH 1..12` on screen** against the pre-fix tree, versus a - settled screen with the fix. -- **Deliberately out of scope, named:** interactive-shell echo on a raw-mode - PTY (Q#GT5 — reproduces in-process too, so it is not the GUI/TUI - asymmetry), and a geometry change appearing to clear the visible screen - (reproduces pre-fix; why acceptance 4 latches its observation across - frames). -- Verification on this branch: `cargo fmt --check` clean; strict workspace - Clippy clean; 1,829 default + 2,006 CRDT library tests; vterm Stage 1/2/3 - 10 / 6 / 9 CRDT; bottom-panel Stage 1 46; M4 121; required GPU 155; - **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) — 2B-1 REGATED; PR #184 OPEN FOR REVIEW -## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 (GPU band) is next +Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing +revision 5's three-way split of 2B was explicitly approved on +2026-07-27; revision 6 records PR #184's review correction. **Stage +2B-1 is implemented and integrated with canonical `main` @ `7fd646d`. +Review round 2's four findings are corrected at `ab7c207`; the +gate-found GPU/PTY probe barrier is corrected and the complete suite is +green at `9e20175`. The follow-up fixture-specific probe correction is +committed and proportionally regated at `9c79ce1`. PR #184 is open and +must not merge before user review.** -Stage 1 is on `main`; nothing in this arc is in flight. Stage 2 has **no -branch and no framing yet** — the approved parent framing -`docs/bottom-panel-framing.md` (rev 4) is what it re-scouts against. +- **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on + `githubsucks/main` @ `7fd646d` by merge because review had begun. + Recovery: `git fetch githubsucks && git checkout + bottom-panel-stage2b`. Everything described through the integration + and gate checkpoint is committed and pushed; nothing depends on a + worktree or `/tmp`. PR #184: + . +- **Ships only the reserved v21 wire layer:** the four wire shapes, + schema version, shared cell-grid validator, and accepted-version + ladder move. The production daemon continues advertising v20 because + its `Hello` is server-first; v21 activation belongs to 2B-3. This + slice has no producer, consumer, or capability change; + `panel_capable` stays `false`, so it changes no user-visible journey + grade and existing v20 clients remain attachable. +- **Review round 1 closed:** two P1s and one P2, all corrected at + `9b364ad`: `PanelFrame` now identifies its buffer, the transport + ratchet covers the actual attach path rather than a detached codec + assertion, and shared grid bounds have one validator. +- **Review round 2 found four issues, corrected at `ab7c207`:** the + server-first `Hello` made the advertised v20↔v21 compatibility + one-way; `COHERENCE.md` and `docs/agent-handoff.md` still named only + v20 schema support; framing §9 named a nonexistent aggregate 2B + suite instead of the three exact 2B slice suites; and the panel plus + copied terminal "one byte over" fixtures were actually two bytes + over. The correction keeps production advertisement at v20, adds a + real-daemon existing-v20-client acceptance, updates all three durable + records, names the exact slice suites, and asserts both rejecting + fixtures are exactly `limit + 1`. +- **The full gate exposed and corrected a contradiction in Vterm Stage + 3's headless probe at `9e20175`.** Its loop exited as soon as resize + plus two nonuniform composites were observed, while its acceptance + later required the PTY child's `VTERMROW` output in the final frame. + The v20-compatible handshake made that scheduling race deterministic: + terminal mode, five frames, and resize all succeeded, but the report + sampled a blank frame. The probe now waits for the exact child-output + observation its acceptance asserts. The formerly failing exact + GPU/PTY test passes, and the full nine-test Stage 3 target passes. +- **Follow-up review corrected the probe barrier's fixture leak at + `9c79ce1`.** The generic runner hard-coded the producer fixture's + `VTERMROW` breadcrumb, so the CAT input fixture could satisfy every + assertion but never satisfy the loop exit and waited out the + 20-second safety deadline. Producer probes now name their required + frame text while input probes finish on the latched echo. The report + exposes `completion_observed`, and both paths assert it, so a + deadline-driven pass cannot hide the stall again. +- **The full gate found and corrected two 2B-1 omissions:** the + statusline version ladder still pinned v20/rejected v21, and Vterm + Stage 3 pinned v20 both structurally and in its real headless probe. + Those ratchets now expect v21 and, where applicable, reject v22. +- **Pre-integration green evidence at `b9123c2`:** formatting and strict + workspace Clippy; library **1,849 passed + 3 ignored default** and + **2,034 passed + 4 ignored CRDT**; bottom-panel Stage 1 / 2A / 2B-1 + **46 / 17 / 15**; folding Stage 2 **48**; GPU font **11**; statusline + **8 CRDT**; m11_5 semantic **2 CRDT**; Vterm Stages 1 / 2 / 3 + **10 / 6 / 9 CRDT**, with Stage 3's real daemon + PTY + wgpu probe + required and green; M4 **121 passed + 3 ignored + 1 filtered**; + required GPU **202**; and the isolated-config, one-invocation full + workspace sweep green on rerun. Its first pass hit the known + completion-before-supersede race in + `m8_1_acceptance::read_dir_supersede_cancels_in_flight_predecessor`; + the exact pin, its full 10-test target, and the complete workspace + rerun all passed. +- **The former deterministic red is resolved on `main`.** PR #183 + corrected `gpu_initial_target_acceptance` through the public + `pmacs --gpu .` path, consumed the asynchronous dired snapshot, and + retained the managed daemon before the wait so failure cleanup remains + effective. The code integration auto-composed. +- **The previous complete post-integration gate was green at `c8895a8`:** + formatting; strict workspace Clippy; library **1,849 passed + 3 + ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel + Stage 1 / 2A / 2B-1 **46 / 17 / 15**; folding Stage 2 **48**; GPU font + **11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU initial + target and invocation **15 / 15 CRDT**; Vterm Stages 1 / 2 / 3 + **10 / 6 / 9 CRDT**, including the required real daemon + PTY + wgpu + probe; M4 **121 passed + 3 ignored + 1 filtered**; required GPU + **202/202**; the isolated-config, one-invocation full workspace sweep; + and `git diff --check`. + - The first required-GPU pass was **201/202** on + `a_fraction_draws_rule_pixels_between_its_operand_rows`, a rendering + test structurally outside this lane's protocol-only GPU diff. The + exact test passed immediately in isolation with one test thread, and + the mandatory complete rerun passed **202/202**. This is retained as + classified gate evidence, not erased as a clean first pass. +- **The corrected review-round-2 head is fully green at `9e20175`:** + formatting; strict workspace Clippy; library **1,849 passed + 3 + ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel + Stage 1 / 2A / 2B-1 **46 / 17 / 16**; folding Stage 2 **48**; GPU + font **11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU + initial target and invocation **15 / 15 CRDT**; the handshake + consumers m5_5 / m5_7 / mode-system wiring **36 / 7 / 1 CRDT** + (the release-only m5 perf test remains ignored by its standing + contract); Vterm Stages 1 / 2 / 3 **10 / 6 / 9 CRDT**, including the + required real daemon + PTY + wgpu probe; M4 **121 passed + 3 ignored + + 1 filtered**; required GPU **202/202**; the isolated-config, + one-invocation full workspace sweep; and `git diff --check`. + - An initial default-library attempt inside the restricted tool + sandbox produced three `Operation not permitted` failures in + socket-based attach tests. The authoritative outside-sandbox rerun + passed all **1,849 + 3 ignored**, and the matching CRDT run passed. + This is retained as environment classification, not presented as a + clean first attempt. +- **The fixture-specific follow-up is proportionally green at + `9c79ce1`:** formatting and strict workspace Clippy; protocol + **17/17**; bottom-panel Stage 2B-1 **16/16**; Vterm Stage 3 **9/9 + CRDT** with the real daemon + PTY + required wgpu probe in **5.72 s**; + the formerly stalled CAT path **1/1 in 0.32 s**; required GPU + **202/202**; and `git diff --check`. The first Stage 2B-1 and Vterm + attempts inside the restricted tool sandbox reproduced the classified + Unix-socket `Operation not permitted` denial; their authoritative + outside-sandbox reruns passed. +- **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 + lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine + belongs to 2B-2; the GPU band and negotiated capability flip belong + to 2B-3. + +- **Stage 2A MERGED as #177** (`main` @ `0a3fcd1`, 2026-07-26, all twelve + checks green at `8424172`, three review rounds). Branch + `githubsucks/bottom-panel-stage2a` and worktree `../pmacs-bp-stage2a` + are retained and carry nothing unmerged. 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. +- **Stage 2A verification on its 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 `docs/agent-handoff.md` §1; the two round lessons are in §5. +- Landed-docs follow-up merged as **#156** (`main` @ `d152120`, + 2026-07-25). +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 6** + is on branch `githubsucks/bottom-panel-stage2b` (revision 5 is commit + `56301ed` there), + worktree `../pmacs-bp-stage2b`. Revisions 1–4 remain on + `githubsucks/bottom-panel-stage2-framing` (head `4fbd47f`, four + framing commits, revision 4 at `49757e5`). Round 1 closed 2 blocking + + 3 high; + round 2 closed 1 blocking + 2 high + 1 medium and decided both open + items; round 3 closed 1 blocking + 1 high + 1 medium. No open items + remain. Revision 5 adds no decision; it records the approved + 2B-1/2B-2/2B-3 implementation split. Revision 6 corrects the + server-first compatibility contract, durable protocol claims, exact + acceptance-suite names, and `limit + 1` fixture. The + parent framing `docs/bottom-panel-framing.md` (rev 4) remains + authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree `../pmacs-bottom-panel`. -- **Stage 2 obligations, already named by the framing** — the starting - point for its own framing doc: `InstanceMessage::PanelFrame` plus - `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}` - at the next available protocol version, gated in both directions and - each extended enum byte-pinned on its own previous final variant; - extracting `paint_frame`'s per-window body *together with* the - active-window auto-scroll preparation; routing every consumer in the - framing's §1.3 census of 23 transitive active-context reads through - `primary_document_window`; the focus-chrome surface matrix (Q#BP14b); - and Q#BP17's fold-projection parameter plus the stale invariant comment - at `src/window.rs`. Stage 3 is the adopter default flip. +- **Stage 2 ships as four serial implementation slices**, each landing + before the next branches: + **2A** = classified §1.3 census routing + `paint_frame` per-window + painter extraction (with the active-window auto-scroll preparation), no + protocol change; **2B-1** = reserved protocol schema **v21**, with + production advertisement held at v20, + (`InstanceMessage::PanelFrame` plus + `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`, + gated both directions, each extended enum byte-pinned on its own + previous final variant); **2B-2** = daemon panel projection and epoch + machine; **2B-3** = compatible v21 activation, the GPU band, and the + negotiated `panel_capable` flip. + Stage 3 is the adopter default flip. +- **Correction — this entry previously mis-stated the census contract.** + It is **not** "route every consumer through `primary_document_window`". + Q#BP14 classifies the 23 reads into four classes and routes only the + **Projection** class that way; focus/input (#13–#15, #23), focus chrome + and surface-routed (#16–#19), and focus/session (#20) keep their own + authorities. Rerouting them would break remote-op validation and + application, `DispatchIdle`, presence, focused search/menu/completion + routing, and terminal bell ownership. The Stage 2 framing carries the + full table. +- **The GPU document bottom is three boundaries, not one.** + `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is today + `status_band_top`, `geometry_capacity_bottom`, and + `document_text_bottom` at once. Once a band is installed they diverge: + the status chrome must stay pixel-identical at the physical window + bottom while document consumers move. A blanket rewrite of that helper + moves both together and passes an "everything moved" assertion, so the + Stage 2 criterion asserts **both directions in one scenario**. The + census is 20 production sites (8 status-owned, 12 document-owned) + 1 + definition + 8 test sites = 29 matches; the framing carries the + per-site table. The three easiest to misclassify are document + completion `:6140`, minibuffer candidates `:7351`, and edge scrolling + `:8561` — each with its own visible symptom. - **Folding Stage 3 and this arc's Stage 2 both touch the semantic projection.** Whichever is framed second re-scouts the other's landed state. @@ -471,6 +611,82 @@ git worktree add --track \ ## Closed since the last snapshot +- **Terminal configuration + copy mode arc — BOTH STAGES MERGED, lane + removed.** Stage 1 **#173** (`main` @ `cf54270`, one review round) and + Stage 2 **#178** (`main` @ `fe8b8ba`, **four review rounds**, twelve + checks green on head `1b44c69` — verified by `head_sha`, not by the + check summary), both 2026-07-26, both with no protocol change. + Approved framing: `docs/terminal-config-and-copy-mode-framing.md` rev + 4, committed as the first commit of Stage 1's branch; its Q#TC6a + carries a superseded-in-part box rather than a silent rewrite. Durable + facts moved to `docs/agent-handoff.md` §1 (the arc bullet) and §4 (the + `set_generated_contents` invariant) per rule 3 below, and to + `COHERENCE.md` §14. **Stage 2 ships eight of nine criteria and the + missing one is named** — criterion 17 needs a real GPU frontend, so it + waits on the `a37` footing; the handoff records what it must assert. + Branches `githubsucks/terminal-config` and + `githubsucks/terminal-copy-mode` with worktrees + `../pmacs-terminal-config` and `../pmacs-terminal-copy-mode` are + retained. The gate-run flake found while gating #178 moved to the CI + `crdt`-coverage lane above, which owns its discrimination. +- **Dired Stage 1 (the directory view) — MERGED as #165** (`main` @ + `c8ec8f3`, 2026-07-25, after one review round). pmacs has a directory + surface: `C-x d` / `C-x C-j`, one read-only buffer per directory named + `*dired:*`, a `dired` major mode carrying + `RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`. No wire change (v20). The Rust is + two things — a per-entry-tolerant `read_dir` (Q#DR6), which had to be + Rust because `read_dir_blocking` fails a whole listing on any of five + per-entry conditions and a tolerant wrapper cannot be written in Lua at + all, and `normalize_buffer_path` going `pub` as + `pmacs.path.canonicalize` (Q#DR2's preferred end state, so no Lua mirror + exists and Stage 2 owes no mirror removal). The frozen m8_1/m8_2/m8_3 + counts are unchanged, which is the additivity gate. 15 claims + bite-verified; one came back VACUOUS (acceptance 3c cannot pin descent + routing — dired holds focus in its own panel, so dedication is the only + discriminator) and is documented at the assertion rather than + relabelled. Its branch (`dired-stage1`) and worktree + (`../pmacs-dired-stage1`) are done; the abandoned `dired` branch + (`ffdd642`, `../pmacs-dired-arc`) was superseded by a fresh cut and + carries nothing unmerged. **Stage 2 (marks and operations) and Stage 3 + (wdired) each still need their own framing**, and the frozen fixture + shrinks after Stage 3. Durable substrate facts and both new ops lessons + live in `docs/agent-handoff.md` §§1/5; the implementation notes are + `docs/dired-framing.md` §0, S1-1…S1-12. Two named forward items for + Stage 2: `apply_resource_op`'s rename rebind is exact-PathBuf-equality, + first-match-only, looked up with the raw path while stored paths are + normalized — so a directory rename strands every buffer under it, and + `pmacs.fs.rename` has zero production callers, so it can be fixed at + the primitive; and Q#DR5's seam is the main-thread drain + `AsyncRuntime::tick`, not `_take_result`, where rename settles as an + undifferentiated `ReplyKind::FsUnit` and so must be keyed on + `JobKind::FsRename`. + +- **GPU terminal input (the double terminal-layout sync) — MERGED as #166** + (`main` @ `b889873`, 2026-07-25, one review round, all twelve checks green + after a macOS PTY-timing rerun). The dispatcher applied **both** + terminal-layout syncs to **every** attached frontend; a semantic session + satisfies both conditions, so its PTY was resized twice per tick forever and + the child took a `SIGWINCH` storm that made a GPU terminal untypable while + output still flowed. `sync_terminal_layout` is now split into a + frontend-kind-neutral half (panel reconcile + controller liveness) and a + grid-only geometry half, with the loop body extracted to + `sync_terminal_layouts_for_tick` so the exclusivity is structural. No + protocol change (v20). Durable lessons are in `docs/agent-handoff.md` §5; + the framing (`docs/gpu-terminal-input-framing.md` rev 2) carries three + falsified hypotheses, the two-pre-image bite matrix, and two named + out-of-scope items (Q#GT5 interactive-shell echo on a raw PTY, which + reproduces in-process and so is not the GUI/TUI asymmetry; and a geometry + change appearing to clear the visible screen, which reproduces pre-fix). + Branch `gpu-terminal-input` and worktree `../pmacs-gui-term-input` retained. + **Its landed-doc pair MERGED as #168** (`main` @ `1b6a084`, + 2026-07-26): #166 recorded as landed, the CI `crdt`-coverage gap + measured (**264 tests dark workspace-wide**, 177 in the library — a + reading taken at `1b6a084` and kept here only as history. **The CI + `crdt`-coverage lane above is the authority for the live figure**; + do not quote this one forward), the + vterm audit corrected — "only 3 of 9 acceptances drive a real daemon" + was optimistic; without the frontend binary the honest number is + **2** — and the a37 findings folded into the coverage lane. - **Inline-math slice — MERGED as #158** (`main` @ `5aa9044`, 2026-07-25). Detect → parse → layout → draw for `$…$`, entirely inside `pmacs-gpu`, no protocol change. Verified by the user's manual pass on diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d873063..bc6f440 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,8 +1,28 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-25, after the inline-math slice (#158) landed — -the first mathematical typesetting in pmacs — following find-file (#162), -the dired arc's Stage 0, and COHERENCE.md (#163), Lean 4 Stage 1 (#160), the +**Last updated: 2026-07-28, during bottom-panel Stage 2B-1 PR #184 +review; the canonical landed base remains the Journey/GPU +directory-target ratchet (#183), following Journey Stage 1a (#182), +which made directory +startup one coherent local/daemon/GPU path and incorporated the terminal +configuration + copy mode landed-doc work (#180); following terminal +copy mode (#178) — `C-c C-t` +materializes a terminal's whole retained range into an ordinary buffer, +plus `Buffer::set_generated_contents`, the first genuinely immutable +generated-buffer write path — and its landed-doc pair (#168); following +Lean 4 Stage 4a (#179) — the typed-edit +consumer chain — and bottom-panel Stage 2A (#177), the classified census +routing that makes every Projection-class consumer ask +`primary_document_window`; the bottom-panel Stage 2 framing +(#175), terminal configuration Stage 1 (#173) — profiles, scrollback, a +per-terminal configurable escape key, and the `C-c t` opening binding — +Lean 4 stages 3a and 3b (#167, #170), pmacs' first Lean language server; +the GPU terminal input fix (#166), the double terminal-layout sync that +made a GPU terminal untypable; the CRDT undo repro (#157), the +inline-math landed-doc refresh (#172), the inline-math slice (#158), the +first mathematical typesetting in pmacs; dired Stage 1 (#165), Lean 4 +Stage 2 (#161), the dired framing pair (#163/#164), find-file (#162) — +the dired arc's Stage 0 — 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 @@ -25,23 +45,231 @@ 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-28) -- `main` @ `d152120` (the bottom-panel landed-doc refresh #156 atop 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` @ `7fd646d` (Journey/GPU directory-target ratchet #183, atop + Journey Stage 1a #182, incorporating terminal configuration + copy + mode landed docs #180, Lean 4 Stage 4b #181, the dired Stage 1 landed + docs #169 and the PTY-terminate diagnostic #176, terminal copy mode + #178, the GPU-terminal-input landed docs #168, Lean 4 Stage 4a + #179, bottom-panel Stage 2A + #177, the bottom-panel Stage 2 framing #175, terminal configuration + Stage 1 #173, Lean 4 Stage 3b #170, 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** — bottom-panel Stage 2A deliberately carries no wire change. + The in-review Stage 2B-1 reserves the v21 schema but keeps the + server-first production `Hello` at v20; compatible activation belongs + to Stage 2B-3. 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 standard new work is evaluated against. Per `CLAUDE.md`, **every new framing doc must state its coherence impact** — journey steps touched, interaction islands added, config-registry adoption, background-work - attribution. Its §2 grades the golden journey **broken at step 3** - (`pmacs .` exits 1). + attribution. Its §2 grades the golden journey; **Journey Stage 1a + moved that grade off "broken at step 3"** — see the arc bullet below. +- **Journey arc (P1) — Stage 1a LANDED** + (`docs/journey-stage1a-framing.md`). `pmacs .` opens a directory + instead of exiting 1, on **one** path: `resolve_target_buffer` gained a + `ResolvedTarget::Directory` arm *ahead* of the load, `EditorState::open` + became a caller of it rather than a parallel implementation, and the + daemon/GPU bootstrap shares the same arm. Which surface handles a + directory is the `path.open-directory` chain with dired as a + replaceable fallback slot. `tests/journey_acceptance.rs` is the new + cross-subsystem ratchet (steps 2, 3, 5 seeded; **stages add rows, none + removes them**). No protocol change. + - **A hook a builtin subscribes to can never be first-claimant-wins + for users.** `HookRegistry::add` only appends and builtins load + before `init.lua`, so a dired subscription would always claim before + any user listener. That is why dired is a *slot* + (`pmacs.path.directory_handler`) and not a subscriber — and why + clearing the slot has to leave startup succeeding with a status, + not exiting 1. + - **A raise and a `false` are indistinguishable in `proceed`.** + `run_short_circuit` returns `proceed = false` for both; only + `HookOutcome.errors` separates them, and it decides whether to + *report*, not whether to fall back. Getting this backwards produces a + fallback that runs after a user's resolver crashed mid-handling. + - **The listing is async; the bootstrap is synchronous.** The whole + post-await commit therefore runs against a destination captured at + request time (`pmacs.window.commit_to`), which preflights every + precondition *before* invoking the callback — dired mutates handle + state, `prev`, and paint long before it reaches anything that could + refuse, so validating at display time is four mutations too late. + Awaiting inside a commit is refused: a yield would restore the scope + while the coroutine is still parked. + - **The scope swaps `core.active_frontend`, not just an override** — + `pmacs.window.buffer()`'s no-arg arm reads the ambient active buffer + directly, so dired's `prev` capture would otherwise follow whatever + frontend happened to be dispatching. The override *also* exists, and + is load-bearing in exactly one case: a commit reached from inside an + interactive command, where the origin would otherwise outrank the + ambient value. Bite-testing found N4 green without it. + - **`replace_active_buffer` does not drop the startup scratch buffer**, + despite its doc comment having claimed so for as long as it has + existed. Its body is one `switch_active_buffer` call. The comment is + corrected here; changing the lifetime is separate work. + - Stage 1b is the named remainder: compile binding + Cargo defaults, + LSP spawn guidance, welcome buffer. +- **Terminal configuration + copy mode arc — COMPLETE** + (`docs/terminal-config-and-copy-mode-framing.md` rev 4; Stage 1 #173, + Stage 2 #178; no protocol change in either, still v20). Stage 1 ships + profiles, scrollback, a per-terminal configurable escape key and the + `C-c t` opener; Stage 2 ships copy mode — `M-x terminal.copy-mode` / + `C-c C-t`. + - **The snapshot MATERIALIZES into an ordinary buffer.** That is the + arc's organizing decision: isearch, motion, selection and the kill + ring work with no new substrate, and "keys must not reach the child" + dissolves structurally, because the transport arm keys on + `is_terminal(buffer_id)` and a snapshot is not a terminal. **The + dispatch-shadow count therefore stays at six.** + - **`prune` reacts to buffer removal rather than causing it** — it + filters on `!registry.contains(buffer_id)`, so a child exiting does + **not** remove the terminal buffer. That is what makes `on_removed` a + sound teardown hook, and why a finished command's output stays + readable. + - **Ownership means "in our own handle table", never found-by-name** + (dired's F7 rule, re-learned here): snapshot writes use + `bypass_intercept`, so adopting a same-named foreign buffer clobbers + user data. Snapshot identity is keyed by **comparing buffer handles + in an array** — `BufferIdLua` implements `__eq` but each wrapper is a + distinct table key, so comparison works and hashing does not. + - **Profiles are a raw Lua table**, joining `pmacs.lsp.config` and + `pmacs.pair.sets`, because `ConfigValue` is four scalars with no + table kind. The two open-time settings resolve through the **global** + chain (they are read before the identity buffer exists); only + `terminal.escape-key` resolves per buffer, and its cache lives on + **`TerminalSession`** so its lifetime is the terminal's — + `value_epoch` alone is not a sufficient key, because it does not + advance when focus moves between terminals holding different + buffer-local values. + - **Criterion 17 is deliberately unpinned, and its bite is now stated + correctly.** A real semantic frontend proving neither copy is mutated + needs the actual GPU binary (the optimistic apply exists only in + `pmacs-gpu/src/main.rs`; the headless `SemanticClient` has no + optimistic path), i.e. the `a37` footing §5 warns about. After + `set_generated_contents` the eventual test must look for + **unauthorized mirror mutation plus daemon refusal — divergence**, + not the "mutates both sides silently" the criterion originally + specified, which can no longer happen and would pass for the wrong + reason. *A fix can invalidate a test that was never written.* + - 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 such probes must **count + occurrences rather than test presence**, because a single-character + probe collides with the child's own banner text. +- **Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b, 4a, 4b ALL LANDED** + (`docs/lean4-mode-framing.md`; #160, #161, #167, #170, #179, #181). 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 (the typed-edit consumer chain) MERGED as #179** + (branch `lean4-stage4a-typed-edit-chain`, framing rev 8; it is part + of the main anchor above). 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. + - **Round 8's durable lesson: `run_all_must_succeed` does NOT abort + the fan-out.** `src/hook.rs:332` collects each callback's error and + continues to the remaining subscribers, marking only the run + failed — so an uncontained throw inside a hook subscriber does not + stop `lsp.lua` from flushing didChange. Two framing revisions + asserted the opposite to justify a `pcall`. The guard was right and + the reason was wrong, and by the time review caught it the wrong + reason had been copied into a module comment, an acceptance + criterion, a test comment, and the ledger. **Correct the source a + rationale derives from, not only the sites that quote it.** + - **Stage 4b (the Unicode input method) MERGED as #181** + (framing rev 9): a vendored + 1,855-entry table generated from `leanprover/vscode-lean4@17d1d08` + by `scripts/regen-lean-abbrev`, plus a consumer registered on the + Stage 4a chain at priority 50, ahead of pairing. **A consumer + cannot both edit and let a later consumer act on the same + keystroke**: the chain hands each consumer a copy of the record made + before any consumer ran, so an edit invalidates every copy still to + be used. The expansion therefore runs on a SECOND + `buffer.after-edit` subscriber after the chain — which is how a + pair character that terminates an abbreviation still pairs + (`\alp(` → `α()`). And **deferring work past a fan-out means + owning which fan-out it belongs to**: these fan-outs NEST, so a + consumer between the expander and pairing that calls + `pmacs.hook.run` re-enters the deferred subscriber while the outer + chain is still mid-list, and the count that recognises this has to + come from a MINIMUM-PRIORITY consumer — the expander is optional + (a claim can stop the chain first) and a subscriber beside the + deferred one is too late (the nested fan-out finishes inside the + outer chain's subscriber). Its other durable facts: + the table must stay an ORDERED SEQUENCE (equal-length ties resolve + by source declaration order, which a `pairs`-iterated map cannot + express); a generator round-trip check must re-read the BYTES ON + DISK, because comparing in-memory strings cannot see an encoding + applied by the write itself; and an expansion that SHRINKS the + buffer must place the point explicitly, or every later self-insert + is silently rejected and the editor looks dead. + - **Round 9 corrected three approved acceptance criteria** by + simulating the state machine over all 1,855 entries rather than + re-reading the prose. Four review rounds over the text had not + found them, because each named an example that reads as obviously + right and is wrong only against the data. + - Remaining: 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 @@ -99,14 +327,86 @@ commands, read `docs/active-work.md` immediately after this file. against an open buffer yet fails to load one that is not open — find-file expands the tilde Lua-side. Loading through the normalized path is a named deferral. - - **Stage 1 (the directory view) is IN REVIEW as PR #165** — the - builtin `dired.lua`, the per-entry-tolerant `read_dir` opt, and - `pmacs.path.canonicalize`. Its branch state, substrate facts, and - verification live in `docs/active-work.md`; this section absorbs them - when it merges. -- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 = - `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 = - the GPU initial-target semantic bootstrap family). +- **dired Stage 1 — the directory view — LANDED — #165** + (`docs/dired-framing.md` §0, S1-1…S1-12; merge `c8ec8f3`; one review + round). pmacs now has a directory surface: `C-x d` / `C-x C-j` open a + read-only listing, one buffer per directory named + `*dired:*`, with a `dired` major mode whose + mode-scoped keymap carries `RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`. + Protocol unchanged at **v20**. **Stage 2 (marks and operations) and + Stage 3 (wdired) each still need their own framing**; the frozen + fixture shrinks after Stage 3. + - **The Rust is confined to two things**: a per-entry-tolerant + `read_dir` (`ReadDirTolerance {Fatal, PerEntry}` → + `FsDirListing {entries, errors}`), because `read_dir_blocking` fails + a whole listing on any of five per-entry conditions and the tolerant + wrapper its own module doc delegates to package authors **cannot be + written in Lua** (one error value, no partial vec); and + `editor_core::normalize_buffer_path` becoming `pub`, exposed as + `pmacs.path.canonicalize`. Only non-UTF-8 **names** stay fatal — + byte-preserving paths would be needed. The Lua result **shape** keys + on `errors.is_some()`, so the bare array the frozen M8.2 fixture + consumes with `ipairs` is untouched. + - **Exposing a core normalizer beat mirroring it in Lua.** A Lua mirror + would have been a second canonical form — the same class of bug as + the five tab-width constants (#137). Applies to any future Lua-side + path reckoning. + - **A fixed-width column must be fixed-width for every input.** The + exported `pmacs.dired._layout` (MARK 0, KIND 2, PERMS 3–12, SIZE 13, + MTIME 24, NAME 41) is the contract Stage 3 reads offsets from, and + `%10d` overflows at ≥10 GB, silently shifting every column right of + it. Sizes now fall back to a width-clamped magnitude (K/M/G/T/P/E). + - **An ambient action must be gated on the buffer it assumes.** A + revert's cursor re-seat settles a tick or more later, by which time + the user may have switched buffers; the paint names its buffer and is + safe, but seating is ambient. This is the buffer-level instance of + the rule below that interactive origin does not survive an await. + - **A failure IS an answer — don't probe first.** Kinds are lstat-based + in both `read_dir` and `stat`, so nothing in an entry says whether a + symlink points at a directory. `RET` tries to list it and treats the + failure as the answer; an explicit probe was a second full + `read_dir`, so a descent listed twice. + - **Unbounded per-entry error collection needs a cap when nothing + cancels the work.** A dired listing carries no supersede key, so + cancellation was never the backstop the tolerant loop implicitly + relied on (`READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS = 1024`). + - **This is the first builtin with mode-scoped keys** (#129's first + non-detection consumer), which broke the pre-existing + `describe_key_identifies_every_default_binding`: it asserted every + binding resolves through `describe.key` context-free, which held only + while the modes table was empty. It now sets the effective context + per binding and explicitly **clears** the mode for global ones, + because a leaked mode legitimately shadows a global chord of the same + name (dired's `RET` shadows `edit.newline-and-indent`), plus a floor + assertion that at least one mode-scoped binding exists. + - **A dedicated panel does not carry its dedication across a descent** + — the framing expected it to. `display_buffer` never replaces the + buffer in a slot dedicated to another one; it discards every + side-specific parameter and falls back to the document window (Q#BP3 + 2.iii), and the exact-window arm errors. Dired does not unpin the + user's panel; both arms are pinned. + - Smaller facts worth knowing before touching this code: a path-backed + buffer's **name is its full path**, not its basename, which matters + for any name assertion; `pmacs.buffer.kill` (not `remove`) redirects + windows off a doomed buffer first, so `dired.kill-when-opening` kills + **after** the replacement is displayed; ownership is checked against + the handle table only, never the buffer name; and `C-x d` takes **no** + completion source on purpose (with one, `RET` on an empty field opens + whatever sorts first, and RET-where-you-are is the gesture the binding + exists for — the field is prefilled instead). + - Verification at merge: 1,832 default + 2,009 CRDT library tests; + dired acceptance 25 + 25 CRDT; the frozen m8_1 10 / m8_2 15 / m8_3 32 + unchanged, which is the additivity gate for the `read_dir` change; M4 + 121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep + 3,205 across 93 suites. 15 claims bite-verified. +- Canonical `main` is protocol **v20** (`SUPPORTED=[6..=20]`; v16 = + `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = + terminal frames/events, v20 = the GPU initial-target semantic + bootstrap family). Bottom-panel Stage 2B-1's in-review schema is v21 + (`SUPPORTED=[6..=21]`), but its production daemon deliberately + advertises v20: the handshake is server-first, so advertising 21 + would make shipped v20 GPU/TUI clients reject before + `AttachRequest`. Stage 2B-3 owns compatible production activation. - **Bottom panel Stage 1 (window placement + TUI side windows) LANDED — #155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now @@ -171,10 +471,26 @@ commands, read `docs/active-work.md` immediately after this file. `bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121; required GPU 152; initial-target 14 CRDT; all three vterm suites; folding Stage 2 48. All 12 CI checks green at merge. - - **Stage 2 (the GPU panel band) needs its own re-framing** before - implementation and takes the next available protocol version; the - framing's §1.3 census of 23 transitive active-context reads is its map. + - **Stage 2 (the GPU panel band) is FRAMED** — + `docs/bottom-panel-stage2-framing.md` rev 6, four framing review + rounds, no open framing items; the rev-5 implementation split was + explicitly approved 2026-07-27 and rev 6 records PR #184's + server-first compatibility and gate correction. It reserves + protocol **v21** and ships as four serial + implementation slices: **2A** classified census routing + + per-window painter extraction (no wire change), **2B-1** the wire, + **2B-2** the daemon projection and epoch machine, then **2B-3** the + GPU band, compatible v21 activation, and negotiated + `panel_capable` flip. Production attachment remains v20 through + 2B-1 and 2B-2. Parent acceptance 37–55 remains authoritative. Stage 3 is the adopter default flip. + - **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the + Projection class (#1–#12, #21–#22) routes through + `primary_document_window`; focus/input (#13–#15, #23), focus chrome + and surface-routed (#16–#19), and focus/session (#20) keep their own + authorities. Rerouting them breaks remote-op validation and + application, `DispatchIdle`, presence, focused + search/menu/completion routing, and terminal bell ownership. - **GPU initial target LANDED — #148** (`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path @@ -681,6 +997,44 @@ commands, read `docs/active-work.md` immediately after this file. DAP, 8 GPU splits, plus the `.ipynb` arc (its JSON-grammar prerequisite shipped in #123). +- **GPU terminal input LANDED — #166** (`main` @ `b889873`; + `docs/gpu-terminal-input-framing.md` rev 2; one review round). The + dispatcher applied **both** terminal-layout syncs to **every** attached + frontend each tick. A semantic session satisfies both conditions — a + `term_sizes` entry from `AttachRequest` *and* a terminal declaration — so + its PTY was resized twice per tick forever: the grid arm installed the TUI + placement size, the semantic arm the declared content rectangle, each arm's + `old_size == size` guard seeing only what the other had just written. The + child took a `SIGWINCH` storm at tick cadence, which made typing into a GPU + terminal impossible while output kept flowing. TUI was structurally + unaffected. + - `EditorInstance::sync_terminal_layout` is split into + `sync_terminal_controller_liveness` (frontend-kind **neutral**: panel + reconcile + release of a controller whose window moved away — reads only + views/windows/controller, never a grid size) and + `sync_terminal_grid_geometry` (**grid only**: TUI placement + resize). + `sync_terminal_layout` survives as the composition, so `editor::run` and + `LOCAL` are byte-identical. + - `daemon::sync_terminal_layouts_for_tick` is the extracted loop body: + liveness for every frontend once per tick, then **exactly one** geometry + arm keyed on `semantic_states` membership — the same fact session + establishment uses, so the arms cannot both fire. + - **The trap, kept in a comment:** the release on a missing + `window_placements` entry reads like liveness and is grid geometry. A + semantic frontend has no placement entry at all, so moving it into the + neutral half would release a GPU controller every tick. + - Why not the one-line guard: the grid arm was also the **only** per-tick + controller-liveness release a semantic frontend got, and + `sync_semantic_terminal_layout` cannot take it over — the buffer-follow + snapshot clears the viewport declaration, so that arm stops running in + exactly the switch-away case that needs the release. + - No protocol change (v20). Gates: 1,829 default + 2,006 CRDT library + tests; vterm Stage 1/2/3 10/6/9 CRDT; bottom-panel 46; M4 121; required + GPU 155; isolated-config workspace sweep 3,177 across 92 suites. + - **Known gap, its own lane:** CI never enables `crdt`, so the Stage 3 + real-path acceptance (including `a37`) is not compiled there. #166's unit + pins are not `crdt`-gated and do run. See `docs/active-work.md`. + ## 2. How we work (the part that must not drift) The user is expert and reviews deeply — they falsify framings and find @@ -756,6 +1110,72 @@ before trusting them: ## 4. Substrate invariants (do not undo; tests enforce most of these) +**Generated buffers: `Buffer::set_generated_contents` is the ONE +authorized write** (terminal copy mode #178) — lift `read_only`, replace +via a single whole-buffer `Replace` skipping intercepts, discard history, +re-assert `read_only`, and **return the `Edit`**. Three things make it a +unit rather than a convenience: + +- **An intercept is not read-only.** `Buffer::undo` reaches the rope + through `ensure_writable` and never consults the intercept chain, so an + intercept-only "read-only" buffer is emptied by `M-x buffer.undo`. + Rebinding the undo *chords* buffer-locally does **not** close it — + `compile.lua`'s own comment says so ("command/menu undo stays + dispatchable"). Only rope-level `read_only` does. +- **A bare `set_read_only` would be worse than nothing**, because it also + refuses the owner's refresh — the operation such buffers exist for. + That is why the pairing, not the setter, is the primitive. There is + deliberately no Lua `set_read_only`. +- **A rope write is only half of an edit.** The returned `Edit` must be + fanned out (`notify_buffer_edit_to_windows`, which also queues the + daemon-origin CRDT op). Skip it and a displaying window keeps a + `TextView` line index describing the previous contents — the next paint + indexes the new rope with stale ranges and trips + `assertion failed: end <= self.len()` — while replica mirrors never + import the write at all. + +History clearing is load-bearing twice (nothing can pop entries +`read_only` makes unreachable, so they leak), and must clear **whichever +history the buffer has**: the v0.1 stacks are bypassed in CRDT mode, where +it lives in loro's `UndoManager`. That has no `clear`, and needs none — a +manager records only what happens after construction, so +`CrdtState::clear_undo_history` rebinds a fresh one to the same doc. + +**Not yet adopted — the inventory is four writer mechanisms covering +five buffers.** *Every remaining intercept-protected writer* uses the +older idiom: an erroring intercept plus `set_round_trip_input`, written +through `bypass_intercept`, with the rope left writable. All are +emptiable by `M-x buffer.undo`: + +| writer | buffers | shape | +|---|---|---| +| `builtin/runtime/listview.lua:60-61` | every listview panel | delete-all + insert | +| `builtin/runtime/compile.lua` (`ensure_slot`) | `*compilation*`, `*shell-command*` | **append** per output batch | +| `builtin/commands/default.lua:869` | `*search-results*` | reset per query, then **append** per match batch | +| `builtin/runtime/dired.lua:371` | every dired buffer | whole-buffer replace | + +**Do not read `ensure_slot` as covering the search panel** — it serves +`*compilation*` and `*shell-command*` only (`compile.lua:1090,1125`). +`*search-results*` is an independent panel with its own intercept, +round-trip mark and writes, and `compile.lua` names it only in a +predicate. Nor is the scope "every generated buffer": `*workers*`, +`*help*` and `*buffer-list*` are generated too but do not use this +idiom, and the REPL package's intercept +(`builtin/packages/repl/init.lua:187`) is an op-filtering editing +policy, not a read-only panel — neither group belongs to this lane. + +Adoption is not a one-line swap. It inherits the fan-out obligation, and +the three appending buffers need a **streaming variant** of the +primitive; listview and dired already write whole-buffer replaces and +are the cheap half. Recorded in `COHERENCE.md` §14. + +**And it does not replace `set_round_trip_input`.** The protection is +layered across two copies: rope-level `read_only` refuses the op at the +daemon; round-trip input stops a semantic frontend applying +optimistically to its **own mirror**, which a daemon-side refusal cannot +reach — the refusal arrives after the frontend has already painted, so it +buys divergence, not prevention. + **Command boundaries (Arc 2 kill-ring substrate)** — `EditorCore.command_history: HashMap`, per frontend. Rotate on: keybound command, self-insert, menu invoke, @@ -794,12 +1214,17 @@ buffer owns a path's recovery slot; only recover/discard release unclaimed crash data; adopt clears the old owner's skip cache. **Protocol** — encoding-breaking bumps are deliberate and versioned. Canonical -`main` is `[6..=20]`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 = +`main` is `[6..=20]`. The in-review bottom-panel 2B-1 schema extends support +to `[6..=21]`, while `ADVERTISED_PROTOCOL_VERSION` stays 20 until 2B-3 +provides compatibility-preserving activation; the server-first `Hello` +cannot advertise 21 without stranding existing v20 clients before +`AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm terminal family; v20 = semantic `SessionBootstrapRequest` plus appended -`InitialTargetResult`. New wire surface ⇒ bump + both-frontends support + -acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS -final variant — its own round-trip cannot detect a discriminant shift. +`InitialTargetResult`; v21 reserves the panel frame/event family. New wire +surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant +must be guarded by a byte pin on the PREVIOUS final variant — its own +round-trip cannot detect a discriminant shift. **Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`, `rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation), @@ -807,6 +1232,34 @@ final variant — its own round-trip cannot detect a discriminant shift. ## 5. Hard-won ops lessons +- **A test that skips on a missing precondition reports `ok`, and a gate log + cannot tell that apart from a pass.** `vterm_stage3_acceptance::a37` — the + only acceptance driving a real daemon, a real PTY and a real wgpu render + together — derives `pmacs-gpu` from `CARGO_BIN_EXE_pmacs` and, when that + binary is absent from the target directory, prints a skip and returns. + A fresh worktree reports the suite 9/9 **in 0.17 s having never run it**; + a real run takes ~4 s. `PMACS_REQUIRE_GPU=1` is what promotes the skip to + a failure, and the standing gate list applies that flag to + `cargo test -p pmacs-gpu`, a *different package*. Two habits follow: + build the workspace before believing any suite that reaches for a sibling + binary, and **judge such a suite by its elapsed time**, not its verdict. +- **Before attributing a red test to your branch, run it on the merge base.** + `a37` failed on the #173 branch, which looked like a regression; it failed + identically on the PR's own base and on two intermediate commits, and had + *passed* on that same base twenty minutes earlier. The variable was machine + load from a second agent compiling continuously. Load-sensitive tests make + both verdicts uninformative in isolation, so the base-commit run is the + cheapest way to tell a regression from weather — and it is much cheaper + than the bisect it replaces. +- **A daemon-side fix is not deployed until the daemon is restarted from a + tree that contains it.** #166's reporter rebuilt and saw no change: the + running daemon had been started from a shared checkout still on a pre-fix + branch, and `pmacs --gpu` attaches to whatever process already owns the + socket. Rebuilding a binary does nothing to a running process. When + validating a daemon-side fix by hand, check the running process's binary + path and start time against the tree you think you fixed — + `ps -eo pid,lstart,args | grep '[p]macs --daemon'` — before concluding the + fix failed. - **Two operations that must be alternatives are not made alternatives by being adjacent.** The dispatcher applied its grid and semantic terminal-layout syncs to every attached frontend; a semantic session @@ -858,6 +1311,24 @@ final variant — its own round-trip cannot detect a discriminant shift. trap-guarded one-file swap over read-only `git show`, with an inverted verdict (exit 0 iff the tests FAIL against the old version), making bite-verification machine-checkable. +- **A fix must be COMMITTED before it is bitten.** `scripts/bite` + restores by `git checkout --`, which reverts the file to **HEAD**, not + to the state it found — so any uncommitted work in a bitten file is + destroyed. A whole review round's fixes were wiped this way during + #165. Corollary for a NEW file: the swap-over-`git show` mode does not + apply at all, so its claims must be bitten by hand-editing, which makes + the commit-first rule load-bearing rather than hygienic. +- **A CONFLICTING PR silently runs no CI at all.** GitHub builds + `pull_request` workflow runs against the PR's **merge ref**, which it + does not create while the branch conflicts with its base. So pushes + land, the branch updates, no run is ever queued, and **nothing reports + the absence** — the checks list simply keeps showing the last + successful run, which reads as current. Three pushes to #165 produced + zero CI before the cause was found, and `gh pr checks` returns nothing + usable here. On any lane that lives through a moving `main`, check + `gh pr view --json mergeable,mergeStateStatus,headRefOid` and + confirm a run exists **for the current head sha**, not merely that a + recent run was green. - **Stacked PRs**: retarget the child to main BEFORE merging the parent — GitHub auto-closes a PR whose base branch is deleted and cannot reopen it (#104 → re-opened as #105). diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md new file mode 100644 index 0000000..2384118 --- /dev/null +++ b/docs/bottom-panel-stage2-framing.md @@ -0,0 +1,958 @@ +# Bottom panel Stage 2 — the GPU panel band (framing) + +**Revision 6 — PR #184 review correction; the underlying Stage 2 +framing remains APPROVED 2026-07-27. 2A is merged and 2B-1 is under +review. Ground truth: canonical `main` @ `7fd646d`, protocol v20 on +`main`; `bottom-panel-stage2b` reserves the v21 schema while its +server-first production handshake continues to advertise v20.** +Revisions 1–4 were pre-implementation; rev 5 recorded the three-way +slice of Stage 2B after its first slice was already built; rev 6 +corrects that slice's mixed-version and gate contracts. + +Stage 1 (#155, merge `e745068`) gave pmacs window placement, window +parameters, TUI side windows, the divider, and the adopter `display` +opt-in. It deliberately set `FrontendView::panel_capable = false` for +every semantic session, so a GPU frontend silently falls back to the +non-side target. **Stage 2 flips that bit, under an exact negotiated +rule, and earns the right to.** + +This document is the re-framing `docs/bottom-panel-framing.md` (rev 4) +§2 requires before Stage 2 is implemented. It does **not** restate the +parent's decisions or replace its acceptance criteria. It records the +re-scout against current `main`, closes the four scout obligations +review round 1 required, and fixes what round 1 found wrong. + +**Inherited reading, all of which remains authoritative:** parent +Q#BP8 (the band), Q#BP9 (protocol), **Q#BP14 (the primary-document +projection contract and its census classification)**, **Q#BP14a (panel +input gating is per-window)**, Q#BP14b (focus chrome and per-window +overlay routing), Q#BP15 (`PanelFrame` lifecycle), Q#BP15a (three +geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and +**parent acceptance criteria 37–55**. + +## 0. Revision history + +### 0.0 Rev 5 → rev 6 — PR #184 review round 2, four findings closed + +- **R6-1 (P1) — v21 is reserved, not advertised, in 2B-1.** The + protocol's handshake is server-first. An existing v20 TUI or GPU + frontend rejects a `Hello { protocol_version: 21 }` before it can + send an `AttachRequest`, so rev 5's claim that a v21 daemon and v20 + peer "still negotiate 20" was impossible. 2B-1 therefore extends the + schema and accepted-version ladder to v21 while the production daemon + continues advertising v20. A real-daemon acceptance emulates the + shipped v20 rejection point and then requires the attachment to reach + its initial grid. **2B-3 owns both a compatibility-preserving + activation mechanism and the production move to v21; it may not + simply change the unsolicited `Hello` to 21.** +- **R6-2 (P2) — durable protocol claims move with the wire.** + `COHERENCE.md` and `docs/agent-handoff.md` now distinguish v21 schema + support from the still-v20 production handshake. +- **R6-3 (P2) — the gate contract names the actual decomposition.** + §9 now names 2B-1's + `bottom_panel_stage2b_protocol_acceptance` suite and the exact planned + daemon/GPU suite names for 2B-2 and 2B-3 instead of the nonexistent + `bottom_panel_stage2b_acceptance`. +- **R6-4 (P2) — "one byte over" means exactly one.** The panel and + copied terminal boundary fixtures replace a one-byte cluster with a + two-byte cluster, and each independently asserts a total of + `limit + 1`. + +### 0.1 Rev 4 → rev 5 — the three-way slice of 2B (not a review round) + +This revision changes no decision. It splits one approved +implementation slice into three and reallocates the acceptance +criteria across them. + +- **R5-1 — why.** Rev 4 §9 scoped 2B as a single PR: v21 protocol, + daemon panel projection, GPU band, and the negotiated + `panel_capable` flip. Implementation showed that to be roughly four + thousand lines spanning `pmacs-protocol`, `src/daemon.rs`, and + `pmacs-gpu` — three review surfaces with different failure modes, in + one diff. The same argument that produced 2A/2B applies again one + level down, and it is the argument this arc has already accepted + twice (Lean 4 stages 3a/3b and 4a/4b). +- **R5-2 — the boundary rule.** A slice ends where the next thing to + build has a different *authority*: the wire format, the daemon that + produces frames, and the frontend that paints them. Each slice is + independently reviewable against a subset of the parent criteria, + and each is additive — no slice makes a previously-passing assertion + fail. + **Criteria that span a boundary are named in every slice they touch, + with their half stated**, rather than assigned wholesale to one. The + clearest case is parent 39: its shared-validation and + transport-budget halves are wire properties provable in 2B-1, while + "the previous valid frame is retained" and "a duplicate does no + work" are receiver-state properties that need the epoch machine and + land in 2B-2. +- **R5-3 — this revision is retroactive for slice 1, and that is a + process defect worth recording.** `bottom-panel-stage2b` already + carries the v21 protocol layer (three commits, one review round + closed) written before this revision existed. The workflow is + framing → approval → branch → implement; slice 1 inverted it. The + slicing decision was sound, but it was taken in code and discovered + in the branch rather than proposed in the document, which is exactly + how a stage's scope drifts without anyone deciding that it should. + Rev 5 exists to put the decision back where it belongs before slices + 2 and 3 are written. +- **R5-4 — two of the three slices ship dark, deliberately.** Nothing + in 2B-1 or 2B-2 is reachable by a user: `panel_capable` stays + `false` for every negotiated semantic session until 2B-3. Rev 5 + incorrectly described that posture as a production v21 negotiation + that remained compatible with v20 clients; rev 6 R6-1 supersedes + that claim. The actual dark posture keeps the server-first + production handshake on v20 while the v21 schema is reserved. This + is the same posture 2A took ("seam adoption that becomes + load-bearing in 2B"), and it means the arc must not stall between + 2B-1 and 2B-3. Recorded here so a stall is visible as a decision + rather than inherited as a default. + +### 0.2 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed + +- **R3-1 (blocker).** Rev 3's three-boundary model was right but its + call-site table was wrong in five places, and each error was a real + defect: `:6140` is **document completion placement** (classified + status-owned, which would let completion overlap the panel); + `:7195`/`:7212` are the two **status text bounds** (classified + document-owned); `:7351` clips **global minibuffer candidate glyphs** + to the dropdown's band anchor (classified document-owned, which would + clip them against the document boundary); `:8561` (**document edge + scrolling**) was missing entirely, leaving it tied to the old bottom; + and `:8077` was described as completion placement when it is **caret + clipping** (its class was right, its label wrong). §5.3's table is + rebuilt from the full census and every row is verified against the + source. + **Root cause worth recording:** rev 3's table was built from a + `grep | head -20` over 29 matches. The truncation is exactly why + `:8561` vanished. The census is now stated as 20 production sites + + 1 definition + 8 test sites = 29, so a future reader can check the + arithmetic instead of trusting the list. +- **R3-2 (high).** The three equations permitted negative coordinates + on a surface shorter than its chrome, where today's + `text_area_bottom` clamps with `.max(0.0)`. All three are now + explicitly clamped, preserving the current helper's behavior. +- **R3-3 (medium).** §5.1's "exact split" omitted `validate_cells`'s + `cell.attachment.is_some()` rejection. It is now classified — and + **shared**, with the reasoning pinned. + +### 0.3 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed + +- **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two + boundaries that must **diverge** once a panel exists. Several sites it + named are not document-bottom consumers at all: the status-band + background (`main.rs:5908`) must stay at the physical window bottom, + the status text buffers (`:3175`, `:3185`, `:6601`, `:6607`) consume a + *height* and never a bottom coordinate, and status text placement + (`:7134`) sits inside an unchanged band. §5.3 now splits the single + value into **three** named boundaries, classifies every existing + `text_area_bottom` call site, and adds the contrast assertion that + catches a uniformly-wrong implementation moving both together. +- **R2-2 (high).** `accept_frame_geometry -> bool` cannot distinguish + *advanced* from *accepted duplicate* from *rejected*. It now returns an + explicit three-valued result. The exhaustion wording also permitted + retaining stale geometry, which is not fail-closed: §3.1 now clears the + authoritative declaration and reconciles to hidden, and adds the + frontend-side terminal latch. +- **R2-3 (high).** Parent acceptance 52 was assigned wholly to 2A, but + 2A has no semantic panel projection — it can only prove the extracted + painter accepts an explicit `None`. 52 is now also reasserted in 2B, + where the contract becomes production-reachable. +- **R2-4 (medium).** §9 names the four touched acceptance suites + explicitly rather than relying on "standing suite". +- Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at + scale 1.0, and `TEXT_TOP` stays unscaled. + +### 0.4 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed + +- **R1-1 (blocker).** Rev 1 said all 23 census reads route through + `primary_document_window`. That contradicts Q#BP14, which routes only + the **Projection** class (#1–#12, #21–#22) that way and leaves focus, + input, chrome, and bell consumers on their own authorities. Rev 1's + rule would have broken remote-op validation, `DispatchIdle`, + presence, focused search/menu/completion routing, and bell ownership. + §3.2 now restores all four classes; §7's criterion pins them + separately. The inherited-reading list above gains Q#BP14 and Q#BP14a. +- **R1-2 (blocker).** Rev 1 treated the three `src/statusline.rs` + active reads as one disposition. Only `:644` selects the wrong + window; `:629` and `:675` must keep tracking **actual focus**. §3.3 + is rewritten and the criterion states the required behavior instead + of routing focus away. +- **R1-3 (high).** The `panel_capable` flip needed an exact attach + rule, not "for semantic sessions". §3.5 states it: **v21-or-later + negotiated authenticated semantic session only**. +- **R1-4 (high).** Option 1 accepted, but the epoch needed a state + machine, split APIs, and a fail-closed allocator. §3.1 now carries + the transition table and the API split. Rev 1's phrasing "rejects a + lower-or-equal epoch carrying different data" was itself wrong — a + lower epoch carrying *identical* data is still stale. +- **R1-5 (high).** Rev 1's eleven draft criteria silently omitted + parent 37–55. §7 now declares the parent list authoritative, maps it + to 2A/2B, and adds only refinements. The painter-extraction criterion + pins cursor, `view_top`, and passive-window state, not just cells. +- **R1-6.** All four scout obligations are closed in §5. +- **R1-7.** The coherence statement understated journey impact and + overclaimed on background work. §6 names journey steps 7–10 and + narrows the §9 claim. +- **R1-8.** Factual corrections in §1 and §3.2. + +## 1. Anchor re-scout + +| Parent anchor | Now at | Verdict | +| --- | --- | --- | +| `paint_frame` returns cursor separately (`editor.rs:2833`) | `src/editor.rs:3171` | Holds | +| Cursor-visible prep (`editor.rs:2883-2935`) | `src/editor.rs:3249+` | Holds; Stage 1 inserted work above it (§2) | +| Per-window paint body (`editor.rs:2937-3040`) | after `src/editor.rs:3260` | Holds | +| `fold_map_for_window` gates on the **active** frontend (`editor_core.rs:566`) | `src/editor_core.rs:734`, gate at `:738` | Holds | +| Stale "semantic session never enters `paint_frame`" (`window.rs:339`) | `src/window.rs:562` | Holds, still stale; now embedded in a longer `fold_projection` doc block, so the edit is a paragraph rewrite | +| `Mouse` is contractually the grid path (`daemon.rs:3122-3130`) | `src/daemon.rs:3123` | Holds | +| Permanent `24×80` placeholder (`attach.rs:420-429`, `:573-577`) | `pmacs-gpu/src/attach.rs:577`, single site | Holds | +| Byte pin `InstanceMessage::InitialTargetResult` | `pmacs-protocol/src/message.rs:1145` | Holds — still the enum's final variant | +| Byte pin `FrontendEvent::TerminalPointer` | final variant of its enum | Holds | + +**Protocol was still v20 at this re-scout**; no intervening PR had +bumped it. Q#BP9's conditional resolved: **Stage 2 reserves v21**. +Rev 6 R6-1 adds the server-first compatibility constraint discovered +during 2B-1 review. + +Fifteen PRs merged between the parent's last re-scout (`47581f4`) and +this one: #149, #150, #152–#155, #158–#166. Nothing in the parent's +mechanical model was falsified by any of them. + +## 2. What Stage 1 already built for Stage 2 + +- `DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }` + (`src/window.rs:522-528`), held as + `FrontendView::frame_geometry: Option<_>` (`:589`) where `None` means + **unknown** — Q#BP15a's "unknown is first-class", already landed. +- `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) → + `declare_frame_geometry` + `reconcile_panel_layout`, driven from two + daemon sites gated on `panel_capable_for` (`src/daemon.rs:1882-1883` + attach, `:1972-1973` resize). +- `paint_frame` declares geometry itself (`src/editor.rs:3187`), before + the statusline fan-out and before the long mutable core borrow. +- `StatuslineEvaluationTarget` (`src/statusline.rs:212-226`) is already + a two-variant enum, so Q#BP8's fan-out generalization is an added + variant, not a refactor. +- `primary_document_window` (`src/editor_core.rs:2830`) and + `primary_document_buffer` (`:2845`). + +## 3. Findings and decisions + +### 3.1 Q#BP2S1 — epoch ownership, resolved: frontend-owned, with an exact state machine + +**Decision: option 1.** The epoch is owned by the frontend for +negotiated semantic-panel sessions. The deciding argument is one rev 1 +missed: **a font or scale transaction can require invalidating an old +`PanelFrame` even when the derived `CellSize` is identical.** Daemon +value dedup cannot detect that case, because the cell totals it +compares are unchanged while the pixels behind them are not. + +The landed allocator conflicts in three ways +(`src/editor_core.rs:3155-3172`): it allocates the id itself, it +early-returns when `total` is unchanged (value dedup), and it uses +`saturating_add`, which is neither wrapping nor fail-closed — it pins +at `u64::MAX`, after which two different geometries share one id. + +**Acceptance rules for a semantic declaration:** + +| Incoming declaration | Result | +| --- | --- | +| epoch **greater** than stored | Accept, store **verbatim**, even if `total` is unchanged | +| same epoch, same `total` | Idempotent no-op | +| same epoch, **different** `total` | Reject | +| **lower** epoch, any `total` | Reject | + +The last row is deliberate and corrects rev 1: a lower epoch carrying +identical data is still stale and must not be accepted. + +**API split.** Two methods, not one method with an optional epoch: + +- `declare_frame_geometry(fid, total)` — the **grid/LOCAL** allocator. + Keeps value dedup (correct there: cells are the unit, and an + unchanged grid means an old frame is still valid under unchanged + metrics). Changes from `saturating_add` to **checked** allocation + with an explicit fail-closed exhaustion arm. +- `accept_frame_geometry(fid, geometry_epoch, total) -> GeometryUpdate` + — the **semantic** path. No value dedup; applies the table above + verbatim. + +An ambiguous single method with an `Option` epoch is rejected +explicitly: it would let a future caller silently take the wrong regime. + +**The result is three-valued, not a boolean.** A boolean cannot +distinguish the three outcomes the caller must act on differently: + +```rust +enum GeometryUpdate { + /// Epoch advanced: stored verbatim. Run panel reconciliation. + Advanced, + /// Same epoch, same total: already current. Do no work. + Duplicate, + /// Same epoch with different total, or a lower epoch: stale or + /// conflicting. Drop the event before any reconciliation. + Rejected, +} +``` + +`Advanced` reconciles, `Duplicate` returns without touching panel +state, and `Rejected` drops the event. Collapsing `Duplicate` into +either neighbour is a defect in one direction or the other: folded into +`Advanced` it reconciles on every repeated declaration, folded into +`Rejected` it would log or surface a stale-event condition that never +happened. (If a boolean is kept for a narrower internal caller, it must +be named `advanced`, never `accepted` — `Duplicate` *is* accepted.) + +**Initial epoch.** The frontend's first declaration after attach +acceptance carries epoch `1`. `0` is reserved as "never declared" and +is rejected on the wire. + +**Exhaustion fails closed on both sides, and rev 2's wording did not.** +Saying the panel "stays at its last valid geometry" is not fail-closed: +if the real frame resizes after the allocator is exhausted, the daemon +would keep painting a panel sized to geometry that no longer describes +the frontend. + +- **Grid/LOCAL path.** On checked-allocation exhaustion, **clear** the + authoritative `frame_geometry` (back to `None` = unknown) and + reconcile. Unknown is already non-presentable under Q#BP2b, so the + panel hides. Stale geometry is never retained. +- **Frontend path.** On exhaustion the frontend sets a **terminal + latch** for the life of the session: it sends no further geometry, + and — critically — an old matching `Present` **cannot** make the band + reappear, because the latch suppresses paint and hit-testing + independently of frame validity. Only a fresh session (reconnect) + clears it. Without the latch, a retained `Present` whose epoch still + matches the last declaration would resurrect a band under geometry + the frontend has disowned. + +### 3.2 The census is classified, and it is mostly unrouted + +**Correction to rev 1.** Q#BP14 routes only the **Projection** class +through `primary_document_window`. Rev 1's "all 23 reads" was wrong and +would have broken five subsystems. The four classes, restored: + +| Class | Census items | Authority | +| --- | --- | --- | +| **Projection** | #1–#7, #9, #10, #12, #21, #22 | `primary_document_window` / `primary_document_buffer` | +| **Projection + focus** | #8 (document `Pointer`), #11 (full-window `TerminalPointer`) | Align the primary document window **and then activate it** — the one place the two legitimately move together | +| **Focus / input** | #13 (remote-op validation), #14 (`dispatch_idle_for`), #15 (presence), #23 (remote-op application) | The frontend's **actually focused** window. Q#BP14a: gating is per-window, never per-buffer | +| **Focus chrome / surface-routed** | #16–#19 (search, menu, minibuffer, completion) | Q#BP14b's routing table — the currently owned surface, with authoritative clears for the other | +| **Focus / session** | #20 (terminal bell drain) | Per-session counter; the **focused** window chooses which session may drain | + +Rerouting any of the last three classes to the document is a defect, +not a simplification: it would break remote-op validation and +application, `DispatchIdle`, presence, focused search/menu/completion +routing, and bell ownership. + +**How much is already routed.** `primary_document_window` has **four** +references in `src/` and **two production paths**: directly at +`src/daemon.rs:1639` (#148's initial-target bootstrap, Q#BP11b), and +through `primary_document_buffer` at `src/daemon.rs:2998`, which is +census **#22** and carries a comment naming it. So one census item is +routed and the Projection class is otherwise open. For scale, `src/*.rs` +still holds ~80 non-test direct `.active` reads on top of the +`active_window*` / `active_buffer*` helper family +(`src/editor_core.rs:663-967`). + +This is not a Stage 1 defect — with `panel_capable = false` no semantic +frontend can hold a side window, so the unrouted Projection reads are +unreachable from the GPU. It does mean **classified census routing is +the bulk of Stage 2**, which is why it is Stage 2A. + +### 3.3 The three statusline reads have two dispositions, not one + +All three sites are real, but only one is wrong: + +- `src/statusline.rs:644` — `.get(&view.active)` **selects the wrong + window** when a panel is focused. This is the Projection read (#12). +- `src/statusline.rs:629` and `:675` — `active: window_id == + view.active` **must continue tracking actual focus**. Three reasons: + grid contexts need a truthful `active`; post-callback revalidation + must notice a focus change; and parent acceptance 42 explicitly + requires that a document provider may observe `active = false` while + the panel is focused. + +**The new semantic-layout target** therefore captures the **primary +document window plus the visible side window**, marks each context +`active` iff its `window_id == view.active`, invokes each provider +**exactly once**, and **invalidates the entire evaluation** if a +callback mutates layout or focus. Unprojected document splits run no +callbacks (Q#BP8). Route the primary-document result to semantic +`StatuslineSegments` and the side result to the panel mode line. + +### 3.4 Fold projection + +Unchanged from Q#BP17, with the anchor corrected: the extracted painter +takes the map as a **parameter**; the panel path passes `None` when the +owning frontend's `fold_projection` is false and must never call +`fold_map_for_window`, which gates on the **active** frontend +(`src/editor_core.rs:734`, gate at `:738`) — right for command-time +reckoning, wrong for painting another frontend's panel. The stale +comment is at `src/window.rs:562`. + +### 3.5 The `panel_capable` flip needs a negotiated rule + +Not "true for semantic sessions". Exactly: + +> `panel_capable = true` **only** for an authenticated semantic session +> that negotiated **v21 or later**. + +A v6–v20 semantic frontend stays non-panel-capable and takes the +existing Stage 1 fallback: the non-side target with **every +side-specific parameter discarded**, leaving the document window +undedicated (Q#BP2c). "It receives no new events" is insufficient — if +the daemon nevertheless places that frontend's window in a side panel +it cannot render, the window becomes invisible. The gate is on +placement, not only on transport. Parent acceptance 51 pins the mixed +session. **The production daemon does not advertise v21 in 2B-1 or +2B-2.** Because `Hello` is server-first, 2B-3 must add or prove a +compatibility-preserving way to activate v21 before applying this rule; +merely advertising 21 would strand already-shipped v20 clients before +they can identify themselves. + +## 4. Revisions to the parent framing + +Only these; everything else stands. + +- **Q#BP9 resolves to the v21 schema, with production advertisement + held at v20 until 2B-3 supplies compatible activation.** +- **Q#BP15a's epoch ownership is specified** by §3.1's table and API + split, replacing the parent's one-line "frontend-owned" statement. +- **Q#BP8's statusline criterion splits** per §3.3: one read reroutes, + two keep tracking focus. +- **Q#BP17's stale comment is at `src/window.rs:562`**, and parent + acceptance 52's reference to `:339` should be read against that. + +## 5. The four scout obligations, closed + +### 5.1 The shared cell-grid validator boundary + +`TerminalFrame::validate` (`pmacs-protocol/src/terminal.rs:226`) +currently interleaves both concerns. The exact split: + +- **Factored into the shared parameterized wire-cell-grid validator:** + checked area (the `checked_mul` + `usize::try_from` guard), the + `MAX_TERMINAL_VISIBLE_CELLS = 262,144` aggregate cap, cell-count + equality against declared area, cursor-in-bounds, and + `validate_cells`'s glyph width / continuation topology and aggregate + glyph-byte checks. +- **Stays terminal-only:** the `MAX_TERMINAL_ROWS/COLS = 512` per-axis + caps in `checked_area`, `validate_metadata` for title/signal/crash + text, `validate_selection`, and the `at_bottom == (scroll_offset == + 0)` coupling. + +**Attachment rejection is shared, not terminal-only.** `validate_cells` +also rejects `cell.attachment.is_some()` +(`pmacs-protocol/src/terminal.rs:305`), and its error text reads "A +cell carries a frontend attachment, which terminals never use" +(`:190-191`) — phrased as a terminal-specific fact, which is why rev 3 +missed it. **Stage 2 classifies it shared**: panels implement no +attachment rendering, so a `PanelFrame` carrying one describes a +surface the GPU would silently not draw. Shared rejection fails closed +on the producer side rather than shipping an invisible cell. The error +message is reworded away from "which terminals never use" to a +grid-neutral phrasing when it moves. If a later stage gives panels +attachment rendering, this rejection moves back to terminal-only as a +deliberate, reviewed change — not by default. + +`PanelFrame` takes the shared half plus its own presence/epoch rules +and does **not** inherit the 512 per-axis cap (Bet B5'), so a 4K +small-font panel wider than 512 columns is legal while the shared area +budget still binds. Parent acceptance 39 pins exactly this. + +### 5.2 The GPU outbox needs four more tags + +`coalesce_kind` (`pmacs-gpu/src/attach.rs:331`) today returns four +tail-only tags: `Viewport` → 0, `Pointer{Drag}` → 1, +`TerminalPointer{Move}` → 2, `TerminalPointer{Drag}` → 3. Everything +else is `None` = lossless, counting against `OUTBOX_MAX = 8192`. + +Stage 2 adds **four distinct tags**: `FrontendCellGeometry` → 4, +`PanelResizeRows` → 5, `PanelPointer{Move}` → 6, `PanelPointer{Drag}` +→ 7. Geometry is latest-wins (epochs need only increase, not be +consecutive); resize drag is latest-wins over the complete event +including its epochs. `PanelPointer` `Down`/`Up`/wheel/context stay +lossless and ordered — repeated left `Down`s are what the daemon click +state reads as a multi-click, and `Down(Right)` is the context-menu +gesture. Tail-only replacement preserves ordering across an +intervening event of any other class. + +### 5.3 The pixel formula's inputs — and one trap + +The formula in Q#BP15a is contract-level, not an implementation +detail, because its inputs are not all safe to adopt: + +| Input | Source | Note | +| --- | --- | --- | +| `status_band_height_px` | `FontMetrics::status_band_height` (`pmacs-gpu/src/main.rs:137`) = `BASE_STATUS_BAND_HEIGHT * scale` | Safe | +| `TEXT_TOP_px` | `const TEXT_TOP: f32 = 16.0` (`main.rs:352`) | Safe; unscaled today | +| `code_line_height_px` | `FontMetrics::code_line_height` (`main.rs:131`) = `BASE_CODE_LINE_HEIGHT * scale` | Safe | +| `resolved_monospace_advance_px` | `State::mono_advance` (`main.rs:4899`) | **Unsafe to adopt blindly** | +| `divider_height_px` | `BASE_DIVIDER_HEIGHT` | **Does not exist yet** | + +**The `mono_advance` trap.** `State::mono_advance` returns +`measured_mono_advance` when a `FontFacts` probe has been applied, but +otherwise falls back to **the first shaped glyph of the document +buffer** (`main.rs:4903+`). Panel column count would therefore become +**document-dependent**: two GPU frontends showing different files could +derive different `total.cols` from identical metrics, and the same +frontend's panel width could change when the document's first glyph +changes. + +**Decision.** The panel geometry declaration uses a **stable normal-face +probe**, never the document sample. `probe_mono_advance(font_system, +family, metrics)` (`main.rs:323`) already exists and is exactly this: it +shapes `ADVANCE_PROBE` in a scratch buffer, independent of document +contents, dividing total run width by logical cells so ligature +substitution survives. The declaration resolves its advance from that +probe for the current family/metrics. If the probe returns `None` (the +family shapes no width), the frontend declares **zero usable geometry** +under a new epoch — the panel hides — rather than falling back to a +document sample. + +**`BASE_DIVIDER_HEIGHT = 4.0`** at scale 1.0, scaled by +`FontMetrics::scale` like `status_band_height`. A 1–2 px rule is +adequate decoration but too fragile as the drag hit strip; 4 px still +reads as a rule while giving the pointer a usable target. **The entire +strip is painted with `ui.divider`, and that exact rectangle is the +hover/drag hit region** — paint geometry and hit geometry are the same +rect, so they cannot drift apart. + +**`TEXT_TOP` stays `16.0`, unscaled.** It is a fixed surface inset +today, like `TEXT_LEFT` and the other paddings, while +`FontMetrics::scale` governs font-derived metrics and row chrome. +Scaling it only inside the declaration formula would disagree with the +actual renderer; scaling every renderer and hit-test occurrence is a +wholesale inset/DPI change and is **named here as separate work**, not +smuggled into Stage 2. The formula is pinned to the real unscaled inset. + +Accordingly, **Q#BP15a's "all quantities use the frontend's current +scale" is narrowed**: font-derived metrics and the divider scale; fixed +surface insets keep their current units. + +#### The seam is three boundaries, not one + +Rev 2 asked for a single document-bottom accessor. That was wrong: +once a panel is installed, today's single value must **diverge into +three**, because some of its consumers must not move at all. + +All three clamp at zero, preserving today's `text_area_bottom` +`.max(0.0)` behavior — without the clamps a surface shorter than its +own chrome yields negative coordinates, and the "exact formula" stops +being exact precisely where it matters most: + +``` +status_band_top = max(0, surface_height - status_band_height) + +geometry_capacity_bottom = max(0, status_band_top - divider_height) + // divider reserved even while absent + +document_text_bottom = max(0, status_band_top + - installed_panel_height + - installed_divider_height) +``` + +`geometry_capacity_bottom` is what Q#BP15a's asymmetry already +requires: the divider is subtracted **for sizing purposes even while +the panel is absent**, which is what breaks the first-open cycle, while +the document renderer does not actually lose those pixels until a +`Present` panel is painted. + +**Today `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is all three +at once**, and its doc comment calls it "the single source for every +bottom-of-text computation" (Q#S3). + +The census is **29 matches: 20 production call sites, 1 definition +(`:8490`), and 8 test sites** (`:12887`, `:12937`, `:12997`, `:13109`, +`:13793`, `:14013`, `:15306`, `:15386`). Every production site, +classified individually against the source: + +**Status-owned — must stay pixel-identical at the physical window +bottom, using `status_band_top`** (8 sites): + +| Site | What it is | +| --- | --- | +| `:5908` | Status-band background rect `y` | +| `:6003` | `mb_visible_window` — rows that fit **above the band** | +| `:6027` | `mb_dropdown_window` origin — dropdown grows up from the band | +| `:7134` | `status_top` for the right status group | +| `:7195` | `status_buffer` `TextBounds.top` — status text bound | +| `:7212` | `status_left_buffer` `TextBounds.top` — status text bound | +| `:7351` | Minibuffer **candidate glyph** clip, anchored to the dropdown's band origin | +| `:7922` | `status_top`, second site | + +The minibuffer is **global, bufferless chrome anchored to the status +band** (Q#BP14b keeps `MinibufferPrompt` global), so all four of its +sites — `:6003`, `:6027`, `:7351`, and its `status_left_buffer` bound +`:7212` — stay status-owned. Clipping candidate glyphs at +`document_text_bottom` would clip the dropdown against a boundary it +does not sit above. + +**Document-owned — must move when a band is installed, using +`document_text_bottom`** (12 sites): + +| Site | What it is | +| --- | --- | +| `:4566` | `terminal_cell_viewport` — drawable height for the cell grid | +| `:6118` | `completion_anchor_px` — anchor visibility bottom | +| `:6140` | `completion_dropdown_layout` — **document completion placement**; `band_top - (line_top + line_h)` is the space below the anchor line | +| `:6581` | `code_height` | +| `:7174` | Code text clip bottom | +| `:7242` | Math text clip bottom | +| `:7273` | Gutter clip bottom | +| `:7421` | Terminal clip bottom | +| `:8077` | `code_caret_rect_in_clip` — **caret clipping** | +| `:8497` | Minimap drawable height | +| `:8501` | Visible-line estimate | +| `:8561` | `edge_scroll_direction` — **document edge scrolling** | + +**Geometry declaration** uses `geometry_capacity_bottom`, and is the +Q#BP15a conversion only. + +**Sites that consume no bottom coordinate at all** and must not be +touched: `:3175`, `:3185`, `:6601`, `:6607` size the status text +buffers to `status_band_height` directly. Rev 2 listed them as seam +consumers; they are not. + +Three of these classifications are the ones a plausible implementation +gets wrong, and each has a visible symptom: document completion +(`:6140`) anchored to `status_band_top` **overlaps the panel**; +minibuffer candidates (`:7351`) clipped at `document_text_bottom` are +**cut off**; and edge scrolling (`:8561`) left on the old bottom +**auto-scrolls from inside the panel**. + +Each call site is classified individually. A blanket rewrite of +`text_area_bottom` to subtract the band would move the status chrome +with the document and is the defect this section exists to prevent. + +**The contrast assertion (A2B-4).** "Every document consumer moved" is +only half a test — a uniformly wrong implementation that moves +everything passes it. The criterion must assert **both directions in +one scenario**: installing a panel moves every document-owned consumer +**while the status band stays pixel-identical** at the physical window +bottom. That is the assertion a blanket rewrite fails. + +The one-accessor-per-boundary rule still holds within each class: a +second, unrouted derivation of any of the three is the exact shape of +Stage 1's `Layout::compute` two-caller defect, where +`src/overlay_paint.rs` derived its own rect and painted peer cursors at +unfixed rows. + +### 5.4 Ordering against folding Stage 3 + +Settled by review round 1: **bottom-panel Stage 2 first, through the +landed GPU band.** Folding Stage 3 then re-scouts the extracted +painter, the panel projection, clipping, and `fold_projection` behavior +exactly once. + +## 6. Coherence impact (per `COHERENCE.md` §20) + +- **Journey steps touched: four, on the GPU frontend — steps 7–10** + (find symbol / find file, terminal, build and test, error + inspection). Rev 1 said "none directly", which contradicted its own + next sentence. Today a GPU user who triggers references, project + search, a terminal, compile, or error inspection gets the Stage 1 + non-side fallback: the output surface steals a document window + instead of opening a panel. Every one of those steps therefore + behaves differently on GPU than on TUI, and Stage 2 is what closes + the divergence. +- **Interaction islands added: none, and this is a reduction.** §6 + grades islands "weak, and growing by one island per modal feature". + Stage 2 extends one already-adopted policy (`display = "panel"`, + used by listview, compile, and terminal) to a second frontend rather + than minting a GPU-only surface. Q#BP14b deliberately reuses the + existing `SearchPrompt` / `MenuPrompt` / `CompletionPopup` messages + instead of panel-specific twins. +- **Config registry adoption: inherited, not extended.** Stage 1's + `window.panel-height` and `window.min-height` already live in the + registry. Stage 2 adds no new user-facing option; if the band needs + one, it enters the registry. +- **Background-work attribution: unchanged, and this stage does not + advance it.** Rev 1 implied Stage 2 helps §9's activity-view gap. It + does not. A panel gives output a coherent *placement*; it does not + make terminal PTYs, LSP servers, or workers appear in the + activity/ownership view §9 describes, and it adds no join key across + the four disjoint activity planes. The §9 gap is untouched. +- **Section this serves:** `COHERENCE.md` §14, which records the panel + primitive as landed for Stage 1 and names "Stage 2 (GPU band) + pending its own framing" as the open item. +- **Which slice pays the coherence debt (rev 5).** The journey claim + above is Stage 2B-3's alone. 2A, 2B-1, and 2B-2 close **no** journey + divergence: with `panel_capable = false`, a GPU user still gets the + Stage 1 non-side fallback on steps 7–10 after all three land. Stated + explicitly so no slice's PR can claim the arc's coherence benefit + before the flip earns it — three quarters of this stage is + preparation, and only the last quarter is the improvement. + +## 7. Acceptance + +**Parent criteria 37–55 remain authoritative and are not replaced.** +This section maps them to the four slices — 2A, then 2B-1/2B-2/2B-3 — +and adds only refinements. A criterion that spans a slice boundary is +named in each slice it touches, with its half stated. + +### 7.1 Stage 2A — classified census routing + painter extraction + +No protocol change. Parent criteria that apply in full: **42, 43, 44, +51 (the `LOCAL`-panel inheritance half)**, plus the extraction half of +**52**. + +**52 splits across the slices.** 2A has no semantic panel projection +and no `PanelFrame`, so all it can prove is that the extracted painter +honors an explicitly supplied `None` fold map and that the stale +`src/window.rs:562` comment is corrected. The actual contract — *a +semantic panel with `fold_projection = false` never collapses folds and +never calls `fold_map_for_window`* — is production-reachable only once +2B lands the projection and the capability flip. It is therefore +reasserted in 2B (§7.2). + +Refinements 2A adds: + +- **A2A-1 (replaces rev 1's criterion 1).** Every **Projection** census + item (#1–#7, #9, #10, #12, #21, #22) resolves through + `primary_document_window` / `primary_document_buffer`; #8 and #11 + align **and then activate**; **#13, #14, #15, #23 continue to resolve + the actually focused window**; #16–#19 follow Q#BP14b's routing + table; #20 keeps its per-session counter with focus choosing the + eligible terminal. Each class is asserted separately, at the + outermost user-reachable seam, and falsified by revert. A test that + only proves "the document is used" would pass with the focus classes + wrongly rerouted, so the focus-class assertions are the load-bearing + half. +- **A2A-2 (replaces rev 1's criterion 2).** `src/statusline.rs:644` + resolves the primary document window, while `:629` and `:675` + continue to report **actual focus** — pinned by a document provider + truthfully observing `active = false` while the panel is focused + (parent 42). The semantic-layout target captures primary document + + visible side window, invokes each provider exactly once, and + invalidates the whole evaluation when a callback mutates layout or + focus. +- **A2A-3 (replaces rev 1's criterion 3).** The painter extraction + preserves, for grid frontends: the painted **cells**, the **returned + cursor**, the **focused window's `view_top` mutation** from the + auto-scroll clamp, and **passive windows' untouched `view_top` and + scroll state**. Byte-identical cells alone would not catch a clamp + that silently moved to the wrong window. + +### 7.2 Stage 2B — v21 protocol, daemon projection, GPU band + +Stage 2B as a whole owns parent criteria **37, 38, 39, 40, 41, 45, 46, +47, 48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44, +and 52** **through the actual negotiated capability flip** rather than +through a test-only panel-capable semantic view. 52's 2B form is the +production one: a real semantic frontend with `fold_projection = false` +displaying a folded buffer in a panel shows every source line, and the +panel path never reaches `fold_map_for_window`. + +Per §0.0 R5-1 those land across three slices. Each slice's own gate run +is the standing suite plus §9's named acceptance suites; **only 2B-3 +changes what a user sees.** + +#### 7.2.1 Slice 2B-1 — the v21 wire layer + +**Authority: `pmacs-protocol`.** The four wire shapes Q#BP9 names, the +version bump, and the shared cell-grid validator. No producer, no +consumer, no capability change. + +- **37, in full.** `PanelFrame` round-trips including `panel_epoch` and + `geometry_epoch`, with independent byte pins on the previous final + `InstanceMessage::InitialTargetResult` and + `FrontendEvent::TerminalPointer` variants. **Both pins must be + falsified by revert**, not merely observed passing: a byte pin that + never saw the shift it exists to catch pins nothing. +- **39, the wire half only.** Shared cell/topology/glyph/area + validation; an area-bounded panel wider than 512 columns is accepted + while a terminal frame retains its 512-column PTY cap; the maximum + legal panel encoding stays below the transport limit. **The ratchet's + fixture must be shown to spend the whole aggregate glyph budget** — + otherwise it measures something smaller than the worst case and the + bound it proves is not the bound that matters. The worst case is + `1 × MAX_PANEL_VISIBLE_CELLS`, a legal panel geometry no terminal can + express, so the terminal's own ratchet has never covered it. + **39's receiver half — atomic rejection with retention of the + previous valid frame, and a duplicate doing no work — is 2B-2.** +- **The version ladder moves with the bump.** `PROTOCOL_VERSION` + becomes 21, `SUPPORTED_PROTOCOL_VERSIONS` accepts `6..=21` and + rejects 22, and any test whose *name* encodes the old number is + renamed. A ladder pin that passes across a bump was not pinning the + version. **`ADVERTISED_PROTOCOL_VERSION` remains 20 in 2B-1 and + 2B-2** because the unsolicited `Hello` precedes any client version + signal. A real daemon must remain attachable by a client whose + supported range ends at 20. +- **Shared bounds are aliased, not duplicated.** Every constant the + terminal screen and the panel validator both enforce is one + definition with the other as an alias, so truncation and validation + cannot drift apart. +- **Not in this slice:** the daemon arm that drops panel events from a + grid session is exhaustiveness bookkeeping the bump forces, not + projection. It asserts only that a grid session's panel declaration + is dropped rather than trusted. + +#### 7.2.2 Slice 2B-2 — the daemon panel projection and epoch machine + +**Authority: `src/daemon.rs`.** Produces `PanelFrame`; derives the +grid; owns stale-event rejection. Exercised through a **test-only** +panel-capable semantic view — `panel_capable` stays `false` in +production negotiation until 2B-3. + +- **38** (open → replace buffer → hidden by a tiny frame → reappear → + close, with authoritative `Absent` and a new epoch on + replacement/reappearance), **40** (first open at a non-80×24 frame + stays absent until real `FrontendCellGeometry` arrives, never + consulting the 24×80 attach placeholder), **49**, **50**, **51**, + **53**. +- **39's receiver half**, per §7.2.1. +- **41, the daemon half:** the daemon alone derives the grid; an older + retained frame neither paints nor accepts input after a new + `geometry_epoch` until a matching `Present` arrives; row-clamping + preserves the stored request; zero, non-finite, and non-positive + metric inputs fail closed to zero usable geometry. *The pixel→cell + formula and its call sites are 2B-3.* +- **42, 43, 44, 45, 52** in their projection form, through the + test-only panel-capable view. Their production re-assertion through + the real flip is 2B-3. +- **A2B-1.** The epoch state machine of §3.1 is pinned row by row, + including the lower-epoch-identical-data rejection and the + same-epoch-different-total rejection, and each row's + `Advanced`/`Duplicate`/`Rejected` result is asserted — a `Duplicate` + performs no reconciliation and a `Rejected` mutates nothing. Epoch + `0` is rejected on the wire. **Exhaustion is pinned on both sides**: + grid exhaustion clears `frame_geometry` to unknown and the panel + hides (a subsequent real resize must not paint a stale-geometry + panel), and a frontend that exhausts latches — a retained `Present` + whose epoch still matches cannot make the band reappear, and only a + fresh session clears the latch. **A2B-1's grid-exhaustion half is + 2B-2; its frontend-latch half needs a real frontend and is 2B-3.** + Both halves are named here so neither is lost at the seam. + +#### 7.2.3 Slice 2B-3 — the GPU band and the capability flip + +**Authority: `pmacs-gpu`, plus the compatibility-preserving negotiation +activation.** This is the only slice a user can observe, and the only +one that closes the journey divergence in §6. It must not advertise +v21 in the server-first `Hello` until an existing v20 client can still +attach. + +- **46** (band + divider shrink the document text area by exactly their + pixel height; carets, hits, and scroll geometry respect the reduced + area), **47** (divider drag, `window.min-height`, `RowResize` hover, + and the stalled-writer tail-coalescing), **48** (`PanelPointer` + driving selection, terminal mouse reporting, and click-to-focus + without disturbing the document mirror), **54** (the + `--headless-probe` run: one real daemon, real PTY, real wgpu, through + a panel-hosted terminal), **55**. +- **41, the GPU half:** the pixel→cell conversion pinned at fractional + widths and heights, and geometry refresh on window resize, font + change, and scale change. +- **42, 43, 44, 45, 52 re-asserted through the production flip**, not + the test-only view. This is the point of the re-assertion: a + test-only panel-capable view can be constructed wrongly and agree + with itself, so the production negotiation path must carry the same + assertions. +- **A2B-1's frontend-latch half**, per §7.2.2. +- **A2B-2.** A font or scale change that leaves `CellSize` **identical** + still produces a new `geometry_epoch`, and the older `PanelFrame` + neither paints nor hit-tests until a matching `Present` arrives. This + is the case daemon value dedup cannot see and is why option 1 was + chosen. +- **A2B-3.** Panel columns are derived from the **stable normal-face + probe**, not `State::mono_advance`'s document-glyph fallback: two GPU + frontends with identical metrics and different documents derive + identical `total.cols`, and a probe returning `None` declares zero + usable geometry rather than falling back to a document sample. +- **A2B-4 (contrast assertion).** Installing a panel moves **all twelve + document-owned consumers** of §5.3 by exactly + `installed_panel_height + divider_height`, **while all eight + status-owned sites stay pixel-identical** at the physical window + bottom. Both halves are asserted in one scenario: a uniformly wrong + implementation that moves the status band too passes the "everything + moved" half alone. Three rows carry their own named symptom because + they are the ones a plausible implementation misclassifies — + **document completion (`:6140`) must not overlap the band**, + **minibuffer candidates (`:7351`) must not be clipped by it**, and + **edge scrolling (`:8561`) must not trigger from inside it**. The + geometry declaration separately reserves the divider while the panel + is `Absent`, and the document loses no pixels until a `Present` is + painted. All three boundaries clamp at zero on a surface shorter than + its chrome. +- **A2B-5.** `panel_capable` is true only for a v21+ negotiated + authenticated semantic session; a v20 semantic session is never + **placed** in a side window, not merely denied the events. The same + acceptance must attach an actual v20 client to the production daemon + after v21 activation, so the new path cannot pass by breaking the old + handshake before placement is evaluated. + +## 8. Open items + +**None.** Both round-1 open items are decided in §5.3: +`BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0 (scaled, whole strip painted +`ui.divider` and used as the hit rect), and `TEXT_TOP` stays unscaled +with wholesale inset/DPI scaling named as separate work. + +One deferral is recorded rather than resolved: **wholesale surface-inset +scaling** (`TEXT_TOP`, `TEXT_LEFT`, and the sibling paddings under +`FontMetrics::scale`) is pre-existing behavior Stage 2 pins rather than +fixes. It belongs to a spacing-system change of its own. + +## 9. Slices, branches, and gates + +Per review round 1 and §0.0 R5-1: **four serial implementation PRs**, +each a named slice under this framing so one-feature/one-branch/one-PR +holds. **Each slice lands before the next branches** — none are +stacked, and each is cut from `main`. + +- **Stage 2A — MERGED as #177** (`main` @ `0a3fcd1`). Classified census + routing + per-window painter extraction. Branch + `bottom-panel-stage2a`. No protocol change. The three-boundary GPU + split is **2B-3**, not 2A: it is only observable once a band can be + installed. +- **Stage 2B-1 — the v21 wire layer.** Branch `bottom-panel-stage2b`. + The four wire shapes, the version bump, the shared cell-grid + validator, and the version-ladder move. The v21 schema is reserved + while the production daemon continues advertising v20. **No + producer, no consumer, no capability change** — `panel_capable` + stays `false`. +- **Stage 2B-2 — the daemon panel projection and epoch machine.** Cut + from `main` after 2B-1 merges. Produces `PanelFrame` and owns + stale-event rejection, exercised through a **test-only** + panel-capable semantic view. Still no production flip. +- **Stage 2B-3 — the GPU band and the negotiated flip.** Cut from + `main` after 2B-2 merges. The three-boundary text-area split, the + divider, pointer routing, the compatibility-preserving v21 + activation, and `panel_capable = true` for a v21+ negotiated + authenticated semantic session. **This is the slice that changes + what a user sees**, and it repeats 2A's and 2B-2's relevant + assertions through the real capability flip. + +**Each slice runs the full gate set below, not a subset of it.** A +slice that touches only `pmacs-protocol` still runs the GPU and vterm +suites: the shared validator and the wire enums are exactly the kind of +change whose breakage surfaces in a consumer rather than at its own +definition. + +Gates for each slice: the standing suite from `CLAUDE.md`, plus the **touched +acceptance suites named explicitly** — the standing rule is to run the +suites a change touches, and "standing suite" does not name them: + +- `bottom_panel_stage1_acceptance` — the substrate all four Stage 2 + slices build on. +- `bottom_panel_stage2a_acceptance` — Stage 2A's classified census and + painter extraction. +- `bottom_panel_stage2b_protocol_acceptance` — Stage 2B-1's v21 schema, + server-first v20 compatibility, byte pins, and shared validation. +- `bottom_panel_stage2b_daemon_acceptance` — the exact suite name + reserved for Stage 2B-2's projection and epoch machine. +- `bottom_panel_stage2b_gpu_acceptance` — the exact suite name reserved + for Stage 2B-3's band, compatible activation, and capability flip. +- `statusline_segments_acceptance` — the fan-out target change (§3.3). +- `m11_5_semantic_acceptance` — the semantic census (§3.2). +- `gpu_initial_target_acceptance` — parent criterion 55. +- `gpu_font_acceptance` — font/scale geometry refresh (§5.3), including + the normal-face probe and the unscaled-`TEXT_TOP` decision. +- The three vterm suites — the panel hosts terminals. +- Folding Stage 2's 48 — shared projection. +- `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. + +Protocol round-trip and byte-pin tests ride 2B. Parent criterion 54's +`--headless-probe` run — one real daemon, real PTY, real wgpu, through a +panel-hosted terminal — is a 2B gate. diff --git a/docs/dired-framing.md b/docs/dired-framing.md index ada853e..83e6f1a 100644 --- a/docs/dired-framing.md +++ b/docs/dired-framing.md @@ -1,7 +1,9 @@ # Dired — framing **Revision 7 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162; -Stage 1 IN REVIEW as PR #165, review round 1 addressed.** +Stage 1 MERGED as #165 (`main` @ `c8ec8f3`, one review round). Stage 2 +(marks and operations) and Stage 3 (wdired) each still need their own +framing before implementation; the frozen fixture shrinks after Stage 3.** Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's implementation falsified in the approved text (§0); rev 5 adds the diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md index c3b372c..41ea7d2 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -282,8 +282,17 @@ observers; they are not the transport implementation. - Any `NotFound` from the initial load creates an empty path-backed buffer, including when a parent is currently absent; save-time errors remain save-time errors, matching local `pmacs FILE`. -- `PermissionDenied`, `IsADirectory`, invalid path bytes at the OS boundary, - and other non-`NotFound` errors fail startup. +- `PermissionDenied`, invalid path bytes at the OS boundary, and other + non-`NotFound` errors fail startup. +- **`IsADirectory` is superseded by Journey Stage 1a** + (`docs/journey-stage1a-framing.md`). A directory no longer reaches the + load at all: `resolve_target_buffer` answers `ResolvedTarget::Directory` + ahead of it, so a directory target now *succeeds*, dispatching the + `path.open-directory` chain and replying `Opened`. Deliberate + supersession, not drift — the whole point of that stage is that + `pmacs .` must not exit 1, and a daemon/GPU bootstrap that still failed + would leave the two entry points disagreeing about the same argument. + Non-directory failures are unchanged. - The buffer display name may use `Path::display()` and therefore replacement characters; this must never replace the raw backing path used for dedup, load, or save. @@ -550,12 +559,17 @@ process behavior. 9. **New file:** a nonexistent target produces an empty snapshot, `[new file]` status/path identity, accepts an edit/save through the real session, and creates the requested file under the launcher cwd—not the daemon cwd. -10. **Open error:** a directory/permission-denied target returns a specific +10. **Open error:** a permission-denied target returns a specific failure before ready/window creation and makes root fail. The daemon shuts down that failed session's socket; a client that lingers or sends another event cannot reach uninstalled session state. An existing daemon remains connectable; a pre-existing frontend's active buffer and contents remain unchanged. + **Amended by Journey Stage 1a:** the *directory* case is deliberately + superseded and moved to the success path — see Q#GT6. A directory + target now reaches ready and the document window shows dired, pinned + by `initial_target_directory_reaches_ready` and its two siblings in + `src/daemon.rs`. Permission-denied is unchanged and still fails. 11. **Dedup preserves unsaved edits:** frontend A opens and modifies a file without saving; target-launch frontend B opens the same normalized path and receives A's authoritative unsaved text with the same `BufferId`, not disk diff --git a/docs/gpu-terminal-input-framing.md b/docs/gpu-terminal-input-framing.md index 0bbccef..aa1bea4 100644 --- a/docs/gpu-terminal-input-framing.md +++ b/docs/gpu-terminal-input-framing.md @@ -81,7 +81,11 @@ character: | | frames for a static screen | typed `Z` ever visible at the prompt | |---|---|---| | `main` today | **730** in a 20 s window | **no** | -| with the guard | **2** | (see Q#GT5 — a separate question) | +| with the fix | **2** | yes | + +Bet B2 is **scored TRUE**: with the fix deployed, the reporter confirmed +typing into a GPU terminal works. The earlier caveat here pointed at Q#GT5, +which is now retracted — see "Deferred (named)". The TUI is unaffected: a grid session has no semantic terminal declaration, so only one arm ever runs for it. This is a **frontend-kind** defect, which is @@ -281,11 +285,12 @@ change. Stays v20. snapshot that signals the switch-away). Hence the split in Q#GT1. Recorded rather than deleted: the failure mode is one a reviewer or a future simplification will re-propose. -- **B2.** The user's reported symptom is this defect. *Partially scored: the - storm is proven and GUI-only, and its shape (line editor unusable, output - still flowing) matches the report. Not fully scored until the user, or an - acceptance running the **user's own shell**, confirms typing works after the - fix. Q#GT5 is the reason this bet is stated rather than assumed.* +- **B2 — SCORED TRUE 2026-07-25.** "The user's reported symptom is this + defect." Confirmed in real use after the fix was deployed: typing into a GPU + terminal works. The confirmation needed a daemon **restart** built from a + tree containing the fix — the first attempt reported no change because a + pre-fix daemon still owned the socket, which is worth remembering whenever a + daemon-side fix is being validated by hand. - **B3.** No other pair of per-frontend-kind daemon operations is applied as siblings rather than alternatives. *Scored by an explicit audit of the dispatcher's per-frontend loop during implementation — this defect's shape @@ -294,7 +299,14 @@ change. Stays v20. ## Deferred (named) -- Interactive-shell echo on a raw-mode PTY (Q#GT5) — its own scout. +- ~~Interactive-shell echo on a raw-mode PTY (Q#GT5)~~ — **RETRACTED + 2026-07-25.** The observation behind it (a `bash --norc -i` fixture not + echoing typed characters) does not reproduce in real use: with the fix + deployed, typing into a GPU terminal echoes normally. The fixture was almost + certainly measuring its own timing — polling a published screen snapshot + before readline had finished initialising — not a product behaviour. Recorded + as retracted rather than deleted so nobody re-derives it from the framing's + earlier revision and spends a scout on it. - **A geometry change appears to clear the visible screen.** Observed while building acceptance 4: after the probe's deliberate 25×92 → 20×71 resize, the next frame's visible grid is entirely blank even though the content diff --git a/docs/journey-stage1a-framing.md b/docs/journey-stage1a-framing.md new file mode 100644 index 0000000..ce5589f --- /dev/null +++ b/docs/journey-stage1a-framing.md @@ -0,0 +1,1082 @@ +# Journey Stage 1a — open a directory, on one path + +**Status: framing, rev 8 — APPROVED at rev 5; revs 6–8 record +corrections found during implementation and review of PR #182.** +**Serves `COHERENCE.md` §2 (the golden product journey), §19 (coherence +acceptance tests), §20 Priority 1.** + +## 0. Revision history + +- rev 1 (2026-07-26) — first framing. Scouted against `main` @ `d400f30`. +- rev 2 (2026-07-26) — review round 1. Q#JR2 withdrawn (its ground truth + was false); destination pinning added; the resolver chain restructured + around append-only hook registration; `ResolvedTarget` typed; + `display_file`'s contract specified; the GPU-framing supersession named. +- rev 3 (2026-07-26) — review round 2. Two blockers, two contract gaps: + - **Fail-closed was not failure-atomic** (§4.4). dired mutates handle + state — claim, listing, `prev`, paint — *before* it ever attempts + `display`, so rev 2's "does nothing" left a hidden buffer and could + corrupt an existing handle's `prev`. **Per the review's decision, 1a + now carries the destination-scope substrate**: a `commit_to` + primitive that revalidates and enters the captured frontend's scope + *before* any dired mutation, so the whole post-await commit — `prev` + capture, claim, paint, display, seat — executes against the captured + destination or not at all. + - **Expected-buffer validation** (Q#JR14): the destination carries the + buffer it was requested against, so a user who replaces the bootstrap + buffer mid-listing is not overwritten by stale launch intent. Rev 2's + window-only pin said launch intent wins; it should not, and B2's + "before the user can act" was false (§8). + - **Acceptance 6 was still vacuous**, and the blanket "each fails with + the change reverted" rule cannot hold for preservation guards. §6 is + split into new-behavior acceptances and preservation pins, each pin + naming the targeted mutation that falsifies it (§6.0). + - **Hook error policy specified** (Q#JR15): in a short-circuit hook a + raise and a `false` both yield `proceed = false` (`hook.rs:299-323`); + only `HookOutcome.errors` distinguishes them. An error now stops the + chain *and* suppresses the fallback. + - The fallback slot is described honestly as an **unowned singleton** + (§0.5), not an "ownership-carrying registration". +- rev 4 (2026-07-26) — review round 3. Three substrate details and one + inverted bite mutation: + - **`InteractiveCommandOrigin` was the wrong mechanism, twice over** + (§2.11). It does not scope the APIs rev 3 claimed — no-arg + `pmacs.window.buffer()` reads `core.active_buffer_id()` directly + (`mod.rs:12547`) and `move_to_line` mutates the core's ambient active + window (`mod.rs:12703`) — so `prev` capture and cursor seating stayed + ambient. And it is *authenticated interactive-command authority*: + entering it would make dired's `paint` satisfy the pre-edit unfold + guard (`mod.rs:1391`), `invoke_interactive`'s rotation (`:5400`), and + terminal command context (`:8515`). Rev 4 uses a **separate scoped + frontend override** that also swaps `core.active_frontend`, and + `commit_to` does not touch the interactive origin (Q#JR14e). + - **`dest` becomes nonconstructible userdata** (Q#JR14d). As a table it + is shared across hook listeners, so an earlier listener could mutate + the destination and decline — redirecting later listeners or dired — + and any Lua could fabricate a valid triple. + - **Preflight was missing replaceability** (Q#JR14f). Exact display + also refuses a window dedicated to another buffer + (`editor_core.rs:3566`), so a live destination holding its expected + buffer could still refuse *after* dired claimed and painted — rev 2's + hidden-buffer failure through another door. + - **N8's falsifier was inverted** (§6.1). Both a claim and a raise give + `proceed == false`, so keying the fallback on `proceed` alone is + *correct*; `errors` decides the extra report, not the fallback. +- rev 5 (2026-07-26) — review round 4. The remaining predicate input and + acceptance details: + - **Replaceability now names the incoming buffer** (Q#JR14f). The + shared predicate takes `Option` and serves all three existing + consumers: exact display passes its requested buffer, + `probe_display_target` passes its existing-buffer result, and + `commit_to` passes `None` because dired's replacement does not exist + yet. Thus a destination dedicated to its still-current bootstrap + buffer is refused before dired mutates anything. + - **N6c is executable:** the first listener catches the userdata + mutation rejection and declines, the second verifies the token stayed + unchanged and declines, and only the fallback commits. + - The dired accessor spelling, revision heading, and Stage 2 ledger + claim are corrected. + +- rev 6 (2026-07-26) — **corrections found while implementing**, not a + new design round. Four, all confirmed against the tree: + - **Q#JR3 was false.** `replace_active_buffer` does *not* drop the + startup scratch buffer; its body is one `switch_active_buffer` call, + which reassigns `aw.buffer_id` and removes nothing. The claim came + from that function's own doc comment (`editor.rs:1071`), which has + been wrong for as long as it has existed, and rev 5 propagated it + into §2.2, §3, P4, and the decision list without checking the body. + Corrected in all four places; the stale comment is corrected in this + PR too, since this PR would otherwise add *more* false references to + it. **Actually removing the stale scratch is separate work** — + buffer-lifetime changes have their own consequences (what else holds + the id, what `C-x b` lists) and are not smuggled into a directory-open + stage. + - **The daemon bootstrap could report the wrong buffer** (§4.5). The + directory arm captured `dest.buffer`, ran the resolver chain + *synchronously*, then returned the captured id — so a handler that + opened something synchronously (through `commit_to`, the supported + way) had already replaced the window's buffer, and the reply would + pair one buffer's snapshot with another's identity. The early return + also skipped the post-hook revalidation this framing claimed stayed + active. Rev 6 decides: **report what the window actually holds after + the dispatch**, and rehome through `non_side_target` exactly as the + file arm does. + - **N11 tested neither `RET` nor self-insert.** It called + `display_file` and `buf:insert` directly, so it stayed green with + dired's `RET` binding, its entry dispatch, and the editor's + self-insert path all broken — most of what "the journey works" means. + Both gestures are now dispatched as real keys. + - **P7 was vacuous and is removed, not weakened.** Q#JR12 has nothing + to pin: `run` computes `had_file = file.is_some()` and a directory + path is `Some` like any other, so suppression is structural and the + named mutation would require inventing the branch first. The rev 5 + test additionally never armed restore and hard-coded `had_file`, so + it asserted nothing about `run`. Q#JR12 is downgraded to an + observation. + +- rev 7 (2026-07-26) — **found while writing the `commit_to` suite and + bite-testing it.** Three, all confirmed: + - **N4 did not pin what its comment claimed.** Deleting the + `ScopedFrontend` arm from `acting_frontend` left N4 green, because + `ScopedFrontend::enter` *also* swaps `core.active_frontend` and the + ambient fallback then answers correctly on its own. The arm is + load-bearing in exactly one situation — a commit reached from inside + an interactive command, where the origin sits between the override + and the ambient value and would otherwise win. **N4b** is added, + driven through `dispatch_key` (the only thing that establishes an + interactive origin), and the mutation now bites it. The general + lesson is the §6.0 one again from a new angle: two mechanisms that + agree on the common path make either one look load-bearing. + - **`commit_to`'s forged-destination message was unreachable.** With + the parameter typed `mlua::AnyUserData`, mlua rejected a table during + argument conversion, so a caller who fabricated one got "error + converting Lua table to userdata" — true, but naming neither the rule + nor how to obtain a real destination. The parameter is now + `mlua::Value` and the pointed message actually fires. The refusal is + unchanged; only its legibility is. + - **P1 and P2 also fail on full revert**, since `commit_to` does not + exist on the pre-image. §6.0's "legitimately green on the pre-image" + does not describe them. They stay in the P list because their + *discriminating* falsifier is the named mutation, not the revert: a + revert-only check cannot distinguish "validates" from "validates in + time", which is the entire claim. Noted at each pin rather than + silently mislabelled. + - Bite results recorded: mutation A (scope stops swapping + `core.active_frontend`) fails N6a and P3 and nothing else; mutation B + (preflight moved after the callback) fails P1 and P2 and nothing + else; mutation C (drop the `ScopedFrontend` arm) fails N4b and + nothing else. + +- rev 8 (2026-07-26) — **review of PR #182.** One implementation gap and + two stale claims: + - **dired did not honor the captured window.** §4.4 specified + `display{ window = dest:window() }`; the implementation still ended + in `pmacs.window.switch_buffer`, which targets whatever window the + *scoped frontend* has selected. The scope pins the frontend; it does + not pin the window. So a split or panel that took focus while + `read_dir` was pending received the listing, and `prev` was captured + from it too — with every preflight check passing, because the + captured window was still live and still held its captured buffer. + Fixed in both places (`display` and the `prev` read), and **N4c** + added. The suite's routing pins all varied *frontend* identity; + none varied the selected window within one frontend, which is why + 23 green pins missed it. + - **The §0 scorecard row still graded §2 "Broken at entry"** while §2's + own ground truth had been rewritten — the scorecard is a second copy + of the same claim and §25's update protocol covers both. §19's row + and ground truth were stale in the same way (this PR creates the + first cross-subsystem suite) and are corrected too. + - **P4 still said "leaves exactly one buffer"**, the exact claim rev 6 + corrected as false everywhere else. Restated to what it actually + pins — the file is in the *active window* — matching the test that + was already written correctly. + +--- + +## 0.5. Coherence impact (`COHERENCE.md` §20, required since #163) + +- **Journey steps.** §20's first-named arc; 1a takes the broken half of + **step 3**. After 1a, `pmacs .` opens the directory. Steps 4 and 6–12 + do not change grade. §2's verdict table and §20 Priority 1's "State: + broken at step 3" line are rewritten in this PR per §25. +- **Interaction islands: adds none, removes one.** No new keymap, mode, + or modal surface; the directory arm routes into #165's dired buffer — + the "must not invent a second directory surface" constraint. The + unification (§3) removes an island: startup and the daemon bootstrap + resolve paths through two independently-written implementations today. +- **Config registry.** Adds no keys. The directory fallback is a function + slot, not a setting — `ConfigValue` is four scalars and a handler is + none of them (the reason terminal profiles could not be settings, #173). +- **Ownership, stated honestly (rev 3).** That slot is an **unowned + singleton**: last writer wins, no owning package, no `SourceLocation`, + no removal lifecycle, and it does not appear in any inspection surface. + That is a real §13 gap and this framing does not dress it up — §20 + Priority 3 is deliberately deferred, and 1a is not the place to invent + ownership machinery for one slot. **Named migration:** when Priority 3 + lands registration ownership and `pmacs.hook.remove`, the slot becomes + an ordinary lowest-priority hook subscription carrying its owner, and + this primitive is deleted rather than extended. +- **Background-work attribution (§9).** No new `JobKind` variant, no new + `PendingJob` field; the listing uses `pmacs.fs.read_dir`, whose kind + #165 added. Neutral. +- **Frontend parity (§16).** Both frontends get the behavior from the + same primitive. One asymmetry ships knowingly: the GPU path displays + its pre-existing bootstrap buffer until the listing settles (§8 B2). +- **New substrate (rev 3, revised rev 4–5).** `commit_to` (§4.4) is a + general fix for a general problem — *every* post-await + `pmacs.window.*` call in the tree acts on the ambient frontend by + documented design (`dired.lua:68-73`). 1a introduces it for one caller + and does not migrate the others; that migration is named as deferred + rather than smuggled in. It adds a **scoped frontend override** + distinct from `InteractiveCommandOrigin` (§2.11), deliberately: a + background continuation gets destination scope **without** acquiring + interactive-command authority, which keeps the "programmatic vs + interactive" distinction the unfold guard, command boundaries, and the + terminal surface all depend on. + +--- + +## 1. What Stage 1a ships + +1. **`pmacs .` opens the directory**, on the local TUI path and the + daemon/GPU bootstrap path, routed into #165's dired buffer. +2. **One path-resolution primitive** — `EditorState::open` adopts + `EditorCore::resolve_target_buffer` wholesale. +3. **A scoped-destination commit primitive** (§4.4) so an async open + lands where it was requested, or nowhere. +4. **The first cross-subsystem journey acceptance suite** (§19). + +Not in 1a — Stage 1b: a compile keybinding and `cargo build`/`test` +defaults from the existing `ProjectKind::Cargo`, LSP spawn-failure +guidance (§1.2), a welcome buffer. + +--- + +## 2. Ground truth (scouted 2026-07-26, `main` @ `d400f30`; re-verified rev 4) + +### 2.1 `pmacs .` still exits 1, and why + +`load_file` (`src/file_io.rs:81`) does `File::open` — which succeeds on a +directory — then `read_to_end`, returning `EISDIR`. Not +`ErrorKind::NotFound`, so every `NotFound` arm is skipped and the error +propagates; `main` prints and exits (`src/main.rs:400-403`). + +### 2.2 There are two path-open implementations, not one + +`resolve_target_buffer` (`editor_core.rs:885`) documents itself as *"One +primitive, so two path-normalization, dedup, and hook transactions cannot +drift apart."* Callers: `display_file` (`window_panel.rs:402`) and the +daemon bootstrap (`daemon.rs:1641`). **Local startup is not one of them** +— `EditorState::open` (`editor.rs:757`) hand-writes the same shape. + +| | `EditorState::open` | `resolve_target_buffer` | +|---|---|---| +| Stored buffer path | **normalized** — `set_buffer_path` normalizes internally (`editor_core.rs:810-822`) | **normalized** — same setter | +| Displayed name | `path.display()` raw (`editor.rs:772`) | `path.display()` raw | +| `NotFound` arm | empty path-backed buffer, `[new file]` | identical | +| Dedup | none | `find_buffer_for_path` | +| Window install | `replace_active_buffer` — switches the ACTIVE window (`editor.rs:797`). **It does not drop the startup scratch** (rev 6): its body is one `switch_active_buffer` call, which reassigns `aw.buffer_id` and removes nothing. The doc comment claiming otherwise was wrong before this stage and is corrected in this PR | none; caller installs | +| Error type | `io::Error`, bare | `String`, prefixed `cannot open {path}: ` | + +**The two agree on every observable except the error prefix and the +window install.** Rev 1 claimed a raw-vs-normalized split and built a +decision, a bet, and an acceptance on it; all three were withdrawn in rev +2. Rev 3 draws the further consequence the review identified: because the +implementations already agree, **no equivalence assertion can prove the +unification happened** — such a test passes on the pre-image. §6.0 +restructures the acceptance list around that. + +The unification's value is therefore (a) the directory arm reaching +startup once rather than being written twice, and (b) closing drift the +primitive was created to prevent and did not. Not a behavior fix. + +### 2.3 dired creates its own buffer and refuses adoption + +`claim_handle` (`dired.lua:486`) creates the buffer, applies the +read-only intercept, `set_round_trip_input`, and the `dired` major mode. +Its comment is explicit that finding a buffer by name is **not** +adoption. Handles are pathless. No Lua `buffer.set_name` / +`set_file_path` exists; dired Stage 2 (PR #171 §5) is scoped to add one. + +### 2.4 The listing is async; the bootstrap reply is not + +`read_listing` (`dired.lua:462`) awaits `pmacs.fs.read_dir` and its +comment says *"Must run inside `pmacs.async`"*. The daemon bootstrap is +one synchronous block: `open_initial_target` (`daemon.rs:1624`) → +`initial_target_snapshot` (`:1823`) → `InitialTargetResult::Opened` +(`:1888`), with the GPU frontend blocking on the reply before creating +its window (`pmacs-gpu/src/attach.rs:551`). + +`tick_async` resuming a coroutine in the frame its result arrives does +**not** bound the listing to one frame — the worker must still finish. +`tests/dired_acceptance.rs:103`'s `pump` drives until parked-coroutine +*and* pending-job counts both reach zero: *"nothing dired does is +observable until this returns."* + +### 2.5 Post-await, dired acts on the ambient frontend — by design + +`dired.lua:68-73`: *"`pmacs.window.*` calls made after the await act for +the **ambient** active frontend, since interactive origin does not +survive the tick boundary; and `pmacs.editor.move_to_line` acts on the +ambient **buffer**, which is why every post-await re-seat is guarded."* + +Correct for an interactive `C-x d`. Wrong for a startup open that must +land in a specific frontend's specific window. + +**And the ambient reach is wider than `display`.** `open_directory` +(`dired.lua:607-655`) after the await, in order: + +1. `read_listing` — the await; +2. `handle_for_path(canonical)` / `claim_handle(canonical)` — **creates a + buffer**, applies intercept/mode, registers a handle; +3. assigns `entries`, `errors`, `sort_mode`; +4. `handle.prev = pmacs.window.buffer()` — **reads the ambient buffer**; +5. `paint(handle)` — mutates the buffer; +6. `display(handle, opts, departed)` — the first call that could refuse; +7. `seat_cursor` — `move_to_line` on the ambient buffer; +8. `kill_departed`. + +`lookup_window` refuses a foreign window id (`window_panel.rs:202-212`), +but only at step 6. **Rev 2's "fails closed, does nothing" was false**: +steps 2–5 have already run. A refusal leaves a hidden dired buffer and a +registered handle, and step 4 can capture an unrelated frontend's buffer +as `prev`. §4.4 fixes this by revalidating and scoping *before* step 2. + +### 2.6 Subscribers exist before the hook fires — but ordering is fixed + +`EditorState::new()` loads the builtin runtime (dired at `editor.rs:539`) +then user `init.lua` (`:609`, `cfg(not(test))`). `HookRegistry::add` +**appends** (`hook.rs:240`); no prepend, no priority, no removal +(`COHERENCE.md` §13 names `pmacs.hook.remove`'s absence as a Priority 3 +prerequisite). **A builtin subscriber always runs before any user +subscriber, forever.** + +### 2.7 Short-circuit cannot distinguish a claim from a crash + +`run_short_circuit` (`hook.rs:299-323`) returns `proceed: false` for a +literal `false` return **and** for a raising callback; only +`HookOutcome.errors` (non-empty in the second case) tells them apart. +A resolver chain that keys only on `proceed` treats a broken user +callback as a successful claim. Q#JR15 decides the policy. + +### 2.8 `open_initial_target` reasserts after hooks + +It re-checks the buffer exists (`daemon.rs:1665-1670`) then reinstalls it +into the origin document window, rehoming if a hook closed it +(`:1673-1690`). §4.5's design does not fight this. + +### 2.9 This deliberately supersedes part of the GPU initial-target framing + +`docs/gpu-initial-target-framing.md` Q#GT6 (`:278`) lists `IsADirectory` +among initial-target failures; its acceptance 10 (`:550`) requires *"a +directory/permission-denied target returns a specific failure before +ready/window creation"*. **1a supersedes the directory half only.** +Permission-denied, invalid path bytes, session teardown, and the +"existing daemon remains connectable" clause keep their contract. The +superseded assertions are amended in that framing in this PR, per §25. + +### 2.10 `display_file`'s directory failure is load-bearing today + +`builtin/commands/default.lua:724` wraps `display_file` in a `pcall` +whose comment says *"only a real failure (a directory, a permission +error) reaches here"*, pinned by +`find_file_accepting_a_directory_reports_instead_of_raising` +(`tests/find_file_acceptance.rs:235`). §4.6 answers to it. + +### 2.11 There is a scope mechanism, and it is the wrong one + +`acting_frontend` (`window_panel.rs:46-50`) reads +`InteractiveCommandOrigin` app data, falling back to +`core.active_frontend_key()`; `InteractiveCommandOrigin::enter(fid)` +(`editor.rs:63-69`) returns an RAII guard. Rev 3 proposed reusing it. +Two independent reasons it cannot be: + +**(a) It does not scope what rev 3 claimed.** Only the window-panel +bindings consult `acting_frontend`. Two of dired's post-await steps do +not go through it at all: + +- **no-arg `pmacs.window.buffer()`** — step 4's `prev` capture — reads + `core.active_buffer_id()` directly (`mod.rs:12547`). Its comment is + explicit that this is deliberate and infallible, and states the + assumption it rests on: *"dispatch sets `active_frontend` to the acting + frontend before running a command, so the two agree on every real + path."* +- **`pmacs.editor.move_to_line`** — step 7's cursor seating — is + `cc.borrow_mut().move_to_line(line)` on the core's ambient active + window (`mod.rs:12703`). + +So entering the interactive origin would scope `display` and leave `prev` +capture and seating ambient — precisely the two steps §2.5 identifies as +corrupting. + +**(b) It is authenticated user-command authority, and a startup +continuation must not impersonate one.** `InteractiveCommandOrigin` is +what distinguishes a user command's edit from a plugin's or the data +API's. Three consumers would be misled: + +- the **pre-edit unfold** guard (`mod.rs:1385-1400`), whose doc calls it + *"the scoped authority that distinguishes a user command's edit from a + plugin's or the data API's programmatic one"* — dired's `paint` would + satisfy it and unfold at the edit site; +- `invoke_interactive`'s command-boundary rotation (`:5400`), which + raises without it and would silently succeed with it; +- `terminal_command_frontend` / `active_terminal_view_key` (`:8515`, + `:8527`), which treat its presence as "an interactive frontend context". + +Q#JR14e therefore introduces a **separate** override. Note that the +`window.buffer()` comment above is not an obstacle but a specification: +swapping `core.active_frontend` for the scope's extent is exactly what +makes its stated assumption true for a continuation, restoring the +invariant rather than working around it. + +--- + +## 3. The unification (Q#JR1) + +`EditorState::open` becomes a thin caller of `resolve_target_buffer`, +keeping `replace_active_buffer` (which switches the **active** window, +Q#JR3 as corrected in rev 6 — it does not destroy the old scratch, and +never did) +and keeping its "fire the hook after the core borrow ends" structure +(`editor.rs:786-795`) — listeners re-enter `pmacs.editor.*` and re-borrow +the core (Q#JR1a). + +**Q#JR4** — startup errors gain the `cannot open {path}: ` prefix. +`pmacs /root/secret` names the file, which today's bare message does not. +This is the *only* user-visible change from the unification (§2.2). + +**Q#JR12 (downgraded to an observation, rev 6)** — a directory argument +suppresses desktop restore, on Q#DS7's reasoning that a positional +argument means "open this" rather than "restore my session". This needs +no work and cannot be pinned: `run` computes `had_file = file.is_some()` +(`editor.rs:3152`), and a directory path is `Some` like any other, so +there is no directory-specific branch that could get it wrong. Rev 5 +carried an acceptance for it; that test never armed restore and +hard-coded `had_file`, asserting nothing, and is removed rather than +repaired. + +--- + +## 4. The directory arm, the resolver, and the destination + +### 4.1 Q#JR5 — a typed result + +```rust +pub enum ResolvedTarget { + Buffer { id: BufferId, fire: HookKind }, + Directory { path: PathBuf }, // normalized: absolute, ~-expanded, lexically clean +} +``` + +Rev 1's `(Option, HookKind)` admitted states that cannot occur. +`resolve_target_buffer` checks `path.is_dir()` ahead of the load. + +**Q#JR8** — the `Directory` variant carries an explicitly normalized +path. It is *not* free: normalization lives inside `set_buffer_path`, and +this arm creates no buffer, so nothing would normalize anything and the +local caller would still hold `"."`. Same lesson as the Lean 4 arc's URI +affinity — a handler keying state by path must never receive `"."`. + +**Q#JR5b** — `editor_core::HookKind` and `hook::HookKind` are unrelated +types sharing a name; both are written path-qualified in every file this +PR touches, and `window_panel.rs:37`'s bare import is changed to match. + +**Q#JR6** — Rust creates no buffer for a directory. A placeholder needs +reaping, is reinstalled by §2.8's reassert, and — if dired adopted it — +would drag in dired Stage 2's rename prerequisite (§2.3). + +### 4.2 Where the directory arm is consumed + +`EditorState::open` and `open_initial_target` dispatch the resolver +chain. `display_file` does not (§4.6). + +### 4.3 Q#JR7 — a user-only hook, then a replaceable fallback + +Given §2.6, "a package subscribes ahead of dired" is unreachable. So the +two roles are split: + +**The chain.** `path.open-directory`, `kind = "short-circuit"`, fired +first. Returning `false` claims the directory and stops the fan-out. **No +builtin subscribes** — the rule that makes "user code runs first" true +under append-only registration, stated in the hook's own description. + +**The fallback.** If unclaimed, the arm calls the directory handler — a +function slot defaulted by `dired.lua`: + +```lua +pmacs.path.set_directory_handler(function(path, dest) + open_async(path, { dest = dest }, nil, "dired") +end) +``` + +Users replace it, chain it (capture the previous value first), or +**disable** it (`set_directory_handler(nil)`), which is what makes +acceptance 10's unclaimed path reachable. It is an unowned singleton +slot, with the honest accounting and named migration in §0.5. + +**Q#JR15 (new) — a raising callback stops the chain *and* suppresses the +fallback.** §2.7 shows `proceed` alone cannot distinguish a raise from a +claim. Policy: inspect `HookOutcome.errors`; when non-empty, report +through `*errors*` **and** `pmacs.editor.set_status`, and do **not** run +the fallback. Rationale: this preserves the existing short-circuit +contract (a raising `buffer.before-save` callback already vetoes the +save), and running the fallback after a user's resolver crashed would +open dired on a directory the user's code may have been mid-way through +handling. The cost — a broken user callback disables directory opening +until fixed — is visible, reported through two surfaces, and preferable +to silently ignoring the user's resolver. + +*Deferred, named:* hook priority/prepend is the general fix for §2.6 and +belongs with `pmacs.hook.remove` in Priority 3. When it lands, the +fallback becomes an ordinary lowest-priority subscription. + +### 4.4 Q#JR14 (rev 5) — the scoped-destination commit + +**The blocker rev 2 missed:** §2.5 shows dired mutates handle state at +steps 2–5 and only reaches a refusable call at step 6. "Fails closed, +does nothing" was false — a refusal left a hidden buffer, a registered +handle, and a `prev` captured from whichever frontend happened to be +ambient. Per the review's decision, **1a carries the substrate fix.** + +**Q#JR14d — the destination is an opaque capability, not a table.** +`dest` is **nonconstructible userdata**, created only by Rust, holding +three private ids: + +| field (private) | source | purpose | +|---|---|---| +| frontend | local: `FrontendId::LOCAL`; bootstrap: the attaching `frontend_id` | the scope to commit in | +| window | local: the active window; bootstrap: `origin_window` (`daemon.rs:1637`) | where the listing goes | +| buffer | the buffer that window holds at capture time | **stale-intent detection** | + +A table would be wrong in two ways, both reachable: the *same* `dest` is +passed to every hook listener in turn, so an earlier listener could +mutate it and then decline — redirecting later listeners or the fallback +— and any Lua could fabricate a plausible triple and call `commit_to` +directly. Userdata makes both unrepresentable rather than merely +discouraged. + +The only accessor is read-only `dest:window()`, which dired needs for its +exact `display{window = …}` target. `commit_to` accepts **only** this +userdata and revalidates its private contents itself; it never trusts a +caller-supplied id. + +**Q#JR14e — a separate scoped frontend override, not the interactive +origin.** §2.11 gives both reasons. Rev 4 adds a distinct app-data +override with resolution order: + +``` +acting_frontend = scoped override → interactive origin → ambient +``` + +Its RAII guard **also** swaps `core.active_frontend` and restores it on +drop, which is what covers the core-ambient APIs `acting_frontend` never +sees (`window.buffer()` no-arg, `move_to_line`). `commit_to` does **not** +enter `InteractiveCommandOrigin`, so a startup continuation never +acquires interactive-command authority. + +**The primitive.** `pmacs.window.commit_to(dest, fn)`: + +1. **Preflight, before running anything** — the destination's frontend + has a registered view; its window is live in that view's layout; the + window still holds the captured buffer (Q#JR14c); and the window is + **replaceable** (Q#JR14f). +2. On any failure, returns `false, reason` **without calling `fn`** — so + nothing is claimed, painted, or captured. +3. On success, enters the scoped override for the dynamic extent of `fn` + and calls it. Inside, `display{window = …}`, no-arg + `window.buffer()`, `move_to_line`, and every other ambient primitive + resolve against the captured destination — which is why a `frontend` + option on `display` alone would have been insufficient. + +**Q#JR14f — preflight must establish replaceability, through the same +predicate every exact-target probe and display uses.** Exact display +refuses a window that is `dedicated` unless it already shows the +*incoming* buffer (`editor_core.rs:3566`). The distinction is +load-bearing here: `dest.buffer` is the captured bootstrap buffer, not +dired's future buffer. Passing it as the incoming buffer would approve a +window dedicated to that bootstrap buffer; dired would then claim and +paint its different buffer, and exact display would refuse afterward — +rev 2's hidden-buffer failure through another door. + +The eligibility test is therefore extracted once, with the semantic +input `incoming: Option`: + +| caller | input | dedicated-window result | +|---|---|---| +| `display_buffer` exact-target arm | `Some(request.buffer_id)` | eligible only when already showing that buffer | +| `probe_display_target` | its existing `Option` | preserves today's load-before-placement probe contract | +| `commit_to` preflight | `None` | always ineligible — the replacement does not exist yet | + +`probe_display_target` already carries the correct `Option` +shape (`editor_core.rs:3470-3483`), so leaving it on a private copy while +sharing only the other two would preserve the same drift this extraction +exists to remove. Core unit coverage pins the three decisive rows: +dedicated + `Some(current)` is eligible; dedicated + `Some(other)` is +refused; dedicated + `None` is refused. + +**Q#JR14b — `fn` must not await.** The scope is an RAII guard on the +Rust stack; a yield inside it would let the guard's extent and the +coroutine's suspension diverge, restoring the override while the +continuation is still parked. `commit_to` sets a flag that `Handle:await` +checks and raises on, naming the rule. Enforced, not documented — pinned +by N6. + +**Atomicity, stated precisely.** `commit_to` is atomic **against +destination-precondition failure**: if any preflight check fails, no +callback runs and nothing is mutated. It is **not** a transaction over +the callback — if `fn` raises halfway through, `commit_to` restores the +scope and propagates, but whatever `fn` already mutated stays mutated. +Rolling that back would require dired to make its claim/paint sequence +undoable, which is a dired change well beyond 1a. What 1a guarantees is +that the *destination* checks happen before the first mutation, which is +the failure the review identified. + +**dired's change.** `open_directory` keeps `read_listing` (the await) +outside, then performs steps 2–8 inside a single `commit_to` callback, +displaying with `{ window = dest:window() }` rather than the ambient +`switch_buffer`. On a `false` return it reports through +`pmacs.editor.set_status` and returns, having mutated nothing. + +**Q#JR14c — stale intent loses to the user.** If the destination window +now holds a different buffer than at capture, the request is stale and +**fails closed**. Rev 2's window-only pin said launch intent overwrites +whatever the user did meanwhile; that was wrong, and it rested on B2's +"before the user can act", which §2.4 disproves — a large directory takes +many frames and the user can act in every one of them. The user's action +is newer information than the launch argument. + +**What this buys, stated as the review framed it:** competing frontend +activity no longer turns a valid startup request into a nondeterministic +no-op. A live, unchanged destination receives its listing regardless of +what other frontends did meanwhile. Fail-closed is reserved for a +destination that is genuinely dead or stale. + +*Deferred, named:* migrating dired's other post-await paths (`C-x d`, +tree descent/ascent, refresh) and every other ambient post-await +`pmacs.window.*` call in the tree onto `commit_to`. 1a introduces the +primitive for the startup path and does not sweep; the sweep is its own +PR with its own acceptance, and this framing does not pretend the general +problem is solved. + +### 4.5 Q#JR9 — what the bootstrap reply names, and what it shows + +`open_initial_target` on a `Directory` installs nothing: it dispatches the +resolver, then replies `Opened { buffer_id }` naming **whatever the +destination window holds once that dispatch returns** — re-read, not the +id captured beforehand (Q#JR9b, rev 6). + +The distinction is not academic. The chain runs **synchronously**. +dired's handler defers, because its listing must await; a user's resolver +is under no such obligation, and one that opens something synchronously +through `commit_to` — the supported way to do it — has already replaced +the window's buffer by the time the reply is built. Reporting the +captured id would pair one buffer's snapshot with another's identity, and +the frontend would render a document nobody asked for. + +Re-reading also subsumes the case where a hook closed the window, so this +arm rehomes through `non_side_target` exactly as the file arm's reassert +does, rather than returning early and skipping that check — which rev 5's +implementation did while this section claimed the revalidation stayed +active. + +Absent a synchronous claimant the re-read yields the buffer the window +already held, which is the ordinary case. + +**That buffer is not necessarily `*scratch*`.** `build_fresh_frontend_view` +clones **LOCAL's primary document buffer** (`daemon.rs:2997`) — M10.9 made +attaching frontends share LOCAL's buffer so overlays fire; the +bottom-panel arc narrowed it to the *primary document* buffer so a TUI +panel could not become a new frontend's document. If LOCAL holds a real +document, `pmacs --gpu .` briefly displays and snapshots that unrelated +document. + +**Decision: accept and document.** A bootstrap placeholder re-creates +everything Q#JR6 rejected to fix a transient, and the session genuinely +*is* showing LOCAL's document — the same thing a no-argument `--gpu` +attach shows. Acceptance N5 pins it with a deliberately non-scratch LOCAL +primary so it is observed rather than assumed. + +### 4.6 Q#JR13 — `display_file` keeps its directory error + +`display_file` does **not** dispatch the resolver. On +`ResolvedTarget::Directory` it raises: + +- the message names the path and the directory reason (an improvement on + the raw `EISDIR` text, and the only user-visible change here); +- the active buffer, window layout, and selected window are unchanged — + nothing created, nothing switched; +- `find_file_accepting_a_directory_reports_instead_of_raising` passes + **unmodified**. + +`display_file` is "put this file in a window", not a CLI router. Routing +it into dired would silently change `C-x C-f` on a directory, in a PR +about the CLI, through a `pcall` arm whose comment guarantees the +opposite. + +*Deferred, named:* Emacs's `find-file` does open dired on a directory, +and that is reasonable eventual behavior. It is a find-file UX decision +with its own acceptance, belonging to the dired arc or 1b. When taken it +is a small change at `default.lua:724`, and the pinned test above is what +gets deliberately rewritten. + +--- + +## 5. The journey acceptance suite (§19) + +New: `tests/journey_acceptance.rs`, seeded with steps 2 (launch +unconfigured), 3 (open a real project), and 5 (edit immediately), +driving the **real startup entry point** — a directory arm with no +production caller passes every direct-call test. Steps 6–12 enter as +later stages make them real; the file is a ratchet. + +Every dired-dependent assertion pumps to quiescence using +`dired_acceptance.rs:103`'s idiom (parked coroutines *and* pending jobs +at zero), never a fixed frame count (§2.4). + +--- + +## 6. Acceptance + +### 6.0 Two kinds of pin, and why the distinction matters + +Rev 2 asserted that every acceptance "fails with the change reverted". +The review is right that this cannot hold for preservation guards — and +rev 2's acceptance 6 was the proof: because both implementations already +agree on every observable (§2.2), an equivalence assertion passes on the +pre-image. **Behavioral equivalence cannot demonstrate structural reuse.** +The list is therefore split, and each preservation pin names the +*targeted mutation* it is bite-tested against: + +- **(N) New-behavior acceptances** — must fail on full revert. +- **(P) Preservation pins** — legitimately green on the pre-image; + falsified by a named targeted mutation, not by revert. + +That local startup reaches the new directory behavior is proven by N1, +not by any equivalence assertion — which is also why rev 2's acceptance 6 +is **removed rather than recast**: it proved nothing N1 does not. + +### 6.1 New-behavior acceptances (N) + +- **N1** `pmacs .` in a project directory exits 0 and, after pumping to + quiescence, the active buffer is dired's, listing that directory. + Today: exit 1. +- **N2** Daemon/GPU bootstrap with a directory initial target receives + `InitialTargetResult::Opened`, not `Failed`, and after quiescence the + document window shows the dired buffer. Supersedes the GPU framing's + acceptance 10 for directories (§2.9). +- **N3** `pmacs .` on an unreadable directory reports through dired's + status path and leaves the session running — no exit 1, no half-built + buffer. +- **N4 — delivery despite competing frontends (the blocker's positive + half).** Two registered frontends; a directory bootstrap for frontend + A; frontend B dispatches unrelated activity (buffer switch, window + focus) while the listing is in flight. After quiescence the listing is + in **A's** captured window, and B's active buffer and window are + unchanged. Falsified by reverting `commit_to` to the ambient + `switch_buffer`. +- **N4b — the scope outranks an *interactive origin*, added rev 7.** N4 + alone does not pin `acting_frontend`'s ordering claim: with the + `ScopedFrontend` arm deleted, N4 still passes, because `enter` also + swaps `core.active_frontend`. The arm matters only when an interactive + origin is set, which sits between the override and the ambient value. + A command dispatched by frontend B calls `commit_to` with A's + destination; the commit must still land in A's window. Falsified by + deleting the arm, or by ordering it after the interactive origin. +- **N5** Bootstrap with a deliberately **non-scratch** LOCAL primary + document buffer: the reply's `buffer_id` is that buffer, and after + quiescence the window shows dired (Q#JR9, §4.5). +- **N4c — the captured *window*, not the captured frontend's selected + one (added rev 8).** One frontend, two windows: capture a destination, + then split and move focus to the other window and give it a buffer of + its own, then run dired's handler path with the captured destination. + The listing lands in the captured window, the focused window is + untouched, and `q` returns to the buffer the *captured* window showed. + Falsified independently by restoring `switch_buffer` in dired's + `display` and by reading `prev` from the ambient window — both were + verified to fail only this pin. +- **N6 — `commit_to` scopes and restores, on every exit path.** Three + cases, each asserting that **both** the scoped override and + `core.active_frontend` return to their prior values: (a) `fn` returns + normally; (b) `fn` raises; (c) `fn` awaits and is refused (Q#JR14b). + Case (c) additionally asserts the raise names the rule. Rev 3 checked + only the interactive origin's restoration on the success path, which + §2.11 shows is neither the right value nor enough paths. Falsified by + dropping the flag, or by restoring on success only. +- **N6b — `commit_to` refuses a forged destination.** A Lua-constructed + table with plausible `frontend`/`window`/`buffer` fields is rejected as + a type error, and userdata cannot be constructed from Lua (Q#JR14d). + Falsified by accepting a table. *Rev 7:* the parameter is typed + `mlua::Value` and `commit_to` performs the check itself, so the refusal + names the rule — typed as `AnyUserData`, mlua rejected the table during + argument conversion with a message naming neither the rule nor the + remedy, leaving the pointed one unreachable. +- **N6c — a declining listener cannot redirect the destination.** Two + listeners: the first receives `dest`, attempts mutation inside `pcall`, + observes the read-only rejection, and declines; the second verifies + `dest:window()` still names the original window and also declines; then + the fallback commits there (Q#JR14d). Falsified by passing a shared, + mutable table. +- **N7 — the resolver chain.** `path.open-directory` is short-circuit and + first-claimant-wins, exercised through an **ordinary user-registered + listener** (no builtin subscribes, §4.3): two listeners, the first + returns `false`, the second must not run, and the fallback must not + run. Falsified by `all-must-succeed` or `accumulate`. +- **N8 — a raising callback suppresses the fallback *and* is reported + (Q#JR15).** A listener that raises: the fallback does not run, the + directory does not open, and the failure reaches both `*errors*` and + the status line. + *Falsifier, corrected in rev 4:* keying the fallback on `proceed` alone + is **already correct** for suppression — §2.7 shows a raise gives + `proceed == false` just as a claim does. `errors` decides the *report*, + not the fallback. So N8 is falsified by either (a) running the fallback + when `errors` is non-empty — i.e. treating a raise as a decline — or + (b) mutating the short-circuit outcome so a raise yields + `proceed = true`. Rev 3 named the inverse mutation, which does not + falsify anything. +- **N9** The hook and the handler receive a **canonical absolute path** — + firing on `.` from a known cwd delivers that cwd, not `"."` (Q#JR8). +- **N10** With the handler slot cleared and no listener claiming, + `pmacs .` exits **0**, leaves the bootstrap buffer in place, and sets a + status naming the path (Q#JR10). +- **N11** `pmacs .` → dired lists → `RET` on a listed file visits it → a + self-insert lands in **that file's** buffer. (Rev 1 self-inserted into + the dired buffer, whose intercept rejects every edit, `dired.lua:506`.) + +### 6.2 Preservation pins (P), each with its falsifying mutation + +*Rev 7 correction:* **P1 and P2 also fail on full revert** — `commit_to` +does not exist on the pre-image, so §6.0's "legitimately green on the +pre-image" does not describe them. They stay here because their +*discriminating* falsifier is the named mutation: a revert-only check +cannot distinguish "validates" from "validates in time", which is their +entire claim. P3–P8 are preservation pins in the strict sense. + +- **P1 — precondition failure is atomic (the blocker's negative half).** + **Three** destination failures, each asserted the same way — after + quiescence the buffer count is unchanged, **no dired buffer or handle + exists for that path**, no window's buffer changed, and a status names + the failure: + 1. **dead** — the destination window was closed; + 2. **stale** — its buffer was replaced (Q#JR14c); + 3. **ineligible** — it is `dedicated` to its still-current captured + buffer, but dired's incoming replacement does not exist yet + (Q#JR14f, completed rev 5). This is the case a preflight that + mistakenly passes `dest.buffer` as the incoming buffer approves and + `display` then refuses *after* dired has claimed and painted. + *Mutation:* move the preflight from before `claim_handle` to after + `paint` — rev 2's design. P1 fails on all three; rev 2's acceptance 3b + passes. *Second mutation, for case 3 specifically:* pass + `Some(dest.buffer)` instead of `None` to the shared eligibility + predicate while keeping liveness and stale-buffer validation. Only case + 3 fails — which is the point of separating it. +- **P2 — stale intent loses (Q#JR14c).** The user replaces the + destination window's buffer while the listing is in flight; their + buffer survives and dired does not overwrite it. + *Mutation:* drop `dest.buffer` from revalidation (rev 2's window-only + pin). P2 fails. +- **P3 — dired's existing handles are not corrupted.** With a dired + buffer already open in another frontend, a failed startup open leaves + that handle's `prev`, entries, and cursor untouched. + *Mutation:* restore the ambient `handle.prev = pmacs.window.buffer()` + outside the scope (§2.5 step 4). +- **P4 — startup shows the file in the *active window* (Q#JR3, corrected + rev 6, restated rev 8).** `EditorState::open` displays the loaded + buffer in the active window and no window is left showing the startup + scratch. It does **not** assert a buffer count: `replace_active_buffer` + does not drop the scratch buffer, and rev 5's "leaves exactly one + buffer" wording — which survived rev 6's correction here by oversight, + caught in review of PR #182 — asserted a guarantee the editor does not + make. + *Mutation:* replace `replace_active_buffer` with a bare + `install_buffer_in_window` into some other window. +- **P5 — the `NotFound` arm survives the refactor.** A nonexistent path + yields an empty path-backed buffer with `[new file]` and fires no hook. + *Mutation:* delete the `NotFound` arm from `resolve_target_buffer`. +- **P6 — `display_file` keeps its contract (Q#JR13).** It raises on a + directory naming path and reason; active buffer, layout, and selected + window unchanged; `find_file_accepting_a_directory_reports_instead_of_raising` + passes unmodified. + *Mutation:* route `display_file` into the resolver chain. +- **P7 — REMOVED in rev 6.** Q#JR12 is structural: `run` computes + `had_file = file.is_some()` and a directory path is `Some` like any + other, so there is no directory-specific branch to break and the named + mutation would have to invent one first. Rev 5's test never armed + restore and hard-coded `had_file`, so it could not fail against any + implementation. Removed rather than repaired — a green test that cannot + fail reads as coverage. +- **P8 — startup errors name the file (Q#JR4).** A non-`NotFound`, + non-directory failure produces a message containing `cannot open` and + the path. *(Legitimately N-shaped for the prefix, P-shaped for the + failure itself; listed here because the failure behavior is preserved + and only the message changes.)* + +`scripts/bite` runs over the new suite. A VACUOUS report on any N is a +blocker; each P's named mutation is run as its bite check, since revert +cannot falsify it. + +--- + +## 7. Deferred (named) + +- **Migrating the rest of the tree onto `commit_to`** (§4.4) — dired's + other post-await paths and every other ambient post-await + `pmacs.window.*` call. Its own PR, its own acceptance. +- **Hook priority / prepend**, with `pmacs.hook.remove`, in §20 Priority + 3 — at which point the fallback slot becomes an ordinary lowest-priority + subscription and §0.5's unowned-singleton gap closes. +- **True adoption (option B).** Rust creates the buffer, dired adopts — + one buffer, no transient — but it needs dired Stage 2's rename / + clear-path capability (§2.3). Dired Stage 3; Q#JR6 does not block it. +- **The bootstrap transient** (§4.5, §8 B2). +- **`C-x C-f` on a directory opening dired** (§4.6). +- **Multiple path arguments** (`main.rs:227`, `:232`, `:240`). +- **`pmacs .` opening a panel** rather than the document window. +- Stage 1b and the rest of §20 Priority 1. + +--- + +## 8. Bets + +- **B1 — "one thing opens a directory" holds.** If a picker and dired + should both run, short-circuit is wrong and the hook must become a + resolver returning a target. +- **B2 (corrected twice) — the bootstrap transient is acceptable.** The + window shows its pre-existing buffer **until the listing settles** — + not "one frame" (rev 1), and **not** "before the user can act" (rev 2): + §2.4 disproves the bound and Q#JR14c is the consequence — the user + *can* act, so stale intent must lose. The bet is only that the + transient is visually acceptable at process start. +- **B3 — withdrawn** (rev 2). There was no path-normalization change. +- **B4 — failing closed on a genuinely dead or stale destination is + better than guessing.** Narrowed in rev 3: it applies only after + revalidation says the destination is gone, not to any competing + activity (N4). +- **B5 — `commit_to`'s no-await rule is livable.** Every commit step + dired performs after the listing is synchronous today, so the rule + costs nothing here. If a future handler genuinely needs to await + mid-commit, the primitive needs a re-entrant design and this bet is + what will have failed. +- **B6 (rev 5) — extracting the eligibility predicate is + behavior-preserving.** Q#JR14f shares one predicate between + `commit_to`'s preflight, `probe_display_target`, and `display_buffer`'s + exact-target arm rather than writing a third copy. The bet is that the + two existing callers' behavior survives the extraction unchanged — + core unit tests pin the `Option` matrix, and + `bottom_panel_stage1_acceptance` catches placement-level drift, which + is why it is in the gate list. The alternative has no extraction risk + and a certain cost: a future eligibility rule added to one copy reopens + Q#JR14f's exact hole. Taking the risk tests can catch over drift they + cannot. + +--- + +## 9. Gates + +``` +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings # own step +cargo test --lib +cargo test --lib --features crdt +cargo test --test journey_acceptance +cargo test --test dired_acceptance +cargo test --test find_file_acceptance # P6, unmodified +cargo test --test gpu_initial_target_acceptance # §2.9 supersession +cargo test --test theme_faces_acceptance # EditorState::open caller +cargo test --test m4_acceptance -- --skip basedpyright # 4 open() callers +cargo test --test bottom_panel_stage1_acceptance # commit_to touches display +PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu +cargo test --workspace -- --skip basedpyright +git diff --check +``` + +`m4_acceptance` and `theme_faces_acceptance` call `EditorState::open` +directly (§2.2) — the unification's blast radius. `find_file_acceptance` +and `gpu_initial_target_acceptance` encode contracts this PR preserves +(§4.6) and supersedes (§2.9). `bottom_panel_stage1_acceptance` is +included because `commit_to` scopes the frontend that `display`'s +placement policy resolves against and Q#JR14f extracts the exact-target +eligibility rule that suite already pins. + +--- + +## 10. Sequencing + +**1a implements after PR #177 merges.** #177 touches `src/daemon.rs` and +`src/editor.rs`; §2.8's reassert logic sits next to its census work. +#179 also touches `src/editor.rs`. + +No dired code is in flight — #169 and #171 are docs-only and no open PR +touches `builtin/runtime/dired.lua` (verified 2026-07-26). 1a's dired +change (a handler registration, plus wrapping `open_directory`'s +post-await commit in `commit_to`) does not collide with dired Stage 2, +which is unapproved for implementation. The `commit_to` wrap is a larger +dired change than rev 2's, touching the body Stage 2's rename work also +touches. + +**Rev 5 — decided: 1a stays ahead of dired Stage 2.** Stage 2 is not in +implementation, and the scoped commit boundary gives it a better shape to +build on than it would have had — a rename transaction across five path +owners is exactly the kind of multi-step commit that wants a validated, +scoped destination rather than ambient state. **Obligation this creates:** +when 1a lands, dired Stage 2 re-scouts and revises its framing around +`commit_to` before implementation; that revision is a prerequisite of +Stage 2's branch, recorded here and in `docs/active-work.md` so it is not +discovered late. + +--- + +## 11. Numbered decisions + +- **Q#JR1** `EditorState::open` adopts `resolve_target_buffer` wholesale. +- **Q#JR1a** The hook fires outside the core borrow. +- **Q#JR2** *Withdrawn (rev 2)* — its premise was false. +- **Q#JR3 (corrected rev 6)** Startup keeps using + `replace_active_buffer`, which switches the **active** window — not + because it drops the old scratch (it does not, and never did) but + because an `install_buffer_in_window` elsewhere would load the file + while leaving the user looking at scratch. Removing the stale scratch + buffer is separate work. +- **Q#JR4** Startup errors gain the `cannot open {path}: ` prefix. +- **Q#JR5** `resolve_target_buffer` returns a typed `ResolvedTarget`. +- **Q#JR5b** Both `HookKind` types are written path-qualified. +- **Q#JR6** Rust creates no buffer for a directory. +- **Q#JR7** `path.open-directory` is a short-circuit **user-only** chain; + builtins do not subscribe; dired is a replaceable fallback slot. +- **Q#JR8** `ResolvedTarget::Directory` carries an explicitly normalized + path. +- **Q#JR9** The bootstrap reply names the destination window's buffer — + absent a synchronous claimant, LOCAL's primary document buffer, not + necessarily scratch. Accepted and documented. +- **Q#JR9b (rev 6)** That id is **re-read after the dispatch**, and the + arm rehomes through `non_side_target` rather than returning early: a + synchronous resolver may already have replaced the buffer. +- **Q#JR10** An unclaimed directory with the handler cleared exits 0 with + a status message. +- **Q#JR12 (observation, rev 6)** A directory argument suppresses + desktop restore structurally, via `had_file = file.is_some()`. No work, + no pin. +- **Q#JR13** `display_file` keeps its directory-is-an-error contract. +- **Q#JR14** The destination `{frontend, window, buffer}` is captured at + resolve time; `commit_to` preflights and scopes the **entire** + post-await commit. +- **Q#JR14b** A `commit_to` callback must not await; enforced, not + documented. +- **Q#JR14c** Stale intent loses to the user: a replaced destination + buffer fails closed. +- **Q#JR14d** `dest` is nonconstructible userdata with a read-only + `window()` accessor — not a table a listener can mutate or Lua can + forge. +- **Q#JR14e** A **separate** scoped frontend override, resolved ahead of + the interactive origin and also swapping `core.active_frontend`. + `commit_to` never enters `InteractiveCommandOrigin`. +- **Q#JR14f** Preflight establishes **replaceability** via the same + `Option` eligibility predicate used by + `probe_display_target` and `display_buffer`; `commit_to` passes `None` + because its replacement does not exist yet. +- **Q#JR15** A raising resolver callback stops the chain **and** + suppresses the fallback, reported through `*errors*` and the status + line. + +--- + +## 12. Branch and PR plan + +One feature, one branch, one PR: `journey-stage1a-directory-open`. + +1. Commit this framing. +2. Unification (§3) + P4, P5, P7, P8. +3. `ResolvedTarget` + the directory arm + the resolver chain, fallback + slot, and error policy (§4.1–4.3) + N7, N8, N9, N10; `display_file`'s + preserved contract (§4.6) + P6. +4. The scoped frontend override + the shared eligibility predicate + (Q#JR14e, Q#JR14f), then `commit_to` and the opaque destination + (§4.4) + N4, N6, N6b, N6c, P1, P2, P3. The override and the predicate + extraction land first as separable core changes: both are testable + without dired, and the predicate's `Some(current)` / `Some(other)` / + `None` unit matrix plus `bottom_panel_stage1_acceptance` must prove the + extraction behavior-preserving before anything depends on it. +5. `tests/journey_acceptance.rs` (§5) + N1, N2, N3, N5, N11. +6. `COHERENCE.md` §2 verdict table and §20 Priority 1 rewritten per §25; + `docs/gpu-initial-target-framing.md` Q#GT6 + acceptance 10 amended for + the superseded directory case (§2.9); `docs/agent-handoff.md` §1 and + `docs/active-work.md` updated. diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index e1fe060..20c66b4 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: **12**. ### 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,445 @@ 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. + +### Round 9 (rev 8 → rev 9) + +Found during Stage 4b implementation, by simulating Q#LN22's state +machine over all 1,855 vendored entries and re-reading upstream's +`TrackedAbbreviation.ts` and `AbbreviationProvider.ts` at `17d1d08`. +**Three acceptance criteria named examples that the real table +contradicts** — every one of them written from what the abbreviation +*looks* like rather than from whether the table makes it eager. + +1. **Acceptance 41 was false.** `\to` does not expand eagerly: `to` is a + proper prefix of `top`, `to0`, `toa` and others, so upstream's + `isAbbreviationUniqueAndComplete` is false and `to` is not among the + 1,550 eager keys. The criterion now uses `\alpha`, which has no + extension, and additionally pins that `\to` alone does **not** + expand — the false half is worth an assertion because it reads as + correct until the table is consulted. +2. **Acceptance 42 was false.** `\zzzz` + space yields `ζzzz `, not + literal text: `z` opens a pending abbreviation (`ze`, `zeta`, + `zsqrtd`) and the second `z` finishes it. Exactly six printable + characters open no key — `$ % , ; @ W` — and the criterion now uses + `\WWWW`. +3. **Acceptance 38's undo claim was false for its own example.** + `alpha` is eager, so `\alpha` expands before the space is typed and + the space is a separate edit; one undo removes the space rather than + restoring `\alpha `. The criterion now states the finish path and the + eager path separately, since "one expansion is one undo step" is true + of both while the text an undo restores is not. + +The mechanism (Q#LN11, Q#LN21, Q#LN22) needed no change — these were +errors in the examples chosen to pin it, which is why a simulation over +the real data found them and four review rounds over the prose did not. + +### Round 10 (rev 9 → rev 10) + +Review of the Stage 4b implementation. Three defects in the expander, +all of them about what happens AROUND the expansion rather than about +resolving an abbreviation, plus one stale count. + +1. **A pair character that terminates an abbreviation never reached + auto-pairing.** Q#LN22 already said the terminator is not claimed; + the implementation claimed it whenever an expansion succeeded, so + `\alp(` gave `α(`. Not claiming is necessary and not sufficient — + the chain hands each consumer a copy of the record made before any + consumer ran, so expanding inside the chain invalidates the copy + pairing is holding and the closer is lost anyway. Q#LN22 now + specifies the deferred subscriber and the span that stops before the + terminator; acceptance 45j pins all three failure modes. +2. **Post-insert point motion was mistaken for a valid pending span.** + The relevance check compared buffer and window but not + `ed.cursor() == rec.post_cursor`, so a redefined self-insert that + inserts and then moves the point still expanded — and teleported the + point back. Pairing has made this three-part check since #110. + Acceptance 45k. +3. **Cursor placement could move the wrong buffer.** A buffer intercept + may switch buffers during `buf:replace`; the unguarded `goto_byte` + afterwards moved the switched-to buffer's point. `repair_cursor` is + the precedent. Acceptance 45l. +4. **The coherence census contradicted itself** — nine settings in one + paragraph, eight three paragraphs below. + +Acceptance 45m was added with them: the expansion now runs on its own +`buffer.after-edit` subscriber, which is a new instance of Q#AP7 and +was unpinned. + +### Round 11 (rev 10 → rev 11) + +One P1 in the round-10 fix, and one stale comment. + +1. **The deferred expansion was not tied to the fan-out that queued + it.** `buffer.after-edit` fan-outs nest — the typed-edit contract + supports a consumer calling `pmacs.hook.run` — and a nested run + re-enters the expander's subscriber while the OUTER chain is still + mid-list. A consumer at priority 75 running one nested fan-out made + `\alp(` yield `α(` again: the nested pass expanded, and outer + pairing then resumed with a record the replace had invalidated. + Round 10's own failure mode, reached through re-entrancy instead of + claiming. Q#LN22 now specifies matching chain invocations against + expander invocations so only the outermost pass expands; acceptance + 45n pins it. +2. **A test comment still described the discarded span design** — it + said the expansion replaces the span "INCLUDING the terminator", + which round 10 deliberately stopped doing. The behaviour it asserts + was correct; only the explanation was stale. + +### Round 12 (rev 11 → rev 12) + +One P1: round 11's counter was in the wrong place. + +1. **The nesting count lived in the expander, which is optional.** A + consumer at a lower priority can claim and stop the chain before the + expander runs, while that fan-out's deferred-expansion subscriber + still runs — so the nested pass went uncounted, looked like the + outermost one, expanded early, and outer pairing resumed with an + invalidated record. `\alp(` gave `α(` again. The count now comes + from a no-op consumer at the minimum priority, which runs first in + every chain invocation that reaches any consumer; acceptance 45o + pins the short-circuit path that 45n does not reach. + +The pattern across rounds 10–12 is worth naming: each fix was correct +about the failure it was shown and wrong about the boundary of the +mechanism it relied on — the chain's copy semantics, then its +re-entrancy, then its short-circuit. **A queue that outlives the thing +that filled it needs to name that thing, not approximate it.** ## 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 +624,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 +671,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 +739,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 +820,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 +842,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 +870,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 +887,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 +904,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 +931,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 +1003,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 +1026,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 +1304,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 +1351,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 +1465,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 +1496,434 @@ 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`, `c` TERMINATES the abbreviation: the + chain does *not* claim it, and the expansion of `text` is + **deferred** until after the chain has run (round 10; see below). +- **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. + +**The expansion is deferred past the chain, and its span stops before +the terminator** (round 10). "Not claiming the terminator" is necessary +and not sufficient: a pair character is a legal terminator (`\alp(` must +give `α()`), and the chain hands every consumer a *copy* of the record +made before any consumer ran. So expanding inside the chain and then +declining leaves auto-pairing holding offsets the replace has already +invalidated — pairing declines and the closer is silently lost, which a +probe confirmed. Claiming the terminator instead suppresses pairing +outright. Neither is recoverable from inside the chain. + +The expander therefore records the pending expansion and performs it on +its **own `buffer.after-edit` subscriber**, registered after +typed_edit.lua's and before lsp.lua's. A claim by any consumer stops the +chain but not a separate subscriber — which is the point, since pairing +claims the terminator it reacts to. The replaced span covers the leader +and the typed text only; whatever pairing did lands after it and +survives untouched. One undo restores the same text either way, because +the terminator was always its own insert. + +**The deferred expansion must belong to its own fan-out** (round 11). +`buffer.after-edit` fan-outs NEST — Q#AP9 and typed_edit.lua's header +both say so explicitly, and a consumer may call `pmacs.hook.run`. A +nested run re-enters every subscriber, including the deferred +expansion's, while the OUTER chain is still walking its consumer list +and pairing has not yet seen the terminator. A nested pass that +performed the expansion would reproduce the exact bug deferring exists +to fix, reached through the chain's documented re-entrancy seam instead +of through claiming. + +The nesting level is counted by a **no-op consumer registered at the +minimum priority**, matched off in the expander's subscriber. Only the +outermost pass expands; a nested one leaves the expansion queued. No +new seam in typed_edit.lua, which is merged substrate. + +Where the count lives is the whole difficulty, and two plausible places +are both wrong (round 12): + +- **A subscriber registered beside the expander's is too late.** The + entire nested fan-out completes inside the OUTER chain's subscriber, + before any subscriber registered after it runs. +- **The expander itself is optional.** A lower-priority consumer may + claim and stop the chain before the expander is reached, so a nested + pass would go uncounted while its `run_deferred` still ran — and + would then look like the outermost one. + +A minimum-priority consumer runs first in every chain invocation that +reaches any consumer at all. Its guarantee is exactly the ordering +contract the chain already rests on, and it degrades safely: the only +thing that can skip it is a claim ahead of it, which skips the expander +too, so nothing is queued in that fan-out either. + +Two guards this exposes, both of which pairing already carries: + +- The relevance check is **three-part**, not two: buffer, window, **and + `ed.cursor() == rec.post_cursor`**. A redefined self-insert can insert + the completing character and then move the point, and expanding over a + span the user has left teleports them back into it. +- Cursor placement after the replace is **context-guarded**. A buffer + intercept may switch window or buffer while `buf:replace` runs; an + unguarded `goto_byte` then moves the point of a buffer that has + nothing to do with the expansion. `pair.lua`'s `repair_cursor` is the + precedent. + +**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 +1954,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 +2011,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 +2078,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 +2158,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 +2259,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 +2306,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,83 +2444,349 @@ 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. **Terminators are retained, and one expansion is one undo step — + but which text an undo restores depends on the path.** Rev 8 stated + a single rule here and it is wrong against the real table, because + it assumed `\alpha` takes the finish path when `alpha` is in the + 1,550-key eager set (round 9; see 41). + - *Finish path.* `\alp` + space yields `α `: the space lands first + and the expansion runs later in the same `buffer.after-edit` + fan-out, so the terminator is **retained**, not consumed. It sits + OUTSIDE the replaced span, which covers only the leader and the + typed text (round 10) — the observable text and the post-undo + text are the same either way, because the terminator was its own + insert. One undo restores `\alp ` — with its space, not `\al`. + Rev 6 wrote the post-undo text without the terminator, which + would be true only if the terminator were swallowed. + - *Eager path.* `\alpha` yields `α` with no terminator typed, and a + following space is a **separate** edit. One undo removes the + space; a second restores `\alpha`. Asserting the finish-path undo + text here would fail, which is the trap this split exists to + record. 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 only completed expansions rather than pending extensions — **both failure modes must be shown**, since they are distinct bugs with the same symptom. -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. +41. **Eager expansion on uniqueness**, with no terminator typed: + `\alpha` yields `α` the moment the final `a` lands. Rev 8 used `\to` + here and that is false against the real table (round 9): `to` is a + proper prefix of `top`, `to0`, `toa` and others, so + `isAbbreviationUniqueAndComplete` is false and `to` is **not** in + the 1,550-key eager set. `\to` alone stays `\to`; `\to` + space + yields `→ ` by the finish path. Both are asserted, because the + wrong one reads as correct until the table is consulted. +42. A prefix that opens no key at all — `\WWWW` + space — is left as + literal text and **no edit is made**. Rev 8 used `\zzzz`, which + expands (round 9): `z` opens a pending abbreviation because `ze`, + `zeta` and `zsqrtd` exist, and the second `z` finishes it, giving + `ζzzz `. Exactly six printable characters open no key: `$ % , ; @ + W`. Bites against an implementation that treats "no complete match" + as "no pending state". +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. +45j. **A pair character that TERMINATES an abbreviation still pairs** + (round 10). `\alp(` yields `α()` with the point between the pair. + Bites three ways, all of which produce different wrong answers: + claiming the terminator gives `α(`; expanding inside the chain and + then declining also gives `α(`, because the replace invalidates the + record copy pairing is holding; and pairing running first gives + `\alp()` unexpanded. Criterion 40 is the same collision from the + other side, and passing it says nothing about this one. +45k. **The relevance check is three-part.** A redefined + `buffer.self-insert` that inserts the completing character and then + moves the point must not expand: `\alph` + `a` under such an + override leaves literal `\alpha` with the point where the command + put it. Bites against checking only buffer and window — the + expansion would otherwise teleport the point back into a span the + user has left. +45l. **Cursor placement is context-guarded.** A buffer intercept that + switches buffers during `buf:replace` must not have the + switched-to buffer's point moved. Bites against an unguarded + `goto_byte`, which translates the LEAN buffer's pre-edit point + through the LEAN buffer's edit and applies it to whatever is + ambient. +45m. **Q#AP7 for the deferred subscriber.** The expansion runs on a + second `buffer.after-edit` subscriber, so it inherits pairing's + flush-ordering obligation: no `didChange` may ever carry the + unexpanded text. Pinned with the `sighelp` fake server and `(` as + the trigger — the flush carrying the terminator carries `α()`. + Falsified by loading lean_input.lua after lsp.lua. +45n. **A nested fan-out must not expand early** (round 11). A consumer + registered BETWEEN the expander and pairing that calls + `pmacs.hook.run("buffer.after-edit")` once still yields `α()` for + `\alp(`. Bites against a deferred slot consumed by whichever + fan-out happens to reach it: the nested pass would expand, and the + outer chain would then hand pairing a record the replace had + invalidated — the round-10 failure again, through the chain's + documented re-entrancy seam rather than through claiming. +45o. **A nested fan-out that never reaches the expander must not + expand early either** (round 12). Same shape as 45n, but the nested + pass is short-circuited by a consumer at priority 25 that claims + when the record is nil — so the expander never runs on it. Bites + against counting fan-outs in the expander, which is optional by + construction: the uncounted nested pass looks outermost, expands, + and outer pairing resumes with an invalidated record. 45n passes + against that bug, which is why both are pinned. +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 +2848,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 +2866,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 12) + +**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/process-signal-tolerance-framing.md b/docs/process-signal-tolerance-framing.md new file mode 100644 index 0000000..1d3c45d --- /dev/null +++ b/docs/process-signal-tolerance-framing.md @@ -0,0 +1,258 @@ +# Framing — make the PTY terminate failure self-describing (diagnostic only) + +**Revision 4.** Status: awaiting review round 4. Lane: +`pty-terminate-eperm`, worktree `../pmacs-math-slice`, based on +`githubsucks/main` @ `ccf29e3`. + +**Diagnostic only. No disposition changes, no tolerance rules, no +behavioural fix.** Every rule this document proposed across revisions 1 +to 3 is parked (§5). The lane's entire deliverable is that the next +occurrence of the failure explains itself. + +## Revision history + +**Revision 3 → 4**, after review round 3 (two blocking, one major) and +its scope call. All accepted. + +- **Group-directed ESRCH was also unsafe**, for the same reason EPERM + was: it proves the selected *foreground group* vanished, not that the + leader exited. A job-control race — foreground job exits after + `tcgetpgrp` and before `kill`, shell alive and not yet reclaiming the + terminal — would have been reported as success with the leader never + signalled. Rev 3's acceptance 7 pinned that unsafe behaviour. **All + tolerance is parked** (§5). +- **Rev 3's Stage A implemented Stage B.** It declared itself + diagnostic-only, then listed tolerance and bookkeeping acceptances. + Removed. +- **Q#PS6 (already-reaped `terminate` is `Ok`) is parked separately.** + It is an independent behavioural fix answering a different failure; + under one-feature/one-PR it does not ride with instrumentation. +- **"Strictly additive / cannot regress behaviour" was overstated** and + is narrowed (Q#PD3). +- The injected-kill seam is restored as an explicit decision (Q#PD4). + +**Rounds 1–3, for the record.** Rev 1 classified on errno alone and +claimed a live owned child cannot yield EPERM — false. Rev 2 gated on +`try_wait`, which observes the leader while a PTY signal targets the +foreground group — unsound whenever those diverge, and it could not be +shown to fix the observed failure at all. Rev 3 corrected EPERM but left +ESRCH unsafe and mixed the stages. **Three consecutive designs were +wrong in the same direction: each tried to conclude something about a +process from something that was not about that process.** + + +## 0. Coherence impact (COHERENCE §20) + +- **Journey step 8, "Open a terminal"** (§2), teardown half. **No grade + change and no behavioural change** — this lane only improves what a + failure reports. +- **Serves §9 (worker model), failure attribution**, in its most literal + sense: an error that names only an errno cannot be attributed. +- **Interaction islands: none. Config registry: not adopted. + Background-work attribution: unchanged.** +- **No audited claim in COHERENCE.md changes**, so under §25 no + COHERENCE edit rides this PR. + + +## 1. Ground truth (scouted @ `ccf29e3`, re-verified each revision) + +### 1.1 The failure reports an errno and nothing else + +`ProcessSupervisor::signal` (`src/process.rs:921`) maps the `kill` +failure to `format!("kill: {e}")` (`:931`). That string is everything a +reader gets. + +### 1.2 The signal target is not the observation target + +- **Signal target** — `signal_target` (`:687`) returns `-pgrp` for a + PTY, where `pgrp = master.process_group_leader()`: the tty's + **current foreground process group**, read at signal time. +- **Observation target** — `ChildHandle::try_wait` (`:668`) observes the + **spawned leader**. + +They coincide only while the leader owns the terminal. Job control is +precisely the mechanism that makes them diverge, and the PTY path is +**always group-directed by design** — spawn rejects `group = true` for +PTY mode with the rationale that "PTY children already lead their own +session and are signaled group-wide" (`:1428-1429`). + +**This is why every tolerance rule across rev 1–3 failed review**, and +why the diagnostic must record the target and the leader state as +*separate* facts. + +### 1.3 The reap ledger is disjoint from this path + +`tick_reap_ledger` (`:1075`) treats any probe error as "nothing left we +can reach" for **bounded growth**, asserting EPERM "cannot happen for our +own children". It is armed only for `proc.spec.group`, which PTY mode +cannot set. Rev 1's "asymmetry" argument was a misreading; withdrawn. + +### 1.4 The observed failure, and the limits of the evidence + +macOS CI, PR #172 (**docs-only** diff), `Test (macos-latest / luajit)`, +`acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel` +([attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30177276839/attempts/1)): + +``` +in function 'terminate' +cause: ExternalError(Process("kill: EPERM: Operation not permitted")) +``` + +**Established:** the errno, and the call path +(`pmacs.terminal.terminate` → `session.rs:566` → `signal`). + +**Not established:** that the child had exited (the probe's last source +statement is a file write at +`tests/bottom_panel_stage1_acceptance.rs:2239`; CPython teardown follows +and does not synchronise with it); that any pgid was recycled; or what +the signal target actually was. + +**This is the whole reason the lane is diagnostic.** Every candidate fix +needs at least one of those three facts, and none is available. + +### 1.5 Caller inventory + +| Caller | Disposition | +|---|---| +| `src/lsp.rs:1364`, `:2427` | discards (`let _ =`) | +| `src/mcp.rs:1229`, `:1239`, `:1915` | discards (`let _ =`) | +| `src/terminal/session.rs:319`, `:607`, `:635` | discards (`let _ =`) | +| **`src/terminal/session.rs:566`** (propagating at `:577`) | **propagates** as `TerminalError::Process` | +| supervisor-internal `shutdown` path | discards | +| `src/lua_bindings/mod.rs:8150`, `:8164` | propagates to Lua | +| `src/lua_bindings/mod.rs:8717` | propagates (via `session.rs:566`) | +| `src/daemon.rs:4162` | **test-only** `.expect`, not production | + +No test in the repository asserts either error string, so widening the +message breaks nothing. + +### 1.6 `portable-pty` caches the exit status on Unix + +Pinned `portable-pty 0.9.0`: `spawn_command` returns +`std::process::Child` (`unix.rs:228`), and `impl Child for +std::process::Child::try_wait` delegates to +`std::process::Child::try_wait` (`lib.rs:271-277`), which caches into +`self.status`. Both `ChildHandle` variants therefore cache. + + +## 2. Decisions + +### Q#PD1 — what the widened error records + +On a `kill` failure in `signal`, the error carries: + +| Field | Why | +|---|---| +| **target source** — `tcgetpgrp` vs `group` vs `leader-pid` fallback | which branch of `signal_target` (`:687`) ran | +| **target kind and value** — `-pgid` or `pid`, with the number | the entity actually signalled | +| **spawn-time pgid / leader pid** | a divergence from the target is the job-control hypothesis, visible only by comparison | +| **errno** | as today | +| **leader `try_wait` state** — `exited(status)` / `live` / `unobservable(e)` | separates "the leader is gone" from "the group we signalled is gone" — the distinction all three failed designs collapsed | + +Every candidate Stage B rule is decidable from these five together, and +none is decidable from the errno alone. + +### Q#PD2 — the disposition is preserved exactly + +The call still fails, with the same `Err`, in every case. No state +transition changes, no ledger arming changes, no tolerance. A reader +diffing behaviour should find none. + +### Q#PD3 — the honest claim is "no disposition change", not "strictly additive" + +Rev 3 said the diagnostic was only an error-string change and could not +regress behaviour. **That overstated it.** `try_wait` on an exited child +**reaps it and caches the status**, so consulting it in the failure path +is an internal state change: the child may be reaped earlier than it +otherwise would be. + +Observably safe, because both variants cache (§1.6) and `poll_one` +(`:1133`) will still see `Ok(Some(_))` and emit its event. But safe by +argument is not safe by assertion, so the terminate-failure-then-tick +event pin is retained (acceptance 5). + +### Q#PD4 — the injected-kill seam injects the KILL, never the observation + +Acceptance 5 needs a forced `kill` failure while the **real** +`ChildHandle::try_wait` runs against the **real** child. A stubbed +observation would bypass exactly the code path in question. + +So the seam is a test-only override of the *kill attempt's result*, +consumed once by the signal path; everything downstream — target +selection, the observation, the error construction — runs for real. This +also makes the diagnostic's own fields testable without racing the +kernel. + +### Q#PD5 — nothing else lands here + +No tolerance rule, no idempotence change, no `signal_target` change. See +§5. + + +## 3. Bets (falsifiable) + +- **B1 — The five fields are sufficient to discriminate the §1.4 + hypotheses.** Falsified if a recurrence carries all five and still + leaves the cause ambiguous — which would itself be a finding worth + having. +- **B2 — Widening the message breaks no caller.** Evidence: §1.5, and no + test asserts the string. + +*Retracted across revisions and not reinstated:* rev 1's "a live owned +child cannot yield EPERM"; rev 2's "exit observation suffices"; rev 2's +"this removes the failure class"; rev 3's "group ESRCH is safe to +tolerate". + + +## 4. Acceptance + +1. A group-directed `kill` failure produces an error carrying all five + Q#PD1 fields, with the target rendered as `-pgid` and the leader + state distinct from it. +2. A leader-directed `kill` failure does the same, with the target + rendered as `pid` and the target source recorded as the fallback + branch. +3. The leader state renders each of `exited(status)`, `live`, and + `unobservable(e)` correctly. +4. **The disposition is unchanged**: every injected failure still + returns `Err`, with no state transition and no ledger arming + (Q#PD2). Falsified by revert — flipping any arm to `Ok` fails this. +5. **Forced injected kill failure against the real PTY child + observation**, then tick: exactly one exit event, with the correct + status (Q#PD3/Q#PD4). A fully stubbed observation does not satisfy + this and is rejected as vacuous. +6. The existing suites stay green, pinning "no behavioural change" from + the outside. + + +## 5. Parked (not deferred-and-forgotten — each needs its own evidence) + +- **All tolerance rules.** Group-directed EPERM *and* ESRCH both fail on + the §1.2 entity split; leader-directed tolerance is plausible but + unmotivated until evidence shows the fallback branch is ever taken. + Needs Stage A evidence first. +- **Q#PS6, `terminate` on an already-reaped process returning `Ok`.** + Independent behavioural fix, different failure (§1.6 of rev 3), its + own lane under one-feature/one-PR. +- **`signal_target`'s read-then-kill of `tcgetpgrp`** — still the most + likely real fix site, still unframed. +- `terminate` cancelling pending restarts; PTYs in + `pmacs.process.list`; any change to `C-c` delivery. + + +## 6. Gates + +Full suite per `CLAUDE.md`. Touched suites: +`bottom_panel_stage1_acceptance`, the vterm stages, and +`compile_mode_acceptance`. Sweep with `-- --skip basedpyright`. + + +## 7. Branch plan + +`pty-terminate-eperm`, one PR, diagnostic only. This framing is its first +commit; the instrumentation and its tests are the second. + +**The lane then closes.** It does not wait for the flake to recur: the +next occurrence — whenever it happens, under whoever's PR — carries its +own evidence, and Stage B is framed then. Math work proceeds immediately +after this lands. 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..bd405ea --- /dev/null +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -0,0 +1,850 @@ +# Terminal configuration and copy mode + +**Revision 4 — scouted against canonical `main` @ `b889873` (protocol v20), +2026-07-25. APPROVED after four review rounds. Stage 1 MERGED as #173 +(`main` @ `cf54270`, 2026-07-26). Stage 2 implemented on branch +`terminal-copy-mode` off `main` @ `cf54270`; no protocol change.** + +**Stage 2 ships eight of its nine criteria, plus 18a and 18b added in review +round 1 and 16c-16e in rounds 2-3.** Rounds 2 and 3 changed the design, not +just the code: the snapshot is now genuinely `read_only` at the rope, so +**Q#TC6a's analysis below is superseded in part** — read the box at its head +before the analysis. Q#TC6a's conclusion survives; two of its premises do +not, and **criterion 17's bite was restated with them** — the daemon now +refuses the op, so the failure it must look for is mirror mutation plus +divergence, not silent agreement. + +Criterion 17's semantic-frontend end-to-end pin is deliberately +absent — see the note under it — because a faithful version requires the real +`pmacs-gpu` optimistic path, and therefore the `a37` foundation, which CI never +compiles and which skips silently. Both halves of the *mechanism* it guards are +pinned ungated instead (16, 16b). No other criterion is partial. + +**Review round 1 found four defects, and the pair of them rhymes.** Two were +implementation (18a's foreign-buffer clobber, 18b's name-keyed identity) and +two were vacuous pins (18/19's refresh, 20's tail-follow) — and all four trace +to the same root: **a name is not an identity, and a context-free readout is +not a state observation.** The name mistake produced both P1s; the readout +mistake produced both P2s. + +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 read-only at the rope AND round-trip-marked, and +each guard covers a copy the other cannot reach: `read_only` refuses the op +at the daemon, `set_round_trip_input` is the ONLY thing standing between a +replica frontend and unauthorized mutation of its own mirror.** + +> **SUPERSEDED IN PART BY IMPLEMENTATION (review rounds 2-3). Read this +> box before the analysis below it.** The reasoning is still the correct +> account of the substrate *as it stood when this was written*, and its +> conclusion about round-trip input still holds. Two of its premises no +> longer do: +> +> - "**No Lua binding sets `read_only` at all**" — one does now. +> `pmacs.buffer.set_generated_contents` leaves it asserted, so on the +> daemon side undo, redo, ordinary edits and imported CRDT ops are all +> refused by `ensure_writable()`. That closed a real defect: undo +> bypasses the intercept chain, so `M-x buffer.undo` emptied the +> snapshot. +> - "**`set_round_trip_input` is the ONLY thing**" — it is now the only +> thing standing between a replica and *mirror* mutation, which is the +> half `read_only` cannot reach. A semantic frontend applies +> optimistically in its own mirror before the daemon sees the op; a +> daemon-side refusal cannot prevent that, it can only make the two +> copies disagree. +> +> The protection is therefore **layered, not singular**: rope-level +> read-only protects the daemon copy, round-trip input protects the +> replica copy, and neither substitutes for the other. The intercept +> survives only to give a dispatching edit a named error. The Deferred +> lane below records what this leaves open for `*compilation*` and +> listview, which have **not** adopted the primitive. + +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. **Done for this snapshot only**, + and not by exposing the setter — see the Deferred lane and the box above. + +**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. + + **PARTIALLY RETIRED in Stage 2, because review round 2 turned it from a + nice-to-have into a defect.** An intercept guards the dispatch path only, + and `Buffer::undo` reaches the rope through `ensure_writable` without ever + consulting the intercept chain — so a single `C-/` replaced a freshly + rendered snapshot with an empty buffer. Rebinding the undo chords + buffer-locally, which is `*compilation*`'s existing idiom, does **not** + close it: `compile.lua` says so itself ("command/menu undo stays + dispatchable"), and `M-x buffer.undo` needs no keymap. + + The fix ships the deferral's two halves together as **one** primitive + rather than exposing the setter: `Buffer::set_generated_contents` (Lua: + `pmacs.buffer.set_generated_contents`) lifts `read_only`, replaces the + contents skipping intercepts, **discards the history**, and re-asserts + `read_only`. Pairing the lock with the write is precisely what makes it + safe — a bare `set_read_only` would let a caller lock a buffer it can no + longer refresh, which is why the lane was deferred in the first place. + Discarding history is load-bearing twice: it removes the entries undo + would replay, and it stops a periodically refreshed buffer accumulating + rope clones that `read_only` guarantees nothing can ever pop. + + **What remains of the lane — four writer mechanisms over five + buffers**, not the two this section first named (round 5 found the + inventory short; round 6 found the corrected version misattributing + the search panel). Every remaining intercept-protected writer still + relies on intercept-plus-round-trip over a writable rope, and every one + is still emptiable by `M-x buffer.undo`: listview panels + (`listview.lua:60-61`); `compile.lua`'s `ensure_slot`, which serves + `*compilation*` and `*shell-command*` — **not** `*search-results*`, + which `compile.lua` names only in a predicate; the independent + `*search-results*` panel in `builtin/commands/default.lua:869`, with + its own intercept and writes; and dired buffers (`dired.lua:371`). The + primitive they need now exists and is proven, so the remaining work is + adoption plus a streaming-friendly variant — the three appending + buffers need it, while listview and dired already write whole-buffer + replaces and are the cheap half. + + **The CRDT half is closed too** (review round 3). Clearing the v0.1 + stacks proves nothing in CRDT mode, where they are bypassed entirely and + the history lives in loro's `UndoManager`. `read_only` would stop that + history being *replayed* but not *retained* — a panel refreshed on a + timer still grows without bound, which is the condition the contract + says it eliminates. `UndoManager` exposes no `clear`, but it needs none: + a manager records only what happens after it is constructed, which + `CrdtState::from_bytes` already relies on to keep the seed insert out of + undo. `CrdtState::clear_undo_history` rebinds a fresh manager to the same + doc, and `set_generated_contents` clears whichever history the buffer + actually has. + +## 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). +16c. **Undo cannot empty the snapshot, by chord OR by command** (review + round 2). `Buffer::undo` bypasses the intercept chain entirely, so the + snapshot must be `read_only` at the rope. Pinning only the chords would + be a false pass: `M-x buffer.undo` and the menu reach the command with + no keymap involved, which is why `*compilation*`'s chord-rebinding idiom + does not close this. Pinned through **`invoke_interactive`**, the real + M-x path, plus the chord, plus redo — and paired with an assertion that + the owner's own refresh still works, since that is what plain + `read_only` would have broken. +16d. **A generated write reaches the window, not just the rope** (review + round 3). `set_generated_contents` returns one whole-buffer `Replace` + and its binding fans it out; swallowing it leaves a displaying + window's `TextView` line index describing the *previous* contents. + Pinned by **painting** — a shrinking write, so the stale offsets point + past the buffer end and the next render trips + `assertion failed: end <= self.len()` in `src/rope.rs`, which is the + reported crash rather than merely stale pixels. Driven through the Lua + binding copy mode itself calls, so it covers every future owner of the + primitive. +16e. **The same write is queued for replica mirrors** (review round 3, + CRDT half). The dropped fan-out also skipped + `queue_daemon_origin_crdt_op`, so a replica's mirror never imports the + owner's write and its optimistic edits are generated against content + already replaced. Pinned through the real copy-mode refresh on an + upgraded snapshot. `crdt`-gated, therefore dark in CI — 16d is the half + that actually runs there. +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 frontend + applies the edit **optimistically to its own mirror** and emits the op; + the mirror now shows text the user was told is read-only. The daemon + refuses the op at `ensure_writable()` — `set_generated_contents` leaves + `read_only` asserted — so the two copies **diverge**, and the local + mirror is the one the user is looking at. + + **This bite changed in review round 3, and the direction matters.** + Rounds 1-2 specified it as "mutates *both sides*, silently, with no + divergence to notice" — true when nothing set `read_only` from Lua, + and false now. The eventual real-GPU test must assert **mirror + mutation plus daemon refusal**, not silent agreement; written the old + way it would look for a daemon-side edit that can no longer happen and + pass for the wrong reason. That the daemon now holds is exactly why + round-trip input is still load-bearing rather than redundant: a + refusal protects the daemon's copy and does nothing for the replica's. + + **NOT PINNED as specified, deliberately, and this is the one gap in + Stage 2.** A faithful test has to drive the *real* `pmacs-gpu` binary: + the optimistic apply lives only in `pmacs-gpu/src/main.rs` + (`optimistic_crdt_insert` / `optimistic_insert_text`), and the headless + `SemanticClient` the other semantic tests use has no optimistic path at + all, so it cannot produce the op whose absence is the claim. That means + building on the `a37` foundation — which is `crdt`-gated so CI never + compiles it, **returns `ok` without running** when `pmacs-gpu` is absent + from the target directory, and is load-sensitive enough to pass and fail + at the same commit twenty minutes apart. A second test on that footing + would add the appearance of coverage without the substance. + + What IS pinned instead, ungated and in CI: acceptance 16 asserts the + guard is armed (`dispatch_idle` false while the snapshot is focused, so + no replica can apply optimistically or emit), and acceptance 16b asserts + the buffer is `is_read_only()` **true** at the rope, so an op that did + arrive at the daemon would be refused by `ensure_writable()` rather + than applied. (Rounds 1-2 asserted **false** here, documenting the + hazard; round 2 closed it, and the assertion was flipped with it. + That does not make 17 redundant — a daemon-side refusal cannot stop a + replica mutating its own mirror, which is precisely what + `set_round_trip_input` is for.) Together those cover both halves of + Q#TC6a's *mechanism*. What remains unproven is only the end-to-end wire + behaviour of a real GPU frontend, and it stays an explicit obligation of + the CI `crdt`-coverage lane rather than being quietly dropped. +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. + + **The refresh half must be observed by CONTENT, not by buffer count** + (review round 1). Counting buffers, or comparing a quiet terminal's + snapshot against itself, passes with `render_snapshot` replaced by a + no-op. The child is `exec cat`, so the test types a marker into the + focused terminal, requires it **absent** from the existing snapshot, and + only then re-invokes — the "advance the world" discipline. +18a. **A foreign buffer carrying the snapshot's name is never adopted.** + `pmacs.buffer.create` accepts any caller-chosen name, and snapshot writes + use `bypass_intercept`, so found-by-name adoption silently overwrites a + user's data — reproduced in review round 1 as "do not clobber" becoming + 23 newlines. Ownership means **"in copy mode's own handle table"**, which + is dired's F7 rule; a taken name yields a `<2>` variant. +18b. **Snapshot identity is the terminal BUFFER, not its name.** + `TerminalManager::open` uniquifies only the *derived* name — an explicit + `name = ...` is inserted verbatim — so two valid terminals can share one. + A name-keyed table hands them a single snapshot: the second invocation + retargets it, `q` returns to the wrong terminal, and killing either one + removes the shared buffer. Keyed instead by comparing buffer handles in + an array, because `BufferIdLua` implements `__eq` but each wrapper is a + distinct table key — comparison works, hashing does not. +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. + + **Tail-following must be read through the registered VIEW.** Review + round 1: `TerminalManager::snapshot(buffer_id)` is context-free and + always returns the live screen, so it reports "at the tail" even for a + view forced to the oldest retained row — falsified by doing exactly + that and watching the assertion still pass. `snapshot_for_view`'s + `at_bottom` plus its projected cells are the only observables that can + tell the two apart. +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** (a read-only buffer whose replica mirror accepts an + edit the user is then looking at — 17's daemon half was closed in review + round 2, and its bite restated in round 3). +- **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/docs/vterm-framing.md b/docs/vterm-framing.md index b1124c0..7d4ca02 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -1674,6 +1674,64 @@ GPU assertions remain in `pmacs-protocol` and `pmacs-gpu` respectively. - **37:** one real-daemon/real-PTY/headless-wgpu acceptance path; it is not replaced by a decoded-message fixture. +### 0.12 As-framed audit, 2026-07-25 (after #166) + +Prompted by a GPU terminal input defect that shipped in Stage 3 and was fixed +in #166. The arc is structurally complete — all 37 criteria have +implementations, and every test named in the Stage 2 verification map exists — +but the audit found two gaps worth recording against the criteria themselves. + +**Criterion 22's "without thrash" was never pinned.** The criterion reads +"unchanged, zero, passive, and failed resize cases preserve prior geometry +*without thrash*". The word appears nowhere in `src/` or `tests/`. The suite +pinned the four enumerated single-arm cases and never the cross-arm +interaction — which is exactly where the thrash lived: the daemon applied +both the grid and the semantic terminal-layout sync to every attached +frontend, so a semantic session's PTY was resized twice per tick forever. +Criterion 31's "only the exact durable controller changes PTY geometry" was +violated in the same event, in spirit rather than letter: the controller was +the right frontend, but the geometry came from the grid projection. #166 adds +the settle pins; the gap was open from #135 (2026-07-22) until then. + +**Why the Stage 3 suite could not see it.** Of its nine tests, only three +drive a real daemon; the other six construct `EditorState` directly and never +execute the dispatcher loop where the defect lived. `a31`, which is about two +semantic frontends sharing one session, therefore passes on the broken tree. +The same structural blindness explains why `bottom_panel_stage1_acceptance` +was unaffected. A criterion about *dispatcher* behavior needs a test that +runs the dispatcher. + +**Four of the nine Stage 3 tests do not run in CI at all**, because they are +`#[cfg(feature = "crdt")]` and the workflow never enables that feature: +`a37`, the two added by #166, and +`terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret` — which +is Stage 3 review round 1's own regression guard. Stage 1's +`read_only_empty_crdt_bootstrap_is_immutable_against_remote_content`, the CRDT +half of criterion 14, is dark for the same reason. Stage 2 is fully covered +(6/6). This is not a vterm problem: 264 tests workspace-wide are dark, +including 177 in the library. It has its own lane in `docs/active-work.md`. + +**And `a37` is darker still than that count implies: it reports `ok` without +running whenever `pmacs-gpu` is absent from the same target directory** +(measured 2026-07-26 while gating #173). It derives the sibling binary from +`CARGO_BIN_EXE_pmacs` and, finding nothing, prints a skip notice and returns. +A fresh worktree reports the suite 9/9 in 0.17 s having executed the arc's +only real-daemon/real-PTY/real-wgpu path zero times; a genuine run takes +about four seconds. `PMACS_REQUIRE_GPU=1` is the only thing that turns that +skip into a failure, and the standing gate list applies that flag to +`cargo test -p pmacs-gpu`, a different package. So the audit's claim that +"only 3 of 9 Stage 3 tests drive a real daemon" was itself optimistic — +**on a target directory without the frontend binary the honest number is 2**, +and nothing in the gate log says so. It is also load-sensitive: it passed and +then failed at the same commit twenty minutes apart under machine +contention. Criterion 22's unpinned "without thrash" and this are the arc's +two standing verification gaps. + +**Not audited:** §11's blanket claim that "deferral means graceful ignore or +documented absence, never escape leakage, panic, unbounded allocation, or +child leak". That covers roughly twenty deferred items and none were +spot-checked. It remains an unproven claim rather than a known gap. + ## 10. Gates and bite verification Every PR runs the standing full gates from `AGENTS.md`, sequentially, plus its diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a26f340..3a8bb55 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -779,11 +779,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { .ok() .and_then(|value| value.parse::().ok()) .map(std::time::Duration::from_millis); + // Normal probes stop only after their fixture-specific evidence arrives. + // A producer fixture names the text it must paint; an input fixture uses + // the latched echo observation. Keeping that choice outside this generic + // runner prevents one fixture's breadcrumb from forcing another fixture + // to sit on the 20-second safety deadline. + let expected_frame_text = std::env::var("PMACS_GPU_PROBE_EXPECT_TEXT") + .ok() + .filter(|value| !value.is_empty()); let quiet = observe_window.is_some(); let deadline = std::time::Instant::now() + observe_window.unwrap_or_else(|| std::time::Duration::from_secs(20)); let mut sent_input = false; let mut sent_resize = false; + let mut completion_observed = false; while std::time::Instant::now() < deadline { let Ok(event) = rx.recv_timeout(std::time::Duration::from_millis(200)) else { continue; @@ -857,7 +866,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.observed_resized_frame = true; } } - if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 { + let fixture_evidence_observed = expected_frame_text.as_deref().map_or_else( + || facts.input_echo_observed, + |expected| facts.last_frame_text.contains(expected), + ); + // Do not exit merely because resize/composition happened + // first: that races the fixture's required PTY evidence and + // produces a self-contradictory "successful" probe report + // whose later acceptance assertion must reject it. + if !quiet + && facts.observed_resized_frame + && facts.rendered_nonuniform_frames >= 2 + && fixture_evidence_observed + { + completion_observed = true; break; } } @@ -890,6 +912,7 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { let _ = writeln!(out, "last_title={}", facts.last_title.unwrap_or_default()); let _ = writeln!(out, "last_frame_text={}", facts.last_frame_text); let _ = writeln!(out, "input_echo_observed={}", facts.input_echo_observed); + let _ = writeln!(out, "completion_observed={completion_observed}"); let _ = writeln!(out, "disconnect={}", facts.disconnect.unwrap_or_default()); if let Err(error) = std::fs::write(report, out) { eprintln!( @@ -936,10 +959,20 @@ fn run_headless_managed_probe( } }; let mut client = managed.client; + let initial_message = client.take_initial_message(); let initial_target_ready = matches!( - client.take_initial_message(), + initial_message.as_ref(), Some(InstanceMessage::BufferSnapshot { .. }) ); + let mut buffer_facts = ManagedProbeBufferFacts::default(); + if let Some(message) = initial_message.as_ref() + && let Err(error) = buffer_facts.observe(message) + { + let contents = format!("phase=error\nerror={error}\n"); + let _ = write_probe_report(report, &contents); + eprintln!("pmacs-gpu managed probe: {error}"); + return 7; + } let daemon = managed.daemon; let protocol = client.server_protocol_version(); @@ -961,8 +994,14 @@ fn run_headless_managed_probe( let mut last_wait_result = None; let mut last_disconnect = String::new(); if ready - && let Err(error) = - write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) + && let Err(error) = write_managed_probe_report( + report, + "ready", + protocol, + &daemon, + &buffer_facts, + &disconnect, + ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", @@ -976,11 +1015,23 @@ fn run_headless_managed_probe( } match event_rx.recv_timeout(Duration::from_millis(50)) { Ok(AttachEvent::Message(message)) => { - if matches!(*message, InstanceMessage::BufferSnapshot { .. }) && !ready { + let is_snapshot = matches!(*message, InstanceMessage::BufferSnapshot { .. }); + if let Err(error) = buffer_facts.observe(&message) { + let contents = format!("phase=error\nerror={error}\n"); + let _ = write_probe_report(report, &contents); + eprintln!("pmacs-gpu managed probe: {error}"); + return 7; + } + if is_snapshot { ready = true; - if let Err(error) = - write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) - { + if let Err(error) = write_managed_probe_report( + report, + "ready", + protocol, + &daemon, + &buffer_facts, + &disconnect, + ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() @@ -1006,9 +1057,14 @@ fn run_headless_managed_probe( || wait_result != last_wait_result || disconnect != last_disconnect) { - if let Err(error) = - write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) - { + if let Err(error) = write_managed_probe_report( + report, + "ready", + protocol, + &daemon, + &buffer_facts, + &disconnect, + ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() @@ -1021,9 +1077,14 @@ fn run_headless_managed_probe( } if ready && stdin_closed { - if let Err(error) = - write_managed_probe_report(report, "complete", protocol, &daemon, &disconnect) - { + if let Err(error) = write_managed_probe_report( + report, + "complete", + protocol, + &daemon, + &buffer_facts, + &disconnect, + ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() @@ -1043,11 +1104,42 @@ fn run_headless_managed_probe( } } +#[derive(Default)] +struct ManagedProbeBufferFacts { + snapshots: u32, + last_snapshot_text: String, +} + +impl ManagedProbeBufferFacts { + fn observe(&mut self, message: &InstanceMessage) -> Result<(), String> { + let InstanceMessage::BufferSnapshot { crdt_snapshot, .. } = message else { + return Ok(()); + }; + let doc = loro::LoroDoc::new(); + doc.import(crdt_snapshot) + .map_err(|error| format!("BufferSnapshot import failed: {error:?}"))?; + self.snapshots += 1; + self.last_snapshot_text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); + Ok(()) + } +} + +fn hex_bytes(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(encoded, "{byte:02x}"); + } + encoded +} + fn write_managed_probe_report( report: &Path, phase: &str, protocol: u32, daemon: &attach::ManagedDaemonFacts, + buffer_facts: &ManagedProbeBufferFacts, disconnect: &str, ) -> std::io::Result<()> { use std::fmt::Write as _; @@ -1056,6 +1148,12 @@ fn write_managed_probe_report( let _ = writeln!(out, "phase={phase}"); let _ = writeln!(out, "server_protocol_version={protocol}"); let _ = writeln!(out, "buffer_snapshot=true"); + let _ = writeln!(out, "buffer_snapshots={}", buffer_facts.snapshots); + let _ = writeln!( + out, + "last_snapshot_hex={}", + hex_bytes(buffer_facts.last_snapshot_text.as_bytes()) + ); let _ = writeln!(out, "spawned_daemon={}", daemon.spawned_daemon()); let _ = writeln!( out, @@ -8904,6 +9002,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments", InstanceMessage::TerminalFrame(_) => "TerminalFrame", InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", + InstanceMessage::PanelFrame(_) => "PanelFrame", } } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index ce2d2c5..e7e36e3 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -40,8 +40,10 @@ pub mod cell; pub mod crdt; pub mod ids; pub mod message; +pub mod panel; pub mod terminal; pub mod transport; +pub mod wire_grid; /// Logical display columns between fixed buffer-text tab stops. /// @@ -55,21 +57,27 @@ pub use cell::{ pub use crdt::CrdtOp; pub use ids::{BufferId, ByteRange, FrontendId, Position}; pub use message::{ - AdornmentContent, AdornmentPlacement, AttachRequest, BUILTIN_PAIR_CHARS, BlockAdornment, - CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment, - FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, - InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, - KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, - MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS, - MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, - MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, - ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, - StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, - is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, + ADVERTISED_PROTOCOL_VERSION, AdornmentContent, AdornmentPlacement, AttachRequest, + BUILTIN_PAIR_CHARS, BlockAdornment, CompletionPopupRow, CursorState, Decoration, + DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, + InitialTarget, InitialTargetResult, InlineAdornment, InstanceCapabilities, InstanceIdentity, + InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, + MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, + MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, + MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, + PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, + SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, + is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, + negotiate_capabilities, }; +pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload}; pub use terminal::{ MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame, TerminalFrameError, TerminalProcessState, TerminalSelectionSpan, }; pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message}; +pub use wire_grid::{ + MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES, WireGridError, WireGridLimits, + checked_area, validate_wire_grid, +}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 971a78e..0e4e815 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -442,6 +442,77 @@ pub enum FrontendEvent { /// Modifiers held during the gesture. mods: Modifiers, }, + /// Bottom panel Stage 2 (protocol v21): the frontend's authoritative + /// cell-equivalent layout capacity (Q#BP15a). + /// + /// Valid **without** a side window — the daemon needs columns before + /// it can paint a first panel frame, so gating this on panel + /// presence would deadlock the first open. "Without" refers to + /// side-window presence only; the protocol and session gates still + /// apply, and the event is accepted only from an authenticated, + /// negotiated panel-capable semantic session. + /// + /// Sent immediately after attach acceptance and refreshed on window + /// resize, font change, and scale change. `geometry_epoch` is + /// frontend-owned because a font or scale change can invalidate an + /// old panel frame while `total` is **identical**, which daemon-side + /// value dedup cannot detect. + FrontendCellGeometry { + /// Which frontend declared this (untrusted; checked against the + /// transport source). + frontend_id: FrontendId, + /// Monotonic frontend-owned declaration id; `0` is reserved for + /// "never declared" and is rejected on the wire. + geometry_epoch: u64, + /// Whole-cell capacity of the frontend's frame. + total: CellSize, + }, + /// Bottom panel Stage 2 (protocol v21): requested fixed panel rows + /// from a divider drag (Q#BP15a). + /// + /// Rows are the only size component; the epochs are identities, not + /// geometry. Accepted only for the currently visible `Present` panel + /// matching both the latest geometry declaration and the current + /// presentation epoch, then clamped by Q#BP2's interactive + /// preference. + PanelResizeRows { + /// Which frontend produced the drag (untrusted, as above). + frontend_id: FrontendId, + /// Geometry declaration this request is measured against. + geometry_epoch: u64, + /// Presentation identity this request addresses. + panel_epoch: u64, + /// Requested fixed panel rows. + rows: u32, + }, + /// Bottom panel Stage 2 (protocol v21): a pointer gesture a semantic + /// frontend hit-tested to a panel CELL (Q#BP16). + /// + /// Carries both epochs so a gesture aimed at a panel that has since + /// been replaced or reopened cannot be applied to its successor. + /// Unlike [`Self::Pointer`], accepting this **activates the panel**. + /// + /// `buffer_id` and `panel_epoch` close different holes and neither + /// subsumes the other: `buffer_id` catches an A→B buffer + /// replacement, while `panel_epoch` catches close/hide/reopen of the + /// **same** persistent buffer — which a buffer id alone cannot + /// distinguish — without putting a `WindowId` on the wire. + PanelPointer { + /// Which frontend produced the gesture (untrusted, as above). + frontend_id: FrontendId, + /// Geometry declaration this gesture was hit-tested against. + geometry_epoch: u64, + /// Presentation identity this gesture addresses. + panel_epoch: u64, + /// Buffer the frontend believed the panel was displaying. + buffer_id: crate::BufferId, + /// Cell the pointer is over, within the declared panel grid. + coord: CellCoord, + /// Which gesture step this is. + kind: MouseKind, + /// Modifiers held during the gesture. + mods: Modifiers, + }, } /// Gesture step for [`FrontendEvent::Pointer`]. Double-click @@ -488,7 +559,10 @@ impl FrontendEvent { | Self::Pointer { frontend_id, .. } | Self::MenuPointer { frontend_id, .. } | Self::TerminalResize { frontend_id, .. } - | Self::TerminalPointer { frontend_id, .. } => *frontend_id, + | Self::TerminalPointer { frontend_id, .. } + | Self::FrontendCellGeometry { frontend_id, .. } + | Self::PanelResizeRows { frontend_id, .. } + | Self::PanelPointer { frontend_id, .. } => *frontend_id, } } } @@ -1143,6 +1217,20 @@ pub enum InstanceMessage { /// Appended after [`Self::TerminalFrame`], the final v19 variant, so no /// legacy postcard discriminant moves. InitialTargetResult(InitialTargetResult), + /// Bottom panel Stage 2 (protocol v21): the daemon's painted + /// projection of one side window, or its authoritative absence + /// (Q#BP15). + /// + /// `Absent` is sent on close **and** on hide: the receiver retains + /// its last valid frame, so silence would leave a stale band on + /// screen indefinitely. `Absent` is duplicate-suppressed like any + /// payload, and applying it clears the last declared panel size and + /// presentation epoch before any later event can validate against + /// them. + /// + /// Appended after [`Self::InitialTargetResult`], the final v20 + /// variant, so no existing postcard discriminant moves. + PanelFrame(crate::panel::PanelFramePayload), } /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full @@ -1565,7 +1653,27 @@ pub enum ResourceBody { /// handshake extension is read only from v20 semantic sessions; the result is /// sent only when such a session requested a target. v6–v19 handshakes and /// message discriminants remain unchanged. -pub const PROTOCOL_VERSION: u32 = 20; +/// +/// Bottom panel Stage 2 (Q#BP9): bumped 20 → 21 for +/// [`InstanceMessage::PanelFrame`] and +/// [`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`]. +/// All four are appended after their enum's previous final variant, so +/// no v6–v20 discriminant moves and the encoding of every existing +/// message is byte-identical. The new traffic is gated in both +/// directions: a v20 peer neither receives `PanelFrame` nor is placed in +/// a side window, because denying only the events would leave its +/// window invisible. +pub const PROTOCOL_VERSION: u32 = 21; + +/// Protocol version placed in the daemon's server-first [`Hello`]. +/// +/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but +/// production attachment remains on v20 until the Stage 2B-3 capability +/// activation can preserve compatibility with existing v20 frontends. +/// Those frontends reject an unknown server-first version before they can +/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would +/// make the otherwise-dark protocol slice user-visible. +pub const ADVERTISED_PROTOCOL_VERSION: u32 = 20; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1643,8 +1751,15 @@ pub const PROTOCOL_VERSION: u32 = 20; /// GPU initial target (Q#GT4): extended to `[6, ..., 20]`. v20 semantic /// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy /// and non-semantic sessions retain their existing handshake shape. +/// +/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. Stage 2B-1 +/// reserves and validates the v21 wire while production daemons continue +/// to send [`ADVERTISED_PROTOCOL_VERSION`] in their server-first +/// [`Hello`]. The later capability-activation slice owns moving production +/// negotiation to v21 without making existing v20 frontends reject the +/// handshake. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = - &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; + &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. @@ -2024,7 +2139,10 @@ pub fn negotiate_capabilities( /// frontend will use as the `FrontendId` on every event it sends. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Hello { - /// The instance's `PROTOCOL_VERSION`. + /// The protocol version this attachment should use. + /// + /// This can deliberately trail [`PROTOCOL_VERSION`] while an additive + /// wire family is reserved but not yet activated in production. pub protocol_version: u32, /// `FrontendId` assigned to this attachment by the instance. The /// frontend stamps this onto subsequent events. v0.1 daemons start diff --git a/pmacs-protocol/src/panel.rs b/pmacs-protocol/src/panel.rs new file mode 100644 index 0000000..e571ca4 --- /dev/null +++ b/pmacs-protocol/src/panel.rs @@ -0,0 +1,213 @@ +//! Bottom-panel wire types (Q#BP15, Q#BP15a, Q#BP16). +//! +//! A panel frame is the daemon's painted projection of one side window. +//! It shares [`crate::wire_grid`]'s cell rules with +//! [`crate::terminal::TerminalFrame`] but not its per-axis PTY caps: a +//! 4K surface at a small font is legitimately wider than 512 columns, +//! and the area bound is what keeps the encoding inside the transport +//! budget. +//! +//! Presence is explicit. [`PanelFramePayload::Absent`] is authoritative +//! and must be sent on close *and* on hide, because the receiver +//! retains its last valid frame: silence would leave a stale band on +//! screen indefinitely. + +use crate::cell::{Cell, CellCoord, CellSize}; +use crate::ids::BufferId; +use crate::wire_grid::{ + MAX_WIRE_GRID_GLYPH_BYTES, WireGridError, WireGridLimits, validate_wire_grid, +}; + +/// Shared visible-cell ceiling for a panel grid. +/// +/// Identical to the terminal bound: it is the transport-safety limit, +/// not a PTY policy, so both messages answer to it. +pub const MAX_PANEL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS; + +/// Bounds a panel frame enforces on its cell grid. +/// +/// The per-axis ceilings are the area bound itself rather than 512: any +/// axis larger than the area bound is already rejected by the area +/// check, so this expresses "no independent per-axis policy" without +/// leaving the multiplication unchecked. +const PANEL_GRID_LIMITS: WireGridLimits = WireGridLimits { + max_rows: MAX_PANEL_VISIBLE_CELLS as u32, + max_cols: MAX_PANEL_VISIBLE_CELLS as u32, + max_visible_cells: MAX_PANEL_VISIBLE_CELLS, + max_glyph_bytes: MAX_WIRE_GRID_GLYPH_BYTES, +}; + +/// The daemon's painted projection of one side window. +/// +/// `panel_epoch` is opaque and monotonic per frontend: stable across +/// ordinary frames of one continuously present window/buffer, and +/// changed on buffer replacement, new side-window creation, and every +/// `Absent` → `Present` transition. That is what stops a stale +/// `PanelPointer` from addressing a reopened panel as if it were the +/// old one (Q#BP16). +/// +/// `geometry_epoch` answers a *frontend* declaration and moves whenever +/// the frontend declares new effective cell geometry — including a font +/// or scale change that leaves [`CellSize`] identical, which is exactly +/// the case daemon-side value dedup cannot see (Q#BP2S1). +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PanelFrame { + /// Buffer this frame projects. + pub buffer_id: BufferId, + /// Presentation identity, monotonic per frontend. + pub panel_epoch: u64, + /// The frontend geometry declaration this frame answers. + pub geometry_epoch: u64, + /// Panel grid dimensions in cells. + pub size: CellSize, + /// Row-major cells; exactly `size.area()` entries. + pub cells: Vec, + /// Panel caret, or `None` when the panel shows no cursor. + /// + /// `paint_frame` returns the cursor separately from the cells, so a + /// frame carrying cells alone would lose the caret. + pub cursor: Option, + /// Whether the panel owns focus. + /// + /// Presentation and focus-chrome routing only (Q#BP14b) — the + /// *keys* decision is `DispatchIdle` (Q#BP14a). + pub focused: bool, +} + +/// Explicit panel presence. +/// +/// `Absent` is authoritative rather than implied by silence, and is +/// duplicate-suppressed like any other payload. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum PanelFramePayload { + /// A panel is visible and this is its current frame. + Present(PanelFrame), + /// No panel is visible; clear any retained frame. + Absent, +} + +/// Why a [`PanelFrame`] is not structurally valid. +/// +/// Validation is atomic: the frame is rejected whole and the receiver +/// retains its previous valid frame. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum PanelFrameError { + /// Rows or columns are zero or above the area-derived bounds. + #[error("panel size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")] + Size { + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + /// Row bound in force. + max_rows: u32, + /// Column bound in force. + max_cols: u32, + }, + /// The checked area exceeds the shared visible-cell bound. + #[error("panel area {area} exceeds the visible-cell bound {max}")] + Area { + /// Checked `rows * cols`. + area: usize, + /// Shared visible-cell bound. + max: usize, + }, + /// `cells.len()` disagrees with the declared area. + #[error("panel frame carries {actual} cells for a {expected}-cell area")] + CellCount { + /// Declared area. + expected: usize, + /// Supplied cell count. + actual: usize, + }, + /// The cursor lies outside the declared grid. + #[error("panel cursor ({row},{col}) is outside the {rows}x{cols} grid")] + Cursor { + /// Cursor row. + row: u32, + /// Cursor column. + col: u32, + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + }, + /// A cell's glyph is not a legal wire glyph. + #[error("panel cell {index} has an invalid glyph: {reason}")] + Glyph { + /// Row-major cell index. + index: usize, + /// Why the glyph failed. + reason: &'static str, + }, + /// A cell carries a frontend attachment, which panels never use. + #[error("panel cell {index} carries an attachment")] + Attachment { + /// Row-major cell index. + index: usize, + }, + /// Aggregate glyph bytes exceed the shared budget. + #[error("panel frame glyph bytes exceed the aggregate bound {max}")] + GlyphBudget { + /// Shared aggregate bound. + max: usize, + }, + /// An epoch is zero, which is reserved for "never declared". + #[error("panel {field} epoch is zero, which is reserved for 'never declared'")] + ZeroEpoch { + /// Which epoch was zero. + field: &'static str, + }, +} + +impl PanelFrame { + /// Check every structural rule a panel frame must satisfy. + /// + /// Pure: a rejected frame mutates nothing, so callers get atomic + /// rejection for free. + pub fn validate(&self) -> Result<(), PanelFrameError> { + if self.panel_epoch == 0 { + return Err(PanelFrameError::ZeroEpoch { field: "panel" }); + } + if self.geometry_epoch == 0 { + return Err(PanelFrameError::ZeroEpoch { field: "geometry" }); + } + validate_wire_grid(self.size, &self.cells, self.cursor, PANEL_GRID_LIMITS) + .map_err(panel_grid_error) + } +} + +/// Map a shared wire-grid failure onto this message's error type. +fn panel_grid_error(error: WireGridError) -> PanelFrameError { + match error { + WireGridError::Size { + rows, + cols, + max_rows, + max_cols, + } => PanelFrameError::Size { + rows, + cols, + max_rows, + max_cols, + }, + WireGridError::Area { area, max } => PanelFrameError::Area { area, max }, + WireGridError::CellCount { expected, actual } => { + PanelFrameError::CellCount { expected, actual } + } + WireGridError::Cursor { + row, + col, + rows, + cols, + } => PanelFrameError::Cursor { + row, + col, + rows, + cols, + }, + WireGridError::Glyph { index, reason } => PanelFrameError::Glyph { index, reason }, + WireGridError::Attachment { index } => PanelFrameError::Attachment { index }, + WireGridError::GlyphBudget { max } => PanelFrameError::GlyphBudget { max }, + } +} diff --git a/pmacs-protocol/src/terminal.rs b/pmacs-protocol/src/terminal.rs index 8f094d7..0bcf7e0 100644 --- a/pmacs-protocol/src/terminal.rs +++ b/pmacs-protocol/src/terminal.rs @@ -12,13 +12,18 @@ //! that single structural policy. A second implementation of these rules //! in a frontend is a bug, not a convenience. //! -//! This module owns the crate's only `unicode-width` use: glyph column -//! width and wide-continuation topology cannot be checked without it. +//! Glyph column width and wide-continuation topology moved to +//! [`crate::wire_grid`] in bottom-panel Stage 2B, which is now the +//! crate's only non-test `unicode-width` use: those rules are shared +//! with [`crate::panel::PanelFrame`]. The 512 per-axis PTY caps, +//! metadata, selection spans, and the `at_bottom`/`scroll_offset` +//! coupling stay here, because a panel does not inherit them. -use crate::cell::{Cell, CellCoord, CellSize, Glyph}; +use crate::cell::{Cell, CellCoord, CellSize}; use crate::ids::BufferId; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +#[cfg(test)] +use unicode_width::UnicodeWidthStr; // --------------------------------------------------------------------------- // Shared limits @@ -32,10 +37,20 @@ pub const MAX_TERMINAL_COLS: u16 = 512; /// Maximum visible terminal cells accepted at creation, resize, or on /// the wire. -pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144; +/// +/// An alias of the shared wire-grid bound: this is transport safety, not +/// a PTY policy, so it must not drift from the panel's. +pub const MAX_TERMINAL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS; /// Maximum UTF-8 bytes retained in one terminal grapheme cluster. -pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256; +/// +/// An **alias** of the shared wire-grid bound, not an independent value. +/// The terminal screen truncates clusters to this constant while +/// [`crate::wire_grid`] validates against its own; if the two were +/// separate literals, raising one would make the producer emit clusters +/// its own validator rejects — or, worse, accept clusters no frontend +/// budgeted for. Keeping this a re-export means they cannot drift. +pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES; /// Shared cap for terminal title and process-outcome metadata. pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024; @@ -51,7 +66,7 @@ pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024; /// protocol test `maximum_legal_terminal_frame_encodes_below_the_transport_cap` /// measures the largest legal frame this bound admits and pins it below /// the unchanged 16 MiB cap. -pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GLYPH_BYTES; // --------------------------------------------------------------------------- // Payload types @@ -217,6 +232,58 @@ pub enum TerminalFrameError { }, } +/// Bounds a terminal frame enforces on its cell grid. +/// +/// The per-axis caps are the PTY-specific half of the split: a panel +/// frame shares every other rule but not these, because a panel is +/// sized by the frontend's surface rather than by a pty window size. +const TERMINAL_GRID_LIMITS: crate::wire_grid::WireGridLimits = crate::wire_grid::WireGridLimits { + max_rows: MAX_TERMINAL_ROWS as u32, + max_cols: MAX_TERMINAL_COLS as u32, + max_visible_cells: MAX_TERMINAL_VISIBLE_CELLS, + max_glyph_bytes: MAX_TERMINAL_FRAME_GLYPH_BYTES, +}; + +/// Map a shared wire-grid failure onto this message's error type. +/// +/// The variants and their text are unchanged by the Stage 2B factoring: +/// every existing terminal-frame assertion still observes exactly what +/// it observed before. +fn terminal_grid_error(error: crate::wire_grid::WireGridError) -> TerminalFrameError { + use crate::wire_grid::WireGridError; + match error { + WireGridError::Size { + rows, + cols, + max_rows, + max_cols, + } => TerminalFrameError::Size { + rows, + cols, + max_rows, + max_cols, + }, + WireGridError::Area { area, max } => TerminalFrameError::Area { area, max }, + WireGridError::CellCount { expected, actual } => { + TerminalFrameError::CellCount { expected, actual } + } + WireGridError::Cursor { + row, + col, + rows, + cols, + } => TerminalFrameError::Cursor { + row, + col, + rows, + cols, + }, + WireGridError::Glyph { index, reason } => TerminalFrameError::Glyph { index, reason }, + WireGridError::Attachment { index } => TerminalFrameError::Attachment { index }, + WireGridError::GlyphBudget { max } => TerminalFrameError::GlyphBudget { max }, + } +} + impl TerminalFrame { /// Check every structural rule a terminal frame must satisfy. /// @@ -224,23 +291,13 @@ impl TerminalFrame { /// by a frontend after decode. It is pure: a rejected frame mutates /// nothing, so callers get atomic rejection for free. pub fn validate(&self) -> Result<(), TerminalFrameError> { - let area = self.checked_area()?; - if self.cells.len() != area { - return Err(TerminalFrameError::CellCount { - expected: area, - actual: self.cells.len(), - }); - } - if let Some(cursor) = self.cursor - && (cursor.row >= self.size.rows || cursor.col >= self.size.cols) - { - return Err(TerminalFrameError::Cursor { - row: cursor.row, - col: cursor.col, - rows: self.size.rows, - cols: self.size.cols, - }); - } + crate::wire_grid::validate_wire_grid( + self.size, + &self.cells, + self.cursor, + TERMINAL_GRID_LIMITS, + ) + .map_err(terminal_grid_error)?; if let Some(title) = &self.title { validate_metadata("title", title)?; } @@ -249,7 +306,6 @@ impl TerminalFrame { TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?, TerminalProcessState::Running | TerminalProcessState::Exited(_) => {} } - self.validate_cells()?; self.validate_selection()?; if self.at_bottom != (self.scroll_offset == 0) { return Err(TerminalFrameError::BottomState { @@ -260,107 +316,6 @@ impl TerminalFrame { Ok(()) } - /// Declared cell area, checked against both shared bounds. - fn checked_area(&self) -> Result { - let rows = self.size.rows; - let cols = self.size.cols; - if rows == 0 - || cols == 0 - || rows > u32::from(MAX_TERMINAL_ROWS) - || cols > u32::from(MAX_TERMINAL_COLS) - { - return Err(TerminalFrameError::Size { - rows, - cols, - max_rows: u32::from(MAX_TERMINAL_ROWS), - max_cols: u32::from(MAX_TERMINAL_COLS), - }); - } - // Both factors are bounded above by 512, so the product cannot - // overflow; `checked_mul` keeps that an assertion rather than an - // assumption a later bound change could quietly break. - let area = rows - .checked_mul(cols) - .and_then(|area| usize::try_from(area).ok()) - .ok_or(TerminalFrameError::Area { - area: usize::MAX, - max: MAX_TERMINAL_VISIBLE_CELLS, - })?; - if area > MAX_TERMINAL_VISIBLE_CELLS { - return Err(TerminalFrameError::Area { - area, - max: MAX_TERMINAL_VISIBLE_CELLS, - }); - } - Ok(area) - } - - /// Glyph legality, wide-continuation topology, and the glyph budget. - fn validate_cells(&self) -> Result<(), TerminalFrameError> { - let cols = self.size.cols as usize; - let mut glyph_bytes = 0usize; - // Columns still owed to the preceding wide lead on this row. - let mut pending_continuation = false; - for (index, cell) in self.cells.iter().enumerate() { - if cell.attachment.is_some() { - return Err(TerminalFrameError::Attachment { index }); - } - let col = index % cols; - if col == 0 && pending_continuation { - // A wide lead in the final column would have to be - // completed on the next row, which is not a footprint a - // terminal grid can express. - return Err(TerminalFrameError::Glyph { - index: index - 1, - reason: "wide glyph has no continuation column on its row", - }); - } - match &cell.glyph { - Glyph::Continuation => { - if !pending_continuation { - return Err(TerminalFrameError::Glyph { - index, - reason: "continuation without a preceding wide glyph", - }); - } - pending_continuation = false; - } - Glyph::Char(ch) => { - if pending_continuation { - return Err(TerminalFrameError::Glyph { - index, - reason: "wide glyph is not followed by its continuation", - }); - } - let width = char_display_width(*ch).ok_or(TerminalFrameError::Glyph { - index, - reason: "glyph is a control or zero-width character", - })?; - glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8())?; - pending_continuation = width == 2; - } - Glyph::Cluster(bytes) => { - if pending_continuation { - return Err(TerminalFrameError::Glyph { - index, - reason: "wide glyph is not followed by its continuation", - }); - } - let width = cluster_display_width(bytes, index)?; - glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len())?; - pending_continuation = width == 2; - } - } - } - if pending_continuation { - return Err(TerminalFrameError::Glyph { - index: self.cells.len() - 1, - reason: "wide glyph has no continuation column on its row", - }); - } - Ok(()) - } - /// One nonempty in-bounds span per row, strictly increasing by row. fn validate_selection(&self) -> Result<(), TerminalFrameError> { let mut previous_row: Option = None; @@ -395,73 +350,6 @@ impl TerminalFrame { } } -/// Column width of a leading `Char` glyph, or `None` when it cannot lead. -fn char_display_width(ch: char) -> Option { - if ch.is_control() { - return None; - } - match UnicodeWidthChar::width(ch) { - Some(1) => Some(1), - Some(2) => Some(2), - _ => None, - } -} - -/// Column width of a leading `Cluster` glyph. -/// -/// Width is clamped into `1..=2` exactly as the terminal screen clamps it -/// when it writes the cluster: a base plus combining marks may measure -/// wider than two columns, and the screen occupies two. Clamping in one -/// place and measuring in another is how a frame that renders correctly -/// gets rejected on the wire. -fn cluster_display_width(bytes: &[u8], index: usize) -> Result { - if bytes.is_empty() { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster is empty", - }); - } - if bytes.len() > MAX_TERMINAL_GRAPHEME_BYTES { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster exceeds the per-cluster byte limit", - }); - } - let text = std::str::from_utf8(bytes).map_err(|_| TerminalFrameError::Glyph { - index, - reason: "cluster is not valid UTF-8", - })?; - if text.chars().any(char::is_control) { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster carries a control character", - }); - } - let width = UnicodeWidthStr::width(text); - if width == 0 { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster occupies no columns", - }); - } - Ok(width.min(2)) -} - -/// Accumulate glyph bytes under the aggregate bound with checked addition. -fn add_glyph_bytes(total: usize, add: usize) -> Result { - let next = total - .checked_add(add) - .ok_or(TerminalFrameError::GlyphBudget { - max: MAX_TERMINAL_FRAME_GLYPH_BYTES, - })?; - if next > MAX_TERMINAL_FRAME_GLYPH_BYTES { - return Err(TerminalFrameError::GlyphBudget { - max: MAX_TERMINAL_FRAME_GLYPH_BYTES, - }); - } - Ok(next) -} - /// Length and control-character rules shared by title and process text. fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFrameError> { if text.len() > MAX_TERMINAL_METADATA_BYTES { @@ -482,7 +370,10 @@ fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFram #[cfg(test)] mod tests { use super::*; - use crate::cell::{Color, Style, UnderlineStyle}; + // `Glyph` is no longer used by this module's production code — the + // glyph rules moved to `crate::wire_grid` — but these tests still + // construct frames cell by cell. + use crate::cell::{Color, Glyph, Style, UnderlineStyle}; use crate::message::InstanceMessage; use crate::transport::MAX_FRAME_BYTES; @@ -952,14 +843,14 @@ mod tests { let mut over = exact.clone(); // One more byte of glyph, nothing else changed. let last = over.cells.len() - 1; - over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(3).into_boxed_slice())); + over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(2).into_boxed_slice())); (exact, over) } #[test] fn maximum_legal_terminal_frame_encodes_below_the_transport_cap() { - let (exact, _) = budget_boundary_frames(); + let (exact, over) = budget_boundary_frames(); assert_eq!(exact.validate(), Ok(())); let mut glyph_bytes = 0usize; @@ -974,6 +865,20 @@ mod tests { glyph_bytes, MAX_TERMINAL_FRAME_GLYPH_BYTES, "the measured fixture must spend the whole aggregate budget" ); + let over_glyph_bytes = over + .cells + .iter() + .map(|cell| match &cell.glyph { + Glyph::Char(ch) => ch.len_utf8(), + Glyph::Cluster(bytes) => bytes.len(), + Glyph::Continuation => 0, + }) + .sum::(); + assert_eq!( + over_glyph_bytes, + MAX_TERMINAL_FRAME_GLYPH_BYTES + 1, + "the rejecting twin must be exactly one byte over the aggregate budget" + ); let msg = InstanceMessage::TerminalFrame(exact); let bytes = postcard::to_allocvec(&msg).expect("encode"); diff --git a/pmacs-protocol/src/wire_grid.rs b/pmacs-protocol/src/wire_grid.rs new file mode 100644 index 0000000..a9449df --- /dev/null +++ b/pmacs-protocol/src/wire_grid.rs @@ -0,0 +1,329 @@ +//! Shared cell-grid validation for every wire message that carries a +//! rectangular grid of [`Cell`]s. +//! +//! Bottom-panel Stage 2B (Q#BP15) factors this out of +//! [`crate::terminal`], which was the only such message until +//! [`crate::panel::PanelFrame`] arrived. The split follows the boundary +//! the framing names: +//! +//! - **Shared** — the checked area, the visible-cell bound, the cell +//! count, cursor bounds, glyph legality, wide-continuation topology, +//! the aggregate glyph-byte budget, and the attachment rejection. +//! - **Terminal-only** — the 512 per-axis PTY caps, title/process +//! metadata, selection spans, and the `at_bottom == (scroll_offset == +//! 0)` coupling. +//! +//! The per-axis caps are a [`WireGridLimits`] parameter rather than a +//! constant precisely because a panel does not inherit them: a 4K +//! surface at a small font is legitimately wider than 512 columns, and +//! the area bound is what keeps the encoding inside the transport +//! budget. +//! +//! The attachment rejection is deliberately **shared**, not +//! terminal-only, even though its terminal-side message reads "which +//! terminals never use". Panels render no attachments either, so +//! rejecting them here fails closed for both; classifying it as +//! terminal-only would let a panel ship a cell no frontend can paint. + +use crate::cell::{Cell, CellCoord, CellSize, Glyph}; + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Aggregate glyph-byte ceiling shared by every wire grid. +/// +/// A grid at the visible-cell bound where every cell carries a maximum +/// cluster would exceed the transport frame limit; this keeps the +/// encoded size bounded independently of the per-cell rule. +pub const MAX_WIRE_GRID_GLYPH_BYTES: usize = 8 * 1024 * 1024; + +/// Per-cell grapheme-cluster byte ceiling shared by every wire grid. +pub const MAX_WIRE_GRID_GRAPHEME_BYTES: usize = 256; + +/// Visible-cell ceiling shared by every wire grid. +/// +/// This is the transport-safety bound, not a per-message policy: it is +/// what keeps `rows * cols * per-cell` inside the transport frame limit, +/// so both the terminal and the panel answer to it even though they +/// carry different per-axis caps. +pub const MAX_WIRE_GRID_VISIBLE_CELLS: usize = 262_144; + +/// Bounds a particular wire grid enforces. +/// +/// `max_rows` / `max_cols` are per-message policy. `max_visible_cells` +/// is the shared area bound and is what actually keeps the encoding +/// inside the transport budget. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct WireGridLimits { + /// Inclusive row ceiling. + pub max_rows: u32, + /// Inclusive column ceiling. + pub max_cols: u32, + /// Inclusive `rows * cols` ceiling. + pub max_visible_cells: usize, + /// Inclusive aggregate glyph-byte ceiling. + pub max_glyph_bytes: usize, +} + +/// Why a wire grid is not structurally valid. +/// +/// Callers map these onto their own message-specific error types so +/// existing wire errors keep their exact variants and text. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum WireGridError { + /// Rows or columns are zero or above this grid's bounds. + Size { + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + /// Row bound in force. + max_rows: u32, + /// Column bound in force. + max_cols: u32, + }, + /// The checked area exceeds the visible-cell bound. + Area { + /// Checked `rows * cols`. + area: usize, + /// Bound in force. + max: usize, + }, + /// `cells.len()` disagrees with the declared area. + CellCount { + /// Declared area. + expected: usize, + /// Supplied cell count. + actual: usize, + }, + /// The cursor lies outside the declared grid. + Cursor { + /// Cursor row. + row: u32, + /// Cursor column. + col: u32, + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + }, + /// A cell's glyph is not legal in a wire grid. + Glyph { + /// Row-major cell index. + index: usize, + /// Why the glyph failed. + reason: &'static str, + }, + /// A cell carries a frontend attachment, which no wire grid uses. + Attachment { + /// Row-major cell index. + index: usize, + }, + /// Aggregate glyph bytes exceed the budget. + GlyphBudget { + /// Bound in force. + max: usize, + }, +} + +/// Declared cell area, checked against this grid's bounds. +/// +/// Separate from [`validate_wire_grid`] because callers need the area +/// before they have cells to check against it. +pub fn checked_area(size: CellSize, limits: WireGridLimits) -> Result { + let rows = size.rows; + let cols = size.cols; + if rows == 0 || cols == 0 || rows > limits.max_rows || cols > limits.max_cols { + return Err(WireGridError::Size { + rows, + cols, + max_rows: limits.max_rows, + max_cols: limits.max_cols, + }); + } + // `checked_mul` rather than a bound-derived assumption: a panel's + // axis ceilings are large enough that the product genuinely can + // overflow, which the terminal's 512x512 could not. + let area = rows + .checked_mul(cols) + .and_then(|area| usize::try_from(area).ok()) + .ok_or(WireGridError::Area { + area: usize::MAX, + max: limits.max_visible_cells, + })?; + if area > limits.max_visible_cells { + return Err(WireGridError::Area { + area, + max: limits.max_visible_cells, + }); + } + Ok(area) +} + +/// Check every structural rule shared by wire grids. +/// +/// Pure: a rejected grid mutates nothing, so callers get atomic +/// rejection for free. +pub fn validate_wire_grid( + size: CellSize, + cells: &[Cell], + cursor: Option, + limits: WireGridLimits, +) -> Result<(), WireGridError> { + let area = checked_area(size, limits)?; + if cells.len() != area { + return Err(WireGridError::CellCount { + expected: area, + actual: cells.len(), + }); + } + if let Some(cursor) = cursor + && (cursor.row >= size.rows || cursor.col >= size.cols) + { + return Err(WireGridError::Cursor { + row: cursor.row, + col: cursor.col, + rows: size.rows, + cols: size.cols, + }); + } + validate_cells(size, cells, limits) +} + +/// Glyph legality, wide-continuation topology, and the glyph budget. +fn validate_cells( + size: CellSize, + cells: &[Cell], + limits: WireGridLimits, +) -> Result<(), WireGridError> { + let cols = size.cols as usize; + let mut glyph_bytes = 0usize; + // Columns still owed to the preceding wide lead on this row. + let mut pending_continuation = false; + for (index, cell) in cells.iter().enumerate() { + if cell.attachment.is_some() { + return Err(WireGridError::Attachment { index }); + } + let col = index % cols; + if col == 0 && pending_continuation { + // A wide lead in the final column would have to be completed + // on the next row, which is not a footprint a cell grid can + // express. + return Err(WireGridError::Glyph { + index: index - 1, + reason: "wide glyph has no continuation column on its row", + }); + } + match &cell.glyph { + Glyph::Continuation => { + if !pending_continuation { + return Err(WireGridError::Glyph { + index, + reason: "continuation without a preceding wide glyph", + }); + } + pending_continuation = false; + } + Glyph::Char(ch) => { + if pending_continuation { + return Err(WireGridError::Glyph { + index, + reason: "wide glyph is not followed by its continuation", + }); + } + let width = char_display_width(*ch).ok_or(WireGridError::Glyph { + index, + reason: "glyph is a control or zero-width character", + })?; + glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8(), limits)?; + pending_continuation = width == 2; + } + Glyph::Cluster(bytes) => { + if pending_continuation { + return Err(WireGridError::Glyph { + index, + reason: "wide glyph is not followed by its continuation", + }); + } + let width = cluster_display_width(bytes, index)?; + glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len(), limits)?; + pending_continuation = width == 2; + } + } + } + if pending_continuation { + return Err(WireGridError::Glyph { + index: cells.len() - 1, + reason: "wide glyph has no continuation column on its row", + }); + } + Ok(()) +} + +/// Column width of a leading `Char` glyph, or `None` when it cannot lead. +pub(crate) fn char_display_width(ch: char) -> Option { + if ch.is_control() { + return None; + } + match UnicodeWidthChar::width(ch) { + Some(1) => Some(1), + Some(2) => Some(2), + _ => None, + } +} + +/// Column width of a leading `Cluster` glyph. +/// +/// Width is clamped into `1..=2` exactly as the terminal screen clamps it +/// when it writes the cluster: a base plus combining marks may measure +/// wider than two columns, and the screen occupies two. Clamping in one +/// place and measuring in another is how a frame that renders correctly +/// gets rejected on the wire. +fn cluster_display_width(bytes: &[u8], index: usize) -> Result { + if bytes.is_empty() { + return Err(WireGridError::Glyph { + index, + reason: "cluster is empty", + }); + } + if bytes.len() > MAX_WIRE_GRID_GRAPHEME_BYTES { + return Err(WireGridError::Glyph { + index, + reason: "cluster exceeds the per-cluster byte limit", + }); + } + let text = std::str::from_utf8(bytes).map_err(|_| WireGridError::Glyph { + index, + reason: "cluster is not valid UTF-8", + })?; + if text.chars().any(char::is_control) { + return Err(WireGridError::Glyph { + index, + reason: "cluster carries a control character", + }); + } + let width = UnicodeWidthStr::width(text); + if width == 0 { + return Err(WireGridError::Glyph { + index, + reason: "cluster occupies no columns", + }); + } + Ok(width.min(2)) +} + +/// Accumulate glyph bytes against the aggregate budget. +fn add_glyph_bytes( + total: usize, + add: usize, + limits: WireGridLimits, +) -> Result { + let next = total.checked_add(add).ok_or(WireGridError::GlyphBudget { + max: limits.max_glyph_bytes, + })?; + if next > limits.max_glyph_bytes { + return Err(WireGridError::GlyphBudget { + max: limits.max_glyph_bytes, + }); + } + Ok(next) +} diff --git a/scripts/regen-lean-abbrev b/scripts/regen-lean-abbrev new file mode 100755 index 0000000..14091aa --- /dev/null +++ b/scripts/regen-lean-abbrev @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Regenerate builtin/runtime/lean_abbrev.lua from vscode-lean4. + +Usage: scripts/regen-lean-abbrev + +Fetches `lean4-unicode-input/src/abbreviations.json` at the given commit +and rewrites the vendored Lua table, including the provenance header, so +the artifact is self-describing to whoever next touches it. A refresh is +an ordinary PR with a visible diff — the diff is the review. + +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 +(docs/lean4-mode-framing.md Q#LN11). + +The emit is an ORDERED SEQUENCE, not a map. Upstream resolves +equal-length abbreviation ties by source declaration order — 101 +prefixes depend on it — and a Lua `{ [key] = symbol }` table iterated +with `pairs` cannot carry that. A map-shaped emit would also be +nondeterministic across builds and, once a hash order happened to be +stable, stably wrong. + +This script ABORTS rather than emitting something plausible when the +source is corrupt: a duplicate key after decoding (JSON permits them, +the table must not), a key or symbol that is not well-formed UTF-8, or a +round-trip mismatch. That last check re-parses the script's own output +with an independent unescaper and compares the full ordered sequence to +the source, entry for entry. It is what makes the vendored file +trustworthy, and it belongs here rather than in the acceptance suite: +the suite cannot see `abbreviations.json`, which is not shipped. +""" + +import json +import pathlib +import sys +import urllib.request + +REPO = "leanprover/vscode-lean4" +PATH = "lean4-unicode-input/src/abbreviations.json" +LICENSE = "Apache-2.0" +OUT = pathlib.Path(__file__).resolve().parent.parent / "builtin/runtime/lean_abbrev.lua" + +# Canonical, lossless, byte-deterministic. Rev 6 of the framing said the +# generator should abort on "a key containing a character the emitted Lua +# would have to escape"; that rule rejects the real table, where `\` is a +# key and `"` begins eleven of them. +SHORT = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"} + + +def die(msg): + print(f"regen-lean-abbrev: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def lua_escape(s): + """Escape one string for a Lua double-quoted literal. + + Operates on CHARACTERS, not bytes. Decomposing to UTF-8 bytes and + emitting each as `chr(byte)` produces a latin-1-shaped string that + `write_text(..., encoding="utf-8")` then re-encodes — every + non-ASCII symbol lands in the file double-encoded, and a round-trip + check that compares in-memory strings agrees with itself and misses + it entirely. Only control bytes, which are single-byte by + definition, become `\\ddd`. + """ + out = [] + for ch in s: + if ch in SHORT: + out.append(SHORT[ch]) + elif ord(ch) < 0x20 or ord(ch) == 0x7F: + out.append(f"\\{ord(ch):03d}") + else: + out.append(ch) + return "".join(out) + + +def lua_unescape(s): + """Independent reader for the round-trip check. + + Deliberately not the inverse of `lua_escape` sharing its table: a + check that reuses the encoder's own assumptions cannot detect that + those assumptions are wrong. + """ + out = bytearray() + i = 0 + raw = s.encode("utf-8") + while i < len(raw): + b = raw[i] + if b != ord("\\"): + out.append(b) + i += 1 + continue + i += 1 + if i >= len(raw): + die("round-trip: trailing backslash in emitted string") + nxt = chr(raw[i]) + if nxt in ("\\", '"'): + out.append(ord(nxt)) + i += 1 + elif nxt in ("n", "r", "t"): + out.append({"n": 10, "r": 13, "t": 9}[nxt]) + i += 1 + elif nxt.isdigit(): + digits = "" + while i < len(raw) and chr(raw[i]).isdigit() and len(digits) < 3: + digits += chr(raw[i]) + i += 1 + out.append(int(digits)) + else: + die(f"round-trip: unknown escape \\{nxt} in emitted string") + return out.decode("utf-8") + + +def main(): + if len(sys.argv) != 2: + die(f"usage: {sys.argv[0]} ") + commit = sys.argv[1] + url = f"https://raw.githubusercontent.com/{REPO}/{commit}/{PATH}" + + with urllib.request.urlopen(url, timeout=60) as resp: + raw = resp.read() + + try: + raw.decode("utf-8") + except UnicodeDecodeError as e: + die(f"source is not well-formed UTF-8: {e}") + + # `object_pairs_hook` keeps declaration order AND exposes duplicate + # keys, which a plain dict would silently collapse. + pairs = json.loads(raw, object_pairs_hook=lambda kv: kv) + + seen = {} + for i, (key, symbol) in enumerate(pairs): + if key in seen: + die(f"duplicate key {key!r} at entries {seen[key]} and {i}") + seen[key] = i + for label, s in (("key", key), ("symbol", symbol)): + if not isinstance(s, str): + die(f"{label} at entry {i} is not a string: {s!r}") + try: + s.encode("utf-8") + except UnicodeEncodeError as e: + die(f"{label} at entry {i} is not well-formed UTF-8: {e}") + + cursor = sum(1 for _, v in pairs if "$CURSOR" in v) + for i, (key, symbol) in enumerate(pairs): + if symbol.count("$CURSOR") > 1: + die(f"symbol for {key!r} at entry {i} has more than one $CURSOR") + + body = "".join( + f' {{ "{lua_escape(k)}", "{lua_escape(v)}" }},\n' for k, v in pairs + ) + text = HEADER.format( + repo=REPO, + path=PATH, + commit=commit, + license=LICENSE, + count=len(pairs), + cursor=cursor, + bytes=len(raw), + script=pathlib.Path(sys.argv[0]).name, + ) + "pmacs.lean_abbrev = {\n" + body + "}\n" + + # Round-trip against the BYTES ON DISK, not the string in memory. + # The file is staged beside its destination, re-read, parsed, and + # only renamed into place once it compares equal entry for entry. A + # check that compares in-memory strings cannot see an encoding + # applied by the write itself, which is exactly how a + # double-encoding bug survived the first version of this script. + staged = OUT.with_suffix(".lua.staged") + staged.write_text(text, encoding="utf-8") + on_disk = staged.read_bytes().decode("utf-8") + + got = [] + # `str.splitlines()` is WRONG here: it also splits on U+2028, U+2029, + # U+0085 and the vertical-tab family, and 53 symbols in the real + # table contain one of those literally. It silently loses entries and + # the round-trip then reports a count mismatch that is the checker's + # bug, not the emit's. The emitted file's line structure is defined + # by the LF we write, and nothing else. + for line in on_disk.split("\n"): + line = line.strip() + if not line.startswith('{ "') or not line.endswith("},"): + continue + inner = line[1:-2].strip() + if not (inner.startswith('"') and inner.endswith('"')): + die(f"round-trip: unparsable emitted line: {line!r}") + fields, buf, esc, depth = [], [], False, 0 + for ch in inner: + if esc: + buf.append(ch) + esc = False + elif ch == "\\": + buf.append(ch) + esc = True + elif ch == '"': + depth += 1 + if depth % 2 == 0: + fields.append("".join(buf)) + buf = [] + elif depth % 2 == 1: + buf.append(ch) + if len(fields) != 2: + die(f"round-trip: expected 2 fields, got {len(fields)}: {line!r}") + got.append((lua_unescape(fields[0]), lua_unescape(fields[1]))) + + def fail(msg): + staged.unlink(missing_ok=True) + die(msg) + + if len(got) != len(pairs): + fail(f"round-trip: emitted {len(got)} entries, source has {len(pairs)}") + for i, (want, have) in enumerate(zip(pairs, got)): + if tuple(want) != have: + fail(f"round-trip: entry {i} differs: source {want!r} vs emitted {have!r}") + + staged.replace(OUT) + print( + f"wrote {OUT} — {len(pairs)} entries from {REPO}@{commit} " + f"({len(raw)} source bytes, {OUT.stat().st_size} emitted bytes), " + "round-trip verified against the bytes on disk" + ) + + +HEADER = """\ +-- lean_abbrev.lua --- VENDORED DATA. Do not edit by hand. +-- +-- The Lean 4 abbreviation table, generated from: +-- +-- repo: https://github.com/{repo} +-- path: {path} +-- commit: {commit} +-- license: {license} +-- entries: {count} ({cursor} carry $CURSOR) +-- source: {bytes} bytes +-- +-- Regenerate with: +-- +-- scripts/{script} {commit} +-- +-- An ORDERED SEQUENCE, not a map: upstream resolves equal-length ties +-- by source declaration order (101 prefixes depend on it), and a +-- `pairs`-iterated Lua map cannot express that. The file's own line +-- order is the audit trail. Consumers must not reorder it. +-- +-- Not fetched at runtime and not a package dependency: the input method +-- has to work offline and on first launch. Upkeep is a documented +-- manual process — see docs/lean4-mode-framing.md Q#LN11. + +pmacs = pmacs or {{}} + +""" + +if __name__ == "__main__": + main() 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/buffer.rs b/src/buffer.rs index 7f048d5..a9c01e9 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -504,6 +504,67 @@ impl Buffer { self.read_only = read_only; } + /// Replace a generated buffer's entire contents on behalf of its owner, + /// and leave it genuinely immutable. + /// + /// This is the **owner-authorized update path** that genuine + /// immutability for generated buffers requires. A snapshot, panel or + /// `*compilation*` buffer must reject ordinary edits, **undo, redo**, + /// and remote CRDT imports alike — and only [`read_only`] does that. + /// An edit intercept is not enough: it guards the dispatch/edit path + /// only, while [`Buffer::undo`] reaches the rope through + /// `ensure_writable` without ever consulting the intercept chain. A + /// buffer protected by an intercept alone can therefore be emptied by + /// `C-/`, by `M-x buffer.undo`, or by the menu — the command is + /// reachable even where the chords are rebound to no-ops. + /// + /// But `read_only` also blocks the owner's own refresh, which is the + /// operation such buffers exist for. So the owner needs exactly one + /// door, and this is it: lift the flag, replace the contents skipping + /// intercepts, **discard the resulting history**, re-assert the flag. + /// + /// Discarding history is not tidiness. Without it every refresh pushes + /// undo entries holding full rope clones that nothing can ever pop — + /// `read_only` guarantees they are unreachable — so a periodically + /// refreshed buffer would grow without bound. In CRDT mode the same + /// retention lives in loro's `UndoManager`, so both are cleared. + /// + /// # The returned edit must be fanned out + /// + /// One whole-buffer [`EditOp::Replace`] is applied, and its [`Edit`] + /// is returned rather than swallowed, because a rope write is only + /// half of an edit. Callers **must** route the result through their + /// normal edit-notification path (for the Lua surface, + /// `notify_buffer_edit_to_windows`). A window already displaying the + /// buffer keeps a stale `TextView` line cache otherwise, and the next + /// paint indexes the new rope with old ranges; and in CRDT mode the + /// op never reaches replica mirrors, so their optimistic edits are + /// generated against content the owner has already replaced. + /// + /// [`read_only`]: Self::set_read_only + pub fn set_generated_contents(&mut self, bytes: &[u8]) -> Result { + self.read_only = false; + let result = self.apply_edit_skip_intercepts(EditOp::Replace { + range: Range::new(0, self.len()), + bytes, + }); + // Cleared even on failure: a partial replace must not leave a + // half-applied edit reachable through an undo the owner cannot see. + self.clear_history(); + self.read_only = true; + result + } + + /// Drop undo and redo history in whichever mode this buffer is in. + fn clear_history(&mut self) { + self.undo.clear(); + self.redo.clear(); + #[cfg(feature = "crdt")] + if let Some(crdt) = self.crdt.as_ref() { + crdt.clear_undo_history(); + } + } + fn ensure_writable(&self) -> Result<(), BufferError> { if self.read_only { Err(BufferError::ReadOnly { @@ -1955,6 +2016,99 @@ mod tests { } ); + /// The whole point of the primitive: after an owner write the buffer + /// is immutable, and `undo` — which never consults the intercept + /// chain — cannot reach back past it. + #[test] + fn set_generated_contents_writes_then_locks_and_leaves_nothing_to_undo() { + let mut buf = Buffer::new(BufferId::next(), "*generated*"); + buf.set_generated_contents(b"first render").expect("write"); + + assert_eq!(buf.len(), 12); + assert!(buf.is_read_only(), "the buffer ends immutable"); + assert!( + matches!(buf.undo(), Err(BufferError::ReadOnly { .. })), + "undo must be refused at the rope, not merely at dispatch" + ); + assert!(matches!(buf.redo(), Err(BufferError::ReadOnly { .. }))); + + // Even with the lock lifted there is no history to replay — the + // protection does not depend on the flag alone. + buf.set_read_only(false); + assert!(matches!(buf.undo(), Err(BufferError::NothingToUndo))); + assert!(matches!(buf.redo(), Err(BufferError::NothingToRedo))); + } + + /// Refreshing repeatedly must not accumulate unreachable history. + /// Each render would otherwise push entries holding full rope clones + /// that `read_only` guarantees nothing can ever pop. + #[test] + fn repeated_generated_writes_do_not_accumulate_history() { + let mut buf = Buffer::new(BufferId::next(), "*generated*"); + for i in 0..10 { + buf.set_generated_contents(format!("render {i}").as_bytes()) + .expect("write"); + } + let mut bytes = vec![0u8; buf.len() as usize]; + buf.snapshot_rope().slice(0, buf.len(), &mut bytes); + assert_eq!(String::from_utf8(bytes).expect("utf8"), "render 9"); + + buf.set_read_only(false); + assert!( + matches!(buf.undo(), Err(BufferError::NothingToUndo)), + "ten renders must leave an empty undo stack, not ten entries" + ); + } + + /// Review round 3, P2. In CRDT mode the v0.1 stacks are bypassed + /// entirely, so clearing them proves nothing: the history the + /// primitive promises to discard lives in loro's `UndoManager`. + /// The lock is lifted deliberately — `read_only` stops the replay, + /// but the contract is that there is nothing left to replay. + #[cfg(feature = "crdt")] + #[test] + fn generated_writes_accumulate_no_crdt_history_either() { + let mut buf = + Buffer::new_with_crdt(BufferId::next(), "*generated*", 1).expect("crdt construction"); + for i in 0..10 { + buf.set_generated_contents(format!("render {i}").as_bytes()) + .expect("write"); + } + assert_eq!(rope_string(&buf), "render 9"); + assert!( + !buf.crdt_state().expect("crdt-backed").can_undo(), + "the UndoManager must have nothing recorded" + ); + + buf.set_read_only(false); + assert!( + matches!(buf.undo(), Err(BufferError::NothingToUndo)), + "CRDT-mode undo must find no history either" + ); + } + + /// An ordinary edit is still refused after a generated write, so the + /// primitive does not quietly leave the buffer writable. + #[test] + fn set_generated_contents_still_refuses_ordinary_edits() { + let mut buf = Buffer::new(BufferId::next(), "*generated*"); + buf.set_generated_contents(b"content").expect("write"); + assert!(matches!( + buf.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"x" + }), + Err(BufferError::ReadOnly { .. }) + )); + assert!(matches!( + buf.apply_edit_skip_intercepts(EditOp::Insert { + pos: 0, + bytes: b"x" + }), + Err(BufferError::ReadOnly { .. }) + )); + } + #[cfg(feature = "crdt")] #[test] fn read_only_rejects_remote_crdt_before_import_and_allows_empty_bootstrap() { diff --git a/src/crdt.rs b/src/crdt.rs index 8cef9e3..ca68d1a 100644 --- a/src/crdt.rs +++ b/src/crdt.rs @@ -486,6 +486,26 @@ impl CrdtState { pub fn record_checkpoint(&self) -> LoroResult<()> { self.undo.borrow_mut().record_new_checkpoint() } + + /// Discard the bound peer's undo and redo history, keeping the + /// document itself untouched. + /// + /// Loro's `UndoManager` exposes no `clear`, but it does not need + /// one: a manager records only what happens **after** it is + /// constructed. [`Self::from_bytes`] already relies on exactly + /// that property to keep the seed insert out of undo. Replacing + /// the manager with a fresh one bound to the same doc therefore + /// leaves nothing to undo, and drops the old manager's retained + /// stacks with it. + /// + /// Used by [`crate::buffer::Buffer::set_generated_contents`], whose + /// contract is that a generated buffer accumulates no history + /// across refreshes. Marking the buffer read-only would stop the + /// history being *replayed*, but not being *retained* — a panel + /// refreshed on a timer would grow without bound. + pub fn clear_undo_history(&self) { + *self.undo.borrow_mut() = Self::create_undo_manager(&self.doc); + } } /// T M10.3: map a [`crate::protocol::FrontendId`] to the loro `PeerID` diff --git a/src/daemon.rs b/src/daemon.rs index 9ac8256..b665d07 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -67,9 +67,9 @@ use crate::lockfile::{self, LockError, LockHandle}; use crate::presence::{PresenceSnapshot, SessionRegistry}; use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm}; use crate::protocol::{ - AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, - InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, - MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PROTOCOL_VERSION, PointerKind, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, + InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, + InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PointerKind, SelectionSnapshot, SessionBootstrapRequest, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; @@ -712,7 +712,7 @@ fn per_attach_thread( // mismatch path without changing the default. let instance_caps_for_hello = instance_capabilities_with_env_override(); let hello = Hello { - protocol_version: PROTOCOL_VERSION, + protocol_version: ADVERTISED_PROTOCOL_VERSION, assigned_frontend_id: frontend_id, instance_identity: daemon_state.build_identity(), instance_capabilities: instance_caps_for_hello.clone(), @@ -739,7 +739,7 @@ fn per_attach_thread( let _ = write_message( &mut stream, &InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { - server: PROTOCOL_VERSION, + server: ADVERTISED_PROTOCOL_VERSION, client: req.protocol_version, }), ); @@ -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; } } } @@ -1632,18 +1627,97 @@ fn open_initial_target( // create and select a side window, and bootstrap must reassert the // requested buffer in a document window rather than overwriting a // panel merely because it became `view.active`. - let (origin_window, buffer_id, fire) = { + let (origin_window, resolved) = { let mut core = editor.core.borrow_mut(); core.active_frontend = frontend_id; let origin_window = core .primary_document_window(frontend_id) .ok_or_else(|| "attaching frontend has no document window".to_string())?; - let (buffer_id, fire) = core.resolve_target_buffer(&path)?; + let resolved = core.resolve_target_buffer(&path)?; + (origin_window, resolved) + }; + + // Journey Stage 1a (Q#JR6/Q#JR9): a DIRECTORY installs nothing. + // + // Nothing can be installed, because the listing that satisfies a + // directory open is asynchronous and this block is synchronous — the + // frontend is blocked on `InitialTargetResult` and will not create + // its window until it arrives, so there is no tick in which a + // listing could settle. The reply therefore names the buffer the + // fresh view's document window ALREADY holds, which is a valid, + // ready session; the listing replaces it a tick or more later. + // + // That buffer is NOT necessarily `*scratch*`: `build_fresh_frontend_view` + // clones LOCAL's primary document buffer. If LOCAL holds a real + // document, this session briefly displays and snapshots it. Accepted + // and documented rather than papered over with a placeholder buffer, + // which would need reaping and would be fought by the reassert below. + // + // `publish_to_replicas` is false for the same reason an `AfterSwitch` + // dedup sets it false: this buffer is pre-existing and already + // published, not freshly loaded here. + let (buffer_id, fire) = match resolved { + crate::editor_core::ResolvedTarget::Directory { path } => { + let dest = editor + .capture_directory_destination(frontend_id, origin_window) + .ok_or_else(|| format!("cannot open {}: no document window", path.display()))?; + editor.dispatch_directory_open(&path, dest); + editor.reconcile_panel_layout(frontend_id); + + // The reply must name what the window ACTUALLY holds now, not + // what it held before the dispatch. + // + // The chain runs synchronously. dired's handler defers (it + // spawns a coroutine for the listing), but a user's resolver + // is under no such obligation: a handler that opens something + // synchronously -- through `commit_to`, which is exactly the + // supported way to do it -- has already replaced this + // window's buffer by the time we get here. Reporting the + // captured id would then send the snapshot of one buffer and + // the identity of another, and the frontend would render a + // document nobody asked for. + // + // Re-reading also covers the case a hook closed the window, + // which is why this rehomes through `non_side_target` exactly + // as the file arm's reassert does rather than returning early + // and skipping that check. + let mut core = editor.core.borrow_mut(); + core.active_frontend = frontend_id; + let destination = if core + .views + .get(&frontend_id) + .is_some_and(|view| view.layout.iter_ids().contains(&origin_window)) + { + origin_window + } else { + core.non_side_target(frontend_id) + .map_err(|error| format!("cannot reselect {}: {error}", path.display()))? + }; + core.focus_window(frontend_id, destination); + let buffer_id = core + .windows + .get(&destination) + .map(|window| window.buffer_id) + .ok_or_else(|| format!("cannot reselect {}: window died", path.display()))?; + return Ok(OpenedInitialTarget { + buffer_id, + // False whether or not the chain replaced the buffer: an + // untouched destination is pre-existing and already + // published, and a buffer a synchronous handler installed + // went through the ordinary display path, which publishes + // on its own terms. + publish_to_replicas: false, + }); + } + crate::editor_core::ResolvedTarget::Buffer { id, fire } => (id, fire), + }; + + { + let mut core = editor.core.borrow_mut(); core.install_buffer_in_window(origin_window, buffer_id) .map_err(|error| format!("cannot select {}: {error}", path.display()))?; core.focus_window(frontend_id, origin_window); - (origin_window, buffer_id, fire) - }; + } match fire { crate::editor_core::HookKind::AfterLoad => { @@ -2038,12 +2112,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 +2135,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 +2204,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 +2446,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 +2586,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 +3025,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 +3114,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( @@ -3229,12 +3392,27 @@ fn apply_event( (grid terminals resize through the Stage 2 layout path)" ); } + FrontendEvent::FrontendCellGeometry { .. } + | FrontendEvent::PanelResizeRows { .. } + | FrontendEvent::PanelPointer { .. } => { + // Bottom panel Stage 2 — panel declarations belong to + // negotiated panel-capable semantic sessions and are routed + // by the authenticated source in `handle_dispatcher_event`. + // A grid session has no panel band at all, so one arriving + // here is a protocol violation; drop it rather than letting + // a payload-trusted id reach a view. + eprintln!( + "pmacs daemon: panel declaration from a grid session; dropping \ + (grid sessions negotiate no panel band)" + ); + } } } #[cfg(test)] mod tests { use super::*; + use crate::protocol::PROTOCOL_VERSION; #[test] fn daemon_state_starts_frontend_id_at_two() { @@ -3399,6 +3577,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 @@ -3790,6 +4058,143 @@ mod tests { ); } + /// Arc 8 Stage 4b acceptance 45f: the Lean abbreviation expander + /// works on the OPTIMISTIC producer, not only on `dispatch_key`. + /// + /// This is the path most users take and the one no other Stage 4b + /// test covers. `classify_key` (`src/optimistic.rs`) 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 + /// `\alpha` arrives here as six source-peer optimistic inserts, + /// while the expansion is a single daemon-peer replace spanning all + /// six. That asymmetry is the accepted undo degradation of Q#LN21; + /// what this pins is that the expansion happens at all. + /// + /// It lives in `--lib` deliberately: the gate list runs + /// `--features crdt` only for `cargo test --lib`, so a crdt-gated + /// INTEGRATION test would be dark in CI and dark in the gates both. + /// + /// The source frontend needs a REGISTERED WINDOW on the edited + /// buffer or nothing is armed at all — `handle_remote_crdt_op` + /// arms the record only when the source's active window displays + /// the buffer, so a source with no view fails closed and silently. + /// A version of this test without the view below passed six + /// fan-outs with a nil record and proved nothing. + #[cfg(feature = "crdt")] + #[test] + fn the_optimistic_producer_also_expands_a_lean_abbreviation() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + use crate::window::{FrontendView, Layout, Window, WindowId}; + + let dir = std::env::temp_dir().join(format!("pmacs-lean-opt-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join("a.lean"); + std::fs::write(&path, "").expect("write fixture"); + + let source = FrontendId(77); + let mut editor = EditorState::new(); + editor + .lua_host + .eval(Some("test"), "pmacs.lsp.config = {}") + .expect("clear lsp config"); + editor + .lua_host + .eval( + Some("test-open"), + &format!( + "pmacs.buffer.find_or_open({:?}); pmacs.editor.goto_byte(0)", + path.display().to_string() + ), + ) + .expect("open the lean fixture"); + + let buffer_id = editor.core.borrow().active_window().buffer_id; + { + let mut core = editor.core.borrow_mut(); + let mut reg = core.registry.borrow_mut(); + reg.get_mut(buffer_id) + .expect("active buffer") + .upgrade_to_crdt(2) + .expect("upgrade to crdt"); + drop(reg); + + // The replica's own window on the shared buffer. + let text_view = { + let registry = core.registry.clone(); + let reg = registry.borrow(); + crate::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let win_id = WindowId::next(); + core.windows + .insert(win_id, Window::new(win_id, buffer_id, text_view)); + core.register_frontend_view( + source, + FrontendView { + layout: Layout::single(win_id), + active: win_id, + fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + } + + let snapshot_bytes = { + let core = editor.core.borrow(); + let reg = core.registry.borrow(); + reg.get(buffer_id) + .expect("buffer") + .crdt_state() + .expect("crdt-backed") + .export_snapshot() + .expect("export snapshot") + }; + let peer = loro::LoroDoc::new(); + peer.set_peer_id(77).expect("set peer id"); + peer.import(&snapshot_bytes).expect("import snapshot"); + + // One op per keystroke, exactly as the attach loop's + // optimistic-apply branch produces them. + for (i, ch) in "\\alpha".chars().enumerate() { + let v_before = peer.oplog_vv(); + peer.get_text("body") + .insert(i, &ch.to_string()) + .expect("peer insert"); + let op_bytes = peer + .export(loro::ExportMode::updates(&v_before)) + .expect("export op"); + super::handle_remote_crdt_op( + &mut editor, + source, + buffer_id, + crate::rope::CrdtOp { + peer_id: 77, + bytes: op_bytes, + }, + ); + } + + let text = match editor + .lua_host + .eval( + Some("test-readback"), + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ) + .expect("read buffer text") + { + mlua::Value::String(s) => String::from_utf8_lossy(&s.as_bytes()).into_owned(), + other => panic!("expected buffer text, got {other:?}"), + }; + assert_eq!( + text, "α", + "the abbreviation expanded on the optimistic path — the \ + record the expander reads is armed by handle_remote_crdt_op, \ + not only by dispatch_key" + ); + } + /// Q#AI9 (PR #109 round 1): the optimistic-apply arm clears an /// EMPTY anchor on the source window — the GPU always takes this /// path, and the TUI attach mirror tracks no selection state, so @@ -4385,7 +4790,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 +4828,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 +4989,552 @@ 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" + ); + } + + /// **N2** (Journey Stage 1a) — a DIRECTORY initial target reaches + /// readiness instead of failing. + /// + /// This deliberately supersedes the directory half of the GPU + /// initial-target framing's Q#GT6 and its acceptance 10, which + /// required `IsADirectory` to fail before window creation. + /// Permission-denied and every other pre-readiness failure keep that + /// contract. + #[test] + fn initial_target_directory_reaches_ready() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("alpha.txt"), b"alpha\n").expect("write"); + + let mut editor = EditorState::new(); + editor + .lua_host + .lua() + .load("pmacs.lsp.config = {}") + .exec() + .expect("wipe lsp config"); + let fid = FrontendId(131); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + let opened = open_initial_target( + &mut editor, + fid, + InitialTarget { + path: dir.path().as_os_str().as_bytes().to_vec(), + cwd: dir.path().as_os_str().as_bytes().to_vec(), + }, + ) + .expect("a directory target must reach readiness, not fail"); + + // The reply names a live buffer in a live document window: a + // valid, ready session. The listing arrives later, asynchronously. + let core = editor.core.borrow(); + assert!( + core.registry.borrow().contains(opened.buffer_id), + "the reported buffer must exist so its snapshot can be sent" + ); + let active = core.views[&fid].active; + assert_eq!( + core.windows[&active].buffer_id, opened.buffer_id, + "the reported buffer is the one the document window shows" + ); + } + + /// **N5** — the bootstrap buffer is not necessarily `*scratch*`. + /// + /// `build_fresh_frontend_view` clones LOCAL's PRIMARY DOCUMENT + /// buffer, so when LOCAL holds a real document the fresh session + /// briefly displays and snapshots it. Q#JR9 accepts that rather than + /// introducing a placeholder; this observes it instead of assuming. + #[test] + fn initial_target_directory_reports_a_non_scratch_primary() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + + let dir = tempfile::tempdir().expect("tempdir"); + let doc = dir.path().join("already-open.txt"); + std::fs::write(&doc, b"local document\n").expect("write"); + + // LOCAL holds a real document, not scratch. + let mut editor = EditorState::open(doc.clone()).expect("open"); + editor + .lua_host + .lua() + .load("pmacs.lsp.config = {}") + .exec() + .expect("wipe lsp config"); + let local_primary = editor + .core + .borrow() + .primary_document_buffer(FrontendId::LOCAL) + .expect("LOCAL always has a document window"); + + let fid = FrontendId(132); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + let opened = open_initial_target( + &mut editor, + fid, + InitialTarget { + path: dir.path().as_os_str().as_bytes().to_vec(), + cwd: dir.path().as_os_str().as_bytes().to_vec(), + }, + ) + .expect("a directory target must reach readiness"); + + assert_eq!( + opened.buffer_id, local_primary, + "the bootstrap reply names LOCAL's primary document buffer, \ + which is a real document here rather than *scratch*" + ); + } + + /// **N2b (rev 6)** — a resolver that claims SYNCHRONOUSLY is reported + /// correctly. + /// + /// The bug this pins: the arm captured the destination buffer id + /// *before* dispatching the chain and reported that. The chain runs + /// synchronously, so a handler that opens something immediately — + /// through `commit_to`, the supported way — had already replaced the + /// window's buffer, and the reply paired one buffer's snapshot with + /// another's identity. + /// + /// Falsified by reporting the captured id instead of re-reading. + #[test] + fn initial_target_directory_reports_what_a_synchronous_handler_installed() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + + let dir = tempfile::tempdir().expect("tempdir"); + + let mut editor = EditorState::new(); + editor + .lua_host + .lua() + .load( + "pmacs.lsp.config = {} + claimed = pmacs.buffer.create('*claimed*') + pmacs.path.set_directory_handler(function(path, dest) + pmacs.window.commit_to(dest, function() + pmacs.window.display(claimed, { select = true }) + end) + end)", + ) + .exec() + .expect("install a synchronous handler"); + + let fid = FrontendId(133); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + let opened = open_initial_target( + &mut editor, + fid, + InitialTarget { + path: dir.path().as_os_str().as_bytes().to_vec(), + cwd: dir.path().as_os_str().as_bytes().to_vec(), + }, + ) + .expect("a claimed directory target must reach readiness"); + + // Compare by NAME: the reported id must be the handler's buffer, + // and naming it is what makes the failure legible when it is not. + let core = editor.core.borrow(); + let reported_name = core + .registry + .borrow() + .get(opened.buffer_id) + .expect("the reported buffer exists") + .name() + .to_string(); + assert_eq!( + reported_name, "*claimed*", + "the reply must name what the handler installed, not the \ + buffer captured before the dispatch" + ); + let active = core.views[&fid].active; + assert_eq!( + core.windows[&active].buffer_id, opened.buffer_id, + "…and that buffer is what the window shows" + ); + } + + /// 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..935ee4c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -26,7 +26,6 @@ use unicode_width::UnicodeWidthStr; use crate::async_runtime::SharedAsyncRuntime; use crate::cell::{CellCoord, CellSize}; use crate::editor_core::EditorCore; -use crate::file_io::load_file; use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook}; use crate::key::{Chord, display_sequence}; use crate::keymap_stack::{Action, KeyDispatcher}; @@ -80,6 +79,109 @@ impl Drop for InteractiveCommandOriginGuard { } } +/// A frontend scope for **background** work — deliberately NOT +/// [`InteractiveCommandOrigin`] (Journey Stage 1a, Q#JR14e). +/// +/// An async continuation (a settled directory listing, and eventually +/// any other post-await window work) needs to act for the frontend that +/// *requested* it rather than whichever one happens to be ambient when +/// the worker finishes. Reusing the interactive origin for that would be +/// wrong twice over: +/// +/// 1. **It does not scope enough.** Only `acting_frontend` consults it, +/// so `pmacs.window.display` would be scoped while no-arg +/// `pmacs.window.buffer()` (which reads `active_buffer_id()` +/// directly) and `pmacs.editor.move_to_line` (which mutates the +/// core's ambient active window) stayed ambient — and those are +/// precisely the calls that capture and seat. +/// 2. **It is authenticated user-command authority.** It is what +/// distinguishes a user command's edit from a plugin's or the data +/// API's: the pre-edit unfold guard, `invoke_interactive`'s +/// command-boundary rotation, and the terminal surface's "requires an +/// interactive frontend context" checks all key off it. A background +/// listing must not acquire any of that. +/// +/// So this is a separate slot, resolved *ahead* of the interactive +/// origin, whose guard **also** swaps `EditorCore::active_frontend` — +/// which is what covers the core-ambient APIs `acting_frontend` never +/// sees. That swap is not a workaround: `pmacs.window.buffer()`'s no-arg +/// arm documents its own correctness as resting on "dispatch sets +/// `active_frontend` to the acting frontend before running a command", +/// and this restores that invariant for a continuation. +#[derive(Clone, Default)] +pub(crate) struct ScopedFrontend(Rc>>); + +impl ScopedFrontend { + /// The override in force, if any. + #[must_use] + pub(crate) fn current(&self) -> Option { + self.0.get() + } + + /// Enter a background frontend scope, also swapping the core's + /// ambient `active_frontend`. Both are restored on drop, on every + /// exit path including a raising callback. + pub(crate) fn enter( + &self, + core: &SharedCore, + commit_scope: &CommitScopeActive, + frontend_id: FrontendId, + ) -> ScopedFrontendGuard { + let previous = self.0.replace(Some(frontend_id)); + let previous_active = { + let mut core = core.borrow_mut(); + let was = core.active_frontend; + core.active_frontend = frontend_id; + was + }; + let previous_commit = commit_scope.0.replace(true); + ScopedFrontendGuard { + scope: self.clone(), + core: core.clone(), + previous, + previous_active, + commit_scope: commit_scope.clone(), + previous_commit, + } + } +} + +pub(crate) struct ScopedFrontendGuard { + scope: ScopedFrontend, + core: SharedCore, + previous: Option, + previous_active: FrontendId, + /// Cleared together with the scope, so an awaiting callback cannot + /// leave `await` refused after the commit ends (Q#JR14b). + commit_scope: CommitScopeActive, + previous_commit: bool, +} + +impl Drop for ScopedFrontendGuard { + fn drop(&mut self) { + self.scope.0.set(self.previous); + self.core.borrow_mut().active_frontend = self.previous_active; + self.commit_scope.0.set(self.previous_commit); + } +} + +/// Whether a `pmacs.window.commit_to` callback is currently running +/// (Journey Stage 1a, Q#JR14b). +/// +/// Read from Lua as `pmacs._async._in_commit_scope()`; `Handle:await` +/// refuses while it is set. Lives beside the scope guard so the two can +/// never disagree. +#[derive(Clone, Default)] +pub struct CommitScopeActive(Rc>); + +impl CommitScopeActive { + /// Whether a commit callback is on the stack. + #[must_use] + pub fn active(&self) -> bool { + self.0.get() + } +} + // --------------------------------------------------------------------------- // EditorState // --------------------------------------------------------------------------- @@ -261,6 +363,13 @@ impl EditorState { let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization"); let interactive_origin = InteractiveCommandOrigin::default(); lua_host.lua().set_app_data(interactive_origin.clone()); + // Q#JR14e/Q#JR14b: the background frontend scope and the + // commit-scope flag live only as Lua app data -- `commit_to` and + // `Handle:await` are the only readers, and both reach them that + // way. No `EditorState` field, so there is no second handle that + // could disagree with the one the guard restores. + lua_host.lua().set_app_data(ScopedFrontend::default()); + lua_host.lua().set_app_data(CommitScopeActive::default()); lua_host .attach_editor(&core) .expect("editor bindings + builtin chunks"); @@ -415,6 +524,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,18 +545,53 @@ 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"), include_str!("../builtin/runtime/pair.lua"), ) .expect("load pair builtin chunk"); + // Arc 8 Stage 4b: the Lean 4 Unicode input method. The vendored + // abbreviation table first — lean_input.lua reads it at chunk + // load to build its prefix and eager-key indexes. Both load + // after typed_edit.lua, which they register into. + // + // Load order does NOT decide whether abbreviation expansion or + // auto-pairing sees a keystroke first — the chain's priority + // does (50 vs 100), which is why Stage 4a exists. It matters + // only that the chain itself is already there. + lua_host + .eval( + Some("@pmacs/builtin/runtime/lean_abbrev.lua"), + include_str!("../builtin/runtime/lean_abbrev.lua"), + ) + .expect("load lean_abbrev builtin chunk"); + lua_host + .eval( + Some("@pmacs/builtin/runtime/lean_input.lua"), + include_str!("../builtin/runtime/lean_input.lua"), + ) + .expect("load lean_input builtin chunk"); lua_host .eval( Some("@pmacs/builtin/runtime/lsp.lua"), 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 @@ -743,35 +899,65 @@ impl EditorState { /// Construct an editor for a path. Empty buffer with `[new file]` /// status if the path does not exist; loaded contents otherwise. + /// + /// Journey Stage 1a (Q#JR1): this is a thin caller of + /// [`EditorCore::resolve_target_buffer`], not a second + /// implementation of it. That primitive documents itself as "one + /// primitive, so two path-normalization, dedup, and hook + /// transactions cannot drift apart" — and local startup, which had + /// hand-written the same three-arm shape, was not one of its callers + /// until now. + /// + /// Two things this caller still owns, and must keep owning: + /// + /// * **The window install.** `resolve_target_buffer` deliberately + /// does not touch windows, so the caller places the buffer. + /// Startup uses [`Self::replace_active_buffer`], which switches + /// the ACTIVE window — an `install_buffer_in_window` into some + /// other window would load the file and leave the user looking at + /// scratch (Q#JR3). + /// + /// It does **not** destroy the scratch buffer, despite what + /// `replace_active_buffer`'s own doc comment has long claimed: + /// that function only calls `switch_active_buffer`, which + /// reassigns the window's `buffer_id` and removes nothing. The + /// startup scratch survives in the registry, and did before this + /// stage too. Changing that is buffer-lifetime work with its own + /// consequences (what else may hold the id, what `C-x b` should + /// list) and is deliberately not smuggled in here. + /// * **Firing the hook outside the core borrow.** Listeners + /// re-enter `pmacs.editor.*`, which re-borrows the core + /// (Q#JR1a) — the same reason the daemon bootstrap and + /// `display_file` both fire theirs after their borrow blocks end. + /// + /// A directory resolves to [`ResolvedTarget::Directory`] and is + /// dispatched to the directory resolver chain rather than opened as + /// a buffer (Q#JR6); see [`Self::open_directory_target`]. + #[allow( + clippy::needless_pass_by_value, + reason = "stable public entry point mirroring `pmacs PATH` and \ + `run(Option)`; the body stopped consuming the \ + PathBuf when this became a `resolve_target_buffer` caller, \ + and churning the signature would touch every caller for no \ + behavioral gain" + )] pub fn open(path: PathBuf) -> io::Result { - let display_name = path.display().to_string(); - let state = Self::new(); + let mut state = Self::new(); + let resolved = state + .core + .borrow_mut() + .resolve_target_buffer(&path) + .map_err(io::Error::other)?; let mut fire_after_load = false; - match load_file(&path) { - Ok((bytes, meta)) => { - let new_id = state - .lua_host - .registry() - .borrow_mut() - .create_from_bytes(display_name, &bytes); - state.replace_active_buffer(new_id); - let mut core = state.core.borrow_mut(); - core.set_buffer_path(new_id, Some(path)); - core.set_buffer_meta(new_id, Some(meta)); - fire_after_load = true; - Ok(()) + match resolved { + crate::editor_core::ResolvedTarget::Buffer { id, fire } => { + state.replace_active_buffer(id); + fire_after_load = matches!(fire, crate::editor_core::HookKind::AfterLoad); } - Err(e) if e.kind() == io::ErrorKind::NotFound => { - let new_id = state.lua_host.registry().borrow_mut().create(display_name); - state.replace_active_buffer(new_id); - let mut core = state.core.borrow_mut(); - core.set_buffer_path(new_id, Some(path)); - core.status = "[new file]".into(); - Ok(()) + crate::editor_core::ResolvedTarget::Directory { path } => { + state.open_directory_target(&path); } - Err(e) => Err(e), - }?; - let mut state = state; + } if fire_after_load { // Fire the hook *after* the borrow on `core` is released // (block above ends). Listeners may legitimately re-enter @@ -783,9 +969,147 @@ impl EditorState { Ok(state) } - /// Switch the active window to `buffer_id`, dropping any old - /// scratch buffer if the active window's previous buffer has no - /// other windows referencing it. Returns silently on a stale id. + /// Capture the destination a directory open must commit to + /// (Q#JR14), or `None` when `frontend` has no document window. + /// + /// Synchronous by necessity: the listing settles a tick or more + /// later, and by then the ambient frontend, selected window, and + /// active buffer may all name something else. + pub(crate) fn capture_directory_destination( + &self, + frontend: crate::protocol::FrontendId, + window: crate::window::WindowId, + ) -> Option { + let core = self.core.borrow(); + let buffer = core.windows.get(&window)?.buffer_id; + Some(crate::editor_core::DirectoryDestination { + frontend, + window, + buffer, + }) + } + + /// Local-startup directory open (Q#JR6): resolve the destination + /// from `LOCAL`'s document window and dispatch the resolver chain. + /// + /// Public because it is the whole of what `pmacs DIRECTORY` does + /// after resolution — acceptance drives this rather than + /// `resolve_target_buffer`, so a directory arm with no production + /// caller cannot pass. + pub fn open_directory_target(&mut self, path: &std::path::Path) { + // Canonicalize here as well as in the resolver arm. The two are + // not redundant: this is a public "open this directory" seam, so + // a caller that did not come through `resolve_target_buffer` + // must still hand the chain a canonical path (Q#JR8) --- and + // normalization is idempotent, so the startup path pays nothing. + let path = crate::editor_core::normalize_buffer_path(path.to_path_buf()); + let path = path.as_path(); + let window = self + .core + .borrow() + .primary_document_window(crate::protocol::FrontendId::LOCAL); + let dest = window.and_then(|window| { + self.capture_directory_destination(crate::protocol::FrontendId::LOCAL, window) + }); + let Some(dest) = dest else { + self.core.borrow_mut().status = + format!("cannot open {}: no document window", path.display()); + return; + }; + self.dispatch_directory_open(path, dest); + } + + /// Run the directory resolver chain for `path`, then its fallback + /// (Journey Stage 1a, Q#JR7/Q#JR15). + /// + /// Order is user chain first, builtin default second — see + /// `install_path_module` for why that cannot be expressed as two + /// hook subscriptions. + /// + /// **A raising listener stops the chain AND suppresses the + /// fallback.** `run_short_circuit` returns `proceed = false` both + /// for a literal `false` (a claim) and for a raise, so `proceed` + /// alone already suppresses correctly; `errors` is what distinguishes + /// them, and it decides only whether to *report*. Running the + /// fallback after a user's resolver crashed would open dired on a + /// directory that resolver may have been part-way through handling, + /// so a crash is treated as a claim that failed — reported through + /// the `*errors*` buffer (which `run_hook` already does) and the + /// status line (which it does not), and visible in both. + pub(crate) fn dispatch_directory_open( + &mut self, + path: &std::path::Path, + dest: crate::editor_core::DirectoryDestination, + ) { + let display = path.display().to_string(); + let args = { + let lua = self.lua_host.lua(); + let destination = + match lua.create_userdata(crate::lua_bindings::DirectoryDestinationLua(dest)) { + Ok(userdata) => mlua::Value::UserData(userdata), + Err(error) => { + self.core.borrow_mut().status = format!("cannot open {display}: {error}"); + return; + } + }; + let path_value = match lua.create_string(display.as_bytes()) { + Ok(string) => mlua::Value::String(string), + Err(error) => { + self.core.borrow_mut().status = format!("cannot open {display}: {error}"); + return; + } + }; + mlua::MultiValue::from_vec(vec![path_value, destination]) + }; + + match self.lua_host.run_hook("path.open-directory", args.clone()) { + // A listener raised. `run_hook` has already appended the + // record to *errors*; add the status line, and do NOT fall + // back (Q#JR15). + Some(outcome) if !outcome.errors.is_empty() => { + self.core.borrow_mut().status = + format!("cannot open {display}: a path.open-directory listener failed"); + return; + } + // Claimed: a listener returned false. + Some(outcome) if !outcome.proceed => return, + // Declined, or no listeners at all. + _ => {} + } + + let handler = { + let lua = self.lua_host.lua(); + lua.globals() + .get::("pmacs") + .and_then(|pmacs| pmacs.get::("path")) + .and_then(|path| path.get::("directory_handler")) + .unwrap_or(mlua::Value::Nil) + }; + let mlua::Value::Function(handler) = handler else { + // The slot is clear: nothing surfaces directories. The + // session started fine and simply has nothing to show for + // the argument, so this is a status message and NOT a + // startup failure (Q#JR10). + self.core.borrow_mut().status = format!("no handler for directory {display}"); + return; + }; + if let Err(error) = handler.call::<()>(args) { + self.core.borrow_mut().status = format!("cannot open {display}: {error}"); + } + } + + /// Switch the active window to `buffer_id`. Returns silently on a + /// stale id. + /// + /// **Corrected (Journey Stage 1a).** This comment previously claimed + /// it dropped "any old scratch buffer if the active window's + /// previous buffer has no other windows referencing it". It never + /// did: the body is one `switch_active_buffer` call, which reassigns + /// `aw.buffer_id` and removes nothing from the registry. The claim + /// was load-bearing enough that a framing decision (Q#JR3) and an + /// acceptance pin were written against it before anyone checked the + /// body. Removing the stale scratch may well be worth doing; it is + /// separate work, and this comment no longer promises it. fn replace_active_buffer(&self, buffer_id: crate::buffer::BufferId) { let mut core = self.core.borrow_mut(); let _ = core.switch_active_buffer(buffer_id); @@ -989,19 +1313,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 +1450,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 +1722,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 +1860,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 +3550,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 +3823,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 +3835,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 +3887,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 +4862,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/editor_core.rs b/src/editor_core.rs index 89432cc..7fd90c6 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -94,6 +94,77 @@ pub enum HookKind { None, } +/// What a path resolved to (Journey Stage 1a, Q#JR5). +/// +/// A sum type rather than `(Option, HookKind)`: that pair +/// admits three states that cannot occur (`None` with `AfterLoad`, +/// `Some` with a directory, …), and every caller would have to +/// re-establish by hand which combinations are real. +/// +/// **Do not confuse [`HookKind`] here with [`crate::hook::HookKind`]** — +/// unrelated types sharing a name. This one says *which* lifecycle hook +/// to fire; that one says how a hook's callbacks fan out. Every site +/// touching both writes them path-qualified (Q#JR5b). +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResolvedTarget { + /// A file buffer, plus the hook the caller must fire with the + /// destination window active. + Buffer { + /// The resolved buffer. + id: BufferId, + /// Which lifecycle hook this resolution owes. + fire: HookKind, + }, + /// A directory. No buffer is created (Q#JR6) — the directory + /// resolver chain decides what surfaces it, and dired builds its own + /// buffer through `claim_handle` rather than adopting one. + /// + /// `path` is **normalized** — absolute, tilde-expanded, lexically + /// clean. This is not free and must not be assumed: normalization + /// otherwise happens inside [`Self::set_buffer_path`], which never + /// runs on this arm, so a caller resolving `"."` would keep `"."` + /// (Q#JR8). A handler keying state by path needs the canonical form. + Directory { + /// The normalized directory path. + path: PathBuf, + }, +} + +/// Where a directory open was requested, captured **synchronously** at +/// resolve time (Journey Stage 1a, Q#JR14). +/// +/// The listing that satisfies a directory open is asynchronous +/// (`pmacs.fs.read_dir` is worker-dispatched and must be awaited), so the +/// code that finally builds and displays the listing runs a tick or more +/// later — outside interactive dispatch, where `pmacs.window.*` acts on +/// the *ambient* frontend by documented design (`builtin/runtime/dired.lua`). +/// Without a captured destination, a second frontend dispatching in the +/// meantime silently redirects the listing. +/// +/// All three fields are load-bearing: +/// +/// * `frontend` — the scope the commit must run in. +/// * `window` — the exact destination; the ambient selected window is +/// not it. +/// * `buffer` — what that window held at capture time, so **stale +/// intent loses to the user** (Q#JR14c). A user who replaced the +/// buffer while the listing was in flight is newer information than +/// the launch argument, and must not be overwritten. +/// +/// Exposed to Lua only as nonconstructible userdata (Q#JR14d): as a +/// table, the *same* value is handed to every resolver listener in turn, +/// so one could mutate it and then decline — redirecting later listeners +/// — and any Lua could fabricate a plausible triple. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DirectoryDestination { + /// Frontend that requested the directory. + pub frontend: FrontendId, + /// Window the listing must land in. + pub window: WindowId, + /// Buffer that window held at capture time (stale-intent check). + pub buffer: BufferId, +} + /// A `display_buffer` request (Q#BP3). /// /// `height` and `dedicated` are deliberately option-valued at the policy @@ -880,18 +951,41 @@ impl EditorCore { /// One primitive, so two path-normalization, dedup, and hook /// transactions cannot drift apart. /// + /// A **directory** resolves to [`ResolvedTarget::Directory`] before + /// any load is attempted (Journey Stage 1a, Q#JR5/Q#JR6). Without + /// that arm the load runs and fails: `File::open` succeeds on a + /// directory and `read_to_end` then returns `EISDIR`, which is not + /// `NotFound`, so the `[new file]` arm never fires and every caller + /// saw a hard error — the reason `pmacs .` exited 1 and the golden + /// journey was graded broken at step 3 (`COHERENCE.md` §2). + /// /// # Errors /// Any load failure other than `NotFound`. - pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<(BufferId, HookKind), String> { + pub fn resolve_target_buffer(&mut self, path: &Path) -> Result { + // Ahead of the load, deliberately: see the EISDIR note above. + if path.is_dir() { + return Ok(ResolvedTarget::Directory { + path: normalize_buffer_path(path.to_path_buf()), + }); + } match self.get_or_load_buffer(path) { - Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)), - Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)), + Ok((id, true)) => Ok(ResolvedTarget::Buffer { + id, + fire: HookKind::AfterLoad, + }), + Ok((id, false)) => Ok(ResolvedTarget::Buffer { + id, + fire: HookKind::AfterSwitch, + }), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { let display_path = path.display().to_string(); let buffer_id = self.registry.borrow_mut().create(display_path); self.set_buffer_path(buffer_id, Some(path.to_path_buf())); "[new file]".clone_into(&mut self.status); - Ok((buffer_id, HookKind::None)) + Ok(ResolvedTarget::Buffer { + id: buffer_id, + fire: HookKind::None, + }) } Err(error) => Err(format!("cannot open {}: {error}", path.display())), } @@ -3472,16 +3566,56 @@ impl EditorCore { fid: FrontendId, existing: Option, window: Option, + ) -> Result { + self.probe_display_target_inner(fid, existing, window) + } + + /// Whether `window` will accept `incoming` as its buffer — the one + /// dedication rule, shared by every consumer (Journey Stage 1a, + /// Q#JR14f). + /// + /// A dedicated window refuses anything other than what it already + /// shows; an undedicated one accepts anything. `incoming` is + /// deliberately optional, and the `None` case is not a degenerate + /// spelling of "don't care" — it means **the replacement buffer does + /// not exist yet**, and a dedicated window must therefore be treated + /// as ineligible: + /// + /// | caller | `incoming` | dedicated window | + /// |---|---|---| + /// | [`Self::display_buffer`] exact-target arm | `Some(request.buffer_id)` | eligible only when already showing it | + /// | [`Self::probe_display_target`] | its existing-buffer result | preserves the load-before-placement probe | + /// | `commit_to` preflight | `None` | always ineligible | + /// + /// `commit_to` passes `None` because a directory open's destination + /// is validated *before* the handler builds its buffer. Passing the + /// captured bootstrap buffer instead would approve a window + /// dedicated to *that* buffer, the handler would then claim and paint + /// a different one, and the exact display would refuse afterwards — + /// after the mutations the preflight exists to prevent. + /// + /// Extracted rather than reimplemented per caller: two copies of a + /// rule that must agree is exactly the drift this stage's + /// path-resolution unification exists to close, and a future + /// eligibility rule added to only one copy would reopen it. + #[must_use] + pub fn window_accepts_buffer(&self, window: WindowId, incoming: Option) -> bool { + self.windows.get(&window).is_some_and(|w| { + !w.params.dedicated || incoming.is_some_and(|buffer_id| w.buffer_id == buffer_id) + }) + } + + fn probe_display_target_inner( + &self, + fid: FrontendId, + existing: Option, + window: Option, ) -> Result { let view = self .views .get(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; - let eligible = |id: WindowId| { - self.windows.get(&id).is_some_and(|w| { - !w.params.dedicated || existing.is_some_and(|buffer_id| w.buffer_id == buffer_id) - }) - }; + let eligible = |id: WindowId| self.window_accepts_buffer(id, existing); if let Some(target) = window { if !view.layout.iter_ids().contains(&target) { return Err(format!( @@ -3563,7 +3697,7 @@ impl EditorCore { .windows .get(&target) .ok_or_else(|| format!("display: window {} is not live", target.raw()))?; - if window.params.dedicated && window.buffer_id != request.buffer_id { + if !self.window_accepts_buffer(target, Some(request.buffer_id)) { return Err(format!( "display: window {} is dedicated to another buffer", target.raw() @@ -5300,6 +5434,45 @@ mod tests { assert!(s.active_window_for(FrontendId::LOCAL).is_some()); } + /// Journey Stage 1a (Q#JR14f): the three decisive rows of the shared + /// eligibility predicate. + /// + /// The `None` row is the one that exists for `commit_to`, and it is + /// not a "don't care": a directory open validates its destination + /// *before* the handler creates the buffer that will land there, so + /// there is no incoming id to compare and a dedicated window must be + /// refused. Approving it would let the handler claim and paint, and + /// the display would refuse afterwards — after the mutations the + /// preflight exists to prevent. + #[test] + fn window_accepts_buffer_matrix() { + let mut s = fresh(); + let window = s.views[&FrontendId::LOCAL].active; + let current = s.windows[&window].buffer_id; + let other = s.registry.borrow_mut().create(String::from("other")); + + // Undedicated: accepts anything, including "not decided yet". + assert!(s.window_accepts_buffer(window, Some(current))); + assert!(s.window_accepts_buffer(window, Some(other))); + assert!(s.window_accepts_buffer(window, None)); + + s.windows.get_mut(&window).expect("live").params.dedicated = true; + + // Dedicated: only what it already shows. + assert!( + s.window_accepts_buffer(window, Some(current)), + "a dedicated window still accepts the buffer it displays" + ); + assert!( + !s.window_accepts_buffer(window, Some(other)), + "a dedicated window refuses a different buffer" + ); + assert!( + !s.window_accepts_buffer(window, None), + "a dedicated window refuses an as-yet-unbuilt replacement" + ); + } + #[test] fn register_and_unregister_frontend_view() { // T M10.8 — the lifecycle API the dispatcher uses on attach diff --git a/src/frontend.rs b/src/frontend.rs index 8fcfb47..6b8755f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -439,6 +439,11 @@ impl Frontend { // Q#GT4 — this pre-window semantic bootstrap result cannot // legitimately reach the grid TUI. | InstanceMessage::InitialTargetResult(_) + // Q#BP15 — the panel band is painted by the GPU frontend; + // the grid TUI renders its side windows through the cell + // grid and negotiates no panel capability, so this cannot + // legitimately reach here. + | InstanceMessage::PanelFrame(_) | InstanceMessage::ResourceOffer { .. } // T M11.6 — DispatchIdle is consumed by `attach.rs`'s // optimistic-apply gate; if any reaches this render path diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 3482879..b624a00 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+) @@ -3038,6 +3065,36 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result mlua::Result { ) })?, )?; + // Journey Stage 1a (Q#JR7): the directory fallback. + // + // The resolver for a directory open is a two-tier arrangement, and + // the split is forced by how registration works rather than chosen + // for elegance. `path.open-directory` is a short-circuit hook that + // **no builtin subscribes to** — because `HookRegistry::add` only + // appends and builtins load before `init.lua`, a subscribing builtin + // would always claim first and no user listener could ever run. So + // the hook is the user's chain, and the default surface is this + // slot, consulted only when the chain declines. + // + // A slot, not a `pmacs.config` setting: `ConfigValue` is four + // scalars and a handler is none of them (the same reason terminal + // profiles could not be settings). It is an UNOWNED singleton — + // last writer wins, no owning package, no `SourceLocation`, no + // removal lifecycle, absent from every inspection surface. That is a + // real `COHERENCE.md` §13 gap, recorded rather than dressed up: when + // §20 Priority 3 lands registration ownership and `hook.remove`, + // this becomes an ordinary lowest-priority subscription carrying its + // owner and this slot is deleted rather than extended. + // + // Readable as `pmacs.path.directory_handler` so a replacement can + // capture and chain to the previous one; `nil` disables directory + // opening entirely, which is what makes that path testable. + path.set("directory_handler", mlua::Value::Nil)?; + path.set( + "set_directory_handler", + lua.create_function(|lua, handler: mlua::Value| { + match &handler { + mlua::Value::Nil | mlua::Value::Function(_) => {} + other => { + return Err(mlua::Error::runtime(format!( + "pmacs.path.set_directory_handler: expected a function or nil, got {}", + other.type_name() + ))); + } + } + let pmacs: Table = lua.globals().get("pmacs")?; + let path: Table = pmacs.get("path")?; + path.set("directory_handler", handler)?; + Ok(()) + })?, + )?; Ok(path) } +/// Lua handle for a captured directory destination (Q#JR14d). +/// +/// Deliberately **nonconstructible from Lua** and read-only. The same +/// value is passed to every `path.open-directory` listener in turn: as a +/// table, an earlier listener could mutate it and then decline, +/// redirecting later listeners or the fallback to a window the user +/// never asked for — and any Lua could fabricate a plausible +/// frontend/window/buffer triple and hand it to `commit_to`. Userdata +/// with no constructor and no setters makes both unrepresentable rather +/// than merely discouraged. +/// +/// The single accessor exists because dired needs the exact window for +/// its `display{window = …}` target; nothing needs the frontend or the +/// captured buffer, which stay private to the preflight. +pub(crate) struct DirectoryDestinationLua(pub(crate) crate::editor_core::DirectoryDestination); + +impl mlua::UserData for DirectoryDestinationLua { + fn add_methods>(methods: &mut M) { + methods.add_method("window", |_, this, ()| Ok(this.0.window.raw())); + } +} + /// Build the `pmacs.ansi.*` table. The only entry today is /// `parser()`; future additions (e.g. an event-table-validator /// helper) live alongside it. @@ -6594,6 +6716,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()?; { @@ -6833,6 +7003,26 @@ pub fn install_async( )?; } + // Journey Stage 1a (Q#JR14b): `pmacs.window.commit_to` runs its + // callback inside a Rust-stack RAII scope. Yielding out of that + // scope would let the guard's dynamic extent and the coroutine's + // suspension diverge — the guard would restore the frontend override + // while the continuation is still parked, so the rest of the commit + // would silently run ambient again, which is the exact bug the scope + // exists to prevent. `Handle:await` therefore refuses inside it. + // + // Enforced here rather than documented in the framing, because a + // rule that only exists in prose is one a future caller breaks + // without noticing. + async_mod.set( + "_in_commit_scope", + lua.create_function(|lua, ()| { + Ok(lua + .app_data_ref::() + .is_some_and(|scope| scope.active())) + })?, + )?; + { let rt = runtime.clone(); async_mod.set( @@ -8809,6 +8999,25 @@ fn install_terminal( )?; } + { + let manager = manager.clone(); + terminal.set( + "_copy_retained", + // Q#TC7: returns the whole retained range as a string, through + // the same serializer selection-copy uses. Takes an explicit + // buffer rather than resolving the active view, because copy + // mode reads a terminal that may not be displayed — and + // because the caller already holds the handle it keyed its + // snapshot on. + lua.create_function(move |lua, buffer: BufferIdLua| { + let Some(bytes) = manager.borrow().copy_retained(buffer.0) else { + return Ok(None); + }; + Ok(Some(lua.create_string(&bytes)?)) + })?, + )?; + } + pmacs.set("terminal", terminal) } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index f4833ef..1c700a5 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -44,8 +44,22 @@ use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId}; /// call falls back to the ambient active frontend, exactly as the /// terminal surface does. pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId { - lua.app_data_ref::() - .and_then(|origin| origin.current()) + // Journey Stage 1a (Q#JR14e): the background scope wins. + // + // Order is deliberate — scoped override, then interactive origin, + // then ambient. A `commit_to` callback runs for the frontend that + // *requested* the work, and it must win over whatever happens to be + // dispatching when the worker settles. It is a separate slot rather + // than a reuse of the interactive origin because that origin is + // authenticated user-command authority (the pre-edit unfold guard, + // command-boundary rotation, and the terminal surface all key off + // it), and a background continuation must not acquire it. + lua.app_data_ref::() + .and_then(|scope| scope.current()) + .or_else(|| { + lua.app_data_ref::() + .and_then(|origin| origin.current()) + }) .unwrap_or_else(|| core.borrow().active_frontend_key()) } @@ -350,6 +364,123 @@ pub(crate) fn finish_adopter_placement( a coherent surface" )] pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> { + { + let cc = core.clone(); + win.set( + "commit_to", + lua.create_function( + move |lua, + (dest, body): (mlua::Value, mlua::Function)| + -> mlua::Result { + // Journey Stage 1a (Q#JR14). Preflight FIRST, then + // scope, then run. The ordering is the whole point: + // an async handler mutates real state (dired claims + // a buffer, registers a handle, captures `prev`, and + // paints) long before it reaches any call that could + // refuse. Validating at display time is four + // mutations too late and leaves a hidden buffer + // behind, so every destination precondition is + // checked before the callback is invoked at all. + // + // Typed as `Value` rather than `AnyUserData` so this + // message is REACHABLE: with the narrower type mlua + // rejects a table during argument conversion, and a + // caller who fabricated one got "error converting Lua + // table to userdata" — true, but it names neither the + // rule nor how to get a real destination. + let dest = match &dest { + mlua::Value::UserData(userdata) => { + userdata.borrow::().ok() + } + _ => None, + }; + let dest = dest + .ok_or_else(|| { + mlua::Error::runtime( + "pmacs.window.commit_to: expected a destination captured by \ + the editor (it cannot be constructed from Lua)", + ) + })? + .0; + + // 1. The requesting frontend still has a layout. + let refusal = { + let core = cc.borrow(); + if !core.views.contains_key(&dest.frontend) { + Some("requesting frontend is gone".to_string()) + } else if !core + .views + .get(&dest.frontend) + .is_some_and(|view| view.layout.iter_ids().contains(&dest.window)) + { + // 2. The destination window is still live in it. + Some(format!("window {} is gone", dest.window.raw())) + } else if core + .windows + .get(&dest.window) + .is_some_and(|w| w.buffer_id != dest.buffer) + { + // 3. Stale intent (Q#JR14c): the user + // replaced the buffer while the work was + // in flight. Their action is newer + // information than the request, so the + // request loses. + Some(format!( + "window {} now shows another buffer", + dest.window.raw() + )) + } else if !core.window_accepts_buffer(dest.window, None) { + // 4. Replaceability (Q#JR14f). `None` + // because the replacement does not exist + // yet — passing the captured buffer would + // approve a window dedicated to *it*, and + // the handler's different buffer would be + // refused later, after mutating. + Some(format!("window {} is dedicated", dest.window.raw())) + } else { + None + } + }; + if let Some(reason) = refusal { + let mut out = mlua::MultiValue::new(); + out.push_back(mlua::Value::String(lua.create_string(reason.as_bytes())?)); + out.push_front(mlua::Value::Boolean(false)); + return Ok(out); + } + + let scope = lua + .app_data_ref::() + .ok_or_else(|| { + mlua::Error::runtime( + "pmacs.window.commit_to: no frontend scope installed", + ) + })? + .clone(); + let commit = lua + .app_data_ref::() + .ok_or_else(|| { + mlua::Error::runtime( + "pmacs.window.commit_to: no commit scope installed", + ) + })? + .clone(); + // Both the override and the core's ambient + // `active_frontend` are restored when this guard + // drops -- on the normal return AND on a raising + // callback, which is why the result is captured + // rather than `?`-propagated through the drop. + let result = { + let _guard = scope.enter(&cc, &commit, dest.frontend); + body.call::(()) + }; + let mut out = result?; + out.push_front(mlua::Value::Boolean(true)); + Ok(out) + }, + )?, + )?; + } + { let cc = core.clone(); win.set( @@ -397,10 +528,33 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result .probe_display_target(fid, existing, explicit_window) .map_err(mlua::Error::runtime)?; // 3. Load, dedup, or create the path-backed buffer. - let (buffer_id, fire) = cc + // + // Journey Stage 1a (Q#JR13): a DIRECTORY raises here + // and does NOT enter the directory resolver chain. + // `display_file` is "put this file in a window", not + // a CLI router — and `find-file`'s accept arm + // (`builtin/commands/default.lua`) wraps this call in + // a `pcall` whose comment guarantees that "only a + // real failure (a directory, a permission error) + // reaches here", pinned by + // `find_file_accepting_a_directory_reports_instead_of_raising`. + // Routing it into dired would silently change what + // `C-x C-f` on a directory does. Opening dired from + // find-file is a named deferral, not a side effect of + // the CLI work. + let (buffer_id, fire) = match cc .borrow_mut() .resolve_target_buffer(&path_buf) - .map_err(mlua::Error::runtime)?; + .map_err(mlua::Error::runtime)? + { + crate::editor_core::ResolvedTarget::Buffer { id, fire } => (id, fire), + crate::editor_core::ResolvedTarget::Directory { path } => { + return Err(mlua::Error::runtime(format!( + "pmacs.window.display_file: {} is a directory", + path.display() + ))); + } + }; // 4. Enter Q#BP4's transaction, so any hook observes // the DOCUMENT TARGET as active. let mut request = DisplayRequest::new(buffer_id); diff --git a/src/process.rs b/src/process.rs index 2b53bf3..9e02629 100644 --- a/src/process.rs +++ b/src/process.rs @@ -470,6 +470,13 @@ pub struct ProcessSupervisor { /// TERM→KILL window used when arming the ledger. Constant /// [`GROUP_TERM_GRACE`] in production; overridable in tests. group_term_grace: Duration, + /// Q#PD4 test seam: forces the next `kill(2)` attempt in + /// [`Self::signal`] to fail with this errno, consumed once. + /// Always `None` in production — there is no way to set it outside + /// `cfg(test)`. It replaces the *kill result only*, so the leader + /// observation still runs against the real child handle; a stubbed + /// observation would bypass the code path under test. + forced_kill_errno: Option, } /// One armed group in the reap ledger. @@ -684,7 +691,50 @@ impl ChildHandle { } } -fn signal_target(proc: &ManagedProcess, pid: u32) -> Result { +/// Which branch of [`signal_target`] chose the target (Q#PD1). +/// +/// Recorded on failure because the branches differ in what a failing +/// `kill` can possibly mean: only [`Self::LeaderPid`] aims at the +/// spawned child itself. The other two aim at a *group*, which for a +/// PTY is read from the terminal and can belong to something the +/// supervisor never spawned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetSource { + /// The tty's current foreground process group, read at signal + /// time. Diverges from the leader exactly when job control has + /// moved the terminal. + ForegroundGroup, + /// A `group = true` pipe child leading its own process group. + SpawnGroup, + /// The child's own pid. + LeaderPid, +} + +impl TargetSource { + fn as_str(self) -> &'static str { + match self { + Self::ForegroundGroup => "tcgetpgrp", + Self::SpawnGroup => "group", + Self::LeaderPid => "leader-pid", + } + } + + /// Whether the target is a process group rather than one process. + fn is_group(self) -> bool { + matches!(self, Self::ForegroundGroup | Self::SpawnGroup) + } +} + +/// The entity a signal was actually aimed at, plus the branch that +/// chose it. Carried so a failure can report the target as a fact +/// separate from the leader's state (Q#PD1). +#[derive(Debug, Clone, Copy)] +struct SignalTarget { + pid: Pid, + source: TargetSource, +} + +fn signal_target(proc: &ManagedProcess, pid: u32) -> Result { if let Some(runtime) = proc.runtime.as_ref() && let ChildHandle::Pty { _master: master, .. @@ -692,7 +742,10 @@ fn signal_target(proc: &ManagedProcess, pid: u32) -> Result { && let Some(pgrp) = master.process_group_leader() && pgrp > 0 { - return Ok(Pid::from_raw(-pgrp)); + return Ok(SignalTarget { + pid: Pid::from_raw(-pgrp), + source: TargetSource::ForegroundGroup, + }); } // `group = true` pipe children lead a fresh process group // (`process_group(0)` at spawn ⇒ pgid == pid), so fatal signals @@ -700,11 +753,80 @@ fn signal_target(proc: &ManagedProcess, pid: u32) -> Result { // (Q#CM3). if proc.spec.group { let pgid = i32::try_from(pid).map_err(|e| e.to_string())?; - return Ok(Pid::from_raw(-pgid)); + return Ok(SignalTarget { + pid: Pid::from_raw(-pgid), + source: TargetSource::SpawnGroup, + }); } - Ok(Pid::from_raw( - i32::try_from(pid).map_err(|e| e.to_string())?, - )) + Ok(SignalTarget { + pid: Pid::from_raw(i32::try_from(pid).map_err(|e| e.to_string())?), + source: TargetSource::LeaderPid, + }) +} + +/// The spawned leader's state at the moment a `kill` failed (Q#PD1). +/// +/// Deliberately reported *beside* the target rather than folded into a +/// verdict: for a PTY the two are different entities whenever job +/// control has moved the terminal, and three successive designs for +/// this code were unsound precisely because they collapsed them. +enum LeaderObservation { + Exited(TermStatus), + Live, + Unobservable(String), + NoRuntime, +} + +impl LeaderObservation { + fn render(&self) -> String { + match self { + Self::Exited(TermStatus::Exited(code)) => format!("exited(code {code})"), + Self::Exited(TermStatus::Signaled(sig)) => format!("exited(signal {sig})"), + Self::Live => "live".to_owned(), + Self::Unobservable(e) => format!("unobservable({e})"), + Self::NoRuntime => "no-runtime".to_owned(), + } + } +} + +/// Observe the spawned leader. Note this *reaps* an exited child and +/// caches its status; that is why Q#PD3 claims "no disposition change" +/// rather than "strictly additive", and why an event-count test pins +/// that `poll_one` still emits exactly one exit event afterwards. +fn observe_leader(proc: &mut ManagedProcess) -> LeaderObservation { + let Some(runtime) = proc.runtime.as_mut() else { + return LeaderObservation::NoRuntime; + }; + match runtime.child.try_wait() { + Ok(Some(status)) => LeaderObservation::Exited(status), + Ok(None) => LeaderObservation::Live, + Err(e) => LeaderObservation::Unobservable(e), + } +} + +/// Render a failing `kill` as the five facts of Q#PD1. The disposition +/// is unchanged (Q#PD2) — this only replaces a message that said +/// nothing but the errno. +fn signal_failure_report( + target: SignalTarget, + leader_pid: u32, + errno: nix::errno::Errno, + leader: &LeaderObservation, +) -> String { + let expected = if target.source.is_group() { + match i32::try_from(leader_pid) { + Ok(p) => format!(", expected_group=-{p}"), + Err(_) => String::new(), + } + } else { + String::new() + }; + format!( + "kill: {errno} (target={} via {}, leader_pid={leader_pid}{expected}, leader={})", + target.pid.as_raw(), + target.source.as_str(), + leader.render(), + ) } /// Termination status of one generation. Internal --- the supervisor @@ -807,9 +929,20 @@ impl ProcessSupervisor { shut_down: false, reap_ledger: HashMap::new(), group_term_grace: GROUP_TERM_GRACE, + forced_kill_errno: None, } } + /// Q#PD4 test seam: make the next `kill(2)` attempt in + /// [`Self::signal`] report `errno` instead of calling the kernel. + /// Consumed by that one attempt. Everything downstream — target + /// selection, the leader observation against the real child, and + /// the error construction — runs unmodified. + #[cfg(test)] + fn force_next_kill_errno(&mut self, errno: nix::errno::Errno) { + self.forced_kill_errno = Some(errno); + } + /// Override the SIGTERM-to-SIGKILL grace window. Test helper. pub fn set_grace_period(&mut self, d: Duration) { self.grace_period = d; @@ -928,7 +1061,21 @@ impl ProcessSupervisor { return Err(format!("process {id} is not running")); }; let target = signal_target(proc, pid)?; - nix::sys::signal::kill(target, Some(signal)).map_err(|e| format!("kill: {e}"))?; + // Q#PD4: the seam injects the KILL attempt's result only — + // never the observation below — so target selection, the real + // `ChildHandle::try_wait` against the real child, and the error + // construction all run for real. Consumed once. + let kill_result = match self.forced_kill_errno.take() { + Some(errno) => Err(errno), + None => nix::sys::signal::kill(target.pid, Some(signal)), + }; + if let Err(errno) = kill_result { + // Q#PD1/Q#PD2: the failure describes itself; the + // disposition is unchanged — this still returns `Err`, + // with no state transition and no ledger arming. + let leader = observe_leader(proc); + return Err(signal_failure_report(target, pid, errno, &leader)); + } if matches!(signal, Signal::SIGTERM | Signal::SIGKILL | Signal::SIGHUP) { proc.state = ProcessState::Exiting { pid, @@ -2131,6 +2278,290 @@ mod tests { ); } + /// Spawn a PTY child that leads its own session and stays alive + /// until terminated, returning its id and OS pid. + /// + /// `/bin/sleep` directly rather than through a shell: a shell may + /// place the command in a different foreground process group, and + /// these tests assert the exact target the tty reports. + fn spawn_live_pty(sup: &mut ProcessSupervisor, name: &str) -> (ProcessId, u32) { + let mut spec = ProcessSpec::new(name, "/bin/sleep"); + spec.args = vec!["30".into()]; + spec.mode = ProcessMode::Pty { + rows: 24, + cols: 80, + mode: TerminalMode::Canonical, + }; + let id = sup.spawn(spec).expect("spawn"); + (id, spawn_started_pid(sup, id)) + } + + /// The OS pid straight from the supervisor's own record, WITHOUT + /// ticking. + /// + /// `drain_until` ticks, and a tick can observe a fast child's exit + /// and transition the record out of `Running` — after which + /// `signal` returns "is not running" and never reaches the + /// diagnostic at all. Any test whose child exits promptly must read + /// the pid this way. (Found by the parallel workspace sweep: the + /// drain-based helper raced only under load.) + fn record_pid(sup: &ProcessSupervisor, id: ProcessId) -> u32 { + match sup.processes.get(&id).expect("record").state { + ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. } => pid, + ProcessState::Starting => panic!("spawn has not reported a pid yet"), + ProcessState::Terminated(_) => { + panic!("the record already left Running; the pid is unavailable") + } + } + } + + /// Drain until `Started` and return the OS pid it carries. Safe + /// only for children that outlive the drain; see [`record_pid`]. + fn spawn_started_pid(sup: &mut ProcessSupervisor, id: ProcessId) -> u32 { + let evs = drain_until(sup, id, Duration::from_secs(5), |evs| { + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) + }); + evs.iter() + .find_map(|e| match e.kind { + ProcessEventKind::Started { pid } => Some(pid), + _ => None, + }) + .expect("Started carries a pid") + } + + /// Drive the production diagnostic until it observes the leader as + /// exited, bounded by `timeout`. + /// + /// A fixed sleep is NOT proof of exit — on a loaded runner the child + /// can still be live, which would turn these tests into false + /// failures. This synchronises on the very observation under test. + /// Each failing attempt leaves the record untouched, because the + /// failure path returns before any bookkeeping (Q#PD2), so looping + /// is side-effect free. + fn terminate_until_leader_exited( + sup: &mut ProcessSupervisor, + id: ProcessId, + timeout: Duration, + ) -> String { + let deadline = Instant::now() + timeout; + loop { + sup.force_next_kill_errno(nix::errno::Errno::EPERM); + let err = sup.terminate(id).expect_err("injected EPERM must fail"); + if err.contains("leader=exited(") { + return err; + } + assert!( + !err.contains("is not running"), + "the record left Running before the diagnostic could run, so \ + this test never exercised it: {err}" + ); + assert!( + Instant::now() < deadline, + "leader never observed as exited within {timeout:?}: {err}" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + /// Q#PD1 acceptance 1 — a group-directed failure names the target, + /// the branch that chose it, the expected group, the errno, and the + /// leader's own state, as five separate facts. + /// + /// Asserted as an exact message against the pid the kernel actually + /// assigned, so a hardcoded target could not satisfy it. The leader + /// field is the one that matters: for a PTY the signal goes to the + /// terminal's foreground group, a different entity from the spawned + /// child whenever job control has moved the terminal. Three rejected + /// designs for this code collapsed the two; the report keeps them + /// apart, and here they are asserted to agree only because nothing + /// has moved the terminal. + #[test] + fn a_group_directed_kill_failure_reports_target_and_leader_separately() { + let mut sup = ProcessSupervisor::new(); + let (id, pid) = spawn_live_pty(&mut sup, "diag-group"); + + sup.force_next_kill_errno(nix::errno::Errno::EPERM); + let err = sup.terminate(id).expect_err("injected EPERM must fail"); + + let expected = format!( + "kill: {} (target=-{pid} via tcgetpgrp, leader_pid={pid}, expected_group=-{pid}, leader=live)", + nix::errno::Errno::EPERM + ); + assert_eq!( + err, expected, + "the report names the exact target the tty reported, the exact \ + leader pid, and observes the leader as live" + ); + + let _ = sup.signal(id, Signal::SIGKILL); + } + + /// Q#PD1 acceptance 2 — a leader-directed failure records the + /// fallback branch and a positive target, and omits the group field + /// that would be meaningless for it. Exact message again. + #[test] + fn a_leader_directed_kill_failure_reports_the_fallback_branch() { + let mut sup = ProcessSupervisor::new(); + let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep"); + spec.args = vec!["30".into()]; + let id = sup.spawn(spec).expect("spawn"); + let pid = spawn_started_pid(&mut sup, id); + + sup.force_next_kill_errno(nix::errno::Errno::ESRCH); + let err = sup.terminate(id).expect_err("injected ESRCH must fail"); + + let expected = format!( + "kill: {} (target={pid} via leader-pid, leader_pid={pid}, leader=live)", + nix::errno::Errno::ESRCH + ); + assert_eq!( + err, expected, + "a non-group pipe child targets its own pid, and the group \ + field is omitted where it has no meaning" + ); + + let _ = sup.signal(id, Signal::SIGKILL); + } + + /// Q#PD1 acceptance 3 — every leader state renders distinctly. The + /// `Unobservable` and `NoRuntime` arms cannot be produced by a real + /// child on demand, so they are pinned directly; `live` and `exited` + /// are pinned through the real path by the tests around this one. + #[test] + fn every_leader_observation_renders_distinctly() { + assert_eq!( + LeaderObservation::Exited(TermStatus::Exited(0)).render(), + "exited(code 0)" + ); + assert_eq!( + LeaderObservation::Exited(TermStatus::Signaled("SIGTERM".into())).render(), + "exited(signal SIGTERM)" + ); + assert_eq!(LeaderObservation::Live.render(), "live"); + assert_eq!( + LeaderObservation::Unobservable("try_wait: boom".into()).render(), + "unobservable(try_wait: boom)" + ); + assert_eq!(LeaderObservation::NoRuntime.render(), "no-runtime"); + } + + /// Q#PD1 acceptance 3, exited arm through the REAL path — the leader + /// has genuinely exited and the report carries its exact code, not + /// merely "some exit". + #[test] + fn a_failure_after_the_child_exits_reports_the_leader_as_exited() { + let mut sup = ProcessSupervisor::new(); + let mut spec = ProcessSpec::new("diag-exited", "/bin/sh"); + spec.args = vec!["-c".into(), "exit 3".into()]; + let id = sup.spawn(spec).expect("spawn"); + // NOT `spawn_started_pid`: draining ticks, and this child exits + // immediately. + let pid = record_pid(&sup, id); + + let err = terminate_until_leader_exited(&mut sup, id, Duration::from_secs(10)); + + let expected = format!( + "kill: {} (target={pid} via leader-pid, leader_pid={pid}, leader=exited(code 3))", + nix::errno::Errno::EPERM + ); + assert_eq!( + err, expected, + "the exact exit code is observed from the real child, not \ + inferred from the errno" + ); + } + + /// Q#PD2 acceptance 4 — **the disposition is unchanged.** An + /// injected failure still fails, and neither the state transition + /// nor the reap-ledger arming runs. This is the assertion that + /// separates a diagnostic from the tolerance rules three review + /// rounds rejected; flipping any arm to `Ok` fails it. + #[test] + fn an_injected_failure_changes_no_state_and_arms_no_ledger() { + let mut sup = ProcessSupervisor::new(); + let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.group = true; + let id = sup.spawn(spec).expect("spawn"); + let pid = spawn_started_pid(&mut sup, id); + assert!( + sup.reap_ledger.is_empty(), + "precondition: nothing armed before the attempt" + ); + + sup.force_next_kill_errno(nix::errno::Errno::EPERM); + let err = sup.terminate(id).expect_err("injected EPERM must fail"); + + let expected = format!( + "kill: {} (target=-{pid} via group, leader_pid={pid}, expected_group=-{pid}, leader=live)", + nix::errno::Errno::EPERM + ); + assert_eq!(err, expected, "a group=true pipe child reports via group"); + + assert!( + matches!( + sup.processes.get(&id).expect("record").state, + ProcessState::Running { .. } + ), + "a failed kill must not transition the record to Exiting" + ); + assert!( + sup.reap_ledger.is_empty(), + "a failed kill must not arm the reap ledger" + ); + + let _ = sup.signal(id, Signal::SIGKILL); + } + + /// Q#PD3/Q#PD4 acceptance 5 — the diagnostic consults the REAL + /// `ChildHandle::try_wait` on the REAL child, which reaps it and + /// caches the status. `poll_one` must still emit exactly one exit + /// event, carrying the exact code. + /// + /// A stubbed observation would bypass the double-`try_wait` path + /// entirely and pin nothing, so the injection replaces the kill + /// result only. + #[test] + fn observing_the_leader_does_not_consume_the_exit_event() { + let mut sup = ProcessSupervisor::new(); + let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh"); + spec.args = vec!["-c".into(), "exit 7".into()]; + spec.mode = ProcessMode::Pty { + rows: 24, + cols: 80, + mode: TerminalMode::Canonical, + }; + let id = sup.spawn(spec).expect("spawn"); + // NOT `spawn_started_pid`: draining ticks, and a tick can reap + // this immediately-exiting child before the diagnostic runs. + let _ = record_pid(&sup, id); + + // Drives `observe_leader`, which try_waits the real PTY child + // for the first time and reaps it. + let err = terminate_until_leader_exited(&mut sup, id, Duration::from_secs(10)); + assert!( + err.contains("leader=exited(code 7)"), + "the real handle was consulted and carries the exact code: {err}" + ); + + // The supervisor's own try_wait must still see that status. + let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + let terminal: Vec = evs + .iter() + .filter_map(|e| match e.kind { + ProcessEventKind::Exited { code, .. } => Some(code), + ProcessEventKind::Signaled { .. } => Some(-1), + _ => None, + }) + .collect(); + assert_eq!( + terminal, + vec![7], + "exactly one terminal event survives the diagnostic's try_wait, \ + carrying the child's real exit code" + ); + } #[test] fn signal_terminates_a_running_child() { let mut sup = ProcessSupervisor::new(); diff --git a/src/protocol.rs b/src/protocol.rs index df65863..baf2709 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_twenty_for_gpu_initial_targets() { + fn protocol_version_is_twenty_one_for_the_bottom_panel_band() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1722,7 +1722,13 @@ mod tests { // variant, see the placement pins). // GPU initial targets bump 19→20 with a semantic-only // SessionBootstrapRequest and appended InitialTargetResult. - assert_eq!(PROTOCOL_VERSION, 20); + // Bottom panel Stage 2 bumps 20→21 (`InstanceMessage::PanelFrame`, + // daemon-gated, plus `FrontendEvent::{FrontendCellGeometry, + // PanelResizeRows, PanelPointer}`, frontend-gated — the second + // bump that gates in BOTH directions; all four appended after + // their enum's final v20 variant, see the placement pins in + // `bottom_panel_stage2b_protocol_acceptance`). + assert_eq!(PROTOCOL_VERSION, 21); } #[test] @@ -1798,17 +1804,18 @@ mod tests { // minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15 // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`), // v18 (`StatuslineSegments`), v19 (the vterm terminal family), - // and v20 (semantic initial-target bootstrap) all interoperate. - for accepted in 6..=20 { + // v20 (semantic initial-target bootstrap), and v21 (the bottom + // panel band) all interoperate. + for accepted in 6..=21 { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 21, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, 22, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v20 binary" + "v{rejected} must be rejected by a v21 binary" ); } } 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/terminal/view.rs b/src/terminal/view.rs index 1c0957d..b787dee 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -329,6 +329,29 @@ impl TerminalManager { copy_selection_bytes(&rows, selection) } + /// Serialize a session's ENTIRE retained range — scrollback plus the + /// visible screen — through the same path [`copy_selection`] uses. + /// + /// Q#TC7. This deliberately builds a whole-range *selection* and hands + /// it to the existing serializer rather than walking the rows itself. + /// Soft-wrap joining, wide-glyph continuation, cluster bytes, and + /// per-row trailing-blank trimming are Vterm Stage 2 criterion 21's + /// pinned behavior; a second walk would re-derive all four and the two + /// would drift. That inheritance is what acceptance 13 asserts, by + /// comparing this against a full-range `copy_selection` rather than + /// against a literal. + /// + /// Returns `None` for a non-terminal buffer and for a session whose + /// retained rows are all empty — there is no cell to anchor to. + /// Unlike `copy_selection` this needs no registered view, so copy mode + /// does not depend on the terminal being currently displayed. + #[must_use] + pub fn copy_retained(&self, buffer_id: BufferId) -> Option> { + let session = self.sessions.get(&buffer_id)?; + let projection = session.screen.projection_ref(); + retained_bytes(&retained_rows(projection)) + } + /// Start an editor-owned primary selection at a viewport coordinate. pub fn begin_selection( &mut self, @@ -540,6 +563,40 @@ fn retained_rows(projection: BorrowedScreenProjection<'_>) -> RetainedRows<'_> { RetainedRows { projection } } +/// Serialize every retained cell, through the selection-copy serializer. +/// +/// Split out from [`TerminalManager::copy_retained`] so the fidelity +/// claims — soft-wrap joining, per-row trailing-blank trimming, wide-glyph +/// continuation, cluster bytes — are testable against the same projection +/// fixtures that pin `copy_selection_bytes` itself. Those four are exactly +/// what a second, independently written walk would get wrong. +fn retained_bytes(rows: &RetainedRows<'_>) -> Option> { + copy_selection_bytes(rows, full_retained_selection(rows)?) +} + +/// The selection spanning every retained cell. +/// +/// Rows with no cells are skipped at both ends rather than clamped: an +/// anchor into a zero-width row cannot resolve (`resolve_anchor` requires +/// `cell_offset` to fall inside `cell_offset .. cell_offset + len`), so +/// including one would make the whole range unresolvable and silently +/// yield nothing. Interior empty rows are untouched, because trailing- and +/// interior-blank handling belongs to the serializer. +fn full_retained_selection(rows: &RetainedRows<'_>) -> Option { + let mut occupied = rows.iter().filter(|row| !row.cells.is_empty()); + let first = occupied.next()?; + // `RetainedRows::iter` is a chain of slice iterators exposed as + // `impl Iterator`, so it is not double-ended; scan forward. + let last = occupied.last().unwrap_or(first); + Some(TerminalSelection { + anchor: row_lead(first), + head: LogicalCellAnchor { + logical_line_id: last.logical_line_id, + cell_offset: last.cell_offset.saturating_add(last.cells.len() as u32 - 1), + }, + }) +} + fn row_lead(row: &TerminalRow) -> LogicalCellAnchor { LogicalCellAnchor { logical_line_id: row.logical_line_id, @@ -1001,6 +1058,92 @@ mod tests { assert_eq!(bytes, b"abcd\ne"); } + /// Stage 2 criteria 13 and 14. Every property here is one a second, + /// independently written whole-range walk would get wrong: a naive + /// walk emits a newline per physical row (breaking the soft wrap), + /// keeps trailing default blanks, and has to rediscover that history + /// precedes the visible screen. Asserting exact bytes is what makes + /// "it reuses the serializer" falsifiable. + #[test] + fn retained_copy_spans_history_joins_soft_wraps_and_trims_blanks() { + let source = projection( + vec![row(1, 0, "ab ", true), row(1, 3, "cd ", false)], + vec![row(2, 0, "e ", false), row(3, 0, " ", false)], + ); + let retained = retained_rows(source.as_borrowed()); + let bytes = retained_bytes(&retained).expect("whole range resolves"); + // `ab`+`cd` joined across the soft wrap; `e` on its own hard row; + // the all-blank final row trimmed to nothing but still separated. + assert_eq!(bytes, b"abcd\ne\n"); + } + + /// The whole-range selection must not depend on a view existing, and + /// must agree with an explicit full-span selection through the public + /// serializer — the anti-drift half of criterion 13. + #[test] + fn retained_copy_agrees_with_an_explicit_full_span_selection() { + let source = projection( + vec![row(1, 0, "aaa", false)], + vec![row(2, 0, "bbb", false), row(3, 0, "ccc", false)], + ); + let retained = retained_rows(source.as_borrowed()); + let explicit = copy_selection_bytes( + &retained, + TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 3, + cell_offset: 2, + }, + }, + ) + .expect("explicit selection resolves"); + assert_eq!(retained_bytes(&retained).expect("whole range"), explicit); + assert_eq!(explicit, b"aaa\nbbb\nccc"); + } + + /// A wide glyph must be copied once across the whole range too, not + /// once per cell it occupies. + #[test] + fn retained_copy_emits_a_wide_glyph_once() { + let wide = TerminalRow { + cells: vec![ + Cell { + glyph: Glyph::Char('界'), + style: Style::default(), + attachment: None, + }, + Cell { + glyph: Glyph::Continuation, + style: Style::default(), + attachment: None, + }, + Cell::default(), + ], + logical_line_id: 9, + cell_offset: 0, + soft_wrapped: false, + }; + let source = projection(Vec::new(), vec![wide]); + let retained = retained_rows(source.as_borrowed()); + assert_eq!( + retained_bytes(&retained).expect("whole range"), + "界".as_bytes() + ); + } + + /// A session with nothing retained yields `None` rather than an empty + /// string, so the caller can tell "no terminal" from "empty terminal". + #[test] + fn retained_copy_of_zero_width_rows_is_none() { + let source = projection(Vec::new(), vec![row(1, 0, "", false)]); + let retained = retained_rows(source.as_borrowed()); + assert!(retained_bytes(&retained).is_none()); + } + #[test] fn wide_continuation_canonicalizes_to_lead_and_copies_once() { let wide = TerminalRow { 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/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs new file mode 100644 index 0000000..b066e4a --- /dev/null +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -0,0 +1,595 @@ +//! Bottom-panel Stage 2B — the v21 protocol slice. +//! +//! Covers parent acceptance 37 (round-trip plus the two byte pins) and +//! the shared/terminal-only validator split of Q#BP15. The daemon +//! projection, the epoch state machine, and the GPU band are later +//! slices of this stage and are not exercised here. + +mod common; + +use std::time::Duration; + +use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Color, Glyph, Style, UnderlineStyle}; +use pmacs_protocol::message::{ + AttachRequest, FrontendEvent, Hello, InstanceMessage, Modifiers, MouseButton, MouseKind, +}; +use pmacs_protocol::panel::{ + MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload, +}; +use pmacs_protocol::terminal::{ + MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState, +}; +use pmacs_protocol::transport::{MAX_FRAME_BYTES, read_message, write_message}; +use pmacs_protocol::wire_grid::{MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES}; +use pmacs_protocol::{ + ADVERTISED_PROTOCOL_VERSION, BufferId, FrontendId, PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, +}; + +use common::daemon::{TestDaemon, build_default_caps}; + +fn cell(ch: char) -> Cell { + Cell { + glyph: Glyph::Char(ch), + style: Style::default(), + attachment: None, + } +} + +/// The style whose postcard encoding is as long as a legal `Style` gets. +fn maximal_style() -> Style { + Style { + fg: Color::Rgb(0xff, 0xee, 0xdd), + bg: Color::Rgb(0x11, 0x22, 0x33), + bold: true, + italic: true, + underline: UnderlineStyle::Dashed, + reverse: true, + underline_color: Color::Rgb(0x44, 0x55, 0x66), + } +} + +fn maximal_cell(glyph: Glyph) -> Cell { + Cell { + glyph, + style: maximal_style(), + attachment: None, + } +} + +/// A single-column cluster of exactly `len` UTF-8 bytes. +fn cluster_of_len(len: usize) -> Vec { + assert!((1..=MAX_WIRE_GRID_GRAPHEME_BYTES).contains(&len)); + let mut text = String::with_capacity(len); + if len % 2 == 1 { + text.push(' '); + } else { + text.push('\u{e9}'); + } + while text.len() < len { + text.push('\u{301}'); + } + assert_eq!(text.len(), len); + text.into_bytes() +} + +fn panel_frame(rows: u32, cols: u32) -> PanelFrame { + PanelFrame { + buffer_id: BufferId::from_raw(9), + panel_epoch: 3, + geometry_epoch: 5, + size: CellSize::new(rows, cols), + cells: vec![cell(' '); (rows * cols) as usize], + cursor: Some(CellCoord::new(0, 0)), + focused: true, + } +} + +fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame { + TerminalFrame { + buffer_id: BufferId::from_raw(9), + size: CellSize::new(rows, cols), + cells: vec![cell(' '); (rows * cols) as usize], + cursor: Some(CellCoord::new(0, 0)), + title: None, + screen_generation: 1, + selection: Vec::new(), + scroll_offset: 0, + at_bottom: true, + pid: 1, + process: TerminalProcessState::Running, + } +} + +// --------------------------------------------------------------------------- +// 37 — version and round-trip +// --------------------------------------------------------------------------- + +#[test] +fn the_panel_stage_takes_protocol_v21() { + assert_eq!(PROTOCOL_VERSION, 21); + assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21)); + // The wire family is reserved before it is activated: the production + // server-first Hello must remain acceptable to already-shipped v20 + // clients throughout the dark protocol and daemon slices. + assert_eq!(ADVERTISED_PROTOCOL_VERSION, 20); + assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20)); +} + +#[test] +fn a_new_daemon_keeps_an_existing_v20_client_attachable() { + let daemon = TestDaemon::spawn(); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set handshake timeout"); + + // This is the rejection point in an already-shipped client: it reads the + // daemon's unsolicited Hello before it is able to identify its own + // supported range or send AttachRequest. + let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); + let v20_client_supported_versions = 6..=20; + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); + assert!( + v20_client_supported_versions.contains(&hello.protocol_version), + "an existing v20 client would reject the server-first Hello" + ); + + write_message( + &mut stream, + &AttachRequest { + protocol_version: hello.protocol_version, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(24, 80), + }, + ) + .expect("write v20 AttachRequest"); + + assert!( + matches!( + read_message::(&mut stream).expect("read initial grid"), + InstanceMessage::CellDelta { + full_grid: true, + .. + } + ), + "the daemon must establish the v20 session, not merely send an acceptable Hello" + ); +} + +#[test] +fn a_present_panel_frame_round_trips_with_both_epochs() { + let frame = panel_frame(2, 3); + let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(frame.clone())); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + let InstanceMessage::PanelFrame(PanelFramePayload::Present(got)) = decoded else { + panic!("expected a Present panel frame, got {decoded:?}"); + }; + // Both epochs must survive: they are the identities every later + // panel event validates against, so a frame that round-trips its + // cells but drops an epoch would silently accept stale input. + assert_eq!(got.panel_epoch, frame.panel_epoch); + assert_eq!(got.geometry_epoch, frame.geometry_epoch); + assert_eq!(got.buffer_id, frame.buffer_id); + assert_eq!(got.size, frame.size); + assert_eq!(got.cells, frame.cells); + assert_eq!(got.cursor, frame.cursor); + assert_eq!(got.focused, frame.focused); +} + +#[test] +fn an_absent_panel_payload_round_trips_as_its_own_state() { + let msg = InstanceMessage::PanelFrame(PanelFramePayload::Absent); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert!(matches!( + decoded, + InstanceMessage::PanelFrame(PanelFramePayload::Absent) + )); + // Absent must be distinguishable from a Present frame carrying no + // cells: it is authoritative, and conflating the two would make + // "hide the band" indistinguishable from "paint an empty band". + let empty_present = InstanceMessage::PanelFrame(PanelFramePayload::Present(panel_frame(1, 1))); + assert_ne!( + postcard::to_allocvec(&empty_present).expect("encode"), + bytes + ); +} + +#[test] +fn the_three_panel_events_round_trip() { + let fid = FrontendId(4); + let events = vec![ + FrontendEvent::FrontendCellGeometry { + frontend_id: fid, + geometry_epoch: 1, + total: CellSize::new(40, 120), + }, + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch: 2, + panel_epoch: 7, + rows: 12, + }, + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: 2, + panel_epoch: 7, + buffer_id: BufferId::from_raw(21), + coord: CellCoord::new(3, 9), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }, + ]; + for event in events { + let bytes = postcard::to_allocvec(&event).expect("encode"); + let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(decoded, event); + assert_eq!(decoded.frontend_id(), fid); + } +} + +#[test] +fn panel_pointer_carries_buffer_id_distinctly_from_panel_epoch() { + // The two fields close different holes and neither subsumes the + // other: `buffer_id` catches an A->B buffer replacement, while + // `panel_epoch` catches close/hide/reopen of the SAME buffer, which + // a buffer id alone cannot see. So each must independently reach the + // wire — a field silently dropped from the encoding would let one of + // those two stale gestures through. + let base = |buffer: u64, panel_epoch: u64| FrontendEvent::PanelPointer { + frontend_id: FrontendId(4), + geometry_epoch: 2, + panel_epoch, + buffer_id: BufferId::from_raw(buffer), + coord: CellCoord::new(1, 1), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }; + let encode = |e: &FrontendEvent| postcard::to_allocvec(e).expect("encode"); + + // Same panel epoch, different buffer: must differ on the wire. + assert_ne!(encode(&base(1, 7)), encode(&base(2, 7))); + // Same buffer, different panel epoch: must also differ. + assert_ne!(encode(&base(1, 7)), encode(&base(1, 8))); + + // And both survive decode rather than being defaulted. + let event = base(31, 7); + let decoded: FrontendEvent = postcard::from_bytes(&encode(&event)).expect("decode"); + let FrontendEvent::PanelPointer { + buffer_id, + panel_epoch, + .. + } = decoded + else { + panic!("expected a PanelPointer, got {decoded:?}"); + }; + assert_eq!(buffer_id, BufferId::from_raw(31)); + assert_eq!(panel_epoch, 7); +} + +// --------------------------------------------------------------------------- +// 37 — byte pins on the previous final variant of each extended enum +// --------------------------------------------------------------------------- + +#[test] +fn appending_panel_frame_does_not_move_the_previous_final_instance_discriminant() { + // `InitialTargetResult` was the final v20 variant. Its encoding must + // be byte-identical after `PanelFrame` is appended; if the new + // variant were inserted anywhere earlier, this leading discriminant + // byte would shift and every v20 peer would misread the wire. + let msg = InstanceMessage::InitialTargetResult( + pmacs_protocol::message::InitialTargetResult::Opened { + buffer_id: BufferId::from_raw(1), + }, + ); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + assert_eq!( + bytes[0], 27, + "InitialTargetResult must stay discriminant 27; got {bytes:?}" + ); + // And the appended variant must be the next one, not a reused slot. + let panel = InstanceMessage::PanelFrame(PanelFramePayload::Absent); + let panel_bytes = postcard::to_allocvec(&panel).expect("encode"); + assert_eq!(panel_bytes[0], 28); +} + +#[test] +fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() { + // `TerminalPointer` was the final v19/v20 variant of `FrontendEvent`. + let event = FrontendEvent::TerminalPointer { + frontend_id: FrontendId(2), + buffer_id: BufferId::from_raw(3), + coord: CellCoord::new(1, 1), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }; + let bytes = postcard::to_allocvec(&event).expect("encode"); + assert_eq!( + bytes[0], 12, + "TerminalPointer must stay discriminant 12; got {bytes:?}" + ); + // The three appended events take the next three slots, in order. + let fid = FrontendId(2); + for (expected, event) in [ + ( + 13u8, + FrontendEvent::FrontendCellGeometry { + frontend_id: fid, + geometry_epoch: 1, + total: CellSize::new(1, 1), + }, + ), + ( + 14, + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch: 1, + panel_epoch: 1, + rows: 1, + }, + ), + ( + 15, + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: 1, + panel_epoch: 1, + buffer_id: BufferId::from_raw(1), + coord: CellCoord::new(0, 0), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }, + ), + ] { + let bytes = postcard::to_allocvec(&event).expect("encode"); + assert_eq!(bytes[0], expected, "wrong discriminant for {event:?}"); + } +} + +// --------------------------------------------------------------------------- +// 39 — the shared/terminal-only validator split +// --------------------------------------------------------------------------- + +#[test] +fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() { + let wide = u32::from(MAX_TERMINAL_COLS) + 1; + + // The panel does not inherit the PTY per-axis cap: a 4K surface at a + // small font is legitimately this wide, and the area bound is what + // keeps the encoding inside the transport budget. + let panel = panel_frame(1, wide); + assert_eq!(panel.validate(), Ok(())); + + // The terminal keeps it, and reports the axis that failed. + let terminal = terminal_frame(1, wide); + assert!(matches!( + terminal.validate(), + Err(TerminalFrameError::Size { cols, max_cols, .. }) + if cols == wide && max_cols == u32::from(MAX_TERMINAL_COLS) + )); +} + +#[test] +fn a_panel_still_answers_to_the_shared_area_bound() { + // Removing the per-axis cap must not remove the area bound: that is + // the check that actually bounds the encoded size. + let huge = panel_frame(1, 1); + let mut huge = huge; + huge.size = CellSize::new(1024, 1024); + huge.cells = vec![cell(' '); 1]; + assert!(matches!(huge.validate(), Err(PanelFrameError::Area { .. }))); +} + +#[test] +fn a_panel_cell_carrying_an_attachment_is_rejected() { + // The attachment rejection is SHARED, not terminal-only, even though + // the terminal-side message says "which terminals never use": + // panels render no attachments either, so a shared rejection fails + // closed for both. + let mut frame = panel_frame(1, 2); + frame.cells[1].attachment = Some(pmacs_protocol::cell::Attachment::ImageCell { + image_id: 1, + sub_x: 0, + sub_y: 0, + }); + assert!(matches!( + frame.validate(), + Err(PanelFrameError::Attachment { index: 1 }) + )); +} + +#[test] +fn panel_glyph_topology_matches_the_terminal_rules() { + // A wide lead with no continuation column on its row is rejected the + // same way for both messages — the topology rule is shared. + let mut frame = panel_frame(1, 1); + frame.cells[0] = cell('\u{4e00}'); + assert!(matches!( + frame.validate(), + Err(PanelFrameError::Glyph { .. }) + )); + + let mut terminal = terminal_frame(1, 1); + terminal.cells[0] = cell('\u{4e00}'); + assert!(matches!( + terminal.validate(), + Err(TerminalFrameError::Glyph { .. }) + )); +} + +#[test] +fn a_panel_cursor_outside_its_grid_is_rejected() { + let mut frame = panel_frame(2, 2); + frame.cursor = Some(CellCoord::new(2, 0)); + assert!(matches!( + frame.validate(), + Err(PanelFrameError::Cursor { + row: 2, + rows: 2, + .. + }) + )); +} + +#[test] +fn a_zero_epoch_panel_frame_is_rejected_on_the_wire() { + // Epoch 0 is reserved for "never declared" (Q#BP2S1), so a frame + // carrying it could otherwise match a receiver that has declared + // nothing yet. + let mut frame = panel_frame(1, 1); + frame.panel_epoch = 0; + assert!(matches!( + frame.validate(), + Err(PanelFrameError::ZeroEpoch { field: "panel" }) + )); + + let mut frame = panel_frame(1, 1); + frame.geometry_epoch = 0; + assert!(matches!( + frame.validate(), + Err(PanelFrameError::ZeroEpoch { field: "geometry" }) + )); +} + +#[test] +fn terminal_frames_are_unchanged_by_the_factoring() { + // The shared validator must not have altered terminal acceptance: + // a valid frame still validates, and each terminal-only rule still + // reports its own variant. + assert_eq!(terminal_frame(3, 4).validate(), Ok(())); + + let mut bad_bottom = terminal_frame(1, 1); + bad_bottom.at_bottom = false; + bad_bottom.scroll_offset = 0; + assert!(matches!( + bad_bottom.validate(), + Err(TerminalFrameError::BottomState { .. }) + )); + + let mut bad_meta = terminal_frame(1, 1); + bad_meta.title = Some("\u{7}".into()); + assert!(matches!( + bad_meta.validate(), + Err(TerminalFrameError::Metadata { field: "title", .. }) + )); + + let mut bad_count = terminal_frame(2, 2); + bad_count.cells.pop(); + assert!(matches!( + bad_count.validate(), + Err(TerminalFrameError::CellCount { + expected: 4, + actual: 3 + }) + )); +} + +// --------------------------------------------------------------------------- +// 39 — the transport-safety ratchet +// --------------------------------------------------------------------------- + +/// The largest legal panel frame, plus the same frame one glyph byte over. +/// +/// Deliberately shaped `1 x MAX_PANEL_VISIBLE_CELLS`: a panel carries no +/// per-axis cap, so this is a legal panel geometry a terminal frame +/// cannot express, and it is therefore the worst case the terminal's own +/// ratchet never measured. +fn panel_budget_boundary_frames() -> (PanelFrame, PanelFrame) { + /// Shortest cluster length postcard encodes with a two-byte length + /// prefix, which is what makes a cluster cell maximally expensive. + const WIDE_PREFIX_LEN: usize = 128; + let area = MAX_PANEL_VISIBLE_CELLS; + + // Every cell owes at least one glyph byte; the rest of the budget is + // spent on as many two-byte-prefix clusters as it affords. + let spare = MAX_WIRE_GRID_GLYPH_BYTES - area; + let wide_cells = spare / (WIDE_PREFIX_LEN - 1); + let remainder = spare % (WIDE_PREFIX_LEN - 1); + assert!(wide_cells + usize::from(remainder > 0) <= area); + + let wide = cluster_of_len(WIDE_PREFIX_LEN).into_boxed_slice(); + let single = cluster_of_len(1).into_boxed_slice(); + let mut cells = Vec::with_capacity(area); + for index in 0..area { + let glyph = if index < wide_cells { + Glyph::Cluster(wide.clone()) + } else if index == wide_cells && remainder > 0 { + Glyph::Cluster(cluster_of_len(remainder + 1).into_boxed_slice()) + } else { + Glyph::Cluster(single.clone()) + }; + cells.push(maximal_cell(glyph)); + } + + let cols = u32::try_from(area).expect("area fits u32"); + let exact = PanelFrame { + buffer_id: BufferId::from_raw(u64::MAX), + panel_epoch: u64::MAX, + geometry_epoch: u64::MAX, + size: CellSize::new(1, cols), + cells, + cursor: Some(CellCoord::new(0, cols - 1)), + focused: true, + }; + + let mut over = exact.clone(); + // One more byte of glyph, nothing else changed. + let last = over.cells.len() - 1; + over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(2).into_boxed_slice())); + + (exact, over) +} + +#[test] +fn maximum_legal_panel_frame_encodes_below_the_transport_cap() { + let (exact, over) = panel_budget_boundary_frames(); + assert_eq!(exact.validate(), Ok(())); + + // The fixture must actually sit ON the boundary, or the ratchet + // below measures something smaller than the worst case and would + // stay green while a real maximum frame overran the transport. + let mut glyph_bytes = 0usize; + for cell in &exact.cells { + glyph_bytes += match &cell.glyph { + Glyph::Char(ch) => ch.len_utf8(), + Glyph::Cluster(bytes) => bytes.len(), + Glyph::Continuation => 0, + }; + } + assert_eq!( + glyph_bytes, MAX_WIRE_GRID_GLYPH_BYTES, + "the measured fixture must spend the whole aggregate budget" + ); + let over_glyph_bytes = over + .cells + .iter() + .map(|cell| match &cell.glyph { + Glyph::Char(ch) => ch.len_utf8(), + Glyph::Cluster(bytes) => bytes.len(), + Glyph::Continuation => 0, + }) + .sum::(); + assert_eq!( + over_glyph_bytes, + MAX_WIRE_GRID_GLYPH_BYTES + 1, + "the rejecting twin must be exactly one byte over the aggregate budget" + ); + + // One byte over is rejected, which is what makes `exact` maximal. + assert!(matches!( + over.validate(), + Err(PanelFrameError::GlyphBudget { .. }) + )); + + let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(exact)); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + assert!( + bytes.len() < MAX_FRAME_BYTES, + "largest legal panel frame encodes to {} bytes, at or above the \ + {MAX_FRAME_BYTES}-byte transport cap; the aggregate glyph bound no \ + longer keeps panel traffic inside the existing transport limit", + bytes.len() + ); +} diff --git a/tests/common/daemon.rs b/tests/common/daemon.rs index 6cb087a..1c8bc0d 100644 --- a/tests/common/daemon.rs +++ b/tests/common/daemon.rs @@ -26,7 +26,7 @@ use tempfile::TempDir; #[cfg(feature = "crdt")] use pmacs::cell::CellSize; #[cfg(feature = "crdt")] -use pmacs::protocol::{AttachRequest, PROTOCOL_VERSION}; +use pmacs::protocol::AttachRequest; use pmacs::protocol::{FrontendCapabilities, Hello}; use pmacs::transport::read_message; #[cfg(feature = "crdt")] @@ -294,7 +294,7 @@ pub fn attach_multi(daemon: &TestDaemon) -> (Hello, UnixStream) { .unwrap(); let hello: Hello = read_message(&mut stream).expect("read Hello"); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: multi_frontend_caps(), initial_size: CellSize::new(24, 80), }; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 5371438..2f56e8f 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -93,9 +93,9 @@ mod crdt { use pmacs::cell::CellSize; use pmacs::crdt::CrdtState; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget, - InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, - PROTOCOL_VERSION, SessionBootstrapRequest, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, + FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities, + InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, SessionBootstrapRequest, }; use pmacs::transport::{read_message, write_message}; @@ -121,6 +121,19 @@ mod crdt { .collect() } + fn decode_hex(encoded: &str) -> String { + assert_eq!(encoded.len() % 2, 0, "hex payload must have even length"); + let bytes = encoded + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let pair = std::str::from_utf8(pair).expect("hex pair is UTF-8"); + u8::from_str_radix(pair, 16).expect("decode hex pair") + }) + .collect::>(); + String::from_utf8(bytes).expect("snapshot text is UTF-8") + } + fn wait_for_fact( report: &Path, key: &str, @@ -231,11 +244,11 @@ mod crdt { .set_read_timeout(Some(Duration::from_secs(5))) .expect("set target frontend timeout"); let hello: Hello = read_message(&mut stream).expect("target frontend Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); write_message( &mut stream, &AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: FrontendCapabilities { multi_frontend: true, crdt_replica: true, @@ -356,6 +369,16 @@ mod crdt { } impl ManagedProbe { + fn from_child(mut child: Child, report: &Path) -> Self { + let stdin = child.stdin.take().expect("probe stdin"); + Self { + child, + stdin: Some(stdin), + report: report.to_owned(), + daemon_pid: None, + } + } + fn spawn(socket: &Path, report: &Path, daemon_executable: &Path, home: &Path) -> Self { Self::spawn_with_env(socket, report, daemon_executable, home, &[]) } @@ -418,18 +441,11 @@ mod crdt { for (key, value) in envs { command.env(key, value); } - let mut child = command.spawn().expect("spawn managed probe"); - let stdin = child.stdin.take().expect("probe stdin"); - Self { - child, - stdin: Some(stdin), - report: report.to_owned(), - daemon_pid: None, - } + Self::from_child(command.spawn().expect("spawn managed probe"), report) } - fn wait_ready(&mut self) -> HashMap { - let facts = wait_for_fact(&self.report, "phase", "ready", Duration::from_secs(10)); + fn wait_for(&mut self, key: &str, expected: &str) -> HashMap { + let facts = wait_for_fact(&self.report, key, expected, Duration::from_secs(10)); if facts .get("spawned_daemon") .is_some_and(|value| value == "true") @@ -439,6 +455,10 @@ mod crdt { facts } + fn wait_ready(&mut self) -> HashMap { + self.wait_for("phase", "ready") + } + fn close(mut self) -> std::process::ExitStatus { self.stdin.take(); wait_for_fact(&self.report, "phase", "complete", Duration::from_secs(5)); @@ -563,7 +583,7 @@ mod crdt { facts .get("server_protocol_version") .and_then(|value| value.parse::().ok()), - Some(PROTOCOL_VERSION) + Some(ADVERTISED_PROTOCOL_VERSION) ); assert_eq!( facts.get("spawned_daemon").map(String::as_str), @@ -662,6 +682,75 @@ mod crdt { assert!(probe.close().success()); } + #[test] + fn public_gpu_directory_target_reaches_dired_and_leaves_the_daemon_usable() { + let temp = secure_tempdir(); + let socket = temp.path().join("directory-target.sock"); + let report = temp.path().join("directory-target-report"); + let wrapper = temp.path().join("headless-gpu"); + let listed_name = "listed-before-bootstrap.txt"; + fs::write(temp.path().join(listed_name), "listed\n").expect("write listed file"); + write_script( + &wrapper, + "test \"$1\" = \"--managed-attach\"\n\ + socket=$2\n\ + daemon=$3\n\ + shift 3\n\ + exec \"$PMACS_REAL_GPU\" --headless-managed-probe \ + \"$socket\" \"$PMACS_TEST_REPORT\" \"$daemon\" \"$@\"", + ); + + let mut command = Command::new(pmacs_binary()); + command + .args(["--gpu", "--socket"]) + .arg(&socket) + .arg(".") + .current_dir(temp.path()) + .env(TEST_GPU_OVERRIDE, &wrapper) + .env("PMACS_REAL_GPU", gpu_binary()) + .env("PMACS_TEST_REPORT", &report) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut directory = + ManagedProbe::from_child(command.spawn().expect("spawn public GPU command"), &report); + + // The public root broker and real managed GPU connector must stay + // alive through Journey N2's asynchronous dired commit. Snapshot + // one is the deliberately pre-existing bootstrap document; snapshot + // two is the post-quiescence directory surface. + // Capture the spawned daemon's PID from the ready report first so + // ManagedProbe::drop can terminate it if snapshot two never arrives. + let ready = directory.wait_ready(); + assert_eq!( + ready.get("spawned_daemon").map(String::as_str), + Some("true") + ); + let facts = directory.wait_for("buffer_snapshots", "2"); + let listing = decode_hex(&facts["last_snapshot_hex"]); + let canonical = fs::canonicalize(temp.path()).expect("canonical directory"); + let mut lines = listing.lines(); + let expected_header = format!("{}:", canonical.display()); + assert_eq!( + lines.next(), + Some(expected_header.as_str()), + "the replacement snapshot must be dired's directory surface:\n{listing}" + ); + assert!( + lines.any(|line| line.trim_end().ends_with(listed_name)), + "dired must list the file that existed before bootstrap:\n{listing}" + ); + + fs::write(temp.path().join("still-alive.txt"), "alive\n").expect("write survivor"); + let survivor = attach_target(&socket, temp.path(), Path::new("still-alive.txt")); + assert_eq!(survivor.replica.materialize_string(), "alive\n"); + + drop(survivor); + assert!(directory.close().success()); + } + #[test] fn malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon() { let temp = secure_tempdir(); @@ -673,7 +762,6 @@ mod crdt { (cwd.clone(), Vec::new()), (cwd.clone(), b"bad\0name".to_vec()), (cwd.clone(), vec![b'x'; 32 * 1024 + 1]), - (cwd.clone(), b".".to_vec()), ]; for (index, (bad_cwd, bad_path)) in invalid.into_iter().enumerate() { let (frontend_id, mut stream, messages) = open_raw_target(&socket, bad_cwd, bad_path); diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs new file mode 100644 index 0000000..7ab36c0 --- /dev/null +++ b/tests/journey_acceptance.rs @@ -0,0 +1,1299 @@ +// tests/journey_acceptance.rs --- the golden product journey. + +//! The first cross-subsystem acceptance suite (`COHERENCE.md` §19, +//! `docs/journey-stage1a-framing.md` §5). +//! +//! Every other suite in the tree pins one subsystem's contract. This one +//! pins that the subsystems form a usable whole, walking `COHERENCE.md` +//! §2's twelve-step journey. Stage 1a seeds it with the steps that are +//! real today — 2 (launch unconfigured), 3 (open a real project), and 5 +//! (edit immediately). Steps 6–12 join as later stages make them real. +//! +//! **This file is a ratchet: stages add rows, none removes them.** +//! +//! Two disciplines it must keep: +//! +//! * **Drive the real entry point.** A directory arm with no production +//! caller passes every direct-call test, so step 3 goes through +//! `EditorState::open` — the same function `pmacs FILE` calls — and +//! not through `resolve_target_buffer`. +//! * **Pump to quiescence, never to a frame count.** Every listing is +//! worker-dispatched; `tick_async` resuming a coroutine in the frame +//! its result arrives does not bound when the worker finishes. +//! +//! Pins are labelled **N** (new behavior — must fail on full revert) or +//! **P** (preservation — legitimately green on the pre-image, falsified +//! by the named targeted mutation). See framing §6.0 for why the +//! distinction is load-bearing: an equivalence assertion between two +//! implementations that already agree proves nothing about structural +//! reuse. +//! +//! Two P pins here — P1 and P2 — *also* fail on full revert, since +//! `commit_to` does not exist on the pre-image. They are labelled P +//! because their discriminating falsifier is the named mutation: a +//! revert-only check cannot distinguish "validates" from "validates in +//! time", which is their entire claim. Each says so at its own site. + +use std::path::Path; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::buffer::BufferId; +use pmacs::editor::EditorState; +use pmacs::editor_core::normalize_buffer_path; +use pmacs::protocol::FrontendId; +use pmacs::window::{FrontendView, Layout, Window, WindowId}; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +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() +} + +/// Drive the async runtime to quiescence — no parked coroutine, no +/// pending worker job. The directory listing is invisible until this +/// returns, and how many frames it takes is not knowable in advance. +fn pump(s: &mut EditorState) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let idle: bool = eval( + s, + "return pmacs._async.parked_count() == 0 and pmacs._async.pending_count() == 0", + ); + if idle { + return; + } + assert!(Instant::now() < deadline, "async pump deadline exceeded"); + s.tick_async(); + } +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn type_char(s: &mut EditorState, c: char) { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE)); +} + +/// The 0-based line an entry renders on, found by its trailing name +/// column -- the same shape `dired_acceptance` uses. +fn line_of(s: &EditorState, name: &str) -> usize { + let text = active_text(s); + for (index, line) in text.lines().enumerate() { + if line.trim_end().ends_with(name) { + return index; + } + } + panic!("no listing line for {name:?} in:\n{text}"); +} + +/// A project a journey can plausibly be run against. +fn project() -> TempDir { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("alpha.txt"), b"alpha\n").expect("write alpha"); + std::fs::write(td.path().join("beta.txt"), b"beta\n").expect("write beta"); + td +} + +fn canon(path: &Path) -> String { + normalize_buffer_path(path.to_path_buf()) + .to_string_lossy() + .into_owned() +} + +fn active_name(s: &EditorState) -> String { + eval(s, "return pmacs.window.buffer():name()") +} + +fn active_text(s: &EditorState) -> String { + eval( + s, + "local b = pmacs.window.buffer()\nreturn b:slice(0, b:len())", + ) +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +fn buffer_count(s: &EditorState) -> usize { + s.core.borrow().registry.borrow().ids().len() +} + +/// The buffer a window currently shows, or `None` if it is not live. +fn buffer_in(s: &EditorState, window: WindowId) -> Option { + s.core.borrow().windows.get(&window).map(|w| w.buffer_id) +} + +/// The window `LOCAL` currently has selected. +fn local_window(s: &EditorState) -> WindowId { + s.core + .borrow() + .views + .get(&FrontendId::LOCAL) + .expect("LOCAL view") + .active +} + +/// Register a second frontend with its own single-window layout, +/// mirroring `build_fresh_frontend_view` (the same helper shape +/// `bottom_panel_stage1_acceptance` uses). +fn attach_frontend(s: &EditorState, fid: FrontendId) -> WindowId { + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let win = WindowId::next(); + core.windows + .insert(win, Window::new(win, buffer_id, text_view)); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout::single(win), + active: win, + fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + win +} + +/// Drive the **real** chain far enough to obtain a genuine destination +/// and leave it in the Lua global `dest`. +/// +/// The listener claims (returns `false`), so nothing is committed and no +/// fallback runs: what lands in `dest` is exactly the userdata dired +/// would have received, produced by the production capture rather than +/// fabricated. Nothing in the test suite can construct one — that is +/// N6b's whole subject. +fn capture_dest(s: &mut EditorState, dir: &Path) { + exec( + s, + "dest = nil + pmacs.hook.add('path.open-directory', function(_, d) dest = d return false end)", + ); + s.open_directory_target(dir); + pump(s); + assert!( + eval::(s, "return dest ~= nil"), + "the chain must hand listeners a destination" + ); +} + +/// Open through the **real** startup entry point, as `pmacs PATH` does. +fn launch(path: &Path) -> EditorState { + let mut s = EditorState::open(path.to_path_buf()).expect("startup must not fail"); + exec(&s, "pmacs.lsp.config = {}"); + pump(&mut s); + s +} + +// --------------------------------------------------------------------------- +// Step 2 — launch unconfigured +// --------------------------------------------------------------------------- + +/// **N** — the editor starts with no configuration and no arguments. +#[test] +fn journey_step2_launches_unconfigured_into_scratch() { + let s = EditorState::new(); + assert_eq!(active_name(&s), "*scratch*"); + assert!( + status(&s).is_empty(), + "a clean launch reports no error; got {:?}", + status(&s) + ); +} + +// --------------------------------------------------------------------------- +// Step 3 — open a real project +// --------------------------------------------------------------------------- + +/// **N1** — `pmacs .` opens the directory. +/// +/// The headline of Stage 1a and of `COHERENCE.md` §2's "broken at step +/// 3" grade. Before the directory arm this construction returned +/// `Err(EISDIR)` and `main` exited 1. +#[test] +fn journey_step3_opening_a_directory_lists_it() { + let td = project(); + let s = launch(td.path()); + + let name = active_name(&s); + assert_eq!( + name, + format!("*dired:{}*", canon(td.path())), + "the active buffer must be the directory's dired buffer" + ); + let text = active_text(&s); + assert!( + text.contains("alpha.txt") && text.contains("beta.txt"), + "the listing must show the directory's entries; got {text:?}" + ); +} + +/// **N1b** — and it is a *successful* startup, not a rescued failure. +/// +/// Guards the specific regression shape: an implementation that opened +/// dired but still left an error on the status line would look right in +/// the assertion above while `pmacs .` still printed a diagnostic. +#[test] +fn journey_step3_directory_startup_reports_no_error() { + let td = project(); + let s = launch(td.path()); + assert!( + !status(&s).contains("cannot open"), + "a successful directory open must not leave an error status; got {:?}", + status(&s) + ); +} + +/// **N3** — an unreadable directory reports and leaves the session +/// running, rather than failing startup. +#[cfg(target_os = "linux")] +#[test] +fn journey_step3_unreadable_directory_reports_without_failing_startup() { + use std::os::unix::fs::PermissionsExt; + let td = tempfile::tempdir().expect("tempdir"); + let locked = td.path().join("locked"); + std::fs::create_dir(&locked).expect("mkdir"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod"); + + // Startup itself must succeed: the failure is the *listing*, which + // happens a tick later and belongs on the status line. + let s = launch(&locked); + assert!( + !status(&s).is_empty(), + "a failed listing must report through the status line" + ); + assert!( + !active_name(&s).starts_with("*dired:"), + "a failed listing must leave no dired buffer behind" + ); + + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).expect("restore"); +} + +/// **N9** — the resolver receives a canonical absolute path. +/// +/// Falsified by dropping the normalization in +/// `ResolvedTarget::Directory`: nothing else normalizes on that arm, +/// because no buffer is created and `set_buffer_path` never runs. +#[test] +fn journey_directory_resolver_receives_a_canonical_path() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + exec( + &s, + "seen = nil + pmacs.hook.add('path.open-directory', function(path) seen = path return false end)", + ); + + // A path with a redundant component, which only canonicalization removes. + let noisy = td.path().join("subdir").join(".."); + std::fs::create_dir_all(td.path().join("subdir")).expect("mkdir"); + s.open_directory_target(&noisy); + pump(&mut s); + + let seen: String = eval(&s, "return seen"); + assert_eq!( + seen, + canon(td.path()), + "the resolver must receive the canonical path, not the literal argument" + ); +} + +/// **N10** — with the handler cleared and nothing claiming, a directory +/// argument still starts successfully. +/// +/// The regression path back to exit 1. Reachable only because the +/// fallback is a clearable slot rather than a builtin hook subscription. +#[test] +fn journey_unclaimed_directory_starts_successfully_with_a_status() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + exec(&s, "pmacs.path.set_directory_handler(nil)"); + + let before = active_name(&s); + s.open_directory_target(td.path()); + pump(&mut s); + + assert_eq!( + active_name(&s), + before, + "with no handler the window keeps the buffer it had" + ); + assert!( + status(&s).contains(&canon(td.path())), + "the status must name the directory nothing surfaced; got {:?}", + status(&s) + ); +} + +// --------------------------------------------------------------------------- +// The resolver chain +// --------------------------------------------------------------------------- + +/// **N7** — first claimant wins, through an ordinary user listener, and +/// a claim suppresses the fallback. +#[test] +fn journey_resolver_chain_is_first_claimant_wins() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + exec( + &s, + "first, second, fallback_ran = false, false, false + pmacs.path.set_directory_handler(function() fallback_ran = true end) + pmacs.hook.add('path.open-directory', function() first = true return false end) + pmacs.hook.add('path.open-directory', function() second = true return false end)", + ); + + s.open_directory_target(td.path()); + pump(&mut s); + + assert!(eval::(&s, "return first"), "the first listener runs"); + assert!( + !eval::(&s, "return second"), + "a claim stops the fan-out before the second listener" + ); + assert!( + !eval::(&s, "return fallback_ran"), + "a claim suppresses the fallback" + ); +} + +/// **N8** — a raising listener suppresses the fallback *and* is +/// reported. +/// +/// Falsified by running the fallback when `errors` is non-empty (i.e. +/// treating a raise as a decline), or by making a raise yield +/// `proceed = true`. NOT falsified by keying suppression on `proceed` +/// alone — that is already correct, since a raise and a claim both give +/// `proceed == false`. +#[test] +fn journey_a_raising_resolver_suppresses_the_fallback_and_reports() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + exec( + &s, + "fallback_ran = false + pmacs.path.set_directory_handler(function() fallback_ran = true end) + pmacs.hook.add('path.open-directory', function() error('resolver exploded') end)", + ); + + s.open_directory_target(td.path()); + pump(&mut s); + + assert!( + !eval::(&s, "return fallback_ran"), + "a crashed resolver must not fall through to the default surface" + ); + assert!( + !status(&s).is_empty(), + "the failure must reach the status line, not only *errors*" + ); + let errors: String = eval( + &s, + "for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == '*errors*' then + return id:slice(0, id:len()) + end + end + return ''", + ); + assert!( + errors.contains("resolver exploded"), + "the failure must also reach the *errors* buffer; got {errors:?}" + ); +} + +// --------------------------------------------------------------------------- +// The destination commit (`pmacs.window.commit_to`) +// --------------------------------------------------------------------------- +// +// The substrate half of Stage 1a. A directory listing settles a tick or +// more after the request, by which time the ambient frontend, selected +// window, and active buffer may all name something else — so the whole +// post-await commit runs against a destination captured at request time. +// +// `LOCAL` is the requesting frontend throughout, because +// `open_directory_target` is the local-startup seam; the daemon's +// non-`LOCAL` capture is pinned in `src/daemon.rs`, where the production +// caller lives. What varies here is what the *ambient* frontend is doing +// while the commit runs, which is exactly the misrouting the scope +// exists to prevent. + +/// The frontend that competes for ambient authority in these tests. +const COMPETITOR: FrontendId = FrontendId(7); + +/// **N4** — the commit lands in the *requesting* frontend's window even +/// though another frontend is the one dispatching. +/// +/// The blocker's positive half. Falsified by reverting `commit_to` to an +/// ambient display: the file then appears in the competitor's window. +#[test] +fn commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + let local_win = local_window(&s); + let other_win = attach_frontend(&s, COMPETITOR); + let other_before = buffer_in(&s, other_win); + + // The competitor becomes the dispatching frontend while the work is + // "in flight" — the state a worker completion actually returns to. + s.core.borrow_mut().active_frontend = COMPETITOR; + + let alpha = td.path().join("alpha.txt").display().to_string(); + exec( + &s, + &format!( + "assert(pmacs.window.commit_to(dest, function() + pmacs.window.display_file({alpha:?}) + end))" + ), + ); + + assert_eq!( + buffer_in(&s, other_win), + other_before, + "the competing frontend's window must be untouched" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert_eq!( + active_name(&s), + alpha, + "the commit must land in the requesting frontend's captured window" + ); + assert_eq!( + local_window(&s), + local_win, + "and in that window, not a new one" + ); +} + +/// **N4b** — the scope beats an *interactive origin*, not merely the +/// ambient frontend. +/// +/// Found by bite-testing N4: with the `ScopedFrontend` arm deleted from +/// `acting_frontend`, N4 still passed, because `ScopedFrontend::enter` +/// also swaps `core.active_frontend` and the ambient fallback then +/// answers correctly on its own. The arm is load-bearing in exactly one +/// situation — a commit reached from inside an interactive command, +/// where the origin sits *between* the override and the ambient value +/// and would otherwise win. `acting_frontend`'s comment claims that +/// ordering; nothing pinned it. +/// +/// Driven through `dispatch_key`, because the interactive origin is +/// established by dispatch and by nothing else — `invoke_interactive` +/// requires a context rather than creating one. +/// +/// Falsified by deleting the `ScopedFrontend` arm from +/// `acting_frontend`, or by reordering it after the interactive origin. +#[test] +fn commit_to_outranks_an_interactive_origin() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + let local_win = local_window(&s); + let other_win = attach_frontend(&s, COMPETITOR); + let other_before = buffer_in(&s, other_win); + + let alpha = td.path().join("alpha.txt").display().to_string(); + exec( + &s, + &format!( + "pmacs.command.define {{ + name = 'test.journey-commit', + description = 'commit to a captured destination from inside a command', + fn = function() + committed = pmacs.window.commit_to(dest, function() + pmacs.window.display_file({alpha:?}) + end) + end, + }} + pmacs.keymap.bind {{ scope = 'global', sequence = 'C-c j', + command = 'test.journey-commit' }}" + ), + ); + + // The COMPETITOR runs the command, so ITS id is the interactive + // origin for the whole invocation. + s.dispatch_key(COMPETITOR, key(KeyCode::Char('c'), KeyModifiers::CONTROL)); + s.dispatch_key(COMPETITOR, key(KeyCode::Char('j'), KeyModifiers::NONE)); + + assert!( + eval::(&s, "return committed"), + "the commit must be accepted" + ); + assert_eq!( + buffer_in(&s, other_win), + other_before, + "the invoking frontend's own window must be untouched" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert_eq!( + active_name(&s), + alpha, + "the commit must land in the captured destination, not the \ + interactive origin's window" + ); + assert_eq!(local_window(&s), local_win); +} + +/// **N4c** — the commit lands in the *captured window*, not merely in +/// the captured frontend's currently selected one. +/// +/// Review finding on PR #182. Every other routing pin here varies +/// frontend identity; none varied the selected window *within* one +/// frontend, and dired's commit still ended in `switch_buffer`, which +/// targets whatever window the scoped frontend has active. The preflight +/// cannot catch this — the captured window is still live and still holds +/// its captured buffer — so a split that took focus while `read_dir` was +/// pending got the listing, and `prev` was captured from it too. +/// +/// Both halves are asserted: where the listing lands, and where `q` +/// goes. Falsified by restoring `pmacs.window.switch_buffer` in dired's +/// `display`, or by reading `prev` from the ambient window. +#[test] +fn a_background_open_uses_the_captured_window_not_the_selected_one() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + let target = local_window(&s); + let origin = buffer_in(&s, target).expect("the captured window's buffer"); + + // Split, move focus to the OTHER window, and give it a buffer of its + // own. The captured window is untouched, so every preflight check + // still passes -- which is exactly why this needs its own pin. + exec( + &s, + "local captured = dest:window() + pmacs.window.split_horizontal() + while pmacs.window.current() == captured do pmacs.window.focus_next() end + pmacs.window.switch_buffer(pmacs.buffer.create('*elsewhere*'))", + ); + let elsewhere = local_window(&s); + assert_ne!(elsewhere, target, "focus must have moved to another window"); + let elsewhere_buffer = buffer_in(&s, elsewhere); + + // dired's real handler path, with the captured destination. + exec( + &s, + &format!( + "pmacs.async(function() + pmacs.dired.open({:?}, {{ dest = dest }}) + end)", + canon(td.path()) + ), + ); + pump(&mut s); + + assert_eq!( + buffer_in(&s, elsewhere), + elsewhere_buffer, + "the window that took focus mid-listing must be untouched" + ); + assert_eq!( + local_window(&s), + target, + "the commit must select the captured window" + ); + assert!( + active_name(&s).starts_with("*dired:"), + "and the listing must be in it; got {:?}", + active_name(&s) + ); + + // `prev` came from the captured window too, not from `*elsewhere*`. + type_char(&mut s, 'q'); + assert_eq!( + buffer_in(&s, target), + Some(origin), + "`q` must return to the buffer the CAPTURED window showed" + ); +} + +/// **N6a** — the scope is restored when the callback returns normally. +/// +/// Falsified by dropping the guard's restore, or by never swapping +/// `core.active_frontend` in the first place (then `inside` reads the +/// competitor and the assertion fails from the other direction). +#[test] +fn commit_to_scopes_and_restores_on_a_normal_return() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + attach_frontend(&s, COMPETITOR); + s.core.borrow_mut().active_frontend = COMPETITOR; + + exec( + &s, + "inside, scoped = nil, nil + assert(pmacs.window.commit_to(dest, function() + inside = pmacs.frontend.id() + scoped = pmacs._async._in_commit_scope() + end))", + ); + + assert_eq!( + eval::(&s, "return inside"), + i64::try_from(FrontendId::LOCAL.0).expect("frontend id"), + "inside the commit the acting frontend is the requesting one" + ); + assert!( + eval::(&s, "return scoped"), + "and the commit-scope flag is set while the callback runs" + ); + assert_eq!( + s.core.borrow().active_frontend, + COMPETITOR, + "the ambient frontend must be restored on return" + ); + assert!( + !eval::(&s, "return pmacs._async._in_commit_scope()"), + "and the commit-scope flag cleared" + ); + assert_eq!( + eval::(&s, "return pmacs.frontend.id()"), + i64::try_from(COMPETITOR.0).expect("frontend id"), + "the Lua-visible frontend must be restored too" + ); +} + +/// **N6b (part of N6)** — a raising callback still restores. +/// +/// The path that makes the guard RAII rather than a pair of statements: +/// `commit_to` captures the call's result and lets the guard drop before +/// propagating it. Falsified by `?`-propagating the callback's error +/// through the scope, or by restoring on the success path only. +#[test] +fn commit_to_restores_when_the_callback_raises() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + attach_frontend(&s, COMPETITOR); + s.core.borrow_mut().active_frontend = COMPETITOR; + + exec( + &s, + "local ok, err = pcall(pmacs.window.commit_to, dest, function() + error('commit exploded') + end) + raised = (not ok) and tostring(err) or ''", + ); + + assert!( + eval::(&s, "return raised").contains("commit exploded"), + "the callback's error must propagate" + ); + assert_eq!( + s.core.borrow().active_frontend, + COMPETITOR, + "a raising callback must still restore the ambient frontend" + ); + assert!( + !eval::(&s, "return pmacs._async._in_commit_scope()"), + "and must still clear the commit-scope flag" + ); +} + +/// **N6c (part of N6)** — awaiting inside a commit is refused, the +/// refusal names the rule, and the scope is restored anyway. +/// +/// A yield would restore the scope while the coroutine is still parked, +/// so the rest of the commit would resume ambient — silently +/// reintroducing exactly the misrouting N4 pins against. Driven inside +/// `pmacs.async`, which is where a real await lives. +/// +/// Falsified by dropping the `_in_commit_scope` check from +/// `Handle:await`: the await then succeeds and `refusal` reads +/// ``. +#[test] +fn commit_to_refuses_an_await_and_restores() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + attach_frontend(&s, COMPETITOR); + s.core.borrow_mut().active_frontend = COMPETITOR; + + exec( + &s, + &format!( + "refusal = nil + pmacs.async(function() + local handle = pmacs.fs.read_dir({:?}) + local ok, err = pcall(pmacs.window.commit_to, dest, function() + return handle:await() + end) + refusal = (not ok) and tostring(err) or '' + -- Drain it OUTSIDE the commit, which is where the refusal + -- says the await belongs -- and which also settles the job + -- so the pump can reach quiescence. + handle:await() + end)", + td.path().display().to_string() + ), + ); + pump(&mut s); + + let refusal: String = eval(&s, "return refusal"); + assert!( + refusal.contains("cannot await inside") && refusal.contains("commit_to"), + "the refusal must name the rule it enforces; got {refusal:?}" + ); + assert_eq!( + s.core.borrow().active_frontend, + COMPETITOR, + "a refused await must still restore the ambient frontend" + ); + assert!( + !eval::(&s, "return pmacs._async._in_commit_scope()"), + "and must still clear the commit-scope flag" + ); +} + +/// **N6b** — a forged destination is rejected, and the callback never +/// runs. +/// +/// A plausible `{frontend, window, buffer}` table is what any Lua could +/// fabricate. Falsified by accepting a table, or by borrowing the +/// userdata after invoking the callback. +#[test] +fn commit_to_refuses_a_forged_destination() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + let win = eval::(&s, "return dest:window()"); + exec( + &s, + &format!( + "ran = false + local ok, err = pcall(pmacs.window.commit_to, + {{ frontend = 0, window = {win}, buffer = 0 }}, + function() ran = true end) + rejected = (not ok) and tostring(err) or ''" + ), + ); + + let rejected: String = eval(&s, "return rejected"); + assert!( + rejected.contains("cannot be constructed from Lua"), + "a forged table must be rejected by type, not merely fail later; got {rejected:?}" + ); + assert!( + !eval::(&s, "return ran"), + "a rejected destination must not reach the callback" + ); +} + +/// **N6c** — a declining listener cannot redirect the destination. +/// +/// The same userdata is handed to every listener in turn. As a table, an +/// earlier listener could rewrite the window and then decline, sending +/// the fallback somewhere the user never asked for. Falsified by passing +/// a shared mutable table. +#[test] +fn a_declining_listener_cannot_redirect_the_destination() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + let target = local_window(&s); + + exec( + &s, + "seen_first, seen_second, mutation = nil, nil, nil + pmacs.hook.add('path.open-directory', function(_, d) + seen_first = d:window() + -- Try to redirect, then decline. Both halves matter: a + -- successful mutation with a decline is the attack. + local ok, err = pcall(function() d.window = 999 end) + mutation = (not ok) and tostring(err) or '' + end) + pmacs.hook.add('path.open-directory', function(_, d) + seen_second = d:window() + end)", + ); + + s.open_directory_target(td.path()); + pump(&mut s); + + let mutation: String = eval(&s, "return mutation"); + assert!( + !mutation.contains(""), + "the destination must be read-only; got {mutation:?}" + ); + let first = eval::(&s, "return seen_first"); + let second = eval::(&s, "return seen_second"); + assert_eq!( + first, second, + "every listener must see the same, unaltered destination" + ); + assert_eq!( + u64::try_from(second).expect("window id"), + target.raw(), + "and it must still name the window the editor captured" + ); + // And the fallback commits THERE, not to whatever the first listener + // wanted -- the observable the attack was aiming at. + assert!( + active_name(&s).starts_with("*dired:"), + "the declined chain must still fall back to dired" + ); + assert_eq!( + buffer_in(&s, target), + Some(eval::(&s, "return pmacs.window.buffer()").0), + "in the captured window" + ); +} + +// --- the commit's preservation pins --------------------------------------- + +/// **P1** — every destination precondition is checked *before* the +/// callback runs, so a failure mutates nothing. +/// +/// Four refusals, each asserted the same way: `commit_to` returns +/// `(false, reason)`, the callback never ran, and no buffer was created. +/// Table-driven deliberately — the failure message names which +/// precondition regressed, which four separate near-identical tests +/// would give up in exchange for nothing. +/// +/// *Mutation:* move the preflight from before the callback to after it +/// (rev 2's design, which validated at display time). All four fail. +/// *Second mutation, for the dedicated case:* pass `Some(dest.buffer)` +/// instead of `None` to `window_accepts_buffer`. Only that case fails — +/// which is why it is listed separately from the stale-buffer case it +/// otherwise resembles. +/// +/// **Also fails on full revert**, since `commit_to` does not exist on the +/// pre-image. It is listed as a P because the discriminating falsifier is +/// the named mutation, not the revert: a revert-only check would not +/// distinguish "validates" from "validates in time". +#[test] +fn preservation_a_failed_precondition_never_reaches_the_callback() { + // (label, Lua that breaks the precondition, expected reason fragment) + let cases: [(&str, &str, &str); 4] = [ + ( + "frontend gone", + // Handled in Rust below: unregistering a view has no Lua surface. + "", + "requesting frontend is gone", + ), + ( + "window gone", + "local doomed = dest:window() + pmacs.window.split_horizontal() + while pmacs.window.current() == doomed do pmacs.window.focus_next() end + pmacs.window.close_others()", + "is gone", + ), + ( + "stale buffer", + "pmacs.window.switch_buffer(pmacs.buffer.create('*usurper*'))", + "now shows another buffer", + ), + ( + "dedicated", + "pmacs.window.set_params(dest:window(), { dedicated = true })", + "is dedicated", + ), + ]; + + for (label, break_it, expected) in cases { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + if label == "frontend gone" { + s.core + .borrow_mut() + .unregister_frontend_view(FrontendId::LOCAL); + } else { + exec(&s, break_it); + } + let before = buffer_count(&s); + + exec( + &s, + "ran = false + ok, reason = pmacs.window.commit_to(dest, function() ran = true end)", + ); + + assert!( + !eval::(&s, "return ok"), + "{label}: commit_to must refuse" + ); + let reason: String = eval(&s, "return tostring(reason)"); + assert!( + reason.contains(expected), + "{label}: reason must say why; wanted {expected:?}, got {reason:?}" + ); + assert!( + !eval::(&s, "return ran"), + "{label}: the callback must not run at all -- validating after it \ + is four mutations too late" + ); + assert_eq!( + buffer_count(&s), + before, + "{label}: a refused commit must create no buffer" + ); + } +} + +/// **P2 — stale intent loses**, through dired's real commit path. +/// +/// The user replaced the destination window's buffer while the listing +/// was in flight. Their action is newer information than the request, so +/// the request loses: dired refuses, their buffer survives, and no dired +/// buffer or handle is left behind for that path. +/// +/// P1 pins the preflight in isolation; this drives `pmacs.dired.open` +/// with a captured destination — the same call the handler makes — so +/// the atomicity claim is asserted where the four mutations actually +/// live. +/// +/// *Mutation:* drop the `dest.buffer` comparison from the preflight +/// (window-only validation). The dired buffer then replaces the user's. +#[test] +fn preservation_a_stale_destination_loses_to_the_users_newer_buffer() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + let target = local_window(&s); + + // The user switches the destination window while the work is in flight. + exec( + &s, + "usurper = pmacs.buffer.create('*usurper*') + pmacs.window.switch_buffer(usurper)", + ); + let usurper = buffer_in(&s, target); + let before = buffer_count(&s); + + exec( + &s, + &format!( + "failure = nil + pmacs.async(function() + local ok, err = pcall(pmacs.dired.open, {:?}, {{ dest = dest }}) + failure = (not ok) and tostring(err) or '' + end)", + canon(td.path()) + ), + ); + pump(&mut s); + + let failure: String = eval(&s, "return failure"); + assert!( + failure.contains("destination is gone"), + "dired must report the refusal rather than commit; got {failure:?}" + ); + assert_eq!( + buffer_in(&s, target), + usurper, + "the user's newer buffer must survive" + ); + assert_eq!( + buffer_count(&s), + before, + "and no dired buffer may be left behind" + ); + assert_eq!( + active_name(&s), + "*usurper*", + "nor may the refusal change what is displayed" + ); +} + +/// **P3** — dired reads its `prev` inside the scope, so `q` returns to +/// the *destination* window's buffer, not the ambient frontend's. +/// +/// `handle.prev` is captured with `pmacs.window.buffer()`, whose no-arg +/// arm reads the core's ambient `active_buffer_id()`. That is precisely +/// why the scope swaps `core.active_frontend` and not only the override: +/// a scope that swapped the override alone would leave this one line +/// reading the competitor's buffer, and `q` would drop the user into a +/// buffer from another frontend's window. +/// +/// Asserted through `q` rather than by reaching into dired's handle +/// table — `prev`'s entire meaning is where `q` lands. +/// +/// *Mutation:* stop swapping `core.active_frontend` in +/// `ScopedFrontend::enter` (keep the override). `q` then lands in +/// `*competitor*`. +#[test] +fn preservation_dired_captures_prev_from_the_destination_not_the_ambient_frontend() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + + let target = local_window(&s); + let origin = buffer_in(&s, target).expect("the startup buffer"); + + // A competitor whose window shows a buffer of its own, ambient while + // the listing settles. + let other_win = attach_frontend(&s, COMPETITOR); + let competitor_buffer = + eval::(&s, "return pmacs.buffer.create('*competitor*')") + .0; + s.core + .borrow_mut() + .install_buffer_in_window(other_win, competitor_buffer) + .expect("install"); + s.core.borrow_mut().active_frontend = COMPETITOR; + + s.open_directory_target(td.path()); + pump(&mut s); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert!( + active_name(&s).starts_with("*dired:"), + "the listing must have committed" + ); + + type_char(&mut s, 'q'); + assert_eq!( + buffer_in(&s, target), + Some(origin), + "`q` must return to the buffer the DESTINATION window showed, not \ + the ambient frontend's" + ); +} + +// --------------------------------------------------------------------------- +// Step 5 — edit immediately +// --------------------------------------------------------------------------- + +/// **N11** — the journey's step-3-into-step-5 path, through the real +/// input path at every step: start on a directory, press `RET` on a +/// listed file, then type a character into it. +/// +/// Rev 6 correction: this previously called `display_file` and +/// `buf:insert` directly, so it stayed green with dired's `RET` binding, +/// its entry dispatch, or the editor's self-insert path all broken — +/// which is most of what "the journey works" is supposed to mean. Both +/// gestures are now dispatched as keys. +/// +/// Deliberately not a self-insert into the dired buffer, whose intercept +/// rejects every edit: asserting an edit lands there would contradict +/// the read-only contract rather than pin the journey. +#[test] +fn journey_step5_editing_a_file_reached_through_the_directory() { + let td = project(); + let mut s = launch(td.path()); + assert!(active_name(&s).starts_with("*dired:")); + + // Seat on the entry, then VISIT it with the real key. + let line = line_of(&s, "alpha.txt"); + exec(&s, &format!("pmacs.editor.move_to_line({line})")); + press(&mut s, KeyCode::Enter); + pump(&mut s); + + assert_eq!( + active_name(&s), + td.path().join("alpha.txt").display().to_string(), + "RET on a listed file must visit it" + ); + + // And type into it with the real key. + type_char(&mut s, 'X'); + let text = active_text(&s); + assert!( + text.starts_with('X'), + "a self-insert must land in the visited file's buffer; got {text:?}" + ); + assert!( + buffer_count(&s) >= 2, + "the dired buffer and the visited file both exist" + ); +} + +// --------------------------------------------------------------------------- +// Preservation pins (P) — green on the pre-image; see the named mutation +// --------------------------------------------------------------------------- + +/// **P4** — startup shows the file in the *active* window. +/// +/// *Mutation:* replace `replace_active_buffer` with a bare +/// `install_buffer_in_window` into some other window in +/// `EditorState::open`. +/// +/// **Note, found during implementation:** this does NOT assert that the +/// initial scratch buffer is destroyed, because it is not. +/// `replace_active_buffer`'s doc comment claims it drops "any old +/// scratch buffer if the active window's previous buffer has no other +/// windows referencing it", but all it does is call +/// `switch_active_buffer`, which reassigns the window's `buffer_id` and +/// never removes anything. The stale scratch survives in the registry +/// today, on `main`, unrelated to this stage — so asserting otherwise +/// would have pinned a guarantee the editor does not make and failed on +/// the pre-image for the wrong reason. What the unification must +/// preserve is which window shows the file, and that is what this pins. +#[test] +fn preservation_opening_a_file_shows_it_in_the_active_window() { + let td = project(); + let target = td.path().join("alpha.txt"); + let s = EditorState::open(target.clone()).expect("open"); + + // The displayed name is the argument as given (`path.display()`), + // which both implementations have always produced -- the *stored* + // path is what gets normalized, inside `set_buffer_path`. + assert_eq!( + active_name(&s), + target.display().to_string(), + "the file must be in the active window, not merely loaded" + ); + let scratch_displayed: bool = eval( + &s, + "for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == '*scratch*' and pmacs.window.buffer() == id then + return true + end + end + return false", + ); + assert!( + !scratch_displayed, + "no window may still be showing the startup scratch buffer" + ); +} + +/// **P5** — the `NotFound` arm survives the unification. +/// +/// *Mutation:* delete the `NotFound` arm from `resolve_target_buffer`. +/// The arm most likely to be lost in a wholesale refactor, because its +/// failure mode is a hard error on a perfectly ordinary gesture. +#[test] +fn preservation_a_missing_path_becomes_a_new_file_buffer() { + let td = project(); + let fresh = td.path().join("not-yet.txt"); + let s = EditorState::open(fresh.clone()).expect("a missing path is not an error"); + + assert_eq!(status(&s), "[new file]"); + let len: usize = eval(&s, "return pmacs.window.buffer():len()"); + assert_eq!(len, 0, "a new-file buffer starts empty"); + assert!(!fresh.exists(), "nothing is written until save"); +} + +/// **P8** — a startup failure names the file. +/// +/// The message gained a `cannot open {path}: ` prefix in Stage 1a; the +/// *failure* is preserved, only its wording improved. Before, the bare +/// `io::Error` never named the path. +#[cfg(target_os = "linux")] +#[test] +fn preservation_an_unreadable_file_reports_with_its_path() { + use std::os::unix::fs::PermissionsExt; + let td = project(); + let locked = td.path().join("locked.txt"); + std::fs::write(&locked, b"secret\n").expect("write"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod"); + + let rendered = match EditorState::open(locked.clone()) { + Ok(_) => panic!("an unreadable file must fail"), + Err(error) => error.to_string(), + }; + + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).expect("restore"); + + assert!( + rendered.contains("cannot open"), + "the message must say what failed; got {rendered:?}" + ); + assert!( + rendered.contains(&locked.display().to_string()), + "the message must name the file; got {rendered:?}" + ); +} + +// **P7 — removed in rev 6, not weakened.** +// +// Q#JR12 said a directory argument must suppress desktop restore, and +// rev 5 carried a pin for it. There is nothing to pin. `run` computes +// `had_file = file.is_some()` (`editor.rs:3152`) and a directory path is +// `Some` like any other, so the suppression is structural: no +// directory-specific branch exists that could get it wrong, and the +// named mutation ("pass false for `had_file` on the directory path") +// would require inventing the branch first. +// +// The rev 5 test also never armed desktop restore and hard-coded +// `had_file = true` after startup, so it asserted nothing about `run`'s +// decision and would have passed against any implementation. Keeping a +// green test that cannot fail is worse than having none: it reads as +// coverage. Q#JR12 is downgraded to an observation in the framing. + +/// **P6** — `display_file` keeps its directory-is-an-error contract and +/// does not enter the resolver chain. +/// +/// *Mutation:* route `display_file` into the directory resolver. +/// `find_file_accepting_a_directory_reports_instead_of_raising` in +/// `find_file_acceptance.rs` is the companion pin through find-file's +/// real accept path; this one pins the primitive and the window state. +#[test] +fn preservation_display_file_still_refuses_a_directory() { + let td = project(); + let mut s = EditorState::open(td.path().join("alpha.txt")).expect("open"); + exec(&s, "pmacs.lsp.config = {}"); + let before_name = active_name(&s); + let before_count = buffer_count(&s); + + let raised: bool = eval( + &s, + &format!( + "local ok = pcall(pmacs.window.display_file, {:?}) return not ok", + td.path().display().to_string() + ), + ); + pump(&mut s); + + assert!(raised, "display_file on a directory must raise"); + assert_eq!( + active_name(&s), + before_name, + "a refused display_file must not change the active buffer" + ); + assert_eq!( + buffer_count(&s), + before_count, + "a refused display_file must not create a buffer" + ); +} 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/lean_input_acceptance.rs b/tests/lean_input_acceptance.rs new file mode 100644 index 0000000..9b6a3b8 --- /dev/null +++ b/tests/lean_input_acceptance.rs @@ -0,0 +1,1020 @@ +//! Lean 4 Unicode input method acceptance (Arc 8 Stage 4b, +//! docs/lean4-mode-framing.md Q#LN11/Q#LN21/Q#LN22, criteria 38–45i). +//! +//! Dispatch-driven throughout: `dispatch_key` is the producer that arms +//! the typed-edit record for a grid frontend. The optimistic CRDT +//! producer is criterion 45f and lives in a `--lib` test, where the gate +//! list's `--features crdt` run reaches it. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; +use pmacs::protocol::FrontendId; +use pmacs::window::{FrontendView, Layout, Window, WindowId}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +fn fresh_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-leaninput-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::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 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 cursor(s: &EditorState) -> i64 { + eval(s, "return pmacs.editor.cursor()") +} + +fn type_as(s: &mut EditorState, fid: FrontendId, chars: &str) { + for ch in chars.chars() { + s.dispatch_key(fid, key(KeyCode::Char(ch))); + } +} + +fn type_str(s: &mut EditorState, chars: &str) { + type_as(s, FrontendId::LOCAL, chars); +} + +/// An editor with an empty `.lean` file open and the point at 0. +/// `pmacs.lsp.config = {}` keeps the real user config from spawning a +/// server; the language still resolves from the extension. +fn lean_editor() -> (EditorState, PathBuf) { + let dir = fresh_dir(); + let f = dir.join("a.lean"); + std::fs::write(&f, "").unwrap(); + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + let fd = f.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + assert_eq!( + eval::>( + &s, + "return pmacs.lsp.buffer_language(pmacs.window.buffer())" + ) + .as_deref(), + Some("lean4"), + "the fixture must actually be a lean4 buffer, or every \ + expansion assertion below is vacuous" + ); + (s, f) +} + +// --------------------------------------------------------------------------- +// 38 / 41 — the two expansion paths, and what an undo restores +// --------------------------------------------------------------------------- + +#[test] +fn the_finish_path_retains_the_terminator_in_one_undo_step() { + // `alp` is not a key; `alpha` is the shortest key extending it. The + // space does not extend anything, so it lands first and the + // expansion replaces the leader and the typed text — the span stops + // BEFORE the terminator, so whatever auto-pairing did with it + // survives. One undo restores the same text either way, because the + // terminator was its own insert to begin with. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp "); + assert_eq!(text(&s), "α ", "terminator retained, not consumed"); + + exec(&s, "pmacs.window.buffer():undo()"); + assert_eq!( + text(&s), + "\\alp ", + "one undo restores the pre-expansion text WITH its terminator — \ + the expansion is a single edit" + ); +} + +#[test] +fn the_eager_path_takes_no_terminator_and_undoes_separately() { + // `alpha` has no longer key extending it, so it is one of the 1,550 + // eager keys: it expands the moment the final `a` lands, and a + // following space is a SEPARATE edit. Rev 8 asserted the finish-path + // undo text for this example, which is the trap (round 9). + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "α", "eager expansion, no terminator typed"); + + type_str(&mut s, " "); + assert_eq!(text(&s), "α "); + exec(&s, "pmacs.window.buffer():undo()"); + assert_eq!(text(&s), "α", "the first undo removes the separate space"); + exec(&s, "pmacs.window.buffer():undo()"); + assert_eq!(text(&s), "\\alpha", "the second undoes the expansion"); +} + +#[test] +fn to_is_not_eager_because_longer_keys_extend_it() { + // The criterion rev 8 got wrong: `to` looks unique and is not. + // `top`, `to0`, `toa` and others extend it, so it needs a + // terminator. Bites against an eager rule that tests only "is this + // a key" without asking whether anything extends it. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\to"); + assert_eq!(text(&s), "\\to", "no expansion without a terminator"); + + type_str(&mut s, " "); + assert_eq!(text(&s), "→ ", "the finish path then resolves it"); +} + +// --------------------------------------------------------------------------- +// 39 — $CURSOR +// --------------------------------------------------------------------------- + +#[test] +fn the_cursor_placeholder_places_the_point_between_the_symbols() { + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\<>"); + assert_eq!(text(&s), "⟨⟩"); + // The placeholder is a point position, not a literal: typing lands + // between the brackets. + type_str(&mut s, "x"); + assert_eq!(text(&s), "⟨x⟩", "$CURSOR left the point inside"); +} + +// --------------------------------------------------------------------------- +// 40 — the pair collision +// --------------------------------------------------------------------------- + +#[test] +fn a_pending_abbreviation_is_never_corrupted_by_auto_pairing() { + // 64 keys contain a `lean4` pair-set character. Two DISTINCT bugs + // produce the same symptom here, so both are asserted: pairing + // running first, and a consumer that claims only completed + // expansions (which would hand each intermediate `[` to pairing). + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\["); + assert_eq!( + text(&s), + "\\[", + "the intermediate `[` was CLAIMED — pairing inserted no `]`, \ + which is what keeps `\\[[]]` reachable" + ); + + type_str(&mut s, "[]]"); + assert_eq!(text(&s), "⟦⟧", "the full key resolves"); +} + +#[test] +fn a_pair_character_that_terminates_an_abbreviation_still_pairs() { + // The other half of the collision, and the one the first revision + // of this file got wrong. `(` does not extend `alp`, so it + // TERMINATES — and a terminator is an ordinary character that + // pairing is entitled to react to. + // + // Claiming the terminator suppresses pairing entirely (`α(`). + // Expanding before declining is no better: the replace makes + // pairing's copy of the record stale, so pairing declines and the + // closer is silently lost. Only deferring the expansion past the + // chain gives both. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp("); + assert_eq!( + text(&s), + "α()", + "the abbreviation expanded AND the terminator paired" + ); + assert_eq!( + cursor(&s), + 3, + "and the point sits between the pair — after α (2 bytes) and \ + the opener" + ); +} + +#[test] +fn a_nested_fan_out_between_the_expander_and_pairing_does_not_expand_early() { + // `buffer.after-edit` fan-outs NEST — the typed-edit contract + // explicitly supports a consumer calling `pmacs.hook.run`, and a + // nested run re-enters every subscriber, including the deferred + // expansion's. If the nested pass performed the expansion, the + // OUTER chain would then resume and hand pairing a record the + // replace had already invalidated: `α(` again, reached through the + // chain's documented re-entrancy seam rather than through claiming. + let (mut s, _f) = lean_editor(); + exec( + &s, + r#" + _G.NESTED = 0 + pmacs.typed_edit.add_consumer { + name = "nested-fan-out", + priority = 75, -- between the expander (50) and pairing (100) + fn = function() + if _G.NESTED == 0 then + _G.NESTED = 1 + pmacs.hook.run("buffer.after-edit") + end + return false + end, + } + "#, + ); + + type_str(&mut s, "\\alp("); + let nested: i64 = eval(&s, "return _G.NESTED"); + assert_eq!(nested, 1, "the nested fan-out must actually have run"); + assert_eq!( + text(&s), + "α()", + "the expansion waited for the OUTERMOST pass, so pairing still \ + held a valid record when the terminator reached it" + ); +} + +#[test] +fn a_nested_fan_out_that_never_reaches_the_expander_still_does_not_expand_early() { + // The chain's OTHER exit: a consumer may CLAIM and stop the chain + // before the expander is reached, while the fan-out's + // deferred-expansion subscriber still runs. Counting in the + // expander itself therefore misses that pass — it would look like + // the outermost one and expand early, and outer pairing would + // resume with an invalidated record. + // + // The sequence, exactly: a consumer at 25 declines on the outer + // pass (there is a record) and claims on the nested one (there is + // not); a consumer at 75 runs one nested fan-out from between the + // expander and pairing. + let (mut s, _f) = lean_editor(); + exec( + &s, + r#" + _G.NESTED, _G.CLAIMED = 0, 0 + pmacs.typed_edit.add_consumer { + name = "claims-only-when-recordless", + priority = 25, -- ahead of the expander at 50 + fn = function(rec) + if rec == nil then + _G.CLAIMED = _G.CLAIMED + 1 + return true -- stops the chain: the expander never runs + end + return false + end, + } + pmacs.typed_edit.add_consumer { + name = "nested-fan-out", + priority = 75, -- between the expander (50) and pairing (100) + fn = function() + if _G.NESTED == 0 then + _G.NESTED = 1 + pmacs.hook.run("buffer.after-edit") + end + return false + end, + } + "#, + ); + + type_str(&mut s, "\\alp("); + let (nested, claimed): (i64, i64) = eval(&s, "return _G.NESTED, _G.CLAIMED"); + assert_eq!(nested, 1, "the nested fan-out must actually have run"); + assert!( + claimed >= 1, + "the nested pass must actually have been short-circuited before \ + the expander, or this pins the same thing as 45n" + ); + assert_eq!( + text(&s), + "α()", + "the nesting count comes from a point that runs before any \ + consumer can claim, so the nested pass was still recognised" + ); +} + +#[test] +fn a_pair_character_outside_a_pending_abbreviation_still_pairs() { + // The other direction: claiming extensions must not disable pairing + // in Lean buffers generally. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "["); + assert_eq!(text(&s), "[]", "ordinary auto-pairing is untouched"); +} + +// --------------------------------------------------------------------------- +// 42 — a prefix that opens nothing +// --------------------------------------------------------------------------- + +#[test] +fn a_prefix_that_opens_no_key_is_left_literal_with_no_edit() { + // `W` is one of exactly six printable characters that begin no key + // (`$ % , ; @ W`). Rev 8 used `\zzzz`, which expands — `ze`, `zeta` + // and `zsqrtd` exist (round 9). + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\WWWW "); + assert_eq!(text(&s), "\\WWWW ", "literal text, no expansion"); +} + +#[test] +fn a_prefix_with_no_complete_match_still_expands_its_best_prefix() { + // The case rev 8 mistook for "no match": `z` DOES open a pending + // abbreviation, and the second `z` finishes it. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\zzzz "); + assert_eq!(text(&s), "ζzzz ", "`z` resolved through `ze`"); +} + +// --------------------------------------------------------------------------- +// 43 — lazy abandonment +// --------------------------------------------------------------------------- + +#[test] +fn moving_the_point_away_abandons_the_pending_abbreviation() { + // There is no cursor-motion hook, so the pending record is + // validated at the NEXT typed edit: the point must still be at the + // end of the pending span. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp"); + exec(&s, "pmacs.editor.goto_byte(0)"); + type_str(&mut s, "h"); + assert_eq!(text(&s), "h\\alp", "the `h` landed as plain text"); + + // The keystroke that makes abandonment OBSERVABLE. Asserting only + // the line above proves nothing: claiming an extension makes no + // edit, so a record that wrongly survived would look identical + // here. If `h` had extended the record to `alph`, this `a` + // completes `alpha` and eagerly expands — over a span whose offsets + // are now stale by one. + type_str(&mut s, "a"); + assert_eq!( + text(&s), + "ha\\alp", + "`\\alp` is still literal: the record was dropped when the \ + point left the end of its span, not carried along" + ); +} + +#[test] +fn switching_buffers_clears_pending_state_eagerly() { + let (mut s, f) = lean_editor(); + let dir = fresh_dir(); + let other = dir.join("b.lean"); + std::fs::write(&other, "").unwrap(); + let od = other.display().to_string(); + let fd = f.display().to_string(); + + // Open the second buffer FIRST, then come back. `find_or_open` + // fires `buffer.after-switch` only on the already-open branch — a + // fresh load fires `buffer.after-load` instead, and its own insert + // fires a record-less `buffer.after-edit`. Without this warm-up the + // test passes through the nil-record path and pins nothing about + // switching: deleting the after-switch subscriber leaves it green. + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + + type_str(&mut s, "\\alph"); + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + + type_str(&mut s, "a"); + assert_eq!( + text(&s), + "\\alpha", + "without the switch this would have eagerly expanded to α; \ + `buffer.after-switch` cleared the record" + ); +} + +#[test] +fn a_self_insert_that_moves_the_point_afterwards_does_not_expand() { + // Buffer and window matching is not enough. A redefined + // `buffer.self-insert` may insert the completing character and THEN + // move the point; expanding over a span the user has left teleports + // them back into it. Pairing makes the same three-part check + // (`ed.cursor() ~= rec.post_cursor`) for the same reason. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alph"); + exec( + &s, + r#" + pmacs.command.unregister("buffer.self-insert") + pmacs.command.define { + name = "buffer.self-insert", + description = "test override: insert, then move the point away", + fn = function(cp) + pmacs.editor.insert_char_over_region(cp) + pmacs.editor.goto_byte(0) + end, + } + "#, + ); + + type_str(&mut s, "a"); + assert_eq!( + text(&s), + "\\alpha", + "the record died with the point that left it — no expansion" + ); + assert_eq!(cursor(&s), 0, "and the point stayed where it was moved to"); +} + +#[test] +fn an_intercept_that_switches_buffers_does_not_move_the_other_points() { + // A buffer intercept may switch window or buffer while the replace + // runs. An unguarded `goto_byte` afterwards moves the point of + // whatever it switched TO — a buffer with nothing to do with this + // expansion. Pairing's `repair_cursor` guards the same way. + let (mut s, f) = lean_editor(); + let dir = fresh_dir(); + let other = dir.join("other.lean"); + std::fs::write(&other, "0123456789").unwrap(); + let od = other.display().to_string(); + let fd = f.display().to_string(); + + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + exec( + &s, + &format!( + r#" + _G.SWITCHED = false + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "replace" and not _G.SWITCHED then + _G.SWITCHED = true + pmacs.buffer.find_or_open({od:?}) + end + return nil + end) + "# + ), + ); + + type_str(&mut s, "\\alpha"); + let switched: bool = eval(&s, "return _G.SWITCHED"); + assert!(switched, "the intercept must actually have fired"); + assert_eq!( + text(&s), + "0123456789", + "we are now in the buffer the intercept switched to" + ); + // Whatever point the switch left in that buffer, the expansion must + // not have moved it. Unguarded, `goto_byte` runs against the + // ambient buffer and translates the LEAN buffer's pre-edit point + // (6) through the LEAN buffer's replace, landing at 2 here — a + // number with no meaning in this buffer at all. + assert_eq!( + cursor(&s), + 0, + "its point is untouched — the expansion's cursor placement is \ + guarded on the window and buffer still being the ones it \ + edited" + ); +} + +// --------------------------------------------------------------------------- +// 44 / 45 — the setting and the language gate, both on the SOURCE buffer +// --------------------------------------------------------------------------- + +#[test] +fn disabling_the_setting_stops_expansion() { + let (mut s, _f) = lean_editor(); + exec(&s, "pmacs.config.set('lean.abbrev', false)"); + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "\\alpha", "no expansion when disabled"); + + exec(&s, "pmacs.config.set('lean.abbrev', true)"); + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, " \\alpha"); + assert_eq!(text(&s), "\\alpha α", "and it comes back live"); +} + +#[test] +fn the_setting_is_read_against_the_typed_edits_source_buffer() { + // A buffer-local override must not follow the user to another + // buffer of the same language — the `editing.auto-pair` precedent, + // including its round-2 correction to resolve `rec.buffer` rather + // than `pmacs.window.buffer()`. + let (mut s, f) = lean_editor(); + let dir = fresh_dir(); + let other = dir.join("b.lean"); + std::fs::write(&other, "").unwrap(); + + exec( + &s, + "pmacs.config.set_local(pmacs.window.buffer(), 'lean.abbrev', false)", + ); + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "\\alpha", "disabled in THIS buffer"); + + let od = other.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "α", "a second lean buffer is unaffected"); + + let fd = f.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + assert_eq!(text(&s), "\\alpha", "and the first is still disabled"); +} + +#[test] +fn no_abbreviation_state_is_opened_outside_a_lean_buffer() { + let dir = fresh_dir(); + let f = dir.join("a.rs"); + std::fs::write(&f, "").unwrap(); + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + let fd = f.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + let mut s = s; + + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "\\alpha", "no expansion in Rust"); + + // And the leader opened nothing, so `[` still pairs normally. + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "\\["); + assert_eq!( + text(&s), + "\\alpha\\[]", + "`\\[` in a Rust buffer pairs — the input method never armed" + ); +} + +// --------------------------------------------------------------------------- +// 45a / 45b / 45c / 45d / 45e — resolution rules +// --------------------------------------------------------------------------- + +#[test] +fn the_shortest_key_wins_not_the_longest() { + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp "); + assert_eq!(text(&s), "α ", "`alp` resolves through `alpha`"); + + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "\\al "); + assert_eq!( + text(&s), + "α ∀ ", + "`al` resolves through `all` (3) — NOT `alpha` (5). A \ + longest-match or unique-match-only rule passes the first \ + assertion and fails this one" + ); +} + +#[test] +fn an_unmatchable_tail_is_appended_not_dropped() { + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp7 "); + assert_eq!( + text(&s), + "α7 ", + "`7` finished `alp`; it is kept, not swallowed, and the whole \ + abbreviation is not abandoned" + ); +} + +#[test] +fn there_is_no_terminator_list() { + // `'+ '` is a key — a trailing SPACE is part of it. Bites against + // any hardcoded space/tab/RET terminator set. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\+ "); + assert_eq!(text(&s), "⊹", "the space EXTENDED rather than terminating"); +} + +#[test] +fn a_doubled_backslash_yields_one_literal_backslash() { + // Not a terminator case: the pending text is empty, `\` is itself a + // key, and it extends-and-eagerly-matches. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\\\"); + assert_eq!(text(&s), "\\", "one literal backslash"); + + // ...and no pending state was left open, so an ordinary letter is + // an ordinary letter. + type_str(&mut s, "n"); + assert_eq!(text(&s), "\\n", "two characters, not a newline"); +} + +#[test] +fn a_terminating_backslash_re_arms_as_a_new_leader() { + // `al` is NOT eager, so its pending record is still open when the + // second `\` arrives: the `\` terminates it, the expansion runs, + // and the same `\` must then open a fresh abbreviation. + // + // The framing's own example — `\alpha\to` — does NOT exercise this + // branch: `alpha` is eager, so the record is already closed and the + // `\` is handled by the ordinary open-a-leader path. It passes with + // the re-arm branch deleted, which is why the non-eager case is the + // one asserted first. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\al\\to "); + assert_eq!( + text(&s), + "∀→ ", + "the terminating `\\` expanded `al` AND opened a new \ + abbreviation at its own position" + ); + + // The criterion's example still holds, by the other route. + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "\\alpha\\to "); + assert_eq!(text(&s), "∀→ α→ "); +} + +#[test] +fn an_inserted_backslash_does_not_re_arm() { + // `setminus` expands to a literal `\`. That backslash is a + // programmatic replace, which arms no typed-edit record — so it + // opens no pending abbreviation. Bites against a future consumer + // that infers pending state from buffer text instead of provenance. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\setminus"); + assert_eq!(text(&s), "\\", "expanded to a literal backslash"); + + type_str(&mut s, "n"); + assert_eq!( + text(&s), + "\\n", + "the letter after it is a plain letter — the INSERTED backslash \ + armed nothing" + ); +} + +// --------------------------------------------------------------------------- +// 45h — the tie-break by source declaration order +// --------------------------------------------------------------------------- + +#[test] +fn equal_length_candidates_break_by_source_declaration_order() { + // `f<` and `f>` are both length 2. `f<` is declared first, so `\f` + // resolves to `‹`. This is the criterion that bites a map-shaped + // vendored table: with `pairs` iteration it passes or fails by hash + // order. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\f "); + assert_eq!(text(&s), "‹ ", "`f<` wins over `f>` by source order"); + + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "\\\" "); + assert_eq!( + text(&s), + "‹ Ä ", + "`\"A` is the first of eleven equal-length candidates" + ); +} + +#[test] +fn reversing_the_vendored_sequence_reverses_the_tie() { + // The falsification 45h requires: run the same resolution against a + // deliberately reversed sequence and show it changes. If this did + // NOT change, the tie-break would not be reading source order at + // all and the assertion above would be passing by luck. + let (s, _f) = lean_editor(); + let forward: String = eval(&s, "return pmacs.lean_input._resolve('f')"); + assert_eq!(forward, "‹"); + + let reversed: String = eval( + &s, + " + local seq = pmacs.lean_abbrev + local rev = {} + for i = #seq, 1, -1 do rev[#rev + 1] = seq[i] end + -- Resolve `f` the way the module does, over the reversed order. + local best = nil + for i = 1, #rev do + local k, v = rev[i][1], rev[i][2] + if k:sub(1, 1) == 'f' then + if best == nil or #k < best.len then best = { sym = v, len = #k } end + end + end + return best.sym + ", + ); + assert_eq!( + reversed, "›", + "reversed source order picks `f>` — the tie really is decided \ + by position in the sequence" + ); +} + +// --------------------------------------------------------------------------- +// 45g — table integrity, limited to what the suite can actually check +// --------------------------------------------------------------------------- + +#[test] +fn the_vendored_table_is_self_consistent() { + // `abbreviations.json` is not shipped, so the suite cannot diff + // against it; the full source-fidelity check belongs to the + // generator, which re-parses its own output from disk. What is + // checkable here are the properties a corrupt emit breaks. + let (s, _f) = lean_editor(); + + let count: i64 = eval(&s, "return #pmacs.lean_abbrev"); + assert_eq!( + count, 1855, + "the declared entry count for the recorded upstream commit" + ); + + let (unique, cursor_ok, utf8_ok): (i64, bool, bool) = eval( + &s, + r#" + local seen, n = {}, 0 + local cursor_ok, utf8_ok = true, true + for i = 1, #pmacs.lean_abbrev do + local k, v = pmacs.lean_abbrev[i][1], pmacs.lean_abbrev[i][2] + if not seen[k] then seen[k] = true; n = n + 1 end + local _, c = v:gsub("%$CURSOR", "") + if c > 1 then cursor_ok = false end + -- A Lua pattern cannot validate UTF-8; check the shape the + -- emitter guarantees instead: no lone continuation byte at the + -- start of a sequence and no truncated tail. + if k:find("[\128-\191]") == 1 then utf8_ok = false end + end + return n, cursor_ok, utf8_ok + "#, + ); + assert_eq!( + unique, 1855, + "every key is unique — a collision would silently drop entries \ + from the derived lookup" + ); + assert!(cursor_ok, "no symbol carries more than one $CURSOR"); + assert!(utf8_ok, "no key begins with a continuation byte"); + + // The resolution spot-set named by 45g. + for (input, want) in [ + ("alpha", "α"), + ("to", "→"), + ("<>", "⟨$CURSOR⟩"), + ("+ ", "⊹"), + ("\\\\", "\\"), + ("n", "\\n"), + ("setminus", "\\"), + ("f", "‹"), + ] { + let got: String = eval(&s, &format!("return pmacs.lean_input._resolve('{input}')")); + assert_eq!(got, want, "resolution of {input:?}"); + } + + // The eager set is the one the state machine branches on. + let alpha_eager: bool = eval(&s, "return pmacs.lean_input._is_eager('alpha')"); + let to_eager: bool = eval(&s, "return pmacs.lean_input._is_eager('to')"); + assert!(alpha_eager, "`alpha` has no extension"); + assert!(!to_eager, "`to` is extended by `top`, `to0`, `toa`, …"); +} + +// --------------------------------------------------------------------------- +// Q#AP7 for the deferred expansion: it must land before lsp.lua flushes +// --------------------------------------------------------------------------- + +#[test] +fn the_expansion_reaches_the_first_did_change() { + // The expansion runs on its OWN `buffer.after-edit` subscriber, + // after the typed-edit chain. That makes it a new instance of the + // Q#AP7 obligation pairing already carries: lsp.lua's subscriber + // flushes `didChange` SYNCHRONOUSLY on the signature-trigger path, + // and `(` is a trigger. A server told about `\alp(` instead of + // `α()` stays wrong until the next edit — diagnostics, semantic + // tokens and inlay hints all frozen at stale byte positions. + // + // Falsified by loading lean_input.lua after lsp.lua in + // `src/editor.rs`: the expansion would then arrive in the SECOND + // didChange, or not at all. + let dir = fresh_dir(); + let sink = dir.join("changes.jsonl"); + let sink_disp = sink.display().to_string(); + let fake = env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned(); + + let f = dir.join("a.lean"); + std::fs::write(&f, "").unwrap(); + 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.lean4 = {{ + command = '{fake}', + env = {{ + PMACS_FAKE_LSP_MODE = 'sighelp', + PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}', + }}, + }}" + ), + ); + + 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, "\\alp("); + assert_eq!(text(&s), "α()", "precondition: the expansion happened"); + + // Wait for the flush that carries the `(` keystroke. Earlier + // keystrokes have already produced their own didChanges, so + // `changes[0]` is NOT the one under test — asserting on it compares + // against `\al` and fails for the wrong reason. + 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.iter().any(|t| t.contains('α')) { + break c; + } + assert!( + Instant::now() < deadline, + "no didChange carrying the expansion reached the fake server; got {:?}", + did_change_texts(&sink) + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert!( + !changes.iter().any(|t| t == "\\alp("), + "no didChange may ever carry the UNEXPANDED text — one would mean lsp.lua flushed before the deferred expansion ran (Q#AP7). Got {changes:?}" + ); + assert_eq!( + changes.last().map(String::as_str), + Some("α()"), + "the flush that carries the terminator carries the expansion and pairing's closer with it" + ); +} + +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() +} + +// --------------------------------------------------------------------------- +// 45i — pending state is per frontend +// --------------------------------------------------------------------------- + +/// Register a second frontend on the SAME buffer, with its own window. +fn attach_frontend(s: &EditorState, fid: FrontendId) -> WindowId { + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let registry = core.registry.clone(); + let reg = registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).unwrap()) + }; + let win_id = WindowId::next(); + core.windows + .insert(win_id, Window::new(win_id, buffer_id, text_view)); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout::single(win_id), + active: win_id, + fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + win_id +} + +const B: FrontendId = FrontendId(9); + +#[test] +fn a_peer_edit_to_the_shared_buffer_abandons_the_pending_record() { + let (mut s, _f) = lean_editor(); + let b_win = attach_frontend(&s, B); + // B sits at the start of the buffer; A types at the end. + s.core.borrow_mut().windows.get_mut(&b_win).unwrap().cursor = 0; + + type_as(&mut s, FrontendId::LOCAL, "\\al"); + type_as(&mut s, B, "p"); + assert!( + text(&s).contains('p'), + "B's keystroke landed as ordinary text rather than extending \ + A's abbreviation, got {:?}", + text(&s) + ); + + type_as(&mut s, FrontendId::LOCAL, "l "); + assert!( + !text(&s).contains('∀'), + "A's record was abandoned: `revision()` is buffer-global, so \ + B's edit invalidates it even though B edited elsewhere. Got {:?}", + text(&s) + ); +} + +#[test] +fn a_peer_buffer_switch_does_not_clear_another_frontends_record() { + let (mut s, f) = lean_editor(); + let dir = fresh_dir(); + let other = dir.join("b.lean"); + std::fs::write(&other, "").unwrap(); + let od = other.display().to_string(); + let fd = f.display().to_string(); + // Warm up both buffers so B's switch takes `find_or_open`'s + // already-open branch, which is the only one that fires + // `buffer.after-switch`. A fresh load fires `buffer.after-load` + // and a record-less edit instead — and that path clears pending + // state for a different reason, which would make this test green + // no matter whose entries the subscriber clears. + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + attach_frontend(&s, B); + + type_as(&mut s, FrontendId::LOCAL, "\\al"); + + // B switches buffers WITHOUT editing the shared buffer. + // Only B moves: the switch is scoped to B's own window, so A's + // window still shows the shared buffer with A's point where it was. + s.core.borrow_mut().active_frontend = B; + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + + type_as(&mut s, FrontendId::LOCAL, "l "); + assert_eq!( + text(&s), + "∀ ", + "`buffer.after-switch` clears only the ACTING frontend's \ + entries — a blanket clear would discard A's half-typed \ + abbreviation" + ); +} + +#[test] +fn detaching_a_frontend_purges_only_its_own_pending_state() { + let (mut s, _f) = lean_editor(); + attach_frontend(&s, B); + + type_as(&mut s, FrontendId::LOCAL, "\\al"); + exec(&s, &format!("pmacs.hook.run('frontend.detached', {})", B.0)); + + type_as(&mut s, FrontendId::LOCAL, "l "); + assert_eq!( + text(&s), + "∀ ", + "B's detachment purged B's entries and left A's record valid" + ); +} 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/m5_5_acceptance.rs b/tests/m5_5_acceptance.rs index ee66ea8..c8c87ed 100644 --- a/tests/m5_5_acceptance.rs +++ b/tests/m5_5_acceptance.rs @@ -37,8 +37,8 @@ use pmacs::cell::Color; #[cfg(feature = "crdt")] use pmacs::overlay_color::color_for_slot; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key, - KeyEvent, Modifiers, PROTOCOL_VERSION, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, + Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, }; use pmacs::transport::{read_message, write_message}; @@ -52,9 +52,9 @@ use common::daemon::{ /// Read the daemon's `Hello`, send our `AttachRequest`, return the Hello. fn do_handshake(stream: &mut UnixStream) -> Hello { let hello: Hello = read_message(stream).expect("read Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_default_caps(), initial_size: CellSize::new(24, 80), }; @@ -418,7 +418,7 @@ fn version_mismatch_clean_disconnect() { // Read Hello. let hello: Hello = read_message(&mut stream).expect("Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); // Send AttachRequest with wrong protocol version. let req = AttachRequest { @@ -431,7 +431,7 @@ fn version_mismatch_clean_disconnect() { // Expect Goodbye(VersionMismatch). match read_message::(&mut stream) { Ok(InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client })) => { - assert_eq!(server, PROTOCOL_VERSION); + assert_eq!(server, ADVERTISED_PROTOCOL_VERSION); assert_eq!(client, 999); } other => panic!("expected VersionMismatch Goodbye, got {other:?}"), @@ -1145,9 +1145,9 @@ fn m10_10_production_attach_negotiates_crdt_replica() { // Production handshake — NOT the test `attach_multi()` path. let hello: Hello = read_message(&mut stream).expect("read Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: pmacs::attach::build_capabilities(), initial_size: CellSize::new(24, 80), }; @@ -1183,9 +1183,9 @@ fn m10_10_production_attach_non_crdt_build_does_not_negotiate_crdt_replica() { stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); - let _hello: Hello = read_message(&mut stream).expect("read Hello"); + let hello: Hello = read_message(&mut stream).expect("read Hello"); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: pmacs::attach::build_capabilities(), initial_size: CellSize::new(24, 80), }; @@ -2171,7 +2171,7 @@ fn m10_10_f14_production_path_keystroke_flows_to_broadcast() { .unwrap(); let hello_a: Hello = read_message(&mut stream_a).expect("A Hello"); let req_a = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello_a.protocol_version, frontend_capabilities: pmacs::attach::build_capabilities(), initial_size: CellSize::new(24, 80), }; diff --git a/tests/m5_7_acceptance.rs b/tests/m5_7_acceptance.rs index ec0733d..ce85591 100644 --- a/tests/m5_7_acceptance.rs +++ b/tests/m5_7_acceptance.rs @@ -77,7 +77,7 @@ use nix::unistd::Pid; use tempfile::TempDir; use pmacs::attach::PMACS_TEST_SSH_BIN; -use pmacs::protocol::{Hello, PROTOCOL_VERSION}; +use pmacs::protocol::{ADVERTISED_PROTOCOL_VERSION, Hello}; use pmacs::transport::read_message; // --------------------------------------------------------------------------- @@ -385,7 +385,7 @@ fn daemon_attach_bridges_hello_from_existing_daemon() { // verbatim. (No AttachRequest sent — the daemon will hold the // attach slot until the bridge stdin closes below.) let hello: Hello = read_message(&mut bridge_stdout).expect("read Hello via bridge"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); // Tear down: drop bridge stdin → bridge's stdin→socket copy sees // EOF, shuts down the socket write half, the daemon notices and @@ -424,7 +424,7 @@ fn daemon_attach_auto_starts_missing_daemon() { // bound the socket and the bridge connected. let hello: Hello = read_message(&mut bridge_stdout).expect("read Hello via auto-started daemon"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); // The lockfile must exist now: `acquire_lock` writes it on // daemon startup. (Existence of the lockfile is what proves diff --git a/tests/m5_perf_acceptance.rs b/tests/m5_perf_acceptance.rs index 4d1a680..12139b3 100644 --- a/tests/m5_perf_acceptance.rs +++ b/tests/m5_perf_acceptance.rs @@ -72,8 +72,8 @@ use tempfile::TempDir; use pmacs::cell::CellSize; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, Hello, InstanceMessage, Key, KeyEvent, - Modifiers, PROTOCOL_VERSION, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, Hello, + InstanceMessage, Key, KeyEvent, Modifiers, }; use pmacs::transport::{TransportError, read_message, write_message}; @@ -158,9 +158,9 @@ fn build_default_caps() -> FrontendCapabilities { fn do_handshake(stream: &mut UnixStream) -> Hello { let hello: Hello = read_message(stream).expect("read Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_default_caps(), initial_size: CellSize::new(24, 80), }; diff --git a/tests/mode_system_wiring_acceptance.rs b/tests/mode_system_wiring_acceptance.rs index bd79b4e..16b6fe5 100644 --- a/tests/mode_system_wiring_acceptance.rs +++ b/tests/mode_system_wiring_acceptance.rs @@ -11,8 +11,8 @@ use std::time::{Duration, Instant}; use pmacs::cell::{Cell, CellSize, Glyph}; use pmacs::protocol::{ - AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, - PROTOCOL_VERSION, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, + Key, KeyEvent, Modifiers, }; use pmacs::transport::{read_message, write_message}; @@ -75,11 +75,11 @@ fn attach(daemon: &TestDaemon) -> (Client, Grid) { .set_read_timeout(Some(Duration::from_secs(5))) .expect("set daemon handshake timeout"); let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); write_message( &mut stream, &AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_default_caps(), initial_size: CellSize::new(ROWS, COLS), }, diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 120ca23..2f5cfd9 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -786,15 +786,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() { #[test] fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { // Vterm Stage 3 appended the terminal family as v19; GPU initial targets - // appended the semantic bootstrap family as v20. This acceptance owns the - // STATUSLINE variant's placement and gate, so it tracks the current wire - // version rather than pinning 18: the v18 floor it actually cares about is - // asserted below and in `peer_accepts_statusline_message`. - assert_eq!(PROTOCOL_VERSION, 20); - for version in 6..=20 { + // appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1 + // appended the panel family as v21. This acceptance owns the STATUSLINE + // variant's placement and gate, so it tracks the current wire version + // rather than pinning 18: the v18 floor it actually cares about is asserted + // below and in `peer_accepts_statusline_message`. + assert_eq!(PROTOCOL_VERSION, 21); + for version in 6..=21 { assert!(is_supported_protocol_version(version)); } - assert!(!is_supported_protocol_version(21)); + assert!(!is_supported_protocol_version(22)); let sample = InstanceMessage::StatuslineSegments { buffer_id: BufferId::from_raw(9), left: vec![StatuslineSegment { 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/terminal_copy_mode_acceptance.rs b/tests/terminal_copy_mode_acceptance.rs new file mode 100644 index 0000000..ed3f6d7 --- /dev/null +++ b/tests/terminal_copy_mode_acceptance.rs @@ -0,0 +1,994 @@ +//! Terminal copy-mode acceptance (Stage 2 of +//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 13-21). +//! +//! **Deliberately NOT `#[cfg(feature = "crdt")]`.** CI never enables that +//! feature, so a gated suite is written and then never run — 264 tests are +//! dark workspace-wide for exactly that reason. Criterion 16, the +//! round-trip gate Q#TC6a's entire safety argument rests on, needs no CRDT +//! and must be caught by the default configuration. + +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; + +const SNAPSHOT_NAME: &str = "*terminal-copy: terminal:sh*"; + +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(state: &EditorState, src: &str) -> T { + state + .lua_host + .lua() + .load(src) + .eval() + .unwrap_or_else(|e| panic!("lua eval 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(), + } +} + +fn press(state: &mut EditorState, code: KeyCode, mods: KeyModifiers) { + state.dispatch_key(FrontendId::LOCAL, KeyEvent::new(code, mods)); +} + +/// The live terminal screen's text, used only to wait for the child. +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(); + }; + let mut text = String::new(); + for cell in &snapshot.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 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)); + } +} + +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() +} + +/// A child that overflows the 24-row screen and then goes quiet, so its +/// early lines exist ONLY in scrollback — which is what makes criterion +/// 15's "content only in scrollback" claim meaningful. +const FILL_PROFILE: &str = r#" +pmacs.terminal.profiles.fill = { + command = "/bin/sh", + args = { "-c", + "printf 'NEEDLE-IN-SCROLLBACK\r\n'; i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" }, +} +"#; + +/// Open the fill terminal, wait for the child to finish, and return its id. +fn open_fill_terminal(state: &mut EditorState) -> pmacs::buffer::BufferId { + exec(state, FILL_PROFILE); + let before = terminal_buffers(state); + exec( + state, + r#"TERM_BUF = pmacs.terminal.open { profile = "fill" }"#, + ); + let fresh: Vec<_> = terminal_buffers(state) + .into_iter() + .filter(|id| !before.contains(id)) + .collect(); + assert_eq!(fresh.len(), 1, "exactly one terminal must have opened"); + let buffer = fresh[0]; + assert!(tick_until(state, "DONE", buffer), "the child must finish"); + buffer +} + +fn viewport() -> CellSize { + CellSize::new(10, 40) +} + +/// Give LOCAL a window on the terminal and register/claim its view, which +/// is what makes `dispatch_key`'s terminal transport arm reachable. +/// Returns the view key, so assertions can read the *projected* view +/// rather than the context-free live screen. +fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> TerminalViewKey { + 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()); + key +} + +/// Make the child produce NEW output, so a refresh has something to find. +/// +/// The child is `exec cat`, so typing into the focused terminal echoes +/// back. Without this, "refresh" tests compare a quiet terminal against +/// itself and pass with the render replaced by a no-op — the defect review +/// round 1 found in acceptance 18 and 19. +fn emit_into_child(state: &mut EditorState, terminal: pmacs::buffer::BufferId, marker: &str) { + focus_terminal(state, terminal); + for ch in marker.chars() { + press(state, KeyCode::Char(ch), KeyModifiers::NONE); + } + assert!( + tick_until(state, marker, terminal), + "the child must echo {marker:?} back onto the live screen" + ); +} + +/// What the registered VIEW currently projects — which, unlike +/// `manager.snapshot(buffer)`, depends on where the view is anchored. +fn view_text(state: &EditorState, key: TerminalViewKey) -> String { + let mut manager = state.terminal_manager.borrow_mut(); + let Some(snapshot) = manager.snapshot_for_view(key, viewport()) else { + return String::new(); + }; + let mut text = String::new(); + for cell in &snapshot.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 view_at_bottom(state: &EditorState, key: TerminalViewKey) -> bool { + state + .terminal_manager + .borrow_mut() + .snapshot_for_view(key, viewport()) + .is_some_and(|snapshot| snapshot.at_bottom) +} + +fn buffer_text_by_name(state: &EditorState, name: &str) -> Option { + eval( + state, + &format!( + r" + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == {name:?} then + return id:slice(0, id:len()) + end + end + return nil + " + ), + ) +} + +fn active_buffer_name(state: &EditorState) -> String { + eval( + state, + r"local b = pmacs.window.buffer(); return (pmacs.describe.buffer(b)).name", + ) +} + +fn buffer_count(state: &EditorState) -> usize { + state.core.borrow().registry.borrow().ids().len() +} + +/// Acceptance 13: the snapshot's text is exactly the whole retained range +/// as the existing copy path serializes it. +/// +/// Compared against `_copy_retained` rather than a literal, so this cannot +/// pass by both sides drifting the same way; the exact-bytes fidelity +/// claims (criterion 14) are pinned at the unit level in +/// `src/terminal/view.rs`, against the same projection fixtures that pin +/// `copy_selection_bytes` itself. +#[test] +fn acc13_snapshot_is_the_whole_retained_range_through_the_shared_serializer() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + + exec(&state, "SNAP = pmacs.terminal.copy_mode(TERM_BUF)"); + let snapshot_text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot buffer exists"); + let serialized: String = eval( + &state, + r"return pmacs.terminal._copy_retained(TERM_BUF) or ''", + ); + + assert_eq!( + snapshot_text, serialized, + "the snapshot must be byte-identical to the shared serializer's output" + ); + assert!( + snapshot_text.contains("NEEDLE-IN-SCROLLBACK") && snapshot_text.contains("LINE200"), + "the range must span scrollback AND the visible screen" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 14 (end-to-end half): the snapshot really is a rope-backed +/// document buffer and not a terminal, which is what makes every +/// buffer-shaped consumer work and what removes the transport arm. +#[test] +fn acc14_the_snapshot_is_an_ordinary_non_terminal_buffer() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let is_terminal: bool = eval( + &state, + r"local b = pmacs.window.buffer(); return pmacs.terminal.is_terminal(b)", + ); + assert!( + !is_terminal, + "the snapshot must NOT be a terminal — that is what structurally \ + removes the transport arm rather than guarding it" + ); + assert_eq!(active_buffer_name(&state), SNAPSHOT_NAME); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 15: isearch finds content that exists ONLY in scrollback, +/// with no change to `src/search.rs` (B1). +#[test] +fn acc15_isearch_finds_content_only_in_scrollback() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + + // The needle is off the visible screen: the live terminal cannot see it. + assert!( + !screen_text(&state, terminal).contains("NEEDLE-IN-SCROLLBACK"), + "precondition: the needle must have scrolled off the live screen" + ); + + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + state.core.borrow_mut().set_cursor_byte(0); + + // Drive real isearch: C-s then the needle. + press(&mut state, KeyCode::Char('s'), KeyModifiers::CONTROL); + for ch in "NEEDLE-IN-SCROLLBACK".chars() { + press(&mut state, KeyCode::Char(ch), KeyModifiers::NONE); + } + let cursor = state.core.borrow().cursor(); + press(&mut state, KeyCode::Enter, KeyModifiers::NONE); + + let text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); + let expected = text + .find("NEEDLE-IN-SCROLLBACK") + .expect("the needle is in the snapshot") as u64; + assert_eq!( + cursor, + expected, + "isearch must land on the scrollback-only match; text was {:?}", + &text[..text.len().min(80)] + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 16 — the load-bearing pin, and the reason this suite is +/// ungated. `set_round_trip_input` is the ONLY thing standing between a +/// replica frontend and unauthorized mutation **of its own mirror** +/// (Q#TC6a), so its regression must be caught in the configuration CI +/// actually compiles. +/// +/// Rope-level `read_only` does not substitute for it. Since review round 2 +/// the daemon refuses such an op at `ensure_writable()` — but a refusal +/// arrives after the frontend has already applied optimistically and +/// painted the result. What that buys is divergence instead of silent +/// agreement; what stops the mutation is this. +#[test] +fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + assert!( + !state.dispatch_idle(), + "a focused snapshot must round-trip keys, so no replica applies \ + optimistically and none emits a CRDT op" + ); + // ...and it is the SNAPSHOT that does it, not merely "some terminal + // buffer is around": switching to an ordinary buffer restores idle. + exec( + &state, + r#"pmacs.window.switch_buffer(pmacs.buffer.create("*plain*"))"#, + ); + assert!(state.dispatch_idle(), "an ordinary buffer is idle again"); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 16 (the other half): the intercept rejects ordinary edits, +/// and the buffer is genuinely `read_only` at the rope boundary, so the +/// protection does not depend on which key or command was used. +#[test] +fn acc16b_the_snapshot_is_immutable_at_the_rope_not_merely_intercepted() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let before = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); + press(&mut state, KeyCode::Char('z'), KeyModifiers::NONE); + let after = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); + assert_eq!(before, after, "the read-only intercept rejects self-insert"); + + let core = state.core.borrow(); + let registry = core.registry.borrow(); + let ids = registry.ids(); + let snapshot = ids + .iter() + .copied() + .find(|id| { + registry + .get(*id) + .is_ok_and(|buf| buf.name() == SNAPSHOT_NAME) + }) + .expect("snapshot buffer id"); + assert!( + registry + .get(snapshot) + .expect("snapshot buffer") + .is_read_only(), + "an intercept guards the dispatch path only; `Buffer::undo` reaches \ + the rope through `ensure_writable` without consulting it, so the \ + snapshot must be read-only at the rope" + ); + drop(registry); + drop(core); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 16c (review round 2, P1): **undo cannot empty the snapshot**, +/// through the chord *or* through the command. +/// +/// The chord half alone would be a false pass. `M-x buffer.undo` and the +/// menu reach `Buffer::undo` without passing through any buffer-local +/// keymap, so rebinding `C-/` to a no-op — the existing `*compilation*` +/// idiom, which documents that "command/menu undo stays dispatchable" — +/// leaves the buffer emptiable. Only rope-level `read_only` closes both. +#[test] +fn acc16c_undo_cannot_empty_the_snapshot_by_chord_or_by_command() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let rendered = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); + assert!( + rendered.contains("LINE200"), + "precondition: the snapshot has content to lose" + ); + + // The command path — reachable regardless of any buffer-local binding. + let _: Value = state + .lua_host + .lua() + .load(r"return pcall(pmacs.command.invoke_interactive, 'buffer.undo')") + .eval() + .expect("invoke_interactive is callable"); + assert_eq!( + buffer_text_by_name(&state, SNAPSHOT_NAME).as_deref(), + Some(rendered.as_str()), + "M-x buffer.undo must not empty the snapshot" + ); + + // The chord path. + press(&mut state, KeyCode::Char('/'), KeyModifiers::CONTROL); + assert_eq!( + buffer_text_by_name(&state, SNAPSHOT_NAME).as_deref(), + Some(rendered.as_str()), + "C-/ must not empty the snapshot" + ); + + // Redo is the same door. + let _: Value = state + .lua_host + .lua() + .load(r"return pcall(pmacs.command.invoke_interactive, 'buffer.redo')") + .eval() + .expect("invoke_interactive is callable"); + assert_eq!( + buffer_text_by_name(&state, SNAPSHOT_NAME).as_deref(), + Some(rendered.as_str()), + "buffer.redo must not alter the snapshot either" + ); + + // ...and the owner's own refresh still works, which is the whole + // reason plain `read_only` was not enough on its own. + emit_into_child(&mut state, terminal, "STILLREFRESHES"); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME) + .expect("snapshot") + .contains("STILLREFRESHES"), + "the owner-authorized write path must survive immutability" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Snapshot buffer id, by name, from the Rust side. +#[cfg(feature = "crdt")] +fn snapshot_buffer_id(state: &EditorState) -> pmacs::buffer::BufferId { + let core = state.core.borrow(); + let reg = core.registry.borrow(); + reg.ids() + .iter() + .copied() + .find(|id| reg.get(*id).is_ok_and(|b| b.name() == SNAPSHOT_NAME)) + .expect("snapshot buffer exists") +} + +/// Rendered cells of the active window (the `m4_acceptance` grid helper; +/// cross-crate test code can't import it). +fn render_active_window_to_grid( + state: &mut EditorState, + rows: u32, + cols: u32, +) -> Vec { + use pmacs::cell::{Cell, CellGrid}; + use pmacs::view::{View, Viewport}; + use pmacs::window::Rect; + + let mut core = state.core.borrow_mut(); + let active = core.active_window_id(); + let registry = core.registry.clone(); + let win = core.windows.get_mut(&active).expect("active window"); + let rect = Rect::new(0, 0, rows, cols); + let mut backing = vec![Cell::default(); (rows * cols) as usize]; + let reg = registry.borrow(); + let buf = reg.get(win.buffer_id).expect("buffer in registry"); + let viewport = Viewport { + buffer_start: 0, + buffer_end: buf.len(), + cell_origin: rect.origin, + cell_size: CellSize::new(rows, cols), + gutter_w: 0, + folds: None, + }; + let mut grid = CellGrid { + cells: &mut backing, + stride: cols, + size: CellSize::new(rows, cols), + }; + win.text_view.render(buf, viewport, &mut grid); + backing +} + +fn grid_row(cells: &[pmacs::cell::Cell], row: u32, cols: u32) -> String { + (0..cols) + .map(|c| match cells[(row * cols + c) as usize].glyph { + Glyph::Char(ch) => ch, + _ => ' ', + }) + .collect::() + .trim_end() + .to_owned() +} + +/// Review round 3, P1. A rope write is only half of an edit: the window +/// showing the buffer holds a `TextView` line index that only `on_edit` +/// maintains, so a write that reaches the rope without the notification +/// leaves the two disagreeing. +/// +/// Pinned by PAINTING, because that is where the disagreement bites: with +/// the fan-out dropped, the next render indexes the new rope with the old +/// line offsets. A shrinking write is used deliberately — stale offsets +/// then point past the buffer end, which is the reported crash rather than +/// merely stale pixels. +/// +/// Driven through `pmacs.buffer.set_generated_contents`, the seam copy +/// mode's refresh actually calls, so it also covers `*compilation*` and +/// any other owner that adopts the primitive later. +#[test] +fn acc16d_a_generated_write_notifies_the_window_that_displays_it() { + let mut state = EditorState::new(); + exec( + &state, + r" + GEN = pmacs.buffer.create('*generated-probe*') + pmacs.buffer.set_generated_contents(GEN, 'alpha\nbeta\ngamma\ndelta\nepsilon\n') + pmacs.window.switch_buffer(GEN) + ", + ); + let painted = render_active_window_to_grid(&mut state, 6, 20); + assert_eq!( + grid_row(&painted, 0, 20), + "alpha", + "precondition: the window paints the generated buffer" + ); + + exec( + &state, + r"pmacs.buffer.set_generated_contents(GEN, 'CHANGED\n')", + ); + let painted = render_active_window_to_grid(&mut state, 6, 20); + assert_eq!( + grid_row(&painted, 0, 20), + "CHANGED", + "the window must paint the refreshed contents" + ); + assert_eq!( + grid_row(&painted, 1, 20), + "", + "and nothing of the longer contents it replaced" + ); +} + +/// Review round 3, P1, CRDT half. The same dropped fan-out also skips +/// `queue_daemon_origin_crdt_op`, so replica mirrors never import the +/// owner's write and their optimistic edits are generated against content +/// the owner has already replaced. +/// +/// Gated because `upgrade_to_crdt` is — and therefore dark in CI, which +/// never enables the feature. The default-configuration half above is the +/// one that actually runs there. +#[cfg(feature = "crdt")] +#[test] +fn acc16e_a_refresh_queues_the_owners_write_for_replica_mirrors() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let snapshot = snapshot_buffer_id(&state); + { + let core = state.core.borrow(); + let mut reg = core.registry.borrow_mut(); + let buffer = reg.get_mut(snapshot).expect("snapshot buffer"); + // `read_only` refuses the upgrade's own bookkeeping path the same + // way it refuses everything else, so lift it around the upgrade. + buffer.set_read_only(false); + buffer.upgrade_to_crdt(2).expect("upgrade"); + buffer.set_read_only(true); + } + state.core.borrow_mut().pending_crdt_ops.clear(); + + emit_into_child(&mut state, terminal, "MIRRORME"); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let queued: Vec<_> = state + .core + .borrow() + .pending_crdt_ops + .iter() + .map(|(_, id, _)| *id) + .collect(); + assert!( + queued.contains(&snapshot), + "the owner's refresh must be queued for broadcast; queued: {queued:?}" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 18: re-invoking refreshes in place, and the lifecycle runs +/// both directions. +#[test] +fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + let count_after_first = buffer_count(&state); + assert!( + !buffer_text_by_name(&state, SNAPSHOT_NAME) + .expect("snapshot") + .contains("REINVOKE"), + "precondition: the marker has not been emitted yet" + ); + + // Advance the world, then re-invoke. Counting buffers alone is + // vacuous: it passes with the render replaced by a no-op, so the + // refresh must be observed by CONTENT that only exists after the + // first snapshot was taken. + emit_into_child(&mut state, terminal, "REINVOKE"); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME) + .expect("snapshot") + .contains("REINVOKE"), + "re-invoking must actually re-serialize, not just reuse the buffer" + ); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + assert_eq!( + buffer_count(&state), + count_after_first, + "...and it must refresh IN PLACE, not accumulate buffers" + ); + + // Killing the snapshot alone leaves the terminal running. + exec( + &state, + &format!( + r" + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == {SNAPSHOT_NAME:?} then pmacs.buffer.kill(id) end + end + " + ), + ); + assert!( + state.terminal_manager.borrow().is_terminal(terminal), + "killing the snapshot must leave the terminal untouched" + ); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(), + "the snapshot buffer is gone" + ); + + // ...and it can be rebuilt afterwards. + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME).is_some(), + "a later invoke rebuilds the snapshot" + ); + + // Killing the terminal takes its snapshot with it. + exec(&state, "pmacs.terminal.terminate(TERM_BUF)"); + exec(&state, "pmacs.buffer.kill(TERM_BUF)"); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(), + "killing the terminal must remove its snapshot" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 19: `C-t` in a terminal — physically `C-c C-t`, because every +/// unescaped key goes to the child — enters copy mode; `g` refreshes and +/// `q` returns to the source terminal. +#[test] +fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + let terminal_name = active_buffer_name(&state); + + // The escape, then the terminal-local binding. + press(&mut state, KeyCode::Char('c'), KeyModifiers::CONTROL); + press(&mut state, KeyCode::Char('t'), KeyModifiers::CONTROL); + assert_eq!( + active_buffer_name(&state), + SNAPSHOT_NAME, + "C-c C-t must enter copy mode" + ); + + // `q` returns to the source terminal. + press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE); + assert_eq!( + active_buffer_name(&state), + terminal_name, + "q must return to the terminal the snapshot was taken from" + ); + + // Now advance the world and come back WITHOUT re-invoking copy mode, + // so the snapshot is genuinely stale. Comparing a quiet terminal's + // snapshot against itself is vacuous — it passes with `render_snapshot` + // replaced by a no-op. + emit_into_child(&mut state, terminal, "AFTER-G"); + exec( + &state, + &format!( + r" + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == {SNAPSHOT_NAME:?} then + pmacs.window.switch_buffer(id) + end + end + " + ), + ); + assert!( + !buffer_text_by_name(&state, SNAPSHOT_NAME) + .expect("snapshot") + .contains("AFTER-G"), + "the snapshot must still be stale before `g` — otherwise the next \ + assertion proves nothing" + ); + + press(&mut state, KeyCode::Char('g'), KeyModifiers::NONE); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME) + .expect("snapshot") + .contains("AFTER-G"), + "`g` must re-snapshot from the live terminal" + ); + assert_eq!( + active_buffer_name(&state), + SNAPSHOT_NAME, + "g must not move us" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 20: copy mode is additive — the live terminal's own keys are +/// unchanged while a snapshot exists, and the terminal still follows its +/// tail. +#[test] +fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + let key = focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + // Back to the terminal; its five live bindings must still resolve. + exec(&state, "pmacs.window.switch_buffer(TERM_BUF)"); + for (sequence, command) in [ + ("M-w", "terminal.copy-selection"), + ("M-v", "terminal.page-up"), + ("C-v", "terminal.page-down"), + ("M-<", "terminal.scroll-oldest"), + ("M->", "terminal.scroll-bottom"), + ] { + let resolved: Option = eval( + &state, + &format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"), + ); + assert_eq!( + resolved.as_deref(), + Some(command), + "{sequence} must still be the live terminal binding" + ); + } + + // The terminal still FOLLOWS ITS TAIL while a snapshot exists. + // + // Read through the registered view, not `manager.snapshot(buffer)`: + // that call is context-free and always returns the live screen, so it + // reports "at the tail" even for a view forced to the oldest retained + // row. The projected view is the only thing that can distinguish them. + assert!( + view_at_bottom(&state, key), + "precondition: the view starts at the tail" + ); + emit_into_child(&mut state, terminal, "TAILMARK"); + assert!( + view_at_bottom(&state, key), + "new child output must not knock the view off the tail" + ); + assert!( + view_text(&state, key).contains("TAILMARK"), + "the freshest output must be visible in the PROJECTED view: {:?}", + view_text(&state, key) + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 21: the dispatch-shadow count is unchanged at six, pinned by +/// the observable difference between a buffer-local keymap and a shadow — +/// `describe-key` telling the truth about `g` and `q` in the snapshot. +/// +/// A seventh shadow would decode these keys before `KeymapStack::resolve` +/// ever ran, so introspection would report whatever the global binding is +/// (or nothing) while the keys behaved differently. +#[test] +fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + for (sequence, command) in [("g", "terminal.copy-refresh"), ("q", "terminal.copy-quit")] { + let resolved: Option = eval( + &state, + &format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"), + ); + assert_eq!( + resolved.as_deref(), + Some(command), + "describe-key must report the buffer-local {sequence} binding" + ); + } + + // And the binding really is scoped: back in the terminal, `q` is not + // the copy-mode command. + exec(&state, "pmacs.window.switch_buffer(TERM_BUF)"); + let resolved: Option = eval( + &state, + r#"local d = pmacs.describe.key("q"); return d and d.command"#, + ); + assert_ne!( + resolved.as_deref(), + Some("terminal.copy-quit"), + "the snapshot's q must not leak into the terminal buffer" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 18a (review round 1, P1): a foreign buffer that happens to +/// carry the snapshot's name is **never adopted**. +/// +/// `pmacs.buffer.create` takes any caller-chosen name, and snapshot writes +/// use `bypass_intercept`, so found-by-name adoption clobbers a user's +/// data outright. Ownership means "in copy mode's own handle table" +/// (dired's F7 rule); a taken name gets a `<2>` variant instead. +#[test] +fn acc18a_a_foreign_same_named_buffer_is_never_adopted_or_clobbered() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + + // A user's buffer, sitting exactly where the snapshot wants to go. + exec( + &state, + &format!( + r" + FOREIGN = pmacs.buffer.create({SNAPSHOT_NAME:?}) + FOREIGN:insert(0, 'do not clobber') + " + ), + ); + + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let foreign_text: String = eval(&state, r"return FOREIGN:slice(0, FOREIGN:len())"); + assert_eq!( + foreign_text, "do not clobber", + "the foreign buffer must be untouched" + ); + assert_ne!( + active_buffer_name(&state), + SNAPSHOT_NAME, + "copy mode must not display the foreign buffer" + ); + assert_eq!( + active_buffer_name(&state), + format!("{SNAPSHOT_NAME}<2>"), + "a taken name must yield a unique variant" + ); + assert!( + buffer_text_by_name(&state, &format!("{SNAPSHOT_NAME}<2>")) + .expect("variant snapshot") + .contains("LINE200"), + "the variant is the real snapshot" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 18b (review round 1, P1): snapshot identity is the terminal +/// BUFFER, not its name. +/// +/// `TerminalManager::open` uniquifies only the *derived* name — an +/// explicit `name = ...` is inserted verbatim — so two valid terminals can +/// share a name. Keying snapshots by name gives them one buffer between +/// them: the second invocation retargets it, `q` returns to the wrong +/// terminal, and killing either one removes the shared snapshot. +#[test] +fn acc18b_two_same_named_terminals_get_two_independent_snapshots() { + let mut state = EditorState::new(); + exec(&state, FILL_PROFILE); + + let before = terminal_buffers(&state); + exec( + &state, + r#"TERM_A = pmacs.terminal.open { profile = "fill", name = "*same*" }"#, + ); + exec( + &state, + r#"TERM_B = pmacs.terminal.open { profile = "fill", name = "*same*" }"#, + ); + let fresh: Vec<_> = terminal_buffers(&state) + .into_iter() + .filter(|id| !before.contains(id)) + .collect(); + assert_eq!(fresh.len(), 2, "two terminals opened under one name"); + + // Distinguish them by content, since their names are identical. + emit_into_child(&mut state, fresh[0], "AAAA"); + emit_into_child(&mut state, fresh[1], "BBBB"); + + focus_terminal(&state, fresh[0]); + let snap_a: String = eval( + &state, + r"local b = pmacs.terminal.copy_mode(TERM_A); return (pmacs.describe.buffer(b)).name", + ); + focus_terminal(&state, fresh[1]); + let snap_b: String = eval( + &state, + r"local b = pmacs.terminal.copy_mode(TERM_B); return (pmacs.describe.buffer(b)).name", + ); + + assert_ne!( + snap_a, snap_b, + "two terminals must not share one snapshot buffer" + ); + let text_a = buffer_text_by_name(&state, &snap_a).expect("snapshot A"); + let text_b = buffer_text_by_name(&state, &snap_b).expect("snapshot B"); + assert!( + text_a.contains("AAAA") && !text_a.contains("BBBB"), + "snapshot A must hold only A's output: {:?}", + &text_a[text_a.len().saturating_sub(60)..] + ); + assert!( + text_b.contains("BBBB") && !text_b.contains("AAAA"), + "snapshot B must hold only B's output" + ); + + // `q` from each snapshot returns to ITS OWN terminal, which is only + // observable through the buffer id — the two names are the same. + exec( + &state, + &format!( + r" + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == {snap_b:?} then pmacs.window.switch_buffer(id) end + end + " + ), + ); + press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE); + let returned_is_b: bool = eval(&state, r"return pmacs.window.buffer() == TERM_B"); + assert!( + returned_is_b, + "q from B's snapshot must return to terminal B" + ); + + // Killing terminal A removes only A's snapshot. + exec(&state, "pmacs.terminal.terminate(TERM_A)"); + exec(&state, "pmacs.buffer.kill(TERM_A)"); + assert!( + buffer_text_by_name(&state, &snap_a).is_none(), + "A's snapshot dies with A" + ); + assert!( + buffer_text_by_name(&state, &snap_b).is_some(), + "B's snapshot must SURVIVE — a shared buffer would have gone too" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Copy mode refuses a non-terminal buffer rather than producing an empty +/// snapshot of nothing. +#[test] +fn copy_mode_refuses_a_non_terminal_buffer() { + let state = EditorState::new(); + let err = eval_err(&state, "return pmacs.terminal.copy_mode()"); + assert!( + err.contains("not a terminal"), + "the refusal must say why: {err}" + ); +} 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)" + ); +} diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index b7c2e9c..f6b1f7f 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -691,6 +691,9 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { .arg(&report) // The chord the probe presses to run `vterm-probe.open`. .env("PMACS_GPU_PROBE_OPEN_KEY", "t") + // This producer fixture does not consume the probe's input; wait + // instead for its own live cursor-addressed breadcrumb. + .env("PMACS_GPU_PROBE_EXPECT_TEXT", "VTERMROW") .output() .expect("run the headless GPU probe"); @@ -717,7 +720,7 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { assert_eq!( facts.get("server_protocol_version").copied(), Some("20"), - "the real daemon negotiated v20 with the real client: {text}" + "the dark v21 wire slice must keep the real client on v20: {text}" ); assert_eq!( facts.get("entered_terminal_mode").copied(), @@ -746,6 +749,11 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { .is_some_and(|t| t.contains("VTERMROW")), "the child's cursor-addressed output must reach the rendered frame: {text}" ); + assert_eq!( + facts.get("completion_observed").copied(), + Some("true"), + "the probe must finish on the fixture's PTY evidence, not its deadline: {text}" + ); let declarations: u32 = facts .get("declarations") .and_then(|v| v.parse().ok()) @@ -846,7 +854,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { panic!("timed out waiting for {what}"); } - assert_eq!(PROTOCOL_VERSION, 20); + assert_eq!(PROTOCOL_VERSION, 21); let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( &[ ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"), @@ -1311,4 +1319,10 @@ fn gpu_terminal_input_reaches_the_child_and_returns_in_a_frame() { "the typed character must reach the child and return: {}", report() ); + assert_eq!( + facts.get("completion_observed").map(String::as_str), + Some("true"), + "the probe must finish on the latched input echo, not its deadline: {}", + report() + ); }