Merge remote-tracking branch 'githubsucks/main' into fix-m4-sink-races
This commit is contained in:
commit
302c21c688
205
COHERENCE.md
205
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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,750 @@
|
|||
-- builtin/runtime/lean.lua --- Arc 8 Stage 3b: the Lean 4 language server.
|
||||
--
|
||||
-- Framing: `docs/lean4-mode-framing.md` Q#LN7 (lake serve + probe +
|
||||
-- fallback latch), Q#LN8 (Lake-aware outermost root), Q#LN16
|
||||
-- (waitForDiagnostics). Stage 1 shipped the grammar, mode, comment
|
||||
-- strings and pair set; Stage 3a shipped the notification/response
|
||||
-- seams and `pmacs.fs.canonicalize` this file consumes.
|
||||
--
|
||||
-- Loaded after `lsp.lua`, which owns `pmacs.lsp.config` and the drain.
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Q#LN8 — the Lake-aware root -----------------------------------------
|
||||
--
|
||||
-- `pmacs.project.detect` cannot express this rule. It is innermost-wins
|
||||
-- by construction, and a Lake package's `lean-toolchain` sits at the
|
||||
-- OUTERMOST level: a file under `<pkg>/.lake/packages/dep/Foo.lean`
|
||||
-- belongs to `<pkg>`'s server, not to `dep`'s, because `lake serve` is
|
||||
-- bound to one package and analyzes its dependencies from inside it.
|
||||
-- Inverting `detect` globally would change Rust/Go/Node roots for every
|
||||
-- user, so the rule lives here as a function-valued `config.root` —
|
||||
-- the generalization Stage 2 (#161) added for exactly this.
|
||||
|
||||
-- The marker test, and the two ways to get it wrong.
|
||||
--
|
||||
-- `pmacs.fs.stat` is UNUSABLE here: it returns an awaitable handle
|
||||
-- (`fs.lua`), and this runs synchronously inside `ensure_server` <-
|
||||
-- `attach_buffer` <- the `buffer.after-load` hook, where there is no
|
||||
-- coroutine to await on. The Lua stdlib's `io.open` is the only
|
||||
-- synchronous existence check available.
|
||||
--
|
||||
-- But `io.open` alone is wrong in BOTH directions:
|
||||
-- * it SUCCEEDS on a directory (probed), so a truthiness test would
|
||||
-- accept a `lean-toolchain` directory as a marker; and
|
||||
-- * requiring a non-nil read rejects an EMPTY `lean-toolchain`, which
|
||||
-- is a legitimate marker — `locate-dominating-file` semantics are
|
||||
-- existence, not content.
|
||||
-- The discriminator is `read`'s SECOND return (probed on LuaJIT 2.1):
|
||||
-- file with content -> "l", no error -> marker
|
||||
-- empty file -> nil, NO error -> marker
|
||||
-- directory -> nil, "Is a directory" -> decline
|
||||
-- missing -> io.open returns nil -> decline
|
||||
-- so: decline only on a non-nil `err`. This needs no per-platform
|
||||
-- re-probe, because both directory behaviors are declines — a platform
|
||||
-- whose `fopen` refuses directories fails at `io.open` instead. There
|
||||
-- is no platform where a directory both opens and yields a byte.
|
||||
local function has_toolchain(dir)
|
||||
local f = io.open(dir .. "/lean-toolchain", "r")
|
||||
if not f then return false end
|
||||
local _, err = f:read(1)
|
||||
f:close()
|
||||
return err == nil
|
||||
end
|
||||
|
||||
local function parent_of(dir)
|
||||
local up = dir:match("^(.*)/[^/]+$")
|
||||
if up == nil or up == dir or up == "" then return nil end
|
||||
return up
|
||||
end
|
||||
|
||||
-- The walk stops at `pmacs.project.search_boundary()`. Not politeness:
|
||||
-- `detect_project_within` (`src/project.rs`) exists precisely so a
|
||||
-- stray marker above a temp fixture cannot leak into detection, and a
|
||||
-- Lua walk that ignored the boundary would break that contract — and
|
||||
-- make acceptance 23's outermost assertion non-hermetic against any
|
||||
-- `lean-toolchain` sitting above the test's tempdir.
|
||||
local function within_boundary(dir, boundary)
|
||||
if not boundary then return true end
|
||||
return dir == boundary or dir:sub(1, #boundary + 1) == boundary .. "/"
|
||||
end
|
||||
|
||||
-- Returns the OUTERMOST ancestor holding a `lean-toolchain`, or nil to
|
||||
-- decline (which falls through to `pmacs.project.detect`, then the
|
||||
-- file's own directory).
|
||||
--
|
||||
-- **The result is canonical, and must be.** A configured root — which
|
||||
-- this is — reaches `file_uri_for` verbatim and that URI is the
|
||||
-- server-affinity key (#161). `pmacs.editor.file_path()` collapses `.`
|
||||
-- and `..` lexically but leaves symlinks intact, so one package opened
|
||||
-- through a symlink and through its real path would otherwise spawn two
|
||||
-- `lake serve` processes. Canonicalizing ONCE up front is enough:
|
||||
-- every ancestor of a canonical path is itself canonical, since the
|
||||
-- walk only strips trailing components.
|
||||
--
|
||||
-- If canonicalization fails (deleted file, broken symlink) the resolver
|
||||
-- declines rather than returning a path it cannot vouch for.
|
||||
function M.root_for(path)
|
||||
if type(path) ~= "string" then return nil end
|
||||
local dir = path:match("^(.*)/[^/]*$")
|
||||
if not dir then return nil end
|
||||
dir = pmacs.fs.canonicalize(dir)
|
||||
if not dir then return nil end
|
||||
local boundary
|
||||
local ok, b = pcall(pmacs.project.search_boundary)
|
||||
if ok then boundary = b end
|
||||
-- The boundary is canonicalized at set time (`set_search_boundary`),
|
||||
-- so comparing it against a canonical `dir` is apples to apples.
|
||||
local outermost = nil
|
||||
local cur = dir
|
||||
while cur and within_boundary(cur, boundary) do
|
||||
if has_toolchain(cur) then outermost = cur end
|
||||
cur = parent_of(cur)
|
||||
end
|
||||
return outermost
|
||||
end
|
||||
|
||||
-- Q#LN7 — `lake serve`, with a lazy probe and a one-shot latch --------
|
||||
--
|
||||
-- `pmacs.lsp.config.lean4` is declarative and must stay cheap: spawning
|
||||
-- a process at startup for every user, Lean-using or not, is the cost
|
||||
-- rev 1 refused. So no probe runs here — it runs on the first `.lean`
|
||||
-- attach, below.
|
||||
pmacs.lsp.config.lean4 = pmacs.lsp.config.lean4 or {
|
||||
command = "lake",
|
||||
args = { "serve" },
|
||||
root = M.root_for,
|
||||
-- No `init_options`: `hasWidgets?` defaults to false, which is the
|
||||
-- correct posture for a client reading plain goals out of standard
|
||||
-- messages rather than driving the `$/lean/rpc/*` widget stack.
|
||||
}
|
||||
|
||||
-- Session state. The latch is one-shot and never re-arms: a user whose
|
||||
-- toolchain is broken sees one fallback attempt, not a loop.
|
||||
local probe = {
|
||||
started = false, -- the `lake --version` probe has been spawned
|
||||
latched = false, -- the fallback has fired (or been ruled out)
|
||||
proc = nil, -- process id of the running probe
|
||||
out = "", -- accumulated probe stdout
|
||||
buf_key = nil, -- tostring() of the buffer that started this
|
||||
watching = nil, -- sid still being polled for die-before-initialize
|
||||
primary = nil, -- sid the probe's verdict applies to; NOT cleared
|
||||
-- when the server initializes, because a late
|
||||
-- version verdict still has to retire it
|
||||
armed = false, -- the target buffer + primary have been captured
|
||||
repaired = {}, -- buffer key -> repair attempted (at most once)
|
||||
repair_attempts = 0, -- COUNT of attach attempts, not distinct buffers:
|
||||
-- table cardinality cannot tell "once per buffer"
|
||||
-- from "every tick for one buffer"
|
||||
fallback_installed = false,
|
||||
fallback_watches = {}, -- sid key -> sid, each polled die-before-init
|
||||
fallback_done = {}, -- sid key -> initialized or terminally handled
|
||||
saw_initialized = false,
|
||||
}
|
||||
|
||||
-- The command as configured, for status text. Hardcoding "lake serve"
|
||||
-- was untruthful the moment the failure latch became command-agnostic:
|
||||
-- a user whose `my-lean-wrapper` failed was told `lake serve` did.
|
||||
local function configured_command()
|
||||
local cfg = pmacs.lsp.config.lean4
|
||||
local cmd = cfg and cfg.command
|
||||
if not cmd then return "the Lean server" end
|
||||
local args = cfg.args or {}
|
||||
if #args > 0 then
|
||||
return "`" .. tostring(cmd) .. " " .. table.concat(args, " ") .. "`"
|
||||
end
|
||||
return "`" .. tostring(cmd) .. "`"
|
||||
end
|
||||
|
||||
-- The fallback command, for status text.
|
||||
local function fallback_name()
|
||||
local args = M._fallback.args or {}
|
||||
if #args > 0 then
|
||||
return "`" .. tostring(M._fallback.command) .. " "
|
||||
.. table.concat(args, " ") .. "`"
|
||||
end
|
||||
return "`" .. tostring(M._fallback.command) .. "`"
|
||||
end
|
||||
|
||||
local function report(msg)
|
||||
-- COHERENCE §1.2: background work must leave an attributed trace.
|
||||
-- `pmacs.editor.set_status` is the channel that EXISTS; `pmacs.error`
|
||||
-- is referenced by fifteen call sites and defined nowhere in
|
||||
-- production, so it rides along rather than standing alone.
|
||||
pcall(pmacs.editor.set_status, msg)
|
||||
if pmacs.error then pcall(pmacs.error, msg) end
|
||||
end
|
||||
|
||||
-- `lake serve` below 3.1.0 starts a server that cannot answer, which is
|
||||
-- worse than failing: `lean4-mode` probes for exactly this and falls
|
||||
-- back to `lean --server`. Parses the leading `x.y` of a version line.
|
||||
-- State kind for the server whose `tostring(id)` is `skey`, or nil if
|
||||
-- the manager has forgotten it (which is itself a terminal answer).
|
||||
local function server_state_kind_for_key(skey)
|
||||
local ok, rows = pcall(pmacs.lsp.list)
|
||||
if not ok or not rows then return nil end
|
||||
for _, info in ipairs(rows) do
|
||||
if tostring(info.id) == skey then
|
||||
return info.state and info.state.kind
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function server_state_kind(sid)
|
||||
return server_state_kind_for_key(tostring(sid))
|
||||
end
|
||||
|
||||
local function version_below_3_1(text)
|
||||
local major, minor = text:match("(%d+)%.(%d+)")
|
||||
if not major then return false end
|
||||
major, minor = tonumber(major), tonumber(minor)
|
||||
if major < 3 then return true end
|
||||
return major == 3 and minor < 1
|
||||
end
|
||||
|
||||
-- What the latch falls back TO.
|
||||
--
|
||||
-- **Underscored: a test seam, not supported user configuration.** It is
|
||||
-- a table only so the acceptance suite can point it at a stand-in server
|
||||
-- and drive the real latch path end to end, instead of asserting on a
|
||||
-- config mutation that proves nothing about whether a server ever
|
||||
-- starts. Presenting it as public config would owe framing,
|
||||
-- documentation, validation and mutation semantics that nothing here
|
||||
-- provides; users configure Lean through `pmacs.lsp.config.lean4`.
|
||||
M._fallback = { command = "lean", args = { "--server" } }
|
||||
|
||||
local function same_args(a, b)
|
||||
a, b = a or {}, b or {}
|
||||
if #a ~= #b then return false end
|
||||
for i = 1, #a do
|
||||
if a[i] ~= b[i] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Swap `command`/`args` ONLY. A wholesale table replacement would
|
||||
-- silently discard a user's `env` / `settings` / `init_options` / `root`
|
||||
-- from `init.lua` at exactly the moment they are least likely to notice.
|
||||
--
|
||||
-- The only guard is idempotence — already-the-fallback means nothing to
|
||||
-- do. It deliberately does NOT refuse when the command is user-supplied:
|
||||
-- the latch fires only when the configured Lean server actually failed
|
||||
-- to start, and one visible fallback attempt beats leaving the user with
|
||||
-- no server at all. `probe.latched` is what keeps it to exactly one.
|
||||
local function swap_to_fallback()
|
||||
local cfg = pmacs.lsp.config.lean4
|
||||
if not cfg then return false end
|
||||
-- Idempotence compares command AND args: the same command with
|
||||
-- different arguments is not "already applied", and treating it as
|
||||
-- such would silently skip a swap that still needed to happen.
|
||||
if cfg.command == M._fallback.command
|
||||
and same_args(cfg.args, M._fallback.args) then
|
||||
return false
|
||||
end
|
||||
cfg.command = M._fallback.command
|
||||
cfg.args = M._fallback.args
|
||||
return true
|
||||
end
|
||||
|
||||
-- Retire the failed server, swap the command, then rebuild the
|
||||
-- attachment on the buffer that started this.
|
||||
-- Retire `sid` so it cannot come back. **Which call to use depends on
|
||||
-- the state, and using the wrong one is worse than doing nothing:**
|
||||
--
|
||||
-- * TERMINAL (`crashed` / `stopped`) -> `forget`. It requires a
|
||||
-- terminal state and removes the client outright, which also drops
|
||||
-- the `next_restart_at` the crash scheduled. `stop` here would take
|
||||
-- its not-initialized branch and set `ShuttingDown { .. None }` on
|
||||
-- the premise that "the next exit observation cleans up" — but the
|
||||
-- exit already happened, which is what made it `Crashed`. No
|
||||
-- further event arrives, so it sits in `ShuttingDown` forever:
|
||||
-- `server_is_live` reads that as LIVE so `attach_buffer` never
|
||||
-- rebuilds, and `forget` then refuses it for not being terminal.
|
||||
-- * NON-TERMINAL -> `stop`. `forget` rejects it, and `stop` disables
|
||||
-- restart and drives the polite shutdown.
|
||||
--
|
||||
-- Round 1 skipped the call entirely for terminal servers. That avoided
|
||||
-- the corruption but left `next_restart_at` armed, so the crashed
|
||||
-- primary respawned 500ms later and kept respawning underneath the
|
||||
-- live fallback — invisible to a test that stopped ticking first.
|
||||
local function retire_server(sid)
|
||||
local kind = server_state_kind(sid)
|
||||
if kind == nil then return end
|
||||
if kind == "crashed" or kind == "stopped" then
|
||||
pcall(pmacs.lsp.forget, sid)
|
||||
else
|
||||
pcall(pmacs.lsp.stop, sid)
|
||||
end
|
||||
end
|
||||
|
||||
-- Retire EVERY Lean server, not just the one that failed.
|
||||
--
|
||||
-- `pmacs.lsp.config.lean4` is a single global entry, so swapping its
|
||||
-- command invalidates every server spawned from the old one — and
|
||||
-- Q#LN15 gives one server per project root, so there can be several.
|
||||
-- Retiring only the server that happened to fail left the others live
|
||||
-- and every buffer attached to them stranded on a command the config no
|
||||
-- longer names.
|
||||
-- Only servers the config-driven path itself produced. A server's label,
|
||||
-- language, command, and root are all caller-supplied public values; none
|
||||
-- is an ownership discriminator. `lsp.lua` records the successful spawn
|
||||
-- in a private origin table, which is the fact this lifecycle may act on.
|
||||
local function is_derived_server(sid)
|
||||
local ok, owned = pcall(pmacs.lsp._is_default_server, sid, "lean4")
|
||||
return ok and owned == true
|
||||
end
|
||||
|
||||
local function retire_derived_lean_servers()
|
||||
local ok, rows = pcall(pmacs.lsp.list)
|
||||
if not ok or not rows then return end
|
||||
local ids = {}
|
||||
for _, info in ipairs(rows) do
|
||||
if is_derived_server(info.id) then ids[#ids + 1] = info.id end
|
||||
end
|
||||
for _, id in ipairs(ids) do
|
||||
-- These ids predate the fallback spawn. Mark them handled before
|
||||
-- retirement so the discovery poll cannot mistake their terminal
|
||||
-- state for a fallback that failed to initialize.
|
||||
probe.fallback_done[tostring(id)] = true
|
||||
retire_server(id)
|
||||
end
|
||||
end
|
||||
|
||||
local function watch_fallback_server(sid)
|
||||
if not sid or not is_derived_server(sid) then return end
|
||||
local key = tostring(sid)
|
||||
if probe.fallback_done[key] then return end
|
||||
probe.fallback_watches[key] = sid
|
||||
end
|
||||
|
||||
-- Rebuild the ACTIVE buffer's attachment if it is Lean and stale.
|
||||
--
|
||||
-- `_attach_buffer` is an active-buffer-only seam, so a global config
|
||||
-- swap cannot be applied to every open buffer at once. It is applied
|
||||
-- lazily instead: whenever a Lean buffer becomes the active one, if its
|
||||
-- record points at a server that is gone or terminal, it is rebuilt.
|
||||
--
|
||||
-- **At most one attempt per buffer.** Without that bound a fallback
|
||||
-- that also fails to spawn would retry every tick forever with nothing
|
||||
-- reported — the round-2 defect, which a general repair loop would
|
||||
-- otherwise reintroduce for every buffer instead of just one.
|
||||
--
|
||||
-- A `shutting-down` server is deliberately NOT treated as stale: it is
|
||||
-- still live by `server_is_live`'s reckoning, so `attach_buffer` would
|
||||
-- early-return the stale record and burn this buffer's single attempt
|
||||
-- on a no-op. Skipping leaves the attempt for a later tick, once the
|
||||
-- retirement has actually landed.
|
||||
local function repair_active_if_stale()
|
||||
-- **`fallback_installed`, not `latched`.** When the swap does not
|
||||
-- happen — the config already names the fallback, or it vanished
|
||||
-- before an asynchronous verdict landed — `fire_latch` returns early
|
||||
-- but `latched` stays true. Gating repair on `latched` then retried
|
||||
-- the UNCHANGED configuration and reported the result as a fallback
|
||||
-- failure, which is both a second pointless spawn and a misleading
|
||||
-- message. Repair exists to apply a swap; no swap, nothing to apply.
|
||||
if not probe.fallback_installed then return end
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
local key = tostring(buf)
|
||||
if probe.repaired[key] then return end
|
||||
local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf)
|
||||
if not ok_lang or lang ~= "lean4" then return end
|
||||
|
||||
local rec = pmacs.lsp.active_attachment()
|
||||
local stale
|
||||
if not rec then
|
||||
stale = true
|
||||
else
|
||||
local kind = server_state_kind(rec.server)
|
||||
stale = (kind == nil or kind == "crashed" or kind == "stopped")
|
||||
end
|
||||
if not stale then return end
|
||||
|
||||
probe.repaired[key] = true
|
||||
probe.repair_attempts = probe.repair_attempts + 1
|
||||
local ok, fresh = pcall(pmacs.lsp._attach_buffer)
|
||||
if not ok or not fresh then
|
||||
report("LSP: lean4 fallback " .. fallback_name()
|
||||
.. " did not start either")
|
||||
return
|
||||
end
|
||||
-- **A successful SPAWN is not a successful START.** The once-per-
|
||||
-- buffer bound stops `_attach_buffer` being called again, but it says
|
||||
-- nothing about the server it produced. Arm this id immediately; the
|
||||
-- poll below also discovers servers created through lsp.lua's own
|
||||
-- after-load and command paths.
|
||||
watch_fallback_server(fresh.server)
|
||||
end
|
||||
|
||||
-- Every fallback server gets its own die-before-initialize poll. A scalar
|
||||
-- watch cannot cover Q#LN15's simultaneous per-root servers, and a server
|
||||
-- may be created by lsp.lua's after-load or command path without passing
|
||||
-- through `repair_active_if_stale`. Discovery from the private ownership
|
||||
-- table closes both holes.
|
||||
local function poll_fallbacks()
|
||||
if not probe.fallback_installed then return end
|
||||
local ok, rows = pcall(pmacs.lsp.list)
|
||||
if not ok or not rows then return end
|
||||
|
||||
local by_key = {}
|
||||
for _, info in ipairs(rows) do
|
||||
local key = tostring(info.id)
|
||||
by_key[key] = info
|
||||
if not probe.fallback_done[key] and is_derived_server(info.id) then
|
||||
probe.fallback_watches[key] = info.id
|
||||
end
|
||||
end
|
||||
|
||||
for key, sid in pairs(probe.fallback_watches) do
|
||||
local info = by_key[key]
|
||||
local kind = info and info.state and info.state.kind
|
||||
if kind == "initialized" then
|
||||
probe.fallback_watches[key] = nil
|
||||
probe.fallback_done[key] = true
|
||||
elseif info == nil or kind == "crashed" or kind == "stopped" then
|
||||
probe.fallback_watches[key] = nil
|
||||
probe.fallback_done[key] = true
|
||||
if info ~= nil then retire_server(sid) end
|
||||
report("LSP: lean4 fallback " .. fallback_name()
|
||||
.. " started but did not stay up")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function fire_latch(sid, why)
|
||||
if probe.latched then return end
|
||||
probe.latched = true
|
||||
probe.watching = nil
|
||||
if not swap_to_fallback() then
|
||||
report("LSP: lean4 " .. why)
|
||||
-- No shared config changed, so only the server whose failure
|
||||
-- triggered this verdict is invalid. Sweeping every root here stops
|
||||
-- healthy instances of a root-sensitive command for no reason.
|
||||
if sid and is_derived_server(sid) then retire_server(sid) end
|
||||
return
|
||||
end
|
||||
retire_derived_lean_servers()
|
||||
probe.fallback_installed = true
|
||||
report("LSP: lean4 " .. why .. "; falling back to " .. fallback_name())
|
||||
-- Repair what is in front of the user now; everything else is
|
||||
-- repaired lazily as it becomes active (see `repair_active_if_stale`).
|
||||
repair_active_if_stale()
|
||||
end
|
||||
|
||||
local function drain_probe()
|
||||
if not probe.proc then return end
|
||||
local ok, evs = pcall(pmacs.process.events_take, probe.proc)
|
||||
if not ok or not evs then return end
|
||||
for _, ev in ipairs(evs) do
|
||||
if ev.kind == "stdout" or ev.kind == "stderr" then
|
||||
probe.out = probe.out .. tostring(ev.bytes)
|
||||
elseif ev.kind == "exited" or ev.kind == "signaled"
|
||||
or ev.kind == "crashed" then
|
||||
local proc = probe.proc
|
||||
probe.proc = nil
|
||||
pcall(pmacs.process.forget, proc)
|
||||
-- A non-zero exit is NOT a fallback trigger on its own. §2.9: elan
|
||||
-- shims make `lake --version` exit non-zero with "no default
|
||||
-- toolchain configured" on a machine where `lake serve` may still
|
||||
-- be the right command — the server-failure latch covers that
|
||||
-- case, and covers it better. The probe answers only the ONE
|
||||
-- question failure detection would otherwise answer slowly: an
|
||||
-- old-but-working lake that starts a useless server.
|
||||
-- **`probe.primary`, NOT `probe.watching`.** `watching` is
|
||||
-- failure-polling state and is cleared the moment the server
|
||||
-- initializes. A slow `--version` that lands after a successful
|
||||
-- initialize would then arrive with nil, and `fire_latch(nil)`
|
||||
-- retires nothing: `_attach_buffer` finds the still-live primary
|
||||
-- attachment, early-returns it, and the retry calls that success.
|
||||
-- Status and config would say "fell back" while the buffer stayed
|
||||
-- on the old server — the same silent no-op as round 1, reached
|
||||
-- through a different event ordering. Initializing must stop the
|
||||
-- failure poll, not erase the server the verdict has to retire.
|
||||
if ev.kind == "exited" and ev.code == 0
|
||||
and version_below_3_1(probe.out) then
|
||||
fire_latch(probe.primary, "lake is older than 3.1.0")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The probe cannot gate the first attach. There is no blocking process
|
||||
-- run (§2.9): `spawn` + `events_take` off a tick is the only shape
|
||||
-- available, so the verdict arrives AFTER `ensure_server` has already
|
||||
-- had to decide. Hence the optimistic `lake serve` spawn, with the
|
||||
-- probe and the latch correcting it.
|
||||
local function start_probe(root)
|
||||
if probe.started then return end
|
||||
probe.started = true
|
||||
local cfg = pmacs.lsp.config.lean4
|
||||
if not cfg or not cfg.command then return end
|
||||
-- **Only probe something actually named `lake`.** `version_below_3_1`
|
||||
-- parses the first `x.y` it finds anywhere in the output, which is a
|
||||
-- rule about LAKE's output contract and nothing else. Run against a
|
||||
-- user's wrapper it is a category error: a working `my-lean-wrapper`
|
||||
-- reporting "wrapper 1.0" would be replaced despite its server having
|
||||
-- initialized fine. The FAILURE latch stays command-agnostic — that
|
||||
-- one keys on the server actually not starting, which is true of any
|
||||
-- command — but the version rule only applies where its contract
|
||||
-- holds.
|
||||
local base = cfg.command:match("([^/]+)$") or cfg.command
|
||||
if base ~= "lake" then return end
|
||||
-- Probe the binary we would actually run, not the literal string
|
||||
-- "lake": a user pointing `command` at an absolute path to lake should
|
||||
-- have THAT probed, not whatever `lake` resolves to on PATH.
|
||||
local spec = {
|
||||
-- COHERENCE §9: `ProcessSpec.label` is the only identity a process
|
||||
-- carries, and it is what `pmacs.process.list` renders. A user
|
||||
-- wondering why their editor touched `lake` finds an owner here.
|
||||
label = "lean:lake-version-probe",
|
||||
command = cfg.command,
|
||||
args = { "--version" },
|
||||
stdin = "null",
|
||||
}
|
||||
if root then spec.cwd = root end
|
||||
local ok, proc = pcall(pmacs.process.spawn, spec)
|
||||
if ok then probe.proc = proc end
|
||||
-- A probe that cannot even spawn says nothing the latch will not say
|
||||
-- more reliably a moment later, so it is not reported here.
|
||||
end
|
||||
|
||||
-- How the latch observes server failure.
|
||||
--
|
||||
-- There is no event for "died before initialize" — the drain ignores
|
||||
-- state events. So this polls `pmacs.lsp.list()` on the
|
||||
-- `process.after-tick` cadence and treats a terminal state reached
|
||||
-- WITHOUT an intervening `initialized` as the trigger. Watching stops
|
||||
-- as soon as the server initializes, so an ordinary later crash (a real
|
||||
-- server dying on a real error) does not silently rewrite the command.
|
||||
local function poll_latch()
|
||||
local sid = probe.watching
|
||||
if not sid or probe.latched then return end
|
||||
local skey = tostring(sid)
|
||||
local ok, rows = pcall(pmacs.lsp.list)
|
||||
if not ok or not rows then return end
|
||||
for _, info in ipairs(rows) do
|
||||
if tostring(info.id) == skey then
|
||||
local kind = info.state and info.state.kind
|
||||
if kind == "initialized" then
|
||||
-- Stop polling for failure; `probe.primary` deliberately
|
||||
-- survives, because a later version verdict still needs it.
|
||||
probe.saw_initialized = true
|
||||
probe.watching = nil
|
||||
return
|
||||
end
|
||||
if kind == "crashed" or kind == "stopped" then
|
||||
fire_latch(sid, configured_command() .. " failed to start")
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
-- Gone from the manager entirely without ever initializing.
|
||||
fire_latch(nil, configured_command() .. " failed to start")
|
||||
end
|
||||
|
||||
-- Q#LN16 — `textDocument/waitForDiagnostics` --------------------------
|
||||
--
|
||||
-- A plain request: no position, so Q#LN12's `outbound_position` concern
|
||||
-- does not apply. Resolves when the server has finished elaborating.
|
||||
-- Awaited through Stage 3a's response seam.
|
||||
--
|
||||
-- **`version` is required, not optional.** Lean's
|
||||
-- `WaitForDiagnosticsParams` is `{ uri, version }` (v4.9.0,
|
||||
-- `src/Lean/Data/Lsp/Extra.lean`), and the request is how the client
|
||||
-- says *which* revision of the document it wants elaboration for.
|
||||
-- Sending only `uri` is a malformed request against a real server; it
|
||||
-- happened to look fine here because the fake server echoes any
|
||||
-- payload. Callers pass the attachment's current `version`.
|
||||
--
|
||||
-- `fn(err)` is called with nil on success. Registering the one-shot
|
||||
-- requires the server to have an attached buffer — see the note on
|
||||
-- `pmacs.lsp.on_response`; every caller here comes from an attachment.
|
||||
function M.wait_for_diagnostics(sid, uri, version, fn)
|
||||
local ok, rid = pcall(pmacs.lsp.send_request, sid,
|
||||
"textDocument/waitForDiagnostics", { uri = uri, version = version })
|
||||
if not ok then
|
||||
if fn then pcall(fn, tostring(rid)) end
|
||||
return nil
|
||||
end
|
||||
if fn then
|
||||
pmacs.lsp.on_response(sid, rid, function(_, err)
|
||||
fn(err and err.message or nil)
|
||||
end)
|
||||
end
|
||||
return rid
|
||||
end
|
||||
|
||||
local function when_server_ready(sid, fn)
|
||||
local function state_kind()
|
||||
local ok, state = pcall(pmacs.lsp.status, sid)
|
||||
if not ok or not state then return nil end
|
||||
return state.kind
|
||||
end
|
||||
|
||||
local kind = state_kind()
|
||||
if kind == "initialized" then
|
||||
fn(nil)
|
||||
return
|
||||
end
|
||||
if kind == nil or kind == "crashed" or kind == "stopped" then
|
||||
fn("server did not initialize")
|
||||
return
|
||||
end
|
||||
|
||||
-- A command may have just healed a dead attachment, in which case the
|
||||
-- replacement is still starting. Requests are not queued before
|
||||
-- initialize, so issue this one after the lifecycle reaches ready
|
||||
-- rather than replacing the attachment and immediately failing on it.
|
||||
pmacs.async(function()
|
||||
for _ = 1, 300 do
|
||||
pmacs.async.yield_to_next_tick()
|
||||
kind = state_kind()
|
||||
if kind == "initialized" then
|
||||
fn(nil)
|
||||
return
|
||||
end
|
||||
if kind == nil or kind == "crashed" or kind == "stopped" then
|
||||
fn("server did not initialize")
|
||||
return
|
||||
end
|
||||
end
|
||||
fn("server initialization timed out")
|
||||
end)
|
||||
end
|
||||
|
||||
pmacs.command.define {
|
||||
name = "lean.wait-for-diagnostics",
|
||||
description = "Wait for the Lean server to finish elaborating this file",
|
||||
fn = function()
|
||||
local rec = pmacs.lsp._attachment_for_command()
|
||||
if not rec or rec.language ~= "lean4" then
|
||||
pmacs.editor.set_status("lean: no Lean server for this buffer")
|
||||
return
|
||||
end
|
||||
pmacs.editor.set_status("lean: elaborating…")
|
||||
when_server_ready(rec.server, function(init_err)
|
||||
if init_err then
|
||||
pmacs.editor.set_status("lean: " .. tostring(init_err))
|
||||
return
|
||||
end
|
||||
M.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err)
|
||||
if err then
|
||||
pmacs.editor.set_status("lean: " .. tostring(err))
|
||||
else
|
||||
pmacs.editor.set_status("lean: elaboration complete")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
}
|
||||
|
||||
-- `$/lean/fileProgress` — the elaboration-in-flight signal. Stage 5's
|
||||
-- goal view reads it to distinguish "no goals" from "not done yet";
|
||||
-- here it is recorded so that consumer has something to read and so the
|
||||
-- notification seam has its first production subscriber.
|
||||
M.file_progress = {}
|
||||
|
||||
pmacs.lsp.on_notification("$/lean/fileProgress", function(_, params)
|
||||
local uri = params and params.textDocument and params.textDocument.uri
|
||||
if type(uri) ~= "string" then return end
|
||||
M.file_progress[uri] = params.processing or {}
|
||||
end)
|
||||
|
||||
-- Wiring --------------------------------------------------------------
|
||||
|
||||
-- Runs after `lsp.lua`'s own `buffer.after-load` subscription.
|
||||
--
|
||||
-- **Keyed on the buffer's LANGUAGE, not on an attachment existing.**
|
||||
-- Round 1 keyed on `active_attachment()` and returned early when it was
|
||||
-- nil — which silently excluded the single most likely real-world
|
||||
-- failure: `lake` not installed. `ensure_server` pcalls the spawn and
|
||||
-- returns nil on ENOENT, so `attach_buffer` produces no record at all,
|
||||
-- so the probe never started and the latch never armed. The case the
|
||||
-- fallback exists for was the one case it could not see.
|
||||
pmacs.hook.add("buffer.after-load", function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf)
|
||||
if not ok_lang or lang ~= "lean4" then return end
|
||||
|
||||
local rec = pmacs.lsp.active_attachment()
|
||||
if rec and rec.language == "lean4" then
|
||||
-- A matching-root server supplied by the user may be adopted by
|
||||
-- `ensure_server`. Its lifecycle is not evidence about the
|
||||
-- config-driven command, and neither the version probe nor fallback
|
||||
-- latch may mutate config because that foreign server changed state.
|
||||
if not is_derived_server(rec.server) then return end
|
||||
if not probe.started then
|
||||
local path = pmacs.editor.file_path()
|
||||
start_probe(path and M.root_for(path) or nil)
|
||||
end
|
||||
-- **Arm ONCE, capturing buffer and server together.** Setting
|
||||
-- `buf_key` on every Lean load meant a second Lean buffer opened
|
||||
-- before the verdict silently became the rebuild target while the
|
||||
-- latch still watched the FIRST buffer's server — so the rebuild
|
||||
-- either repaired the wrong buffer or accepted the second buffer's
|
||||
-- unrelated live server as success, stranding the first. The pair
|
||||
-- (target buffer, primary server) is one fact and is captured as
|
||||
-- one.
|
||||
if not probe.armed and not probe.latched and not probe.saw_initialized then
|
||||
probe.armed = true
|
||||
probe.buf_key = tostring(buf)
|
||||
probe.primary = rec.server
|
||||
probe.watching = rec.server
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- **Unconfigured is DISABLED, not failed.** A user who sets
|
||||
-- `pmacs.lsp.config.lean4 = nil`, or clears its `command`, has turned
|
||||
-- the Lean server off; reporting that "nil could not be started" is a
|
||||
-- false alarm, and latching would poison the session so a later
|
||||
-- configuration could never take effect. Only a CONFIGURED command
|
||||
-- that produced no attachment is a failure.
|
||||
local cfg = pmacs.lsp.config.lean4
|
||||
if not cfg or not cfg.command then return end
|
||||
if not probe.started then
|
||||
local path = pmacs.editor.file_path()
|
||||
start_probe(path and M.root_for(path) or nil)
|
||||
end
|
||||
|
||||
-- No attachment for a Lean buffer with a configured command means
|
||||
-- `ensure_server` could not spawn at all — a synchronous ENOENT,
|
||||
-- already swallowed upstream. That is not something to wait for; it
|
||||
-- is the failure itself, and the only place it is still observable.
|
||||
if not probe.latched then
|
||||
-- No server was ever created, so there is no primary to retire —
|
||||
-- but the rebuild still needs a target buffer.
|
||||
if not probe.armed then
|
||||
probe.armed = true
|
||||
probe.buf_key = tostring(buf)
|
||||
end
|
||||
fire_latch(nil, configured_command() .. " could not be started")
|
||||
end
|
||||
end)
|
||||
|
||||
-- A buffer switch is the moment a stale Lean buffer becomes visible, so
|
||||
-- repair immediately rather than waiting for the next tick. lsp.lua's
|
||||
-- own `after-switch` subscription re-pushes views but does NOT rebuild a
|
||||
-- stale attachment, so nothing else covers this.
|
||||
pmacs.hook.add("buffer.after-switch", function()
|
||||
repair_active_if_stale()
|
||||
end)
|
||||
|
||||
pmacs.hook.add("process.after-tick", function()
|
||||
drain_probe()
|
||||
poll_latch()
|
||||
-- Repair the active buffer if the latch invalidated it. Cheap when
|
||||
-- there is nothing to do, and bounded to one attempt per buffer.
|
||||
repair_active_if_stale()
|
||||
poll_fallbacks()
|
||||
end)
|
||||
|
||||
-- Test seam: acceptance drives the latch deterministically rather than
|
||||
-- waiting on real process timing. Not part of the public surface.
|
||||
M._probe = probe
|
||||
M._fire_latch = fire_latch
|
||||
M._version_below_3_1 = version_below_3_1
|
||||
|
||||
pmacs.lean = M
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <buf>, buffer = <buf> }`, 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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "<unprintable error>"
|
||||
end
|
||||
pcall(ed.set_status,
|
||||
"typed-edit consumer '" .. c.name .. "' failed: " .. rendered)
|
||||
elseif claimed then
|
||||
return
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
|
@ -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 <their base>..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:<canonical path>*` 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:
|
||||
<https://github.com/levineuwirth/pmacs/pull/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:<canonical path>*`, 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
|
||||
|
|
|
|||
|
|
@ -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:<canonical path>*`, 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<FrontendId, CommandBoundary{this, last}>`,
|
||||
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 <N> --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).
|
||||
|
|
|
|||
|
|
@ -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<u64>` 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
|
|
@ -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<ByteRange>)`
|
||||
(`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 `<escape> <key>`. 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -779,11 +779,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
|
|||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Cell>,
|
||||
/// 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<CellCoord>,
|
||||
/// 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 },
|
||||
}
|
||||
}
|
||||
|
|
@ -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<usize, TerminalFrameError> {
|
||||
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<u32> = 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<usize> {
|
||||
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<usize, TerminalFrameError> {
|
||||
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<usize, TerminalFrameError> {
|
||||
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::<usize>();
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<usize, WireGridError> {
|
||||
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<CellCoord>,
|
||||
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<usize> {
|
||||
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<usize, WireGridError> {
|
||||
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<usize, WireGridError> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regenerate builtin/runtime/lean_abbrev.lua from vscode-lean4.
|
||||
|
||||
Usage: scripts/regen-lean-abbrev <vscode-lean4-commit>
|
||||
|
||||
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]} <vscode-lean4-commit>")
|
||||
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()
|
||||
|
|
@ -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!({
|
||||
|
|
|
|||
154
src/buffer.rs
154
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<Edit, BufferError> {
|
||||
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() {
|
||||
|
|
|
|||
20
src/crdt.rs
20
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`
|
||||
|
|
|
|||
1049
src/daemon.rs
1049
src/daemon.rs
File diff suppressed because it is too large
Load Diff
777
src/editor.rs
777
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<Cell<Option<FrontendId>>>);
|
||||
|
||||
impl ScopedFrontend {
|
||||
/// The override in force, if any.
|
||||
#[must_use]
|
||||
pub(crate) fn current(&self) -> Option<FrontendId> {
|
||||
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<FrontendId>,
|
||||
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<Cell<bool>>);
|
||||
|
||||
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<PathBuf>)`; 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<Self> {
|
||||
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<crate::editor_core::DirectoryDestination> {
|
||||
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::<mlua::Table>("pmacs")
|
||||
.and_then(|pmacs| pmacs.get::<mlua::Table>("path"))
|
||||
.and_then(|path| path.get::<mlua::Value>("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<TerminalViewKey> {
|
||||
// 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<std::sync::Mutex<crate::diag::DiagnosticStore>>,
|
||||
) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,77 @@ pub enum HookKind {
|
|||
None,
|
||||
}
|
||||
|
||||
/// What a path resolved to (Journey Stage 1a, Q#JR5).
|
||||
///
|
||||
/// A sum type rather than `(Option<BufferId>, 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<ResolvedTarget, String> {
|
||||
// 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<BufferId>,
|
||||
window: Option<WindowId>,
|
||||
) -> Result<WindowId, String> {
|
||||
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<BufferId>) -> 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<BufferId>,
|
||||
window: Option<WindowId>,
|
||||
) -> Result<WindowId, String> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -668,6 +668,33 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option<BufferId>, 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<BufferId>,
|
||||
fallback: &str,
|
||||
) -> (String, u64) {
|
||||
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() 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<T
|
|||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
// Replace a generated buffer's contents and leave it genuinely
|
||||
// immutable — the one authorized door through `read_only`.
|
||||
//
|
||||
// Deliberately NOT an exposed `set_read_only`: that would let a
|
||||
// caller lock a buffer with no way to refresh it, which is the
|
||||
// failure mode that kept generated-buffer immutability deferred.
|
||||
// Pairing the lock with the write in a single call is what makes
|
||||
// it safe to ship.
|
||||
"set_generated_contents",
|
||||
lua.create_function(move |lua, (id, text): (BufferIdLua, mlua::String)| {
|
||||
let edit = {
|
||||
let mut registry = reg.borrow_mut();
|
||||
let buffer = registry.get_mut(id.0).map_err(mlua::Error::external)?;
|
||||
buffer
|
||||
.set_generated_contents(&text.as_bytes())
|
||||
.map_err(mlua::Error::external)?
|
||||
};
|
||||
// The registry borrow is released first: the fan-out
|
||||
// re-enters the core, and a live borrow would panic.
|
||||
// Skipping it is not an option — see
|
||||
// `Buffer::set_generated_contents`.
|
||||
notify_buffer_edit_to_windows(lua, id.0, &edit);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
|
|
@ -3593,9 +3650,74 @@ fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
|
|||
)
|
||||
})?,
|
||||
)?;
|
||||
// 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<M: mlua::UserDataMethods<Self>>(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::<crate::editor::CommitScopeActive>()
|
||||
.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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<crate::editor::InteractiveCommandOrigin>()
|
||||
.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::<crate::editor::ScopedFrontend>()
|
||||
.and_then(|scope| scope.current())
|
||||
.or_else(|| {
|
||||
lua.app_data_ref::<crate::editor::InteractiveCommandOrigin>()
|
||||
.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<mlua::MultiValue> {
|
||||
// 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::<super::DirectoryDestinationLua>().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::<crate::editor::ScopedFrontend>()
|
||||
.ok_or_else(|| {
|
||||
mlua::Error::runtime(
|
||||
"pmacs.window.commit_to: no frontend scope installed",
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let commit = lua
|
||||
.app_data_ref::<crate::editor::CommitScopeActive>()
|
||||
.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::<mlua::MultiValue>(())
|
||||
};
|
||||
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);
|
||||
|
|
|
|||
445
src/process.rs
445
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<nix::errno::Errno>,
|
||||
}
|
||||
|
||||
/// One armed group in the reap ledger.
|
||||
|
|
@ -684,7 +691,50 @@ impl ChildHandle {
|
|||
}
|
||||
}
|
||||
|
||||
fn signal_target(proc: &ManagedProcess, pid: u32) -> Result<Pid, String> {
|
||||
/// 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<SignalTarget, String> {
|
||||
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<Pid, String> {
|
|||
&& 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<Pid, String> {
|
|||
// (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<i32> = 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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StatuslineEvaluation>,
|
||||
statusline_document_window: Option<crate::window::WindowId>,
|
||||
) -> Vec<InstanceMessage> {
|
||||
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<crate::window::WindowId>,
|
||||
out: &mut Vec<InstanceMessage>,
|
||||
) {
|
||||
let to_wire = |segments: Vec<crate::statusline::EvaluatedStatuslineSegment>| {
|
||||
|
|
@ -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<InstanceMessage> {
|
||||
// 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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<EscapeCache>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Owns the one-buffer/one-process/one-screen terminal registry.
|
||||
#[derive(Default)]
|
||||
pub struct TerminalManager {
|
||||
pub(super) sessions: HashMap<BufferId, TerminalSession>,
|
||||
/// Total escape-key parses performed (Q#TC4c observability).
|
||||
escape_parses: u64,
|
||||
process_to_buffer: HashMap<ProcessId, BufferId>,
|
||||
/// 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<String>) {
|
||||
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<BufferId, EscapeCache>`) 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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<TerminalSelection> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<WindowId> {
|
||||
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<WindowId> {
|
||||
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<String> {
|
||||
(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::<String>()
|
||||
})
|
||||
.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::<usize>(),
|
||||
),
|
||||
_ => 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:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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<u8> {
|
||||
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::<InstanceMessage>(&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::<usize>();
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
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<String, String> {
|
||||
let facts = wait_for_fact(&self.report, "phase", "ready", Duration::from_secs(10));
|
||||
fn wait_for(&mut self, key: &str, expected: &str) -> HashMap<String, String> {
|
||||
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<String, String> {
|
||||
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::<u32>().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);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
|
||||
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
|
||||
}
|
||||
|
||||
fn fake_lsp_path() -> String {
|
||||
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
|
||||
}
|
||||
|
||||
/// 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::<i64>(&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::<i64>(&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::<i64>(&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::<i64>(&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::<i64>(&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::<i64>(&state, "return _G.echoed or -1"),
|
||||
42,
|
||||
"the handler receives the server's result payload"
|
||||
);
|
||||
assert_eq!(
|
||||
eval::<String>(&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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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::<InstanceMessage>(&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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Value> = 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<pmacs::buffer::BufferId> {
|
||||
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::<i64>()
|
||||
.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<BufferId,
|
||||
// EscapeCache>` 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<String> = 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::<String>()
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
lua.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#)
|
||||
.eval::<i64>()
|
||||
.unwrap(),
|
||||
10_000
|
||||
);
|
||||
assert_eq!(
|
||||
lua.load(r#"return pmacs.config.get("terminal.escape-key")"#)
|
||||
.eval::<String>()
|
||||
.unwrap(),
|
||||
"C-c"
|
||||
);
|
||||
assert!(
|
||||
lua.load("return next(pmacs.terminal.profiles) == nil")
|
||||
.eval::<bool>()
|
||||
.unwrap(),
|
||||
"no profiles are registered by default"
|
||||
);
|
||||
}
|
||||
|
|
@ -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<T: mlua::FromLuaMulti>(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<Value> = 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<pmacs::buffer::BufferId> {
|
||||
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<String> {
|
||||
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<pmacs::cell::Cell> {
|
||||
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::<String>()
|
||||
.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<String> = 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<String> = 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<String> = 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}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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<T: mlua::FromLuaMulti>(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("<unprintable error>"),
|
||||
"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<String> {
|
||||
let Ok(raw) = std::fs::read_to_string(sink) else {
|
||||
return Vec::new();
|
||||
};
|
||||
raw.lines()
|
||||
.filter_map(|l| serde_json::from_str::<serde_json::Value>(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::<StateDir>();
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue