diff --git a/AGENTS.md b/AGENTS.md index 58c28f9..823b6e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,15 @@ # pmacs agent instructions -**Start here: read `docs/agent-handoff.md`, then +**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then `docs/active-work.md`, before taking on any work.** The handoff carries durable project state, working method, substrate invariants, and the -standing backlog. The active-work ledger carries volatile branches, +standing backlog. `COHERENCE.md` carries the product-coherence thesis +and its audited ground truth (scorecard, per-concern gaps, priority +order) — it is the standard new work gets evaluated against, not just a +backlog item; read it before framing anything and cite the section a +framing doc serves. The active-work ledger carries volatile branches, checkpoints, verification, and exact cross-machine recovery commands. -Keep both updated according to their own update protocols. +Keep all three updated according to their own update protocols. Always true, independent of the handoff: @@ -14,7 +18,11 @@ Always true, independent of the handoff: (`pmacs-protocol`). `#![forbid(unsafe_code)]`. - Workflow: framing doc in `docs/` -> user approval -> branch -> implement -> full gate suite -> PR -> user review rounds -> user says when to - merge. Never merge unprompted. One feature, one branch, one PR. + merge. Never merge unprompted. One feature, one branch, one PR. A + framing doc for coherence-affecting work should state its coherence + impact (journey steps touched, interaction islands added, config + registry adoption, background-work attribution) per `COHERENCE.md` + §20. - Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings` (as its own step); `cargo test --lib`; `cargo test --lib --features crdt`; the touched acceptance suites; diff --git a/CLAUDE.md b/CLAUDE.md index 58c28f9..823b6e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,15 @@ # pmacs agent instructions -**Start here: read `docs/agent-handoff.md`, then +**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then `docs/active-work.md`, before taking on any work.** The handoff carries durable project state, working method, substrate invariants, and the -standing backlog. The active-work ledger carries volatile branches, +standing backlog. `COHERENCE.md` carries the product-coherence thesis +and its audited ground truth (scorecard, per-concern gaps, priority +order) — it is the standard new work gets evaluated against, not just a +backlog item; read it before framing anything and cite the section a +framing doc serves. The active-work ledger carries volatile branches, checkpoints, verification, and exact cross-machine recovery commands. -Keep both updated according to their own update protocols. +Keep all three updated according to their own update protocols. Always true, independent of the handoff: @@ -14,7 +18,11 @@ Always true, independent of the handoff: (`pmacs-protocol`). `#![forbid(unsafe_code)]`. - Workflow: framing doc in `docs/` -> user approval -> branch -> implement -> full gate suite -> PR -> user review rounds -> user says when to - merge. Never merge unprompted. One feature, one branch, one PR. + merge. Never merge unprompted. One feature, one branch, one PR. A + framing doc for coherence-affecting work should state its coherence + impact (journey steps touched, interaction islands added, config + registry adoption, background-work attribution) per `COHERENCE.md` + §20. - Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings` (as its own step); `cargo test --lib`; `cargo test --lib --features crdt`; the touched acceptance suites; diff --git a/COHERENCE.md b/COHERENCE.md new file mode 100644 index 0000000..95761b2 --- /dev/null +++ b/COHERENCE.md @@ -0,0 +1,1605 @@ +# Product Coherence for Pmacs + +## Status of this document + +This document has two jobs. It states the **product-coherence thesis** +for pmacs, and it records the **audited ground truth** of how the +codebase measures against that thesis, so that no future agent or +contributor has to re-excavate it. + +- The vision prose is durable. The **Ground truth** subsections were + established **2026-07-25** by a four-lane code audit (discoverability, + interaction islands, packages/workers, first-run journey) plus a + distribution check, on branch `lsp-multi-root-affinity` + (= `main` @ `0827dd1` plus the multi-root LSP work). +- Citations name **symbols first, `file:line` second**. Line numbers + drift with the tree — `docs/keybindings.md` drifted by 250–1000 lines + within days of its "last verified" stamp (§24) — so treat the symbol + name and the structural claim as authoritative and the line number as + a hint. Re-grep before relying on a number. +- Grades used below: **Strong / Partial / Weak / Missing** (and + **Broken** where something actively fails). +- Update protocol is §25. When a PR changes any audited claim here, + updating this file rides that PR, the same way `docs/agent-handoff.md` + does. + +Relationship to the other required documents: `docs/agent-handoff.md` +carries durable project state and working method; `docs/active-work.md` +carries volatile branches and recovery; `docs/side-quest-backlog.md` +carries item-level deferrals. This document carries **product direction +and the measured distance to it**. It is not a second backlog; it is the +standard the backlog gets ranked against. + +--- + +## Purpose + +Pmacs already has an unusually strong technical foundation for an editor +at its stage of development. Its daemon/frontend split, semantic +rendering protocol, CRDT-based editing, structured worker runtime, Lua +programmability, language tooling, package resolver, terminal support, +and remote-capable architecture all point toward a system with genuine +long-term differentiation. + +The next challenge is not primarily adding more isolated capabilities. +It is making the existing and planned capabilities converge into a +coherent product. + +Visual Studio Code is used throughout this document as a reference point +because it is an exceptionally successful modern editor. Pmacs is +obviously not trying to become VS Code. Its goals are substantially +different: live programmability, inspectability, stronger concurrency +semantics, frontend plurality, and deeper user control are central to +Pmacs in ways they are not central to VS Code. The useful lesson is +therefore not to copy VS Code's interface or architecture wholesale, but +to understand how a technically complex system can become immediately +useful, progressively discoverable, and easy to adopt. + +The deeper reference point is Emacs. Emacs's beauty comes from its +ontological unity: the editor is text, Lisp, commands, buffers, and a +running system that the user can interrogate and change. Its enduring +achievement is not any single feature, but that it created the kind of +environment in which generations of users could build almost anything. + +Pmacs should preserve that unity while correcting the accidental +historical constraints beneath it: cooperative rather than general +parallelism, unclear ownership, global mutation, difficult unloading, +rendering coupled too closely to the core, opaque latency, implicit +remote context, and inconsistent package lifecycle. + +Pmacs does not need to contain everything Emacs contains before it can +be considered a successor. It must instead remain the kind of system in +which everything Emacs contains could eventually be built — with clearer +ownership, stronger concurrency, richer frontends, explicit execution +locations, and fewer historical traps. + +That places the VS Code comparison in its proper role. VS Code +demonstrates how a complex development environment can be coherent, +approachable, and immediately useful. Emacs demonstrates how an editor +can become a live, fertile, user-transformable world. Pmacs should +combine the adoption discipline of the former with the programmability +and unity of the latter. + +The core product objective should be: + +> **Pmacs should be immediately excellent, progressively understandable, +> completely inspectable, and ultimately replaceable.** + +A user should receive a polished workstation before they become an +editor engineer. If they choose to become one, the entire system should +remain open to them. + +--- + +## 0. Scorecard (audited 2026-07-25) + +| § | Concern | Grade | One-line state | +|---|---|---|---| +| 2 | Golden product journey | **Broken at entry** | `pmacs .` exits 1; only "launch" and "edit" pass cleanly zero-config | +| 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 | +| 6 | Interaction islands | **Weak, and growing** | Six hardcoded key-interception shadows; no transient-keymap mechanism exists | +| 7 | First-class workspaces | **Missing (conventions only)** | Marker walk + four independent consumers; no workspace object | +| 8 | Execution locations | **Missing (architecture ready)** | SSH attach works; "location" is not a value anywhere | +| 9 | Worker ownership | **Mechanism without identity** | Cancellation solid; no owner/purpose/hierarchy; four disjoint activity views | +| 10 | Extension trust classes | **Missing (one class)** | Shared Lua state, `__index = _G`; MCP is the one out-of-process seam | +| 11 | Config layering + provenance | **Partial (foundation only)** | Typed registry is right; 5 settings live in it; no value provenance | +| 12 | Profiles | **Missing** | One hardcoded default keymap; not a named concept | +| 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 | +| 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 | + +Three cross-cutting patterns explain most of the table; they are +detailed in §1.1–§1.3: **substrate without surface**, **the silence +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), +multi-root LSP affinity (merged #161), the config registry foundation +(merged #127). + +--- + +## 1. The Product Problem + +Pmacs is building many difficult things correctly and in parallel. That +is appropriate for an early systems project. The risk is that the +project succeeds architecturally while remaining fragmented +experientially. + +A technically sophisticated editor can still feel incoherent when: + +- installation requires repository knowledge; +- capabilities exist but are difficult to discover; +- subsystems expose unrelated interaction conventions; +- project, process, terminal, language-server, and remote state are + modeled separately; +- configuration is powerful but provenance is unclear; +- packages can extend the editor but cannot be understood, controlled, + or attributed; +- background work is concurrent but not meaningfully owned; +- new users must configure the system before they can experience its + strengths. + +The relevant distinction is between **capability completeness** and +**product coherence**. Capability completeness asks "can pmacs do X?". +Product coherence asks whether a user naturally encounters X at the +right time, whether X behaves by shared conventions, whether the user +can understand why X is active, and whether X feels like part of one +editor rather than an adjacent demonstration. + +Pmacs is well on its way toward capability completeness in several major +areas. Product coherence must now become an explicit development track +rather than an emergent consequence of subsystem work. The 2026-07-25 +audit found that every one of the eight bullet points above is true of +pmacs today, and that they share three structural causes. + +### 1.1 Ground truth: substrate without surface + +The single most consistent audit finding, appearing independently in all +four lanes: **the mechanism layer is disciplined, often best-in-class; +the product surface that would make it perceptible is missing.** The +July 2026 roadmap named an instance of this "dark matter — built but +unwired" and treated it as a one-time backlog. It is not one-time; it is +the project's default failure mode. The audited inventory of complete, +working, unreachable capability: + +- **The entire rich help system.** `src/help.rs` implements a + self-navigable `*help*` buffer with `[command:]` / `[key:]` / + `[mode:]` / `[hook:]` / `[buffer:]` / `[view:]` cross-reference links + and `follow_link_at`, installed as `pmacs.help.show_command` / + `show_key` / `show_buffer` / `show_mode` / `show_hook` / `show_view` + (`install_help_module`, `src/lua_bindings/mod.rs:5597`). `grep -rn + "pmacs.help" builtin/` returns **zero hits** — no command, no + keybinding, no caller. +- **File-name completion.** `CompletionSource::Files { root }` + (`src/minibuffer.rs:589`) is reachable from Lua as `source = "files"` + + `source_root` — zero builtin callers. +- **Command availability.** `Command.predicate` is stored on every + command and **never evaluated** by `invoke`, `invoke_interactive`, + keymap dispatch, M-x filtering, or the menu (§5). +- **Package ownership.** `CurrentlyLoadingPackage` is a stack correctly + pushed/popped around every package chunk + (`src/lua_bindings/mod.rs:3953-3966`) and consulted by exactly one + binding (`on_unload`'s fallback). Every registrar ignores it (§13). +- **LSP health.** `LspManager::status_buffer_text()` (`src/lsp.rs:1204`) + is Lua-bound; no builtin command opens `*lsp*` (§2, §9). +- **Interactive file opening.** `pmacs.buffer.find_or_open` + (`src/lua_bindings/mod.rs:3103`) had no interactive caller at audit + 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 + stays frozen — its `install_local` + `require` routing *is* the M8 + package-universality proof (Q#DR1) — and shrinking it is scheduled + after Stage 3. + +The strategic consequence: **most coherence gaps in pmacs are doors, +not engines** — deliberately deferred surface, not design error. That is +the cheap kind of gap, and it should change how the remaining work is +costed. + +### 1.2 Ground truth: the silence asymmetry + +Synchronous, user-initiated failures report well: `M-x` errors surface +as `"M-x error: "` (`builtin/commands/default.lua:633-641`), +compile spawn failures print in-buffer and on the status line +(`builtin/runtime/compile.lua:850-855`), and `pmacs --gpu` with no +`pmacs-gpu` binary produces the best missing-tool message in the +codebase — it names both the sibling path it tried and the PATH fallback +(`src/main.rs:367-379`). + +Automatic, background failures are swallowed. The canonical case, hit on +**every file open** when a language server is preconfigured but not +installed: `Command::spawn` ENOENT propagates up through +`LspManager::spawn` and raises in Lua — where `ensure_server` `pcall`s +it and returns nil (`builtin/runtime/lsp.lua:614-626`), and the +`buffer.after-load` hook `pcall`s the whole attach +(`builtin/runtime/lsp.lua:895-897`). Net user-visible result: nothing. +No status message, no `*errors*` entry, no modeline marker (the LSP +segment is gated on an attachment record existing, so absence is +indistinguishable from "unsupported file type"). Working tree-sitter +highlighting **actively masks** the failure — the user sees colored text +and assumes language intelligence is on. Post-crash is the same shape: +`LspEventKind::Crashed` is pushed (`src/lsp.rs:2394`) and no builtin +subscriber surfaces it. + +This directly contradicts the product thesis (§23): the "without +freezing" half is delivered; the "without becoming opaque" half is +currently false for exactly the failures a new user will hit first. + +**The reporting channel the runtime believes it has does not exist.** +Fifteen call sites — `async.lua` (5), `syntax.lua` (4), and one each in +`lsp.lua`, `mcp.lua`, `fs.lua`, `editops.lua`, `autosave.lua`, and +`commands/default.lua` — report background failures through +`pmacs.error`, each guarded as `if pmacs.error then pmacs.error(...)`. +**`pmacs.error` is never defined in production** — the only assignment +in the tree is a test stub (`src/editor.rs:9881`), and +`type(pmacs.error)` is `nil` in a fresh `EditorState`. So every one of +those fifteen reports is dead: the guard makes the silence look +deliberate and keeps it from ever being noticed. `pmacs.errors` (plural, +`builtin/runtime/compile.lua:45`) is an unrelated namespace and is not +it. This is the silence asymmetry one level deeper than §1.2 first +recorded — not "the failure isn't surfaced" but "the surface was +written, guarded, and never built." Found while landing PR #161, which +nearly added a sixteenth; that one reports via +`pmacs.editor.set_status` (which exists) with the `pmacs.error` arm +riding along for when the channel is built. + +**Rule to adopt:** anything that fails automatically must leave a +user-visible trace with a named owner. A `pcall` around background +wiring must log attributed failure, never discard it. Corollary from the +above: report through a channel with a **test that observes it**, or the +guard is indistinguishable from the silence it was meant to fix. + +**Frequency note (PR #161):** per-root server affinity means the +preconfigured-but-missing-server failure now fires **once per project +root** rather than once per language per session. The silence is +unchanged in kind; it is strictly more frequent. Surfacing it stays +Priority 1 work with its own framing — it is a user-visible product +behavior (what message, where, with what guidance), not a substrate fix +to smuggle into an affinity PR. + +### 1.3 Ground truth: coherence debt compounds per-arc + +Three audited growth patterns show subsystem work accruing coherence +debt with no counter-pressure: + +- Each new modal UI **extended the shadow family** instead of building + the keymap-layer mechanism (menu → completion → query-replace, §6) — + and each addition must hand-sync three guard lists (`dispatch_key`, + `dispatch_idle_for`, `dispatch_paste`). +- Each new subsystem **added its own activity view** (`*workers*`, + `pmacs.process.list`, `*lsp*`, the terminal-private id set, §9), + because no common identity key exists to join them. +- Each new option **individually decides** whether to adopt the config + registry; five have, everything else has not (§11). + +The framing-doc workflow (scout → framing → approval → acceptance +criteria → bite-verified review) is exactly the right tool to reverse +this — no framing has ever carried a product-coherence acceptance +criterion. Adding them is a process change, not an engineering arc, and +it is what makes this document *required* rather than advisory. + +--- + +## 2. The Golden Product Journey + +Pmacs should maintain one protected end-to-end experience against which +all major work is tested: + +1. Install Pmacs. +2. Launch it without prior configuration. +3. Open a real project. +4. Understand the visible interface. +5. Edit immediately. +6. Receive language intelligence. +7. Find a symbol or file. +8. Open a terminal. +9. Build or test the project. +10. Inspect and act on an error. +11. Understand what background work is running. +12. Close and later restore the workspace. + +This does not need to exercise every advanced feature. It exists to +prove that the editor's components form a usable whole. A strong initial +target is a Rust project, because Rust stresses many of pmacs's intended +strengths: project detection, toolchain discovery, language-server +lifecycle, async diagnostics, build/test integration, terminal use, +large compilation workloads, symbol search, background indexing, +structured error presentation. + +```text +Install Pmacs + ↓ +Run `pmacs .` + ↓ +Project root detected + ↓ +Rust mode activated + ↓ +rust-analyzer found or installation guidance shown + ↓ +Files, diagnostics, terminal, and project actions available + ↓ +Build or test command discoverable + ↓ +Errors become navigable structured results +``` + +This journey should become a release gate. New architectural work should +be evaluated partly by whether it improves, preserves, or complicates +the journey. + +### Ground truth: the journey today + +**Grade: broken at step 3.** Verified empirically at audit time: + +``` +$ ./target/release/pmacs . +pmacs: Is a directory (os error 21) +EXIT=1 +``` + +The literal first arrow of the diagram above fails. `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. + +Full verdict table: + +| # | Step | Verdict | Evidence | +|---|---|---|---| +| 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 | +| 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.* | +| 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) | +| 12 | Close + restore | **Partial** | Per-file cursor+scroll (saveplace), recent files, minibuffer history, autosave recovery all restore zero-config. Open-buffer set and window layout do **not**: desktop-save is opt-in (`pmacs.session.desktop_mode(true)`) *and* a documented no-op under a daemon (`src/desktop.rs:323-326`, `:353-356`, Q#DS9) | + +A journey observation worth keeping verbatim from the audit: +**keybinding coverage is inverted relative to frequency** — `C-c @ +C-M-s` opens all folds, while opening a file, opening a terminal, and +running a build have no bindings at all. + +--- + +## 3. A Strong Zero-Configuration State + +Pmacs should not require configuration before it becomes pleasant. The +default experience should demonstrate the editor's thesis: responsive +editing, visible asynchronous work, coherent project awareness, language +intelligence, integrated terminal and task execution, helpful +diagnostics, discoverable commands, graceful failure when external tools +are absent. + +Configuration should be an escalation path: + +1. The editor works. +2. The user notices a preference. +3. The relevant setting or command is easy to find. +4. The user changes it. +5. The editor explains where the effective value came from. +6. Advanced users can replace the behavior entirely. + +### Recommended default surface + +The graphical frontend should have a deliberate default workspace with a +restrained number of visible regions: main editor area; compact +statusline; optional project/files surface; bottom panel for terminal, +build output, diagnostics, and other transient tools; command palette; +contextual actions; unobtrusive background activity indicator. The TUI +should express the same conceptual model within terminal constraints. +The goal is not identical geometry across frontends — it is shared +nouns, commands, lifecycle, and state. + +### Ground truth + +**Grade: partial — the defaults half is strong, the graceful-failure +half fails.** + +What already works with zero configuration, and is a real asset: + +- Missing config is **not an error by contract** (`src/config.rs:7-9`); + no config directory is created or required; a *broken* `init.lua` + does not block startup — the error lands in `*errors*` and the status + line (`src/config.rs:10-13`). +- Default-on persistence: recentf (`builtin/runtime/recentf.lua`, cap + 50, `C-x C-r`), saveplace (`builtin/runtime/saveplace.lua`, restores + cursor + view on `after-load`), autosave every 30 s with next-session + recovery (`builtin/runtime/autosave.lua:24`), per-bucket minibuffer + history. State root: `PMACS_STATE_HOME` → `$XDG_STATE_HOME/pmacs` → + `~/.local/state/pmacs` (`user_state_dir`, `src/state.rs:54-70`), + wired only in real entry points (`install_state_dirs`) so tests stay + hermetic. +- Atomic saves, full editing surface, bundled grammars for every + preconfigured LSP language. + +What fails the escalation path: + +- Step 3 ("easy to find") fails for both settings and commands (§5). +- Step 5 ("explains where the value came from") is **unanswerable + today**: config overrides are stored as bare values with no source + (§11). +- "Graceful failure when external tools are absent" is the silence + asymmetry (§1.2). The `--gpu` message (`src/main.rs:367-379`) is the + pattern to replicate; LSP auto-attach is the anti-pattern. + +--- + +## 4. Progressive Disclosure + +Pmacs should support several levels of use without requiring users to +inhabit the most advanced one. These levels should be different +presentations of the same underlying objects — a command selected from a +context menu, invoked through `M-x`, bound to a key, called from Lua, or +triggered by an agent should be the same command object. + +### Ground truth + +**Grade: inverted.** The advanced level is largely real; the beginner +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; + 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 + M-x-only · build ✓ but M-x-only with empty prompt · menus △ + (right-click only, 11 items) · missing-tool guidance ✗ (§1.2). + +**Intermediate** (should discover: palette, keybinding search, workspace +settings, profiles, package management, task definitions, +frontend/language settings): + +- palette △ (`M-x` fuzzy over bare names, §5) · keybinding search ✗ (no + list-keybindings/where-is commands) · workspace settings ✗ (no + workspace scope, §11) · profiles ✗ (§12) · package management ✗ + in-session (§13) · task definitions ✗ · frontend customization △ + (themes, `pmacs.gpu.set_font`, statusline providers — all Lua-only) · + language settings △ (raw Lua tables, outside the registry). + +**Advanced** (should be able to: inspect implementations, redefine live, +create packages, new views, providers, keymap layers, workspace policy, +orchestrate workers, replace interaction models): + +- inspect ✓ (SourceLocation on everything; no jump-to-source command + though) · redefine live ✓ (`unregister` + `define`) · packages ✓ + (authoring is real, §13) · new views ✓ (listview is Lua-usable) · + providers ✓ (statusline; completion/minibuffer sources are a fixed + Rust vocabulary) · keymap layers ✗ (§6 — the mechanism does not + exist) · workspace policy ✗ · orchestrate workers △ + (`pmacs.workers.register` funnels into builtin dispatchers, §9) · + replace interaction models ✗ (the shadows, §6). + +The "same command object" principle largely holds where surfaces exist — +menu items, keybindings, and M-x all resolve command names into the one +registry — with one caveat: menu items are a **parallel registry of +labels** whose command references are unvalidated (§5). + +--- + +## 5. Unify Discoverability + +Pmacs already has the beginnings of a strong command registry. This +should become the center of a broader discovery model. Every meaningful +action should eventually expose: stable symbolic identity, title, +description, category, aliases, current keybindings, provenance, +applicability predicate (with an explanation when unavailable), argument +schema, destructive/asynchronous/reversible flags, locality, related +commands and settings, and source location. Settings should expose name, +type, description, default, effective value, provenance, scope, +validation rules, listeners, related commands. Packages and workers +should expose the analogous sets (§13, §9). + +This suggests a general pmacs principle: + +> **Anything that can affect the user should be discoverable as a +> structured object with identity, provenance, ownership, and +> lifecycle.** + +### Ground truth + +**Grade: substrate without surface — the sharpest instance of §1.1.** + +**What the substrate already has (genuinely strong):** + +- `Command` (`src/command.rs:66-79`) = `{ name, description, source, + body, predicate }`. Description is **mandatory and validated** (R42); + duplicate names are a hard error, not an overwrite; `SourceLocation + { file, line }` is auto-captured from Lua debug info on **every** + command, hook, menu item, config definition, config listener, and + keybinding — the user cannot forge it. ~147 `pmacs.command.define` + sites across `builtin/`. +- `ConfigDefinition` (`src/config_registry.rs:396-410`) is **richer + than `Command`**: name, mandatory description, `ConfigKind` + (Boolean/Integer/Number/String/Enum with bounds, choices, + allow_empty), default, `Live`/`StartupOnly` mutability, source. + `pmacs.config.list()` returns full descriptor tables. +- Reverse keybinding lookup exists as data: `KeymapStack::iter_all()` + (`src/keymap_stack.rs:295-311`) enumerates every binding; + `pmacs.describe.command(name).key_bindings` computes where-is on + demand. +- `pmacs.describe.*` (`src/lua_bindings/mod.rs:6042-6162`) returns + structured tables for command/key/buffer/view/mode/hook, and + `describe.key` resolves against the **active buffer + major mode**. +- M-x matching is fuzzy (case-insensitive subsequence with + boundary/consecutive bonuses, `fuzzy_score`, + `src/minibuffer.rs:637-666`). + +**What is missing, itemized:** + +- `Command` has **no title, no category, no aliases, no argument + schema, no destructive/async/reversible flags**. The dotted-name + prefix (`buffer.`, `lsp.`) is convention, not data. MCP tooling works + around the missing schema by stuffing rendered JSON schema text into + the description string. +- **`Command.predicate` is dead metadata.** It is read in exactly two + places (a literal line in the unreachable help renderer, and a test) + and **never evaluated** by `invoke`, `invoke_interactive`, dispatch, + M-x filtering, or the menu. The doc comment's claim that "the command + palette (T M2.7) uses it to gray out unavailable entries" describes + something that never shipped. +- **M-x shows bare name strings.** `CompletionSource::Commands` returns + `Vec` of names; the wire type `MinibufferPrompt.candidates` + is `Vec` (`pmacs-protocol/src/message.rs:994-1006`). No + description, no keybinding, no category alongside candidates — while + `CompletionPopupRow` (`:1231`) already carries `kind` and `detail`, + proving richer rows are a solved wire problem in this codebase. +- **The entire Rust help layer is orphaned** (§1.1). Consequence: two + parallel `*help*` implementations exist — `help.rs`'s + cross-referenced renderer and the Lua `show_help_text` in + `builtin/commands/default.lua:1103-1136` — and the one users can + actually reach (`M-x editor.describe-command`) renders **less** than + the unreachable one (no source, no scope, no predicate note). +- **Missing as commands entirely:** describe-key, describe-mode, + describe-hook, describe-buffer, where-is, list-commands, + list-settings, list-keybindings, apropos. What exists: + `editor.describe-command`, `editor.describe-setting`, + `editor.describe-instance[-buffer]`, `editor.list-buffers`, + `editor.list-workers`. `M-x describe-setting` prompts **free-text + with no completion source** (deliberately skipped — + `builtin/commands/default.lua:1180-1185`); a typo yields a status + line error. +- **No help prefix key.** `C-h` is `buffer.delete-word-backward` + (`builtin/keymaps/default.lua:86`, with a comment noting the key "was + free"). No `F1`, no `C-h k/f/b`. +- **Settings value provenance is absent.** Overrides are stored as bare + values (`global: HashMap`, + `src/config_registry.rs:693-708`); `describe-setting`'s "Source:" is + the *definition* site. "Why is this setting 4 and who set it?" is + unanswerable (§11). +- **Menu items are a parallel registry.** `MenuItem` + (`src/menu.rs:56-78`) carries its own hand-written `label` duplicating + the command's description, with a lazily-resolved `command` name + string that is **never validated to exist** — a typo'd item silently + does nothing when clicked. The wire row is label + separator only + (`MenuPromptRow`): no key hints, no grayed state. Note the asymmetry: + `pmacs.menu.list` reports `has_predicate`; `pmacs.describe.command` + does not. +- **The two key-lookup APIs disagree.** `pmacs.keymap.lookup` is + global-only (it resolves with no buffer and no modes, + `src/lua_bindings/mod.rs:6294-6307`) while `pmacs.describe.key` is + context-aware. `pmacs.keymap.list` erases `source` and renders + `Scope::Buffer(id)` as bare `"buffer"` (id erased), so full-fidelity + enumeration requires per-command `describe.command` calls. There is no + which-key-style prefix surface. + +**Shape of the fix:** roughly (a) three metadata additions on `Command` +(title, category, predicate actually evaluated + reported), (b) value +provenance in the config registry, (c) a dozen interactive commands and +richer M-x candidate rows over introspection that **already exists**. +This is the highest payoff-per-effort concern in the document. + +--- + +## 6. Eliminate Hardcoded Interaction Islands + +Pmacs's public programmability story will be strongest when all major +interaction layers pass through ordinary registries and extension +points. Temporary or modal interfaces — incremental search, query +replace, minibuffer prompts, completion menus, context menus, transient +selectors — should eventually use inspectable keymap layers rather than +special Rust-level interception. A general transient keymap model +includes priority, activation condition, owner, lifetime, fallback +behavior, discoverability, help labels, and cancellation behavior. + +### Ground truth + +**Grade: weak, and growing by one island per modal feature.** + +Everything funnels through one function: `EditorInstance::dispatch_key` +(`src/editor.rs:901`), a single input-precedence state machine (its own +`#[allow(too_many_lines)]` says as much). The audited precedence order: + +| # | Surface | Guard site | Decoder | Kind | +|---|---|---|---|---| +| 0 | popup-vs-modal auto-close | `editor.rs:917-925` | — | pre-step | +| 1 | Context menu | `editor.rs:933` | `MenuKey::from_chord` (`editor.rs:3005`) | **full shadow** | +| 2 | isearch | `editor.rs:939` | `SearchKey::from_chord` (`editor.rs:2902`) | **full shadow** | +| 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** | +| 7 | Ordinary dispatch | `editor.rs:1018-1032` | `KeymapStack::resolve` | the only inspectable layer | + +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. +- **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 → + mode → global with cooperative prefix-pending across scopes + (`resolve`, `keymap_stack.rs:235-291`). No layer stack, no push/pop, + no priority, no lifetime. The Lua scope accept-list hard-rejects + anything else. So this is not "migrate the shadows to the layer + system" — **the layer system must be built first.** (`active_modes` + is also at most one mode today; minor modes are unbuilt.) +- **`describe-key` lies while a shadow is active.** With the completion + popup open, `describe-key C-n` reports `cursor.down @global`; the + literal arm `'n' => Some(Self::Next)` fires instead. Introspection + has zero awareness of the shadows; Lua can observe only a boolean per + surface (`popup_visible`, `search_active`, `query_replace_active`, + minibuffer-active). +- **This is deliberate and documented** — rationale R51 + (`docs/keybindings.md`, `src/minibuffer.rs:470`): the shadows are + intentionally not user-configurable. The completion framing + considered and rejected buffer-local binds on teardown-lifecycle + grounds (`docs/in-buffer-completion-framing.md:93-105`) — the + objection was a *leaked binding outliving its session*, which is an + argument for a lifetime-owning layer handle, not against layers. +- **Three hand-synced guard lists** must be updated per shadow: + `dispatch_key`, `dispatch_idle_for` (`editor.rs:791` — deliberately + omits the partial popup shadow; load-bearing for CRDT frontends' + optimistic-apply correctness), and `dispatch_paste` + (`editor.rs:1129-1140`). +- Off-path hardcodes: client-side **F12 detach** (`is_detach_key`, + `src/attach.rs:997-1006`) and the replica frontends' **optimistic key + classifiers** — classification, not routing, and kept honest by + `dispatch_idle_for`. There are **two, one per replica frontend**, and the + original audit named only one: `crate::optimistic::classify_key` belongs to + the **`pmacs --attach` TUI** replica (`src/attach.rs:843` is its only + consumer), while `pmacs-gpu` has its own, unrelated + `optimistic_insert_text` / `optimistic_crdt_insert` + (`pmacs-gpu/src/main.rs:2694`/`3306`). The "kept honest by + `dispatch_idle_for`" claim was **verified for both** while investigating the + GPU terminal input defect: a focused terminal buffer is in + `round_trip_buffers` (`src/terminal/session.rs:338`), so `dispatch_idle_for` + reports false and neither classifier can fire there. + +**The counter-example that proves the idiom:** the entire picker/panel +family — listview (references, outline), project-search, buffer-list, +compile-mode, REPL, terminal scroll commands — uses ordinary +**buffer-local keymaps** via `pmacs.keymap.bind { scope = "buffer" }` +(`builtin/runtime/listview.lua:76-88` and siblings). These are +inspectable, correctly reported by describe-key, and rebindable from +`init.lua`. Roughly half the transient UI already lives on the right +side of the line. + +**The concrete missing primitive** is small and well-scoped: a transient +overlay consulted before buffer scope (a `Scope::Transient` or an +overlay `Vec`), with (a) push/pop tied to session lifetime via a +lifetime-owning handle (RAII on the Rust side), (b) a full-shadow vs +partial-shadow flag (isearch eats everything and falls back to +search-self-insert; the popup intercepts eight chords and falls +through), and (c) `dispatch_idle_for` **derived** from the stack ("any +active layer is full-shadow") instead of hand-maintained. With that, the +six ladder rungs collapse into "session pushes a layer on open, pops on +close," and describe-key becomes truthful for free. + +--- + +## 7. First-Class Workspaces + +Project-root detection is useful, but pmacs needs a richer workspace +object. A project answers "which root contains this file?"; a workspace +answers "which persistent development environment owns this set of +activity?" A workspace should eventually own: + +```text +Workspace +├── identity +├── one or more roots +├── execution location +├── environment and toolchain +├── configuration layers +├── trust policy +├── enabled packages +├── language-server instances +├── indexes +├── terminals and processes +├── tasks +├── debugger sessions +├── open buffers and views +├── frontend layout state +└── persistence and restoration policy +``` + +This matters for multi-root language servers, monorepos, generated +files, remote projects, containers, HPC environments, per-project +packages, task ownership, session restoration, and project-specific +trust. The workspace should be a core runtime entity, not an informal +convention shared across unrelated subsystems. + +### Ground truth + +**Grade: missing — what exists is a marker walk plus four independent +per-subsystem conventions.** + +- **Detection**: `src/project.rs` — `default_markers()` is + `Cargo.toml`, `go.mod`, `package.json`, `.git` (directory), with the + deliberate rule that **language markers outrank `.git`** at the same + ancestor level; upward walk, innermost wins; `set_search_boundary` + honored; `ProjectKind` (e.g. `Cargo`) exists and is **consumed by + nothing** user-facing. +- **Four independent consumers**, each resolving on its own: + LSP root (`project_root_for`, `builtin/runtime/lsp.lua:554-570`: + config override → marker walk → file's own directory, returning + `root, source` where source ∈ config/detected/fallback); compile cwd + (`project_root_of_active`, `builtin/runtime/compile.lua:600-608`); + project-search root (`resolve_search_root`, + `builtin/commands/default.lua:843-857`, falls back to `"."`); the + project symbol index (`.pmacs/index.json`, `src/project_index.rs`). +- **There is no "current project" independent of the active buffer's + path.** With only `*scratch*` open, every consumer above returns + nil/`"."`. Nothing owns the set {roots, servers, terminals, tasks, + layout} — which is why desktop-save under a daemon had nothing + principled to attach to (Q#DS9, §2 step 12). +- **First slice landed (PR #161)**: the multi-root LSP server-affinity + work makes *(language, found-root)* the server identity — the first + time a root functions as an identity key rather than a spawn + parameter. It also establishes the rule that a *fallback* root (the + file's own directory, when no marker was found) is deliberately **not** + an identity, so markerless files keep sharing one server per language. + Note it is again per-subsystem: LSP learns roots; compile, search, + index, and trust do not share the object. + +A workspace entity is a **model gap** (real arc), not wiring. It is also +the prerequisite that keeps §8 (locations), §9 (task ownership), §11 +(workspace config scope), and step 12 of the journey from each inventing +their own ownership story. + +--- + +## 8. First-Class Execution Locations + +Pmacs's daemon/frontend architecture gives it an excellent basis for +remote development. The next step is to model execution location +explicitly — a value that can be inspected and assigned, not an +implementation detail hidden inside file access or process spawning: + +```text +Location +├── local +├── ssh://host +├── container://name +├── slurm://allocation +├── daemon://session +└── custom provider +``` + +Filesystem roots, processes, terminals, language servers, workers, +debuggers, indexers, package services, and build/test tasks should all +carry a location. That makes answerable: where is this server running? +where will this build execute? is this terminal local? can this worker +migrate? what happens if the remote daemon disconnects? + +### Ground truth + +**Grade: missing as a model; the architecture half already works.** + +What exists: `pmacs --attach user@host` (remote TUI over SSH), +`ssh:user@host/instance` / `local:/path.sock` addressing, mosh-modeled +reconnect-on-drop, and the daemon/frontend split itself — i.e. +`daemon://session` exists implicitly and robustly. What does not exist: +any `Location` value. Every `ProcessSpec` spawn, LSP server, terminal +PTY, and worker is implicitly daemon-local; no resource carries a +location field; nothing can be asked "where is this running?". No +container/slurm/provider concept anywhere. + +This concern is deliberately *after* §7 in dependency order: a location +without a workspace to scope it has nothing to attach to. For the +research/HPC ambition (§12's Research Workstation profile), this pair is +the long-lead differentiator — nothing else in the editor market models +it well. + +--- + +## 9. Extend the Worker Model into Structured Concurrency + +Pmacs's worker system is one of its most distinctive strengths. +Cancellation, supersession, streaming, frame-aware draining, and the +`*workers*` view provide a strong basis. The next step is ownership and +hierarchy: every substantial task should have an owner, a workspace, an +optional buffer/view, a parent, children, a latency class, a +cancellation scope, a resource budget, an execution location, progress, +and failure attribution. + +```text +Workspace: pmacs +└── Command: project-build + ├── Task: save-dirty-buffers + ├── Task: cargo-check + │ ├── Process: cargo + │ └── Stream: compiler-diagnostics + └── Task: refresh-diagnostics +``` + +Cancelling `project-build` should cancel its children. Closing a +workspace should terminate or detach workspace-owned work. Reloading a +package should stop package-owned tasks. The activity view should answer +what is running, why, who owns it, where, what depends on it, and what +cancellation will affect. That turns parallelism into a product feature +rather than an implementation claim. + +### Ground truth + +**Grade: mechanism without identity.** + +**The mechanism layer is solid:** cooperative per-job cancellation +tokens with panic isolation (`src/worker.rs:13-28`); supersession with +correct settle-time pruning (`src/async_runtime.rs:688-701`) — a +genuinely good primitive; a completions ring (cap 64); `register_external` +so non-pool work (LSP requests, MCP) appears uniformly; one shared +`ProcessSupervisor` under everything (`src/editor.rs:341`); the +`*workers*` view (`src/workers_buffer.rs`, opened by `M-x +editor.list-workers`, auto-refreshing, `C-c C-k` cancel-at-point). + +**The identity layer is absent:** + +- `PendingJob` (`src/async_runtime.rs:365-392`) carries `{cancel, + state, supersede_key, stream_buffer, max_batch, kind, + dispatched_at}`. **No owner. No purpose string. No + workspace/buffer association. No parent.** The one buffer link that + exists (parse job → buffer) lives in a `SyntaxCoordinator` side map, + invisible to the workers view. +- `JobKind` is a **closed 12-variant enum** (Sleep, ComputeSum, EmitN, + Grep, Parse, FsReadDir, FsStat, FsRename, FsChmod, FsRemove, + McpRequest, LspRequest). `pmacs.workers.register` funnels Lua jobs + into existing Rust dispatchers, so **every third-party job renders + under a builtin's label**. +- Supersession is opt-in per dispatch site and underused: `"search"` + (grep) and `lsp:{method}:{sid}:{uri}` use it; **parse jobs and all + MCP requests pass `None`** — a fast typist stacks parse jobs. +- Cancellation scopes: per-id and per-key only. No cancel-all, + by-kind, by-buffer, by-owner, or by-subtree — there is no scope to + range over. +- **Four disjoint activity planes with no join key:** + +| Plane | Surface | What it misses | +|---|---|---| +| Async jobs | `*workers*` | processes, servers, terminals | +| OS processes | `pmacs.process.list` (no buffer view exists) | **filters to `LineOriented` only — terminal PTYs are invisible**; `spawn_terminal` bypasses the public path entirely | +| LSP servers | `*lsp*` status text | **no builtin command opens it**; LSP sets `RestartPolicy::Never` on the supervisor and runs its own restart logic | +| Terminals | private id set drained after the supervisor tick | user-visible in none of the above | + + A terminal PTY appears in **no** user-visible activity view. An LSP + server appears in `*lsp*` (unreachable) and `list()`; its requests + appear in `*workers*`; nothing joins them. +- **No progress indicator exists anywhere** — no statusline spinner, + no busy count (grep for progress/spinner/busy in `src/statusline.rs` + is empty). "Visible asynchronous work" (§3) is currently false unless + the user knows to run `M-x editor.list-workers`. +- `ProcessSpec.label` is the nearest thing to attribution: caller- + supplied, unvalidated convention (`lsp:{name}`, terminal buffer + name). + +The audit's conclusion, worth preserving verbatim: *because identity is +missing, scoped cancellation has nothing to scope over and a unified +activity view has nothing to group by — the four views exist precisely +because there is no common key to merge them on.* Owner/purpose/parent +fields on the job and process specs are the prerequisite; the unified +view and the ownership tree fall out of them. + +--- + +## 10. Define Extension Trust and Isolation Classes + +Pmacs should preserve live, low-friction programmability — it should not +force all extensions into rigid out-of-process APIs. At the same time, +namespace isolation inside a shared Lua state is not enough for fault +containment, security, latency containment, memory accounting, +native-code isolation, reliable unloading, or project-local trust. Pmacs +should define extension classes before the ecosystem becomes large: + +- **10.1 Trusted core packages** — in-process, deep API access, + distributed with pmacs or explicitly trusted. +- **10.2 Normal Lua packages** — shared/managed runtime, declared + capabilities, owned registrations and workers, execution budgets, + measurable latency, reloadable lifecycle, package-level error + attribution. +- **10.3 Isolated service extensions** — separate process, typed RPC, + crash recovery, resource accounting, explicit fs/process/network + permissions. +- **10.4 Project-local / untrusted** — explicit approval, restricted + capabilities, strong isolation, workspace-scoped trust, easy + revocation. + +### Ground truth + +**Grade: missing — one class exists.** + +Every package today is a 10.1/10.2 hybrid with none of 10.2's +machinery: in-process, per-package `_ENV` with `__index = _G` +(namespace hygiene, not containment), full API access, no capability +declarations, no budgets, no latency measurement, no owned-registration +lifecycle (§13). The only containment primitive in the tree is the +instruction-count hook that can cancel a hot-looping main-thread chunk +(`src/lua_isolation.rs:1-39`) — a runaway guard, not an isolation class. + +Two real assets to build on: the loader's `exports` gating (the package +searcher is deliberately inserted at position 1 of `package.searchers` +so exports are enforceable, `src/lua_bindings/mod.rs:3891-3900`), and +**MCP as the existing 10.3 seam** — packages can already spawn MCP +servers and consume their tools over a typed transport +(`docs/mcp-for-package-authors.md`), which is exactly the +separate-process/typed-RPC shape 10.3 asks for. Project-local trust +(10.4) has a natural anchor once §7's workspace exists. + +Sequencing note: 10.2's "owned registrations, reloadable lifecycle, +error attribution" is the same work as §13's ownership gap — do it once, +under one arc. + +--- + +## 11. Configuration as Typed, Layered Data + +Pmacs's typed configuration registry is the correct foundation. It +should develop into a layered system with explicit provenance. Likely +layers: built-in defaults; profile defaults; user settings; +machine-local; remote-location; workspace; root/folder; language/mode; +buffer-local; session overrides. A setting inspection view should show +the full chain and the active source: + +```text +setting: editor.tab-width +effective value: 4 +type: integer +scope: workspace + +defined by: + built-in default: 8 + Rust profile: 4 + user setting: 2 + workspace override: 4 + +active source: + ~/src/pmacs/.pmacs/settings.lua +``` + +Pmacs should also preserve three distinct levels — **settings** (typed +declarative data), **behavioral customization** (commands, hooks, +keymaps, Lua), **package construction** (new capabilities) — so that +users do not need executable Lua for ordinary preferences, while +advanced users can still replace the mechanism. + +### Ground truth + +**Grade: partial — the foundation shipped (#127) and is correct; the +layering, provenance, and adoption have not followed.** + +- The registry is typed, described, duplicate-rejected, freeze-aware + (`StartupOnly`), listener-bearing, and introspectable — see §5. Its + design decisions (always-store overrides, explicit buffer, no ambient + scope) are recorded in `docs/config-registry-framing.md`. +- **Two scopes exist** of the ten layers listed above: global and + buffer-local. Per-language and per-project are patterns (a hook + calling `set_local`), not scopes. No profile, workspace, machine, or + remote layer. +- **Value provenance is absent** (§5): overrides are bare + `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), + `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 + 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). +- **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 + is effectively empty — users need executable Lua for nearly every + ordinary preference, which is the exact failure the section warns + about. + +--- + +## 12. Profiles as Product-Level Bundles + +Pmacs should offer a small number of official profiles bundling default +keymaps, visible interface regions, package recommendations, settings, +task conventions, discovery hints, and onboarding: **Pmacs Standard** +(approachable graphical workstation), **Emacs** (familiar bindings, +minibuffer-centered), **Minimal**, and later **Research Workstation** +(terminals, remote machines, Slurm, proof assistants, long-running +builds). Profiles must not create separate products — they exercise the +same registries and primitives. + +### Ground truth + +**Grade: missing.** Not a named concept anywhere in the tree. There is +one hardcoded default: a single 161-line keymap +(`builtin/keymaps/default.lua`) that is already a de-facto hybrid of the +"Standard" and "Emacs" profiles (CUA selection + Emacs kill/yank/isearch +chords). No profile object, no bundle format, no selection mechanism, no +per-profile defaults layer (§11's missing profile scope is the same +gap). Prerequisites: the config profile layer, and enough registry +adoption that a profile has something declarative to set. + +--- + +## 13. Package Experience, Not Merely Package Resolution + +Pmacs already has serious package-resolution machinery. Product +coherence requires a package *lifecycle* experience: search, +installation, updates, disable, reload, uninstall, version inspection, +dependency graph, compatibility warnings, capability declarations, +ownership inspection, error history, active-worker inspection, resource +use, trust state. Installation should work during a running session. +Users should be able to install coherent capability bundles ("Rust +Development") rather than individual packages. Marketplace sequencing: +stable format → ownership/reload lifecycle → in-editor manager → curated +registry → bundles → publisher identity → public marketplace. + +### Ground truth + +**Grade: resolution without lifecycle — the artifact layer is mature, +the lifecycle layer assumes a single author iterating on their own +package.** + +**Mature (keep):** `pmacs.toml` manifest (validated name, semver, +`pmacs_required`, dependencies/conflicts, entry, exports); git-address +installs (`github:`/`gitlab:`/URL; auth delegated to git config; no +registry service); iterate-to-fixed-point resolver with deterministic +ordering and honest unsatisfiability errors (documented no-backtracking +tradeoff); merged SHA-256 lockfile; per-package `_ENV`; `exports` +enforced by a position-1 searcher; bundled packages through the +identical path. + +**The lifecycle facts:** + +| Operation | State | +|---|---| +| `install` / `install_project` / `install_local` / `update` | exist, **init.lua-only** — `require_init_phase` raises `InitOnlyApi` mid-session; the error text admits there is no CLI equivalent ("restart with an updated init.lua") | +| `reload(name)` | **works in-session and is well-built**: unload hooks → loaded-table invalidation (name + `name.` prefixes) → env clear → re-require | +| `installed()` / `describe(name)` / `load(name)` / `on_unload(fn)` | work in-session; `describe` returns manifest metadata only | +| uninstall / remove | **absent** — the documented procedure is `rm` in a shell (`src/packages/installer.rs:1178-1180`) | +| disable / enable | **absent** — no concept | +| search / list-available | **absent** — no registry, no index; you must already know a git URL | +| inspect contributions | **absent** — `describe` cannot say which commands/hooks/keys/settings a package contributed; no `*packages*` view exists | + +**Structural findings that any lifecycle arc must address:** + +- **The roster is in-memory per session**, rebuilt from `init.lua` + calls. A package on disk that init.lua doesn't `install` is invisible + to `require`/`installed()`. And because `do_install` unconditionally + runs the resolver, **every startup runs `git fetch --prune --tags` + per package** before the idempotent fast-path can trigger — a + first-launch latency and offline-use problem. (The Rust-side + `UpdatePolicy::Frozen` that would fix offline installs is + **unreachable from Lua** — dead code.) +- **Ownership is not tracked.** `SourceLocation` is path attribution, + not package attribution (for `install_local` the path may be the dev + tree, not the install root); nothing indexes registrations by source; + there is no "what did package X register" query and no + bulk-unregister. The correct signal (`CurrentlyLoadingPackage`) + already exists and is ignored by every registrar (§1.1). +- **The teardown surface is incomplete in a way that makes the + documented convention unsatisfiable: `pmacs.hook.remove` does not + exist** (`install_hook_module` exposes define/add/list/run; + `HookRegistry` has no removal method at all). A package that calls + `pmacs.hook.add` leaks a callback on every reload, permanently. The + package-author guide's hand-rolled `OWNED = {}` cleanup pattern + (`docs/package-author-guide.md:379-415`) cannot be followed for + hooks. (Independently rediscovered by the Lean 4 arc scout.) +- **Error attribution exists on exactly one code path** — + `packages.load` wraps require and logs `[package ] load failed` + to `*errors*` — **and nothing in `builtin/` uses it**. Plain + `require` from init.lua attributes only by traceback; a failing + `install` aborts the whole init.lua with no per-package isolation. +- Install-root directory names are the manifest name's last segment, so + same-basename packages collide on disk (knowingly accepted, + `installer.rs:44-50`). + +Sequencing: ownership + `hook.remove` + attribution is the same work as +§10's class 10.2 and is the prerequisite for disable/uninstall/inspect; +in-session install requires reworking the init-phase gate; search/ +bundles/marketplace remain correctly last. + +--- + +## 14. Coherent Workbench Primitives + +Pmacs should resist implementing each subsystem with a custom UI +vocabulary. It should provide a small set of reusable view primitives — +editable text view, virtual list, tree, structured table, inspector, +output channel, diagnostics collection, task/progress view, diff view, +transient selector, contextual popup, side panel, bottom panel, +help/documentation view — and packages should provide structured models +to them. Git status, project files, symbol outlines, package +dependencies, and worker trees should share one tree model with +consistent selection, expansion, filtering, action discovery, mouse and +keyboard behavior, persistence, and accessibility. + +### Ground truth + +**Grade: partial, with the best trajectory of any concern.** + +Primitive-by-primitive against the list above: + +- **Editable text view** ✓ — the buffer itself, everywhere. +- **List** ✓ — **listview is a real shared primitive**, the strongest + coherence asset in the UI layer: references, outline, buffer-list, + and project-search all use it, with a shared buffer-local keymap + idiom (RET/SPC visit, n/p, g refresh, q quit) that is inspectable and + 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. +- **Diagnostics collection** ✓ — `DiagnosticStore` + signs + unified + `error.next` source. +- **Transient selector** ✓ — the minibuffer (though its `source` + vocabulary is fixed Rust-side). +- **Contextual popup** ✓ — completion popup, context menu (each a + shadow, §6). +- **Bottom/side panel** ✓ — landed as bottom-panel Stage 1 (#155): + `WindowParams` side/fixed_rows/dedicated, `display = "current" | + "panel"` adopted by listview/compile/terminal, quit-action, divider + drag. Stage 2 (GPU band) pending its own framing. +- **Task/progress view** △ — `*workers*` exists but joins nothing + (§9). +- **Help view** △ — exists twice (§5); needs unification, not + invention. +- **Tree** ✗ — none. The named future consumers (project files, symbol + 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 + 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 + would land. +- **Structured table / inspector / diff view** ✗ — none. (`describe.*` + tables are the inspector's data model without a view; the + wire-declared `ResourceOffer` family was reserved for diff/blame + sources and remains unproduced.) + +--- + +## 15. Contextual Affordances + +Pmacs should remain excellent for keyboard-driven users while making +capabilities visible to users who do not know their names: a diagnostic +should offer code actions; a test definition run/debug; a Git change +stage/revert/diff; a missing formatter configuration guidance; a symbol +references/rename/definition/documentation; a remote workspace its +location; a long-running task progress and cancellation. Affordances +should invoke ordinary commands, never separate logic paths. + +### Ground truth + +**Grade: weak.** + +What exists: the right-click context menu — 11 items in 4 groups +(edit/symbol/diagnostic/history, `builtin/menus/default.lua:117-142`), +with a closed context vocabulary (`always`/`selection`/`symbol`/ +`diagnostic`, `src/menu.rs:44`) and per-item predicates that *are* +evaluated (unlike command predicates). It correctly invokes ordinary +commands by name. Its limits: right-click only (no keyboard path in), +no key hints on rows, invisible items filtered rather than grayed, and +the unvalidated command references of §5. + +What does not: + +- **Code actions apply the first action blindly** — no picker (a + roadmap "dark matter" item still true at audit). +- **There is no Git integration at all** — no status, stage, diff, + blame, or gutter markers anywhere in the tree (gutter git riders and + the `ResourceOffer` diff/blame family are named deferrals). The Git + affordance list above has nothing to attach to yet. +- No test run/debug affordances (DAP is a future arc, + `docs/dap-debugging-framing.md`). +- No missing-tool guidance affordances (§1.2 — the diagnostic that + *should* say "rust-analyzer not found — install with rustup" says + nothing). +- No remote-location display (§8 — nothing carries a location). +- Task progress/cancellation affordances exist only inside `*workers*` + (§9); a long-running task shows nothing at the point of origin. + +--- + +## 16. Productize the Semantic Frontend Architecture + +The semantic protocol should be visible as a product advantage: native +frontend rendering, frontend-specific typography, high-quality +decorations, efficient incremental updates, accessible semantic +information, multiple simultaneous frontends, stable remote attachment, +frontend experimentation without reimplementing editor semantics. To +preserve coherence: core commands frontend-neutral; stable semantic +identities; explicit capability negotiation; graceful degradation; +layout state separated from semantic state; no frontend becoming the de +facto privileged implementation. + +### Ground truth + +**Grade: strong — the healthiest concern in this document, and most of +its asks are already practiced.** + +- Versioned, negotiated protocol `SUPPORTED=[6..=20]` with deliberate + encoding-breaking bumps, both-frontends support required per bump, + and byte-pin discipline for appended variants (handoff §4). +- 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**: + fold projection is per-frontend (`FrontendView.fold_projection`, + selected from the negotiated `semantic_render` bit) so a grid + frontend collapses folds while a simultaneous GPU session does not + skip lines (#149/#148). + - **But it is enforced by convention, not by structure.** The GPU terminal + input defect was a per-frontend-kind operation applied to *both* kinds: + the dispatcher's grid and semantic terminal-layout syncs were written as + twins and executed as siblings, so a GPU session's PTY was resized twice + per tick forever. `sync_terminal_layouts_for_tick` now makes that one + exclusive by construction; every other per-frontend-kind pair in the + dispatcher remains two adjacent `if`s that a reader must notice are + alternatives. +- The GPU frontend exceeds the TUI (minimap, squiggles, typography) + without the TUI losing the model — the "no privileged frontend" rule + is holding under real divergence pressure. + +Remaining, honestly small relative to the section's ambition: capability +negotiation is per-bit rather than a first-class declared capability +set; layout state vs semantic state separation is partial (window layout +is daemon-side; desktop restore under a daemon is unresolved, §2 step +12); and the advantage is invisible as *product* because §17 means +nobody outside the repo can try it. + +--- + +## 17. Distribution Is Part of the Product + +Pmacs should eventually be installable without repository familiarity: +reproducible release builds, Linux and macOS binaries, checksums and +signatures, stable and nightly channels, one-command update, rollback, +protocol- and package-API compatibility reporting. First launch should +create/locate config directories, explain the default profile, identify +optional external tools, and let the user open a project immediately. + +### Ground truth + +**Grade: missing — zero release machinery exists.** + +`.github/workflows/` contains exactly one workflow, `ci.yml`, and it is +test-only (fmt/clippy/test matrix; the only `release` strings in it are +`cargo test --release` flags). No release job, no artifact upload, no +tags-to-binaries path, no checksums, no channels, no update or rollback +mechanism. Installation is `git clone` + `cargo build --release +--workspace --features pmacs/crdt` (README), which additionally requires +knowing the feature-flag matrix (luajit vs lua54 × crdt). Runtime +dependencies (`/bin/sh`, `stty`, git, tar) are documented in the README +and never checked at runtime. First launch creates nothing and explains +nothing (§18) — though by design it also *requires* nothing (§3), which +is the right half to have. + +This concern is independent of every other arc and can start anytime; +until it does, every other coherence improvement is invisible outside +the repository. + +--- + +## 18. Onboarding + +Pmacs needs onboarding that teaches concepts through use: open a +project → command palette → find a file → terminal → inspect a +diagnostic → view workers → change a setting → inspect where it came +from → Lua REPL → redefine a command. That sequence communicates the +whole thesis: already useful, discoverable, visible computation, +explainable settings, programmable internals. It should be an ordinary, +restartable help workspace, not a one-time modal wizard. + +### Ground truth + +**Grade: missing entirely.** + +No welcome buffer, no tutorial, no first-run detection, no cheat sheet +reachable from inside the editor (`docs/keybindings.md` exists on disk +only). `C-h` is `buffer.delete-word-backward`; there is no help prefix +key and no `F1`. The sole discovery affordance is knowing to press +`M-x` (`builtin/keymaps/default.lua:141` — whose own header comment +calls it the "command palette"). The empty `*scratch*` buffer that +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 +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 +`*scratch*` naming `M-x`, the keybinding cheat sheet as a help buffer, +and a help prefix decision — has no prerequisites at all. + +--- + +## 19. Product Coherence Acceptance Tests + +Pmacs should add acceptance tests that exercise product behavior across +subsystems, complementing (not replacing) subsystem tests: + +- **Installation/first launch** — no config, open a directory, usable + workspace, actionable guidance for missing tools. +- **Command discovery** — search by title and synonym; display + keybinding, provenance, availability; invoke from palette and menu + through the same object. +- **Workspace lifecycle** — multi-root open, servers, terminal, build, + close, restore, ownership cleanup. +- **Worker ownership** — start completion/search/build, inspect, + cancel a parent, confirm child cancellation and UI recovery. +- **Package lifecycle** — install in-session, inspect contributions, + disable, confirm disappearance, reload, uninstall cleanly. +- **Remote execution** — attach to remote daemon, edit optimistically, + remote terminal and server, disconnect/reconnect, coherent state. + +### Ground truth + +**Grade: missing — but the culture that would make them excellent is the +project's strongest process asset.** + +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. + +(Related lesson already in the handoff: `compile_mode_acceptance` +accidentally reads the real user config — an *unintentional* +whole-product test that keeps catching real coherence bugs. That is +evidence this class of test has teeth.) + +--- + +## 20. Recommended Priority Order + +Each priority is annotated with its audited state and whether the gap is +**wiring** (surface over existing machinery — cheap) or **model** (a +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 +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. + +### Priority 2: Make workspace and location explicit + +Otherwise project, LSP, remote, task, and persistence accumulate +incompatible ownership models — the audit confirms four have already +diverged (§7). **State: missing; first slice in flight (multi-root LSP +affinity). Model gap:** the Workspace entity (§7), then Location values +(§8). This is the long-lead arc; start it before the fifth and sixth +subsystems grow their own root conventions. + +### Priority 3: Strengthen extension ownership and isolation + +**State: missing; prerequisite-shaped. Model gap, with one bug-sized +prerequisite: `pmacs.hook.remove` does not exist (§13).** The work +unit: registrations carry their owning package (the +`CurrentlyLoadingPackage` signal already exists), removal APIs complete +the set, error attribution becomes default rather than opt-in. This +single arc unblocks §13's disable/uninstall/inspect, §10's class 10.2, +and package-scoped task cancellation in §9. + +### Priority 4: Unify discovery + +**State: substrate without surface. Almost pure wiring — the best +payoff-per-effort in this document (§5):** a dozen interactive commands +over existing introspection, richer M-x rows (the wire pattern already +exists), title/category on `Command`, predicate evaluation, help-layer +unification, a help prefix key. Most of P1's "understand the interface" +and §18's floor ride on this. + +### Priority 5: Finish the workbench convergence + +**State: partial and moving (§14) — bottom panel Stage 1 landed, GPU +band pending; listview proven.** Remaining: the tree primitive (build +it before dired and the worker tree invent two), table/inspector/diff, +help unification. Wiring plus one modest model piece (the tree model). + +### Priority 6: Productize configuration + +**State: foundation only (§11). Model-lite:** value provenance in the +registry, then layering (profile/workspace scopes — depends on P2 for +workspace, §12 for profiles), then adoption migration (table-valued +settings are the hard prerequisite), then persistence. + +### Priority 7: Build package lifecycle UX + +**State: not started; correctly sequenced after P3.** In-session +install (init-gate rework), disable/uninstall over P3's ownership, +`*packages*` view over P5's primitives, then bundles and registry +sequencing per §13. + +### Priority 8: Ship binaries and release channels + +**State: zero (§17). Independent of everything — can start anytime.** +The editor becomes testable by users who are not repository +contributors; every other priority's value is invisible until this one +exists. + +### How this maps to arcs + +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. +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 + handle + derived `dispatch_idle`, then migrate shadows one per PR. +4. **Extension ownership** (P3): `hook.remove`, owner-carrying + registrations, attribution-by-default. +5. **Worker identity** (§9): owner/purpose/parent on jobs and + processes, join the four planes, statusline activity indicator. +6. **Workspace entity** (P2): the object, then location values. +7. **Config provenance + adoption** (P6). +8. **Package lifecycle** (P7, after 4). +9. **Distribution** (P8, anytime). + +A standing process change accompanies all of them (§1.3): **every new +framing doc must state its coherence impact** — which journey steps it +touches, whether it adds an interaction island, whether its options +enter the config registry, whether its background work is attributed — +so the debt stops compounding silently. + +--- + +## 21. What Pmacs Should Borrow + +Proven adoption-cost reducers from successful modern editors, with +audited status: immediate usefulness (△ — editing yes, journey no); +strong defaults (✓ where they exist, §3); progressive disclosure (✗ +inverted, §4); searchable commands (△ names-only, §5); integrated +language tooling (✓ data layer / △ surface); project awareness (△ +conventions, §7); visible contextual actions (△ §15); coherent +task/terminal integration (✓ mechanics / ✗ visibility, §9); package +discoverability (✗, §13); configuration layering (△ foundation, §11); +remote development as core workflow (△ works, unmodeled, §8); smooth +distribution and updates (✗, §17); consistent interface primitives (△ +best trajectory, §14); explicit missing-tool guidance (✗ except +`--gpu`, §1.2). + +--- + +## 22. What Pmacs Should Preserve and Deepen + +Pmacs should not trade away the qualities that justify its existence — +and the audit confirms these are today's genuine strengths: live +programmability (redefine/unregister at runtime, per-package envs); +implementation inspectability (SourceLocation on every registration, +mandatory descriptions); replaceable interaction models (aspirational — +§6 is the gap); multiple genuine frontends and semantic rendering (✓, +§16 — the strongest concern); explicit parallel work with +cancellability and observability (mechanics ✓, product visibility ✗, +§9); remote daemon architecture (✓); user control over the editor as a +running system (✓). + +The goal is not to make pmacs less powerful so that it becomes +approachable. The goal is to make power **progressively available**. + +--- + +## 23. Product Thesis + +Emacs offers: *the editor is a programmable environment, and the user +may transform it completely.* VS Code offers: *the editor is already a +coherent development workstation, and extensions fill in the remaining +gaps.* Pmacs should offer: + +> **The editor is already an excellent workstation, and every part of +> that workstation remains inspectable, programmable, concurrent, and +> replaceable.** + +Its strongest distinctive proposition is not "Emacs in Rust" or "Emacs +with threads": + +> **Pmacs is a live-programmable editor in which computation, +> interfaces, ownership, and execution locations are explicit — allowing +> local, remote, interactive, and background work to coexist without +> freezing or becoming opaque.** + +The audit's one-line verdict on the thesis: **"without freezing" is +delivered; "without becoming opaque" is not yet true** — for the +failures a new user meets first (§1.2), for background work (§9), for +settings (§11), and for what a key will do while a modal surface is +active (§6). Product coherence is what will make the architecture +perceptible. Without it, pmacs risks becoming an impressive collection +of subsystems. With it, pmacs becomes a workstation whose complexity is +available without being imposed. + +--- + +## 24. Known documentation drift (as of 2026-07-25) + +Found during the audit; fix opportunistically, ideally before this +document is wired into CLAUDE.md/AGENTS.md as required reading: + +- `docs/keybindings.md` — every `src/editor.rs` line citation in §3 is + stale by ~250–1000 lines despite a "last verified @ `f8096ff` + (2026-07-20)" stamp; its shadow list also omits the terminal `C-c` + escape (reports 5 shadows, actual 6). +- `builtin/api/packages.lua` (EmmyLua annotations) — missing + `install_local`, `reload`, `load`, `describe`, `on_unload`; claims + `update` is unimplemented (it is implemented). +- `CHANGELOG.md` (~line 300) — claims a `describe-key` command for + self-introspection; no such command ever shipped (the Lua API + `pmacs.describe.key` exists; the interactive command does not). +- `docs/config-registry-framing.md` (~658) — claims `describe-setting` + renders through `src/help.rs`; it hand-builds its own text in + `builtin/commands/default.lua`. +- `src/workers_buffer.rs` module doc — says the completions ring caps + at 32; `COMPLETED_RING_CAP` is 64. +- `src/command.rs` doc comment on `predicate` — describes palette + gray-out behavior (T M2.7) that never shipped. + +--- + +## 25. Update protocol for this document + +- **When a PR changes any audited claim here, updating this file rides + that PR** — flip the grade, rewrite the fact, note the PR number. + Same discipline as `docs/agent-handoff.md`. +- Line numbers are hints; symbols are authoritative. When touching a + section anyway, re-verify its citations; do not let this document + accumulate the drift §24 catalogs in others. +- Grades change only with evidence (a landed PR, a re-audit), never + aspirationally. +- The **Ground truth** subsections are a snapshot dated 2026-07-25. If + a future comprehensive re-audit is performed, update the date in the + header and prune superseded facts rather than appending — this is a + briefing, not a log. +- Framing docs for coherence-affecting work should cite the section + they serve (e.g. "COHERENCE §6") and state their coherence impact per + §20's standing process change. diff --git a/Cargo.lock b/Cargo.lock index d7caff6..498f267 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,27 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arborium-lean" +version = "2.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80b795046d03aae5780c58e746ddaf780f683e36d9efa8f67abbe9bc01299eb5" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-sysroot" +version = "2.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d99d80550b726f9dec7ee6d07118c31e08b10e729ac488eabd4c10603dc841" +dependencies = [ + "cc", + "dlmalloc", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -747,6 +768,17 @@ dependencies = [ "libloading", ] +[[package]] +name = "dlmalloc" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -2538,6 +2570,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" name = "pmacs" version = "1.0.0" dependencies = [ + "arborium-lean", "codebook-tree-sitter-latex", "crossbeam", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 589ccaf..4e21592 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -241,6 +241,24 @@ codebook-tree-sitter-latex = "0.6" # engine (see `crate::syntax::BUILTIN_LANGUAGES`). tree-sitter-html = "0.23" tree-sitter-css = "0.25" +# Lean 4 (`.lean`) — Arc 8 Stage 1 (`docs/lean4-mode-framing.md`, Q#LN1). +# `leanprover` ships no tree-sitter grammar (Lean parses with its own +# kernel), so both candidates are third-party. The obvious-looking +# `tree-sitter-lean4` is NOT usable: it depends on `tree-sitter = "0.25"` +# DIRECTLY rather than the shared `tree-sitter-language` ABI crate, which +# `^0.25` makes incompatible with our 0.26 and would fork the graph (the +# same defect that rules out `tree-sitter-dockerfile` above); it exports +# only `pub fn language()` while its README advertises a `LANGUAGE` const +# that does not exist; and its package `include` omits `queries/`, so it +# ships no highlights at all. `arborium-lean` is a republish from the +# arborium grammar collection that does it correctly: `tree-sitter-language +# 0.1` as its sole runtime dep, a pre-generated ABI-15 `parser.c` plus +# `scanner.c` (no CLI at build time), and `HIGHLIGHTS_QUERY` / +# `INJECTIONS_QUERY` / `LOCALS_QUERY` constants. Note the shape: it exports +# `const fn language() -> LanguageFn`, so the entry in +# `crate::syntax::BUILTIN_LANGUAGES` reads `arborium_lean::language().into()` +# rather than the `LANGUAGE.into()` every other entry uses. +arborium-lean = "2.18" # T M4.4 process supervisor: signal sending without `unsafe`. Keep # the feature surface tight to keep build time low. `poll` feeds the # compile-mode group readers (cancellable poll-based reads, Q#CM3). diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 04c49fe..2a13c21 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -611,6 +611,129 @@ cmd { name = "editor.switch-buffer", } end } +-- find-file (dired arc Stage 0; docs/dired-framing.md Q#DR11) ---------------- +-- +-- Until now pmacs had no discoverable way to open a file by path: a file +-- entered a session only from the CLI, an LSP jump, a project-search +-- visit, or `C-x C-r` (whose prompt does pass free text through, but +-- completes only over the recent list). This is that surface. +-- +-- Two substrate facts shape it, and both are load-bearing: +-- +-- 1. COMPLETION IS FLAT. `source = "files"` lists ONE directory and +-- yields bare basenames (`minibuffer.rs` `list_directory`), capped at +-- the shared candidate limit. A custom function source could not do +-- better: sources are called with NO arguments, so a callback cannot +-- see the input to re-root on, and it runs synchronously outside any +-- coroutine, where `Handle:await()` raises --- so it cannot list a +-- directory either. Hierarchical completion is a named Rust change in +-- the framing, not something this command can fake. +-- +-- 2. A SELECTED CANDIDATE SHADOWS TYPED TEXT. `recompute_candidates` +-- sets `selected = Some(0)` whenever the candidate list is non-empty, +-- and `resolve_accepted_value` returns the CANDIDATE whenever +-- anything is selected. So `on_accept` receives typed text only when +-- the input filters every candidate away --- which, since candidates +-- are basenames and the filter is a subsequence match, is exactly +-- when the input contains a `/`. That makes the deeper-path case work +-- (`sub/inner.txt` matches no basename, so it arrives verbatim) and +-- leaves TWO documented consequences, each pinned by a test rather +-- than left to be rediscovered: +-- +-- (a) typing a NEW bare name that happens to be a subsequence of an +-- existing entry opens the existing file instead of creating the +-- new one --- `find_file_selected_candidate_shadows_typed_text`. +-- A new bare name that matches nothing is unaffected and creates +-- normally (`find_file_bare_new_name_creates_in_the_root`). +-- (b) accepting on EMPTY input opens the first candidate in sort +-- order. `fuzzy_score` returns `Some(0)` for an empty needle, so +-- everything ties and `filter_and_sort` falls back to +-- lexicographic order --- which puts dotfiles first, and can put +-- a DIRECTORY first, in which case the open fails and reports. +-- This is the same mechanism `M-x` and `switch-buffer` already +-- have, so it is inherited rather than introduced; it is recorded +-- as decided, not overlooked, and listed in the framing's +-- deferrals beside the accept-semantics fix that would close it. +-- +-- The root is the active buffer's directory, or the process cwd when the +-- buffer has no backing path (`source_root` defaults to "." Rust-side, +-- so the nil case needs no special handling here). It appears in the +-- prompt because the field itself must stay empty: any prefill would +-- contain a `/` and filter every candidate away, killing completion. + +-- Directory part of a path. "/a/b" -> "/a"; "/a" -> "/"; "a" -> nil. +local function find_file_dirname(path) + local dir = path:match("^(.*)/[^/]*$") + if dir == nil then return nil end + if dir == "" then return "/" end + return dir +end + +-- Expand a leading `~` component using $HOME: `~` -> $HOME, `~/x` -> +-- $HOME/x. `~user` is left alone (no passwd lookup), matching the core's +-- own `expand_tilde`. +-- +-- This has to happen HERE, before the path reaches the core, because +-- `get_or_load_buffer` normalizes the path it STORES but loads from the +-- raw one --- so a `~/...` path deduplicates against an already-open +-- buffer yet fails to load when the file is not open yet. Expanding up +-- front makes both halves agree. +local function find_file_expand_tilde(path) + local home = os.getenv("HOME") + if home == nil or home == "" then return path end + if home:sub(-1) == "/" then home = home:sub(1, -2) end + if path == "~" then return home end + local rest = path:match("^~/(.*)$") + if rest == nil then return path end + return home .. "/" .. rest +end + +-- Turn an accepted value into a path. The value is either a bare +-- basename (a selected candidate) or whatever the user typed, so a +-- non-absolute value joins onto the prompt's root --- which resolves +-- both cases to the same file when they name the same one. +local function find_file_resolve(root, value) + local path = find_file_expand_tilde(value) + if path:sub(1, 1) == "/" then return path end + local base = root or "." + if base:sub(-1) == "/" then return base .. path end + return base .. "/" .. path +end + +-- The active buffer's directory, or nil when it has no backing path. +local function find_file_root() + local buf = pmacs.window.buffer() + if buf == nil then return nil end + local ok, path = pcall(function() return buf:path() end) + if not (ok and path) then return nil end + return find_file_dirname(path) +end + +cmd { name = "find-file", + description = "Open a file by path, completing within one directory.", + fn = function() + local root = find_file_root() + pmacs.minibuffer.read { + prompt = "Find file (" .. (root or ".") .. "): ", + source = "files", + source_root = root, + history = "find-file", + on_accept = function(value) + if value == nil or value == "" then return end + local path = find_file_resolve(root, value) + -- A path that does not exist yet CREATES a buffer bound to + -- it: `display_file` routes through `resolve_target_buffer`, + -- which on NotFound creates, binds, and sets "[new file]". + -- That is Emacs parity and deliberate, so only a real + -- failure (a directory, a permission error) reaches here. + local ok, err = pcall(pmacs.window.display_file, path, { select = true }) + if not ok then + pmacs.editor.set_status("find-file: " .. tostring(err)) + end + end, + } + end } + -- Command palette (M-x) ------------------------------------------------------ -- -- Opens the minibuffer with a "commands" completion source, then diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index 7dfb9db..c260170 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -148,6 +148,7 @@ bind("C-x o", "window.focus-next") bind("C-x O", "window.focus-prev") bind("C-x 0", "window.close") bind("C-x 1", "window.close-others") +bind("C-x C-f", "find-file") bind("C-x b", "editor.switch-buffer") bind("C-x C-b", "editor.list-buffers") bind("C-x ", "editor.next-buffer") diff --git a/builtin/runtime/comment.lua b/builtin/runtime/comment.lua index 7ee91e8..a9912d6 100644 --- a/builtin/runtime/comment.lua +++ b/builtin/runtime/comment.lua @@ -39,6 +39,10 @@ pmacs.comment.strings = { sh = "#", toml = "#", yaml = "#", + -- Lean 4 (framing Q#LN5). `--` only: Lean's block comment is `/- -/` and + -- its docstring `/-- -/`, but block-comment toggling is the comment arc's + -- own named deferral and this lane does not front-run it. + lean4 = "--", } -- Start of the line containing `pos`: chunked backward scan for the diff --git a/builtin/runtime/dired.lua b/builtin/runtime/dired.lua new file mode 100644 index 0000000..9c6bc92 --- /dev/null +++ b/builtin/runtime/dired.lua @@ -0,0 +1,895 @@ +-- dired.lua --- the directory view (dired arc Stage 1). +-- +-- Dired is not a convenience rider on an existing file surface: until +-- Stage 0 (`C-x C-f`, #162) there was no way to open a file by path at +-- all, and browsing is the half a user reaches for when they do NOT +-- already know the path. So this is a primary surface, and the one +-- thing it may never do is refuse to render a listing --- hence the +-- per-entry-tolerant `read_dir` opt it drives (Q#DR6), the only Rust +-- this stage needed besides exposing the path normalizer. +-- +-- Framing: docs/dired-framing.md (Q#DR1-DR10). Stage 1 is the view: +-- listing, navigation, sort, revert, quit. Marks and operations are +-- Stage 2; the editable wdired layer is Stage 3. +-- +-- Public surface: +-- +-- pmacs.dired.open(path [, opts]) -- awaits; run inside pmacs.async +-- opts.display = "current" | "panel" (Q#BP11b, default "current") +-- opts.select_name = "" -- seat the cursor on it +-- +-- M-x dired / C-x d -- prompt for a directory +-- M-x dired-jump / C-x C-j -- dired on this file's directory +-- +-- In a dired buffer (mode-scoped keys, Q#DR8): +-- RET, f visit (directory -> descend, file -> display_file) +-- ^ parent directory +-- n / p move by line ( / too) +-- g revert (re-read, preserving the cursor's entry) +-- q quit (restore the previous buffer, or window.quit in a panel) +-- s cycle sort mode (name -> mtime -> size) +-- +-- Three structural decisions worth knowing before editing this file: +-- +-- 1. ONE BUFFER PER DIRECTORY, named `*dired:*` +-- (Q#DR2). Navigation *opens the target's buffer*; it never mutates +-- the current one. That is Emacs behavior, and it is also the only +-- way to keep the name honest --- there is no +-- `pmacs.buffer.set_name`, so the M8.2 fixture's in-place repaint +-- leaves a buffer named after a directory it no longer shows. +-- +-- 2. THE CANONICAL FORM IS THE CORE'S, not a copy of it +-- (`pmacs.path.canonicalize` is `normalize_buffer_path` itself). +-- Dired's name-dedup and `display_file`'s `find_buffer_for_path` +-- dedup have to agree; two implementations that disagree on `//tmp` +-- or a `..` at root would mint two buffers for one directory with no +-- error anywhere. +-- +-- 3. EVERY LISTING IS ASYNC. `pmacs.fs.read_dir` is worker-dispatched, +-- so each command spawns a coroutine and the work after the first +-- `:await()` resumes on a later tick --- outside interactive +-- dispatch. Three consequences: +-- +-- * Errors MUST be `pcall`ed and reported here, and that is +-- load-bearing rather than tidy. An uncaught raise inside a +-- `pmacs.async` coroutine reaches `step()`, which reports through +-- `pmacs.error` --- a channel that **is never defined in +-- production** (`COHERENCE.md` §1.1) --- and so falls through to a +-- bare `error()` inside `pmacs._async.tick()`, whose result +-- `EditorState::tick_async` discards with `let _ =`. The failure +-- would not reach the status line, the `*errors*` buffer, or a log: +-- it would reach nowhere, and dired would look like it silently did +-- nothing. +-- * Reporting therefore goes through `pmacs.editor.set_status`, which +-- exists and which the acceptance suite observes --- the corollary +-- COHERENCE draws from that dead channel: report through a surface +-- a test can see, or the guard is indistinguishable from the +-- silence it was meant to fix. +-- * `pmacs.window.*` calls made after the await act for the *ambient* +-- active frontend, since interactive origin does not survive the +-- tick boundary; and `pmacs.editor.move_to_line` acts on the +-- ambient *buffer*, which is why every post-await re-seat is +-- guarded (see `seat_cursor`). + +-- Emacs 28's dired-kill-when-opening-new-dired-buffer, as a setting +-- rather than a hardcoded policy: buffer-per-directory accumulates +-- buffers when walking a deep tree, and Emacs users differ on whether +-- that is a feature. +pmacs.config.define { + name = "dired.kill-when-opening", + description = "Kill the dired buffer being left when descending or ascending.", + type = "boolean", + default = false, + mutability = "live", +} + +-- --------------------------------------------------------------------------- +-- Layout +-- --------------------------------------------------------------------------- +-- +-- The mark column is column 0 (Q#DR4), so every other column sits two +-- bytes right of the M8.2 fixture's offsets. Stage 1 always renders it +-- blank: filling it in is Stage 2's job, but reserving it now means +-- Stage 2 does not have to move every column, and Stage 3's +-- column-classifying intercept can be written against constants that +-- did not shift under it. Offsets are computed from the widths for the +-- same reason --- the fixture hardcoded `NAME_START = 39` and paid for +-- it in every wdired test. + +local MARK_BYTES = 2 +local KIND_BYTES = 1 +local PERMS_BYTES = 9 +local SIZE_BYTES = 10 +local MTIME_BYTES = 16 + +local MARK_START = 0 +local KIND_START = MARK_START + MARK_BYTES -- 2 +local PERMS_START = KIND_START + KIND_BYTES -- 3 +local PERMS_END = PERMS_START + PERMS_BYTES -- 12 (exclusive) +local SIZE_START = PERMS_END + 1 -- 13 +local MTIME_START = SIZE_START + SIZE_BYTES + 1 -- 24 +local NAME_START = MTIME_START + MTIME_BYTES + 1 -- 41 + +local BLANK_MARK = string.rep(" ", MARK_BYTES) + +local SORT_MODES = { "name", "mtime", "size" } + +-- --------------------------------------------------------------------------- +-- Per-buffer state +-- --------------------------------------------------------------------------- +-- +-- handles: array of { buf, path, entries, errors, sort_mode, prev }. +-- +-- Keyed by linear scan over `BufferIdLua.__eq` rather than by table +-- key: two BufferIdLua values for the same buffer are distinct +-- userdata, so a `handles[buf]` lookup would miss. The scan is over a +-- handful of dired buffers. Dead buffers are compacted out first, so a +-- command in a removed dired buffer sees "not in dired" rather than +-- operating on dead state (the M8.2 fixture's `find_handle` lesson). + +local handles = {} + +local function live_handles() + local live = {} + for _, h in ipairs(handles) do + local ok, valid = pcall(h.buf.is_valid, h.buf) + if ok and valid then live[#live + 1] = h end + end + handles = live + return live +end + +local function handle_for_buffer(buf) + if buf == nil then return nil end + for _, h in ipairs(live_handles()) do + if h.buf == buf then return h end + end + return nil +end + +local function handle_for_path(path) + for _, h in ipairs(live_handles()) do + if h.path == path then return h end + end + return nil +end + +local function active_handle() + return handle_for_buffer(pmacs.window.buffer()) +end + +-- --------------------------------------------------------------------------- +-- Paths and names +-- --------------------------------------------------------------------------- + +local canonicalize = pmacs.path.canonicalize + +local function join_path(dir, name) + if dir:sub(-1) == "/" then return dir .. name end + return dir .. "/" .. name +end + +-- Parent of a canonical directory, through the same normalizer: `..` +-- against the root folds away, so `/` is its own parent and no separate +-- root special case can drift out of agreement with the canonical form. +local function parent_path(path) + return canonicalize(join_path(path, "..")) +end + +local function basename(path) + return path:match("([^/]+)/*$") +end + +local function dirname(path) + local dir = path:match("^(.*)/[^/]*$") + if dir == nil then return nil end + if dir == "" then return "/" end + return dir +end + +local function buffer_name(path) + return "*dired:" .. path .. "*" +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 + +-- The directory a prompt or a jump should start from: the active +-- buffer's own directory, else the process cwd (which the normalizer +-- yields for a bare "." because it absolutizes against it). +local function current_directory() + local buf = pmacs.window.buffer() + if buf ~= nil then + local ok, path = pcall(function() return buf:path() end) + if ok and path then + local dir = dirname(path) + if dir then return canonicalize(dir) end + end + local h = handle_for_buffer(buf) + if h then return h.path end + end + return canonicalize(".") +end + +-- --------------------------------------------------------------------------- +-- Failure reporting +-- --------------------------------------------------------------------------- + +-- `Handle:await()` raises structured tables (R45), so `tostring` on a +-- failure yields "table: 0x...". Every user-visible dired failure goes +-- through here. +local function failure_message(err) + if type(err) == "table" then + return tostring(err.message or err.tag or "error") + end + return tostring(err) +end + +local function report(where, err) + pmacs.editor.set_status(where .. ": " .. failure_message(err)) +end + +-- --------------------------------------------------------------------------- +-- Rendering +-- --------------------------------------------------------------------------- + +-- `rwxr-xr-x`, without the leading kind char (rendered separately so a +-- symlink shows `l` and a directory `d`). Arithmetic rather than bit +-- ops: this file has to run on LuaJIT (5.1) as well as Lua 5.4. +-- +-- The nine basic bits only: setuid / setgid / sticky are deliberately +-- not surfaced as Emacs's `s` / `t`, matching the M8.3 fixture's +-- `parse_perm_string`, which edits exactly these nine. Rendering a bit +-- Stage 3 could not accept back would be worse than omitting it. +local function fmt_perms(mode) + local function tri(bits) + local r = (bits >= 4) and "r" or "-" + local w = ((bits % 4) >= 2) and "w" or "-" + local x = ((bits % 2) >= 1) and "x" or "-" + return r .. w .. x + end + return tri(math.floor(mode / 64) % 8) + .. tri(math.floor(mode / 8) % 8) + .. tri(mode % 8) +end + +local function kind_char(kind) + if kind == "dir" then return "d" + elseif kind == "symlink" then return "l" + elseif kind == "file" then return "-" + else return "?" -- device, fifo, socket + end +end + +-- Exact bytes while they fit the column; a magnitude past that. +-- +-- `%10d` holds ten digits, so a file of 10 GB or more (VM images, core +-- dumps --- ordinary things) widens the field and shifts mtime and name +-- right on that line alone. That is only cosmetic today, but +-- `_layout.NAME_START` is exported as a contract and Stage 3's +-- column-classifying intercept is planned against these constants, so a +-- line that violates them now is a Stage 3 trap. Same discipline as +-- `fmt_mtime`: the width is the invariant, and precision yields to it. +-- +-- This is NOT the deferred human-readable size column (§13): the exact +-- byte count is still what a listing shows, right up to the point where +-- it cannot be shown at all. +local SIZE_UNITS = { "K", "M", "G", "T", "P", "E" } + +local function fmt_size(n) + local exact = string.format("%" .. SIZE_BYTES .. "d", n) + if #exact <= SIZE_BYTES then return exact end + local value, unit = n, SIZE_UNITS[#SIZE_UNITS] + for _, suffix in ipairs(SIZE_UNITS) do + value = value / 1024 + unit = suffix + if value < 1024 then break end + end + local scaled = string.format("%.1f%s", value, unit) + if #scaled > SIZE_BYTES then scaled = scaled:sub(1, SIZE_BYTES) end + return string.rep(" ", SIZE_BYTES - #scaled) .. scaled +end + +local function fmt_mtime(secs) + -- Explicit format string, so the width is fixed and the result does + -- not move with LC_TIME. A pre-epoch mtime is legal and `os.date`'s + -- behavior on a negative time is platform-dependent, so a + -- non-conforming result degrades to a fixed-width placeholder rather + -- than shifting every column right of it. + local ok, formatted = pcall(os.date, "%Y-%m-%d %H:%M", secs) + if ok and type(formatted) == "string" and #formatted == MTIME_BYTES then + return formatted + end + return string.rep("?", MTIME_BYTES) +end + +-- POSIX permits any byte but `/` and NUL in a filename, including `\n`. +-- Rendering one verbatim would break the one-line-per-entry invariant +-- that cursor-line -> entry resolution rests on (and that Stage 3's +-- intercept will rest on harder), so control bytes are escaped. The +-- backslash goes first, which is what makes the encoding invertible --- +-- Stage 3 needs the exact inverse so a no-op commit cannot fire a +-- spurious rename. Carried over from the M8.2 fixture as decided +-- design, not re-litigated. +local function escape_displayable(s) + if s == nil then return "" end + s = s:gsub("\\", "\\\\") + s = s:gsub("\n", "\\n") + s = s:gsub("\r", "\\r") + s = s:gsub("\t", "\\t") + -- NUL is deliberately absent from the class: the kernel forbids it in + -- a filename, so the fixture's `%z` (removed from Lua 5.2's pattern + -- syntax) was covering a case that cannot occur. + s = s:gsub("[\1-\8\11\12\14-\31]", function(ch) + return string.format("\\x%02X", string.byte(ch)) + end) + return s +end + +local function render_entry(entry) + local target = "" + if entry.symlink_target then + target = " -> " .. escape_displayable(entry.symlink_target) + elseif entry.kind == "symlink" then + -- A tolerant listing keeps a symlink whose target could not be + -- represented (non-UTF-8) or read; say so rather than rendering a + -- bare `l` line that looks like a complete entry. + target = " -> ?" + end + return string.format( + "%s%s%s %s %s %s%s", + BLANK_MARK, kind_char(entry.kind), fmt_perms(entry.mode), + fmt_size(entry.size), fmt_mtime(entry.mtime), + escape_displayable(entry.name), target) +end + +-- Header (line 0) + one line per entry + the unreadable-count footer. +-- The footer exists because a tolerant listing that silently dropped +-- entries is worse than one that failed: the user has to know the view +-- is incomplete (and Stage 3's wdired refuses to open on one). +local function render_text(handle) + local lines = { handle.path .. ":" } + for _, entry in ipairs(handle.entries) do + lines[#lines + 1] = render_entry(entry) + end + local unreadable = #handle.errors + if unreadable > 0 then + lines[#lines + 1] = string.format("%d entries unreadable", unreadable) + end + return table.concat(lines, "\n") +end + +-- Dired's own writes are the only ones that reach the buffer: the +-- read-only intercept rejects everything else, and this bypasses it. +local function paint(handle) + local text = render_text(handle) + handle.buf:replace(0, handle.buf:len(), text, { bypass_intercept = true }) +end + +-- --------------------------------------------------------------------------- +-- Cursor +-- --------------------------------------------------------------------------- +-- +-- Entry i renders on line i (line 0 is the header), so the entry under +-- the cursor is `entries[cursor_line()]`. + +local function entry_at_cursor(handle) + local line = pmacs.editor.cursor_line() + if line < 1 then return nil end + return handle.entries[line], line +end + +local function index_of_name(handle, name) + if name == nil then return nil end + for i, entry in ipairs(handle.entries) do + if entry.name == name then return i end + end + return nil +end + +-- Re-seat by BASENAME (Q#DR9), falling back to the nearest surviving +-- line. Every repaint is wholesale, so without this a revert, a sort, +-- or any Stage 2 operation would drop the cursor to the header. +-- +-- `move_to_line` is AMBIENT --- it moves the active window's cursor, not +-- `handle.buf`'s --- so every caller that can run after an `:await()` +-- has to check that dired is still the active buffer first. Painting is +-- safe either way (it names the buffer); seating is not. Callers that +-- activate the buffer themselves (an open, which displays first) are +-- unconditionally in the right place. +local function seat_cursor(handle, name, fallback_line) + local count = #handle.entries + if count == 0 then + pmacs.editor.move_to_line(0) + return + end + local target = index_of_name(handle, name) + if target == nil then + target = math.max(1, math.min(fallback_line or 1, count)) + end + pmacs.editor.move_to_line(target) +end + +-- --------------------------------------------------------------------------- +-- Sorting +-- --------------------------------------------------------------------------- + +local function sort_entries(entries, mode) + if mode == "name" then + table.sort(entries, function(a, b) return a.name < b.name end) + elseif mode == "mtime" then + -- Newest first, name as a stable tiebreak so a directory of + -- same-second files renders deterministically. + table.sort(entries, function(a, b) + if a.mtime ~= b.mtime then return a.mtime > b.mtime end + return a.name < b.name + end) + elseif mode == "size" then + table.sort(entries, function(a, b) + if a.size ~= b.size then return a.size > b.size end + return a.name < b.name + end) + else + error("dired: unknown sort mode: " .. tostring(mode)) + end +end + +local function next_sort_mode(mode) + for i, candidate in ipairs(SORT_MODES) do + if candidate == mode then + return SORT_MODES[(i % #SORT_MODES) + 1] + end + end + return SORT_MODES[1] +end + +-- --------------------------------------------------------------------------- +-- Reading +-- --------------------------------------------------------------------------- + +-- Read and sort one directory without touching editor state, so a +-- failure happens before any side effect is committed (acceptance 15). +-- Must run inside `pmacs.async`. +-- +-- Always tolerant (Q#DR6): a plain refresh of a busy directory must not +-- fail because one child was unlinked between `readdir` and `lstat`. +-- Parent-level failures and non-UTF-8 *names* still raise. +local function read_listing(path, sort_mode) + local listing = pmacs.fs.read_dir(path, { tolerant = true }):await() + local entries = listing.entries + sort_entries(entries, sort_mode) + return entries, listing.errors +end + +-- --------------------------------------------------------------------------- +-- Buffer ownership +-- --------------------------------------------------------------------------- + +-- 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 `*dired:/tmp*`. Painting into it through +-- `bypass_intercept` would clobber a user's data, so found-by-name is +-- NOT adoption: ownership means "this buffer is in dired's own handle +-- table" (F7). +-- +-- That is deliberately narrower than the framing's "in the handle table +-- OR major_mode == dired": a foreign buffer that also carries the mode +-- is precisely the case the check exists to refuse, and a builtin's +-- handle table cannot be lost the way a reloadable package's can. +local function claim_handle(path) + local existing = handle_for_path(path) + if existing then return existing end + + local name = buffer_name(path) + if buffer_named(name) then + local unique = nil + for i = 2, NAME_VARIANT_LIMIT do + local candidate = string.format("%s<%d>", name, i) + if buffer_named(candidate) == nil then + unique = candidate + break + end + end + if unique == nil then + error(string.format("dired: %s is taken and no free variant remains", name)) + end + name = unique + end + + local buf = pmacs.buffer.create(name) + -- Read-only by the listview idiom (Q#DR3): every non-bypass edit is + -- rejected, and the intercept lives as long as the buffer. + pmacs.buffer.add_intercept(buf, function() + error(name .. " is read-only") + end) + -- Q#DR3/Q#P6: while this buffer is active a semantic frontend must + -- round-trip keys, or optimistic apply would swallow the single-key + -- bindings (`g` would insert a `g` into a CRDT mirror instead of + -- reverting) and bypass the intercept entirely. + pmacs.buffer.set_round_trip_input(buf, true) + -- Q#DR8: the mode is what carries the keymap, and dired is #129's + -- first consumer of mode-scoped keys outside language detection. + pmacs.buffer.set_major_mode(buf, "dired") + + local handle = { + buf = buf, + path = path, + entries = {}, + errors = {}, + sort_mode = SORT_MODES[1], + prev = nil, + } + handles[#handles + 1] = handle + return handle +end + +-- --------------------------------------------------------------------------- +-- Display +-- --------------------------------------------------------------------------- + +local function drop_handle(handle) + for i, candidate in ipairs(handles) do + if candidate == handle then + table.remove(handles, i) + return + end + end +end + +-- Kill the dired buffer being left, when the user asked for it. +-- Deliberately after the new buffer is displayed: `pmacs.buffer.kill` +-- redirects windows showing the doomed buffer, and doing that first +-- would fight the display we are about to perform. +local function kill_departed(departed, arriving) + if departed == nil or departed == arriving then return end + if not pmacs.config.get("dired.kill-when-opening") then return end + local ok, err = pcall(pmacs.buffer.kill, departed.buf) + if ok then + drop_handle(departed) + else + -- A buffer that could not be killed keeps its handle: dropping it + -- would leave a live dired buffer no command recognizes. + report("dired", err) + end +end + +-- Where a dired buffer goes. +-- +-- A fresh `dired` takes the standard adopter opt (Q#BP11b): omitted or +-- "current" is the raw switch every other adopter defaults to in +-- Stages 1-2, "panel" is the bottom side window. +-- +-- Navigation (`departed ~= nil`) instead reuses the window dired +-- already occupies, which is the opposite routing from a file visit and +-- 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) + local side = nil + if departed ~= nil then + -- Dired's own window, not the request's: walking a tree in a side + -- window keeps the side window. + local params = pmacs.window.params() + side = params and params.side + elseif opts and opts.display == "panel" then + side = "bottom" + end + if side ~= nil then + -- A side slot DEDICATED to another buffer refuses the replacement + -- and this falls back to the document window (Q#BP3 2.iii). That is + -- 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 }) + else + pmacs.window.switch_buffer(handle.buf) + end +end + +-- --------------------------------------------------------------------------- +-- Public: open a directory +-- --------------------------------------------------------------------------- + +pmacs.dired = pmacs.dired or {} + +local OPEN_OPTS = { display = true, select_name = true } + +-- Open `path`'s dired buffer, replacing `departed` (a handle) in the +-- window it occupies when this is a navigation rather than a fresh +-- open. Awaits, so it must run inside `pmacs.async`; raises on a read +-- failure, having changed nothing. Returns the buffer. +local function open_directory(path, opts, departed) + if type(path) ~= "string" then + error("pmacs.dired.open: path must be a string, got " .. type(path)) + end + opts = opts or {} + -- Validated up front, before the read and before any buffer exists, + -- so a bad opt leaves nothing to roll back (the + -- `parse_adopter_placement` discipline). + for key in pairs(opts) do + if not OPEN_OPTS[key] then + error(string.format("pmacs.dired.open: unknown opts key %q", tostring(key))) + end + end + local wanted = opts.display + if wanted ~= nil and wanted ~= "current" and wanted ~= "panel" then + error(string.format('pmacs.dired.open: unknown display %q (expected "current" or "panel")', + tostring(wanted))) + end + local canonical = canonicalize(path) + + -- Read first: a failure must leave no buffer, no window change, and + -- no handle behind. + 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 + + -- `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 + end + 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 +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 +-- in *errors* and leave the user with a silent no-op). +local function open_async(path, opts, departed, where) + pmacs.async(function() + local ok, err = pcall(open_directory, path, opts, departed) + if not ok then report(where or "dired", err) end + end) +end + +-- --------------------------------------------------------------------------- +-- Commands +-- --------------------------------------------------------------------------- + +pmacs.command.define { + name = "dired", + description = "Open a directory listing (dired).", + fn = function() + local root = current_directory() + -- No completion source, deliberately. `source = "files"` would make + -- RET-on-empty open whatever sorts first (the minibuffer selects + -- candidate 0 whenever the list is non-empty, and a selected + -- candidate shadows typed text --- S0-1/S0-4), and RET-on-the- + -- default-directory is exactly the gesture `C-x d` exists for. The + -- field is prefilled instead, which is Emacs's own shape here. + pmacs.minibuffer.read { + prompt = "Dired: ", + initial = root, + history = "dired", + on_accept = function(value) + if value == nil or value == "" then return end + open_async(value, nil, nil, "dired") + end, + } + end, +} + +pmacs.command.define { + name = "dired-jump", + description = "Open dired on the current file's directory, cursor on that file.", + fn = function() + local buf = pmacs.window.buffer() + local path = nil + if buf ~= nil then + local ok, value = pcall(function() return buf:path() end) + if ok then path = value end + end + if path == nil then + pmacs.editor.set_status("dired-jump: this buffer has no file") + return + end + local dir = dirname(path) + if dir == nil then + pmacs.editor.set_status("dired-jump: cannot find the directory of " .. path) + return + end + open_async(dir, { select_name = basename(path) }, nil, "dired-jump") + end, +} + +pmacs.command.define { + name = "dired.visit", + description = "Visit the entry under the cursor (descend a directory, open a file).", + fn = function() + local handle = active_handle() + if handle == nil then return end + local entry = entry_at_cursor(handle) + -- The header and the unreadable-count footer are not entries. + if entry == nil then return end + local target = join_path(handle.path, entry.name) + if entry.kind == "dir" then + open_async(target, nil, handle, "dired") + return + end + if entry.kind == "symlink" then + -- `read_dir` and `stat` are both lstat-based, so nothing in the + -- entry says whether the link points at a directory --- the only + -- way to find out is to try to list it. A symlinked directory is + -- an ordinary thing to walk into, so try the descent and fall back + -- to a file visit. + -- + -- `open_directory` is the try: it reads before touching any editor + -- state and raises having changed nothing (acceptance 15), so its + -- failure IS the "not a directory" answer. An explicit probe + -- followed by the real open would list the whole directory TWICE + -- --- opendir plus one lstat per child, each time. + pmacs.async(function() + local descended = pcall(open_directory, target, nil, handle) + if descended then return end + local visited, err = pcall(pmacs.window.display_file, target, { select = true }) + if not visited then report("dired", err) end + end) + return + end + -- Q#DR10: `display_file`, never `find_or_open`, which switches the + -- active window in both branches before firing hooks --- in a + -- panel-displayed dired that would replace the panel with the + -- visited file, i.e. the panel swallows itself. + local ok, err = pcall(pmacs.window.display_file, target, { select = true }) + if not ok then report("dired", err) end + end, +} + +pmacs.command.define { + name = "dired.parent", + description = "Open the parent directory.", + fn = function() + local handle = active_handle() + if handle == nil then return end + local parent = parent_path(handle.path) + if parent == handle.path then + pmacs.editor.set_status("dired: already at the filesystem root") + return + end + -- Seat on the directory we came from, the way Emacs's `^` does. + open_async(parent, { select_name = basename(handle.path) }, handle, "dired") + end, +} + +pmacs.command.define { + name = "dired.revert", + description = "Re-read the directory, keeping the cursor on its entry.", + fn = function() + local handle = active_handle() + if handle == nil then return end + local entry, line = entry_at_cursor(handle) + local name = entry and entry.name + pmacs.async(function() + local ok, entries, errors = pcall(read_listing, handle.path, handle.sort_mode) + if not ok then + -- On failure `entries` carries the raised value, not a listing. + report("dired", entries) + return + end + if not handle.buf:is_valid() then return end + handle.entries = entries + handle.errors = errors + paint(handle) + -- The re-read settles a tick or more later, and the user may have + -- left (a buffer switch, or `q`) in the meantime. The paint names + -- its buffer and is safe; seating is ambient, so a stale seat here + -- would move an unrelated buffer's cursor to a line index that + -- only means something in this listing. + if pmacs.window.buffer() == handle.buf then + seat_cursor(handle, name, line) + end + end) + end, +} + +pmacs.command.define { + name = "dired.sort-cycle", + description = "Cycle the sort mode: name -> mtime -> size.", + fn = function() + local handle = active_handle() + if handle == nil then return end + local entry, line = entry_at_cursor(handle) + local name = entry and entry.name + -- A pure reorder of the entries already in hand: sort is a display + -- decision, not a reason to re-read the directory. + handle.sort_mode = next_sort_mode(handle.sort_mode) + sort_entries(handle.entries, handle.sort_mode) + paint(handle) + seat_cursor(handle, name, line) + pmacs.editor.set_status("dired: sorted by " .. handle.sort_mode) + end, +} + +pmacs.command.define { + name = "dired.quit", + description = "Leave dired, restoring the previous buffer.", + fn = function() + local handle = active_handle() + if handle == nil then return end + -- Q#BP11b, matching `listview.quit`: `q` keeps its name and its + -- user-visible behavior, delegating to `window.quit` only when + -- dired really is in a side window. + local params = pmacs.window.params() + if params and params.side and params.quit_action then + pmacs.window.quit() + return + end + local target = handle.prev + if not (target and target:is_valid()) then + target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*") + end + pmacs.window.switch_buffer(target) + end, +} + +-- --------------------------------------------------------------------------- +-- Keys +-- --------------------------------------------------------------------------- + +-- Global: both sequences are unbound repo-wide, and both are the Emacs +-- defaults. +pmacs.keymap.bind { scope = "global", sequence = "C-x d", command = "dired" } +pmacs.keymap.bind { scope = "global", sequence = "C-x C-j", command = "dired-jump" } + +-- In-buffer keys are MODE-scoped (Q#DR8), bound once here rather than +-- per buffer: a second dired buffer needs no `keymap.bind` of its own, +-- and Stage 3's wdired swap changes the whole keymap with the mode +-- instead of unbinding key by key. +local function bind(sequence, command) + pmacs.keymap.bind { scope = "mode", mode = "dired", sequence = sequence, command = command } +end + +bind("RET", "dired.visit") +bind("f", "dired.visit") +bind("^", "dired.parent") +bind("n", "cursor.down") +bind("", "cursor.down") +bind("p", "cursor.up") +bind("", "cursor.up") +bind("g", "dired.revert") +bind("q", "dired.quit") +bind("s", "dired.sort-cycle") + +-- --------------------------------------------------------------------------- +-- Test seam +-- --------------------------------------------------------------------------- +-- +-- The layout constants, so acceptance can assert column positions +-- without hardcoding the numbers this file computes. +pmacs.dired._layout = { + MARK_START = MARK_START, + KIND_START = KIND_START, + PERMS_START = PERMS_START, + PERMS_END = PERMS_END, + SIZE_START = SIZE_START, + MTIME_START = MTIME_START, + NAME_START = NAME_START, +} diff --git a/builtin/runtime/fs.lua b/builtin/runtime/fs.lua index 02ca064..49c006e 100644 --- a/builtin/runtime/fs.lua +++ b/builtin/runtime/fs.lua @@ -12,7 +12,10 @@ -- `symlink_target` is present only on symlink entries. -- `opts` may contain `supersede = ""` to chain into the M3 -- supersede semantics (a later read_dir under the same key --- cancels the earlier one). +-- cancels the earlier one), and `tolerant = true` to swap the +-- all-or-nothing listing for `{ entries = ..., errors = ... }` +-- (see fs.read_dir's own comment below). Any other key is an +-- error rather than being silently ignored. -- -- Order: entries are returned in *filesystem iteration order*, -- which is whatever the kernel's `readdir` syscall returns. On @@ -69,24 +72,61 @@ end local fs = {} --- Shared opts.supersede extractor; raises on misshapen opts. -local function supersede_key(opts, where) - if opts == nil then return nil end +-- Shared read-op opts parser; raises on misshapen opts. +-- +-- Unknown keys are REJECTED, not ignored. The earlier version read +-- `opts.supersede` and silently dropped everything else, which means a +-- typo'd `tolerant` would degrade to the fatal contract with no signal +-- at all --- exactly the failure the tolerant opt exists to prevent +-- (dired framing §8, minor c). `allowed` is the per-op whitelist. +local function read_opts(opts, where, allowed) + if opts == nil then return nil, false end if type(opts) ~= "table" then error(where .. ": opts must be a table or nil, got " .. type(opts)) end - local k = opts.supersede - if k ~= nil and type(k) ~= "string" then + for key in pairs(opts) do + if not allowed[key] then + local names = {} + for name in pairs(allowed) do names[#names + 1] = name end + table.sort(names) + error(string.format("%s: unknown opts key %q (expected one of: %s)", + where, tostring(key), table.concat(names, ", "))) + end + end + local key = opts.supersede + if key ~= nil and type(key) ~= "string" then error(where .. ": opts.supersede must be a string") end - return k + local tolerant = opts.tolerant + if tolerant ~= nil and type(tolerant) ~= "boolean" then + error(where .. ": opts.tolerant must be a boolean") + end + return key, tolerant == true end +local READ_DIR_OPTS = { supersede = true, tolerant = true } +local STAT_OPTS = { supersede = true } + +-- Two result shapes, chosen by `opts.tolerant` (dired Q#DR6): +-- +-- read_dir(path) -> { , ... } +-- read_dir(path, { tolerant = true }) -> { entries = { , ... }, +-- errors = { { name = ...?, +-- message = ... }, ... } } +-- +-- The bare array is the M8.1 contract and stays exactly as it was, so +-- an existing consumer (the frozen M8.2 dired fixture consumes it with +-- `ipairs`) is unaffected. Under the opt, a per-entry `readdir` / +-- `lstat` / `readlink` failure and a non-UTF-8 symlink *target* become +-- `errors` rows instead of failing the whole listing; a failure on the +-- parent directory, and a non-UTF-8 entry *name*, stay fatal. An +-- `errors` row has no `name` when the entry never materialized. function fs.read_dir(path, opts) if type(path) ~= "string" then error("pmacs.fs.read_dir: path must be a string, got " .. type(path)) end - local id = async_mod._dispatch_fs_read_dir(path, supersede_key(opts, "pmacs.fs.read_dir")) + local key, tolerant = read_opts(opts, "pmacs.fs.read_dir", READ_DIR_OPTS) + local id = async_mod._dispatch_fs_read_dir(path, key, tolerant) return build_handle(id) end @@ -94,7 +134,8 @@ function fs.stat(path, opts) if type(path) ~= "string" then error("pmacs.fs.stat: path must be a string, got " .. type(path)) end - local id = async_mod._dispatch_fs_stat(path, supersede_key(opts, "pmacs.fs.stat")) + local key = read_opts(opts, "pmacs.fs.stat", STAT_OPTS) + local id = async_mod._dispatch_fs_stat(path, key) return build_handle(id) end diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 4181156..6021134 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -30,9 +30,13 @@ pmacs.lsp = pmacs.lsp or {} -- env (table) extra environment -- init_options (table) `initializationOptions` -- settings (table) answered to `workspace/configuration` --- root (string) optional explicit project root; overrides +-- root (string|function) optional explicit project root; overrides -- the `pmacs.project.detect` marker walk used --- to set `rootUri`/`cwd` (see project_root_for) +-- to set `rootUri`/`cwd`. A `function(path) -> +-- string|nil` is resolved per file and +-- memoized per directory; returning nil +-- declines and falls through to the marker +-- walk (see project_root_for) pmacs.lsp.config = pmacs.lsp.config or {} -- Default rust-analyzer config. Users replace any field from init.lua @@ -507,34 +511,144 @@ end -- the rest of the editor uses, honoring set_search_boundary, -- 3. the file's own directory (a lone file still gets a sane root -- rather than leaking the editor cwd). --- This is single-root: it fixes which root the one per-language server --- uses, NOT one-server-per-root scoping (still deferred post-v0.1). +-- Returns `root, source`, where `source` is "config", "detected", or +-- "fallback" — and nil alongside a nil root. The source matters because +-- only the first two mean a root was actually *found*; `ensure_server` +-- keys server affinity on those and treats the fallback as rootless. +-- +-- `config[language].root` may be a `function(path) -> string|nil` as +-- well as a plain string, for languages whose root rule the shared +-- marker walk cannot express (an innermost-wins walk cannot find an +-- *outermost* marker). A resolver that returns nil declines, and +-- resolution falls through to the marker walk. +-- +-- **A configured root — string or resolver return — MUST be a canonical +-- absolute path.** The `"detected"` arm is canonicalized for free +-- (`pmacs.project.detect` canonicalizes before walking), but a +-- configured one is fed to `file_uri_for` exactly as written, and the +-- affinity key is that URI. On macOS a resolver returning `/var/…` and +-- a detected `/private/var/…` are different keys for the same +-- directory, which silently yields two servers for one project. There +-- is no Lua-side canonicalizer to normalize this for you. +-- +-- Resolver results are memoized per directory, because `ensure_server` +-- resolves the root on the *reuse* path as well as the spawn path — so +-- an unmemoized filesystem-walking resolver would re-walk on every +-- attach rather than once per project. The memo is keyed by the +-- resolver function itself, weakly: replacing `config[lang].root` +-- installs a new key and the old memo is collected, so a swapped +-- resolver can never serve a root the previous one computed. +local root_resolver_memo = setmetatable({}, { __mode = "k" }) + +local function resolve_root_fn(language, resolver, path) + local dir = dir_of(path) + if not dir then return nil end + local memo = root_resolver_memo[resolver] + if not memo then + memo = {} + root_resolver_memo[resolver] = memo + end + local hit = memo[dir] + -- `false` is the memoized form of "this resolver declined"; nil means + -- "not yet asked", so the two must stay distinguishable. + if hit ~= nil then + return hit or nil + end + local ok, resolved = pcall(resolver, path) + -- COHERENCE §1.2: background wiring must not DISCARD a failure. A + -- resolver that raises, or that returns something other than a string + -- or nil, is a config bug — and the memo below would otherwise bury + -- it permanently for this directory, so it is never observed again. + -- Returning nil is the documented decline and stays silent. + local failure + if not ok then + failure = "raised: " .. tostring(resolved) + elseif resolved ~= nil and type(resolved) ~= "string" then + failure = "returned a " .. type(resolved) .. "; want string or nil" + end + if failure then + local msg = string.format( + "LSP: %s root resolver for %s %s", language, dir, failure) + -- Report on the channel that EXISTS. `pmacs.error` is referenced by + -- fifteen guarded call sites across the runtime and is defined + -- nowhere in production (only by a test stub in `src/editor.rs`), so + -- `if pmacs.error then ...` alone would be a sixteenth report that + -- never fires — the unwired-guard shape, not a fix for it. The + -- status line is what lsp.lua already uses for every other LSP + -- error. The `pmacs.error` arm rides along so this upgrades for free + -- if that channel is ever built. + -- + -- Both reports are pcall'd: a broken reporting channel must not turn + -- a declined root into a failed attach. + pcall(pmacs.editor.set_status, msg) + if pmacs.error then pcall(pmacs.error, msg) end + resolved = nil + end + if type(resolved) ~= "string" then resolved = nil end + memo[dir] = resolved or false + return resolved +end + local function project_root_for(language, path) local cfg = pmacs.lsp.config[language] - if cfg and cfg.root then return cfg.root end - if not path then return nil end + local configured = cfg and cfg.root + -- Truthiness, not `~= nil`: `root = false` has always read as "unset", + -- and a `false` leaking through as a root would reach `file_uri_for`. + if configured and type(configured) ~= "function" then + return configured, "config" + end + if not path then return nil, nil end + if configured then + local resolved = resolve_root_fn(language, configured, path) + if resolved then return resolved, "config" end + end local ok, det = pcall(pmacs.project.detect, path) - if ok and det and det.root then return det.root end - return dir_of(path) + if ok and det and det.root then return det.root, "detected" end + return dir_of(path), "fallback" end local function ensure_server(language, path) local cfg = pmacs.lsp.config[language] if not cfg or not cfg.command then return nil end - -- Reuse an existing same-language server if one is up. Multi-root - -- scoping (one server per project root) ships post-v0.1, so the - -- first file that attaches a given language fixes that server's - -- root; later files of the same language reuse it regardless of - -- their own project (known, documented limitation). + -- Reuse an existing same-language server *serving the same root*. + -- One server per project root: `lake serve` is bound to one Lake + -- package and rust-analyzer/gopls to one workspace, so handing the + -- second project's files to the first project's server yields + -- unresolvable imports and empty diagnostics. + -- + -- The affinity key is the root only when a root was actually FOUND + -- (config override or marker walk). `project_root_for` never returns + -- nil for a file that has a path — its last resort is the file's own + -- directory — so keying on the fallback would give every directory + -- of loose scratch files its own server, for every language: two + -- stray .py files in different directories would spawn two pyrights + -- where today they share one. The fallback therefore keys on nil. + -- + -- Matching is on the spawned spec's `root_uri`, nil matching nil, so + -- the fallback spawn must pass `root_uri = nil` for the key and the + -- stored spec to agree. `cwd` still carries the directory and + -- `build_initialize` derives the identical `rootUri` from it when the + -- field is None (src/lsp.rs), so the initialize payload is unchanged + -- for that case — only what this loop matches on changes. + -- + -- Consequence, deliberate: a server hand-spawned from `init.lua` with + -- only `cwd` set also reads back nil, so a root-bearing attach will + -- not adopt it. We cannot know which root it was meant to serve, and + -- guessing wrongly routes a project's files to the wrong server. + local root, source = project_root_for(language, path) + local key_uri = nil + if source == "config" or source == "detected" then + key_uri = file_uri_for(root) + end for _, info in ipairs(pmacs.lsp.list()) do - if info.language_id == language and info.state then + if info.language_id == language and info.state + and info.root_uri == key_uri then local kind = info.state.kind if kind ~= "crashed" and kind ~= "stopped" then return info.id end end end - local root = project_root_for(language, path) local ok, sid = pcall(pmacs.lsp.spawn, { label = "default-" .. language, language_id = language, @@ -544,7 +658,7 @@ local function ensure_server(language, path) init_options = cfg.init_options, settings = cfg.settings, cwd = root, - root_uri = root and file_uri_for(root) or nil, + root_uri = key_uri, }) if ok then return sid end return nil diff --git a/builtin/runtime/pair.lua b/builtin/runtime/pair.lua index 6d014d4..9ed9d1f 100644 --- a/builtin/runtime/pair.lua +++ b/builtin/runtime/pair.lua @@ -62,6 +62,22 @@ pmacs.pair.sets = { markdown = { "()", "[]", "{}", '""', "``" }, sh = { "()", "[]", "{}", '""', "''" }, bash = { "()", "[]", "{}", '""', "''" }, + -- Lean 4 (framing Q#LN6). `⟨⟩` (anonymous constructor) is among the + -- most-typed constructs in Lean and omitting it would make the pair set + -- feel broken; `⦃⦄` (strict implicit binder) and `⟮⟯` ride along because + -- the Stage 4 input method can produce them (`\{{}}`, `\([])'`) and a + -- bracket the pair set does not understand is worse than one it does. + -- + -- All three are OUTSIDE the nine built-in pair chars, so per Q#AP1 their + -- opener is a source-peer op and their closer a daemon-peer op: their undo + -- is cross-peer-degraded. That is the documented, pre-existing limitation + -- of user-extended pairs, whose general fix is chronological cross-peer + -- undo arbitration (named substrate work). + -- + -- No `''`: Lean uses `'` as a primed-identifier suffix (`h'`, `foo'`), so + -- pairing it would fight the user constantly. Same reasoning that excludes + -- it for Rust. + lean4 = { "()", "[]", "{}", "⟨⟩", "⦃⦄", "⟮⟯", '""' }, } -- Length of the well-formed UTF-8 sequence starting at `s[i]`, or nil diff --git a/builtin/runtime/syntax.lua b/builtin/runtime/syntax.lua index 812e621..50dad82 100644 --- a/builtin/runtime/syntax.lua +++ b/builtin/runtime/syntax.lua @@ -227,6 +227,11 @@ local default_modeline_aliases = { yml = "yaml", makefile = "make", docker = "dockerfile", + -- Lean 4 (framing Q#LN2). The grammar entry is named `lean4` because that + -- name becomes the `didOpen` language_id, but an Emacs `-*- mode: lean -*-` + -- or a Vim `ft=lean` line is what people actually write, so neither + -- spelling strands a file. + lean = "lean4", } for name, language in pairs(default_modeline_aliases) do if pmacs.parse.modeline_aliases[name] == nil then diff --git a/docs/active-work.md b/docs/active-work.md index cb0d839..6dc7563 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-24.** This file records volatile work that has not +**Snapshot: 2026-07-25.** 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. @@ -14,10 +14,11 @@ 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` @ `e745068` (bottom-panel Stage 1 #155 atop GPU - initial-target #148, folding Stage 2 landed-doc refresh #150, folding - Stage 2 #149, the ledger refresh #147, web grammars HTML+CSS #146, and - the LaTeX Stage 1 #144 / inline-math framing #145 pair; protocol v20). + `githubsucks/main` @ `8c86d34` (the dired framing #164 atop find-file + #162, 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; protocol + v20). - 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 @@ -51,9 +52,343 @@ git worktree list git status --short --branch ``` -The `git log` command must expose `e745068` or a newer intentional main. +The `git log` command must expose `8c86d34` or a newer intentional main. 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) + +- 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) + +- 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`. + +## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) + +- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the + approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1 + implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3 + (wdired) each get their own detailed framing after the prior stage lands. +- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`, + 2026-07-25, one review round, 12/12 CI green). Durable facts moved to + `docs/agent-handoff.md` §1 per rule 3 below. +- **Stage 1 branch: `githubsucks/dired-stage1`**, worktree + `../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the + framing merge #164). **A fresh cut, not a rebase:** the older `dired` + branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the + superseded `0827dd1` and carried only the framing content #164 already + put on `main`, so merging it would have reconciled two histories of one + document. It is left untouched and carries nothing unmerged. +- **Stage 1 implemented; no wire change (protocol stays v20).** What + landed on the branch: + - `builtin/runtime/dired.lua`: one buffer per directory named + `*dired:*` with the handle-table ownership check; + read-only intercept + `set_round_trip_input`; the `dired` major mode + and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`); + basename cursor re-seating across every wholesale repaint; + `display_file` for file visits and same-window reuse for directory + descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting. + Loaded after `window.lua`. + - `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`, + and one walk that either fails on a per-entry condition or records it + (Q#DR6). `src/async_runtime.rs` carries the listing in + `ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs` + keys the Lua result **shape** on `errors.is_some()`, so the bare array + the frozen M8.2 fixture consumes with `ipairs` is untouched; + `builtin/runtime/fs.lua` validates read-op opts and **rejects unknown + keys** (a typo'd `tolerant` used to degrade silently to fatal). + - `src/editor_core.rs` + `src/lua_bindings/mod.rs`: + `normalize_buffer_path` is `pub` and exposed as + `pmacs.path.canonicalize` — Q#DR2's preferred end state, so no Lua + mirror exists and Stage 2 owes no mirror removal. This makes B2 + ("tolerant `read_dir` is the only Rust change") false by one small + binding, deliberately. + - `tests/dired_acceptance.rs`: 22 tests over framing items 1–16, + dispatch-driven; item 17 is the m8_1/m8_2/m8_3 additivity gate. +- **The framing claim the substrate falsified (S1-2):** R2-3 expected a + dedicated dired panel to carry its dedication across a descent. + `display_buffer` never replaces the buffer in a slot dedicated to + another one — it discards every side-specific parameter and falls back + to the document window (Q#BP3 2.iii), and the exact-window arm errors. + Dired does not unpin the user's panel; both arms are pinned. +- **The vacuity the bites found (S1-3):** acceptance 3c cannot pin the + descent *routing*. Dired holds focus in its own panel, so a raw + `switch_buffer` lands in the same window and every 3c assertion holds + either way. Dedication is the only discriminator, so the + dedicated-panel test is the real pin — and the vacuity is documented at + the assertion rather than relabelled. +- **The pre-existing test dired's first mode-scoped binding broke + (S1-4):** `describe_key_identifies_every_default_binding` asserted every + binding in the stack resolves through `describe.key` context-free, which + held only while the modes table was empty. It now sets the effective + context per binding and explicitly *clears* the mode for global ones, + because a leaked mode legitimately shadows a global chord of the same + name (dired's `RET` shadows `edit.newline-and-indent`). +- Durable substrate facts, independent of this arc: + - `pmacs.buffer.kill` (not `remove`) redirects windows off a doomed + buffer before removal, so `kill-when-opening` kills **after** the + replacement is displayed. + - Interactive origin does **not** survive an await: work resumed in + `tick_async` sees no `InteractiveCommandOrigin`, so `pmacs.window.*` + acts for the *ambient* active frontend (S1-9). + - Kinds are lstat-based in both `read_dir` and `stat`, so nothing in an + entry says whether a symlink points at a directory; `RET` probes by + trying to list it (S1-8). + - A path-backed buffer's *name* is its full path, not its basename — + worth knowing before writing any name assertion. + - `C-x d` takes **no** completion source on purpose (S1-5): with one, + RET on an empty field opens whatever sorts first, and + RET-on-where-you-are is the gesture the binding exists for. The field + is prefilled instead. +- **Bite verification:** 15 claims, each mutated in place and required to + fail the test that names it. `dired.lua` is new, so `scripts/bite`'s + file swap does not apply; every mutation was applied and reverted with + `git checkout --`. One came back VACUOUS and is recorded above. +- **Review round 1 addressed** (framing rev 7, S1-10…S1-12). Three + behavioral fixes, each bite-verified: `dired.revert`'s re-seat is + guarded on the active buffer (an ambient `move_to_line` after an await + moved an unrelated buffer's cursor — the buffer-level instance of + S1-9); `fmt_size` keeps the column width past ten digits, because + `_layout` is a contract Stage 3 is planned against; and the symlink + descent dropped its probe, since `open_directory`'s + changed-nothing-on-failure invariant *is* the probe (it was listing the + target directory twice). Plus a consecutive-`readdir`-error cap, because + **nothing cancels a dired listing** — it carries no supersede key, so + cancellation was never the backstop the tolerant loop implicitly relied + on. Naming/comment findings taken as-is. + - Durable process lesson, hit twice now: a mutation-bite helper restores + with `git checkout --`, which reverts to **HEAD** — so a fix must be + committed *before* it is bitten. Round 1's fixes were briefly wiped by + exactly that. +- **Canonical main integrated twice** — at `46a1b8f` (multi-root LSP + affinity #161) and again at `b889873` (GPU terminal input #166), both + merged rather than rebased per the #135/#137 precedent so the review + anchors stay addressable. Each conflict was a single doc hunk resolved + as the union: this lane owns COHERENCE's journey step 7 file half, #161 + owns the in-flight list, #166 owns step 8's GPU-terminal addendum. + Three things worth carrying: + - **A conflicting PR silently stops running CI.** GitHub builds + `pull_request` runs against the merge ref, which does not exist while + the PR conflicts, so no run is created and nothing reports a + failure — the checks list simply stays as it was. Three pushes to + this branch produced no CI at all before the cause was found. Watch + `mergeable` on a long-lived lane, not just the check list. + - #161's own COHERENCE finding **falsified a claim in this lane's + module doc**: `pmacs.error` is never defined in production, so an + uncaught raise inside a `pmacs.async` coroutine does not reach + `*errors*` as the comment said. It reaches a bare `error()` inside + `pmacs._async.tick()`, whose result `tick_async` discards with + `let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` + + `set_status` load-bearing rather than tidy, and the comment now says + so. + - **A lane in review against a fast-moving `main` needs its gates rerun + per integration, not per push.** Main advanced twice inside this + review round, and the second time landed while the first + integration's sweep was still running. The numbers below describe the + twice-merged tree. +- Verification on the twice-merged tree (`main` @ `b889873`): + `cargo fmt --check` clean; strict workspace Clippy clean; **1,832 + default + 2,009 CRDT** library tests; dired acceptance **25 default + + 25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; multi-root 13 and + vterm Stage 3 5 (both suites main added, green under this lane's + `mod.rs` and `editor.rs` changes); M4 121; required GPU 155; + **isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 passed across 93 + suites, zero failures**; `git diff --check` clean. The sweep needs the + isolated config for the reason recorded in the bottom-panel lane + below. +- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` §20 + Priority 1, which names this work explicitly; journey step 7's file half + goes from no surface to a surface; **adds no interaction island** — keys + are a mode-scoped keymap, and wdired will be a mode swap; adopts + `pmacs.config` for `dired.kill-when-opening`; inherits §9's + worker-attribution gap for its `read_dir` jobs without worsening it. The + audited claims this changes are updated in `COHERENCE.md` itself, per its + §25. +- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut + 1): CLI directory-argument handling (`pmacs .` exits 1) belongs there, + not here — Stage 1 does **not** fix it. The two meet at + `resolve_target_buffer`; dired supplies the buffer a directory should + resolve *to*, and `pmacs .` should route into it rather than growing a + second directory surface. + +## GPU terminal input lane — IN REVIEW + +- 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: + + | 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 | + +- 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) — Stage 1 MERGED; Stage 2 (GPU band) is next Stage 1 is on `main`; nothing in this arc is in flight. Stage 2 has **no diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index af0ac46..21352e9 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,8 +1,12 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-24, after bottom-panel Stage 1 (#155) landed, -following GPU initial-target (#148, protocol v20), -folding Stage 2 (#149) and its landed-doc refresh (#150), +**Last updated: 2026-07-25, after find-file (#162) landed — the dired +arc's Stage 0 — following 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 +initial-target (#148, protocol v20), +following folding Stage 2 (#149) and its landed-doc refresh (#150), web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math framing pair (#144/#145), folding Stage 1 (#142), one-command GPU invocation (#141), the documentation refresh (#140), Vterm Stage 3 (#135), tab-width rendering @@ -20,13 +24,58 @@ 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-24) +## 1. Where the project stands (2026-07-25) -- `main` @ `e745068` (bottom-panel Stage 1 #155 atop GPU initial-target #148, - folding Stage 2 landed-doc refresh #150, folding Stage 2 #149, ledger - refresh #147, web grammars #146, - LaTeX Stage 1 #144 / inline-math framing #145, and folding Stage 1 #142), - protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 = +- `main` @ `2af1ab3` (find-file #162 atop COHERENCE.md #163, Lean 4 Stage 1 + #160, minimap blank-slab #159, bottom-panel Stage 1 #155, inline-math + re-scout #154, vterm PTY-flake #153, and doc refresh #152). Protocol + unchanged at **v20**. The bullets below describe the arcs in their own + terms; this line is the head-of-`main` anchor. +- **`COHERENCE.md` is now required reading and a required framing input + — #163.** It carries the product-coherence thesis, an audited + scorecard, per-concern gaps, and §20's priority order, and it is the + 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). +- **find-file LANDED — #162** (`docs/dired-framing.md` §10, Q#DR11; merge + `2af1ab3`; one review round). `C-x C-f` is the dired arc's **Stage 0**: + pmacs previously had no discoverable way to open a file by path — no + such command existed and `pmacs.buffer.find_or_open` had no interactive + caller. Pure Lua in `builtin/commands/default.lua`, one keymap line, an + 8-test dispatch-driven acceptance suite; no Rust, no protocol change. + Two substrate facts it documents, both worth knowing before touching + any minibuffer prompt: + - **Completion over files is flat and cannot be made hierarchical from + Lua.** A custom `source` function is called with **zero arguments** + (`minibuffer.rs:591`) and runs synchronously outside any coroutine, + where `Handle:await()` raises — so it can neither see the input to + re-root on nor list a directory. Only the Rust + `CompletionSource::Files { root }` can list, and it is + single-directory and 1024-capped. + - **A selected candidate SHADOWS typed text.** `recompute_candidates` + sets `selected = Some(0)` whenever the list is non-empty + (`minibuffer.rs:372-377`) and `resolve_accepted_value` returns the + candidate over the typed contents (`:564-574`). So free-text accept + fires only when the input filters every candidate away — for + basename candidates under a subsequence filter, when it contains a + `/`. This applies to `M-x` and `switch-buffer` too. Consequences are + pinned as decisions, including the hole where a new bare name that is + a subsequence of an existing entry opens the existing file, and the + empty-input case (`fuzzy_score` gives `Some(0)` for an empty needle + and ties break lexicographically, so dotfiles lead). + - Also: `get_or_load_buffer` computes a normalized path but **loads + from the raw one** (`editor_core.rs:842-856`), so a `~/…` path dedups + 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). - **Bottom panel Stage 1 (window placement + TUI side windows) LANDED — @@ -729,6 +778,42 @@ final variant — its own round-trip cannot detect a discriminant shift. ## 5. Hard-won ops lessons +- **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 + 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. Each arm had a correct `old_size == size` + idempotence guard — **individually sound, jointly useless**, because each + saw only the size the other had just written. Write mutually exclusive + per-frontend-kind work as one `if`/`else` keyed on the same fact session + establishment uses, and extract the loop body so a test can drive the real + thing. +- **Bite against every pre-image the fix could plausibly have taken, not just + `main`.** For the same defect, the obvious one-line guard (skip the grid arm + for semantic frontends) *does* fix the storm — and silently introduces a + controller leak, because that arm was also the only per-tick + controller-liveness release a semantic frontend got. A single revert would + have scored the fix complete. The pin that catches it (`acc 6`) deliberately + **passes on `main`** and fails only against the naive guard: today's defect + supplies the release by the accident of running an arm it should not. +- **A quiet child is an instrument.** A frame storm is invisible against a + fixture that legitimately emits hundreds of frames, and an assertion like + `frames >= 2` cannot see one. The same applies to geometry: a + "did a frame at the new width arrive" readout is satisfied by a geometry + *oscillating through* that width. Assert upper bounds over a fixed window + against a child that produces nothing, and let the child self-report the + signal you care about (a `SIGWINCH` trap printing a **fresh distinct** + breadcrumb per signal — repeated identical markers paint nothing, because + `cell::diff` skips already-matching cells). +- **`TerminalMode::Raw` makes `sh`-based input fixtures useless.** There is no + `ICRNL`, so Enter delivers CR and a `read -r` loop waits forever for a LF + that never comes — the test then "proves" input never arrived. Use + `exec cat`, which copies stdin to stdout byte by byte. It is also the right + echo instrument for the opposite reason people assume: termios `ECHO` is + *off* in raw mode, so nothing double-echoes and one keystroke yields exactly + one cell. + - **The checkout may be shared with the user.** Check `git status` for foreign uncommitted work before any stash/checkout/branch surgery; never assume dirty files are yours. (Their uncommitted fix was nearly @@ -950,14 +1035,6 @@ setup (reader → editable → kernel execution) now has its JSON grammar prerequisite, but remains a real arc, not a one-shot. GPU: auto-reconnect after daemon restart, splits/multi-buffer, gutter riders (whitespace guides, folding, git markers). -Bottom panel (SHIPPED Stage 1, #155; full list in its framing "Deferred"): -left/right/top side windows, multiple slots per side, rehoming a leaf across -the tree, the whole `no_other_window` parameter, manual panel hide/show and -`window.toggle-panel`, `display-buffer-alist`-style user rules, panel -persistence (blocked on settings persistence), `OSC 22` pointer shape in the -TUI, per-panel statusline segments on the wire, proportional-font panels, -`window-configuration` registers, atomic windows, panel-local keymaps, and -horizontal (`C-x {`/`}`) resize. Themes (full list in theme-faces framing rev 9 "Deferred (named)"): popup/menu/dropdown bg + selected-row faces, `ui.background` / `ui.caret`, `ui.modeline.inactive`, minimap chrome, peer-cursor diff --git a/docs/dired-framing.md b/docs/dired-framing.md new file mode 100644 index 0000000..ada853e --- /dev/null +++ b/docs/dired-framing.md @@ -0,0 +1,1341 @@ +# 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.** +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 +**coherence impact** statement now required of every framing +(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5; rev 6 records what Stage +1's implementation falsified (§0, S1-1…S1-9); rev 7 adds what its first +review round found (§0, S1-10…S1-12). Deliberately +unnumbered: the roadmap's Arc 8 is GPU +structural parity but `docs/lean4-mode-framing.md` also claims Arc 8, so +the arc space is already forked in uncommitted work. (Rev 2 also cited +`docs/dap-debugging-framing.md` as part of that fork — wrong: its Arc 7 +*matches* the roadmap's Arc 7 = Debugging. R2-5.) Numbering this one +would mint a third claim; it is ranked when the roadmap is next +reconciled. Not on `docs/roadmap-2026-07.md` and not in +`docs/side-quest-backlog.md` — this framing proposes the work as well as +its design. + +## 0. Revision history + +### Round 1 (rev 1 → rev 2) + +- **F1 (load-bearing).** §3's rationale for freezing the M8 fixture was + **false**. Rev 1 claimed the 47 tests pin the package system — + `install_local`, `on_unload` unregistration, `DuplicateName`, per-package + `require` scoping. Verified: `install_local` appears only in the shared + `editor_with_dired()` harness and in doc comments, never in an + assertion; `on_unload`, `DuplicateName`, and require-scoping are + asserted **nowhere** in either file; `dired_source_size_under_audit_ceiling` + is a `lines < 1500` lint. Exactly **one** of 47 + (`dired_package_reload_is_safe_after_init_complete`, + `m8_2_acceptance.rs:1190`) exercises package mechanics; the other 45 + assert dired/wdired behavior. §3 rewritten on honest grounds; the + "shrink the fixture" follow-up repositioned from *if drift appears* to + **scheduled after Stage 3**, because it is now known to be cheap. +- **F2 (load-bearing).** `RET` must not use bare `find_or_open`. + `window_panel.rs:373-376` documents why: `find_or_open` "switches the + ACTIVE window in both branches before firing hooks, so a visit to a + previously unopened file would replace a focused panel." A `RET` in a + panel-displayed dired would swallow the panel. Visits now route through + `pmacs.window.display_file` (new Q#DR10), which also dedups by + *normalized* path. §2 gains the window-primitive ground truth it was + missing entirely, and the `dired` command gains the `display` opt that + acceptance 11 was already testing without. +- **F3 (load-bearing).** Stage 0's completion mechanism does not work as + described. A function source is re-called per keystroke but invoked as + `f.call(())` — **zero arguments** (`src/minibuffer.rs:591`) — and the + callback runs synchronously from Rust dispatch, outside any + `pmacs.async` coroutine, where `Handle:await()` raises + (`async.lua:76-79`). There is no synchronous directory listing in Lua, + so a function source **cannot descend into directories**. Stage 0 + rewritten around the existing Rust `CompletionSource::Files` + (`minibuffer.rs:589`) plus free-text accept; hierarchical completion is + a named deferral (new Q#DR11). Rev 1's "a file you have never opened is + unreachable" is corrected to *undiscoverable and uncompleted* — recentf's + prompt already passes free text to `find_or_open`. +- **F4 (design gap).** Q#DR5(a) as specced did not cover dired's own hard + case. `apply_resource_op`'s rebind uses `find_by_path` + (`buffer_registry.rs:168-174`) — **exact `Path` equality, first match + only** — so a *directory* rename strands every buffer beneath it, and + `R` on a directory line is an ordinary dired operation. Worse, that arm + looks up with the **raw** path (`mod.rs:3248`) while stored paths are + normalized on write (`EditorCore::set_buffer_path`, `editor_core.rs:819`) + and the normalizing wrapper `find_buffer_for_path` (`:864-867`) exists and + is bypassed — so a non-normalized old path silently fails to match today. + Q#DR5 widened to include rebind *semantics*, with new evidence: + **`pmacs.fs.rename` has zero production callers**, so changing the + primitive's contract breaks nobody. +- **F5 (spec fix).** Non-UTF-8 **symlink targets** are fatal too + (`fs.rs:227`), and differ in kind from names: the entry's own name is + fine and nothing needs to pass a target back through `rename`. Tolerant + mode now carries `readlink` failures and target-encoding failures in the + per-entry channel; only **names** stay fatal. +- **F6 / F7 (Q#DR2 gaps).** Name-keyed dedup needs a canonical path form + (`/tmp`, `/tmp/`, `/tmp/../tmp` would mint three buffers), and + found-by-name must verify **dired ownership** before painting into a + buffer through `bypass_intercept`. Both folded into Q#DR2. +- **Minors.** Arc number dropped; `C-x C-r` attributed to `recentf.lua:85` + rather than the default keymap; the tolerant opt must **validate** + unknown keys (`supersede_key`, `fs.lua:73-83`, silently ignores them, so + a typo'd `tolerant` would degrade to fatal mode unnoticed); acceptance 13 + carries the fixture's macOS ignore gate (`m8_2_acceptance.rs:211-213`); + and §8 footnotes one per-entry failure that is *already* tolerated — + a failing `metadata.modified()` yields mtime 0 rather than an error + (`fs.rs:463-476`). + +### Round 2 (rev 2 → rev 3) + +- **R2-1.** Q#DR5's "at reply-settle time" named no seam, and the obvious + one is wrong. The fs ops are fire-and-forget-capable — nothing obliges a + caller to `await` or attach `on_complete` — so a rebind implemented + where results are *consumed* (`_take_result`, `mod.rs:6758`) misses any + rename whose handle is never taken: the rename lands on disk and the + buffer is never rebound. The trap survives one layer down, and an + acceptance that awaits would pass while the fire-and-forget path stayed + broken (the pin-through-the-real-path class again). §7 now names the + **main-thread completion drain** `AsyncRuntime::tick` + (`async_runtime.rs:991`) as the seam, unconditional on success — plus a + fact that makes the implementation non-obvious: **rename settles as an + undifferentiated `ReplyKind::FsUnit`**, the same reply chmod and remove + produce (`:1022-1025` maps `Sleep | FsUnit` alike to `JobResult::Unit`; + there is no `Rename` variant). The drain therefore cannot key on the + reply — it must key on the pending job's own `JobKind::FsRename`, and + the job must **retain from/to** so the paths exist at settle. Stage 2's + acceptance includes a **no-await** rename. +- **R2-2.** The `errors` row shape cannot always carry a name. A per-entry + `readdir` iterator error (`fs.rs:215-218`) has no filename — the entry + never materialized, and the error is wrapped with the *parent* path. §8 + makes `name` optional for that arm; the footer counts it without naming + it. +- **R2-3.** §2's `pmacs.window` inventory listed five exports; there are + **eight** — also `display_target` (`:425`), `panel` (`:440`), and + `set_params` (`:544`). The omission was the relevant one: + `display_target` is "the non-side window a visit from a panel should + address", i.e. the mechanism behind `display_file`'s panel-safety. §9's + loosest sentence — directory descent in a panel-displayed dired, left as + "(or `display`, when dired was itself panel-displayed)" — is now + specified, with the dedication question answered. +- **R2-4.** The Lua mirror of `normalize_buffer_path` is a second + implementation of a canonical form — the tab-width-constants class in + miniature. If the mirror and the Rust normalizer disagree on an edge + (`//tmp`, `~` with `HOME` unset, root's trailing slash), dired's + name-dedup and `display_file`'s `find_buffer_for_path` dedup diverge + **silently**: two buffers, no error. §4 now carries a parity obligation, + and records that the "binding whose only caller is dired" argument + undercounts — Q#DR5's fix touches the same normalizer, so a Lua-exposed + canonicalize has at least two consumers by Stage 2. +- **R2-5.** Header nit: only Lean 4 forks the arc numbering; DAP's Arc 7 + matches the roadmap. Conclusion unchanged, evidence corrected. +- **R2-6.** Stage 0 was "recommended first" with no acceptance and no + ruling on nonexistent paths. `display_file` routes through + `resolve_target_buffer` (`editor_core.rs:885-898`), which on + `ErrorKind::NotFound` **creates** the buffer, binds the path, and sets + status `"[new file]"` — Emacs parity, now stated rather than inherited. + §14 gains four Stage 0 acceptance items. + +### Stage 0 implementation notes (rev 3 → rev 4) + +Implementing Stage 0 falsified one thing the approved text asserted, and +the correction belongs here rather than only in the code. + +- **S0-1. Flat completion and free-text accept do not compose the way + Q#DR11 described.** Rev 3 said Stage 0 is "the flat Rust `Files` source + rooted at the current buffer's directory, plus free-text accept", as + though the two were independent and always both available. They are + not: `recompute_candidates` sets `selected = Some(0)` **whenever the + candidate list is non-empty** (`minibuffer.rs:372-377`), and + `resolve_accepted_value` (`:564-574`) returns the **selected candidate** + in preference to the typed contents. So typed text reaches `on_accept` + **only when the input filters every candidate away** — which, since + candidates are bare basenames and the filter is a case-insensitive + subsequence match, means *when the input contains a `/`*. + Consequences, all now pinned by `tests/find_file_acceptance.rs`: + - the deeper-path case works (`sub/inner.txt` matches no basename, so + it arrives verbatim) — which is what acceptance 0b actually tests; + - the new-file case works **only for names containing a separator**, so + acceptance 0c uses one; + - and there is a genuine hole: typing a **new bare name that is a + subsequence of an existing entry** opens the existing file instead of + creating the new one. `find_file_selected_candidate_shadows_typed_text` + pins that as a decision rather than an accident. + + Closing the hole needs a Rust change to accept semantics — prefer typed + text over the selection when the two differ and the user has not + explicitly moved the selection — which would change `M-x` and + `switch-buffer` too, and so is deliberately **not** made in Stage 0. It + joins the hierarchical-completion deferral in §13. +- **S0-4. Accepting on empty input opens the first-sorted candidate.** + A consequence of S0-1 with an empty needle: `fuzzy_score` returns + `Some(0)` for every entry (`minibuffer.rs:637-640`) and + `filter_and_sort` breaks the resulting tie lexicographically (`:678`), + so an immediate RET opens whatever sorts first — dotfiles lead, and a + directory can lead, in which case the open fails and reports (S0-6). + `M-x` and `switch-buffer` share the mechanism, so this is inherited + rather than introduced; it is documented at the command and listed in + §13 beside the accept-semantics fix that would close it. +- **S0-5. Minibuffer history stores the pre-join value.** `accept` + pushes the resolved value into the history bucket **before** `on_accept` + joins it onto the root, so a `C-p` recall of a root-relative entry + under a different root resolves somewhere else. Rust-side, so Stage 0 + cannot fix it; §13. +- **S0-6. The failure arm is a real path and is pinned.** Accepting a + *directory* candidate reaches `display_file`, whose load fails + (`File::open` on a directory succeeds; the read returns EISDIR), so the + command's `pcall` turns it into a status message instead of letting the + error escape mid-dispatch. + `find_file_accepting_a_directory_reports_instead_of_raising` pins it + through the real accept path and fails when the `pcall` is removed. +- **S0-2. The prompt field must start empty.** Emacs prefills find-file's + field with the directory. Here any prefill contains a `/`, which by + S0-1 filters every candidate away and silently disables completion — + so the root is named in the *prompt string* instead, and the empty + field is pinned by acceptance 0d. +- **S0-3. A leading `~` must be expanded before the path reaches the + core.** `get_or_load_buffer` (`editor_core.rs:842-856`) computes a + normalized path but calls `load_file` with the **raw** one (`:847`), so + a `~/…` path deduplicates against an already-open buffer (dedup goes + through the normalizing `find_buffer_for_path`) yet fails to load a + file that is not open yet. Stage 0 expands the tilde in Lua, which + makes both halves agree without changing core load semantics for the + CLI, LSP, and bootstrap callers. **Using the normalized path for the + load is the better fix and is now a named deferral** (§13) — it is the + same normalize-before-lookup family as Q#DR5's `apply_resource_op` + correction. + +### Stage 1 implementation notes (rev 5 → rev 6) + +Implementing Stage 1 (PR #165) falsified four things the approved text +asserted and settled five it left open. Recorded here rather than only +in the code, per the rev-4 precedent. + +- **S1-1. The normalizer is EXPOSED, not mirrored — so B2 is partly + false, in the direction Q#DR2 preferred.** Q#DR2 made the mirror + conditional (`Stage 1 may still mirror if exposure turns out to drag + in EditorCore borrow plumbing it does not otherwise need`). + `normalize_buffer_path` is a **free function** (`editor_core.rs`), so + exposure drags in nothing: it is now `pub` and reachable as + `pmacs.path.canonicalize`. Consequences, all deliberate: B2 ("tolerant + `read_dir` is the only Rust change Stage 1 needs") is false by one + small binding; acceptance 3b degenerates to the round-trip form the + framing described; and the Stage 2 mirror-removal follow-up **is not + owed** — there is no second canonical form to remove. The parity + acceptance is still carried, now as "the Lua binding and the Rust + function agree over one shared edge list", which is exactly the claim + a future re-mirroring would break. +- **S1-2. R2-3's dedication claim is falsified by the substrate.** It + read "a dedicated dired panel stays dedicated across descent and the + new dired buffer inherits it". `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 outright. Dired therefore does + **not** try to unpin the user's panel — which is also what Emacs's + `display-buffer` does with a dedicated window. Acceptance 3c is split: + a non-dedicated panel keeps the descent, and a dedicated one keeps its + buffer *and* its pin while the new directory appears in the document + window. +- **S1-3. Acceptance 3c cannot pin the descent ROUTING, and the test now + says so.** Dired holds the focus in its own panel, so a raw + `switch_buffer` lands in that same window and every 3c assertion holds + either way — the mutation is *vacuous* against it. Dedication is the + only thing that distinguishes `display { side = … }` from the raw + switch, so the dedicated-panel test is the discriminating pin. Found + by running the bite rather than by reading the test; the vacuity is + documented at the assertion instead of being left to be believed. +- **S1-4. Dired is the first builtin to bind a mode-scoped key, and one + pre-existing lib test assumed none existed.** + `describe_key_identifies_every_default_binding` iterated *every* + binding in the stack and asserted `pmacs.describe.key` resolves it + 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 a global one, because a mode left over from a previous + iteration legitimately shadows a global chord of the same name + (dired's `RET` shadows `edit.newline-and-indent`, which is the point + of the mode). +- **S1-5. `C-x d` deliberately takes NO completion source.** It is the + direct consequence of S0-1/S0-4: with a `files` source, RET on an + empty field opens whatever sorts first (the minibuffer selects + candidate 0 whenever the list is non-empty, and a selected candidate + shadows typed text), and RET-on-the-directory-you-are-in is exactly + the gesture `C-x d` exists for. The field is **prefilled** with the + current directory instead — Emacs's own shape here — and free text + always reaches `on_accept` because `CompletionSource::None` bypasses + candidate resolution entirely. Directory-name completion is what dired + itself replaces. +- **S1-6. Ownership is the handle table ALONE**, narrower than Q#DR2's + "present in dired's handle table, or `major_mode(buf) == "dired"`". A + foreign buffer that carries the mode *is* the case the check exists to + refuse, and a builtin's handle table cannot be lost the way a + reloadable package's can. Acceptance 4 sets the mode on the foreign + buffer to pin the stronger reading. +- **S1-7. The mark column ships in Stage 1, rendered blank.** Q#DR4 is a + Stage 2 decision, but reserving the two columns now means Stage 2 does + not move every offset and Stage 3's column-classifying intercept can + be written against constants that did not shift under it. The + constants are computed from the widths (the fixture hardcoded + `NAME_START = 39` and paid for it in every wdired test) and exported + as `pmacs.dired._layout` so acceptance cannot drift from them. +- **S1-8. A symlinked directory needs a probe, because kinds are + lstat-based.** Both `read_dir` and `stat` report a link as + `"symlink"`, so nothing in the entry says whether it points at a + directory. `RET` on a symlink therefore *tries* to list the target + (one extra syscall, on symlink lines only) and descends if that + succeeds, else visits it as a file. Q#DR10 specified only the + dir/file arms; this is the third. +- **S1-9. Interactive origin does not survive the await.** Every listing + is worker-dispatched, so the work after the first `:await()` resumes + inside `tick_async`, where `InteractiveCommandOrigin` is empty and + `pmacs.window.*` falls back to the **ambient** active frontend. Single + frontend: correct. Multi-frontend: a dired opened from peer B while A + is ambient would display for A. Not fixable from Lua (the display + surface takes no frontend argument) and named here rather than + discovered later. + +### Stage 1 review round 1 (rev 6 → rev 7) + +Three findings changed behavior; the rest were naming and comments. Each +fix is bite-verified against the test that names it. + +- **S1-10. An ambient re-seat is not safe after an await.** `dired.revert` + painted its own buffer by name (safe) and then re-seated through + `pmacs.editor.move_to_line`, which moves whatever window is + **active** — so a user who switched buffers while the re-read was in + flight had an unrelated buffer's cursor moved to a line index + meaningful only in the dired listing. This is the buffer-level instance + of the hazard S1-9 named at the frontend level, and it generalizes: in + this codebase, *painting takes a buffer and seating takes the world*. + Any post-await cursor operation needs an active-buffer guard; + `open_directory` is exempt only because it displays the buffer first. +- **S1-11. The rendered columns are a contract, so precision yields to + width.** `%10d` overflowed at 10 GB (VM images, core dumps), widening + the size field and shifting mtime and name right on that line alone. + Cosmetically harmless today, but `_layout` is exported and Stage 3's + column-classifying intercept is planned against it, so a + contract-violating line now is a Stage 3 trap. `fmt_size` took + `fmt_mtime`'s shape: exact bytes while they fit, else a fixed-width + magnitude. Not the deferred human-readable column (§13) — the exact + count still renders right up to the point where it cannot. +- **S1-12. `open_directory`'s "changed nothing on failure" invariant is + reusable as a PROBE.** S1-8's symlink descent originally listed the + target to learn its kind and then opened it — two full listings of the + same directory. Because a failed open touches no editor state + (acceptance 15), the open itself is the probe: try the descent, fall + back to `display_file`. One read. The comment that claimed "one + syscall" for a full `read_dir` is corrected rather than left as a + cost claim nobody would re-check. + +Also, on the tolerant channel (Q#DR6): a `readdir` iterator may keep +yielding errors without terminating, and **cancellation is not a backstop +for a dired listing** — it carries no supersede key, so nothing cancels +it. A consecutive-error cap now fails the listing the way an unopenable +directory fails, rather than accumulating error rows on a worker thread. +It is deliberately untested: faking a failing iterator would need the +walk generic over it, a refactor with no other consumer. + +## 0.5. Coherence impact (`COHERENCE.md` §20) + +Required of every framing since #163. This arc was scouted and approved +before that rule existed; the statement is added here rather than +backfilled silently. + +**Section served: §20 Priority 1 — protect the golden product journey**, +which already names this work: *"a find-file surface (in flight, PR +#162)"* and *"directory-argument handling"*. Secondary: §5 (unify +discoverability) and §14 (coherent workbench primitives). + +**Journey steps touched (§2).** + +- **Step 7, "find a symbol or file"** — the file half, which had no + surface at all. Stage 0 (`C-x C-f`, merged as #162) covers opening a + known path; Stages 1–3 cover browsing, which is the half a user + reaches for when they do *not* already know the path. +- **Step 3, "open a real project"** — partially, and the boundary + matters. §2's ground truth grades the journey *broken at step 3* + because `pmacs .` exits 1: `load_file` (`src/file_io.rs:81-87`) 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 and the error escapes. + **That is the same mechanism Stage 0 pinned** in + `find_file_accepting_a_directory_reports_instead_of_raising` — where + the `pcall` turns it into a status message instead. Dired Stage 1 is + what makes a directory *open into something* rather than merely fail + politely. + **Boundary with the adjacent arc:** §20's arc-cut list puts CLI + directory-argument handling in "Journey Stage 1", noted as riding + alongside this arc. The two meet at `resolve_target_buffer`. This + framing does **not** claim the CLI path; it supplies the buffer a + directory should resolve *to*, and Journey Stage 1 should route + `pmacs .` into it rather than inventing a second directory surface. +- **Step 4, "understand the visible interface"** — marginally, via the + `dired` major mode showing in the statusline (Q#DR8). +- Steps 1–2, 5–6, 8–12: untouched. + +**Interaction islands added (§6): none — deliberately.** §6 grades this +area "weak, and growing by one island per modal feature", with every +modal surface funnelling through `EditorInstance::dispatch_key`'s +precedence machine. Dired adds no Rust-level interception: its keys are +an ordinary **mode-scoped keymap** through the existing `pmacs.keymap` +registry (Q#DR8), so they are introspectable by `describe.key` and +rebindable like any other binding. Stage 3's wdired is a **major-mode +swap**, not a modal layer — which is the reason Q#DR3 chose a mode swap +over an edit-mode flag. Two existing islands are *consumed* (the +minibuffer prompt for `C-x d`, and Stage 0's), neither added by this +arc. This arc therefore moves §6's count sideways, not up. + +**Config registry adoption (§11): yes.** `dired.kill-when-opening` is +defined through `pmacs.config` with a type, default, and +`mutability = "live"` (Q#DR2), not a bare Lua global — matching the +#127 adopters. Sort mode is deliberately *not* a setting in Stage 1: it +is per-buffer session state, and promoting it would need the +buffer-local scope plus a persistence story the registry does not have +yet (its own named deferral). + +**Background-work attribution (§9): inherited debt, not fixed here.** +Every listing runs as a `pmacs.fs.read_dir` worker job, and those jobs +carry no owner or purpose — §9's gap. A dired refresh will therefore +show up in the activity planes exactly as anonymously as every other fs +job does today. Stage 1 does not fix that and does not make it worse; +when §20's "worker identity" arc lands, dired's jobs are ordinary +consumers of it. Naming it here so the debt is visible rather than +silently compounded (§1.3). + +**Net.** One journey step goes from *no surface* to *a surface*; one +more moves from *fails* toward *resolves*; no island added; one setting +enters the registry; one attribution gap inherited and named. + +## 1. Problem and what ships + +Two separate facts collide here, and the second is why this is worth more +than "a file browser would be nice". + +**Fact one: a complete dired already exists, and ships to nobody.** +`tests/fixtures/pmacs-dired/init.lua` is 1,384 lines of Lua implementing +the read-only directory view (T M8.2) and the wdired editable +rename/chmod layer (T M8.3), pinned by **47 acceptance tests** (15 in +`tests/m8_2_acceptance.rs`, 32 in `tests/m8_3_acceptance.rs`). It was +built as one of M8's three "universality proof" packages — the evidence +that a buffer can be a projection of external state. It lives under +`tests/fixtures/`, and `rg pmacs-dired` outside `tests/` returns zero +hits. Nothing installs it; no user can reach it. + +**Fact two: pmacs has no discoverable way to open a file by path.** There +is no `find-file` command and no `C-x C-f` binding. The complete list of +builtin command names contains nothing matching `file` or `open`; +`pmacs.buffer.find_or_open` (`src/lua_bindings/mod.rs:3104`) is a Lua API +with no interactive caller of its own. A file enters a session via the +CLI (`pmacs FILE`, `pmacs --gpu FILE`), an LSP jump, a project-search +visit, or `C-x C-r` recent-files — whose prompt *does* pass free text +through to `find_or_open` (`recentf.lua:74-80`), so an arbitrary path is +technically reachable, but only by typing it blind into a prompt labelled +"Recent file:" with no completion and no discoverability. +`editor.switch-buffer` (`builtin/commands/default.lua:594`) completes over +*already-open buffers* and reports `no buffer: ` for anything else. + +So dired is not a convenience rider on an existing file surface. **Dired +is the file surface.** That reframes both its value and its risk: it is +the first thing a new user needs, and the last place we can afford a +listing that refuses to render. + +**What ships**, staged (§10): + +- **Stage 1 — the dired view.** A builtin `builtin/runtime/dired.lua`: + read-only listing, navigation, `RET` to visit (files open through + `display_file`, directories descend), sort modes, revert, quit, + `C-x d` / `C-x C-j`, a `dired` major mode with mode-scoped keys, cursor + preservation across refresh — plus the one Rust change Stage 1 needs, a + **per-entry-tolerant `read_dir`** (Q#DR6). +- **Stage 2 — marks and operations.** `m`/`u`/`U`/`t`, deletion flags + `d`/`x`, immediate `D`, `R` rename, `C` copy, `+` mkdir — and the three + filesystem primitives that do not exist yet, plus the rename/rebind fix. +- **Stage 3 — wdired.** The editable layer, carrying over the fixture's + hard-won commit logic. + +`find-file` itself (`C-x C-f`) is separable and is Stage 0 (§10). + +## 2. Ground truth (scouted 2026-07-25, `main` @ `e745068`; verified across review rounds 1 and 2; re-verified against `main` @ `0827dd1`) + +**Base note.** `main` moved from `e745068` to `0827dd1` (Lean 4 Stage 1, +#160) between the scout and approval. The diff touches exactly one file +this framing cites — `builtin/runtime/syntax.lua`, which gained a +`lean = "lean4"` modeline alias — and nothing else in the ground truth +below. The only consequence is a line drift: `set_major_mode` is now +`syntax.lua:497`, not `:492`. Every other citation is unchanged. + +### The existing fixture + +- `tests/fixtures/pmacs-dired/init.lua` (1,384 lines) defines eight + commands: `open-line`, `parent`, `sort-name`, `sort-mtime`, + `sort-size`, `wdired-edit`, `wdired-abandon`, `wdired-commit`. It binds + `RET` and `Backspace` **buffer-locally** at open (`:381-388`), paints by + wholesale `buf:replace` behind a `painting` passthrough flag + (`:294-302`), and keys per-buffer handles by linear scan over + `BufferIdLua.__eq` with a liveness compaction (`:53-67`). +- Its wdired layer is the valuable part and is not naive: a + column-classifying `intercept_edit` (`:640-712`), fixed-width perms with + positional validation (`:520-542`), `\\`/`\n`/`\r`/`\t`/`\xNN` filename + escaping with an **exact inverse** so a no-op commit cannot fire a + spurious rename (`:140-211`), field-by-field external-change detection + including `mtime_nsec` (`:854-902`), duplicate-final-name rejection + before any syscall, and a **two-phase rename through unique temp names** + so swaps and chains commit safely (`:1176-1216`). +- **What the 47 tests actually assert (F1).** 45 assert dired/wdired + *behavior*: rendered listing shape, sort order, escaping round-trips, + intercept column rejection, on-disk chmod/rename effects, the two-phase + swap, external-change detection, partial-application reporting. One + (`m8_2:1190`) exercises package mechanics — reload safety after + `set_init_complete`. One (`m8_2:1245`) is a source-line-count lint. + `install_local` appears only in the shared `editor_with_dired()` harness + as a setup precondition and in doc comments; `on_unload`, + `DuplicateName`, and per-package `require` scoping are asserted nowhere. +- **Two of its own stated limitations are now false.** + - `open-line` on a non-directory errors with "requires the + buffer-from-file API (not yet exposed)" (`:948-961`). + `pmacs.buffer.from_file` (`mod.rs:3054`) and `find_or_open` (`:3104`) + both exist and ship. + - The test seam claims "the v0.1 buffer surface doesn't expose + move_to_byte yet, so tests can't reliably position the cursor" + (`:1355`). `pmacs.editor.goto_byte` (`mod.rs:12765`) and + `move_to_line` (`:12526`) landed with editops (#111). +- **A real defect in its model:** navigation mutates `handle.path` and + repaints, but the buffer was named `*dired:*` at creation and + **there is no `pmacs.buffer.set_name`** — the `pmacs.buffer` table + exports exactly `create`, `from_bytes`, `from_file`, `find_or_open`, + `list`, `kill`, `remove`, `on_removed`, `major_mode`, `set_major_mode`, + `set_round_trip_input`, `mark_create`, `add_intercept`, + `remove_intercept`, `apply_resource_op`, and the style-overlay family. + (`Buffer::set_name` exists Rust-side, unexposed.) So after one `RET` the + buffer name names a directory it is no longer showing. Q#DR2 answers this. + +### The filesystem surface + +- `pmacs.fs` is exactly five worker-dispatched ops — `read_dir`, `stat`, + `rename`, `chmod`, `remove` (the complete `_dispatch_fs_*` set) — plus a + Lua-side polling `fs.watch` (`builtin/runtime/fs.lua:226`). **No + `mkdir`, no `copy`, no symlink-create, no recursive remove.** +- **`read_dir` is all-or-nothing, and this is the load-bearing gap.** + `read_dir_blocking` (`src/fs.rs:201`) returns + `Result, FsError>`. **Five** per-entry conditions fail the + **entire listing**: a per-entry `readdir` error, a failed + `symlink_metadata`, a failed `read_link` (`:228-234`), a non-UTF-8 + symlink target (`:227`), and a non-UTF-8 name (`:238`). The module doc + acknowledges the shape and says "dired-class will likely want a + per-entry-tolerant wrapper but that's the package's job, not the + primitive's" (`:196-200`) — **that wrapper cannot be written in Lua.** + The primitive hands Lua one structured error and no partial vec; there is + nothing to be tolerant *with*. Three concrete failure modes, all + ordinary: + 1. a directory readable but not searchable (`r` without `x`) — `readdir` + succeeds, every child `lstat` fails; + 2. a file unlinked between `readdir` and `lstat` — ENOENT, i.e. a plain + refresh of a busy directory (`/tmp`, a build tree) can just fail; + 3. any single non-UTF-8 filename or symlink target in the directory. + One per-entry failure is *already* tolerated: a failing + `metadata.modified()` yields mtime 0 rather than an error (`:463-476`). +- `read_dir` already takes `(path, opts)` and the opts parser + (`supersede_key`, `fs.lua:73-83`) reads only `opts.supersede` and + **silently ignores unknown keys** — signature-natural for Q#DR6's opt, + but a typo'd `tolerant` would degrade to fatal mode unnoticed. +- `chmod` **follows symlinks** (`src/fs.rs:370`; `fs.lua:104`) while + `read_dir`/`stat` use `lstat`. The fixture rejects symlink perms edits + at intercept time for exactly this reason (`init.lua:629-638`) — that + decision carries over unchanged. +- **`pmacs.fs.rename` has zero production callers** — only its own + definition (`fs.lua:126`), `m8_1`/`m8_3` acceptance, and the fixture. +- `pmacs.fs.rename` does **not** rebind an open buffer's path. + `pmacs.buffer.apply_resource_op` (`mod.rs:3206`) — the LSP + workspace-edit applier — does, but by **exact first match**: its + `"rename"` arm calls `reg.borrow().find_by_path(&from)` (`:3248`), which + is exact `Path` equality over insertion order + (`buffer_registry.rs:168-174`). It also uses the **raw** path while + stored paths are normalized on write + (`EditorCore::set_buffer_path`, `editor_core.rs:819`) and the normalizing + lookup `find_buffer_for_path` (`:864-867`) exists and is bypassed. So the + model rev 1 proposed copying is itself subtly wrong, and has no prefix + rebind anywhere. `apply_resource_op` is also **synchronous and blocking + on the main thread**, unlike every `pmacs.fs` op. Q#DR5. + +### Windows, panels, and how anything gets displayed + +- `pmacs.window` exports **eight** functions: `display` + (`window_panel.rs:356`), `display_file` (`:379`), `display_target` + (`:425`), `panel` (`:440`), `quit` (`:453`), `params` (`:499`), + `set_params` (`:544`), and `resize` (`:591`) — alongside the pre-arc + `switch_buffer`. **`display_target` is "the non-side window a visit from + a panel should address"**, i.e. the mechanism that makes `display_file` + panel-safe; it is what Q#DR10 rests on, and it is the reason a file + visit and a directory descent take different routes (§9). +- **`find_or_open` is panel-hostile, by documented design.** + `window_panel.rs:373-376`: `find_or_open` "switches the ACTIVE window in + both branches before firing hooks, so a visit to a previously unopened + file would replace a focused panel before any display policy could + help." `display_file` is the Q#BP11b answer — a side-effect-free dedup + via the **normalizing** `find_buffer_for_path` *before* any I/O, then + destination resolution before the read, so a dedicated origin cannot + force load-before-failure. LSP visits and compile already route through + it. +- `pmacs.listview` (`builtin/runtime/listview.lua`) implements the + disciplines a read-only panel needs: a read-only `add_intercept` + (`:101-104`), `set_round_trip_input` (`:106`), a buffer-local keymap, a + line→item map, `q`-restores-previous, and the `display = "current" | + "panel"` opt-in (`:132-142`). **But** its keymap is a fixed set — + `RET`/`SPC`/`n`/`p`/`g`/`q` (`:76-88`) — with no extension point, and + its read-only intercept is installed once at panel creation and **never + removed** (`:101`), which wdired requires. Three panels already depend + on this module (references, outline, project-search). +- `set_round_trip_input` (`mod.rs:3085`) is what makes single-key bindings + work on a semantic/GPU frontend: while a marked buffer is active + `dispatch_idle` reports false, so optimistic-apply stays off and `d` + reaches the binding instead of landing as a CRDT insert. The same gate + (`dispatch_idle_for`, `editor.rs:818`) *also* disables optimistic apply + for any focused side window (`!window.is_side()`, Q#BP14a), so a + panel-displayed dired is covered twice. +- **There is no real `read_only` buffer flag** — a standing backlog item + (`docs/side-quest-backlog.md`, cross-cutting substrate). The intercept + idiom is what every generated buffer uses today. + +### Minibuffer completion + +- A `source` **function** is re-called on every keystroke + (`recompute_candidates`, `minibuffer.rs:361`) but invoked as + `f.call(())` — **zero arguments** (`:591`). `pmacs.minibuffer.contents()` + exists, but the callback runs synchronously from Rust dispatch, outside + any `pmacs.async` coroutine, and `Handle:await()` explicitly raises + there (`async.lua:76-79`). There is **no synchronous directory listing in + Lua**, so a function source cannot descend into directories. +- `CompletionSource::Files { root }` exists Rust-side + (`minibuffer.rs:589` → `list_directory(root)`), reached from Lua as + `source = "files"` with `source_root` (`mod.rs:13286`). It is flat, + single-directory, capped at 1024 candidates, and **currently used by + nothing outside a unit test**. +- Free text accepts: `resolve_accepted_value` (`minibuffer.rs:564`) + returns the raw typed string when no candidate is selected. + +### Buffers, modes, keys + +- Mode-scoped keymaps exist since #129: `pmacs.keymap.bind { scope = + "mode", mode = "", … }` (`mod.rs:13432`), resolving buffer-local → + mode → global. `syntax.lua:497` is the **only** `set_major_mode` caller + in `builtin/`, firing on `buffer.after-load`; a `buffer.create`d dired + buffer has no path and fires no `after-load`, so nothing contends. +- `find_or_open` on a directory reaches `file_io::load_file` → EISDIR. + Dired must dispatch on `entry.kind` itself. +- **Keybinding space.** `C-x d`, `C-x C-j`, `C-x C-f`, and `C-x C-q` are + unbound **repo-wide**. `C-x C-r` is bound to recent-files in + `recentf.lua:85` (not the default keymap). `C-c ` is fully taken + by LSP (`lsp.lua:2248-2256`); `C-c @` is the folding prefix + (`fold.lua:48-52`); `C-c C-k` is bound **buffer-locally** by compile + (`compile.lua:231`) and async, not globally. + +## 3. Where dired lives (Q#DR1) + +**A builtin runtime module, `builtin/runtime/dired.lua`, written fresh. +The M8 fixture stays exactly where it is, frozen.** + +Builtin rather than a package, because a surface that is the primary way +to open a file cannot be gated on the user installing something, and +because builtin modules get the load-order and config-registry guarantees +a package does not (the `pair.lua`-before-`lsp.lua` precedent). + +**Why the fixture is not promoted.** Rev 1 argued the 47 tests pin the +package system and would be voided by re-pointing. That was false (F1) — +45 of them assert dired behavior, and behavior transfers. The honest +argument is narrower and rests on three things: + +1. **The harness route is itself the proof.** 46 of 47 tests reach dired + through `install_local` + `require`, and that *routing* — a third-party + package, in its own environment, driving buffers, intercepts, marks, + commands, and keymaps — is the M8 universality claim. A builtin loaded + by the runtime demonstrates nothing about packages. Re-pointing the + suites keeps every behavioral assertion and silently deletes the claim + they were written to support. +2. **The builtin diverges structurally**, so the tests cannot transfer + verbatim anyway: the mark column (Q#DR4) shifts every column offset the + wdired tests hardcode through `_test.NAME_START`; mode-scoped keys + (Q#DR8) replace the buffer-local `RET`/`Backspace` binds + `dired_ret_and_backspace_keybindings_navigate` drives; + buffer-per-directory (Q#DR2) replaces the in-place repaint that + `dired_parent_command_navigates_up_one_level` asserts; and the whole + `M._test` seam is package-shaped. +3. **The behavior gets re-pinned regardless**, by the builtin's own + acceptance (§14), which is where those 45 assertions are owed a home. + +The cost is real: roughly 900 lines of rendering, escaping, and commit +logic will exist in two places. The mitigation is that the fixture is +**frozen** — a proof artifact, not a maintained feature, already fully +pinned. + +**Named follow-up, scheduled rather than conditional (F1 corollary).** +Because the fixture's package-system value concentrates in exactly one +test plus the harness routing, shrinking it to the minimum payload that +still proves universality is cheap — far cheaper than rev 1 implied. It +is scheduled **after Stage 3**, when the builtin owns every behavior the +45 tests currently cover, not left to "if drift shows up". + +The fixture remains a *reference* for the parts that were hard — +escaping with an exact inverse, two-phase rename, external-change +detection, the symlink rules — and those carry over as decided design, +not as re-litigated questions. + +## 4. Buffer model and navigation (Q#DR2) + +**One buffer per directory, found-or-created by canonical name; +navigation opens the target's buffer rather than mutating the current +one.** + +This is Emacs's actual behavior (`dired-find-file` on a directory yields a +dired buffer for that directory), and it answers the fixture's stale +buffer-name defect without adding a `pmacs.buffer.set_name` binding: +nothing is ever renamed, because a buffer's name always describes the +directory it was created for. It also makes `C-x d` on an +already-visited directory and `C-x C-j` dedup for free. + +**Canonicalization (F6).** `/tmp`, `/tmp/`, and `/tmp/../tmp` are all +absolute and would otherwise mint three buffers. The rule is **lexical +normalization before naming and before lookup**: expand a leading `~`, +absolutize, collapse `.` and `..`, strip a trailing slash except at root. +Symlinks are **deliberately not resolved** — Emacs parity, and resolving +them would make `..` from a symlinked directory jump somewhere the user +did not navigate from. The core already has exactly this shape in +`normalize_buffer_path` (`editor_core.rs:4790`); it is not Lua-exposed, +so Stage 1 either mirrors it in Lua or exposes it. + +**A mirror is a second implementation of a canonical form, and needs a +parity pin (R2-4).** This is the tab-width-constants class in miniature: +if the Lua mirror and the Rust normalizer disagree on an edge — `//tmp` +(POSIX gives a leading double slash implementation-defined meaning), `~` +with `HOME` unset, root's trailing slash, a `..` that would escape root — +then dired's name-dedup and `display_file`'s `find_buffer_for_path` dedup +**diverge silently**: two buffers for one directory, no error anywhere. +So whichever route Stage 1 takes, it carries a **parity acceptance** that +drives both implementations over one shared edge-case list and asserts +identical output (acceptance 3b). + +Rev 2's "avoids a binding whose only caller is dired" also **undercounts**: +Q#DR5's rename fix touches the same normalizer, so a Lua-exposed +`pmacs.path.canonicalize` (or equivalent) has at least two consumers by +Stage 2. Exposing it and deleting the mirror is therefore the better +end state; Stage 1 may still mirror if exposure turns out to drag in +`EditorCore` borrow plumbing it does not otherwise need, but the parity +acceptance is required either way, and the mirror is then a **named +Stage 2 removal**, not a permanent duplicate. + +**Ownership check (F7).** `pmacs.buffer.create` takes any caller-chosen +name, so a foreign buffer named `*dired:/tmp*` would be "found" by name +and then painted into through `bypass_intercept`. Found-by-name must +confirm the buffer is dired-owned — present in dired's handle table, or +`pmacs.buffer.major_mode(buf) == "dired"` — and otherwise create a fresh +buffer under a disambiguated name rather than clobbering it. + +Buffer name: `*dired:*`. + +The cost is buffer accumulation when walking a deep tree. Emacs users +live with this; Emacs 28 added an opt-out, and we mirror it as a config +key rather than a hardcoded policy: + +- `dired.kill-when-opening` (boolean, default `false`, live) — when true, + descending or ascending kills the dired buffer being left. + +## 5. Read-only discipline and the wdired seam (Q#DR3) + +The dired buffer is read-only by the listview idiom — an `add_intercept` +that rejects every non-bypass edit, plus `set_round_trip_input(buf, +true)` so a GPU session's optimistic-apply cannot swallow single-key +bindings — and dired's own paints use `bypass_intercept = true`. + +**Dired owns its buffer directly; it is not built on `pmacs.listview`.** +Listview would have to grow a keymap extension point and a removable +read-only mode, and three shipped panels depend on it. Bending a module +into a shape its existing callers do not need is exactly the change class +that took CI red in #155: scoping `pmacs.window.buffer()`'s no-arg arm +"for consistency" made a total function partial and silently dropped +edits from six unpcall'd runtime callers. Dired reuses listview's +*disciplines*, not its code. Factoring the shared disciplines into a +common helper is a named follow-up, to be done once dired's real shape is +known rather than predicted. + +**Wdired (Stage 3) is a mode swap, not a flag.** `C-x C-q` removes the +read-only intercept, installs the column-classifying one, and calls +`set_major_mode(buf, "wdired")`; commit and abandon restore both. Because +keys are mode-scoped (Q#DR8), the entire keymap changes with the mode — +`m` means "mark" in dired and means "type an m" in wdired, with no +per-key bookkeeping. + +**Wdired refuses to open on a partially-listed directory.** If the +listing carried any per-entry error (Q#DR6), rename and chmod are refused +with that reason: a rename batch is planned against a snapshot, and you +cannot safely plan against a directory you could not fully see. + +## 6. Marks (Q#DR4) + +Emacs's mark column is column 0, so every other column shifts right by +two (`" "` or `"* "`). The builtin computes its offsets from the +constants rather than inheriting the fixture's `PERMS_START = 1` / +`NAME_START = 39`. + +**Marks are keyed by basename, never by line index.** A sort, a revert, +or an external change reorders lines; a line-indexed mark set would +silently retarget onto a different file — the same class of defect as +keying kill-ring state by index rather than by stable id (the Arc 2 +substrate rule). `*buffer-list*` keys its deletion marks by buffer id +(`builtin/commands/default.lua:507`) for the same reason. + +Two mark characters, following Emacs: `*` (general mark, consumed by +operations) and `D` (deletion flag, consumed by `x`). A basename that +disappears between marking and executing is dropped from the batch and +reported, not silently skipped. + +## 7. Operations and the missing primitives (Q#DR5) + +Stage 2's operations need three filesystem primitives that do not exist, +and one correctness fix. + +**New `pmacs.fs` ops** (worker-dispatched, matching the existing five; +mutating ops take no `supersede`, per `fs.lua:101-124`): + +- `pmacs.fs.mkdir(path, opts)` — `opts.parents` for `create_dir_all`. +- `pmacs.fs.copy(from, to, opts)` — regular files in v1; a directory + source is **refused** rather than silently shallow-copied. Preserves + mode bits; `opts.overwrite` defaults false and the op refuses an + existing target otherwise. +- `pmacs.fs.remove_dir_all(path)` — separate from `remove` rather than a + flag on it, so a recursive delete can never be reached by a caller that + meant the single-object op. + +**The rename rule — decided, with the semantics widened (F4).** A rename +must rebind open buffers, and it must do so at the **primitive**: +`pmacs.fs.rename` has **zero production callers**, so changing its +contract breaks nobody, and leaving the trap armed guarantees the next +caller rediscovers it the expensive way. The rebind is: + +- **prefix-aware**, not exact-match. `apply_resource_op`'s + `find_by_path` (`buffer_registry.rs:168-174`) is exact `Path` equality, + first match only — which strands every buffer beneath a renamed + *directory*, and `R` on a directory line is an ordinary dired + operation. The reconcile rebinds the renamed path itself **and** every + buffer whose path has it as a path-component prefix. +- **normalize-before-lookup.** Stored paths are normalized on write + (`editor_core.rs:819`) and the normalizing wrapper + `find_buffer_for_path` (`:864-867`) already exists; + `apply_resource_op` bypasses it with a raw lookup (`mod.rs:3248`), which + is a latent miss today. The new path goes through the wrapper. + `apply_resource_op`'s own raw lookup is fixed in the same change — it is + the same bug, one call site away. +- in the **main-thread completion drain**, `AsyncRuntime::tick` + (`async_runtime.rs:991`), unconditionally on success — **not** where + results are consumed. This is the load-bearing half of the decision + (R2-1). The fs ops are fire-and-forget-capable: nothing obliges a caller + to `await` or attach `on_complete`, so a rebind hung off `_take_result` + (`mod.rs:6758`) would miss every rename whose handle is never taken — + the rename lands on disk, the buffer is never rebound, and the trap + survives one layer below where we thought we fixed it. An acceptance + that awaits the rename would pass throughout, so **Stage 2's acceptance + includes a no-await rename** and bites against the drain. + + Two facts make this non-obvious to implement. **Rename settles as an + undifferentiated `ReplyKind::FsUnit`** — the same reply `chmod` and + `remove` produce; there is no `Rename` variant, and the drain arm maps + `Sleep | FsUnit` alike to `JobResult::Unit` (`async_runtime.rs:1022-1025`). + So the drain cannot key on the reply; it must key on the **pending job's + own `JobKind::FsRename`**. And the from/to paths live only in the + dispatch call today, so the pending job must **retain them** for the + drain to have anything to rebind with. Both are additive to + `async_runtime.rs`; neither changes the wire or the worker contract. + +**v1 supports `R` on a directory** — that is precisely what prefix-aware +rebinding buys, and refusing it while `C` refuses directory sources for a +different reason (no recursive copy primitive) would be an arbitrary +asymmetry. Stage 2's acceptance pins the directory case explicitly. + +**Confirmation.** Destructive operations (`x`, `D`, recursive delete, +overwriting copy) prompt. There is no `y_or_n` helper — a named +autosave-arc deferral — so Stage 2 adds one rather than repeating +`autosave.lua:219`'s two-element `minibuffer.read` at four call sites. + +## 8. Tolerant listing — the Stage 1 Rust change (Q#DR6) + +`read_dir` grows a per-entry error channel behind an **opt**. The Lua +result under `{ tolerant = true }` becomes: + +```lua +{ entries = { , ... }, errors = { { name = "..." | nil, message = "..." }, ... } } +``` + +with per-entry failures recorded and enumeration continuing. Errors on +the **parent** `read_dir` itself stay fatal — a directory you cannot open +has no partial answer. Dired renders a footer line (`N entries +unreadable`) and refuses wdired (§5). + +**`name` is optional (R2-2).** A per-entry `readdir` *iterator* error +(`fs.rs:215-218`) carries no filename — the entry never materialized, so +there is nothing to name, and the error is wrapped with the **parent** +path. That arm reports `name = nil`; the footer counts it without naming +it. Every other per-entry arm has an entry in hand and names it. + +**What moves into the per-entry channel (F5):** per-entry `readdir` +errors, `symlink_metadata` failures, `read_link` failures +(`fs.rs:228-234`), and **non-UTF-8 symlink targets** (`:227`). A +non-UTF-8 target differs in kind from a non-UTF-8 name: the entry's own +name is fine, the listing renders it with the target shown as unknown, +and nothing needs to pass the target back through `rename`. As it stands +today, one weird symlink in `/tmp` kills the entire listing — the exact +failure class this section exists to fix. + +**Non-UTF-8 names stay fatal**, and are a named deferral. Rendering them +tolerantly is not a listing problem but a *path representation* problem: +`FsDirEntry.name` is `String`, every `pmacs.fs` op takes a `String` path, +and `src/fs.rs:152-155` names byte-preserving paths as post-v0.1 work +that widens the whole surface. Doing it properly changes the type of +every path in the API; doing it improperly hands dired a name it cannot +pass back to `rename`. Stage 1 reports the directory as unlistable with +the offending bytes named, which `FsError::NonUtf8Path` already carries. + +**Why an opt rather than a shape change.** `read_dir` already takes +`(path, opts)`, so it is signature-natural; it keeps the change additive +for third-party packages; and it leaves the frozen fixture's bare-array +consumption (`init.lua:312`) untouched, which matters because a proof +artifact that must be edited to accommodate new work is not frozen. + +**The opts parser must validate (minor c).** `supersede_key` +(`fs.lua:73-83`) reads only `opts.supersede` and silently ignores every +other key, so a typo'd `tolerant` would degrade to fatal mode with no +signal. The tolerant change adds unknown-key rejection to the read ops' +opts parsing. + +**Already tolerated, for the record:** a failing `metadata.modified()` +yields mtime 0 rather than an error (`fs.rs:463-476`), so the per-entry +channel is not the first such concession — it is the first *explicit* one. + +## 9. Keybindings, display, and the major mode (Q#DR7, Q#DR8, Q#DR10) + +**Global** (both unbound repo-wide): + +- `C-x d` → `dired` — prompt for a directory, defaulting to the current + buffer's directory. Takes the standard `display = "current" | "panel"` + opt (Q#BP11b), defaulting to `"current"` in Stages 1–2 like every other + adopter. +- `C-x C-j` → `dired-jump` — dired on the current buffer's file's + directory, cursor seated on that file. + +**Visit routing (Q#DR10, F2).** A `RET` on a **file** line goes through +`pmacs.window.display_file(path, { select = true })`, never bare +`find_or_open`. `find_or_open` switches the active window in both +branches before firing hooks (`window_panel.rs:373-376`), so a `RET` in a +panel-displayed dired would replace the panel with the visited file — +the panel swallows itself. `display_file` is the Q#BP11b answer: it dedups +side-effect-free through the **normalizing** `find_buffer_for_path` +before any I/O, resolves the destination before the read, and is what LSP +visits and compile already use. Underneath, its panel-safety comes from +`display_target` (`window_panel.rs:425`) — "the non-side window a visit +from a panel should address". + +**Directory descent routes differently, and deliberately (R2-3).** A +`RET` on a **directory** line replaces the dired buffer **in the window +dired already occupies**: `switch_buffer` when dired is in a document +window, and `pmacs.window.display(buf, { side = , select = +true })` when dired is panel-displayed. This is Emacs behavior — walking +a tree in a side window keeps the side window — and it is the opposite +routing from a file visit for a principled reason: a *file* is not a +dired buffer and belongs in the document area (hence `display_target`), +while the next *directory* is the same kind of thing as the current one +and belongs in the same slot. **Dedication is a property of the slot, not +the buffer**, so a dedicated dired panel stays dedicated across descent +and the new dired buffer inherits it; Stage 1's acceptance pins that +rather than assuming it, since it is a `Layout`/`WindowParams` behavior +this framing does not otherwise touch. + +**Mode-scoped on `dired`** (Q#DR8: `scope = "mode", mode = "dired"`, +bound once at load rather than per buffer — dired is the first real +consumer of #129's mode keymaps beyond language detection): + +| Key | Command | Stage | +|-----|---------|-------| +| `RET`, `f` | visit (dir → descend, file → `display_file`) | 1 | +| `^` | parent directory | 1 | +| `n` / `p`, `` / `` | move by line | 1 | +| `g` | revert (re-read, preserve cursor and marks) | 1 | +| `q` | quit (restore previous buffer / `window.quit` in a side window) | 1 | +| `s` | cycle sort mode (name → mtime → size) | 1 | +| `m` / `u` / `U` / `t` | mark / unmark / unmark-all / toggle | 2 | +| `d` / `x` | flag for deletion / execute flagged | 2 | +| `D` | delete now (confirms) | 2 | +| `R` / `C` / `+` | rename / copy / mkdir | 2 | +| `w` | copy filename to the kill ring | 2 | +| `C-x C-q` | toggle wdired | 3 | + +**Mode-scoped on `wdired`:** `C-c C-c` commit, `C-c C-k` abandon — +matching compile's buffer-local `C-c C-k` idiom without colliding with +it, since dired buffers are never compilation buffers. + +Everything follows the `M-;` / `M-%` / `C-c @` precedent of shipping the +faithful Emacs default; users rebind through `pmacs.keymap`. + +**Cursor preservation (Q#DR9)** is a Stage 1 requirement, not a nicety: +`g`, a sort, and every Stage 2 operation repaint wholesale, and a dired +that drops you to line 0 after each mark is unusable. The cursor is +re-seated by **basename**, falling back to the nearest surviving line +index when the file is gone — `pmacs.editor.move_to_line` +(`mod.rs:12526`) makes this exact rather than the `move_down`-in-a-loop +walk `listview.lua:68-74` uses. + +## 10. Staging and scope + +- **Stage 0 (separable, recommended first) — `find-file` (Q#DR11).** + `C-x C-f` → `pmacs.minibuffer.read` with `source = "files"` and + `source_root` set to the current buffer's directory, accepting free text + (`resolve_accepted_value`, `minibuffer.rs:564`) into + `pmacs.window.display_file`. **Completion is flat and does not + descend**: a function source cannot list a directory (it is called with + zero arguments and cannot `await`, F3), and the Rust `Files` source is + single-directory and 1024-capped. Typing a full path still works via + free-text accept; typing a *prefix* completes only within the root. + Hierarchical completion is a named Rust change (§13) — either pass the + current input to custom sources, or re-root the `Files` source per + keystroke. **A nonexistent path creates a `[new file]` buffer** rather + than erroring: `display_file` routes through `resolve_target_buffer` + (`editor_core.rs:885-898`), which on `ErrorKind::NotFound` creates the + buffer, binds the path, and sets that status. This is Emacs parity and + is stated rather than inherited (R2-6). Stage 0 carries its own + acceptance (§14) — it is small but no longer trivial to describe + honestly, which is itself an argument for taking it as its own PR. It is + not required by any later stage; Stage 1's `RET` opens files directly. + **Say if you want it folded into Stage 1 instead; it is one branch + either way.** +- **Stage 1 — the dired view. Approval-critical.** + `builtin/runtime/dired.lua`; the `dired` major mode and mode keymap; + buffer-per-directory with canonical naming and the ownership check; + read-only intercept + round-trip input; visit routing through + `display_file`; parent / sort / revert / quit; `C-x d` (with the + `display` opt) / `C-x C-j`; cursor preservation across repaint; the + `dired.kill-when-opening` config key; **and the tolerant `read_dir` opt + plus its unknown-key validation** (Q#DR6) — the only Rust in this stage. + No wire change; no protocol bump. +- **Stage 2 — marks and operations.** The mark column and basename-keyed + mark set; `m`/`u`/`U`/`t`/`d`/`x`/`D`/`R`/`C`/`+`/`w`; + `pmacs.fs.mkdir` / `copy` / `remove_dir_all`; the **prefix-aware, + normalized rename rebind in the completion drain** and the matching + `apply_resource_op` raw-lookup fix (Q#DR5), pinned by a **no-await** + rename and by a directory rename that must not strand the buffers + beneath it; a `y_or_n` confirm helper; and removal of the Lua + canonicalization mirror if Stage 1 shipped one (Q#DR2). +- **Stage 3 — wdired.** `C-x C-q` mode swap; the column-classifying + intercept over the mark-shifted layout; escape/unescape round-trip; + duplicate-name and NUL/slash rejection pre-syscall; two-phase rename; + field-by-field external-change detection; the symlink perms and symlink + target rules; partial-application reporting. +- **After Stage 3 — shrink the M8 fixture** to the minimum payload that + still proves package universality (§3). + +Stages 2 and 3 are sketched here and each gets its own detailed framing +after the prior stage lands, per the folding-arc precedent. **This +framing asks approval for the architecture and Stage 1's detail.** + +## 11. Numbered decisions + +- **Q#DR1** Dired ships as `builtin/runtime/dired.lua`, written fresh; + the M8 fixture stays frozen under `tests/fixtures/` because the + **harness routing** (46/47 tests reaching dired through `install_local` + + `require`) *is* the universality proof and dies if re-pointed, and + because the builtin diverges structurally. Its 45 behavioral assertions + are re-pinned by §14. Shrinking the fixture is **scheduled after + Stage 3**. (§3) +- **Q#DR2** One buffer per directory, `*dired:*`, + found-or-created by name; navigation opens the target's buffer rather + than renaming the current one (there is no `pmacs.buffer.set_name`). + Names and lookups are **lexically normalized** (tilde, absolutize, + `.`/`..`, trailing slash) with **symlinks deliberately unresolved**; + found-by-name **verifies dired ownership** before painting. + A Lua mirror of `normalize_buffer_path` is a second canonical form and + requires a **parity acceptance** against the Rust normalizer; exposing + the normalizer instead is the preferred end state, since Q#DR5 gives it + a second consumer. `dired.kill-when-opening` (default `false`) mirrors + Emacs 28's opt-out. (§4) +- **Q#DR3** Read-only via `add_intercept` + `set_round_trip_input`, with + dired's own paints using `bypass_intercept`; dired owns its buffer and + does **not** extend `pmacs.listview`; wdired is a major-mode swap, and + refuses to open on a partially-listed directory. (§5) +- **Q#DR4** Mark column at column 0 shifts all offsets; marks are keyed + by **basename**, never line index; `*` and `D` are the two mark + characters; a vanished basename is dropped from a batch and reported. + (§6) +- **Q#DR5** Stage 2 adds `pmacs.fs.mkdir` / `copy` / `remove_dir_all`. + Rename rebinding is fixed **at the primitive** (zero production callers + to break), **prefix-aware** (a directory rename must not strand the + buffers beneath it), **normalize-before-lookup** (fixing + `apply_resource_op`'s raw `find_by_path` in the same change), and in the + **main-thread completion drain** `AsyncRuntime::tick` — never in the + take/await path, which a fire-and-forget rename never reaches. Because + rename settles as an undifferentiated `ReplyKind::FsUnit`, the drain + keys on the pending job's `JobKind::FsRename` and the job retains + from/to. `R` on a directory is supported in v1; `C` refuses directory + sources. Destructive ops confirm via a new `y_or_n` helper. (§7) +- **Q#DR6** `read_dir` becomes per-entry tolerant behind an **opt** + (`{ tolerant = true }`), carrying per-entry `readdir`/`lstat`/`readlink` + failures **and non-UTF-8 symlink targets** in an `errors` channel; + parent-level failures stay fatal; non-UTF-8 **names** stay fatal, + deferred to a byte-preserving path surface. The read ops' opts parsing + gains unknown-key rejection. (§8) +- **Q#DR7** Emacs-parity bindings: global `C-x d` (with the `display` + opt) / `C-x C-j`; mode-scoped in-buffer keys per the §9 table; wdired on + `C-x C-q` with `C-c C-c` / `C-c C-k`. (§9) +- **Q#DR8** Keys are **mode-scoped** (`scope = "mode", mode = "dired"`), + not buffer-local — bound once at load, and the wdired swap changes the + whole keymap with the mode. Dired is #129's first non-detection + consumer. (§9) +- **Q#DR9** Cursor is re-seated by **basename** after every repaint, + falling back to the nearest surviving index, via + `pmacs.editor.move_to_line`. (§9) +- **Q#DR10** File visits route through `pmacs.window.display_file` + (panel-safe via `display_target`), never bare `find_or_open`, which + switches the active window before hooks and would let a `RET` replace + the panel dired is displayed in. **Directory descent instead reuses + dired's own window** — `switch_buffer` in a document window, + `display { side = , select = true }` in a panel — because + the next directory is the same kind of thing as the current one. + Dedication is a slot property and follows across descent. (§9) +- **Q#DR11** Stage 0's completion is the flat Rust `Files` source rooted + at the current buffer's directory, plus free-text accept. Function + sources cannot descend (zero-argument call, no `await` in the dispatch + context); hierarchical path completion is a named Rust deferral. + **Amended by S0-1:** the two are not independent — a selected candidate + **shadows** typed text, so free-text accept is reached only when the + input filters every candidate away (in practice, when it contains a + `/`). The prompt field therefore starts empty (S0-2), and a leading + `~` is expanded Lua-side (S0-3). (§0, §10) + +## 12. Bets + +- **B1** The fixture's hard parts — escaping with an exact inverse, + two-phase rename, field-by-field external-change detection, the symlink + rules — transfer to the builtin as decided design. FALSIFIABLE at + Stage 3: if the mark-shifted layout or the mode swap forces a different + commit model, the fixture stops being a reference and Stage 3 is + re-framed from scratch. +- **B2** Tolerant `read_dir` is the *only* Rust change Stage 1 needs — + `display_file`, mode keymaps, `move_to_line`, and + `set_round_trip_input` all already exist. FALSIFIABLE during + implementation; the most likely miss is **scroll** preservation, since + the daemon owns `view_top` and "viewport facts on the wire" is a + standing backlog gap — if preserving scroll (not just cursor) across a + repaint needs a new fact, Stage 1 grows. +- **B3** Mode-scoped single-key bindings survive both frontends, because + `set_round_trip_input` keeps optimistic-apply off — and, when dired sits + in a panel, the `!window.is_side()` arm of the same gate + (`editor.rs:818`) covers it a second time. FALSIFIABLE on a real GPU + session: pressing `d` must flag, never insert. +- **B4** Buffer-per-directory does not produce clutter users complain + about (Emacs parity), and `dired.kill-when-opening` is a sufficient + escape hatch. Falsifiable only by use. +- **B5** Flat, non-descending completion is acceptable for Stage 0 + because free-text accept covers the full-path case. FALSIFIABLE + immediately by use: if typing full paths blind is what people actually + do, hierarchical completion stops being a deferral and becomes Stage 0's + real scope. + +## 13. Deferred (named) + +- **Hierarchical path completion** — pass the current minibuffer input to + custom sources, or re-root `CompletionSource::Files` per keystroke + (Q#DR11). +- **Typed text vs. a selected candidate on accept** (S0-1) — prefer the + typed contents when they differ from the selection and the user has not + explicitly moved it. Closes Stage 0's "a new bare name that is a + subsequence of an existing entry opens the existing file" hole, but + changes `M-x` and `switch-buffer` accept semantics too, so it needs its + own reasoning and gates. **It would also close the empty-input case** + (S0-4): `fuzzy_score` returns `Some(0)` for an empty needle + (`minibuffer.rs:637-640`) and `filter_and_sort` breaks ties + lexicographically (`:678`), so accepting immediately opens the + first-sorted entry — dotfiles first, and possibly a directory, which + then fails and reports. Inherited from the shared minibuffer, not + introduced by find-file, and recorded as decided rather than + overlooked. +- **Minibuffer history stores the pre-join value** (S0-5) — + `Minibuffer::accept` pushes the *resolved* value (a bare basename, or a + root-relative path) into the history bucket Rust-side, **before** Lua + joins it onto the root. So recalling `sub/inner.txt` with `C-p` under a + *different* root resolves against the new root, and can silently create + a `[new file]` buffer somewhere else. Emacs's `file-name-history` stores + absolute paths. Lua cannot fix this — the push happens before + `on_accept` runs — so it belongs with the other Rust-side minibuffer + deferrals here. +- **Load through the normalized path** (S0-3) — `get_or_load_buffer` + computes a normalized path and then loads from the raw one + (`editor_core.rs:842-856`), so tilde paths dedup but do not load. Same + normalize-before-lookup family as Q#DR5's `apply_resource_op` fix. +- **Non-UTF-8 filenames** — needs byte-preserving `pmacs.fs` paths + (`src/fs.rs:152-155`); widens every path in the API. (Non-UTF-8 symlink + *targets* are handled in Stage 1, §8.) +- **Shrinking the M8 fixture** — scheduled after Stage 3 (§3). +- **Factoring the shared panel disciplines** out of dired and + `pmacs.listview` (§5). +- `o` / `C-o` visit-in-other-window — the GPU has no splits (GPU + structural parity, roadmap Arc 8). +- `!` / `&` shell command on marked files; `Q` query-replace across marked + files; `A` search across marked files. +- `i` insert-subdirectory (in-buffer recursive listing) and + `dired-hide-details`. +- Owner and group columns — no uid/gid → name primitive exists. +- Human-readable sizes; sort by extension; reverse-sort toggle. +- `%m` / `%d` regex mark family; `dired-omit-mode`. +- Recursive copy (`C` on a directory), which v1 refuses. +- Auto-revert on external change — `pmacs.fs.watch` exists and polls + (`fs.lua:226`), so this is wiring plus a policy decision about polling a + directory the user is not looking at. +- Dired buffers in the desktop session — covered by Arc 3's standing + "non-file buffers in the desktop" deferral. +- Remote / Tramp-style paths. +- Symlink creation and symlink-target editing (the fixture rejects target + edits at commit; `init.lua:821-840`). + +## 14. Acceptance + +### Stage 0 — `find-file` (R2-6) + +0a. **Flat completion within the root.** With the current buffer in a + temp directory, `C-x C-f` offers that directory's entries as + candidates and does **not** offer entries of a subdirectory — + documenting the flat-source limitation as intended behavior rather + than leaving it unpinned. +0b. **Free-text accept of a deeper path.** Typing a full path below the + root and accepting opens that file, with no candidate selected + (`resolve_accepted_value`'s raw-input path). +0c. **Nonexistent path creates.** Accepting a path that does not exist + yields a buffer bound to it, unmodified and empty, with the + `[new file]` status — not an error. +0d. **No-path origin.** From a buffer with no backing path, the prompt + roots at the process cwd rather than erroring or offering nothing. + +### Stage 1 — the dired view + +1. **Listing shape.** `C-x d` on a temp directory renders a header line + plus one line per entry, with kind char, perms, size, mtime, and name; + a symlink renders `l` with ` -> target`; the entry count matches + `read_dir`. +2. **Visit dispatches on kind, through the panel-safe primitive + (Q#DR10).** `RET` on a subdirectory line opens that directory's dired + buffer; `RET` on a **file** line opens the file bound to its path (the + fixture's "not yet exposed" error is gone). `RET` on the header does + nothing. **The panel case is the real assertion**: with dired opened + `display = "panel"`, a `RET` on a file line leaves the dired panel + alive and puts the file in the document window — **falsified by + swapping `display_file` for `find_or_open`**, which must make the panel + disappear. +3. **Buffer-per-directory and canonicalization (Q#DR2).** Descending + twice then ascending twice yields the *same* buffer ids as the first + visit; every dired buffer's name matches the directory it displays; + and `C-x d` on `/tmp`, `/tmp/`, and `/tmp/../tmp` (with a real temp + dir) yields **one** buffer, not three. With + `dired.kill-when-opening = true`, the departed buffer is gone. +3b. **Canonicalization parity (R2-4).** One shared edge-case list — + `//tmp`, a trailing slash, `~` with `HOME` set and unset, `.`/`..` + segments including a `..` that would escape root, a relative path — + driven through **both** dired's canonicalizer and the Rust + `normalize_buffer_path`, asserting identical output. If Stage 1 + exposes the normalizer instead of mirroring it, this degenerates to a + round-trip test and the mirror-removal follow-up is dropped. +3c. **Panel descent (Q#DR10, R2-3).** With dired opened + `display = "panel"`, `RET` on a **directory** line leaves dired in the + same side window showing the new directory — the panel is neither + replaced by a document window nor duplicated — and a dedicated panel + is still dedicated afterward. +4. **Ownership check (Q#DR2, F7).** A foreign `pmacs.buffer.create` + buffer named exactly `*dired:*` is **not** adopted: `C-x d` on + that path leaves the foreign buffer's contents byte-identical and + opens dired elsewhere. +5. **Read-only (Q#DR3).** A `buffer.self-insert` into a dired buffer is + rejected by the intercept and leaves the text byte-identical; dired's + own repaint succeeds through `bypass_intercept`. `set_round_trip_input` + is set, pinned **through the real dispatch path** so a semantic + frontend's `d` reaches the binding rather than optimistic-applying — + falsified by reverting the `set_round_trip_input` call, not by a + direct-call assertion. +6. **Mode keymap (Q#DR8).** The keys resolve through `scope = "mode"` + with no per-buffer binding: a *second* dired buffer, created without + any `keymap.bind` call of its own, still responds to `g` and `^`. + `pmacs.buffer.major_mode(buf)` is `"dired"`, and the mode shows in the + statusline. +7. **Cursor preservation (Q#DR9).** With the cursor on entry `k`, `g` + re-seats on the same **basename** after an external file was added + *above* it (so the line index changed); when that basename is deleted + externally, the cursor lands on the nearest surviving line, not line 0. +8. **Sort.** `s` cycles name → mtime → size → name; mtime sorts newest + first and size largest first, each with a stable name tiebreak; the + cursor stays on its basename across the reorder. +9. **Tolerant listing (Q#DR6).** In a directory containing a child whose + `lstat` fails, `{ tolerant = true }` returns the surviving entries plus + one `errors` row naming the child; dired renders every readable entry + plus the unreadable-count footer; and **the default (non-opt) call + still returns a bare array** — both forms called in one test, so the + fixture's contract cannot regress unnoticed. A failure on the parent + directory itself is still fatal. +10. **Tolerant symlink targets (Q#DR6, F5).** A directory containing a + symlink whose target is non-UTF-8 lists successfully under + `{ tolerant = true }`, with that entry present and its target reported + unknown — **falsified by reverting the `read_link`/target arm**, which + must take the whole listing down. +11. **Unknown opts rejected (minor c).** `read_dir(path, { tolerat = true })` + errors naming the unknown key rather than silently listing in fatal + mode. +12. **Non-UTF-8 names stay fatal, and say so.** A directory containing a + non-UTF-8 *name* reports the structured `NonUtf8Path` error with the + offending bytes; dired surfaces it as a status message and creates no + buffer. +13. **`dired-jump`.** From a file buffer, `C-x C-j` opens dired on that + file's directory with the cursor on that file's line. From a buffer + with no path, it reports that and creates nothing. +14. **Quit.** `q` restores the previously active buffer; in a side window + (`display = "panel"`) it routes through `pmacs.window.quit`, matching + `listview.quit`'s Q#BP11b split. +15. **Failure leaves nothing behind.** `C-x d` on a nonexistent or + unreadable directory creates no buffer, switches no window, and + reports the reason — the fixture's + `dired_open_failure_leaves_editor_unchanged` invariant. +16. **Scale.** A 10,000-entry directory renders within the fixture's + established 200 ms budget, on the builtin path — carrying the same + `cfg_attr(target_os = "macos", ignore)` gate the fixture's version + uses (`m8_2_acceptance.rs:211-213`), since hosted macOS debug runners + do not consistently satisfy it. +17. **The fixture still passes.** `m8_2_acceptance` 15/15 and + `m8_3_acceptance` 32/32 unchanged, proving the `read_dir` opt is + additive. + +Every behavioral claim above is bite-verified with `scripts/bite`. + +## 15. Gates (Stage 1) + +`cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D +warnings` as its own step; `cargo test --lib`; `cargo test --lib +--features crdt`; `tests/dired_acceptance.rs` (default + CRDT); +`tests/m8_1_acceptance.rs`, `tests/m8_2_acceptance.rs`, and +`tests/m8_3_acceptance.rs` (the additivity proof — m8_1 because it +exercises `pmacs.fs.rename` and `read_dir` directly); `cargo test --test +m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test -p +pmacs-gpu`; the workspace sweep **with an isolated `XDG_CONFIG_HOME`** +(the real `~/.config/pmacs/init.lua` on this desktop calls +`install_local`, which races every editor the sweep builds and leaks a +status message into frame-comparing suites); `git diff --check`. + +## 16. Branch and PR plan + +Branch `dired`, worktree `../pmacs-dired-arc` — **not** `../pmacs-dired`, +which would read as the fixture. **Based on canonical `githubsucks/main` +@ `0827dd1`** (Lean 4 Stage 1 #160), which is one merge ahead of the +scout's `e745068`; see §2's base note for why that movement does not +disturb the ground truth. + +**The shared checkout is not the place to cut this.** It currently has +`lean4-stage1` checked out with in-progress foreign work +(`src/highlight.rs` modified), and this framing is untracked in it. Per +the §5 ops rule, the branch is cut as a **sibling worktree off `main`** +and the framing is committed there as the branch's first commit, rather +than by switching the shared checkout. The framing does not travel until +that commit is pushed. + +Stage 1 implements on the same branch and opens as the first dired PR. +Stages 2 and 3 are separate branches and PRs off the `main` that results +from the prior stage, each with its own detailed framing. If Stage 0 +(`find-file`) is taken separately it goes first, on its own branch +`find-file`, and Stage 1 rebases onto the resulting `main`. diff --git a/docs/gpu-terminal-input-framing.md b/docs/gpu-terminal-input-framing.md new file mode 100644 index 0000000..0bbccef --- /dev/null +++ b/docs/gpu-terminal-input-framing.md @@ -0,0 +1,438 @@ +# GPU terminal input — the double terminal-layout sync + +**Revision 2 — approved 2026-07-25. Scouted against canonical `main` @ +`8c86d34`; implemented on branch `gpu-terminal-input` off `main` @ `46a1b8f`, +whose only delta (#161) touches no file on this fix surface. Protocol stays +v20.** + +Revision 2 answers Q#GT4 from the code instead of deferring it, which changes +the proposed fix from a one-line guard to a **split of `sync_terminal_layout` +into a frontend-kind-neutral liveness half and a grid-only geometry half**; +rescores B1 as half-false; gives acceptance criteria 2 and 3 a landable +observation seam; and corrects four line citations plus the criterion-4 +rationale. Revision 1's diagnosis is unchanged — the defect, its measurements, +and the three falsified hypotheses all stand. + +Reported symptom: *"Text input within the terminal doesn't work on GUI, this +is fine in TUI."* + +This is a bug-fix framing, not a feature. It repairs a defect in Vterm Stage 3 +(#135) that ships on `main` today, and it closes the acceptance hole that let +the defect ship: the Stage 3 real-path acceptance drives a terminal session +end to end, and *still could not see this*. + +## Summary of the defect + +Every dispatcher tick, the daemon applies **both** terminal-layout syncs to +**every** attached frontend: + +```rust +// src/daemon.rs:1536-1554 (current main) +for frontend_id in &attached_fids { + if let Some(size) = term_sizes.get(frontend_id).copied() { + editor.sync_terminal_layout(*frontend_id, size); // GRID path + } + if let Some((buffer_id, size)) = semantic_states + .get(frontend_id) + .and_then(SemanticRenderState::terminal_viewport) + { + editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size); // SEMANTIC path + } +} +``` + +They are written as twins — the comment on the semantic arm even says *"right +beside the grid sync"* — but they are applied as **siblings, not +alternatives**. A GPU session has an entry in `term_sizes` (its `AttachRequest` +carries an initial cell size, and `Resize` events maintain it) *and* a +semantic terminal declaration. So both run, every tick. + +The two disagree by construction, and the semantic arm's own doc comment says +why: + +> the frontend declared a CONTENT rectangle, so this consumes the size +> directly instead of running the TUI placement helper, **which would subtract +> a modeline the GPU never drew**. + +That is exactly what the grid arm then does. Measured, on a real daemon with a +real PTY and the real GPU attach client: + +``` +PROBE sync_semantic old=Some(24x80) declared=25x92 +PROBE manager.resize BufferId(3) 25x92 +PROBE manager.resize BufferId(3) 22x80 +PROBE sync_semantic old=Some(22x80) declared=25x92 +PROBE manager.resize BufferId(3) 25x92 +PROBE manager.resize BufferId(3) 22x80 +... +``` + +The PTY is resized **twice per dispatcher tick, forever**. Each resize is a +`TIOCSWINSZ` + `SIGWINCH` to the child and a screen reflow in +`TerminalScreen`, so the child gets a SIGWINCH storm at tick cadence and the +screen alternates between two geometries. An interactive line editor +(readline, zle, fish's reader) redraws on every SIGWINCH, so what the user +types is continuously destroyed before it can settle — while ordinary child +*output* keeps flowing, which is why the terminal looks alive. + +Measured user-visible effect, real bash `-i` in the real GPU path, typing one +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) | + +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 +why it presents as "GUI broken, TUI fine". + +## Ground truth (measured this session, not inferred) + +Everything below was established against `main` @ `8c86d34` with a real +daemon, a real PTY child, and the real `pmacs-gpu` attach client. The probe +harness is preserved (see "Verification plan"). + +### What is *not* wrong — three hypotheses falsified + +Recording these because each is a plausible-looking cause that a future +reader (or a review round) will re-propose. + +1. **The GPU's optimistic-CRDT path is not implicated.** The first hypothesis + was that a typed character becomes a `CrdtOp` against the read-only + terminal identity buffer and is dropped. It does not. Terminal buffers are + already marked round-trip — `core.set_round_trip_input(buffer_id, true)` + at `src/terminal/session.rs:338`, beside `set_read_only(true)` — so + `dispatch_idle_for` returns **false** while a terminal window is focused, + the daemon publishes `DispatchIdle { idle: false }`, and the GPU's + `daemon_intercepts_keys()` is true. Measured on the wire: + `dispatch_idle_in_terminal=false`, `intercept_in_terminal=true`, + `input_route=send_key(intercept)`. The optimistic gate is shut. +2. **Key transport is not implicated.** The keystroke reaches the daemon, + resolves a terminal view key, encodes, and is written to the PTY without + error: `PROBE dispatch_key ... terminal_key=Some(TerminalViewKey { .. })`, + `PROBE terminal transport encode=Some([90])`, `PROBE after send status=""`. + With a `cat` child the byte comes back on screen through the whole real GPU + path (`echoed_typed_char=true`). +3. **The `pmacs --attach` TUI replica does *not* share the defect.** It gates + its optimistic path on `dispatch_idle` alone (`src/attach.rs:843`), and + that signal is already correct for terminals per (1). + +### The mechanism + +- `EditorInstance::sync_terminal_layout` (`src/editor.rs:1195`) is the grid + path: it runs the TUI placement helper over the frontend's *frame* size. +- `EditorInstance::sync_semantic_terminal_layout` (`src/editor.rs:1331`) is + the semantic path: it consumes a declared *content* rectangle directly. +- Both resolve the same controller and call `TerminalManager::resize` on the + same session. Each has a correct `old_size == size` idempotence guard + (`src/editor.rs:1239` grid, `src/editor.rs:1360` semantic) — the guards are + individually sound and jointly useless, because each arm sees the size the + *other* just installed. +- `TerminalViewStore::record_view_size` (`src/terminal/view.rs:276-292`) + returns `true` for any valid declaration with no unchanged-size dedupe, + which is why the semantic arm re-fires every tick against the grid arm's + flip rather than settling. +- Loop order is grid first, semantic second, so the screen *ends* each tick at + the declared size. That is why rendering looks alive while the child is + whipsawed — and why a frame-based assertion is the wrong instrument + (acceptance criteria 2 and 3). +- `TerminalScreen::changed` bumps `generation` per mutation + (`src/terminal/screen.rs:1467`), which is why generation advances by + **exactly 2** per tick — one bump per resize. +- Frame suppression is full-struct equality + (`self.last_terminal_frame.as_ref() == Some(&frame)`, + `src/semantic_render.rs:882`). It is behaving correctly: the frames really + do differ. The churn is upstream, and fixing the churn fixes the frame + storm. **No suppression change is proposed.** + +### Why the Stage 3 acceptance could not catch it + +`a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session` +(`tests/vterm_stage3_acceptance.rs:637`) is a genuine real-daemon + +real-PTY + real-wgpu path, and it still passes on the broken tree. Three +reasons, each worth keeping: + +1. Its child is `sh` printing 400 rows on a timer. **A frame storm is + invisible against a child that legitimately produces ~400 frames**, and its + only frame-count assertion is `frames >= 2`. +2. Its input step is `client.send_key(...)` called **directly** + (`pmacs-gpu/src/main.rs:784-785`), so it pins transport, not routing — and + it asserts nothing about the result of that input reaching the child. +3. It resizes **once, deliberately**, and asserts the new width comes back. + A geometry that oscillates *through* the asserted width satisfies that + assertion. This is the project's own "a geometric readout is not a state + predicate" lesson (`docs/active-work.md`, bottom-panel round 2) in a new + place: `observed_resized_frame` says "a frame at this width arrived", not + "the geometry settled at this width". + +## Decisions + +**Q#GT1 — Where does the fix go?** `sync_terminal_layout` is **split**, and +only its geometry half is gated by frontend kind. A bare "skip the grid arm +for semantic frontends" guard is wrong — see Q#GT4, which establishes that the +grid arm is also the only per-tick controller-liveness release. Not by +removing `term_sizes` for semantic sessions: semantic key and mouse dispatch +hard-depend on it (`src/daemon.rs:2191-2213`). + +The function has three separable concerns +(`src/editor.rs:1195-1260`), and they do not split where the name suggests: + +| lines | concern | frontend kind | +|---|---|---| +| 1199 | `reconcile_panel_layout` (Q#BP2b per-tick defensive) | **neutral** | +| 1200-1221 | controller liveness: released when the frontend has no view, or its active window no longer shows that terminal | **neutral** — reads only `core.views` / `core.windows` / the controller, never `term_size` | +| 1222-1259 | TUI placement (`window_placements`) + `resize` | **grid only** | + +So the daemon loop becomes: run the neutral half for every attached frontend +every tick, then exactly one geometry arm per frontend kind. +`sync_terminal_layout` survives as the composition of both halves, so +`editor::run`'s in-process loop and `LOCAL` keep byte-identical behavior. The +liveness half must run **once** per frontend per tick — reconciliation is +idempotent, so a double call is safe rather than wrong, but the loop should +not pay for it. + +**The trap inside the split:** the third release, at `src/editor.rs:1226` +(no placement found for the window), looks like liveness and is **not** — it +is grid geometry. A semantic frontend has no `window_placements` entry at all, +so moving that arm into the neutral half would release a GPU session's +controller on every single tick. That would be a new defect of exactly the +family this framing fixes, so it stays in the grid half. + +**Q#GT2 — Which arm wins for a semantic frontend?** The semantic one, +unconditionally. It is the only arm that consumes a *content* rectangle; the +grid arm's modeline subtraction is meaningless for a frontend that draws no +modeline into the terminal band. A GPU frontend that has not yet declared a +terminal viewport gets **neither** arm, which is correct: the terminal keeps +the geometry it was opened with until the frontend declares one. + +**Q#GT3 — Is the guard "no semantic state" or "not a semantic session"?** +`semantic_states` keyed by frontend id is the same map the semantic arm reads +one line later, so the two arms become provably exclusive by construction +rather than by two independent predicates that could drift apart. Rejected +alternative: keying on the negotiated `semantic_render` capability bit — it is +the *same* fact one indirection away, and the pair could then disagree. + +**Q#GT4 — Does anything else in `sync_terminal_layout` need to keep running +for a semantic frontend? Yes: the controller-liveness release, and the +semantic arm neither performs it nor can be made to.** Revision 1 left this +open; the code answers it. + +`release_controller` is called from exactly five sites, all in +`src/editor.rs`: the three grid-arm early returns (1210, 1219, 1226), +`dispatch_focus(gained = false)` (1189), and `reconcile_panel_layout`'s +unsatisfiable-panel path (848). **`sync_semantic_terminal_layout` releases +nothing.** When the window has switched away, `semantic_terminal_key` returns +`None` (`src/editor.rs:1278` — `window.buffer_id != buffer_id`) and the arm +returns `false` without touching the controller. + +Growing a release inside the semantic arm — revision 1's stated fallback for +B1 — **cannot work**, and the reason is worth keeping: when a GPU window +switches from the terminal to a document, the buffer-follow snapshot clears +the viewport declaration (`on_buffer_snapshot_sent` sets +`terminal_viewport = None`, `src/semantic_render.rs:574`), so +`terminal_viewport()` returns `None` and the semantic arm **stops running +entirely** for that frontend. A release placed inside it would never execute +in precisely the scenario that needs it. + +Nor do the other two sites cover it: `dispatch_focus(false)` fires on +whole-frontend focus loss, not on a window or buffer switch, and semantic +sessions are not panel-capable yet (`panel_capable_for` is false for them — +`src/daemon.rs:1893-1898`), so 848 never fires either. + +Consequence of shipping revision 1's guard as written: a GPU frontend that +switches away from its terminal **holds the controller indefinitely**. Because +another frontend's grid sync early-returns on a +`controller_view_for_frontend` mismatch, that peer then cannot resize the PTY +until it explicitly re-claims. This is why Q#GT1 splits the function instead +of gating it. + +**Q#GT7 — The per-tick defensive panel reconcile stays for semantic +frontends.** It is the only per-tick pre-paint reconcile the Q#BP2b contract +names (`src/editor.rs:822-830`), and today the grid arm supplies it for GPU +sessions too. Putting it in the neutral half of the split preserves that +exactly. It is harmless-either-way today — semantic sessions have unknown +frame geometry until the bottom-panel GPU band lands — but "harmless today" +is not a reason to remove a contract's only per-tick enforcement point in a +PR about something else. + +**Q#GT5 — Typed characters not echoing by an interactive shell is a +*separate* question and is deliberately out of scope.** Measured: with `bash +--norc -i` on a `TerminalMode::Raw` PTY, typed characters are not echoed to +the screen — **and this reproduces identically in-process**, i.e. on the TUI's +own path, where the user reports the terminal works. Because it is not +frontend-specific it cannot be the GUI/TUI asymmetry, and folding it in would +make this PR two features. It gets its own scout: whether `TerminalMode::Raw` +is the right mode for a `pmacs.terminal.open` child, and what pmacs owes a +child that expects to own its termios. Named, not silently dropped. + +**Q#GT6 — Protocol impact: none.** No wire shape, no negotiation, no version +change. Stays v20. + +## Bets + +- **B1 — SCORED HALF-FALSE before implementation (revision 2).** "Removing the + grid arm for semantic frontends removes the storm without removing any + behavior a GPU session relies on." The first clause holds (measured). The + second is **false**: it also removes the only controller-liveness release + and the only per-tick Q#BP2b reconcile a GPU session gets (Q#GT4, Q#GT7). + Its stated contingency — "the semantic arm grows the release" — is false + too, for a structural reason (`terminal_viewport` is cleared by the very + 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.* +- **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 + is "twins applied as siblings", and it would be negligent to fix one + instance without looking for others.* + +## Deferred (named) + +- Interactive-shell echo on a raw-mode PTY (Q#GT5) — its own scout. +- **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 + (two short lines near the top) should survive a shrink of that size. It + reproduces on the pre-fix tree, so it is neither caused nor fixed here, and + it is why acceptance 4 latches its observation across frames instead of + reading the final one. Not investigated: it could be correct reflow + behaviour given where the child leaves its cursor (frames show the cursor + on the bottom row), or a real reflow defect. Named because the next person + to write a resize assertion will hit it. +- `TerminalFrame` suppression including `screen_generation` in its equality: + correct today and load-bearing for correctness, but it means any future + content-neutral generation bump re-emits a frame. Recorded, not changed. +- The `a37` probe's structural weaknesses beyond what the acceptance below + fixes (it still cannot exercise `App::window_event`'s routing, because that + logic is inline in the winit handler with no extractable seam). Making GPU + key routing testable is a real refactor and belongs to its own lane. + +## Acceptance criteria + +**The observation seam (revision 2).** Criteria 2, 3 and 6 assert daemon-side +state, and `TestDaemon` runs the daemon as a **subprocess** +(`tests/common/daemon.rs:90`), so nothing in-process can see it and the +scouting instrumentation does not land. The seam that does land: **extract the +dispatcher loop's per-frontend terminal-layout step into a named function** +that takes `(&mut EditorState, &[FrontendId], &term_sizes, &semantic_states)`. +That is required by Q#GT1's split anyway, it makes the grid/semantic +exclusivity structural rather than two adjacent `if`s, and it lets an +in-process test in the style of the existing `src/daemon.rs` unit tests +(3375ff) drive **the real loop body** rather than a re-implementation — which +is the a37 lesson applied to this PR's own tests. + +The observable is `TerminalScreen::generation`, reachable through +`TerminalManager::snapshot(..).generation`. It advances once per screen +mutation (`src/terminal/screen.rs:1467`), so with a quiet child it is a +**state predicate**, not a readout: "the geometry settled" is exactly +"generation stopped advancing". + +1. On a real daemon + real PTY + real GPU attach, a terminal session that + receives no child output produces a **bounded** number of terminal frames + (settling to zero new frames once the screen is static) — not one per tick. + Fails on `main` with ~730 frames in 20 s; passes with ≤ a small constant. +2. Driving the extracted loop body N times against a semantic frontend with a + fixed declaration and a quiet child: `TerminalManager::resize` takes effect + **exactly once** (generation advances once, then is constant for the + remaining N-1 iterations). Fails on `main`, where generation advances by + two per iteration. +3. After a declaration, `screen_size(buffer)` **equals the declared content + rectangle and stays equal** across subsequent iterations — the state + predicate, not the "a frame at this width arrived" readout that + `observed_resized_frame` provides today. +4. A character sent through the real GPU attach client reaches the child and + its echo appears in a rendered frame. **This is a keep-working pin, not a + fix discriminator: it already passes on today's broken `main`** (falsified + hypothesis 2 measured `echoed_typed_char=true`). Pinned with a `cat` child + — not because `cat` echoes (termios `ECHO` is off in raw mode; nothing + echoes) but because `cat` *copies stdin to stdout*, so the byte comes back + exactly once, with no line discipline and no double echo to disambiguate. +5. A grid (TUI) session's terminal resize behavior is **unchanged** — pinned + against the existing Stage 2 real-TUI PTY smoke, which must stay green + without modification. +6. A semantic frontend whose window stops showing the terminal **releases its + controller** (Q#GT4), pinned through the extracted loop body — driven by an + actual buffer switch, not by calling the release directly. This one bites + against **revision 1's naive guard**, and deliberately **passes on `main`**: + today's sibling arms do supply the release, by the accident of the grid arm + running for a frontend it should never have run for. It is the pin that + stops the fix from trading one defect for another. +7. End-to-end SIGWINCH count through the real PTY: a child trapping `WINCH` + and printing a **fresh distinct breadcrumb per signal** (`WINCH 1`, + `WINCH 2`, …) shows a bounded count. The distinctness is load-bearing — + the established PTY-paint trap is that cell diffing skips both spaces and + already-matching cells, so a repeated identical marker can assert nothing. +8. Bite-verified against **two** pre-images, because one is not enough here — + the naive guard fixes the storm and introduces a different defect, so a + single revert would score the fix complete when it is not. Measured + (`cargo test --lib`, manual revert since these tests share `src/daemon.rs` + with the production code): + + | pin | `main` (sibling arms) | rev-1 naive guard | the split | + |---|---|---|---| + | acc 2+3 settle | **FAIL** | pass | pass | + | acc 6 controller release | pass | **FAIL** | pass | + | acc 5 grid still resizes | pass | pass | pass | + + The middle column is B1's half-false score made executable: the naive + guard's first clause holds (the storm stops) and its second does not. + +Criteria 1, 2 and 7 are deliberately expressed as **quiet-child** assertions, +because the existing acceptance's chatty child is exactly what hid this. + +## Coherence impact (`COHERENCE.md` §20) + +- **§2 golden journey, step 8 ("Open a terminal")** — currently graded *"Works + but undiscoverable"*. On the GPU frontend it does not work; this restores + the step for the frontend the document calls the more capable one. Priority + 1 explicitly treats journey regressions as release blockers. +- **§16 Productize the Semantic Frontend Architecture** — graded *strong*, + with "graceful per-frontend degradation is practiced, not aspirational" as + its evidence, citing per-frontend fold projection. This defect is the + counter-example: a per-frontend-kind operation applied to both kinds at + once. The section's claim survives, but the audit should record that the + practice is enforced by convention, not by structure — two arms that must be + alternatives are currently just two adjacent `if`s. Q#GT1's extracted loop + body makes this one structural; the audit note should say the *pattern* is + still convention-enforced everywhere else (B3). +- **§6 Eliminate Hardcoded Interaction Islands** — the audit's row 6 note that + the GPU optimistic classifier "is kept honest by `dispatch_idle_for`" is + **confirmed correct** by this investigation (falsified hypothesis 1), and the + §6 citation `crate::optimistic::classify_key` should be corrected: that + symbol is `src/optimistic.rs`, the **`pmacs --attach` TUI replica's** + classifier. The GPU's separate, unrelated classifier is + `optimistic_insert_text` / `optimistic_crdt_insert` in + `pmacs-gpu/src/main.rs`. Two replica frontends, two classifiers; the audit + conflates them. +- **§19 Product Coherence Acceptance Tests** — this is a concrete instance of + the section's thesis. Every subsystem test passed; the defect lives in how + two correct subsystems compose per frontend kind. Criterion 1's quiet-child + shape is the transferable technique. +- No interaction island added, no config registry surface, no background-work + attribution change. + +## Verification plan + +Full gate suite per `CLAUDE.md`, plus: + +- `cargo test --features crdt --test vterm_stage3_acceptance` (the suite this + repairs) and `--test vterm_stage2_acceptance` (the TUI no-regression pin). +- `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. +- The scouting harness is preserved and should be re-run against the branch: + a quiet-child variant of the `a37` probe plus daemon-side resize tracing, + saved as `scratch_gui_terminal_input.rs`, `scratch_inproc_input.rs`, and + `gui-terminal-probe-instrumentation.patch`. The instrumentation is scratch; + the acceptance criteria above are what lands. +- Manual confirmation with the user's own shell (fish) in a real GPU window, + since B2 is not fully scored by any automated test (Q#GT5). + +**Ops.** This doc is currently untracked in a detached-HEAD worktree +(`../pmacs-gui-term-input`), so it does not travel. On approval it becomes the +branch's first commit before any implementation, per the standing workflow — +no cross-machine expectation should attach to it until then. diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md new file mode 100644 index 0000000..e1fe060 --- /dev/null +++ b/docs/lean4-mode-framing.md @@ -0,0 +1,1467 @@ +# Lean 4 mode — framing (Arc 8) + +pmacs has no Lean support of any kind: `grep -rin lean` over `*.rs`, +`*.lua`, `*.toml`, `*.md` returns zero hits outside the words "clean", +"boolean", and "leans on". A `.lean` file today opens as a pathless-ish +plain buffer — no grammar, no major mode, no comment syntax, no pair set, +no server. + +This lane closes that in seven stages. Stage boundaries are drawn where +the *substrate* changes, not where the feature list does — see §4. + +## 0. Why this lane, why now + +- Arc 5 (terminal), Arc 4 (themes), the config registry, and the mode + system are all complete, and Arc 7 Stage 1 (bottom panel) merged as #155 + at `e745068`. The goal view in Stage 5 is the first real consumer of the + panel placement API outside listview/compile/terminal, which is a useful + forcing function for it. +- The language-support pattern is well worn and cheap: #123 (JSON/YAML), + #144 (LaTeX), #146 (HTML+CSS). Stage 1 is that pattern almost exactly. +- Stages 2 and 4–6 are **not** that pattern, and none should be mistaken + for a one-liner. Stage 2 changes `ensure_server`, shared by every LSP + language. Stage 4 builds the editor's first input method. Stage 5 is the + first consumer of a non-standard LSP method family. Stage 6 adds a + severity-routing policy to `LspServerSpec`. +- The user's stated north star is **matching or exceeding what VS Code + does with Lean**. §5's bet 6 scores honestly how close seven stages get + and names precisely what is still missing. + +Parallel-safety: Stage 1 touches `Cargo.toml`, `src/syntax.rs`, +`src/highlight.rs`, and four runtime Lua files. Stage 2 touches +`src/lua_bindings/mod.rs` and `builtin/runtime/lsp.lua` only. Folding +Stage 3 (the other open lane) touches `pmacs-gpu/*` and +`src/semantic_render.rs`. None of the three footprints overlap; the only +file Stage 1 shares with anything is `Cargo.toml`, at one line. + +Stages 1 and 2 are independent of each other and **can** run as sibling +worktrees — they share no file. Per the #126/#127 lesson, that split is +recorded here, before either starts, rather than discovered during a +rebase. + +## 0.1 Revision history + +Revision 1 — initial. + +### Round 1 (rev 1 → rev 2) + +Five findings, all revision edits — no re-scout was required. The reviewer +independently reproduced both crate teardowns, every file:line citation, +the blast-radius greps, and the Lean server facts. + +1. **Acceptance 8 contradicted the change it pinned.** The negative pin + named Lua and Python as "byte-identical" fixtures, but both are among + the languages `constructor` retro-paints — so a fixture that didn't + move would have been exactly the vacuous-assertion shape from the #155 + R2 lesson. Acceptance 7/8 redrawn: Lua and Python moved to the positive + side with asserted deltas, and the negative pin now uses languages + verified to emit none of the four names. +2. **The retro-paint is broader than rev 1 stated, and differently + shaped.** `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated + into the `javascriptreact`, `typescript`, and `typescriptreact` + entries (`src/syntax.rs:1009`–`1056`), so `constructor` reaches + **seven** language entries, not four. More importantly the *shape* is + not "constructors": rust/python/javascript tag **every capitalized + identifier** (`#match? "^[A-Z]"`), and lua tags **every + table-constructor brace**. §2.3 and Q#LN4 now state this, because it + is what the ruling is actually about. +3. **The goal view's refresh loop had no seam.** There is no motion hook. + Named the real mechanism (debounced polling off `process.after-tick`) + in Q#LN13 rather than letting it grow a polling loop or new hook + substrate unframed. + +*(Round-1 findings are stated against the features, not stage numbers: +round 2 renumbered the stages, so a rev-1 "Stage 4" is now Stage 5.)* +4. **Q#LN10's ordering example didn't motivate the ordering.** `<` is not + in the proposed pair set, so `\<>` is safe under either order. Replaced + with the real collisions (64 abbreviation keys contain a pair-set + character), and stated the contract that finding exposes: the + abbreviation consumer must claim self-inserts that *extend an open + pending abbreviation*, not only completed expansions. +5. **Q#LN8's resolver must honor the search boundary.** A Lua + `lean-toolchain` walk that ignores `pmacs.project.search_boundary()` + breaks the contract `detect_project_within` exists to enforce and makes + the Stage 3 outermost-root test non-hermetic. + +### Round 2 (rev 2 → rev 3) — scope expansion + +Not review findings: the user pulled seven items out of §6 and into scope, +with the stated north star that **the arc should eventually match or +exceed what VS Code does with Lean**. Folded in, with two designs +corrected against ground truth the expansion request assumed differently: + +- **Lake version probe** (was deferred) → Q#LN7, rewritten. **Corrected: + there is no blocking process run in pmacs.** `pmacs.process` is + `spawn`/`write_stdin`/`terminate`/`list`/`status`/`events_take`/ + `forget`/`resize_pty`, all drained asynchronously off + `process.after-tick`; nothing returns output synchronously. A lazy + probe therefore cannot gate the first attach, so the design is + probe-plus-fallback-latch rather than probe-then-configure. Second + correction: **`lake` being on PATH does not mean Lean works** — see + §2.9, where the scouting machine's own `lake --version` fails. +- **Multi-root Lake scoping** (was deferred) → Q#LN15, and promoted to + its own stage. **Corrected: `root` is computed at + `builtin/runtime/lsp.lua:537`, *after* the reuse loop at `:529`–`:536`, + not before it.** The fix therefore hoists the computation above the + loop, which makes `project_root_for` run on the reuse path where it + previously did not — a real consequence for Q#LN8's function-valued + resolver, handled there. +- **`⦃⦄` / `⟮⟯` pairs** → folded into Q#LN6. +- **Lean in markdown fences** → Q#LN17. +- **`textDocument/waitForDiagnostics`** → Q#LN16. +- **Abbreviation table upkeep** → Q#LN11, as a documented process rather + than a deferral. +- **`#eval` / `#check` output channel** → Q#LN18, its own stage. +- **Module hierarchy** → Q#LN19, its own stage. + +Deliberately still deferred: the interactive infoview (`$/lean/rpc/*`) — +named as the arc's eventual destination, not its scope; the GPU goal band +(blocked on bottom-panel Stage 2); a `cursor.after-move` hook; `.olean` / +`.ilean`; and block-comment toggle, which the user confirmed belongs to +the comment arc's framing rather than this one. + +Nits corrected in round 1: the ledger-drift note (`agent-handoff.md` +omits the panel lane rather than describing it as in-review); Q#LN12's +layering (`_request_*_raw` are the Lua bindings in +`src/lua_bindings/mod.rs`; `src/lsp.rs` has `request_hover` — Stage 5 +touches both files); §2.2's chain step is +`pmacs.parse.language_from_filename`; §2.3 no longer calls +`Style::default()` entries "styled"; and bet 1's grammar count. + +### Round 3 (rev 3 → rev 4) + +Six findings against the round-2 expansion. All revision edits. + +1. **The response half of the seam was never designed** (the real hole). + Q#LN16 and Q#LN19 both awaited replies "through the Q#LN9 seam", and + acceptance pinned it — but Q#LN9 defined only `on_notification` and a + notification arm. Confirmed: **no Lua anywhere consumes + `ev.kind == "response"`**, so a `send_request` reply is drained and + dropped; `send_request` is effectively write-only from Lua. Q#LN9 now + specifies both halves, including one-shot removal-before-invoke and a + pending-response purge on server death, with acceptance mirroring the + notification-side integrity pins. +2. **The affinity key silently fragmented loose files for every + language.** `project_root_for`'s last fallback is `dir_of(path)`, so it + **never returns nil for a file with a path** — a naive + `(language_id, root)` key would give every directory of markerless + scratch files its own server, in Python and Go and TypeScript, caused + by a change made for Lean. Q#LN15 now rules: the affinity key is the + root only when a root was actually *detected*, and nil for the + fallback. Two acceptance cases pin it. +3. **An acceptance criterion was unimplementable.** `pmacs.hook` exposes + `add` / `define` / `list` / `run` and **no `remove`**, so "leaves no + `process.after-tick` subscription" could not be satisfied or tested. + Reworded to the observable: after teardown, ticks issue no request and + write nothing. +4. **Four stale cross-references survived the round-2 renumber**, despite + that round claiming reconciliation: the opening "four stages"; §2.5 + still calling multi-root a §6 deferral; Q#LN12's "only Rust in Stages + 2–4", broken three ways; and §4's row 7 omitting Stage 7's typed + request. Q#LN12 now carries a per-stage Rust table instead of a prose + claim, which is harder to get wrong on the next renumber. +5. **Q#LN7's latch had no named observation mechanism.** Added: it polls + `pmacs.lsp.list()` state on the `process.after-tick` cadence (there is + no event for "died before initialize"), it calls `pmacs.lsp.stop` + before spawning the fallback so `RestartPolicy` cannot respawn the + broken command underneath it, and it updates `command`/`args` only, + preserving user-supplied `env`/`settings`/`init_options`/`root`. +6. Wording: `\{}` expands to `{$CURSOR}`; `⦃⦄` comes from `\{{}}`. + + +## 1. What ships + +Seven stages. The north star is VS Code parity; the honest statement of +where that lands is in §5, bet 6. + +**Stage 1 — grammar, mode, and the editing table stakes.** `.lean` files +highlight, carry a `lean4` major mode, and get comment-toggle and +auto-pairing (including `⟨⟩`, `⦃⦄`, `⟮⟯`). Lean fenced blocks in markdown +highlight too. No LSP, no protocol change, no frontend change. + +**Stage 2 — multi-root LSP server affinity.** Pure substrate, no Lean +content: `ensure_server` stops reusing a server across project roots. +Independently valuable for every language pmacs supports; a prerequisite +for Lean being usable across more than one Lake package. Split out +precisely *because* it is cross-cutting — see §4. + +**Stage 3 — the Lean language server.** `pmacs.lsp.config.lean4` drives +`lake serve` with a Lake-aware outermost root, a lazy toolchain probe and +a one-shot `lean --server` fallback, and a notification-subscription seam +so `$/lean/fileProgress` has an owner. Adds +`textDocument/waitForDiagnostics`. Diagnostics, hover, completion, +goto-definition, document symbols, and semantic tokens all arrive through +the existing typed surfaces. + +**Stage 4 — the Unicode input method.** Typing `\alpha` produces `α`, +`\to` produces `→`, `\<>` produces `⟨⟩` with the point between them. +1,855 abbreviations vendored from vscode-lean4. This is the stage that +makes Lean actually typable in pmacs. + +**Stage 5 — the goal view.** A `*lean-goal*` panel that renders +`$/lean/plainGoal` at the point, refreshed on a debounced tick and on +file-progress completion, displayed through #155's +`pmacs.window.display(buf, { side = "bottom" })`. + +**Stage 6 — the `#eval` / `#check` output channel.** Lean reports command +output as *information*-severity diagnostics, which pmacs currently +squiggles and counts in the modeline. Routes them to a `*lean-output*` +panel instead, via a per-server severity policy that changes nothing for +any other language. + +**Stage 7 — module hierarchy.** `$/lean/prepareModuleHierarchy` and +`$/lean/moduleHierarchy/{imports,importedBy}` into the existing listview +panel. + +## 2. Ground truth (scouted 2026-07-24, `main` @ `e745068`) + +### 2.1 Crate facts (external, verified by downloading and reading both) + +Two candidate grammar crates exist. They are not close in quality. + +**`tree-sitter-lean4` 0.3.0** (`wvhulle/tree-sitter-lean`) — **rejected**: + +- Depends on `tree-sitter = "0.25"` **directly**, not on the shared + `tree-sitter-language 0.1` ABI crate. The workspace is on + `tree-sitter = "0.26"`, and `^0.25` excludes it, so this forks the graph + and its `Language` is a different type from ours. This is the exact + failure mode already documented for `tree-sitter-dockerfile` in + `Cargo.toml`'s comments. +- `src/lib.rs` exports only `pub fn language() -> Language`. Its README + advertises `tree_sitter_lean4::LANGUAGE.into()`, which **does not + exist** — the README is stale. +- Its `include` list is `["build.rs", "src/*", "grammar.js", "grammar/*", + "tree-sitter.json"]`. **No `queries/`.** It ships no highlights query at + all; the upstream repo's queries target Helix. +- Its `build.rs` shells out to a `tree-sitter` CLI when `src/parser.c` is + absent. `parser.c` *is* in the package, so this would not fire — but it + is a live hazard in a crate we would otherwise depend on. + +**`arborium-lean` 2.18.1** (`bearcove/arborium`) — **selected**: + +- `[dependencies] tree-sitter-language = "0.1"` and nothing else at + runtime. No second `tree-sitter` in the graph. +- `grammar/src/parser.c` declares `#define LANGUAGE_VERSION 15` and + `.abi_version = LANGUAGE_VERSION`. ABI 15 is current for tree-sitter + 0.25/0.26. Pre-generated; no CLI at build time. A 1,150-byte + `grammar/scanner.c` supplies one external token (`NEWLINE`). +- Exports `pub const fn language() -> LanguageFn`, plus + `HIGHLIGHTS_QUERY` (`include_str!("../queries/highlights.scm")`, 213 + lines), `INJECTIONS_QUERY` (empty string), and `LOCALS_QUERY` (empty + string). +- `edition = "2024"`, `rust-version = "1.85"`. The workspace is edition + 2024 / MSRV 1.95 on rustc 1.95.0. Compatible. +- **Two things to verify at implementation, not assumed here.** (a) Every + existing entry in `BUILTIN_LANGUAGES` is spelled + `tree_sitter_foo::LANGUAGE.into()` — a `LanguageFn` const. arborium + exposes a `const fn` instead, so the entry reads + `arborium_lean::language().into()`, a shape no current entry uses. + (b) arborium's README shows usage against a + `tree_sitter_patched_arborium` crate. That crate is *not* in the + dependency graph and the `LanguageFn` ABI is the shared one, so this + should be cosmetic — but the loader gets a real parse smoke test against + `tree-sitter 0.26` before the entry is trusted. + +Neither crate is first-party. `leanprover` ships no tree-sitter grammar; +Lean's own tooling parses with the Lean kernel. The upstream README of the +rejected crate says so plainly: *"Lean is a very extensible language. +Therefore, the Tree-Sitter grammar is of limited use."* That is true and it +bounds what Stage 1 can promise — see the bets in §5. + +### 2.2 The grammar table and the detection chain + +`src/syntax.rs:816` `BUILTIN_LANGUAGES` is a `&[LanguageEntry]` of +`{ name, extensions, loader, highlights_query, locals_query, +injections_query }`. Adding a grammar is one entry plus one `Cargo.toml` +line; the doc comment at `src/syntax.rs:756` says exactly this and it has +held for every grammar since. + +`builtin/runtime/syntax.lua:452` `detect_buffer_language` resolves, in +order: modeline → `pmacs.parse.language_for_path` (the grammar extension +table) → `pmacs.lsp.filetypes[ext]` → `pmacs.parse.language_from_filename` +→ shebang. A grammar entry claiming `lean` therefore resolves `.lean` +without any `pmacs.lsp.filetypes` entry; adding one would be dead weight. + +**`LanguageEntry.name` is the LSP `language_id`.** `ensure_server` at +`builtin/runtime/lsp.lua:540` passes `language_id = language` straight +into `pmacs.lsp.spawn`, and the surrounding comments (lines 70, 86, 122) +record that `c`/`cpp` and the four TS/JS entries exist as separate entries +*only* so that id is accurate. This makes the entry name a wire-visible +decision, not a label — see Q#LN2. + +### 2.3 The global capture table (the #146 trap) + +`Theme::default_dark()` at `src/highlight.rs:143` is a single flat +`&[(&str, Style)]` shared by **every** language and by LSP semantic-token +type names. `lookup()` walks dotted prefixes right-to-left, so +`@function.definition` falls back to `function`. + +Resolving arborium's Lean query against the current table: + +| Lean capture | Resolves to | Effect | +|---|---|---| +| `@comment` `@string` `@number` `@operator` `@constant` | themselves | distinct style | +| `@constant.builtin` `@property` `@attribute` | themselves | distinct style | +| `@function.definition` `@function.call` `@function.builtin` | `function` | distinct style | +| `@type.definition` | `type` | distinct style | +| `@string.special` | `string` | distinct style | +| `@keyword.conditional` `.function` `.import` `.modifier` | `keyword` | styled, but flattened | +| `@variable` | `variable` | **entry exists but is `Style::default()`** — visually plain | +| `@punctuation.special` `.bracket` `.delimiter` | `punctuation` | **`Style::default()`** — visually plain | +| `@constructor` | — | **unstyled** | +| `@character` | — | **unstyled** | +| `@warning` | — | **unstyled** | + +Blast radius of adding each name, measured over every `.scm` in the +workspace's actual dependency graph (crates confirmed present in +`Cargo.lock`): + +- **`constructor` — seven language entries, not four grammars.** The + emitting crates are `tree-sitter-javascript`, `-lua`, `-python`, and + `-rust`, but `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated + base-first into `javascriptreact`, `typescript`, and `typescriptreact` + as well (`src/syntax.rs:1009`–`1056`), so the reachable set is + `rust`, `lua`, `python`, `javascript`, `javascriptreact`, `typescript`, + `typescriptreact`. + + **And the shape is not "constructors".** Verified against the crate + queries: + + ```scheme + ; rust, python, javascript — every capitalized identifier + ((identifier) @constructor (#match? @constructor "^[A-Z]")) + + ; lua — every table-constructor brace + (table_constructor [ "{" "}" ] @constructor) + ``` + + So adding `constructor` recolors **every capitalized identifier** in + five entries (in Rust that is `Some`/`None`/`Ok`/`Err` and every + class-cased name; in Python/JS/TS every class-cased name) and **every + `{`/`}` of a Lua table literal.** That is the change being ruled on in + Q#LN4 — not a narrow constructor-only recolor. +- `character` — `tree-sitter-zig` only. +- `keyword.conditional` — `tree-sitter-cmake` and `-zig` only. Both + currently flatten to `keyword`. +- `warning` — used by **no** grammar in the graph. + +Verified clean of all four names (usable as negative-pin fixtures): +markdown, json, yaml, html, css, c, cpp, go, containerfile, make, toml, +bash. + +This is the #146 lesson verbatim: *the capture table is global, so adding a +capture name retro-paints every other language; check the reverse direction +and pin it.* + +### 2.4 The LSP substrate + +- `pmacs.lsp` already exposes generic `send_request(id, method, params)` + → request id and `send_notification(id, method, params)` + (`src/lua_bindings/mod.rs:9342`, `:9361`). Non-standard methods need no + new Rust to *send*. +- `LspEventKind` (`src/lsp.rs:264`) has generic `Notification { method, + params }` and `Response { id, result, error, method }` variants. Unknown + server methods are delivered, not dropped. +- **But `events_take` has exactly one consumer**: `handle_server_requests` + at `builtin/runtime/lsp.lua:1448`, driven off `pmacs._async.tick`. It + `take`s — a drain. Its `if/elseif` chain handles five `request` methods + and `initialized`, and **ignores every `notification` and every + `response`**. A second module calling `events_take` would steal events + from it. Any new consumer must extend that loop, not open a second one. +- **`_request_*_raw` helpers route outbound positions through + `outbound_position` (byte → negotiated encoding); a raw `send_request` + does not.** This is handoff §4's standing invariant, and it is sharper + for Lean than for any language pmacs already supports: Lean source is + saturated with non-ASCII (`α`, `→`, `⟨⟩`, `∀`), the Lean server + negotiates UTF-16 by default, and a raw byte column is wrong on + essentially every interesting line. This is why Stage 5 is not + "just call `send_request` from Lua" — see Q#LN12. + +### 2.5 Project-root detection + +`project_root_for` (`builtin/runtime/lsp.lua:513`) resolves: +`pmacs.lsp.config[language].root` → `pmacs.project.detect` → the file's own +directory. Two gaps for Lean: + +1. **No Lean marker, and no way to add one.** `default_markers()` + (`src/project.rs:145`) is `Cargo.toml`, `.luarc.json`, `pyproject.toml`, + `go.mod`, `deno.json`, `deno.jsonc`, `package.json`, `.git`. The + `pmacs.project` Lua surface is `detect`, `set_search_boundary`, + `search_boundary` — **there is no marker-registration binding.** +2. **`detect_project` returns the innermost match; Lean needs the + outermost.** `walk_for_marker` returns the first ancestor that matches. + `lean4-mode` deliberately does the opposite: + + ```elisp + (while-let ((dir (locate-dominating-file file-name "lean-toolchain"))) + (setq root dir + file-name (file-name-directory (directory-file-name dir)))) + ``` + + It keeps walking *past* each hit and takes the topmost. This matters + concretely: Lake vendors dependencies under `/.lake/packages/*`, + and each vendored package carries its own `lean-toolchain`. Opening + `/.lake/packages/batteries/Batteries/Data/List.lean` must serve + from ``, not from `batteries`. Innermost-wins gets this backwards + every time, and the symptom is a server that starts, initializes, and + then reports import errors for the whole file. + +Third, and the reason Stage 2 exists: `ensure_server` +(`builtin/runtime/lsp.lua:527`) reuses any live server with a matching +`language_id` regardless of the new file's project, so **the first `.lean` +file opened fixes the root for every later `.lean` file.** For most +languages that is an inconvenience; for Lean, where `lake serve` is bound +to one package, it is a correctness failure. Rev 1 carried this as a +deferral. **It is now Stage 2 / Q#LN15.** + +One property of `project_root_for` matters for that fix and is easy to +miss: its final fallback is `dir_of(path)`, so **it never returns nil for +a file with a path.** A markerless scratch file's "root" is its own +directory. Q#LN15's affinity rule has to account for that or it silently +changes loose-file behavior for every language. + +### 2.6 Typed-edit provenance — the only input-method-shaped seam + +`builtin/runtime/pair.lua` is the whole precedent for "react to a typed +character": subscribe to `buffer.after-edit`, gate on +`ed.this_command() == "buffer.self-insert"` (`pair.lua:213`), then take the +exact provenance record. + +`pmacs.editor.take_typed_edit()` (`src/lua_bindings/mod.rs:12798`) returns +`{ buffer, window, codepoint, char, requested_start, requested_end, +effective_start, effective_end, inserted_len, post_cursor, clean }` — or +nil. Its doc comment is explicit: + +> Consuming clears the slot: later callbacks and nested manual hook runs +> see nil, and the producer clears any untaken record when the fan-out +> returns. Per-frontend — one frontend can never take another's record. + +**It is one-shot and first-come-first-served.** `pair.lua` already consumes +it on every self-insert. A Lean abbreviation expander that independently +calls `take_typed_edit()` in the same `buffer.after-edit` fan-out gets nil +or steals it from auto-pairing, depending on hook order — and hook order is +not a contract. This is the single load-bearing constraint on Stage 4 and +the reason Stage 4 is its own PR rather than a rider on Stage 1. + +Related, from `pair.lua:30`'s Q#AP1 note: only the nine built-in pair chars +`()[]{}"'` and backtick are excluded from the frontends' optimistic +classifiers. A pair char outside that set still pairs, but its opener is a +source-peer op and its closer a daemon-peer op, so **its undo is +cross-peer-degraded**. Lean's `⟨⟩` is outside that set. + +### 2.7 Panels and generated read-only buffers + +- #155 landed on `main` at `e745068`. `pmacs.window.display(buf, { side = + "bottom", select = true })` is the placement call; `listview.lua:138` + shows the adopter shape, gated on `spec.display == "panel"`. + `pmacs.window.params()` and `pmacs.window.quit()` complete the surface. +- Read-only generated buffers use the listview idiom, documented at + `builtin/runtime/compile.lua:264`: an erroring `pmacs.buffer.add_intercept` + for user edits, with module writes passing `{ bypass_intercept = true }`. +- **Note for whoever picks this up on another machine:** the ledgers are + stale about this. `docs/active-work.md:57` still heads the lane "Stage 1 + IN REVIEW"; `docs/agent-handoff.md` §1 is stamped at #148 and **omits + the bottom-panel lane entirely**. It merged at `e745068`. That drift is + #156's business, not this lane's, but do not scout Stage 5 off either + file. + +### 2.8 Lean server facts (external, verified against `leanprover/lean4`) + +From `src/Lean/Server/FileWorker/RequestHandling.lean` and +`src/Lean/Data/Lsp/Extra.lean`: + +- `$/lean/plainGoal` — params extend `TextDocumentPositionParams`; result + is `PlainGoal { rendered : String, goals : Array String }` or null. +- `$/lean/plainTermGoal` — result `PlainTermGoal { goal : String, + range : Range }` or null. +- `$/lean/fileProgress` — a server→client **notification**, params + `{ textDocument : VersionedTextDocumentIdentifier, processing : Array + { range : Range, kind : "processing" | "fatalError" } }`. This is the + "orange bar": which regions are still elaborating. +- Also present, all deferred here: `$/lean/rpc/{connect,call,release, + keepAlive}` (the interactive widget/infoview stack), + `$/lean/prepareModuleHierarchy`, `$/lean/moduleHierarchy/{imports, + importedBy}`, `$/lean/waitForILeans`, `textDocument/waitForDiagnostics`. +- From `src/Lean/Data/Lsp/InitShutdown.lean`: + `InitializationOptions { hasWidgets? : Option Bool, logCfg? : Option + LogConfig }`. `hasWidgets?` **defaults to false**, and its documented + meaning is: when true, the server may *omit* information from standard + LSP messages because the client will fetch it interactively. A + plain-goal client wants the default. Omitting `initializationOptions` + entirely is accepted (`FromJson` maps missing/null to `none`). +- Server launch, from `lean4-mode`'s `lean4--server-cmd`: `lake serve` when + Lake ≥ 3.1.0 is found, else `lean --server`. + +### 2.9 There is no blocking process run — and `lake` on PATH proves nothing + +The complete `pmacs.process` Lua surface is `spawn`, `write_stdin`, +`terminate`, `list`, `status`, `events_take`, `forget`, `resize_pty`, +`_tick`. `ProcessSpec` (`src/process.rs:193`) carries no +wait-for-output mode, and there is no `wait_with_output` anywhere in +`src/process.rs`. Everything is asynchronous and drained off +`process.after-tick`, which is how compile mode streams. + +**Consequence: any toolchain probe is async, and cannot gate the attach +that triggered it.** A design that reads "probe, then set +`pmacs.lsp.config.lean4.command`, then attach" cannot be written against +this substrate. + +Second, and sharper — scouted on this machine: + +``` +$ elan --version +elan 4.2.1 (3d5138e15 2026-03-18) +$ lake --version +error: no default toolchain configured. run `elan default stable` to ... +$ lean --version +error: no default toolchain configured. run `elan default stable` to ... +``` + +`elan` installs `lake` and `lean` as **toolchain shims**. Both are on +PATH, both are executable, and both fail. So: + +- A version probe must parse a *failure*, not just a version string — + `lake --version` returning non-zero is a normal, common state. +- `command -v lake` is worthless as a capability check. +- The failure modes a Lean client must survive are: lake absent, lake + present but shimmed with no toolchain, lake present and working but too + old, and lake working but the directory is not a Lake package. Only the + third is a *version* question. +- **Acceptance cannot assume a working Lean toolchain exists.** Every + Stage 3+ test runs against the fake LSP server; a live `lake serve` + smoke is PATH-gated *and* success-gated, following the #123 JSON/YAML + provider-smoke pattern. + +### 2.10 Information-severity diagnostics are squiggled and counted + +`src/diag.rs:50` defines `DiagnosticSeverity` with `Information` mapped +from LSP severity `3` (`:103`). Information diagnostics get the +`ui.diag.info` face (`:408`), a `UnderlineStyle::Single` squiggle +(`:426`), and a slot in the severity count tuple (`:223`) that feeds the +modeline. + +Lean reports every `#eval`, `#check`, `#print`, and `example` result as an +information-severity diagnostic at the command's position. Under the +current surface those render as underlined "problems" with a gutter sign +and a modeline count — which is why rev 1 called them noise. The claim is +now specific: they are not merely unstyled, they are **actively +mis-rendered as defects**, and the count misleads. + +The publish path absorbs into the Rust store *and* still delivers the +notification to `events_take`, so Lua can observe them; but suppressing +them from the store needs a Rust-side policy, not a Lua filter. Q#LN18. + +## 3. Decisions + +### Q#LN1 — Bundle `arborium-lean` 2.18; reject `tree-sitter-lean4` + +Per §2.1. The decision is forced by the dependency graph, not by taste: +`tree-sitter-lean4`'s `tree-sitter ^0.25` cannot coexist with the +workspace's 0.26. Its missing queries and stale README are secondary. + +Risk accepted: `arborium-lean` is a third-party republish from a grammar +collection, not the grammar's upstream. `codebook-tree-sitter-latex` (#144) +set this precedent for exactly the same reason — no usable first-party +crate exists. The mitigation is the same: pin a real parse + highlight +smoke test so a bad republish fails our suite, not a user's file. + +### Q#LN2 — Name the entry `lean4`, not `lean` + +`LanguageEntry.name` becomes the `didOpen` `language_id` (§2.2), and the +Lean ecosystem's id is `lean4` (vscode-lean4 uses it; `lean` is Lean 3). +The grammar's C symbol is `tree_sitter_lean`, but that is arborium's +business — the entry name is ours to choose. + +Consequences, all deliberate: `pmacs.comment.strings.lean4`, +`pmacs.pair.sets.lean4`, `pmacs.lsp.config.lean4`, and a mode line reading +`lean4`. An Emacs `-*- mode: lean -*-` or a Vim `ft=lean` modeline is +normalized to `lean4` through `pmacs.parse.modeline_aliases`, so neither +spelling strands a file. + +### Q#LN3 — Extensions: `lean` only + +Not `.olean` (compiled binary artifacts — opening one as text is never +what the user wants) and not `.ilean` (JSON metadata; if anything it +belongs to the `json` entry). + +### Q#LN4 — Add four capture entries and pin the retro-paint in both directions + +**Ruled (round 1): add to the global table.** The alternative is an in-repo +overlay, and there is no partial option — see below. + +Add to `Theme::default_dark()`: `constructor`, `character`, +`keyword.conditional`, and `warning`. + +Rationale, per name: + +- **`constructor`** is the consequential one. Per §2.3 it reaches seven + language entries and its real effect is *"recolor every capitalized + identifier in five entries and every `{}` in Lua"*. That is stated + bluntly because it is the decision, not a side effect. It is + nevertheless the right call: capitalized-identifier-as-constructor is + the mainstream editor convention (it is what nvim-treesitter, Helix, and + Zed all render off these same queries), those tokens are currently + *unstyled* rather than deliberately plain, and Lua's braces gaining a + colour is a cosmetic difference on a token that today renders as default + text. The cost of avoiding it is owning a forked 213-line query forever. +- `character` — `tree-sitter-zig` only, currently unstyled. +- `keyword.conditional` currently flattens to `keyword`. Giving it + `keyword.control`'s brighter style makes Lean's `if`/`then`/`else`/ + `match`/`do` read the way Rust's already do, and reaches cmake and zig + the same way. +- `warning` has zero blast radius and gives Lean's `(sorry)` — an + unproved goal, the single most important thing to see in a proof file — + a visible style. + +All four are pinned in the reverse direction exactly as #146 required — +acceptance 7 asserts the retro-paint *happened* on each affected language, +acceptance 8 asserts it did not leak into languages that emit none of the +four names. + +**Rejected alternative:** an in-repo query overlay +(`builtin/queries/lean4/highlights.scm`) rewriting the capture names into +the existing vocabulary, the #144 LaTeX pattern. It avoids touching the +global table, but it forks a 213-line query we would then own and +hand-merge on every arborium bump. Overlays are for grammars whose crate +ships *no* usable query; arborium ships one. + +**There is no middle option.** Styling Lean's constructors without +touching the other seven entries requires renaming the capture, which +requires the overlay, which forks the query. The choice is binary: accept +the retro-paint, or own the fork. + +### Q#LN5 — Comments: `--` only in Stage 1 + +`pmacs.comment.strings.lean4 = "--"`. Lean's block comment is `/- ... -/` +and its docstring is `/-- ... -/`; block-comment toggling is an existing +named deferral of the comment arc (`docs/comment-toggle-framing.md`) and +this lane does not front-run it. + +### Q#LN6 — Pair set includes `⟨⟩`, and the degradation is named + +`pmacs.pair.sets.lean4 = { "()", "[]", "{}", "⟨⟩", "⦃⦄", "⟮⟯", '""' }`. + +`⟨⟩` (anonymous constructor) is among the most-typed constructs in Lean and +omitting it would make the pair set feel broken. It is outside the nine +built-in pair chars, so per §2.6 its undo is cross-peer-degraded. That is a +documented, pre-existing limitation of user-extended pairs whose general +fix is chronological cross-peer undo arbitration — already on the standing +backlog. Ship it; name it in the module comment. + +`''` is excluded: Lean uses `'` as a primed-identifier suffix (`h'`, +`foo'`), so pairing it would fight the user constantly. Same reasoning that +excludes it for Rust. + +`⦃⦄` (strict implicit binder) and `⟮⟯` ride along — one list entry each, +same degradation, and both have abbreviation keys (`\{{}}`, `\([])'`) so +omitting them would make the input method produce brackets the pair set +does not understand. + +### Q#LN7 — `lake serve` by default, with a lazy probe **and** a failure latch + +```lua +pmacs.lsp.config.lean4 = pmacs.lsp.config.lean4 or { + command = "lake", + args = { "serve" }, +} +``` + +`lean4-mode` probes Lake's version and falls back to `lean --server` below +3.1.0. This lane does the same, but **lazily and asynchronously**, and +pairs it with a failure latch — because §2.9 makes probe-alone +insufficient in two independent ways. + +**Where the probe runs.** Not at init: `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. It runs on the first `.lean` attach, in +`builtin/runtime/lean.lua`, cached for the session. + +**Why the probe cannot gate the first attach.** There is no blocking +process run (§2.9). `pmacs.process.spawn` + `events_take` off +`process.after-tick` is the only shape available, so the probe's verdict +arrives *after* `ensure_server` has already had to decide. This is the +correction to the round-2 request, which assumed the verdict could be +consulted before configuring. + +**The design that follows:** + +1. First `.lean` attach spawns `lake serve` optimistically and fires + `lake --version` alongside it, with `cwd` at the resolved Lake root. +2. If the probe reports a version below 3.1.0, or the server dies before + `initialize` completes, a **one-shot latch** swaps in `lean --server` + and restarts once. The latch is per session and never re-arms. + + **How the latch observes failure.** There is no event for "exited + before initialize" — the drain ignores state events. The latch polls + `pmacs.lsp.list()` for the server's `state.kind` on the same + `process.after-tick` cadence Q#LN13 uses, and treats + `crashed`/`stopped` reached without an intervening `initialized` as + the trigger. (Q#LN9's pending-response purge fires on the same + transition, so anything already awaiting a reply fails cleanly rather + than hanging.) + + **Interplay with `RestartPolicy`.** The manager will otherwise respawn + the same broken command underneath the latch, producing a loop the + latch cannot see the end of. So the latch calls `pmacs.lsp.stop` on + the failing server *first*, then swaps the config, then spawns — the + fallback is a fresh server, not a restart of the old one. + + **The swap is a field update, not a table replacement.** It rewrites + only `command` and `args`, preserving any user-supplied `env`, + `settings`, `init_options`, and `root` on `pmacs.lsp.config.lean4`. A + wholesale table replacement would silently discard a user's + `init.lua` configuration at exactly the moment they are least likely + to notice. +3. If `lean --server` also fails, the error surfaces through the ordinary + `pmacs.lsp.last_error` path. pmacs does not attempt to install a + toolchain. + +**Why a latch and not just a probe.** Per §2.9 the failure modes are lake +absent, lake shimmed-with-no-toolchain, lake too old, and +not-a-Lake-package. **Only the third is a version question**, and the +scouting machine exhibits the second — `lake --version` there exits +non-zero with `error: no default toolchain configured`. Since the failure +path must exist regardless, the probe's job shrinks to the one case +failure detection would otherwise handle slowly (an old-but-working lake +that starts a useless server). Probe and latch are complements, not +alternatives. + +**Named risk.** The optimistic first spawn means a user on a +lake-less-but-lean-ful toolchain sees one failed spawn before the +fallback. That is a one-line status message, once per session, and it +buys not blocking every other user's first attach behind a process +round-trip. + +No `init_options`. Per §2.8, `hasWidgets?` defaults to false and that is +the correct value for a client that reads plain goals out of standard +messages. + +### Q#LN8 — Lake-aware root via a **function-valued** `config.root` + +Generalize `project_root_for` (`builtin/runtime/lsp.lua:513`) so +`pmacs.lsp.config[lang].root` may be a `function(path) -> string|nil` as +well as a string, and implement Lean's resolver in +`builtin/runtime/lean.lua`: walk up from the file's directory collecting +every ancestor containing `lean-toolchain`, and return the **outermost**; +fall back to `pmacs.project.detect`, then the file's directory. + +**The walk stops at `pmacs.project.search_boundary()`.** This is not +optional politeness: `detect_project_within` (`src/project.rs:213`) exists +precisely so a stray marker above a temp fixture cannot leak into +detection, and a Lua walk that ignores the boundary breaks that contract — +including for acceptance 23, whose outermost-root assertion is otherwise +non-hermetic against any `lean-toolchain` that happens to sit in an +ancestor of the test's tempdir. + +Why this and not the two alternatives: + +- *Adding `lean-toolchain` to Rust's `default_markers()`* does not work — + `detect_project` is innermost-wins by construction (§2.5), and inverting + it globally would change Rust/Go/Node root detection for every user. +- *A `pmacs.project.add_marker` Lua binding* is a bigger new surface than + this lane needs and still leaves the innermost/outermost problem. + +The function-valued `root` is ~3 lines in `lsp.lua`, is a strict +generalization (a string still works), and puts the Lean-specific rule in +the Lean module where it belongs. + +### Q#LN9 — Notification **and response** subscription seams in the existing dispatch + +Per §2.4 there is exactly one `events_take` consumer, and its `if/elseif` +chain handles five `request` methods plus `initialized`. It ignores +notifications **and responses** — and no Lua anywhere in the runtime +consumes `ev.kind == "response"`. So today a `send_request` reply is +drained and dropped on the floor: **`send_request` is effectively a +write-only API from Lua.** + +Rev 2 specified only the notification half. That was a hole, since +Q#LN16 (`waitForDiagnostics`), Q#LN19 (`imports` / `importedBy`), and +Q#LN12's typed goal request all await replies. Both halves ship in +Stage 3. + +```lua +pmacs.lsp.on_notification(method, fn) -- fn(sid, params); persistent +pmacs.lsp.on_response(sid, request_id, fn) -- fn(result, err); ONE-SHOT +``` + +Routed from two new arms of the existing loop: + +- `elseif ev.kind == "notification"` → every subscriber registered for + `ev.method`. +- `elseif ev.kind == "response"` → the one-shot registered for + `(sid, ev.id)`, **removed before it is invoked** so a raising handler + cannot be re-entered. + +Each handler is `pcall`ed, so one raising subscriber cannot stall the +drain or starve the `request` arms that share it. + +**Pending-response lifetime.** A one-shot whose server dies never fires on +its own, leaking the registration and hanging whatever awaits it. On +`crashed` / `stopped` / `restarting` for a `sid`, every pending one-shot +for that `sid` is invoked with an error and cleared. This is what lets +Stage 5's in-flight tracking (acceptance 52) be honest rather than +optimistic, and it is what Q#LN7's latch observes (below). + +Explicitly **not** a second `events_take` caller — a second drain would +steal events from `handle_server_requests`. The tests pin that in both +directions: a Lean subscriber must not cause `workspace/applyEdit` to be +missed, and a raising subscriber must not stop later events in the same +drain. + +Stage 3 registers `$/lean/fileProgress` on the notification seam and +`waitForDiagnostics` on the response seam; stages 5 and 7 use the response +seam for `plainGoal` and the hierarchy calls. + +### Q#LN10 — Stage 4 mechanism: one shared provenance read, not two + +The hazard is §2.6 — `take_typed_edit()` is one-shot and `pair.lua` +already consumes it. + +Decision: **`pair.lua` stops being the sole consumer.** Extract the +provenance read into a single `buffer.after-edit` subscriber owned by a +small shared module, which takes the record once and passes it to an +ordered list of typed-edit consumers (auto-pair, Lean abbreviation). +Consumers return whether they handled the edit; the first that does stops +the chain. + +Two consequences worth stating up front: + +- This touches `pair.lua`, which is load-bearing for auto-pairing + acceptance. The full pairing suite is a required gate for Stage 4, and + the refactor lands *first*, as its own commit with no behavior change, + so a regression bisects cleanly. +- Ordering is a contract, not an accident, and the collision is real: + **64 of the 1,855 abbreviation keys contain a character in the proposed + `lean4` pair set** — `\[[]]` → `⟦⟧`, `\(())` → `⸨⸩`, `\{{}}` → `⦃⦄`, + `\{}` → `{$CURSOR}`. With pairing first, typing `\[` inserts `[]` + with the point between, so the pending key is corrupted to `\[]` before + the second `[` is ever typed and `\[[]]` becomes unreachable. The + abbreviation consumer runs first. + + (Rev 1 justified this with `\<>`, which was wrong: `<` is not in the + pair set per Q#LN6, so that key is safe under either order.) + +**The contract that collision exposes:** the abbreviation consumer must +claim a self-insert that **extends an open pending abbreviation**, not +only one that completes an expansion. A consumer that only claims +completed expansions hands every intermediate keystroke to auto-pairing, +which is exactly how `\[` gets corrupted. "Claimed" here means the chain +stops, not that an edit was made. + +Expansion semantics (matching vscode-lean4 and `lean4-input`): + +- `\` opens a pending abbreviation, tracked per buffer with its start + offset. Every subsequent self-insert that extends it is claimed. The + pending state is abandoned on any non-self-insert command, buffer + switch, or cursor move away from the pending region. +- Expansion fires on a unique complete match that no longer key extends, + or on an explicit terminator (space, tab, RET, or a second `\`). +- The vendored table's `$CURSOR` placeholder becomes the point position + after the replace — this is how `\<>` yields `⟨|⟩`. +- The whole expansion is **one `buf:replace`** — one undo step, one CRDT + op, one effective-edit verification. Same discipline as + `comment.lua`'s Q#CT5. +- Gated by `pmacs.config.define{ name = "lean.abbrev", type = "boolean", + default = true, mutability = "live" }`, read against the *source* buffer + of the typed edit — the `editing.auto-pair` precedent (`pair.lua:44`), + including its round-2 correction to resolve `rec.buffer` rather than + `pmacs.window.buffer()`. + +### Q#LN11 — Stage 4 data: vendor the table, generated, attributed + +`abbreviations.json` in `leanprover/vscode-lean4` is a flat +`string → string` object of **1,855 entries** (counted, not estimated), +of which **64 contain a character in the `lean4` pair set** — the +collision Q#LN10's ordering exists to handle. vscode-lean4 is Apache-2.0. + +Vendor it as a generated `builtin/runtime/lean_abbrev.lua` with a header +recording source repo, commit, license, and the regeneration command — +the `builtin/queries/latex/highlights.scm` precedent (#144) for +third-party data, extended with provenance because this is a much larger +artifact under a named license. + +Not fetched at runtime, not a package-manager dependency: the input method +must work offline and on first launch. + +**Upkeep is a documented manual process, not code.** There is no automatic +sync and none is wanted — an editor that silently re-downloads its input +method has a supply-chain problem, not a feature. The generator script +lives at `scripts/regen-lean-abbrev`, takes a vscode-lean4 commit as its +argument, and rewrites the file including its provenance header. The +header records source commit, license, entry count, and the regeneration +command, so the file is self-describing to whoever next touches it. A +refresh is an ordinary PR with a visible diff — which is the point: the +diff is the review. + +### Q#LN12 — Stage 5 sends `$/lean/plainGoal` through a typed Rust request + +Per §2.4, `send_request` does **not** route positions through +`outbound_position`. Lean negotiates UTF-16 and Lean source is +overwhelmingly non-ASCII, so a Lua-built byte column would be wrong +wherever it matters most. + +Stage 5 therefore adds a typed request that reuses `outbound_position` +unchanged. It spans **two files**, because the `_raw` naming is a layer +boundary, not a module: + +- `src/lsp.rs` — `request_plain_goal`, alongside `request_hover` + (`src/lsp.rs:1690`) and `request_definition` (`:1733`), which is where + `outbound_position` is actually applied. +- `src/lua_bindings/mod.rs` — the `_request_plain_goal_raw` binding, + alongside the `_request_hover_raw` family (`:9501`–`:9823`). + +It is a thin builder — the result is passed through as JSON and parsed in +Lua, since `PlainGoal` is two fields and does not warrant a typed store. + +It exists specifically to honor handoff §4's standing invariant rather +than quietly reintroduce the bug it was written to prevent. + +**Where the arc's Rust actually lives** (rev 2 stated this in pre-renumber +stage numbers and was wrong three ways): + +| Stage | Rust | +|---|---| +| 1 | `Cargo.toml` + `BUILTIN_LANGUAGES` entry + Q#LN4's four capture entries | +| 2 | `lsp.list()` row builder (`mod.rs:9919`) | +| 3 | **none** — Lua only | +| 4 | **none** — Lua only | +| 5 | `request_plain_goal` + its binding | +| 6 | `LspServerSpec` severity-policy field and its publish-path honoring | +| 7 | `request_prepare_module_hierarchy` + its binding | + +Stages 3 and 4 — the two largest Lean-specific stages — are entirely Lua. + +### Q#LN13 — Stage 5 goal panel shape + +- `*lean-goal*`, read-only via the erroring-intercept idiom, module writes + with `{ bypass_intercept = true }` (§2.7). +- Displayed with `pmacs.window.display(buf, { side = "bottom", select = + false })`. `select = false`: a goal view that steals focus on every + cursor move is unusable. +- **Refresh mechanism, named explicitly because there is no motion hook.** + The complete hook inventory is `buffer.{after-edit,after-load, + after-save,after-switch,before-save}`, `editor.before-quit`, + `frontend.detached`, and `process.after-tick`. Nothing fires on cursor + movement. Stage 5 therefore refreshes from a **debounced poll off + `pmacs.hook.add("process.after-tick", …)`** — the cadence pattern + autosave (`autosave.lua:139`) and compile (`compile.lua:687`) already + use — comparing the point against the last position it queried and + issuing at most one in-flight `$/lean/plainGoal` at a time. + + This is written down so Stage 5 cannot quietly grow either an + unframed polling loop or new hook substrate. A `cursor.after-move` hook + would be the better long-term answer; it is out of scope here and is + named in §6. +- Also refreshed on a `$/lean/fileProgress` notification whose + `processing` array no longer covers the point's range. +- Content: `PlainGoal.rendered` when present, "no goals" when the result is + null with the file elaborated, "elaborating…" when file-progress still + covers the point. The three states are distinct and the middle one is the + one users actually need to trust. +- Keys under `pmacs.keymap.bind { scope = "mode", mode = "lean4", … }` + (#129's mode-scoped keymaps). + +### Q#LN14 — No protocol change in any stage + +Stages 1–4 and 7 touch no wire surface at all. Stage 5's panel rides #155 +Stage 1, which is grid-only and bumped nothing; Stage 6 adds a field to +the in-process `LspServerSpec`, which is not wire. A GPU-rendered goal band +needs bottom-panel Stage 2, which is itself unframed — so the GPU half is +deferred, not attempted. Protocol stays v20. + +### Q#LN15 — Multi-root server affinity (Stage 2, substrate) + +Today `ensure_server` reuses any live server whose `language_id` matches, +**regardless of project root** (`builtin/runtime/lsp.lua:524`–`536`, whose +own comment documents this as a known limitation). For Lean this is not a +rough edge but a correctness failure: `lake serve` is bound to one Lake +package, so the second package a user opens gets a server that cannot +resolve its imports. + +The change is small and spans two files: + +- **`src/lua_bindings/mod.rs:9919`** — the `lsp.list()` row builder sets + `id`/`label`/`language_id`/`command`/`state`/`attempt`. Add `root_uri` + from `spec.root_uri` (already `Option` on `LspServerSpec`, + `src/lsp.rs:125`) and `cwd`. Bump the `create_table_with_capacity` + hint. +- **`builtin/runtime/lsp.lua:521`–`551`** — hoist `local root = + project_root_for(language, path)` **above** the reuse loop and match on + the `(language_id, root_uri)` pair. + +**Correcting the round-2 request:** `root` is currently computed at +`:537`, *after* the loop, not before it. Hoisting is therefore part of the +change, and it has a consequence: `project_root_for` begins running on the +reuse path, where it previously ran only on spawn. For Q#LN8's +function-valued Lean resolver — which walks the filesystem — that means +once per attach rather than once per spawn. The resolver memoizes per +directory for the session. + +**Comparison rule, part 1 — hand-spawned servers.** Compare +`info.root_uri` against the request's affinity key, with nil matching nil. +A server spawned directly from `init.lua` with only `cwd` set has +`root_uri = nil` and will therefore *not* match a root-bearing request — +it gets a new server rather than being silently adopted. Conservative and +deliberate, but a behavior change, so acceptance asserts it. + +**Comparison rule, part 2 — markerless files must not fragment.** Per +§2.5, `project_root_for` **never returns nil for a file with a path**: its +last fallback is `dir_of(path)`. A naive `(language_id, root)` key +therefore gives *every directory of markerless scratch files its own +server*, for **every language** — two loose `.py` files in different +directories would spawn two pyrights where today they share one. That is a +silent regression for Python, Go, TypeScript and everyone else, caused by +a change made for Lean. + +Ruling: **the affinity key is the root only when a root was actually +detected.** `project_root_for` returns `(root, source)` with `source` one +of `"config"`, `"detected"`, or `"fallback"`; the affinity key is `root` +for the first two and **`nil` for `"fallback"`**. The directory is still +passed as `cwd` / `rootUri` exactly as today — only the *matching* key +changes. + +Consequences, both intended: + +- Files in a real project (Cargo/Lake/go.mod/…) get one server per root — + the fix. +- Markerless loose files keep today's single shared server per language — + no change, which is the point. + +**Rejected alternative:** keying on the fallback directory anyway and +accepting per-directory servers. It fragments the common scratch-file case +for every language in the editor to buy nothing for Lean, whose files are +essentially always in a Lake package. + +**Blast radius, stated plainly.** This is the central server-affinity +function for *every* LSP language in pmacs. A bug here routes a file to +the wrong server: diagnostics land on the wrong buffer, or a redundant +server spawns. This is why it is Stage 2 and its own PR, with no Lean +content in the diff — a cross-cutting change to every language's server +affinity must not be reviewable only as a Lean feature. + +**Named risk: unbounded server growth.** Per-root affinity means opening +files across N Lake packages spawns N `lake serve` processes, and Lean +elaboration is memory-hungry. rust-analyzer has the same property and no +editor caps it by default. No cap ships here; `pmacs.lsp.stop` is the +manual escape, and an LRU reaping policy is named in §6. + +### Q#LN16 — `textDocument/waitForDiagnostics` (Stage 3) + +A plain request (no position, so no `outbound_position` concern — Q#LN12 +does not apply). It resolves when the server has finished elaborating the +document. + +Two uses, in order of importance: + +1. **Deterministic acceptance.** Lean elaboration is slow and + asynchronous; a test that sleeps is flaky and a test that polls is + slow. This is the seam that makes a live `lake serve` smoke + deterministic when a toolchain happens to be present. +2. A `M-x lean-wait-for-diagnostics` command, and a gate for the goal + panel's "elaborating" state (Q#LN13) that is cheaper than parsing + `$/lean/fileProgress` ranges. + +Sent through `pmacs.lsp.send_request` and awaited through the Q#LN9 +notification/response seam. ~20 lines. + +### Q#LN17 — Lean in markdown fences (Stage 1) + +The injection engine (#122) resolves fence names through +`pmacs.parse.injection_aliases`, a case-folded, Lua-extensible map +snapshotted into `ParseRequest`. Register `lean` and `lean4` → `lean4`. + +Two lines, and it is the one place where the Lean 3 spelling is +deliberately *not* normalized away: a ` ```lean ` fence is overwhelmingly +Lean 4 in practice, and mapping it to the `lean4` grammar is right. + +`lean4-mode` does the equivalent through `markdown-code-lang-modes`. Being +in Stage 1 means Lean blocks in this repo's own docs highlight from the +first PR. + +### Q#LN18 — `#eval` / `#check` output channel (Stage 6) + +Per §2.10, Lean's command output arrives as information-severity +diagnostics and pmacs squiggles them, signs them in the gutter, and counts +them in the modeline. VS Code shows them in the infoview instead. This +stage routes them. + +Decision: **a per-server severity policy on the spec, not a Lua filter.** +The publish path absorbs into the Rust `DiagnosticStore` before Lua sees +the notification, so a Lua-side filter would suppress the *display* while +leaving the store's counts wrong. Add an optional +`diagnostic_severity_policy` to `LspServerSpec` — default "all severities +to the store", which is a no-op for every existing language — and have the +Lean config route `Information` to the output channel only. + +The channel itself is a `*lean-output*` buffer using the same read-only +generated-buffer idiom as Q#LN13, appended to in position order and +cleared per publish for the owning document. + +Deliberately *not* merged into the goal panel: a goal is a property of the +point, output is a property of the file, and the two refresh on different +triggers. Merging them is what makes VS Code's infoview complicated. + +### Q#LN19 — Module hierarchy (Stage 7) + +`$/lean/prepareModuleHierarchy` at the point returns hierarchy items; +`$/lean/moduleHierarchy/imports` and `.../importedBy` expand one in either +direction. Rendered with `pmacs.listview.open{ name, header, rows, +on_visit, on_refresh, display = "panel" }` (`listview.lua:111`) — the same +panel the LSP references/outline views already use. + +`prepareModuleHierarchy` is position-bearing, so it goes through the +Q#LN12 typed-request path; the two expansion calls take an item, not a +position, and can use `send_request` directly. + +Last stage because it is the least load-bearing: it is navigation +convenience, and nothing else in the arc depends on it. + +## 4. Stage boundaries and why this order + +Each stage is one branch, one PR, and is independently useful if the next +never lands. + +| Stage | Ships | Substrate risk | Depends on | +|---|---|---|---| +| 1 | grammar, mode, comments, pairs, md fences | new crate; **global capture table** | — | +| 2 | multi-root server affinity | **`ensure_server`, shared by every language** | — | +| 3 | `lake serve` + probe/latch, Lake root, notification seam, `waitForDiagnostics` | two `lsp.lua` generalizations | 1, 2 | +| 4 | Unicode input method | **refactors `pair.lua`'s provenance read** | 1 | +| 5 | goal panel | new typed LSP request; panel adopter | 3 | +| 6 | `#eval` / `#check` output channel | **new `LspServerSpec` policy field** | 3, 5 | +| 7 | module hierarchy | listview adopter + one typed Rust request | 3 | + +Three of the seven carry risk that is *not* about Lean — stages 1, 2, and +6 each change something every language touches. That is the organizing +principle of the split: **no PR in this arc mixes a cross-cutting +substrate change with Lean feature content.** A reviewer looking at Stage +2 sees only `ensure_server`; a reviewer looking at Stage 3 sees only Lean. + +Ordering notes: + +- **Stage 2 has no Lean in it and could ship independently of this arc.** + It is sequenced here because Lean is the language that makes its absence + a correctness bug rather than an inconvenience, and because Stage 3's + acceptance would otherwise have to encode the broken behavior. +- **Stage 4 does not depend on stages 2–3** and could run in parallel, but + should not: both touch `lsp.lua`/`pair.lua`-adjacent runtime files, and + the #126/#127 lesson is that parallel-safety requires the file split be + agreed *before* either lane starts. Sequential is cheaper. +- **Stage 6 depends on Stage 5** only for the read-only generated-buffer + and panel machinery, which Stage 5 establishes. If Stage 5 slips, Stage + 6 can carry that machinery itself at the cost of duplicating it. + +Stage 1 is deliberately shippable alone. If the arborium grammar turns out +to be worse in practice than its query suggests (see §5, bet 3), that is +discovered at Stage 1 for the cost of Stage 1 — and stages 2 through 7 are +almost entirely independent of grammar quality, since they are driven by +the language server rather than the parse tree. + +## 5. Categorical bets + +Stated so they can be scored, per house style. + +1. **`arborium-lean`'s ABI-15 parser loads under `tree-sitter 0.26` with a + single `tree-sitter` in the graph.** Falsified by `cargo tree -d` + showing a duplicate, or by the loader failing `Parser::set_language`. + Confidence: high — `tree-sitter-language 0.1` exists precisely for this + and roughly fifteen shipped grammars already rely on it. +2. **No protocol change in any stage.** Falsified by any new wire variant. + Confidence: high. +3. **The grammar is good enough that highlighting reads as correct on + ordinary Lean, including Mathlib-style files.** This is the weakest bet + in the lane, and the upstream author's own warning is the reason: Lean's + syntax is user-extensible via macros, so a static grammar necessarily + mis-parses custom notation. Scored against a real fixture set at Stage 1 + acceptance. If it fails, Stage 1 still ships — degraded highlighting on + exotic notation is strictly better than none — but the framing is + revised to say so plainly rather than overselling it. +4. **`$/lean/plainGoal` alone is a useful goal view, without the + `$/lean/rpc/*` widget stack.** Confidence: medium-high — it is exactly + what `lean4-mode` shipped for years before infoview widgets, and + `hasWidgets? = false` is a supported client posture, not a hack. +5. **The abbreviation expander needs no Rust.** Falsified if the one-shot + provenance refactor (Q#LN10) cannot be done in Lua, or if `buf:replace` + inside `buffer.after-edit` re-enters the hook in a way pairing does not + already survive. Confidence: medium — pairing does the same thing, but + over a single codepoint rather than a multi-byte span. +6. **These seven stages reach rough VS Code parity for everything except + the interactive infoview.** Scored honestly rather than aspirationally. + What lands: highlighting, goal view, Unicode input, diagnostics, + hover, completion, goto-definition, symbols, semantic tokens, `#eval` + output, module hierarchy, correct multi-package roots. What does + **not**: interactive/collapsible goals, `Try this` code-action + suggestions, widgets, the term-mode goal on hover, and the + `$/lean/rpc/*` session that powers all of them. That gap is real and + is the arc's eventual destination (§6) — a framing that claimed parity + without it would be overselling. +7. **Stage 2's affinity change breaks no existing language.** Falsified by + any regression in the Rust/Python/Go/TS acceptance suites, or by a + user's hand-spawned server no longer being adopted in a way they + relied on. Confidence: medium-high for the suites, deliberately lower + for hand-spawned servers — Q#LN15's comparison rule changes that case + on purpose, and the acceptance pins it rather than hiding it. + +## 6. Deferred (named) + +Pruned in round 2 — seven former entries are now stages 1–7 (see §0.1). +What remains deferred: + +- **Interactive infoview** — `$/lean/rpc/{connect,call,release,keepAlive}`, + widgets, collapsible goal trees, `Try this` code actions, term-mode goal + on hover. **This is the arc's eventual destination, not a rejection.** + It needs `hasWidgets? = true`, a real RPC session lifecycle with + keep-alive, and a rendering surface for structured rather than plain + goals — plausibly its own multi-stage arc once stages 1–7 are in. Bet 6 + scores what its absence costs. +- **GPU goal band** — blocked on bottom-panel Stage 2 (Q#LN14). The panel + is grid-only until then. +- **A `cursor.after-move` hook** — there is none (Q#LN13), so Stage 5 + polls off `process.after-tick`. A real motion hook would serve the goal + view, `completion.lua`'s cursor-delta heuristic, and the outline/hover + panels alike; it is substrate work that should not be invented inside a + language lane. +- **LSP server reaping / LRU** — Q#LN15's per-root affinity makes + unbounded `lake serve` growth possible. No editor caps this by default + and pmacs will not either in this arc, but the policy question is now + live in a way it was not before. +- **Block-comment toggle** (`/- -/`) and **docstring awareness** + (`/-- -/`) — confirmed as owned by the comment arc's framing, not this + one. +- **`.olean` / `.ilean` handling** (Q#LN3). +- **Lean 3 support** — `.lean` files predating Lean 4 will mis-parse. + Out of scope permanently; Lean 3 is end-of-life. + +## 7. Acceptance + +**Stage 1** + +1. `cargo tree -d` shows exactly one `tree-sitter` version after adding + `arborium-lean`. +2. A `.lean` fixture parses: the loader produces a tree whose root node is + `module` and which is not all-ERROR. +3. Opening `foo.lean` sets `pmacs.buffer.major_mode` to `lean4`. +4. An Emacs `-*- mode: lean -*-` modeline and a Vim `ft=lean` modeline both + resolve to `lean4`. +5. Highlighting produces non-default styles for a comment, a `def` name, a + `theorem` name, a string, and a numeric literal in the fixture. +6. `(sorry)` picks up the `warning` style. +7. **Reverse-direction positive pin (#146).** Every language the four new + capture entries reach asserts its *expected delta* — not that nothing + moved, since these languages necessarily move: + - `rust`, `python`, `javascript`, `javascriptreact`, `typescript`, + `typescriptreact`: a capitalized identifier (`Some`, `MyClass`) picks + up the `constructor` style. All seven entries are covered because + `HIGHLIGHT_QUERY` composition means the JS-family entries inherit the + rule rather than restating it — a regression in composition would + otherwise go unseen. + - `lua`: a table literal's `{` and `}` pick up the `constructor` style. + - `zig`: a character literal picks up `character`; a conditional picks + up `keyword.conditional`. + - `cmake`: a conditional picks up `keyword.conditional`. +8. **Reverse-direction negative pin.** Fixtures in languages verified to + emit **none** of the four capture names render byte-identically to + their pre-change baseline: `markdown`, `json`, `yaml`, `html`, `css`, + `c`, `cpp`, `go`, `toml`, `bash`. + + Rev 1 named Lua and Python here, which was a self-contradiction: both + are retro-painted by `constructor`, so a fixture that did not move + would have been vacuous — the #155 R2 assertion shape. Whichever + fixtures ship, the negative pin must be shown non-vacuous by + confirming it *fails* when a capture the language does emit is added. +9. `M-;` comments and uncomments a Lean line with `--`. +10. Typing `⟨` inserts `⟨⟩` with the point between; likewise `⦃` and `⟮`. + Typing `'` after an identifier does **not** pair. +11. A ` ```lean ` fence and a ` ```lean4 ` fence in a markdown buffer both + highlight as Lean (Q#LN17); a fence with an unknown name still does + not. +12. **No live toolchain required.** The whole Stage 1 suite passes on a + machine with no `lean`, no `lake`, and no configured elan toolchain + (§2.9) — Stage 1 touches no process at all. + +**Stage 2 — multi-root affinity (no Lean content)** + +13. `pmacs.lsp.list()` rows carry `root_uri` and `cwd`. +14. Two files of the **same language in different project roots** spawn + **two** servers, each with its own `rootUri`. Exercised with the fake + server so it is toolchain-free. +15. Two files of the same language in the **same** root reuse **one** + server — the pre-change behavior, pinned so the fix does not become + "always spawn". +16. **Regression pin, per language:** the existing Rust, Python, Go, and + TypeScript attach paths behave unchanged for the single-root case + that is all they exercised before. +17. **Hoist pin:** `project_root_for` is called on the reuse path, and a + function-valued `root` is invoked at most once per directory per + session (Q#LN15's memoization) rather than once per attach. +18. **Hand-spawned server pin:** a server spawned from `init.lua` with + `cwd` but no `root_uri` is *not* adopted by a root-bearing attach — + the deliberate behavior change, asserted rather than discovered. +19. A crashed or stopped server in the matching root is not reused; a new + one spawns. +20. **Loose-file pin (Q#LN15 part 2).** Two **markerless** files of the + same language in **different** directories still share **one** server. + This is the no-change case, and it is the one a naive `(language_id, + root)` key breaks — `project_root_for` never returns nil for a file + with a path, so it must be asserted, not assumed. +21. **Fallback-vs-detected pin.** A file under a real project marker and a + markerless file of the same language get **different** servers, and + the markerless one's server carries the fallback directory as `cwd` + while matching on a nil affinity key. + +**Stage 3 — the Lean language server** + +22. Opening a `.lean` file inside a Lake package spawns one server with + `cwd` and `rootUri` at the package root. +23. **Outermost-root pin:** a file under + `/.lake/packages/dep/…` whose ancestor chain contains two + `lean-toolchain` files resolves to ``, not to `dep`. Run with + `pmacs.project.set_search_boundary` at the fixture root so the + assertion is hermetic. +24. **Boundary pin:** with the search boundary set at the fixture root, a + `lean-toolchain` planted in an ancestor *above* the boundary is not + reached — the resolver stops at the boundary rather than walking past + it. +25. A string-valued `pmacs.lsp.config.lean4.root` still works — the Q#LN8 + generalization is strictly additive. +26. `didOpen` carries `languageId = "lean4"`. +27. **Fallback-latch pin (Q#LN7):** a `lake` stub that exits non-zero — + reproducing §2.9's shimmed-elan state — causes exactly **one** restart + against `lean --server`, and a second failure surfaces an error rather + than looping. The latch does not re-arm within the session. +28. **Probe pin:** a `lake` stub reporting version 3.0.0 triggers the + fallback; one reporting 3.1.0 does not. A stub that never exits does + not block the attach — the optimistic `lake serve` spawn proceeds. +29. A `$/lean/fileProgress` notification delivered through the fake server + reaches a registered `on_notification` subscriber. +30. **Dispatch-integrity pin:** with a Lean subscriber registered, a + `workspace/applyEdit` request in the same drain is still handled — no + event is stolen. +31. A subscriber that raises does not prevent later events in the same + drain from being processed. +32. **Response-seam pin (Q#LN9).** A `send_request` reply reaches its + registered `on_response` one-shot, and the one-shot is **removed + before** invocation — a raising handler is not re-entered. Bites + against rev 2, where no Lua consumed `ev.kind == "response"` at all + and the reply was dropped. +33. **Response dispatch-integrity pin.** With a response subscriber + registered, `workspace/applyEdit` in the same drain is still handled; + a raising response handler does not stop later events in that drain. + Mirrors the notification-side pins above. +34. **Pending-purge pin.** A server that dies with a response outstanding + invokes the pending one-shot with an error and clears it — the + registration does not leak and the awaiting caller does not hang. +35. **Config-preservation pin (Q#LN7).** After the fallback latch fires, + user-supplied `env` / `settings` / `init_options` / `root` on + `pmacs.lsp.config.lean4` survive; only `command` and `args` change. +36. **No-respawn-loop pin.** The latch stops the failing server before + spawning the fallback, so `RestartPolicy` does not respawn the broken + command underneath it. +37. `textDocument/waitForDiagnostics` resolves through the response seam + (Q#LN16). **PATH-and-success-gated live smoke:** if `lake serve` + starts successfully a real elaboration completes and diagnostics + arrive; skipped otherwise, never failed. + +**Stage 4 — the Unicode input method** + +38. `\alpha` + space yields `α`; the whole expansion is a single undo step. +39. `\<>` yields `⟨⟩` with the point between them, from the `$CURSOR` + placeholder. +40. **Pair-collision pin (Q#LN10).** `\[[]]` yields `⟦⟧`: each `[` is + claimed as an extension of the pending abbreviation, so auto-pairing + never inserts a closing `]` into the pending key. Bites against an + ordering where pairing runs first, and against a consumer that claims + only completed expansions rather than pending extensions — **both + failure modes must be shown**, since they are distinct bugs with the + same symptom. +41. `\to` yields `→` eagerly on uniqueness, with no terminator typed. +42. A prefix with no match (`\zzzz` + space) is left as literal text; no + edit is made. +43. Moving the cursor out of a pending abbreviation abandons it. +44. `pmacs.config.set("lean.abbrev", false)` disables expansion; the + setting is read against the typed edit's **source** buffer. +45. Expansion does not fire in a non-`lean4` buffer — including that a + pending abbreviation is never opened there, so `\[` in a Rust buffer + still pairs normally. +46. **Provenance-refactor pin:** the full auto-pairing acceptance suite + passes unchanged, and a bite against the pre-refactor `pair.lua` + confirms the shared-consumer commit is behavior-preserving. + +**Stage 5 — the goal view** + +47. `$/lean/plainGoal` is sent with a position encoded through + `outbound_position` — pinned with a UTF-16 fake server and a + non-ASCII Lean line, which fails against a raw byte column. +48. A non-null `PlainGoal` renders `rendered` into `*lean-goal*`. +49. A null result with the file elaborated renders "no goals". +50. A point inside a range still covered by `$/lean/fileProgress` renders + the elaborating state, not "no goals". +51. **Refresh pin (Q#LN13).** Moving the point to a new position and + driving `process.after-tick` past the debounce issues exactly one new + `$/lean/plainGoal`; ticking again with the point unmoved issues none. +52. **In-flight pin.** A second point move while a request is outstanding + does not issue a concurrent request, and the panel ends on the result + for the *latest* position — a stale response for an abandoned + position never wins. +53. The panel opens at the bottom without stealing focus. +54. `*lean-goal*` rejects a user edit and accepts a module write. +55. **Teardown pin.** After the Lean buffer is killed or the frontend + detaches, driving `process.after-tick` issues **no** further + `$/lean/plainGoal` and writes **nothing** to the panel, and any + outstanding request's one-shot has been purged. + + Worded as an observable because it must be: `pmacs.hook` exposes + `add` / `define` / `list` / `run` and **no `remove`**. A subscription + cannot be torn down, only made inert — so "leaves no subscription" + (rev 2's wording) is untestable and, taken literally, unimplementable. + +**Stage 6 — the output channel** + +56. An information-severity diagnostic from the **Lean** server lands in + `*lean-output*` and **not** in the diagnostic store: no squiggle, no + gutter sign, and the modeline info count stays zero. +57. Warning- and error-severity diagnostics from the Lean server are + unaffected and still reach the store. +58. **Cross-language pin:** an information-severity diagnostic from a + **non-Lean** server still squiggles and still counts — the + `LspServerSpec` policy defaults to a no-op. +59. Output is cleared per publish for the owning document, so a + re-elaborated file does not accumulate stale `#eval` results. +60. Output rows appear in source-position order regardless of publish + order. + +**Stage 7 — module hierarchy** + +61. `$/lean/prepareModuleHierarchy` is sent through the Q#LN12 typed path + (position-bearing), pinned against a UTF-16 fake server. +62. `imports` and `importedBy` each render into the listview panel and are + navigable through the existing `on_visit`. +63. An empty result renders an empty panel with its header, not an error. +64. The panel's `q` returns to the originating buffer, not to another + panel (`listview.lua:118`'s existing rule). + +## 8. Prior art in pmacs + +- **#144 (LaTeX)** — the third-party-republish grammar decision and the + vendored-artifact-with-provenance pattern. +- **#146 (HTML+CSS)** — the global capture table, and the requirement to + pin retro-paint in both directions. Q#LN4 is that lesson applied. +- **#123 (JSON/YAML)** — declarative `pmacs.lsp.config` entries with a + fake-server delivery proof plus PATH-gated live smokes. Stage 3 follows + it, with the extra success-gate §2.9 forces. +- **#110 (auto-pairing)** — `take_typed_edit()` provenance, the fail-closed + discipline on transformed source edits, and Q#AP1's optimistic-classifier + limitation. Stage 4 is built on all three. +- **#127 (config registry)** — `pmacs.config.define` and the + source-buffer-resolution correction. Q#LN10's gate follows + `editing.auto-pair` exactly. +- **#129 (mode system)** — mode-scoped keymaps for Stage 5. +- **#155 (bottom panel)** — `pmacs.window.display` and the panel adopter + shape, for stages 5–7. +- **#113 (compile mode)** — the erroring-intercept read-only generated + buffer idiom (stages 5 and 6), and `process.after-tick` as a debounced + cadence source (Q#LN13). +- **#122 (multi-language injections)** — `pmacs.parse.injection_aliases`, + which Q#LN17 registers into. +- **#94/#95 (LSP panels)** — `pmacs.listview.open` and the + references/outline panel shape that Stage 7 reuses wholesale. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 29acbe3..665194c 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -728,7 +728,22 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { let _ = client.send_key(ProtocolKey::Char(chord), Modifiers::CTRL | Modifiers::ALT); } - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + // Quiet-observation mode. `PMACS_GPU_PROBE_OBSERVE_MS` makes the probe + // send NO input and request NO resize, and observe for exactly that long + // instead of stopping at its usual condition. + // + // This exists because the ordinary probe cannot see a frame storm: it + // stops as soon as it has watched a resize land, so a session emitting a + // frame every tick and one emitting three in total both satisfy it. A + // fixed window over a child that produces no output turns "how many + // frames did the daemon send?" into a number worth asserting on. + let observe_window = std::env::var("PMACS_GPU_PROBE_OBSERVE_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(std::time::Duration::from_millis); + 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; while std::time::Instant::now() < deadline { @@ -778,13 +793,17 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { if pixels.iter().any(|&b| b != first) { facts.rendered_nonuniform_frames += 1; } - if !sent_input && facts.frames >= 1 { + if facts.last_frame_text.contains(PROBE_INPUT_CHAR) { + facts.input_echo_observed = true; + } + if !quiet && !sent_input && facts.frames >= 1 { sent_input = true; // Real child input over the real wire. - let _ = client.send_key(ProtocolKey::Char('x'), Modifiers::NONE); + let _ = + client.send_key(ProtocolKey::Char(PROBE_INPUT_CHAR), Modifiers::NONE); let _ = client.send_key(ProtocolKey::Enter, Modifiers::NONE); } - if !sent_resize && facts.frames >= 2 { + if !quiet && !sent_resize && facts.frames >= 2 { sent_resize = true; state.resize(700, 500); if let Some((buffer_id, size)) = state.terminal_declaration_if_changed() @@ -800,7 +819,7 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.observed_resized_frame = true; } } - if facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 { + if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 { break; } } @@ -832,6 +851,7 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { let _ = writeln!(out, "resized_cols={}", facts.resized_cols); 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, "disconnect={}", facts.disconnect.unwrap_or_default()); if let Err(error) = std::fs::write(report, out) { eprintln!( @@ -1037,9 +1057,20 @@ struct ProbeFacts { resized_cols: u32, last_title: Option, last_frame_text: String, + /// Whether any frame carried the probe's own typed character back. + /// + /// Latched ACROSS frames, not read off the final one: a later geometry + /// change reflows the screen, so "the echo arrived" and "the echo is + /// still on the last frame" are different questions and only the first + /// one is about input reaching the child. + input_echo_observed: bool, disconnect: Option, } +/// The character the probe types into the child. Distinct from anything the +/// acceptance children print themselves, so its appearance is unambiguous. +const PROBE_INPUT_CHAR: char = 'x'; + /// One-line printable text of a terminal frame, for probe reporting. fn frame_probe_text(frame: &TerminalFrame) -> String { let mut text = String::new(); @@ -8109,7 +8140,17 @@ fn dominant_line_shape( indent_sum += shape.indent_cols; content_sum += shape.content_cols; } - (count > 0).then_some(MinimapLineShape { + // `then`, NOT `then_some`: `bool::then_some` takes its argument by + // value, so the struct literal --- and with it `indent_sum / count` + // --- is evaluated before the guard is ever consulted. A slab of + // all-blank source lines makes `count` zero and panics the frontend + // on the division. `bool::then` defers the body into a closure, so + // the zero case short-circuits to `None`. + // + // Clippy's `unnecessary_lazy_evaluations` lint pushes in exactly the + // wrong direction here; it does not fire on a body that can panic, + // but do not "simplify" this back. + (count > 0).then(|| MinimapLineShape { indent_cols: indent_sum / count, content_cols: content_sum.div_ceil(count), }) @@ -10678,6 +10719,66 @@ mod tests { ); } + #[test] + fn minimap_downsampling_survives_a_slab_of_blank_lines() { + // Regression: `dominant_line_shape` counted only lines with + // content, then built its average with `then_some` --- which + // evaluates its argument eagerly, so `indent_sum / count` + // divided by zero whenever a downsampled pixel row covered + // nothing but blank lines. Reachable on any long file with a + // run of blank lines, which is precisely when the bucketing + // branch runs at all. + let red = style_with_fg(CellColor::Rgb(255, 0, 0)); + let lines = vec![red; 10_000]; + // Every line blank: `minimap_line_shape("")` yields + // `content_cols == 0`, so `has_content()` is false throughout + // and every bucket counts zero contentful lines. + let shapes = vec![ + MinimapLineShape { + indent_cols: 0, + content_cols: 0, + }; + lines.len() + ]; + + let rects = minimap_rects(&lines, &shapes, 240, 120, 0, 30, FontMetrics::default()); + + // The strokes are all suppressed (no content to draw), but the + // thumb still paints --- the point is that this returns at all. + assert!( + rects.len() <= 8, + "blank slabs must emit no line strokes, got {}", + rects.len() + ); + } + + #[test] + fn minimap_downsampling_averages_only_contentful_lines() { + // Guards the other half: a bucket that mixes blank and + // contentful lines must average over the contentful ones only, + // so the fix cannot regress into `count = slice.len()`. + let blank = MinimapLineShape { + indent_cols: 0, + content_cols: 0, + }; + let solid = MinimapLineShape { + indent_cols: 4, + content_cols: 20, + }; + let shapes = [blank, solid, solid, blank]; + + let shape = dominant_line_shape(&shapes, 0, 4).expect("bucket has contentful lines"); + + assert_eq!(shape.indent_cols, 4, "blank lines must not dilute indent"); + assert_eq!(shape.content_cols, 20, "blank lines must not dilute length"); + } + + #[test] + fn minimap_dominant_line_shape_is_none_for_an_empty_bucket() { + let shape = dominant_line_shape(&[], 0, 0); + assert!(shape.is_none(), "an empty bucket has no shape"); + } + #[test] fn minimap_hidden_when_surface_is_too_narrow() { let lines = [style_with_fg(CellColor::Rgb(255, 0, 0))]; diff --git a/src/async_runtime.rs b/src/async_runtime.rs index 6bc8292..493d993 100644 --- a/src/async_runtime.rs +++ b/src/async_runtime.rs @@ -71,8 +71,8 @@ use crossbeam::channel as cb_channel; use serde::{Deserialize, Serialize}; use crate::fs::{ - FsDirEntry, FsError, chmod_blocking, read_dir_blocking, remove_blocking, rename_blocking, - stat_blocking, + FsDirEntry, FsDirListing, FsError, ReadDirTolerance, chmod_blocking, read_dir_blocking, + remove_blocking, rename_blocking, stat_blocking, }; use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry}; use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle}; @@ -220,9 +220,10 @@ enum ReplyKind { /// T M4.1. Parse { duration_ms: u64 }, /// `dispatch_fs_read_dir` completed; payload is the directory - /// listing. The Vec is `Serialize` so it crosses the bus - /// directly --- no side handoff like parse trees need. T M8.1. - ReadDir(Vec), + /// listing. The listing is `Serialize` so it crosses the bus + /// directly --- no side handoff like parse trees need. T M8.1; its + /// per-entry error channel is dired Q#DR6. + ReadDir(FsDirListing), /// `dispatch_fs_stat` completed; payload is the per-path /// metadata. T M8.1. Stat(FsDirEntry), @@ -266,10 +267,11 @@ pub enum JobResult { duration_ms: u64, }, /// `dispatch_fs_read_dir` produced a directory listing. The - /// Lua boundary in [`crate::lua_bindings`] turns the Vec into a - /// per-entry table when `_take_result` consumes the result. - /// T M8.1. - ReadDir(Vec), + /// Lua boundary in [`crate::lua_bindings`] turns the entries into + /// per-entry tables when `_take_result` consumes the result, and + /// keys the result *shape* on whether the listing carries a + /// per-entry error channel. T M8.1 / dired Q#DR6. + ReadDir(FsDirListing), /// `dispatch_fs_stat` produced metadata for a single path. The /// Lua boundary turns the [`FsDirEntry`] into the same table /// shape `read_dir` entries use. T M8.1. @@ -832,11 +834,21 @@ impl AsyncRuntime { /// `lstat`-style metadata. Polls cancel every batch of /// entries; supersede follows the same rule as the other /// dispatchers. T M8.1. - pub fn dispatch_fs_read_dir(&self, path: PathBuf, supersede: Option<&str>) -> JobId { + /// + /// `tolerance` selects the per-entry contract (dired Q#DR6): + /// [`ReadDirTolerance::Fatal`] is the original all-or-nothing + /// listing, [`ReadDirTolerance::PerEntry`] carries per-entry + /// failures alongside the entries that survived. + pub fn dispatch_fs_read_dir( + &self, + path: PathBuf, + tolerance: ReadDirTolerance, + supersede: Option<&str>, + ) -> JobId { let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { - let kind = run_fs_read_dir(&cancel, &path); + let kind = run_fs_read_dir(&cancel, &path, tolerance); let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind }); }); id @@ -1038,8 +1050,8 @@ impl AsyncRuntime { ReplyKind::Parse { duration_ms } => { PendingState::Complete(JobResult::Parse { duration_ms }) } - ReplyKind::ReadDir(entries) => { - PendingState::Complete(JobResult::ReadDir(entries)) + ReplyKind::ReadDir(listing) => { + PendingState::Complete(JobResult::ReadDir(listing)) } ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)), ReplyKind::Json(v) => PendingState::Complete(JobResult::Json(v)), @@ -1295,9 +1307,13 @@ fn run_sleep(cancel: &CancellationToken, total: Duration) -> ReplyKind { /// [`FsError::Cancelled`] becomes [`ReplyKind::Cancelled`]; /// [`FsError::Io`] becomes [`ReplyKind::Error`] with the /// human-readable message attached. -fn run_fs_read_dir(cancel: &CancellationToken, path: &Path) -> ReplyKind { - match read_dir_blocking(path, cancel) { - Ok(entries) => ReplyKind::ReadDir(entries), +fn run_fs_read_dir( + cancel: &CancellationToken, + path: &Path, + tolerance: ReadDirTolerance, +) -> ReplyKind { + match read_dir_blocking(path, cancel, tolerance) { + Ok(listing) => ReplyKind::ReadDir(listing), Err(FsError::Cancelled) => ReplyKind::Cancelled, Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => { ReplyKind::Error(e.to_string()) diff --git a/src/daemon.rs b/src/daemon.rs index 5af71d0..9ac8256 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1536,23 +1536,7 @@ fn dispatcher_loop( // Accepted terminal context controls PTY size. Apply any focus, // window, or resize changes before consuming another child-output // batch so screen reflow and subsequent bytes share one geometry. - for frontend_id in &attached_fids { - if let Some(size) = term_sizes.get(frontend_id).copied() { - editor.sync_terminal_layout(*frontend_id, size); - } - // Vterm Stage 3 — the semantic twin, right beside the grid - // sync so both frontend kinds resize the screen before the - // next child-output drain. The frontend declared a CONTENT - // rectangle, so this consumes the size directly instead of - // running the TUI placement helper, which would subtract a - // modeline the GPU never drew. - if let Some((buffer_id, size)) = semantic_states - .get(frontend_id) - .and_then(crate::semantic_render::SemanticRenderState::terminal_viewport) - { - editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size); - } - } + sync_terminal_layouts_for_tick(editor, &attached_fids, &term_sizes, &semantic_states); // `tick_async` last: the M4.5 async bridge settles awaiters // inside `tick_lsp` (via the message bus); draining + resuming @@ -3064,6 +3048,56 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre } } +/// One dispatcher tick's terminal-layout step, for every attached frontend. +/// +/// Extracted from the dispatcher loop so the grid/semantic exclusivity is +/// **structural** rather than two adjacent `if`s, and so acceptance tests can +/// drive the real loop body instead of re-implementing it (Q#GT1). +/// +/// The shape that matters: liveness is frontend-kind NEUTRAL and runs for +/// everyone, exactly once; the geometry arms are EXCLUSIVE alternatives keyed +/// on the same `semantic_states` membership that session establishment uses, +/// so a session can never be caught by both. +/// +/// Before this existed, both arms ran for every frontend. A semantic session +/// has 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 installed the declared +/// content rectangle, and each arm's own idempotence guard saw only the size +/// the other had just written. The child got a `SIGWINCH` storm at tick +/// cadence, which is what made typing into a GPU terminal impossible while +/// output kept flowing. +fn sync_terminal_layouts_for_tick( + editor: &mut EditorState, + attached_fids: &[FrontendId], + term_sizes: &HashMap, + semantic_states: &HashMap, +) { + for frontend_id in attached_fids { + // Neutral half: panel reconciliation (Q#BP2b's only per-tick + // enforcement point) and the release of a controller whose window + // moved away. A semantic frontend gets this from nowhere else — + // its own arm stops running the moment the buffer-follow snapshot + // clears the declaration (Q#GT4/Q#GT7). + editor.sync_terminal_controller_liveness(*frontend_id); + + // Geometry: exactly one arm per frontend kind. + if let Some(state) = semantic_states.get(frontend_id) { + // Vterm Stage 3 — the frontend declared a CONTENT rectangle, + // so this consumes the size directly instead of running the + // TUI placement helper, which would subtract a modeline the + // GPU never drew. A semantic frontend with no declaration yet + // gets NO resize at all, which is correct: the terminal keeps + // the geometry it was opened with until one arrives. + if let Some((buffer_id, size)) = state.terminal_viewport() { + editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size); + } + } else if let Some(size) = term_sizes.get(frontend_id).copied() { + editor.sync_terminal_grid_geometry(*frontend_id, size); + } + } +} + /// Dispatch a semantic (grid-less) frontend's input event into the /// shared editor core (Phase B, session B1). Mirrors the `Key` / `Mouse` /// arms of [`apply_event`] but takes no `RenderState` — a semantic @@ -3365,6 +3399,235 @@ mod tests { ); } + // ---- GPU terminal input: the double terminal-layout sync ------------- + // + // These drive `sync_terminal_layouts_for_tick` — the REAL dispatcher loop + // body, not a re-implementation of it. That distinction is the whole + // point: the Stage 3 acceptance sent input through `client.send_key` + // directly and therefore pinned transport rather than routing, which is + // how the defect these pin shipped. + // + // The observable is `TerminalScreen::generation`. It advances once per + // screen mutation, so with a child that produces no output and no + // `tick_processes` call, "generation stopped advancing" is exactly "the + // geometry settled" — a state predicate, not a readout. + + /// Open a quiet terminal and give `frontend_id` a view that shows it, + /// holding its controller — the state the dispatcher loop runs against. + fn quiet_terminal_for( + editor: &EditorState, + frontend_id: FrontendId, + ) -> (crate::buffer::BufferId, crate::window::WindowId) { + let mut spec = crate::terminal::TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.rows = 24; + spec.cols = 80; + let buffer_id = editor + .terminal_manager + .borrow_mut() + .open( + spec, + &mut editor.core.borrow_mut(), + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("open terminal"); + + let window_id = crate::window::WindowId::next(); + { + let mut core = editor.core.borrow_mut(); + let text_view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + let buffer = registry.get(buffer_id).expect("terminal buffer"); + crate::text_view::TextView::new(buffer) + }; + core.windows.insert( + window_id, + crate::window::Window::new(window_id, buffer_id, text_view), + ); + core.register_frontend_view( + frontend_id, + crate::window::FrontendView { + layout: crate::window::Layout::single(window_id), + active: window_id, + fold_projection: true, + panel_capable: false, + frame_geometry: None, + panel_hidden: false, + }, + ); + } + let key = crate::terminal::TerminalViewKey::new(frontend_id, window_id, buffer_id); + let mut manager = editor.terminal_manager.borrow_mut(); + manager.register_view(key); + manager.claim_controller(key); + (buffer_id, window_id) + } + + fn screen_generation(editor: &EditorState, buffer_id: crate::buffer::BufferId) -> u64 { + editor + .terminal_manager + .borrow() + .snapshot(buffer_id) + .expect("terminal snapshot") + .screen_generation + } + + /// Acceptance 2 and 3: one declaration produces exactly one resize, and + /// the screen then STAYS at the declared content rectangle. + /// + /// Against the pre-split tree both arms ran for the semantic frontend and + /// generation advanced by two per iteration forever, because each arm's + /// idempotence guard only ever saw the size the other had just written. + #[test] + fn semantic_terminal_geometry_settles_after_one_declaration() { + let fid = FrontendId(41); + let mut editor = EditorState::new(); + let (buffer_id, _window) = quiet_terminal_for(&editor, fid); + + // The GPU declares a CONTENT rectangle; the grid size it also + // reported at attach is deliberately DIFFERENT, which is the + // collision the defect fed on. + let declared = CellSize::new(25, 92); + let mut semantic = crate::semantic_render::SemanticRenderState::for_peer(fid, 20); + semantic.set_terminal_viewport(buffer_id, declared); + let semantic_states = HashMap::from([(fid, semantic)]); + let term_sizes = HashMap::from([(fid, CellSize::new(24, 80))]); + let attached = vec![fid]; + + sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states); + let after_first = screen_generation(&editor, buffer_id); + assert_eq!( + editor.terminal_manager.borrow().screen_size(buffer_id), + Some(declared), + "the declared content rectangle must win" + ); + + // Acceptance 2: every further tick is a no-op. + for _ in 0..8 { + sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states); + } + assert_eq!( + screen_generation(&editor, buffer_id), + after_first, + "an unchanged declaration must not mutate the screen again \ + (pre-split: +2 per tick, forever)" + ); + // Acceptance 3: the state predicate, not "a frame at this width + // arrived at some point". + assert_eq!( + editor.terminal_manager.borrow().screen_size(buffer_id), + Some(declared), + "the geometry must SETTLE at the declared rectangle" + ); + + editor.process_supervisor.borrow_mut().shutdown(); + } + + /// Acceptance 6: a semantic frontend whose window switches away releases + /// its terminal controller. + /// + /// This bites against BOTH the pre-split tree's sibling arms and against + /// the naive "skip the grid arm for semantic frontends" guard, which is + /// why B1 is recorded as half-false. The release cannot live in + /// `sync_semantic_terminal_layout`: the buffer-follow snapshot clears the + /// viewport declaration, so that arm stops running in exactly this case — + /// modelled here by dropping the declaration alongside the switch. + #[test] + fn semantic_frontend_releases_its_terminal_controller_when_its_window_switches_away() { + let fid = FrontendId(42); + let mut editor = EditorState::new(); + let (buffer_id, window_id) = quiet_terminal_for(&editor, fid); + + let declared = CellSize::new(25, 92); + let mut semantic = crate::semantic_render::SemanticRenderState::for_peer(fid, 20); + semantic.set_terminal_viewport(buffer_id, declared); + let mut semantic_states = HashMap::from([(fid, semantic)]); + let term_sizes = HashMap::from([(fid, CellSize::new(24, 80))]); + let attached = vec![fid]; + + sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states); + assert_eq!( + editor + .terminal_manager + .borrow() + .controller_view_for_frontend(fid), + Some(crate::terminal::TerminalViewKey::new( + fid, window_id, buffer_id + )), + "precondition: the frontend holds the controller" + ); + + // The window switches to a document, and the snapshot that announces + // it clears the semantic declaration — `on_buffer_snapshot_sent`. + let document = editor.core.borrow().registry.borrow_mut().create("doc"); + { + let mut core = editor.core.borrow_mut(); + let text_view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + let buffer = registry.get(document).expect("document buffer"); + crate::text_view::TextView::new(buffer) + }; + let window = core.windows.get_mut(&window_id).expect("window"); + *window = crate::window::Window::new(window_id, document, text_view); + } + semantic_states + .get_mut(&fid) + .expect("semantic state") + .on_buffer_snapshot_sent(document); + + sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states); + assert_eq!( + editor + .terminal_manager + .borrow() + .controller_view_for_frontend(fid), + None, + "a semantic frontend that left its terminal must release the \ + controller, or no peer can resize that PTY again" + ); + + editor.process_supervisor.borrow_mut().shutdown(); + } + + /// Acceptance 5 at the unit seam: a GRID frontend still gets its + /// placement-derived resize. The split must not turn the storm fix into + /// "semantic frontends win everywhere". + #[test] + fn grid_terminal_geometry_still_syncs_for_a_grid_frontend() { + let fid = FrontendId(43); + let mut editor = EditorState::new(); + let (buffer_id, _window) = quiet_terminal_for(&editor, fid); + + let semantic_states = HashMap::new(); + let term_sizes = HashMap::from([(fid, CellSize::new(40, 100))]); + let attached = vec![fid]; + + let before = editor.terminal_manager.borrow().screen_size(buffer_id); + sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states); + let after = editor.terminal_manager.borrow().screen_size(buffer_id); + + assert_ne!(before, after, "a grid frontend must still resize its PTY"); + assert_eq!( + after.map(|size| size.cols), + Some(100), + "the grid arm supplies the full declared width" + ); + // And it too settles. + let settled = screen_generation(&editor, buffer_id); + for _ in 0..4 { + sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states); + } + assert_eq!( + screen_generation(&editor, buffer_id), + settled, + "an unchanged grid size must not mutate the screen again" + ); + + editor.process_supervisor.borrow_mut().shutdown(); + } + #[test] fn frontend_events_from_uninstalled_sessions_are_dropped_without_state_access() { let source = FrontendId(77); diff --git a/src/editor.rs b/src/editor.rs index 79f1225..1db5c3a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -525,6 +525,17 @@ impl EditorState { include_str!("../builtin/runtime/window.lua"), ) .expect("load window builtin chunk"); + // Dired Stage 1: the directory view. Loaded AFTER window.lua, + // whose `window.panel-height` setting a `display = "panel"` + // listing resolves, and after the pre-runtime tables it drives + // (`pmacs.config` / `command` / `keymap` / `buffer` / `editor` / + // `minibuffer` / `path`, plus `pmacs.fs` from fs.lua above). + lua_host + .eval( + Some("@pmacs/builtin/runtime/dired.lua"), + include_str!("../builtin/runtime/dired.lua"), + ) + .expect("load dired builtin chunk"); // Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT: // compile.lua must load AFTER lsp.lua. It takes over // `M-g n` / `M-g p` for the unified error dispatchers, and @@ -1189,14 +1200,84 @@ impl EditorState { let _ = self.terminal_manager.borrow_mut().release_controller(key); } + /// Reconcile panels and release a controller whose window moved away. + /// + /// **Frontend-kind neutral, and deliberately so** (Q#GT1/Q#GT4): this + /// half reads only `core.views`, `core.windows`, and the controller — + /// never a grid size — so it is the half the dispatcher runs for EVERY + /// attached frontend once per tick. It was previously fused into + /// [`Self::sync_terminal_layout`], which meant a semantic frontend got + /// its controller-liveness release only as a side effect of a grid + /// resize it should never have received. + /// + /// [`Self::sync_semantic_terminal_layout`] cannot substitute for this: + /// when a GPU window switches away from its terminal, the buffer-follow + /// snapshot clears the viewport declaration + /// (`SemanticRenderState::on_buffer_snapshot_sent`), so the semantic arm + /// stops running entirely in exactly the case that needs the release. + /// + /// Returns `true` while `frontend_id` still holds a live controller. + pub fn sync_terminal_controller_liveness(&mut self, frontend_id: FrontendId) -> bool { + // Bottom-panel arc (Q#BP2b): a panel that just became + // unsatisfiable must have released its controller before any + // resize runs, or the child would be resized against a dead rect. + // This is the contract's only per-tick enforcement point, and it + // stays neutral so semantic frontends keep it (Q#GT7). + self.reconcile_panel_layout(frontend_id); + let Some(key) = self + .terminal_manager + .borrow() + .controller_view_for_frontend(frontend_id) + else { + return false; + }; + let core = self.core.borrow(); + let Some(view) = core.views.get(&frontend_id) else { + drop(core); + let _ = self.terminal_manager.borrow_mut().release_controller(key); + return false; + }; + if view.active != key.window_id + || core + .windows + .get(&key.window_id) + .is_none_or(|window| window.buffer_id != key.buffer_id) + { + drop(core); + let _ = self.terminal_manager.borrow_mut().release_controller(key); + return false; + } + true + } + /// Resize the one session durably controlled by `frontend_id`. /// /// This is called before process drain and paint, never from rendering. + /// + /// Composition of the two halves, preserved verbatim for the in-process + /// `editor::run` loop and `LOCAL`. The daemon dispatcher calls the halves + /// separately, because only the geometry half is grid-specific. pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool { - // Bottom-panel arc (Q#BP2b): a panel that just became - // unsatisfiable must have released its controller before this - // runs, or the child would be resized against a dead rect. - self.reconcile_panel_layout(frontend_id); + self.sync_terminal_controller_liveness(frontend_id) + && self.sync_terminal_grid_geometry(frontend_id, term_size) + } + + /// The grid half: TUI placement plus the resize it implies. + /// + /// **Grid frontends only** (Q#GT1). The placement lookup below is why: + /// a semantic frontend has no `window_placements` entry at all, so the + /// "no placement" arm would release its controller on EVERY tick. That + /// release reads like liveness and is not — it is grid geometry, and + /// moving it into [`Self::sync_terminal_controller_liveness`] would + /// reintroduce this framing's own defect in a new place. + /// + /// Assumes liveness already ran: the controller is live and its window + /// still shows the terminal. + pub fn sync_terminal_grid_geometry( + &mut self, + frontend_id: FrontendId, + term_size: CellSize, + ) -> bool { let Some(key) = self .terminal_manager .borrow() @@ -1206,23 +1287,11 @@ impl EditorState { }; let content = { let core = self.core.borrow(); - let Some(view) = core.views.get(&frontend_id) else { - let _ = self.terminal_manager.borrow_mut().release_controller(key); - return false; - }; - if view.active != key.window_id - || core - .windows - .get(&key.window_id) - .is_none_or(|window| window.buffer_id != key.buffer_id) - { - let _ = self.terminal_manager.borrow_mut().release_controller(key); - return false; - } let Some(placement) = window_placements(&core, frontend_id, term_size) .get(&key.window_id) .copied() else { + drop(core); let _ = self.terminal_manager.borrow_mut().release_controller(key); return false; }; @@ -5648,17 +5717,25 @@ mod tests { // ---- T M2.11 acceptance -------------------------------------------------- - /// Every chord in the default global keymap must round-trip through + /// Every chord in the default keymap must round-trip through /// `pmacs.describe.key`: returning a non-nil table whose `command` /// matches the binding the keymap stack stores. + /// + /// `describe.key` resolves against the **effective context** + /// (buffer-local → mode → global), so a mode-scoped default is + /// asserted with a buffer that carries that mode rather than + /// context-free. Dired is the first builtin to bind mode-scoped keys + /// (#129's first non-detection consumer), and without the mode in + /// place its `n` / `p` / `g` correctly resolve to nothing. #[test] fn describe_key_identifies_every_default_binding() { + use crate::keymap_stack::Scope; let s = EditorState::new(); let kms = s.lua_host.keymaps().borrow(); - let bindings: Vec<(String, String)> = kms + let bindings: Vec<(Scope, String, String)> = kms .iter_all() .into_iter() - .map(|(_, seq, b)| (crate::key::display_sequence(&seq), b.command)) + .map(|(scope, seq, b)| (scope, crate::key::display_sequence(&seq), b.command)) .collect(); drop(kms); // Sanity floor: the default keymap binds at least the M1 surface. @@ -5667,18 +5744,50 @@ mod tests { "default keymap unexpectedly small: {} bindings", bindings.len() ); + let modes: usize = bindings + .iter() + .filter(|(scope, _, _)| matches!(scope, Scope::Mode(_))) + .count(); + assert!( + modes >= 1, + "a mode-scoped default is expected since dired Stage 1; \ + found none, so the mode arm below asserts nothing" + ); - for (seq, expected_command) in &bindings { + for (scope, seq, expected_command) in &bindings { + let mode = match scope { + Scope::Mode(name) => Some(name.clone()), + // No buffer-scoped defaults exist; a future one would + // need its own buffer context here. + Scope::Buffer(_) => continue, + Scope::Global => None, + }; + // Set the context explicitly on EVERY iteration, including + // the global one: a mode left over from a previous iteration + // legitimately shadows a global binding of the same chord + // (dired's mode-scoped `RET` shadows + // `edit.newline-and-indent`, which is the point of the + // mode), so a leaked mode would make this assert the wrong + // thing. + let context = match &mode { + Some(name) => { + format!("pmacs.buffer.set_major_mode(pmacs.window.buffer(), {name:?}); ") + } + None => "pmacs.buffer.set_major_mode(pmacs.window.buffer(), nil); ".to_owned(), + }; let script = format!( - "local r = pmacs.describe.key({seq:?}); \ + "{context}local r = pmacs.describe.key({seq:?}); \ if r == nil then return 'nil' else return r.command end" ); let got: String = s.lua_host.lua().load(&script).eval().unwrap_or_else(|e| { panic!("describe.key({seq}) raised: {e}"); }); assert_eq!( - &got, expected_command, - "describe.key for {seq:?} returned {got:?}, expected {expected_command:?}" + &got, + expected_command, + "describe.key for {seq:?} (scope {}) returned {got:?}, \ + expected {expected_command:?}", + scope.render() ); } } diff --git a/src/editor_core.rs b/src/editor_core.rs index cfb2bcb..89432cc 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -4787,7 +4787,14 @@ fn backward_word(buf: &Buffer, mut pos: Position) -> Position { /// path's on-disk identity. Every step is best-effort — if `$HOME` /// or the cwd is unavailable the path is returned as far as it could /// be resolved rather than panicking. -fn normalize_buffer_path(path: PathBuf) -> PathBuf { +/// +/// Public because dired needs the *same* canonical form the buffer +/// registry keys on (Q#DR2): its buffer-per-directory naming and +/// `find_buffer_for_path`'s dedup have to agree, and a Lua-side mirror +/// of this function would be a second implementation of a canonical +/// form — the tab-width-constants class in miniature. `pmacs.path +/// .canonicalize` is this function, not a copy of it. +pub fn normalize_buffer_path(path: PathBuf) -> PathBuf { let path = expand_tilde(path); let abs = if path.is_absolute() { path diff --git a/src/fs.rs b/src/fs.rs index 0b20a6d..0e05cbc 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -42,6 +42,28 @@ use crate::worker::CancellationToken; /// directories. const READDIR_CANCEL_POLL_EVERY: usize = 32; +/// How many *consecutive* `readdir` iterator errors a tolerant listing +/// records before giving up and failing (dired Q#DR6). +/// +/// [`std::fs::ReadDir`] is not obliged to terminate after yielding an +/// `Err`: a directory pulled out from under a stalled network mount can +/// keep producing them. Tolerant mode records-and-continues, so without +/// a bound that is an unbounded error vector on a worker thread. +/// +/// Cancellation is **not** an adequate backstop here, which is the +/// reason this constant exists rather than a comment saying it is: a +/// dired listing carries no supersede key and nothing cancels it, so the +/// only thing that would stop the loop is the directory itself. A +/// directory whose iterator produces nothing but errors has no partial +/// answer worth rendering, so the listing fails with the last error the +/// way an unopenable directory does. +/// +/// Deliberately untested: forcing a real `readdir` to yield errors +/// repeatedly is not portable, and faking it would need the walk to be +/// generic over its iterator — a refactor with no other consumer. The +/// counter resets on any entry that materializes. +const READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS: usize = 1024; + /// One directory entry as returned by [`read_dir_blocking`]. /// /// The shape is what `dired` / `magit-class` / `outline-class` @@ -116,6 +138,59 @@ impl FsEntryKind { } } +/// Per-entry tolerance for [`read_dir_blocking`] (dired Q#DR6). +/// +/// The M8.1 primitive was all-or-nothing: five per-entry conditions +/// failed the *entire* listing, which makes a plain refresh of a busy +/// directory (`/tmp`, a build tree) fail outright. The module doc used +/// to say a per-entry-tolerant wrapper was "the package's job" --- it +/// cannot be: the primitive hands Lua one structured error and no +/// partial vec, so there is nothing to be tolerant *with*. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReadDirTolerance { + /// Any per-entry failure fails the whole listing. The original + /// M8.1 contract, and still the default at every Lua call site + /// that does not opt in. + Fatal, + /// Per-entry failures are recorded in [`FsDirListing::errors`] and + /// enumeration continues. A failure on the *parent* `read_dir` + /// stays fatal (a directory you cannot open has no partial + /// answer), and so does a non-UTF-8 entry **name** --- see + /// [`FsError::NonUtf8Path`]. + PerEntry, +} + +/// One per-entry failure recorded by a tolerant [`read_dir_blocking`]. +/// +/// `name` is optional because a per-entry `readdir` *iterator* error +/// has no filename to report: the entry never materialized, and the +/// underlying error is about the parent directory. Every other arm has +/// an entry in hand and names it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FsDirEntryError { + /// Basename of the entry that failed, when one is known. + pub name: Option, + /// Rendered failure, already formatted for display. + pub message: String, +} + +/// What [`read_dir_blocking`] returns: the entries it could read, plus +/// the per-entry failures when the caller asked to tolerate them. +/// +/// `errors` is `None` under [`ReadDirTolerance::Fatal`] and `Some` +/// (possibly empty) under [`ReadDirTolerance::PerEntry`]. The +/// distinction is load-bearing at the Lua boundary: it is what selects +/// the bare-array result shape the M8.1 surface promises from the +/// `{ entries = …, errors = … }` shape the tolerant opt returns, so the +/// conversion never has to look the job back up. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FsDirListing { + /// One entry per readable child, in filesystem iteration order. + pub entries: Vec, + /// Per-entry failures; `None` in [`ReadDirTolerance::Fatal`] mode. + pub errors: Option>, +} + /// Errors produced by [`read_dir_blocking`] / [`stat_blocking`] / /// [`rename_blocking`] / [`chmod_blocking`] / [`remove_blocking`]. /// @@ -192,50 +267,100 @@ pub enum FsError { /// `to_string_lossy` would have mangled dired/wdired round-trips). /// /// Errors on the *parent* `read_dir` call surface as -/// [`FsError::Io`]. Errors on individual entries (a single broken -/// symlink, a permission-denied stat) currently propagate the same -/// way --- the cleanest behavior at this primitive layer is "fail -/// fast and let the caller decide whether a partial listing is -/// acceptable"; dired-class will likely want a per-entry-tolerant -/// wrapper but that's the package's job, not the primitive's. +/// [`FsError::Io`] regardless of `tolerance`: a directory you cannot +/// open has no partial answer. +/// +/// Errors on individual entries (a permission-denied `lstat`, a child +/// unlinked between `readdir` and `lstat`, a `readlink` failure, a +/// non-UTF-8 symlink target) are governed by `tolerance`. Under +/// [`ReadDirTolerance::Fatal`] they fail the whole listing, which is +/// the M8.1 contract every existing caller relies on; under +/// [`ReadDirTolerance::PerEntry`] they land in +/// [`FsDirListing::errors`] and enumeration continues (dired Q#DR6). +/// +/// A non-UTF-8 entry **name** is fatal in both modes. That is not a +/// listing problem but a path-representation one: [`FsDirEntry::name`] +/// is a `String` and every `pmacs.fs` op takes a `String` path, so a +/// tolerantly-rendered non-UTF-8 name would be a name the caller could +/// not pass back through `rename`. Byte-preserving paths are the named +/// deferral (see [`FsError::NonUtf8Path`]). A non-UTF-8 *target* +/// differs in kind --- the entry's own name is fine and nothing needs +/// to round-trip the target --- so it joins the per-entry channel. pub fn read_dir_blocking( path: &Path, cancel: &CancellationToken, -) -> Result, FsError> { + tolerance: ReadDirTolerance, +) -> Result { let iter = std::fs::read_dir(path).map_err(|source| FsError::Io { path: path.display().to_string(), source, })?; let mut out: Vec = Vec::new(); + let mut errors: Option> = + matches!(tolerance, ReadDirTolerance::PerEntry).then(Vec::new); let parent_str = path.display().to_string(); + let mut consecutive_entry_errors = 0usize; for (i, entry_result) in iter.enumerate() { if i % READDIR_CANCEL_POLL_EVERY == 0 && cancel.is_cancelled() { return Err(FsError::Cancelled); } - let entry = entry_result.map_err(|source| FsError::Io { - path: parent_str.clone(), - source, - })?; + let entry = match entry_result { + Ok(entry) => entry, + Err(source) => { + // R2-2: the entry never materialized, so there is no + // name to report and the error names the parent. + let error = FsError::Io { + path: parent_str.clone(), + source, + }; + consecutive_entry_errors += 1; + if consecutive_entry_errors > READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS { + return Err(error); + } + record_entry_error(&mut errors, None, error)?; + continue; + } + }; + consecutive_entry_errors = 0; let entry_path = entry.path(); - let metadata = std::fs::symlink_metadata(&entry_path).map_err(|source| FsError::Io { - path: entry_path.display().to_string(), - source, - })?; - let kind = classify(&metadata); - let symlink_target = if matches!(kind, FsEntryKind::Symlink) { - match std::fs::read_link(&entry_path) { - Ok(t) => Some(path_to_utf8_string(t.as_os_str(), &parent_str)?), - Err(source) => { - return Err(FsError::Io { + // Resolved first so a later per-entry failure can name it. + let name = path_to_utf8_string(&entry.file_name(), &parent_str)?; + let metadata = match std::fs::symlink_metadata(&entry_path) { + Ok(metadata) => metadata, + Err(source) => { + record_entry_error( + &mut errors, + Some(&name), + FsError::Io { path: entry_path.display().to_string(), source, - }); - } + }, + )?; + continue; } - } else { - None }; - let name = path_to_utf8_string(&entry.file_name(), &parent_str)?; + let kind = classify(&metadata); + let mut symlink_target = None; + if matches!(kind, FsEntryKind::Symlink) { + match std::fs::read_link(&entry_path) { + // A target we cannot represent leaves the entry in the + // listing with its target unknown, not the entry out of + // it: one weird symlink in `/tmp` used to take the + // whole directory down. + Ok(target) => match path_to_utf8_string(target.as_os_str(), &parent_str) { + Ok(target) => symlink_target = Some(target), + Err(error) => record_entry_error(&mut errors, Some(&name), error)?, + }, + Err(source) => record_entry_error( + &mut errors, + Some(&name), + FsError::Io { + path: entry_path.display().to_string(), + source, + }, + )?, + } + } out.push(FsDirEntry { name, kind, @@ -246,7 +371,33 @@ pub fn read_dir_blocking( symlink_target, }); } - Ok(out) + Ok(FsDirListing { + entries: out, + errors, + }) +} + +/// Route one per-entry failure: append it to the tolerant channel, or +/// propagate it when the caller asked for the fatal contract. +/// +/// `errors.is_none()` *is* [`ReadDirTolerance::Fatal`] --- keeping the +/// mode in the accumulator rather than passing it separately makes the +/// two impossible to disagree. +fn record_entry_error( + errors: &mut Option>, + name: Option<&str>, + error: FsError, +) -> Result<(), FsError> { + match errors { + Some(list) => { + list.push(FsDirEntryError { + name: name.map(ToOwned::to_owned), + message: error.to_string(), + }); + Ok(()) + } + None => Err(error), + } } /// Convert an [`std::ffi::OsStr`] to `String` strictly. Returns @@ -495,12 +646,23 @@ mod tests { CancellationToken::new() } + /// The fatal-mode shorthand every pre-Q#DR6 test used. + fn read_dir_fatal(path: &Path, cancel: &CancellationToken) -> Result, FsError> { + read_dir_blocking(path, cancel, ReadDirTolerance::Fatal).map(|listing| { + assert!( + listing.errors.is_none(), + "fatal mode must not open a per-entry channel" + ); + listing.entries + }) + } + #[test] fn read_dir_returns_entries_with_lstat_metadata() { let td = tempfile::tempdir().expect("tempdir"); std::fs::write(td.path().join("a.txt"), b"hello").expect("write"); std::fs::create_dir(td.path().join("subdir")).expect("mkdir"); - let entries = read_dir_blocking(td.path(), &token()).expect("read_dir"); + let entries = read_dir_fatal(td.path(), &token()).expect("read_dir"); let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); names.sort_unstable(); assert_eq!(names, vec!["a.txt", "subdir"]); @@ -516,7 +678,7 @@ mod tests { let td = tempfile::tempdir().expect("tempdir"); std::fs::write(td.path().join("real.txt"), b"x").expect("write"); symlink("real.txt", td.path().join("link")).expect("symlink"); - let entries = read_dir_blocking(td.path(), &token()).expect("read_dir"); + let entries = read_dir_fatal(td.path(), &token()).expect("read_dir"); let link = entries.iter().find(|e| e.name == "link").unwrap(); assert_eq!(link.kind, FsEntryKind::Symlink); assert_eq!(link.symlink_target.as_deref(), Some("real.txt")); @@ -535,7 +697,7 @@ mod tests { } let cancel = token(); cancel.cancel(); - let err = read_dir_blocking(td.path(), &cancel).expect_err("must observe cancel"); + let err = read_dir_fatal(td.path(), &cancel).expect_err("must observe cancel"); assert!(matches!(err, FsError::Cancelled), "got {err:?}"); } @@ -627,7 +789,7 @@ mod tests { fn read_dir_on_missing_path_reports_io_error() { let td = tempfile::tempdir().expect("tempdir"); let missing = td.path().join("does-not-exist"); - let err = read_dir_blocking(&missing, &token()).expect_err("must error"); + let err = read_dir_fatal(&missing, &token()).expect_err("must error"); match err { FsError::Io { path, .. } => { assert!( @@ -640,6 +802,109 @@ mod tests { } } + #[test] + fn read_dir_tolerant_opens_an_empty_error_channel_on_a_clean_directory() { + // `Some(vec![])` rather than `None` is the whole shape + // contract: the Lua boundary keys the bare-array-vs-table + // result on `errors.is_some()`, so a clean tolerant listing + // must still carry the channel. + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("a.txt"), b"x").expect("write"); + let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry) + .expect("tolerant read_dir"); + assert_eq!(listing.entries.len(), 1); + assert_eq!(listing.errors.as_deref(), Some(&[][..])); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn read_dir_tolerant_keeps_an_entry_whose_symlink_target_is_not_utf8() { + use std::os::unix::ffi::OsStrExt; + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("real.txt"), b"x").expect("write"); + // A legal Unix symlink target that is not representable as a + // Rust `String`. Before Q#DR6 this single entry took the whole + // listing down. + symlink( + std::ffi::OsStr::from_bytes(b"tgt-\xff"), + td.path().join("weird"), + ) + .expect("symlink"); + + let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry) + .expect("tolerant read_dir must survive a non-UTF-8 target"); + let weird = listing + .entries + .iter() + .find(|e| e.name == "weird") + .expect("the entry itself must be listed"); + assert_eq!(weird.kind, FsEntryKind::Symlink); + assert!( + weird.symlink_target.is_none(), + "an unrepresentable target reports as unknown" + ); + assert!( + listing.entries.iter().any(|e| e.name == "real.txt"), + "the readable sibling must survive too" + ); + let errors = listing.errors.expect("tolerant mode opens the channel"); + assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}"); + assert_eq!(errors[0].name.as_deref(), Some("weird")); + + // The same directory under the fatal contract still fails + // whole-listing --- the opt is what changes behavior, not the + // walk. + let err = read_dir_fatal(td.path(), &token()).expect_err("fatal mode must still fail"); + assert!( + matches!(err, FsError::NonUtf8Path { .. }), + "expected NonUtf8Path, got {err:?}" + ); + } + + #[test] + fn read_dir_tolerant_records_a_failed_lstat_and_lists_nothing_else_wrong() { + use std::os::unix::fs::PermissionsExt; + // Failure mode 1 from the framing: a directory readable but not + // searchable. `readdir` yields the names; every child `lstat` + // fails with EACCES. + let td = tempfile::tempdir().expect("tempdir"); + let dir = td.path().join("no-search"); + std::fs::create_dir(&dir).expect("mkdir"); + std::fs::write(dir.join("child"), b"x").expect("write child"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).expect("chmod 400"); + let searchable = std::fs::symlink_metadata(dir.join("child")).is_ok(); + if searchable { + // Running as root (or on a filesystem that ignores the + // bits): the premise cannot be established, so assert + // nothing rather than pass vacuously. + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) + .expect("restore perms"); + eprintln!("lstat still succeeds without search permission; skipping"); + return; + } + + let tolerant = read_dir_blocking(&dir, &token(), ReadDirTolerance::PerEntry); + let fatal = read_dir_fatal(&dir, &token()); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) + .expect("restore perms"); + + let listing = tolerant.expect("tolerant read_dir must not fail the listing"); + assert!( + listing.entries.is_empty(), + "the unreadable child cannot be described: {:?}", + listing.entries + ); + let errors = listing.errors.expect("tolerant mode opens the channel"); + assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}"); + assert_eq!( + errors[0].name.as_deref(), + Some("child"), + "an lstat failure has an entry in hand and must name it" + ); + let err = fatal.expect_err("fatal mode must still fail the whole listing"); + assert!(matches!(err, FsError::Io { .. }), "got {err:?}"); + } + #[cfg(not(target_os = "macos"))] #[test] fn read_dir_on_non_utf8_entry_name_reports_structured_error() { @@ -650,7 +915,7 @@ mod tests { // Rust `String`. let bad_name = std::ffi::OsStr::from_bytes(b"bad-\xff-name"); std::fs::write(td.path().join(bad_name), b"").expect("write entry"); - let err = read_dir_blocking(td.path(), &token()).expect_err("must error on non-UTF-8"); + let err = read_dir_fatal(td.path(), &token()).expect_err("must error on non-UTF-8"); match err { FsError::NonUtf8Path { parent, bytes } => { assert!( diff --git a/src/highlight.rs b/src/highlight.rs index 2c7369f..de8ffe0 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -174,6 +174,46 @@ impl Theme { // prefix-walks to `tag`. ("tag", fg(5)), ("attribute", fg(3)), + // Lean 4 (framing Q#LN4). These four are the captures the Lean + // query uses that the set above lacks — but three of them are + // NOT Lean-only, and adding them here changes languages that + // already ship. That is the #146 lesson (`attribute`, above, + // retro-painted rust/lua/yaml) and it is deliberate, not + // incidental: + // + // * `constructor` reaches SEVEN entries — rust, lua, python, + // javascript, and (because `tree_sitter_javascript:: + // HIGHLIGHT_QUERY` is concatenated base-first into them) + // javascriptreact, typescript, typescriptreact. Its shape is + // not "constructors": rust/python/javascript tag every + // capitalized identifier (`#match? "^[A-Z]"`), and lua tags + // every table-constructor brace. So this recolors `Some`, + // `None`, `Ok`, `Err`, every class-cased name, and every Lua + // `{}`. All of those render as unstyled default text today. + // * `character` reaches zig only. + // * `keyword.conditional` reaches cmake and zig, which + // currently flatten it to `keyword`; giving it + // `keyword.control`'s style makes their conditionals read the + // way rust's already do. + // * `warning` reaches no other grammar. It exists for Lean's + // `sorry` — an unproved goal, the single most important thing + // to see in a proof file. + // + // The alternative was an in-repo query overlay renaming these + // into the existing vocabulary (the #144 LaTeX pattern), which + // would fork a 213-line query we would then own and hand-merge + // on every crate bump. There is no middle option: styling Lean's + // constructors without touching the other seven entries requires + // renaming the capture, which requires the overlay. + ("constructor", fg(11)), + ("character", fg(2)), + ("keyword.conditional", fg_bold(13)), + // Bold BRIGHT red, deliberately the loudest entry in the table + // and deliberately distinct from `number`'s plain `fg(1)`: in a + // proof file `sorry` means "this is admitted, not proved", which + // is the one thing a reader must never skim past. Plain `fg(1)` + // would have collided with every numeric literal on colour alone. + ("warning", fg_bold(9)), ]; let by_capture = entries .iter() @@ -1484,6 +1524,262 @@ mod tests { ); } + /// Paint `src` as `language` into a one-row grid and return the style + /// at column `col`. Shared by the Q#LN4 retro-paint pins below. + fn painted_fg_at( + language_name: &str, + file: &str, + src: &str, + col: u32, + ) -> pmacs_protocol::cell::Color { + painted_style_at(language_name, file, src, col).fg + } + + /// As [`painted_fg_at`], but returns the whole style — needed where a + /// colour alone does not discriminate (Lean's `warning` vs `number`). + fn painted_style_at( + language_name: &str, + file: &str, + src: &str, + col: u32, + ) -> pmacs_protocol::cell::Style { + use crate::buffer::{Buffer, BufferId, EditOp}; + use crate::cell::{Cell, CellSize}; + use crate::syntax::{ParseView, SyntaxRegistry}; + + let reg = SyntaxRegistry::new(); + let language = reg.language(language_name).expect("grammar loads"); + let mut buf = Buffer::new(BufferId::next(), file); + buf.apply_edit(EditOp::Insert { + pos: 0, + bytes: src.as_bytes(), + }) + .unwrap(); + let view = ParseView::new(&buf, language, language_name.to_owned()); + let handle = view.handle(); + let _vid = buf.attach_view(Box::new(view)); + let mut req = handle.make_request(); + req.injection_aliases = reg.injection_alias_snapshot(); + let bundle = crate::syntax::run_parse(req).expect("parse"); + handle.install(reg.resolve_layer_queries(&bundle)); + + let mut hv = SyntaxHighlightView::new(handle, reg.theme()); + let (rows, cols) = (1usize, 40usize); + let mut backing: Vec = vec![Cell::default(); rows * cols]; + let mut grid = CellGrid { + cells: &mut backing, + stride: cols as u32, + size: CellSize::new(rows as u32, cols as u32), + }; + let viewport = Viewport { + buffer_start: 0, + buffer_end: u64::MAX, + cell_origin: CellCoord::new(0, 0), + cell_size: CellSize::new(rows as u32, cols as u32), + gutter_w: 0, + folds: None, + }; + hv.render(&buf, viewport, &mut grid); + grid.get(CellCoord::new(0, col)).style + } + + /// Does `language`'s compiled highlight query use `capture`? + fn query_uses_capture(language: &str, capture: &str) -> bool { + let reg = crate::syntax::SyntaxRegistry::new(); + let Some(query) = reg.highlights_query(language) else { + panic!("{language} has no highlights query"); + }; + query.capture_names().contains(&capture) + } + + #[test] + fn lean4_grid_paints_comment_keyword_name_operator_and_number() { + // Framing acceptance 5: the grammar plus the crate query plus the + // theme table actually produce distinct styles on a painted grid. + // Asserted end-to-end rather than at the query level because a + // capture that resolves to `Style::default()` is indistinguishable + // from no capture at all to a reader. + use pmacs_protocol::cell::Color; + + // `-- c` — the whole comment run. + assert_eq!( + painted_fg_at("lean4", "a.lean", "-- c\n", 0), + Color::Indexed(8), + "a Lean line comment paints the comment style" + ); + + // `def foo : Nat := 42` + let src = "def foo : Nat := 42\n"; + assert_eq!( + painted_fg_at("lean4", "a.lean", src, 0), + Color::Indexed(5), + "`def` paints the keyword style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", src, 4), + Color::Indexed(4), + "the definition's name paints the function style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", src, 14), + Color::Indexed(6), + "`:=` paints the operator style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", src, 17), + Color::Indexed(1), + "a numeric literal paints the number style" + ); + + // A string literal, and `theorem` as a second declaration keyword. + assert_eq!( + painted_fg_at("lean4", "a.lean", "def s := \"hi\"\n", 9), + Color::Indexed(2), + "a string literal paints the string style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 0), + Color::Indexed(5), + "`theorem` paints the keyword style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 8), + Color::Indexed(4), + "the theorem's name paints the function style" + ); + } + + #[test] + fn lean4_sorry_paints_the_warning_style_distinctly_from_a_number() { + // Framing acceptance 6. `sorry` admits a goal without proving it — + // in a proof file it is the single most important token to notice, + // and it is why Q#LN4 adds a `warning` entry at all. + // + // The style is asserted in FULL, not by colour: `number` and the + // first-choice `warning` colour were both indexed red, so a + // colour-only assertion would have passed with `sorry` painted + // exactly like the literal `42` beside it. That is the whole failure + // this test exists to prevent. + use pmacs_protocol::cell::Color; + + let sorry = painted_style_at("lean4", "a.lean", "theorem t : True := sorry\n", 20); + assert_eq!( + sorry.fg, + Color::Indexed(9), + "`sorry` paints the warning colour" + ); + assert!(sorry.bold, "`sorry` is bold"); + + let number = painted_style_at("lean4", "a.lean", "def n := 42\n", 9); + assert_ne!( + (sorry.fg, sorry.bold), + (number.fg, number.bold), + "`sorry` must be visually distinct from a numeric literal" + ); + } + + #[test] + fn lean4_constructor_capture_retro_paints_the_whole_javascript_family() { + // Framing acceptance 7 (Q#LN4), the breadth half. `constructor` was + // added for Lean, but four crates emit it — and because + // `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated + // base-first into the react/typescript entries + // (`src/syntax.rs`), it reaches SEVEN language entries, not four. + // + // Asserted at the query level rather than per-fixture precisely + // because the composition is the fragile part: if someone stops + // concatenating the JS base query into `typescript`, this fails + // while any single-language fixture would still pass. + for language in [ + "rust", + "lua", + "python", + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + ] { + assert!( + query_uses_capture(language, "constructor"), + "`{language}` emits @constructor, so Q#LN4's entry retro-paints it" + ); + } + } + + #[test] + fn lean4_capture_additions_paint_rust_constructors_and_lua_braces() { + // Framing acceptance 7, the "actually reaches painted cells" half — + // a query-name check alone would not prove the theme entry resolves. + // Both of these rendered as unstyled default text before Q#LN4. + use pmacs_protocol::cell::Color; + + // `None` at col 8 — a bare capitalized identifier, which is what the + // rust query's `#match? "^[A-Z]"` tags. Note that `Some(1)` does NOT + // work here: in call position a narrower `@function` pattern wins and + // paints fg 4. The distinction is worth keeping in the test, because + // it is the difference between "capitalized identifiers recolor" and + // "enum variants recolor" — only the former is true. + assert_eq!( + painted_fg_at("rust", "a.rs", "let x = None;\n", 8), + Color::Indexed(11), + "a bare Rust capitalized identifier paints the shared @constructor style" + ); + // `Some` in pattern position (col 10) does reach @constructor. + assert_eq!( + painted_fg_at("rust", "a.rs", "match v { Some(z) => z, None => 0 };\n", 10), + Color::Indexed(11), + "a Rust pattern-position variant paints the shared @constructor style" + ); + // ...but in CALL position the narrower @function pattern wins. Pinned + // so the blast radius recorded in the framing stays accurate. + assert_eq!( + painted_fg_at("rust", "a.rs", "let e = Err(1);\n", 8), + Color::Indexed(4), + "a called variant keeps @function, not @constructor" + ); + // Lua tags the table-constructor BRACES, not a name: `{` at col 10 + // of `local t = {}`. + assert_eq!( + painted_fg_at("lua", "a.lua", "local t = {}\n", 10), + Color::Indexed(11), + "a Lua table brace paints the shared @constructor style" + ); + } + + #[test] + fn lean4_capture_additions_do_not_reach_unrelated_languages() { + // Framing acceptance 8 — the negative pin, redrawn in review round 1. + // + // Rev 1 named Lua and Python here, which was a self-contradiction: + // both are retro-painted by `constructor`, so a "nothing moved" + // assertion over them would have been vacuous — the #155 R2 shape. + // These ten emit NONE of the four names, verified by grep over the + // crate queries in the dependency graph. + // + // Stated at the query level, which is stronger than a fixture + // snapshot: it holds for every construct in the language, not just + // the one a fixture happened to exercise. + const ADDED: [&str; 4] = ["constructor", "character", "keyword.conditional", "warning"]; + for language in [ + "markdown", "json", "yaml", "html", "css", "c", "cpp", "go", "toml", "bash", + ] { + for capture in ADDED { + assert!( + !query_uses_capture(language, capture), + "`{language}` must not emit @{capture}; Q#LN4 would silently restyle it" + ); + } + } + + // Non-vacuity: the same predicate must find each name where it DOES + // occur. Without this, a `query_uses_capture` that always returned + // false would pass the loop above. + assert!(query_uses_capture("lean4", "constructor")); + assert!(query_uses_capture("zig", "character")); + assert!(query_uses_capture("cmake", "keyword.conditional")); + assert!(query_uses_capture("lean4", "warning")); + } + #[test] fn web_grid_paints_html_tag_and_attribute() { // Q#WEB4 acceptance: the two capture entries this lane adds (`tag`, diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 29e3b47..3482879 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -2439,6 +2439,7 @@ pub fn install( )?; pmacs.set("instance", install_instance_module(lua, registry)?)?; pmacs.set("ansi", install_ansi_module(lua)?)?; + pmacs.set("path", install_path_module(lua)?)?; pmacs.set("packages", install_packages_module(lua)?)?; pmacs.set("state", install_state_module(lua)?)?; pmacs.set("session", install_session_module(lua)?)?; @@ -3555,6 +3556,46 @@ impl UserData for AnsiParserLua { } } +/// Build the `pmacs.path.*` table: pure path arithmetic, no +/// filesystem access and no editor state. +/// +/// `canonicalize(path)` is [`crate::editor_core::normalize_buffer_path`] +/// itself — the function the buffer registry's path keys already go +/// through on write and that `find_buffer_for_path` looks up with. It +/// expands a leading `~`, absolutizes against the process cwd, folds +/// `.` / `..` lexically, and drops redundant separators (so a trailing +/// slash disappears everywhere except at root). Symlinks are +/// deliberately **not** resolved: dired's `..` must return where the +/// user navigated from, and a not-yet-created "[new file]" path has +/// nothing to resolve. +/// +/// Exposed rather than mirrored in Lua because dired keys one buffer per +/// directory on this form (Q#DR2). Two implementations that disagree on +/// an edge (`//tmp`, `~` with `HOME` unset, a `..` that would escape +/// root) would mint two buffers for one directory with no error +/// anywhere. +/// +/// The result crosses the boundary through `to_string_lossy`, so a +/// non-UTF-8 `$HOME` (or a non-UTF-8 argument) can yield a Lua string +/// that no longer names the `PathBuf` the registry keys on. That is the +/// same limit `pmacs.fs` already documents — byte-preserving paths are +/// post-v0.1 work that widens every path in the API — and it is recorded +/// here so this binding is not read as an exception to it. +fn install_path_module(lua: &Lua) -> mlua::Result { + let path = lua.create_table()?; + path.set( + "canonicalize", + lua.create_function(|_, raw: String| { + Ok( + crate::editor_core::normalize_buffer_path(std::path::PathBuf::from(raw)) + .to_string_lossy() + .into_owned(), + ) + })?, + )?; + Ok(path) +} + /// Build the `pmacs.ansi.*` table. The only entry today is /// `parser()`; future additions (e.g. an event-table-validator /// helper) live alongside it. @@ -6479,6 +6520,40 @@ fn fs_dir_entry_to_lua(lua: &Lua, entry: &crate::fs::FsDirEntry) -> mlua::Result Ok(t) } +/// Convert a settled `read_dir` listing to its Lua result value. +/// +/// The shape is chosen by the listing itself (dired Q#DR6): a fatal-mode +/// listing carries no error channel and stays the **bare array** the +/// M8.1 surface documents --- the frozen M8.2 fixture consumes it with +/// `ipairs` --- while a tolerant listing becomes +/// `{ entries = { … }, errors = { { name = …?, message = … }, … } }`. +/// Keying on the payload rather than on the job keeps the additive +/// promise checkable in one place. +fn fs_dir_listing_to_lua(lua: &Lua, listing: crate::fs::FsDirListing) -> mlua::Result { + let entries = lua.create_table_with_capacity(listing.entries.len(), 0)?; + for (i, entry) in listing.entries.iter().enumerate() { + entries.set(i + 1, fs_dir_entry_to_lua(lua, entry)?)?; + } + let Some(errors) = listing.errors else { + return Ok(mlua::Value::Table(entries)); + }; + let rows = lua.create_table_with_capacity(errors.len(), 0)?; + for (i, error) in errors.iter().enumerate() { + let row = lua.create_table_with_capacity(0, 2)?; + // `name` is absent for a per-entry `readdir` iterator error: + // the entry never materialized, so there is nothing to name. + if let Some(name) = &error.name { + row.set("name", name.as_str())?; + } + row.set("message", error.message.as_str())?; + rows.set(i + 1, row)?; + } + let out = lua.create_table_with_capacity(0, 2)?; + out.set("entries", entries)?; + out.set("errors", rows)?; + Ok(mlua::Value::Table(out)) +} + fn stream_payload_to_lua(lua: &Lua, payload: StreamPayload) -> mlua::Result { match payload { StreamPayload::U64(v) => Ok(mlua::Value::Integer(i64::try_from(v).unwrap_or(i64::MAX))), @@ -6570,9 +6645,23 @@ pub fn install_async( let rt = runtime.clone(); async_mod.set( "_dispatch_fs_read_dir", - lua.create_function(move |_, (path, key): (String, Option)| { - Ok(rt.dispatch_fs_read_dir(std::path::PathBuf::from(path), key.as_deref())) - })?, + lua.create_function( + move |_, (path, key, tolerant): (String, Option, Option)| { + // dired Q#DR6: the tolerance is decided at dispatch + // and travels in the settled payload, so the result + // conversion below never has to look the job back up. + let tolerance = if tolerant == Some(true) { + crate::fs::ReadDirTolerance::PerEntry + } else { + crate::fs::ReadDirTolerance::Fatal + }; + Ok(rt.dispatch_fs_read_dir( + std::path::PathBuf::from(path), + tolerance, + key.as_deref(), + )) + }, + )?, )?; } @@ -6777,16 +6866,15 @@ pub fn install_async( i64::try_from(duration_ms).unwrap_or(i64::MAX), )); } - Some(JobOutcome::Complete(JobResult::ReadDir(entries))) => { + Some(JobOutcome::Complete(JobResult::ReadDir(listing))) => { // Lua surface for fs.read_dir settle: // status "ok", value = array of per-entry - // tables. T M8.1. + // tables (T M8.1), or the + // `{ entries = …, errors = … }` table when the + // caller opted into per-entry tolerance + // (dired Q#DR6). out.push_back(mlua::Value::String(lua.create_string("ok")?)); - let t = lua.create_table_with_capacity(entries.len(), 0)?; - for (i, entry) in entries.into_iter().enumerate() { - t.set(i + 1, fs_dir_entry_to_lua(lua, &entry)?)?; - } - out.push_back(mlua::Value::Table(t)); + out.push_back(fs_dir_listing_to_lua(lua, listing)?); } Some(JobOutcome::Complete(JobResult::Stat(entry))) => { // Lua surface for fs.stat settle: status @@ -6933,9 +7021,9 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res "ok", mlua::Value::Integer(i64::try_from(*duration_ms).unwrap_or(i64::MAX)), ), - JobOutcome::Complete(JobResult::ReadDir(entries)) => ( + JobOutcome::Complete(JobResult::ReadDir(listing)) => ( "ok", - mlua::Value::Integer(i64::try_from(entries.len()).unwrap_or(i64::MAX)), + mlua::Value::Integer(i64::try_from(listing.entries.len()).unwrap_or(i64::MAX)), ), JobOutcome::Complete(JobResult::Stat(entry)) => { ("ok", mlua::Value::String(lua.create_string(&entry.name)?)) @@ -9923,12 +10011,26 @@ pub fn install_lsp( let ids: Vec = mgr.ids().collect(); let out = lua.create_table_with_capacity(ids.len(), 0)?; for (i, id) in ids.iter().enumerate() { - let row = lua.create_table_with_capacity(0, 5)?; + let row = lua.create_table_with_capacity(0, 7)?; row.set("id", LspServerIdLua(*id))?; if let Some(spec) = mgr.spec(*id) { row.set("label", spec.label.as_str())?; row.set("language_id", spec.language_id.as_str())?; row.set("command", spec.command.as_str())?; + // Server *affinity* fields. `root_uri` is the spec + // field verbatim — deliberately NOT the URI the + // server was initialized with, which `build_initialize` + // derives from `cwd` when the field is `None`. Lua's + // `ensure_server` matches on this exact value, so a + // server that never asked for a specific root must + // read back as nil rather than as its cwd; see the + // affinity-key comment in `builtin/runtime/lsp.lua`. + if let Some(root_uri) = spec.root_uri.as_deref() { + row.set("root_uri", root_uri)?; + } + if let Some(cwd) = spec.cwd.as_deref() { + row.set("cwd", cwd.display().to_string())?; + } } if let Some(state) = mgr.state(*id) { row.set("state", lsp_state_to_lua(lua, state)?)?; diff --git a/src/syntax.rs b/src/syntax.rs index efbbd4d..bc6acf9 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -251,6 +251,12 @@ pub fn default_injection_aliases() -> HashMap { ("golang", "go"), ("yml", "yaml"), ("md", "markdown"), + // Lean 4 (framing Q#LN17). A ```lean fence is overwhelmingly Lean 4 + // in practice, so the Lean 3 spelling is deliberately mapped forward + // rather than left unresolved. `lean4` needs no alias — it is the + // entry name. `lean4-mode` does the equivalent through + // `markdown-code-lang-modes`. + ("lean", "lean4"), ] .into_iter() .map(|(a, b)| (a.to_owned(), b.to_owned())) @@ -1130,6 +1136,33 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[ locals_query: &[], injections_query: &[], }, + // Lean 4 (framing `docs/lean4-mode-framing.md`, Arc 8 Stage 1). + // + // The entry is named `lean4`, not `lean` (Q#LN2): this name becomes the + // `language_id` sent in `didOpen` — `ensure_server` at + // `builtin/runtime/lsp.lua:540` passes it straight through — and the + // Lean ecosystem's id is `lean4` (`lean` is Lean 3, which is + // end-of-life). The grammar's own C symbol is `tree_sitter_lean`; that + // is arborium's business, not ours. Stage 3 adds + // `pmacs.lsp.config.lean4` against this name. + // + // Note the loader shape: `arborium-lean` exports `const fn language() -> + // LanguageFn` rather than a `LANGUAGE` const, so this is the one entry + // that calls a function to get the `LanguageFn` before `.into()`. + // + // `.olean` (compiled artifacts) and `.ilean` (JSON metadata) are + // deliberately unclaimed (Q#LN3). Locals and injections are empty + // because the crate ships both as empty strings — Lean has no embedded + // sublanguage worth injecting, and its scoping is far beyond what a + // tree-sitter locals query could model. + LanguageEntry { + name: "lean4", + extensions: &["lean"], + loader: || arborium_lean::language().into(), + highlights_query: &[arborium_lean::HIGHLIGHTS_QUERY], + locals_query: &[], + injections_query: &[], + }, ]; /// LaTeX highlights overlay (framing Q#LX2). The chosen grammar crate @@ -2394,6 +2427,149 @@ mod tests { } } + #[test] + fn builtin_languages_include_lean4() { + // Framing acceptance 1/3 (`docs/lean4-mode-framing.md`). The entry is + // named `lean4` because that name becomes the `didOpen` language_id + // (Q#LN2), and it claims `.lean` ONLY: `.olean` is a compiled binary + // artifact and `.ilean` is JSON metadata (Q#LN3). + let lean = BUILTIN_LANGUAGES + .iter() + .find(|l| l.name == "lean4") + .expect("`lean4` language entry must be present"); + assert!(lean.extensions.contains(&"lean"), "`lean4` claims `.lean`"); + for unclaimed in ["olean", "ilean"] { + assert!( + !lean.extensions.contains(&unclaimed), + "`lean4` must not claim `.{unclaimed}`" + ); + } + assert!( + lean.highlights_query + .contains(&arborium_lean::HIGHLIGHTS_QUERY), + "`lean4` drives highlighting from the crate's query constant, not an overlay" + ); + assert!( + lean.locals_query.is_empty() && lean.injections_query.is_empty(), + "`lean4` ships neither locals nor injections (Q#LN1)" + ); + } + + #[test] + fn lean4_grammar_loads_and_parses() { + // Framing acceptance 2 and the open half of Q#LN1: `arborium-lean` + // exports `const fn language() -> LanguageFn` (not the `LANGUAGE` + // const every other entry uses) over `tree-sitter-language 0.1`, and + // its README demonstrates usage against a `tree_sitter_patched_ + // arborium` core. Neither is supposed to matter — the LanguageFn ABI + // is shared — but "supposed to" is not evidence, so this pins that + // OUR `tree-sitter` 0.26 core accepts it and produces a real tree. + // + // The fixture exercises the grammar's external scanner (`scanner.c` + // supplies a NEWLINE token, so layout-sensitive `def`/`theorem` + // bodies depend on it) and the Unicode operators that make Lean + // Lean — `→`, `∀`, `≥` — which a byte-oriented misbuild would shred. + let reg = SyntaxRegistry::new(); + let language = reg + .language("lean4") + .expect("`lean4` language loads from BUILTIN_LANGUAGES"); + let mut buf = fresh_buffer("Basic.lean"); + buf.apply_edit(EditOp::Insert { + pos: 0, + bytes: "-- a comment\n\ + def fibonacci : Nat → Nat\n\ + \x20 | 0 => 0\n\ + \x20 | n + 1 => n\n\ + \n\ + theorem fib_nonneg : ∀ n, fibonacci n ≥ 0 := by\n\ + \x20 intro n\n\ + \x20 exact Nat.zero_le _\n" + .as_bytes(), + }) + .unwrap(); + let view = ParseView::new(&buf, language, "lean4".to_owned()); + let handle = view.handle(); + let _vid = buf.attach_view(Box::new(view)); + let bundle = parse_synchronously(&handle); + assert_eq!( + bundle.root_tree().root_node().kind(), + "module", + "Lean grammar roots at module" + ); + let sexp = bundle.root_tree().root_node().to_sexp(); + // This specific committed fixture parses cleanly. The claim is + // scoped to the fixture on purpose: Lean's syntax is user-extensible + // via macros, so a static grammar necessarily mis-parses some legal + // input (the upstream grammar says so itself, and the framing scores + // it as bet 3). What a clean parse HERE proves is that the crate is + // wired correctly, not that Lean is fully parseable. + assert!( + !bundle.root_tree().root_node().has_error(), + "the fixture parses without error; got {sexp}" + ); + // `def` and `theorem` sit under a `declaration` wrapper, not directly + // under `module`. + for expected in ["(comment)", "(def ", "(theorem "] { + assert!( + sexp.contains(expected), + "expected `{expected}` in the tree; got {sexp}" + ); + } + // The load-bearing part of this test. A grammar built against a + // mismatched core, or one whose scanner mis-handles multibyte input, + // does not fail loudly — it produces a tree that silently degrades on + // exactly the characters Lean is made of. `→` must become an `arrow`, + // `∀` a `forall`, and `≥` a `comparison`; if these three hold, the + // UTF-8 path through the parser is sound. + for expected in ["(arrow ", "(forall ", "(comparison "] { + assert!( + sexp.contains(expected), + "Unicode operator did not produce `{expected}`; got {sexp}" + ); + } + } + + #[test] + fn lean4_highlights_resolve() { + // The crate's 213-line query must COMPILE against the grammar it + // ships with — the node-name compatibility gate. A query referencing + // a node this grammar version lacks fails here rather than silently + // producing no spans at runtime. + let reg = SyntaxRegistry::new(); + let query = reg + .highlights_query("lean4") + .expect("lean4 highlights compile against the grammar"); + let names = query.capture_names(); + // The four capture names Q#LN4 adds to the GLOBAL theme table are + // present here — this is the forward direction of that decision; the + // reverse direction (what they do to other languages) is pinned in + // `highlight.rs`. + for expected in ["constructor", "character", "keyword.conditional", "warning"] { + assert!( + names.contains(&expected), + "lean4 query uses `@{expected}`, which Q#LN4 adds to the theme; got {names:?}" + ); + } + } + + #[test] + fn language_for_path_resolves_lean_extension() { + let reg = SyntaxRegistry::new(); + assert_eq!( + reg.language_name_for_path("Mathlib/Data/Nat/Basic.lean") + .as_deref(), + Some("lean4"), + "`.lean` resolves to the lean4 grammar" + ); + for unclaimed in ["Basic.olean", "Basic.ilean"] { + assert_ne!( + reg.language_name_for_path(unclaimed).as_deref(), + Some("lean4"), + "{unclaimed} must not resolve to lean4" + ); + } + } + #[test] fn builtin_languages_include_html_and_css() { // Both crate grammars export their query constants (no overlay). HTML diff --git a/src/workers_buffer.rs b/src/workers_buffer.rs index 05e9a84..6a6eeb4 100644 --- a/src/workers_buffer.rs +++ b/src/workers_buffer.rs @@ -202,8 +202,18 @@ fn format_outcome(outcome: &JobOutcome) -> String { JobOutcome::Complete(JobResult::Parse { duration_ms }) => { format!("ok (parse {duration_ms}ms)") } - JobOutcome::Complete(JobResult::ReadDir(entries)) => { - format!("ok ({} entries)", entries.len()) + JobOutcome::Complete(JobResult::ReadDir(listing)) => { + // Per-entry failures (dired Q#DR6) are counted here too: a + // tolerant listing that dropped half a directory is not the + // same observable outcome as a clean one. + match listing.errors.as_deref() { + Some(errors @ [_, ..]) => format!( + "ok ({} entries, {} unreadable)", + listing.entries.len(), + errors.len() + ), + _ => format!("ok ({} entries)", listing.entries.len()), + } } JobOutcome::Complete(JobResult::Stat(entry)) => { format!("ok (stat {:?})", entry.name) diff --git a/tests/dired_acceptance.rs b/tests/dired_acceptance.rs new file mode 100644 index 0000000..7d0f2c5 --- /dev/null +++ b/tests/dired_acceptance.rs @@ -0,0 +1,1656 @@ +// tests/dired_acceptance.rs --- dired arc Stage 1 acceptance. + +//! Acceptance for the dired view (`docs/dired-framing.md` §14 items +//! 1-16, Q#DR2-DR10). Item 17 --- "the fixture still passes" --- is a +//! gate item rather than a test here: `m8_1`/`m8_2`/`m8_3` prove the +//! `read_dir` opt is additive by continuing to pass unchanged. +//! +//! Discipline, following the Stage 0 suite: +//! +//! * every in-buffer claim is driven by a **real key** through +//! `dispatch_key`, so a dead mode-keymap entry cannot pass vacuously; +//! * `pmacs.dired.open` is called directly only where a test needs an +//! opt the interactive command does not carry (`display = "panel"`), +//! and it is the documented public entry point in those cases; +//! * every listing is async, so each dispatch is followed by `pump`, +//! which drives `tick_async` until the coroutine and its worker job +//! have both settled. +//! +//! Fixtures use `.txt` files and empty `pmacs.lsp.config`, so no +//! `buffer.after-load` hook spawns a language server. Note the suite +//! asserts nothing about LSP, so the wipe cannot make an assertion +//! vacuous (the Lean 4 round-1 trap). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::cell::{CellGrid, CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::editor_core::normalize_buffer_path; +use pmacs::protocol::FrontendId; +use pmacs::window::WindowId; +use tempfile::TempDir; + +const ROWS: u32 = 24; +const COLS: u32 = 100; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn ctrl(s: &mut EditorState, c: char) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(c), KeyModifiers::CONTROL), + ); +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn type_char(s: &mut EditorState, c: char) { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + type_char(s, ch); + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// A fresh editor with a declared frame geometry (a grid frontend's real +/// frame size *is* its geometry declaration, and the panel tests need +/// one before any side window can be placed). +fn editor() -> EditorState { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + s +} + +/// An editor whose active buffer is a real file inside `dir`, so the +/// `C-x d` prompt prefills with that directory and `C-x C-j` has a file +/// to jump from. +fn editor_in(dir: &Path) -> (EditorState, PathBuf) { + let anchor = dir.join("anchor.txt"); + std::fs::write(&anchor, b"anchor\n").expect("write anchor"); + let s = editor(); + let anchor_str = anchor.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({anchor_str:?})")); + (s, anchor) +} + +/// Drive the async runtime until no coroutine is parked and no worker +/// job is pending. Every dired command dispatches `read_dir` on a +/// worker and resumes on a later tick, so nothing dired does is +/// observable until this returns. +fn pump(s: &mut EditorState) { + let deadline = Instant::now() + Duration::from_secs(10); + let mut spins = 0u32; + loop { + let idle: bool = eval( + s, + "return pmacs._async.parked_count() == 0 and pmacs._async.pending_count() == 0", + ); + if idle { + return; + } + assert!(Instant::now() < deadline, "async pump deadline exceeded"); + s.tick_async(); + spins += 1; + if spins > 64 { + std::thread::sleep(Duration::from_millis(1)); + } + } +} + +/// The canonical form of `path` — the core's own normalizer, which is +/// exactly what `pmacs.path.canonicalize` calls. +fn canon(path: &Path) -> String { + normalize_buffer_path(path.to_path_buf()) + .to_string_lossy() + .into_owned() +} + +fn active_text(s: &EditorState) -> String { + eval( + s, + "local b = pmacs.window.buffer()\nreturn b:slice(0, b:len())", + ) +} + +fn active_lines(s: &EditorState) -> Vec { + active_text(s).lines().map(str::to_owned).collect() +} + +fn active_name(s: &EditorState) -> String { + eval( + s, + "return pmacs.describe.buffer(pmacs.window.buffer()).name", + ) +} + +fn active_path(s: &EditorState) -> Option { + eval( + s, + "local b = pmacs.window.buffer()\n\ + if b == nil then return nil end\n\ + local ok, p = pcall(function() return b:path() end)\n\ + if ok then return p end\n\ + return nil", + ) +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +fn buffer_names(s: &EditorState) -> Vec { + eval( + s, + "local out = {}\n\ + for _, id in ipairs(pmacs.buffer.list()) do\n\ + out[#out + 1] = pmacs.describe.buffer(id).name\n\ + end\n\ + return out", + ) +} + +fn dired_buffer_names(s: &EditorState) -> Vec { + let mut names: Vec = buffer_names(s) + .into_iter() + .filter(|n| n.starts_with("*dired:")) + .collect(); + names.sort(); + names +} + +/// One layout offset from dired's own constants, so column assertions +/// cannot drift from the module that computes them. +fn layout(s: &EditorState, field: &str) -> usize { + let value: i64 = eval(s, &format!("return pmacs.dired._layout.{field}")); + usize::try_from(value).expect("layout offsets are non-negative") +} + +/// The rendered name column of one listing line. +fn line_name(s: &EditorState, line: &str) -> String { + let start = layout(s, "NAME_START"); + line.get(start..).unwrap_or("").to_owned() +} + +/// The 0-based line the entry named `name` renders on. +fn line_of(s: &EditorState, name: &str) -> usize { + let lines = active_lines(s); + for (index, line) in lines.iter().enumerate().skip(1) { + let rendered = line_name(s, line); + if rendered == name || rendered.starts_with(&format!("{name} -> ")) { + return index; + } + } + panic!("no listing line for {name:?} in {lines:#?}"); +} + +/// Seat the cursor on `name`'s line. Test scaffolding: the *keys* that +/// move by line are exercised separately (acceptance 6). +fn seat_on(s: &EditorState, name: &str) { + let line = line_of(s, name); + exec(s, &format!("pmacs.editor.move_to_line({line})")); +} + +fn cursor_line(s: &EditorState) -> usize { + let value: i64 = eval(s, "return pmacs.editor.cursor_line()"); + usize::try_from(value).expect("cursor lines are non-negative") +} + +/// The entry name under the cursor, or `None` on the header/footer. +fn cursor_entry(s: &EditorState) -> Option { + let line = cursor_line(s); + if line == 0 { + return None; + } + let lines = active_lines(s); + lines.get(line).map(|text| line_name(s, text)) +} + +/// Open `path` through the public entry point, pumping to settle. +/// Returns the raised message, if it raised. +fn open_dired(s: &mut EditorState, path: &str, opts: &str) -> Option { + exec( + s, + &format!( + "_G.DIRED_ERR = nil\n\ + pmacs.async(function()\n\ + local ok, err = pcall(pmacs.dired.open, {path:?}, {opts})\n\ + if not ok then\n\ + _G.DIRED_ERR = type(err) == 'table' and tostring(err.message) or tostring(err)\n\ + end\n\ + end)" + ), + ); + pump(s); + eval(s, "return _G.DIRED_ERR") +} + +fn open_ok(s: &mut EditorState, path: &Path, opts: &str) { + let raised = open_dired(s, &path.display().to_string(), opts); + assert!( + raised.is_none(), + "dired.open must succeed; raised {raised:?}" + ); +} + +fn side_window(s: &EditorState) -> Option { + s.core.borrow().side_window_for(FrontendId::LOCAL) +} + +fn window_buffer_name(s: &EditorState, window: WindowId) -> String { + let buffer_id = s + .core + .borrow() + .windows + .get(&window) + .map(|w| w.buffer_id) + .expect("window is live"); + let registry = s.lua_host.registry().borrow(); + registry + .get(buffer_id) + .expect("buffer is live") + .name() + .to_owned() +} + +fn active_window(s: &EditorState) -> WindowId { + s.core.borrow().active_window_id() +} + +/// Paint one real frame and return its rows as text. +fn painted_rows(s: &EditorState) -> Vec { + 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: COLS, + size, + }; + pmacs::editor::paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); + (0..ROWS) + .map(|row| { + (0..COLS) + .map(|col| match &cells[(row * COLS + col) as usize].glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(_) => '?', + Glyph::Continuation => ' ', + }) + .collect::() + .trim_end() + .to_owned() + }) + .collect() +} + +/// `a.txt` (5 bytes), `b.txt` (6 bytes), `subdir/`, and `link -> +/// a.txt`. +fn fixture_dir() -> TempDir { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("a.txt"), b"hello").expect("write a"); + std::fs::write(td.path().join("b.txt"), b"world!").expect("write b"); + std::fs::create_dir(td.path().join("subdir")).expect("mkdir"); + std::fs::write(td.path().join("subdir").join("inner.txt"), b"deep\n").expect("write inner"); + std::os::unix::fs::symlink("a.txt", td.path().join("link")).expect("symlink"); + td +} + +// --------------------------------------------------------------------------- +// 1 --- listing shape +// --------------------------------------------------------------------------- + +/// Header line plus one line per entry, with kind char, perms, size, +/// mtime, and name; a symlink renders `l` with ` -> target`; the entry +/// count matches `read_dir`. Driven through the real `C-x d`, accepting +/// the prefilled directory. +#[test] +fn dired_renders_a_header_and_one_line_per_entry() { + let td = fixture_dir(); + let (mut s, _anchor) = editor_in(td.path()); + + ctrl(&mut s, 'x'); + type_char(&mut s, 'd'); + assert!( + eval::(&s, "return pmacs.minibuffer.is_active()"), + "C-x d must open a prompt" + ); + assert_eq!( + eval::(&s, "return pmacs.minibuffer.contents()"), + canon(td.path()), + "the prompt prefills with the current buffer's directory, so RET \ + opens where you are" + ); + press(&mut s, KeyCode::Enter); + pump(&mut s); + + let lines = active_lines(&s); + assert_eq!( + lines[0], + format!("{}:", canon(td.path())), + "line 0 is the header" + ); + let on_disk = std::fs::read_dir(td.path()).expect("read_dir").count(); + assert_eq!( + lines.len() - 1, + on_disk, + "one line per entry, no footer on a clean listing: {lines:#?}" + ); + + let kind_start = layout(&s, "KIND_START"); + let perms_start = layout(&s, "PERMS_START"); + let perms_end = layout(&s, "PERMS_END"); + let size_start = layout(&s, "SIZE_START"); + + let a = &lines[line_of(&s, "a.txt")]; + assert_eq!(&a[kind_start..=kind_start], "-", "a regular file: {a:?}"); + let perms = &a[perms_start..perms_end]; + assert_eq!(perms.len(), 9, "nine permission characters: {perms:?}"); + assert!( + perms.starts_with("rw"), + "owner may read and write a file we just wrote: {perms:?}" + ); + assert_eq!( + a[size_start..size_start + 10].trim(), + "5", + "the size column carries a.txt's five bytes: {a:?}" + ); + assert!( + a[perms_end..size_start].chars().all(char::is_whitespace), + "columns are space-separated: {a:?}" + ); + + let sub = &lines[line_of(&s, "subdir")]; + assert_eq!(&sub[kind_start..=kind_start], "d", "a directory: {sub:?}"); + + let link = &lines[line_of(&s, "link")]; + assert_eq!(&link[kind_start..=kind_start], "l", "a symlink: {link:?}"); + assert_eq!( + line_name(&s, link), + "link -> a.txt", + "a symlink shows its target" + ); + + // The mark column is reserved and blank in Stage 1 (Q#DR4): filling + // it in is Stage 2's job, and reserving it now is what keeps Stage + // 2 from moving every column right of it. + let mark_start = layout(&s, "MARK_START"); + for line in &lines[1..] { + assert_eq!( + &line[mark_start..kind_start], + " ", + "the mark column renders blank: {line:?}" + ); + } + + let mtime_start = layout(&s, "MTIME_START"); + let name_start = layout(&s, "NAME_START"); + let stamp = &a[mtime_start..name_start - 1]; + assert_eq!(stamp.len(), 16, "fixed-width mtime: {stamp:?}"); + assert!( + stamp.starts_with("20") && stamp.contains('-') && stamp.contains(':'), + "an ISO-ish minute-precision timestamp: {stamp:?}" + ); +} + +/// The columns are a CONTRACT, not a formatting preference: `_layout` is +/// exported and Stage 3's column-classifying intercept is planned +/// against it. A size that does not fit ten digits (10 GB and up — VM +/// images, core dumps) must therefore yield precision rather than width, +/// the way `fmt_mtime` already does. Without that, one line's mtime and +/// name shift right and nothing notices until Stage 3. +#[test] +fn dired_keeps_its_columns_when_a_size_exceeds_the_field() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("small.txt"), b"x").expect("write small"); + let huge = td.path().join("huge.img"); + // Sparse: `set_len` allocates nothing on any filesystem pmacs + // supports. If one refuses, the premise cannot be established. + let file = std::fs::File::create(&huge).expect("create huge"); + if file.set_len(12_000_000_000).is_err() { + eprintln!("filesystem refused a sparse 12 GB file; skipping"); + return; + } + drop(file); + let reported = std::fs::metadata(&huge).expect("stat huge").len(); + assert!( + reported > 9_999_999_999, + "fixture premise: the size must exceed ten digits, got {reported}" + ); + + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + let size_start = layout(&s, "SIZE_START"); + let mtime_start = layout(&s, "MTIME_START"); + let name_start = layout(&s, "NAME_START"); + + let lines = active_lines(&s); + for name in ["huge.img", "small.txt"] { + let line = &lines[line_of(&s, name)]; + let size = &line[size_start..mtime_start - 1]; + assert_eq!( + size.len(), + 10, + "the size field must stay ten columns wide: {line:?}" + ); + let stamp = &line[mtime_start..name_start - 1]; + assert!( + stamp.starts_with("20") && stamp.contains(':'), + "so the mtime still starts where the layout says: {line:?}" + ); + assert_eq!( + line_name(&s, line), + name, + "and the name still starts at NAME_START" + ); + } + + // The oversized value degrades to a magnitude rather than a + // placeholder, so the listing still says how big the file is. + let huge_line = &lines[line_of(&s, "huge.img")]; + let size = huge_line[size_start..mtime_start - 1].trim(); + assert!( + size.ends_with('G') || size.ends_with('T'), + "an oversized size keeps its magnitude: {size:?}" + ); + // A size that DOES fit stays exact. + let small_line = &lines[line_of(&s, "small.txt")]; + assert_eq!( + small_line[size_start..mtime_start - 1].trim(), + "1", + "a size that fits is still the exact byte count" + ); +} + +// --------------------------------------------------------------------------- +// 2 --- visit dispatches on kind, through the panel-safe primitive +// --------------------------------------------------------------------------- + +/// `RET` on a directory descends; on a file it opens the file; on the +/// header it does nothing. +#[test] +fn dired_visit_dispatches_on_entry_kind() { + let td = fixture_dir(); + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + + // Header: no entry, so nothing happens. + exec(&s, "pmacs.editor.move_to_line(0)"); + let before = active_name(&s); + press(&mut s, KeyCode::Enter); + pump(&mut s); + assert_eq!( + active_name(&s), + before, + "RET on the header must not visit anything" + ); + + // Directory: descend into its own dired buffer. + seat_on(&s, "subdir"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + assert_eq!( + active_name(&s), + format!("*dired:{}*", canon(&td.path().join("subdir"))), + "RET on a directory opens that directory's dired buffer" + ); + assert_eq!( + line_name(&s, &active_lines(&s)[1]), + "inner.txt", + "the descended listing is the subdirectory's" + ); + + // File: the fixture's "requires the buffer-from-file API" error is + // gone --- `f` is the same command as RET. + seat_on(&s, "inner.txt"); + type_char(&mut s, 'f'); + pump(&mut s); + assert_eq!( + active_path(&s).map(PathBuf::from), + Some(PathBuf::from(canon( + &td.path().join("subdir").join("inner.txt") + ))), + "RET/f on a file opens the file bound to its path" + ); + assert_eq!( + eval::(&s, "return pmacs.window.buffer():slice(0, 4)"), + "deep", + "the file's real contents load" + ); +} + +/// A symlink's kind is `"symlink"` in both `read_dir` and `stat` (both +/// are lstat-based), so nothing in the entry says what it points at. +/// `RET` therefore tries the descent and falls back to a file visit — +/// one read, since `open_directory` reads before touching any editor +/// state and its failure *is* the "not a directory" answer. +#[test] +fn dired_visit_follows_a_symlink_to_the_kind_of_its_target() { + let td = fixture_dir(); + std::os::unix::fs::symlink("subdir", td.path().join("linkdir")).expect("symlink to dir"); + + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + + // A symlink to a directory descends. The path is NOT resolved + // (canonicalization is lexical), so the buffer names the way the user + // navigated — Emacs parity. + seat_on(&s, "linkdir"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + assert_eq!( + active_name(&s), + format!("*dired:{}*", canon(&td.path().join("linkdir"))), + "a symlinked directory descends under the path we walked" + ); + assert_eq!( + line_name(&s, &active_lines(&s)[1]), + "inner.txt", + "and shows the target directory's contents" + ); + + // A symlink to a file opens the file. + type_char(&mut s, '^'); + pump(&mut s); + seat_on(&s, "link"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + let path = active_path(&s).expect("a file must be open"); + assert!( + path.ends_with("/link"), + "the visit keeps the link's own path; got {path}" + ); + assert_eq!( + eval::(&s, "return pmacs.window.buffer():slice(0, 5)"), + "hello", + "with the target's contents" + ); +} + +/// The panel case, which is the real assertion (Q#DR10): with dired +/// displayed as a panel, `RET` on a file leaves the dired panel alive +/// and puts the file in the document window. Falsified by swapping +/// `display_file` for `find_or_open`, which switches the active window +/// in both branches before firing hooks --- the panel swallows itself. +#[test] +fn dired_visit_from_a_panel_keeps_the_panel_and_uses_the_document_window() { + let td = fixture_dir(); + let (mut s, anchor) = editor_in(td.path()); + let document = active_window(&s); + open_ok(&mut s, td.path(), r#"{ display = "panel" }"#); + + let panel = side_window(&s).expect("display = panel must create a side window"); + assert_eq!(active_window(&s), panel, "the panel is selected"); + assert_eq!( + window_buffer_name(&s, panel), + format!("*dired:{}*", canon(td.path())), + "the panel shows dired" + ); + + seat_on(&s, "a.txt"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + + let panel_after = side_window(&s).expect("the dired panel must survive a file visit"); + assert_eq!(panel_after, panel, "the same side window, not a new one"); + assert_eq!( + window_buffer_name(&s, panel_after), + format!("*dired:{}*", canon(td.path())), + "the panel still shows dired" + ); + assert_eq!( + window_buffer_name(&s, document), + canon(&td.path().join("a.txt")), + "the visited file lands in the document window" + ); + assert_eq!( + active_path(&s).map(PathBuf::from), + Some(PathBuf::from(canon(&td.path().join("a.txt")))), + "and it is what the visit selected" + ); + assert!( + anchor.exists(), + "fixture sanity: the anchor file was never touched" + ); +} + +// --------------------------------------------------------------------------- +// 3 --- one buffer per directory, canonicalized +// --------------------------------------------------------------------------- + +/// Descending twice then ascending twice yields the *same* buffers as +/// the first visit, and every dired buffer's name describes the +/// directory it displays. +#[test] +fn dired_navigation_reuses_one_buffer_per_directory() { + let td = tempfile::tempdir().expect("tempdir"); + let deep = td.path().join("one").join("two"); + std::fs::create_dir_all(&deep).expect("mkdir -p"); + std::fs::write(deep.join("leaf.txt"), b"leaf\n").expect("write leaf"); + + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + exec(&s, "_G.ROOT = pmacs.window.buffer()"); + + seat_on(&s, "one"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + exec(&s, "_G.ONE = pmacs.window.buffer()"); + seat_on(&s, "two"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + exec(&s, "_G.TWO = pmacs.window.buffer()"); + assert_eq!( + active_name(&s), + format!("*dired:{}*", canon(&deep)), + "each buffer's name describes the directory it displays" + ); + + // Back up, with `^`. + type_char(&mut s, '^'); + pump(&mut s); + assert!( + eval::(&s, "return pmacs.window.buffer() == _G.ONE"), + "ascending returns to the SAME buffer, not a fresh one" + ); + assert_eq!( + cursor_entry(&s).as_deref(), + Some("two"), + "`^` seats the cursor on the directory it came from" + ); + type_char(&mut s, '^'); + pump(&mut s); + assert!( + eval::(&s, "return pmacs.window.buffer() == _G.ROOT"), + "and again at the next level up" + ); + + // Down again: still the same two buffers. + seat_on(&s, "one"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + assert!(eval::(&s, "return pmacs.window.buffer() == _G.ONE")); + seat_on(&s, "two"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + assert!(eval::(&s, "return pmacs.window.buffer() == _G.TWO")); + assert_eq!( + dired_buffer_names(&s).len(), + 3, + "three directories visited, three dired buffers: {:?}", + dired_buffer_names(&s) + ); +} + +/// Three spellings of one directory yield ONE buffer, because names and +/// lookups both go through the canonical form (Q#DR2). +#[test] +fn dired_canonicalizes_before_naming_and_lookup() { + let td = fixture_dir(); + let base = td.path().display().to_string(); + let name = td + .path() + .file_name() + .expect("tempdir has a basename") + .to_string_lossy() + .into_owned(); + + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + let raised = open_dired(&mut s, &format!("{base}/"), "nil"); + assert!(raised.is_none(), "trailing slash must open: {raised:?}"); + let raised = open_dired(&mut s, &format!("{base}/../{name}"), "nil"); + assert!(raised.is_none(), "a `..` round trip must open: {raised:?}"); + + assert_eq!( + dired_buffer_names(&s), + vec![format!("*dired:{}*", canon(td.path()))], + "three spellings, one buffer" + ); +} + +/// `dired.kill-when-opening` (Emacs 28's opt-out): the departed buffer +/// is gone after a descent. +#[test] +fn dired_kill_when_opening_kills_the_departed_buffer() { + let td = fixture_dir(); + let mut s = editor(); + exec(&s, "pmacs.config.set('dired.kill-when-opening', true)"); + open_ok(&mut s, td.path(), "nil"); + assert_eq!(dired_buffer_names(&s).len(), 1); + + seat_on(&s, "subdir"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + + assert_eq!( + dired_buffer_names(&s), + vec![format!("*dired:{}*", canon(&td.path().join("subdir")))], + "descending killed the buffer it left" + ); + // And the setting is what did it: the default keeps both. + exec(&s, "pmacs.config.set('dired.kill-when-opening', false)"); + type_char(&mut s, '^'); + pump(&mut s); + assert_eq!( + dired_buffer_names(&s).len(), + 2, + "with the setting off, the departed buffer survives: {:?}", + dired_buffer_names(&s) + ); +} + +// --------------------------------------------------------------------------- +// 3b --- canonicalization parity +// --------------------------------------------------------------------------- + +/// The Lua canonicalizer and the core normalizer agree on every edge in +/// one shared list --- because they are the *same function* +/// (`pmacs.path.canonicalize` is `normalize_buffer_path`). Stage 1 +/// deliberately did not mirror the normalizer in Lua: a second +/// implementation that disagreed on `//tmp` or a `..` at root would +/// mint two buffers for one directory with no error anywhere, and the +/// mirror would then owe Stage 2 a removal. +#[test] +fn dired_canonicalization_is_the_cores_own_normalizer() { + let s = editor(); + let cases = [ + "//tmp", + "/tmp/", + "/tmp/../tmp", + "/tmp/./x/../y", + "/../..", + "/", + ".", + "relative/path", + "~", + "~/inside", + "~notauser/x", + ]; + for case in cases { + let from_lua: String = eval(&s, &format!("return pmacs.path.canonicalize({case:?})")); + let from_rust = normalize_buffer_path(PathBuf::from(case)) + .to_string_lossy() + .into_owned(); + assert_eq!( + from_lua, from_rust, + "canonicalization must not fork for {case:?}" + ); + } + + // And the form dired names buffers with is that same form. + let td = fixture_dir(); + let mut s = s; + open_ok(&mut s, td.path(), "nil"); + assert_eq!(active_name(&s), format!("*dired:{}*", canon(td.path()))); +} + +// --------------------------------------------------------------------------- +// 3c --- panel descent +// --------------------------------------------------------------------------- + +/// A directory descent in a panel-displayed dired stays in the *same* +/// side window (Q#DR10): the next directory is the same kind of thing as +/// the current one and belongs in the same slot. Neither replaced by a +/// document window nor duplicated. +/// +/// **This test does not pin the routing itself, and says so rather than +/// implying otherwise:** dired holds the focus in its own panel here, so +/// a raw `switch_buffer` lands in that same window and the assertions +/// below hold either way (verified — the mutation is VACUOUS against +/// this test). What distinguishes `display { side = … }` from the raw +/// switch is dedication, so the discriminating pin is +/// `dired_descent_from_a_dedicated_panel_leaves_the_pin_alone` below. +#[test] +fn dired_directory_descent_stays_in_its_side_window() { + let td = fixture_dir(); + let (mut s, anchor) = editor_in(td.path()); + let document = active_window(&s); + open_ok(&mut s, td.path(), r#"{ display = "panel" }"#); + let panel = side_window(&s).expect("a side window"); + + seat_on(&s, "subdir"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + + assert_eq!( + side_window(&s), + Some(panel), + "the same side window, not a second one" + ); + assert_eq!( + window_buffer_name(&s, panel), + format!("*dired:{}*", canon(&td.path().join("subdir"))), + "showing the new directory" + ); + assert_eq!( + window_buffer_name(&s, document), + canon(&anchor), + "the document window is untouched" + ); + assert_eq!(active_window(&s), panel, "and dired keeps the focus"); +} + +/// A **dedicated** panel is a different story, and the framing's R2-3 +/// expectation ("the new dired buffer inherits the dedication") is +/// falsified by the substrate: `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). Dired does not try to unpin the user's panel, so the +/// pin holds and the new directory appears in the document area, which +/// is also what Emacs's `display-buffer` does with a dedicated window. +#[test] +fn dired_descent_from_a_dedicated_panel_leaves_the_pin_alone() { + let td = fixture_dir(); + let (mut s, _anchor) = editor_in(td.path()); + let document = active_window(&s); + open_ok(&mut s, td.path(), r#"{ display = "panel" }"#); + let panel = side_window(&s).expect("a side window"); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + panel.raw() + ), + ); + + seat_on(&s, "subdir"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + + assert_eq!( + side_window(&s), + Some(panel), + "no second side window is created" + ); + assert_eq!( + window_buffer_name(&s, panel), + format!("*dired:{}*", canon(td.path())), + "the dedicated slot keeps the buffer it was pinned to" + ); + assert!( + eval::( + &s, + &format!("return pmacs.window.params({}).dedicated", panel.raw()) + ), + "and it is still dedicated afterward" + ); + assert_eq!( + window_buffer_name(&s, document), + format!("*dired:{}*", canon(&td.path().join("subdir"))), + "the new directory falls back to the document window" + ); +} + +// --------------------------------------------------------------------------- +// 4 --- ownership check +// --------------------------------------------------------------------------- + +/// A foreign buffer that merely *has* dired's name is not adopted (F7): +/// `pmacs.buffer.create` takes any caller-chosen name, and dired paints +/// with `bypass_intercept`, so adopting one would silently clobber a +/// user's data. +#[test] +fn dired_does_not_adopt_a_foreign_buffer_with_its_name() { + let td = fixture_dir(); + let mut s = editor(); + let name = format!("*dired:{}*", canon(td.path())); + exec( + &s, + &format!( + "local b = pmacs.buffer.create({name:?})\n\ + b:insert(0, 'FOREIGN CONTENTS')\n\ + _G.FOREIGN = b" + ), + ); + // Even with the major mode set, which is the weaker ownership test + // the framing floated: the handle table is the authority. + exec(&s, "pmacs.buffer.set_major_mode(_G.FOREIGN, 'dired')"); + + open_ok(&mut s, td.path(), "nil"); + + assert_eq!( + eval::(&s, "return _G.FOREIGN:slice(0, _G.FOREIGN:len())"), + "FOREIGN CONTENTS", + "the foreign buffer's contents must be byte-identical" + ); + assert!( + !eval::(&s, "return pmacs.window.buffer() == _G.FOREIGN"), + "dired must not display the foreign buffer" + ); + assert_eq!( + active_name(&s), + format!("{name}<2>"), + "dired opens under a disambiguated name instead" + ); + assert!( + active_lines(&s)[0].ends_with(':'), + "and it is a real listing: {:?}", + active_lines(&s)[0] + ); +} + +// --------------------------------------------------------------------------- +// 5 --- read-only discipline +// --------------------------------------------------------------------------- + +/// An ordinary self-insert is rejected by the intercept and leaves the +/// text byte-identical, while dired's own repaint succeeds through +/// `bypass_intercept`. `set_round_trip_input` is pinned through the +/// **production** seam a semantic frontend reads (`dispatch_idle_for`, +/// published as `DispatchIdle`) rather than by a direct-call assertion: +/// without it, a GPU session would optimistically apply `g` as an +/// insert instead of letting it reach the revert binding. +#[test] +fn dired_buffer_is_read_only_and_round_trips_input() { + let td = fixture_dir(); + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + let before = active_text(&s); + + // A document window, deliberately: the panel arm of the same gate + // (`!window.is_side()`) would otherwise be what makes this pass. + assert!( + !s.core + .borrow() + .windows + .get(&active_window(&s)) + .expect("live window") + .is_side(), + "fixture premise: dired is in a document window here" + ); + assert!( + !s.dispatch_idle_for(FrontendId::LOCAL), + "a round-trip buffer must turn optimistic apply OFF" + ); + + // `z` is bound nowhere in dired mode, so it reaches self-insert. + type_char(&mut s, 'z'); + assert_eq!( + active_text(&s), + before, + "the read-only intercept must reject a self-insert" + ); + assert!( + status(&s).contains("read-only"), + "and say so; got {:?}", + status(&s) + ); + + // Dired's own writes still land: revert repaints the whole buffer. + std::fs::write(td.path().join("c.txt"), b"new\n").expect("write c"); + type_char(&mut s, 'g'); + pump(&mut s); + assert!( + active_text(&s).contains("c.txt"), + "dired's own repaint bypasses the intercept: {:?}", + active_text(&s) + ); +} + +// --------------------------------------------------------------------------- +// 6 --- mode keymap +// --------------------------------------------------------------------------- + +/// The keys resolve through `scope = "mode"` with no per-buffer +/// binding: a *second* dired buffer, created by a descent that calls no +/// `keymap.bind` of its own, still responds to `n`, `g`, and `^`. The +/// mode also shows in the statusline, through a real painted frame. +#[test] +fn dired_keys_resolve_through_the_mode_keymap() { + let td = fixture_dir(); + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + seat_on(&s, "subdir"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + + assert_eq!( + eval::>(&s, "return pmacs.buffer.major_mode(pmacs.window.buffer())"), + Some("dired".to_owned()), + "the descended buffer carries the mode" + ); + assert_eq!( + eval::( + &s, + "local n = 0\n\ + for _, entry in ipairs(pmacs.keymap.list()) do\n\ + if entry.scope:find('buffer') then n = n + 1 end\n\ + end\n\ + return n" + ), + 0, + "and no buffer-scoped binding exists anywhere" + ); + + // `n` moves by line through the mode binding. + exec(&s, "pmacs.editor.move_to_line(0)"); + type_char(&mut s, 'n'); + assert_eq!(cursor_line(&s), 1, "`n` moves down one line"); + + // `g` reverts: a file added externally appears. + std::fs::write(td.path().join("subdir").join("second.txt"), b"x\n").expect("write second"); + type_char(&mut s, 'g'); + pump(&mut s); + assert!( + active_text(&s).contains("second.txt"), + "`g` re-read the directory: {:?}", + active_text(&s) + ); + + // `^` ascends. + type_char(&mut s, '^'); + pump(&mut s); + assert_eq!( + active_name(&s), + format!("*dired:{}*", canon(td.path())), + "`^` ascends from the second buffer too" + ); + + let rows = painted_rows(&s); + let mode_line = rows + .iter() + .rev() + .find(|row| row.contains("dired")) + .unwrap_or_else(|| panic!("no painted row mentions the mode: {rows:#?}")); + assert!( + mode_line.contains("dired"), + "the major mode shows in the statusline: {mode_line:?}" + ); +} + +// --------------------------------------------------------------------------- +// 7 --- cursor preservation +// --------------------------------------------------------------------------- + +/// The cursor is re-seated by BASENAME across a repaint (Q#DR9), and +/// falls back to the nearest surviving line when the entry is gone. +/// Every repaint is wholesale, so a dired that dropped to line 0 after +/// each revert would be unusable. +#[test] +fn dired_revert_reseats_the_cursor_by_basename() { + let td = tempfile::tempdir().expect("tempdir"); + for name in ["c.txt", "d.txt", "e.txt"] { + std::fs::write(td.path().join(name), b"x").expect("write"); + } + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + seat_on(&s, "d.txt"); + let line_before = cursor_line(&s); + + // Two files that sort BEFORE it, so its line index has to change. + std::fs::write(td.path().join("a.txt"), b"x").expect("write a"); + std::fs::write(td.path().join("b.txt"), b"x").expect("write b"); + type_char(&mut s, 'g'); + pump(&mut s); + + assert_ne!( + cursor_line(&s), + line_before, + "fixture premise: the line index moved" + ); + assert_eq!( + cursor_entry(&s).as_deref(), + Some("d.txt"), + "the cursor follows the basename, not the line" + ); + + // Now the entry disappears: land on the nearest surviving line. + let vanished_line = cursor_line(&s); + std::fs::remove_file(td.path().join("d.txt")).expect("rm d"); + type_char(&mut s, 'g'); + pump(&mut s); + assert!( + cursor_line(&s) > 0, + "a vanished entry must not drop the cursor to the header" + ); + assert_eq!( + cursor_line(&s), + vanished_line.min(active_lines(&s).len() - 1), + "it lands on the nearest surviving line" + ); +} + +/// A revert settles a tick or more later, and the user may have left in +/// the meantime. `pmacs.editor.move_to_line` is **ambient** — it moves +/// whatever window is active — so an unguarded re-seat moves an +/// unrelated buffer's cursor to a line index that only means something +/// in the dired listing. The paint is safe either way because it names +/// its buffer; this pins the half that does not. +#[test] +fn dired_revert_does_not_seat_a_buffer_the_user_switched_to() { + let td = tempfile::tempdir().expect("tempdir"); + for name in ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"] { + std::fs::write(td.path().join(name), b"x").expect("write"); + } + let notes = td.path().join("notes.txt"); + std::fs::write(¬es, b"one\ntwo\nthree\nfour\nfive\nsix\n").expect("write notes"); + + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + exec(&s, "_G.DIRED_BUF = pmacs.window.buffer()"); + // A late line, so a stale seat would be visible in the other buffer. + seat_on(&s, "e.txt"); + let dired_line = cursor_line(&s); + assert!( + dired_line >= 4, + "fixture premise: a late line, got {dired_line}" + ); + + // Start the revert, then leave BEFORE the read settles. + type_char(&mut s, 'g'); + exec( + &s, + &format!( + "pmacs.buffer.find_or_open({:?})", + notes.display().to_string() + ), + ); + assert_eq!(cursor_line(&s), 0, "a freshly opened file starts at line 0"); + pump(&mut s); + + assert_eq!( + active_path(&s).map(PathBuf::from), + Some(PathBuf::from(canon(¬es))), + "the switch stands: the revert must not pull the user back" + ); + assert_eq!( + cursor_line(&s), + 0, + "and it must not move the cursor of the buffer they moved to" + ); + + // The revert itself still happened: the dired buffer is repainted, + // and returning to it seats normally on the next command. + std::fs::write(td.path().join("f.txt"), b"x").expect("write f"); + exec(&s, "pmacs.window.switch_buffer(_G.DIRED_BUF)"); + type_char(&mut s, 'g'); + pump(&mut s); + assert!( + active_text(&s).contains("f.txt"), + "the dired buffer still reverts when it is the active one: {:?}", + active_text(&s) + ); +} + +// --------------------------------------------------------------------------- +// 8 --- sort modes +// --------------------------------------------------------------------------- + +/// `s` cycles name -> mtime -> size -> name; mtime sorts newest first +/// and size largest first, each with a stable name tiebreak; the cursor +/// stays on its basename across the reorder. +#[test] +fn dired_sort_cycles_name_then_mtime_then_size() { + let td = tempfile::tempdir().expect("tempdir"); + // Explicit sizes and mtimes, so neither order depends on the + // filesystem's timestamp resolution or on write ordering. + let plan = [ + ("a.txt", 3usize, 1_000u64), + ("b.txt", 1, 3_000), + ("c.txt", 2, 2_000), + ]; + for (name, size, mtime) in plan { + let path = td.path().join(name); + std::fs::write(&path, vec![b'x'; size]).expect("write"); + let file = std::fs::File::options() + .write(true) + .open(&path) + .expect("open for set_modified"); + file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime)) + .expect("set mtime"); + } + + let mut s = editor(); + open_ok(&mut s, td.path(), "nil"); + let names = |s: &EditorState| -> Vec { + active_lines(s) + .iter() + .skip(1) + .map(|line| line_name(s, line)) + .collect() + }; + assert_eq!( + names(&s), + vec!["a.txt", "b.txt", "c.txt"], + "the initial order is by name" + ); + + seat_on(&s, "c.txt"); + type_char(&mut s, 's'); + assert_eq!( + names(&s), + vec!["b.txt", "c.txt", "a.txt"], + "mtime sorts newest first" + ); + assert!( + status(&s).contains("mtime"), + "and reports the new mode: {:?}", + status(&s) + ); + assert_eq!( + cursor_entry(&s).as_deref(), + Some("c.txt"), + "the cursor stays on its basename across the reorder" + ); + + type_char(&mut s, 's'); + assert_eq!( + names(&s), + vec!["a.txt", "c.txt", "b.txt"], + "size sorts largest first" + ); + type_char(&mut s, 's'); + assert_eq!( + names(&s), + vec!["a.txt", "b.txt", "c.txt"], + "and cycles back" + ); +} + +// --------------------------------------------------------------------------- +// 9 --- tolerant listing +// --------------------------------------------------------------------------- + +/// A child whose `lstat` fails no longer fails the whole listing +/// (Q#DR6): the readable entries render, the footer counts what could +/// not be read, and the default (non-opt) call still returns a bare +/// array — both forms are exercised here, so the frozen fixture's +/// contract cannot regress unnoticed. +#[test] +fn dired_tolerant_listing_renders_what_it_can_and_counts_the_rest() { + use std::os::unix::fs::PermissionsExt; + let td = tempfile::tempdir().expect("tempdir"); + let dir = td.path().join("no-search"); + std::fs::create_dir(&dir).expect("mkdir"); + std::fs::write(dir.join("readable.txt"), b"x").expect("write readable"); + std::fs::write(dir.join("blocked.txt"), b"x").expect("write blocked"); + // Readable but not searchable: `readdir` yields the names, every + // child `lstat` fails. + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).expect("chmod 400"); + if std::fs::symlink_metadata(dir.join("readable.txt")).is_ok() { + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).expect("restore"); + eprintln!("lstat still succeeds without search permission (root?); skipping"); + return; + } + + let mut s = editor(); + let raised = open_dired(&mut s, &dir.display().to_string(), "nil"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).expect("restore"); + assert!( + raised.is_none(), + "a per-entry failure must not fail the listing: {raised:?}" + ); + + let lines = active_lines(&s); + assert_eq!( + lines.last().map(String::as_str), + Some("2 entries unreadable"), + "the footer names how much of the view is missing: {lines:#?}" + ); + + // Both call shapes, in one test: the bare array is what the frozen + // M8.2 fixture consumes with `ipairs`. + let shapes: Vec = eval( + &s, + &format!( + "local out = {{}}\n\ + pmacs.async(function()\n\ + local bare = pmacs.fs.read_dir({:?}):await()\n\ + local tolerant = pmacs.fs.read_dir({:?}, {{ tolerant = true }}):await()\n\ + _G.SHAPES = {{\n\ + #bare,\n\ + bare.entries == nil and 1 or 0,\n\ + #tolerant.entries,\n\ + tolerant.errors ~= nil and 1 or 0,\n\ + #tolerant.errors,\n\ + }}\n\ + end)\n\ + return out", + td.path().display().to_string(), + td.path().display().to_string() + ), + ); + assert!(shapes.is_empty(), "the async body has not run yet"); + pump(&mut s); + let shapes: Vec = eval(&s, "return _G.SHAPES"); + assert_eq!( + shapes, + vec![1, 1, 1, 1, 0], + "bare: one entry and no `entries` field; tolerant: one entry plus \ + an empty error channel" + ); + + // A failure on the parent itself is still fatal. + let missing = td.path().join("does-not-exist"); + let raised = open_dired(&mut s, &missing.display().to_string(), "nil"); + assert!( + raised.is_some(), + "an unopenable directory has no partial answer" + ); +} + +// --------------------------------------------------------------------------- +// 10 --- tolerant symlink targets +// --------------------------------------------------------------------------- + +/// A symlink whose target is not UTF-8 lists successfully with the +/// entry present and its target reported unknown (F5). Falsified by +/// reverting the `read_link`/target arm in `read_dir_blocking`, which +/// takes the whole listing down. +#[cfg(not(target_os = "macos"))] +#[test] +fn dired_lists_a_symlink_whose_target_is_not_utf8() { + use std::os::unix::ffi::OsStrExt; + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("real.txt"), b"x").expect("write real"); + std::os::unix::fs::symlink( + std::ffi::OsStr::from_bytes(b"target-\xff"), + td.path().join("weird"), + ) + .expect("symlink"); + + let mut s = editor(); + let raised = open_dired(&mut s, &td.path().display().to_string(), "nil"); + assert!( + raised.is_none(), + "one weird symlink must not take the directory down: {raised:?}" + ); + + let lines = active_lines(&s); + let weird = &lines[line_of(&s, "weird")]; + assert_eq!( + line_name(&s, weird), + "weird -> ?", + "the entry is listed with an unknown target" + ); + assert!( + lines.iter().any(|line| line_name(&s, line) == "real.txt"), + "and the readable sibling is still there: {lines:#?}" + ); + assert_eq!( + lines.last().map(String::as_str), + Some("1 entries unreadable"), + "the footer counts it: {lines:#?}" + ); +} + +// --------------------------------------------------------------------------- +// 11 --- unknown opts keys +// --------------------------------------------------------------------------- + +/// A typo'd opt errors naming the key instead of silently listing in +/// fatal mode (framing §8, minor c). Silently ignoring it is exactly +/// how a tolerant listing would degrade with no signal at all. +#[test] +fn read_dir_rejects_an_unknown_opts_key() { + let td = fixture_dir(); + let s = editor(); + let message: String = eval( + &s, + &format!( + "local ok, err = pcall(pmacs.fs.read_dir, {:?}, {{ tolerat = true }})\n\ + if ok then return 'NO ERROR' end\n\ + return tostring(err)", + td.path().display().to_string() + ), + ); + assert!( + message.contains("tolerat") && message.contains("unknown opts key"), + "the error must name the offending key; got {message:?}" + ); + + // A wrongly-typed known key is rejected too. + let message: String = eval( + &s, + &format!( + "local ok, err = pcall(pmacs.fs.read_dir, {:?}, {{ tolerant = 'yes' }})\n\ + if ok then return 'NO ERROR' end\n\ + return tostring(err)", + td.path().display().to_string() + ), + ); + assert!( + message.contains("tolerant must be a boolean"), + "got {message:?}" + ); +} + +// --------------------------------------------------------------------------- +// 12 --- non-UTF-8 names stay fatal +// --------------------------------------------------------------------------- + +/// A non-UTF-8 *name* is a path-representation problem, not a listing +/// one: dired reports the structured error and creates no buffer. +/// Rendering it tolerantly would hand dired a name it could not pass +/// back through `rename`. +#[cfg(not(target_os = "macos"))] +#[test] +fn dired_reports_a_non_utf8_name_and_creates_no_buffer() { + use std::os::unix::ffi::OsStrExt; + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write( + td.path() + .join(std::ffi::OsStr::from_bytes(b"bad-\xff-name")), + b"", + ) + .expect("write entry"); + + let mut s = editor(); + let before = active_name(&s); + // Through the real command, so the reporting path is the one a user + // hits rather than `pmacs.dired.open`'s raise. + exec( + &s, + &format!( + "pmacs.async(function()\n\ + local ok, err = pcall(pmacs.dired.open, {:?})\n\ + if not ok then\n\ + pmacs.editor.set_status('dired: ' .. tostring(err.message))\n\ + end\n\ + end)", + td.path().display().to_string() + ), + ); + pump(&mut s); + + let line = status(&s); + assert!( + line.contains("non-UTF-8") && line.contains("255"), + "the structured error must surface with the offending raw bytes; \ + got {line:?}" + ); + assert!( + dired_buffer_names(&s).is_empty(), + "and no dired buffer was created: {:?}", + dired_buffer_names(&s) + ); + assert_eq!(active_name(&s), before, "the active buffer is untouched"); +} + +// --------------------------------------------------------------------------- +// 13 --- dired-jump +// --------------------------------------------------------------------------- + +/// `C-x C-j` opens dired on this file's directory with the cursor on +/// that file's line; from a buffer with no path it reports and creates +/// nothing. +#[test] +fn dired_jump_seats_the_cursor_on_the_visited_file() { + let td = fixture_dir(); + let (mut s, anchor) = editor_in(td.path()); + + ctrl(&mut s, 'x'); + ctrl(&mut s, 'j'); + pump(&mut s); + + assert_eq!( + active_name(&s), + format!("*dired:{}*", canon(td.path())), + "dired opens on the file's directory" + ); + assert_eq!( + cursor_entry(&s).as_deref(), + Some("anchor.txt"), + "with the cursor on the file we jumped from" + ); + assert!(anchor.exists()); + + // From a pathless buffer: report, create nothing. + exec( + &s, + "pmacs.window.switch_buffer(pmacs.buffer.create('*pathless*'))", + ); + let before = dired_buffer_names(&s); + ctrl(&mut s, 'x'); + ctrl(&mut s, 'j'); + pump(&mut s); + assert!( + status(&s).contains("no file"), + "the reason must surface; got {:?}", + status(&s) + ); + assert_eq!( + dired_buffer_names(&s), + before, + "and nothing new was created" + ); + assert_eq!(active_name(&s), "*pathless*", "nor was anything displayed"); +} + +// --------------------------------------------------------------------------- +// 14 --- quit +// --------------------------------------------------------------------------- + +/// `q` restores the previously active buffer; in a side window it +/// routes through `pmacs.window.quit`, matching `listview.quit`'s +/// Q#BP11b split. +#[test] +fn dired_quit_restores_the_previous_buffer_and_closes_a_panel() { + let td = fixture_dir(); + let (mut s, anchor) = editor_in(td.path()); + open_ok(&mut s, td.path(), "nil"); + type_char(&mut s, 'q'); + assert_eq!( + active_name(&s), + canon(&anchor), + "`q` returns to the buffer dired was opened from" + ); + + // The panel arm: `q` deletes the side window rather than switching + // the buffer inside it. + open_ok(&mut s, td.path(), r#"{ display = "panel" }"#); + assert!(side_window(&s).is_some(), "fixture premise: a side window"); + type_char(&mut s, 'q'); + assert_eq!( + side_window(&s), + None, + "`q` in a side window routes through window.quit" + ); + assert_eq!( + active_name(&s), + canon(&anchor), + "and focus lands back in the document window" + ); +} + +// --------------------------------------------------------------------------- +// 15 --- failure leaves nothing behind +// --------------------------------------------------------------------------- + +/// `C-x d` on a nonexistent directory creates no buffer, switches no +/// window, and reports the reason (the fixture's +/// `dired_open_failure_leaves_editor_unchanged` invariant), driven +/// through the real prompt. +#[test] +fn dired_open_failure_leaves_the_editor_unchanged() { + let td = fixture_dir(); + let (mut s, anchor) = editor_in(td.path()); + let before_window = active_window(&s); + let before_names = buffer_names(&s); + + ctrl(&mut s, 'x'); + type_char(&mut s, 'd'); + // The field prefills with the anchor's directory; append a path + // component that does not exist. + type_str(&mut s, "/nope"); + press(&mut s, KeyCode::Enter); + pump(&mut s); + + let line = status(&s); + assert!( + line.starts_with("dired: "), + "the failure surfaces as dired's own status message; got {line:?}" + ); + assert_eq!( + buffer_names(&s), + before_names, + "no buffer was created: {:?}", + buffer_names(&s) + ); + assert_eq!(active_window(&s), before_window, "no window changed"); + assert_eq!( + active_name(&s), + canon(&anchor), + "the active buffer is intact" + ); +} + +// --------------------------------------------------------------------------- +// 16 --- scale +// --------------------------------------------------------------------------- + +/// A 10,000-entry directory renders within the fixture's established +/// 200 ms budget, on the builtin path. Carries the fixture's macOS +/// ignore gate: hosted macOS debug runners do not consistently satisfy +/// it. +#[test] +#[cfg_attr( + target_os = "macos", + ignore = "hosted macOS debug runners do not consistently satisfy this timing gate" +)] +fn dired_renders_10k_entries_within_200ms() { + let td = tempfile::tempdir().expect("tempdir"); + for i in 0..10_000 { + std::fs::write(td.path().join(format!("f{i:05}")), b"").expect("write fixture entry"); + } + + let mut s = editor(); + let started = Instant::now(); + open_ok(&mut s, td.path(), "nil"); + let elapsed = started.elapsed(); + + assert_eq!( + active_lines(&s).len(), + 10_001, + "header plus one line per entry" + ); + assert!( + elapsed < Duration::from_millis(200), + "10K entries must render within 200ms; took {elapsed:?}" + ); +} diff --git a/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs new file mode 100644 index 0000000..fb793e3 --- /dev/null +++ b/tests/find_file_acceptance.rs @@ -0,0 +1,373 @@ +// tests/find_file_acceptance.rs --- dired arc Stage 0 (`C-x C-f`) acceptance. + +//! Acceptance for `find-file`, the dired arc's Stage 0 +//! (`docs/dired-framing.md` §14, items 0a-0d, Q#DR11). +//! +//! Dispatch-driven throughout: the prompt is opened with a real +//! `C-x C-f`, filled by typing real keys, and completed with a real +//! RET. `pmacs.command.invoke` would bypass the binding (a dead +//! keymap entry would pass vacuously) and the Lua lifecycle +//! `minibuffer.accept()` bypasses the dispatch path interactive input +//! actually takes --- the editops suite's discipline, for the same +//! reasons. +//! +//! Fixtures use `.txt` files so no `buffer.after-load` hook spawns a +//! language server. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn ctrl(s: &mut EditorState, c: char) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(c), KeyModifiers::CONTROL), + ); +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Open the find-file prompt through the real `C-x C-f` binding. +fn open_prompt(s: &mut EditorState) { + ctrl(s, 'x'); + ctrl(s, 'f'); + assert!( + eval::(s, "return pmacs.minibuffer.is_active()"), + "C-x C-f must open a minibuffer prompt" + ); +} + +/// The active buffer's backing path, or `None`. +fn active_path(s: &EditorState) -> Option { + eval::>( + s, + "local b = pmacs.window.buffer()\n\ + if b == nil then return nil end\n\ + local ok, p = pcall(function() return b:path() end)\n\ + if ok then return p end\n\ + return nil", + ) +} + +fn candidates(s: &EditorState) -> Vec { + eval::>(s, "return pmacs.minibuffer.candidates()") +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +/// An editor whose active buffer is a real file inside `dir`, so +/// find-file's root resolves to that directory. +fn editor_in(dir: &std::path::Path) -> EditorState { + let anchor = dir.join("anchor.txt"); + std::fs::write(&anchor, b"anchor\n").expect("write anchor"); + let state = EditorState::new(); + state.lua_host.reopen_init_phase_for_testing(); + let anchor_str = anchor.display().to_string(); + exec( + &state, + &format!("pmacs.buffer.find_or_open({anchor_str:?})"), + ); + state +} + +/// 0a --- completion is flat: it offers the root's own entries and +/// never descends into a subdirectory. +#[test] +fn find_file_completion_lists_the_root_only_and_does_not_descend() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("alpha.txt"), b"a").expect("write"); + std::fs::create_dir(td.path().join("sub")).expect("mkdir"); + std::fs::write(td.path().join("sub").join("inner.txt"), b"i").expect("write"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + + let cands = candidates(&s); + assert!( + cands.iter().any(|c| c == "alpha.txt"), + "root entry must be offered; got {cands:?}" + ); + assert!( + cands.iter().any(|c| c == "sub"), + "the subdirectory itself must be offered; got {cands:?}" + ); + assert!( + !cands.iter().any(|c| c == "inner.txt"), + "completion must NOT descend into subdirectories; got {cands:?}" + ); +} + +/// 0b --- free text carries the deeper case. `sub/inner.txt` matches no +/// bare-basename candidate, so it reaches `on_accept` verbatim and is +/// joined onto the prompt's root. +#[test] +fn find_file_free_text_opens_a_path_below_the_root() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(td.path().join("sub")).expect("mkdir"); + let inner = td.path().join("sub").join("inner.txt"); + std::fs::write(&inner, b"deep contents\n").expect("write"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + type_str(&mut s, "sub/inner.txt"); + + assert!( + candidates(&s).is_empty(), + "a needle containing '/' must filter every basename candidate away, \ + or the selection would shadow the typed text" + ); + + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a file must be open"); + assert_eq!( + std::fs::canonicalize(&path).expect("canonicalize opened"), + std::fs::canonicalize(&inner).expect("canonicalize fixture"), + "free text must open the deeper path" + ); + let text: String = eval(&s, "return pmacs.window.buffer():slice(0, 13)"); + assert_eq!(text, "deep contents", "the file's real contents must load"); +} + +/// 0c --- a path that does not exist creates a `[new file]` buffer +/// bound to it, rather than erroring. The name contains a `/` so the +/// candidate list is empty and the typed text is what arrives (see +/// `find_file_selected_candidate_shadows_typed_text` for the other +/// half of that rule). +#[test] +fn find_file_nonexistent_path_creates_a_new_file_buffer() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(td.path().join("sub")).expect("mkdir"); + let fresh = td.path().join("sub").join("brand-new.txt"); + assert!(!fresh.exists(), "fixture must not exist yet"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + type_str(&mut s, "sub/brand-new.txt"); + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a buffer must be bound to the new path"); + assert!( + path.ends_with("sub/brand-new.txt"), + "the buffer must be bound to the typed path; got {path}" + ); + let len: usize = eval(&s, "return pmacs.window.buffer():len()"); + assert_eq!(len, 0, "a new-file buffer starts empty"); + assert!( + !fresh.exists(), + "find-file must not create the file on disk --- only the buffer" + ); + let line = status(&s); + assert!( + line.contains("[new file]"), + "the new-file status must surface; got {line:?}" + ); +} + +/// The everyday new-file flow: a BARE name, no separator, matching no +/// existing entry. The candidate list empties on its own, so the typed +/// text arrives and joins onto the root. This is the path users hit +/// first, and it is the only route through `find_file_resolve` that +/// combines free text with a relative join. +#[test] +fn find_file_bare_new_name_creates_in_the_root() { + let td = tempfile::tempdir().expect("tempdir"); + let fresh = td.path().join("zzz.txt"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + // "zzz.txt" is not a subsequence of "anchor.txt" (no 'z' in it), so + // nothing survives the filter and the typed name is what accepts. + type_str(&mut s, "zzz.txt"); + assert!( + candidates(&s).is_empty(), + "fixture premise: a bare non-matching name must empty the list; got {:?}", + candidates(&s) + ); + + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a buffer must be bound to the new path"); + assert_eq!( + std::path::Path::new(&path).parent(), + Some(td.path()), + "a bare name must join onto the prompt's root; got {path}" + ); + assert!( + path.ends_with("zzz.txt"), + "the buffer must carry the typed name; got {path}" + ); + let len: usize = eval(&s, "return pmacs.window.buffer():len()"); + assert_eq!(len, 0, "a new-file buffer starts empty"); + assert!(!fresh.exists(), "nothing is written to disk until save"); +} + +/// The failure arm. Accepting a DIRECTORY candidate reaches +/// `display_file`, whose load fails (opening a directory succeeds, the +/// read does not), and the command's `pcall` must turn that into a +/// status message rather than letting the error escape mid-dispatch. +/// Without the `pcall` this test fails, which is the point --- the +/// guard is pinned through the real accept path, not asserted directly. +#[test] +fn find_file_accepting_a_directory_reports_instead_of_raising() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(td.path().join("sub")).expect("mkdir"); + + let mut s = editor_in(td.path()); + let before = active_path(&s).expect("the anchor must be open"); + + open_prompt(&mut s); + // Only the directory matches: "anchor.txt" contains no 's'. + type_str(&mut s, "sub"); + assert_eq!( + candidates(&s), + vec!["sub".to_string()], + "fixture premise: the directory must be the sole candidate" + ); + + press(&mut s, KeyCode::Enter); + + let line = status(&s); + assert!( + line.starts_with("find-file: "), + "the failure must surface as this command's status message; got {line:?}" + ); + assert_eq!( + active_path(&s).as_deref(), + Some(before.as_str()), + "a failed open must leave the active buffer alone" + ); + assert!( + !eval::(&s, "return pmacs.minibuffer.is_active()"), + "the prompt must have closed even though the open failed" + ); +} + +/// 0d --- with no backing path, the prompt roots at the process cwd +/// (`source_root` is omitted, and the Rust side defaults to "."). +/// The test crate's cwd is the crate root, so `Cargo.toml` is a +/// stable, real candidate there. +#[test] +fn find_file_without_a_backing_path_roots_at_the_process_cwd() { + let mut s = EditorState::new(); + s.lua_host.reopen_init_phase_for_testing(); + assert!( + active_path(&s).is_none(), + "the scratch buffer must have no backing path" + ); + + open_prompt(&mut s); + + let cands = candidates(&s); + assert!( + cands.iter().any(|c| c == "Cargo.toml"), + "a pathless buffer must root the prompt at the process cwd; got {cands:?}" + ); + // The field must start EMPTY. Any prefill (e.g. Emacs's + // directory-in-the-field) would contain a `/`, which filters every + // basename candidate away and silently disables completion --- the + // reason the root is named in the prompt string instead. + let typed: String = eval(&s, "return pmacs.minibuffer.contents()"); + assert_eq!( + typed, "", + "the prompt field must start empty or completion is dead on arrival" + ); +} + +/// The documented hole in Q#DR11, pinned so it is a decision rather +/// than an accident: `recompute_candidates` selects index 0 whenever +/// the list is non-empty and `resolve_accepted_value` returns the +/// SELECTED CANDIDATE over the typed text, so typing a new bare name +/// that is a subsequence of an existing entry opens the existing file. +/// Fixing this needs a Rust change to accept semantics, which Stage 0 +/// deliberately does not make. +#[test] +fn find_file_selected_candidate_shadows_typed_text() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("notes.md"), b"existing\n").expect("write"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + // "nots" is a subsequence of "notes.md", so the candidate survives + // the filter and shadows the typed name. + type_str(&mut s, "nots"); + assert_eq!( + candidates(&s), + vec!["notes.md".to_string()], + "the fixture depends on 'nots' matching 'notes.md'" + ); + + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a file must be open"); + assert!( + path.ends_with("notes.md"), + "documented behavior: the selected candidate wins over typed text; got {path}" + ); +} + +/// A leading `~` is expanded before the path reaches the core. This +/// matters because `get_or_load_buffer` normalizes the path it STORES +/// but loads from the RAW one, so an unexpanded `~/...` would dedup +/// against an open buffer yet fail to load a file that is not open. +#[test] +fn find_file_expands_a_leading_tilde() { + let Some(home) = std::env::var_os("HOME") else { + eprintln!("HOME unset; skipping tilde expansion pin"); + return; + }; + let home = home.to_string_lossy().into_owned(); + if home.is_empty() || !std::path::Path::new(&home).is_dir() { + eprintln!("HOME is not a usable directory; skipping"); + return; + } + + let mut s = EditorState::new(); + s.lua_host.reopen_init_phase_for_testing(); + open_prompt(&mut s); + // Contains a '/', so the typed text reaches on_accept verbatim. + // The leaf does not exist, so this lands on the new-file path and + // touches no disk state. + type_str(&mut s, "~/pmacs-find-file-tilde-probe.txt"); + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a buffer must be bound"); + assert!( + !path.contains('~'), + "the tilde must be expanded, not passed through; got {path}" + ); + assert!( + path.starts_with(&home), + "the expansion must use $HOME; got {path} with HOME={home}" + ); +} diff --git a/tests/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs new file mode 100644 index 0000000..d48a86c --- /dev/null +++ b/tests/lean4_stage1_acceptance.rs @@ -0,0 +1,340 @@ +//! Lean 4 mode, Stage 1 acceptance (Arc 8, `docs/lean4-mode-framing.md`). +//! +//! Covers the framing's Stage 1 criteria that live above the Rust +//! substrate — major mode, modeline aliasing, comment toggle, the pair +//! set, and markdown fence injection. Criteria 1, 2, and the Q#LN4 +//! retro-paint pins (7, 8) are unit tests in `src/syntax.rs` and +//! `src/highlight.rs`, where the theme table and grammar registry live. +//! +//! Dispatch-driven, following `comment_toggle_acceptance`: `M-;` and +//! typed characters go through `dispatch_key` so the real command +//! boundary and typed-edit provenance are exercised. Buffers are +//! file-backed (language detection needs a path); each editor gets a +//! private tempdir `StateDir` and an emptied `pmacs.lsp.config` so +//! nothing spawns a language server — Stage 1 has no LSP at all. +//! +//! Criterion 12 is the reason this suite touches no process: it must +//! pass on a machine with no `lean`, no `lake`, and no configured elan +//! toolchain. That is not hypothetical — the machine this arc was +//! scouted on has elan installed with no default toolchain, where +//! `lake --version` itself fails. + +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}; + +fn fresh_state_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-lean4-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn editor(state_dir: &std::path::Path) -> EditorState { + let s = EditorState::new(); + s.lua_host.lua().remove_app_data::(); + s.lua_host + .lua() + .set_app_data(StateDir(state_dir.to_path_buf())); + exec(&s, "pmacs.lsp.config = {}"); + s +} + +fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p.display().to_string() +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn alt(s: &mut EditorState, c: char) { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +fn buffer_text(s: &EditorState) -> String { + let b: mlua::String = eval( + s, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + String::from_utf8_lossy(&b.as_bytes()).into_owned() +} + +fn cursor(s: &EditorState) -> i64 { + eval(s, "return pmacs.editor.cursor()") +} + +/// Fresh editor visiting `name` (created in the state tempdir) with +/// `body` on disk, cursor at 0. +fn editor_visiting(name: &str, body: &str) -> EditorState { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, name, body); + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + s +} + +fn major_mode(s: &EditorState) -> Option { + eval(s, "return pmacs.buffer.major_mode(pmacs.window.buffer())") +} + +// --------------------------------------------------------------------------- +// Criterion 3 — major mode +// --------------------------------------------------------------------------- + +#[test] +fn acc3_opening_a_lean_file_sets_the_lean4_major_mode() { + let s = editor_visiting("Basic.lean", "def x : Nat := 1\n"); + assert_eq!( + major_mode(&s).as_deref(), + Some("lean4"), + "a .lean file carries the lean4 major mode" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 4 — modeline aliasing (Q#LN2) +// --------------------------------------------------------------------------- + +#[test] +fn acc4_emacs_and_vim_modelines_spelling_lean_resolve_to_lean4() { + // The grammar entry is `lean4`, but `-*- mode: lean -*-` and `ft=lean` + // are what people write. Both must land on the same mode, or a file + // with an explicit modeline is stranded with no grammar. + // + // Deliberately on a `.txt` path: if the fixture were `.lean`, the + // extension alone would produce `lean4` and the assertion would pass + // with the alias table empty — the vacuous shape. + for body in [ + "-- -*- mode: lean -*-\ndef x : Nat := 1\n", + "-- vim: ft=lean\ndef x : Nat := 1\n", + ] { + let s = editor_visiting("modeline.txt", body); + assert_eq!( + major_mode(&s).as_deref(), + Some("lean4"), + "modeline {body:?} resolves through the alias to lean4" + ); + } +} + +#[test] +fn acc4b_the_lean_alias_is_load_bearing() { + // Non-vacuity guard for acc4: with the alias removed, the same + // fixture resolves to the raw `lean` name instead. If this ever + // reports `lean4`, acc4 is proving nothing. + let s = editor_visiting("modeline.txt", "x\n"); + exec(&s, "pmacs.parse.modeline_aliases.lean = nil"); + let dir = fresh_state_dir(); + let f = write_file(&dir, "other.txt", "-- -*- mode: lean -*-\ndef x := 1\n"); + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + assert_eq!( + major_mode(&s).as_deref(), + Some("lean"), + "without the alias the modeline name is not normalized" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 9 — comment toggle (Q#LN5) +// --------------------------------------------------------------------------- + +#[test] +fn acc9_comment_toggle_round_trips_with_the_dash_dash_prefix() { + let mut s = editor_visiting("Basic.lean", "def x : Nat := 1\ndef y : Nat := 2\n"); + exec(&s, "pmacs.editor.goto_byte(0)"); + alt(&mut s, ';'); + assert_eq!( + buffer_text(&s), + "-- def x : Nat := 1\ndef y : Nat := 2\n", + "M-; comments a Lean line with `-- `" + ); + // Round trip, including the padding space. + exec(&s, "pmacs.editor.goto_byte(0)"); + alt(&mut s, ';'); + assert_eq!( + buffer_text(&s), + "def x : Nat := 1\ndef y : Nat := 2\n", + "M-; uncomments it exactly" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 10 — pairs (Q#LN6) +// --------------------------------------------------------------------------- + +#[test] +fn acc10_lean_bracket_pairs_close_and_the_prime_does_not() { + // The three Unicode brackets are the reason this decision exists: all + // are outside the nine built-in pair chars, so they exercise the + // user-extended pair path rather than the frontends' optimistic + // classifier. + for (opener, expected) in [("⟨", "⟨⟩"), ("⦃", "⦃⦄"), ("⟮", "⟮⟯")] { + let mut s = editor_visiting("Basic.lean", ""); + exec(&s, "pmacs.editor.goto_byte(0)"); + type_str(&mut s, opener); + assert_eq!( + buffer_text(&s), + expected, + "typing {opener} inserts the closing half" + ); + assert_eq!( + cursor(&s), + i64::try_from(opener.len()).expect("opener length fits"), + "the point sits between the pair" + ); + } +} + +#[test] +fn acc10b_the_prime_suffix_does_not_pair_in_lean() { + // Lean uses `'` as a primed-identifier suffix (`h'`, `foo'`), so + // pairing it would fight the user on nearly every proof. + let mut s = editor_visiting("Basic.lean", ""); + exec(&s, "pmacs.editor.goto_byte(0)"); + type_str(&mut s, "h'"); + assert_eq!( + buffer_text(&s), + "h'", + "the prime is a suffix in Lean, not an opener" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 11 — markdown fences (Q#LN17) +// --------------------------------------------------------------------------- + +/// Parse `src` as markdown and return the child layer language names. +/// +/// Goes through the real `_parse_now` injection path rather than reading +/// the alias table: `pmacs.parse.injection_aliases` is a documented +/// WRITE-ONLY proxy (the canonical map lives Rust-side), so an +/// alias-table read would prove nothing about what the parser does. +fn markdown_layer_languages(src: &[u8]) -> Vec { + let state = EditorState::new(); + let buf_id = state + .lua_host + .registry() + .borrow_mut() + .create_from_bytes("doc.md".to_owned(), src); + state + .lua_host + .lua() + .globals() + .set("BUF", pmacs::lua_bindings::BufferIdLua(buf_id)) + .expect("bind BUF"); + state + .lua_host + .lua() + .load("pmacs.parse._parse_now(BUF, 'markdown')") + .exec() + .expect("synchronous parse"); + let bundle = state + .syntax_registry + .view(buf_id) + .and_then(|h| h.current()) + .expect("installed bundle"); + bundle + .layers + .iter() + .map(|l| l.language_name.clone()) + .collect() +} + +#[test] +fn acc11_lean_and_lean4_markdown_fences_both_inject_the_lean_grammar() { + // Both spellings must resolve to the same grammar: `lean4` is the entry + // name and `lean` goes through the injection alias. A ```lean fence is + // overwhelmingly Lean 4 in practice, which is why the Lean 3 spelling + // is mapped forward rather than left unresolved (Q#LN17). + for fence in ["lean", "lean4"] { + let src = format!("# Doc\n\n```{fence}\ndef x : Nat := 1\n```\n"); + let langs = markdown_layer_languages(src.as_bytes()); + assert!( + langs.iter().any(|l| l == "lean4"), + "```{fence} injects a lean4 child layer; got {langs:?}" + ); + } +} + +#[test] +fn acc11b_an_unknown_fence_name_still_injects_nothing() { + // Non-vacuity guard for acc11: the alias must be what resolves `lean`, + // not some catch-all that would light up any fence name. + let langs = markdown_layer_languages(b"# Doc\n\n```leen\ndef x := 1\n```\n"); + assert!( + !langs.iter().any(|l| l == "lean4"), + "a misspelled fence must not reach the lean4 grammar; got {langs:?}" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 12 — no toolchain required +// --------------------------------------------------------------------------- + +#[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). + + // 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. + 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" + ); + // 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" + ); + + // 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. + 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"); +} diff --git a/tests/lsp_multi_root_acceptance.rs b/tests/lsp_multi_root_acceptance.rs new file mode 100644 index 0000000..39ac68f --- /dev/null +++ b/tests/lsp_multi_root_acceptance.rs @@ -0,0 +1,704 @@ +//! Arc 8 Stage 2 acceptance — multi-root LSP server affinity. +//! +//! `docs/lean4-mode-framing.md` Q#LN15, acceptance 13–21. +//! +//! This suite deliberately contains **no Lean content**. `ensure_server` +//! (`builtin/runtime/lsp.lua`) is the single server-affinity function for +//! every LSP language in pmacs, so the change is exercised through the +//! four languages that already shipped attach paths — rust, python, go, +//! typescript — driven against `pmacs_fake_lsp` so nothing here needs a +//! real toolchain on PATH. +//! +//! Every fixture calls `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 would silently turn +//! the "markerless" cases into detected ones — the assertions would still +//! pass while testing nothing. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use pmacs::editor::EditorState; + +fn exec(state: &EditorState, source: &str) { + state.lua_host.lua().load(source.to_owned()).exec().unwrap(); +} + +fn eval(state: &EditorState, source: &str) -> T { + state.lua_host.lua().load(source.to_owned()).eval().unwrap() +} + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +/// A fresh editor with the shipped language configs cleared, so the only +/// server any test can spawn is the fake one it configures itself. +fn editor() -> EditorState { + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + state +} + +fn lua_str(path: &Path) -> String { + path.display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} + +/// Mirror of `file_uri_for` in `builtin/runtime/lsp.lua` and +/// `path_to_file_uri` in `src/lsp.rs`. Reimplemented rather than +/// imported so the test states the expected encoding independently of +/// the code under test. +fn file_uri(path: &Path) -> String { + let mut out = String::from("file://"); + for ch in path.display().to_string().chars() { + match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '/' | '-' | '_' | '.' | '~' | ':' => out.push(ch), + _ => { + use std::fmt::Write as _; + let mut buf = [0u8; 4]; + for byte in ch.encode_utf8(&mut buf).as_bytes() { + let _ = write!(out, "%{byte:02X}"); + } + } + } + } + out +} + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, +} + +impl Fixture { + /// Canonicalized so the expected roots below compare equal to what + /// `pmacs.project.detect` returns (it canonicalizes before walking, + /// which matters on macOS where `/var` is a symlink to `/private/var`). + 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)), + ); +} + +fn settle(state: &mut EditorState) { + for _ in 0..8 { + state.tick_processes(); + state.tick_lsp(); + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// One `language_id|root_uri|cwd|state` row per live server, sorted so +/// assertions do not depend on spawn order. Absent fields read as "". +fn rows(state: &EditorState) -> Vec { + let joined: String = eval( + state, + r#" + local out = {} + for _, s in ipairs(pmacs.lsp.list()) do + out[#out + 1] = table.concat({ + s.language_id or "", + s.root_uri or "", + s.cwd or "", + (s.state and s.state.kind) or "", + }, "|") + end + table.sort(out) + return table.concat(out, "\n") + "#, + ); + if joined.is_empty() { + Vec::new() + } else { + joined.lines().map(str::to_owned).collect() + } +} + +fn status(state: &EditorState) -> String { + state.core.borrow().status.clone() +} + +fn count(state: &EditorState) -> usize { + let n: i64 = eval(state, "return #pmacs.lsp.list()"); + usize::try_from(n).expect("server count is non-negative") +} + +// --------------------------------------------------------------------------- +// Acceptance 13 — `lsp.list()` rows carry `root_uri` and `cwd`. +// --------------------------------------------------------------------------- + +#[test] +fn acc13_list_rows_carry_root_uri_and_cwd() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let file = fx.write("proj/src/main.rs", "fn main() {}\n"); + let mut state = editor(); + fx.bind(&state); + configure(&state, "rust"); + open(&state, &file); + settle(&mut state); + + let proj = fx.dir("proj"); + let rows = rows(&state); + assert_eq!(rows.len(), 1, "{rows:?}"); + let fields: Vec<&str> = rows[0].split('|').collect(); + assert_eq!(fields[0], "rust"); + assert_eq!( + fields[1], + file_uri(&proj), + "root_uri must be the project root" + ); + assert_eq!( + fields[2], + proj.display().to_string(), + "cwd must be the root" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 14 — two roots, same language, two servers. +// --------------------------------------------------------------------------- + +#[test] +fn acc14_two_project_roots_of_one_language_spawn_two_servers() { + let fx = Fixture::new(); + fx.write("a/Cargo.toml", "[package]\nname = \"a\"\n"); + fx.write("b/Cargo.toml", "[package]\nname = \"b\"\n"); + let first = fx.write("a/src/main.rs", "fn main() {}\n"); + let second = fx.write("b/src/main.rs", "fn main() {}\n"); + let mut state = editor(); + fx.bind(&state); + configure(&state, "rust"); + open(&state, &first); + settle(&mut state); + open(&state, &second); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 2, "one server per project root: {rows:?}"); + let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect(); + assert!( + roots.contains(&file_uri(&fx.dir("a")).as_str()), + "{roots:?}" + ); + assert!( + roots.contains(&file_uri(&fx.dir("b")).as_str()), + "{roots:?}" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 15 — same root, two files, one server. The pre-change +// behavior, pinned so the fix cannot degrade into "always spawn". +// --------------------------------------------------------------------------- + +#[test] +fn acc15_two_files_in_one_root_reuse_a_single_server() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let first = fx.write("proj/src/main.rs", "fn main() {}\n"); + let second = fx.write("proj/src/other.rs", "pub fn other() {}\n"); + let mut state = editor(); + fx.bind(&state); + configure(&state, "rust"); + open(&state, &first); + settle(&mut state); + open(&state, &second); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 1, "same root must reuse: {rows:?}"); + assert_eq!( + rows[0].split('|').nth(1).unwrap(), + file_uri(&fx.dir("proj")) + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 16 — per-language regression pin. The single-root case is +// all the shipped attach paths ever exercised; it must be untouched. +// --------------------------------------------------------------------------- + +#[test] +fn acc16_shipped_languages_are_unchanged_for_the_single_root_case() { + // (language id, project marker, two source files under it) + let cases: [(&str, &str, &str, &str); 4] = [ + ("rust", "Cargo.toml", "one.rs", "two.rs"), + ("python", "pyproject.toml", "one.py", "two.py"), + ("go", "go.mod", "one.go", "two.go"), + ("typescript", "package.json", "one.ts", "two.ts"), + ]; + for (language, marker, first_name, second_name) in cases { + let fx = Fixture::new(); + fx.write(&format!("proj/{marker}"), "{}\n"); + let first = fx.write(&format!("proj/src/{first_name}"), "\n"); + let second = fx.write(&format!("proj/src/{second_name}"), "\n"); + let mut state = editor(); + fx.bind(&state); + configure(&state, language); + open(&state, &first); + settle(&mut state); + open(&state, &second); + settle(&mut state); + + let rows = rows(&state); + assert_eq!( + rows.len(), + 1, + "{language}: expected one server, got {rows:?}" + ); + let fields: Vec<&str> = rows[0].split('|').collect(); + assert_eq!(fields[0], language, "{language}: language_id"); + assert_eq!( + fields[1], + file_uri(&fx.dir("proj")), + "{language}: root must be the marker directory" + ); + } +} + +// --------------------------------------------------------------------------- +// Acceptance 17 — hoist pin. `project_root_for` now runs on the *reuse* +// path, and a function-valued `root` is memoized per directory. +// --------------------------------------------------------------------------- + +#[test] +fn acc17_function_root_runs_on_the_reuse_path_and_memoizes_per_directory() { + let fx = Fixture::new(); + let shared = fx.dir("shared"); + std::fs::create_dir_all(&shared).unwrap(); + let a1 = fx.write("one/a.rs", "fn a() {}\n"); + let a2 = fx.write("one/b.rs", "fn b() {}\n"); + let b1 = fx.write("two/c.rs", "fn c() {}\n"); + let mut state = editor(); + fx.bind(&state); + // A resolver that answers the same root for every directory: the + // second directory therefore REUSES the first directory's server, + // which is exactly the path the hoist put the resolver on. + exec( + &state, + &format!( + r#" + _G.ROOT_CALLS = 0 + pmacs.lsp.config.rust = {{ + command = "{}", + root = function(_) + _G.ROOT_CALLS = _G.ROOT_CALLS + 1 + return "{}" + end, + }} + "#, + fake_lsp_path(), + lua_str(&shared) + ), + ); + + open(&state, &a1); + settle(&mut state); + assert_eq!(eval::(&state, "return _G.ROOT_CALLS"), 1, "spawn path"); + + // Same directory: served from the memo, so the count does not move. + open(&state, &a2); + settle(&mut state); + assert_eq!( + eval::(&state, "return _G.ROOT_CALLS"), + 1, + "second file in the same directory must hit the memo" + ); + + // Different directory: the resolver runs again — proving the reuse + // path resolves at all — but resolves to the same root, so no second + // server appears. + open(&state, &b1); + settle(&mut state); + assert_eq!( + eval::(&state, "return _G.ROOT_CALLS"), + 2, + "a new directory must consult the resolver on the reuse path" + ); + let rows = rows(&state); + assert_eq!(rows.len(), 1, "one resolved root, one server: {rows:?}"); + assert_eq!(rows[0].split('|').nth(1).unwrap(), file_uri(&shared)); +} + +// --------------------------------------------------------------------------- +// Acceptance 18 — a hand-spawned server carrying only `cwd` is not +// adopted by a root-bearing attach. A deliberate behavior change. +// --------------------------------------------------------------------------- + +#[test] +fn acc18_hand_spawned_server_without_root_uri_is_not_adopted() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let file = fx.write("proj/src/main.rs", "fn main() {}\n"); + let proj = fx.dir("proj"); + let mut state = editor(); + fx.bind(&state); + configure(&state, "rust"); + // Exactly what an init.lua would write: cwd, no root_uri. + exec( + &state, + &format!( + r#" + pmacs.lsp.spawn({{ + label = "hand-rolled", + language_id = "rust", + command = "{}", + cwd = "{}", + }}) + "#, + fake_lsp_path(), + lua_str(&proj) + ), + ); + settle(&mut state); + assert_eq!(count(&state), 1, "the hand-spawned server is up"); + + open(&state, &file); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 2, "the attach must not adopt it: {rows:?}"); + let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect(); + assert!( + roots.contains(&""), + "hand-spawned reads back nil: {roots:?}" + ); + assert!( + roots.contains(&file_uri(&proj).as_str()), + "the attach's own server carries the root: {roots:?}" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 19 — a dead server in the matching root is not reused. +// --------------------------------------------------------------------------- + +#[test] +fn acc19_stopped_server_in_the_matching_root_is_not_reused() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let first = fx.write("proj/src/main.rs", "fn main() {}\n"); + let second = fx.write("proj/src/other.rs", "pub fn other() {}\n"); + let mut state = editor(); + fx.bind(&state); + configure(&state, "rust"); + open(&state, &first); + settle(&mut state); + let original: i64 = eval(&state, "return pmacs.lsp.list()[1].id:raw()"); + + exec(&state, "pmacs.lsp.stop(pmacs.lsp.list()[1].id)"); + for _ in 0..200 { + settle(&mut state); + let dead: bool = eval( + &state, + r#" + for _, s in ipairs(pmacs.lsp.list()) do + local k = s.state and s.state.kind + if k == "stopped" or k == "crashed" then return true end + end + return false + "#, + ); + if dead { + break; + } + } + + open(&state, &second); + settle(&mut state); + let live: i64 = eval( + &state, + r#" + for _, s in ipairs(pmacs.lsp.list()) do + local k = s.state and s.state.kind + if k ~= "stopped" and k ~= "crashed" then + return s.id:raw() + end + end + return -1 + "#, + ); + assert_ne!(live, -1, "a replacement server must exist"); + assert_ne!(live, original, "the dead server must not be reused"); +} + +// --------------------------------------------------------------------------- +// Acceptance 20 — the loose-file pin (Q#LN15 part 2). This is the +// no-change case, and the one a naive `(language_id, root)` key breaks. +// --------------------------------------------------------------------------- + +#[test] +fn acc20_markerless_files_in_different_directories_share_one_server() { + let fx = Fixture::new(); + let first = fx.write("loose_a/one.rs", "fn one() {}\n"); + let second = fx.write("loose_b/two.rs", "fn two() {}\n"); + let mut state = editor(); + fx.bind(&state); + configure(&state, "rust"); + open(&state, &first); + settle(&mut state); + open(&state, &second); + settle(&mut state); + + let rows = rows(&state); + assert_eq!( + rows.len(), + 1, + "loose files must keep sharing one server: {rows:?}" + ); + let fields: Vec<&str> = rows[0].split('|').collect(); + assert_eq!(fields[1], "", "the fallback root is not an affinity key"); + assert_eq!( + fields[2], + fx.dir("loose_a").display().to_string(), + "cwd still carries the first file's directory" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 21 — detected and fallback are different servers, and the +// fallback one still carries its directory as `cwd`. +// --------------------------------------------------------------------------- + +#[test] +fn acc21_detected_root_and_markerless_file_get_different_servers() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let inside = fx.write("proj/src/main.rs", "fn main() {}\n"); + let loose = fx.write("loose/stray.rs", "fn stray() {}\n"); + let mut state = editor(); + fx.bind(&state); + configure(&state, "rust"); + open(&state, &inside); + settle(&mut state); + open(&state, &loose); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 2, "detected and fallback must differ: {rows:?}"); + let detected = rows + .iter() + .find(|r| r.split('|').nth(1).unwrap() == file_uri(&fx.dir("proj"))) + .unwrap_or_else(|| panic!("no server rooted at the project: {rows:?}")); + assert_eq!( + detected.split('|').nth(2).unwrap(), + fx.dir("proj").display().to_string() + ); + let fallback = rows + .iter() + .find(|r| r.split('|').nth(1).unwrap().is_empty()) + .unwrap_or_else(|| panic!("no rootless server: {rows:?}")); + assert_eq!( + fallback.split('|').nth(2).unwrap(), + fx.dir("loose").display().to_string(), + "the markerless server keeps the fallback directory as cwd" + ); +} + +// --------------------------------------------------------------------------- +// Review-round-1 pins. Neither is a numbered acceptance criterion; both +// cover a branch the nine above leave untested. +// --------------------------------------------------------------------------- + +/// A *string* `config.root` is an affinity key. acc17 covers the function +/// form; without this the `return configured, "config"` arm has no test. +/// +/// The bite: both files sit in their own marked project, so if the config +/// arm were dropped they would key on their own detected roots and spawn +/// two servers. One server keyed on the configured root is only possible +/// if the override wins. +#[test] +fn config_string_root_overrides_detection_as_the_affinity_key() { + let fx = Fixture::new(); + fx.write("a/Cargo.toml", "[package]\nname = \"a\"\n"); + fx.write("b/Cargo.toml", "[package]\nname = \"b\"\n"); + let first = fx.write("a/src/main.rs", "fn main() {}\n"); + let second = fx.write("b/src/main.rs", "fn main() {}\n"); + let shared = fx.dir("shared"); + std::fs::create_dir_all(&shared).unwrap(); + let mut state = editor(); + fx.bind(&state); + exec( + &state, + &format!( + "pmacs.lsp.config.rust = {{ command = \"{}\", root = \"{}\" }}", + fake_lsp_path(), + lua_str(&shared) + ), + ); + open(&state, &first); + settle(&mut state); + open(&state, &second); + settle(&mut state); + + let rows = rows(&state); + assert_eq!( + rows.len(), + 1, + "a configured root outranks both detected roots: {rows:?}" + ); + let fields: Vec<&str> = rows[0].split('|').collect(); + assert_eq!(fields[1], file_uri(&shared), "keyed on the configured root"); + assert_eq!(fields[2], shared.display().to_string()); +} + +/// `root = false` reads as unset, as it always has. Defended in +/// `project_root_for` by a truthiness check rather than `~= nil`; this +/// pins the behavior instead of trusting the comment. +/// +/// The bite: under a `~= nil` test the config arm would return +/// `false, "config"`, and `file_uri_for(false)` returns nil — so the file +/// would land on a rootless server instead of its detected project. +#[test] +fn config_root_false_reads_as_unset_and_detection_still_wins() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let file = fx.write("proj/src/main.rs", "fn main() {}\n"); + let mut state = editor(); + fx.bind(&state); + exec( + &state, + &format!( + "pmacs.lsp.config.rust = {{ command = \"{}\", root = false }}", + fake_lsp_path() + ), + ); + open(&state, &file); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!( + rows[0].split('|').nth(1).unwrap(), + file_uri(&fx.dir("proj")), + "`false` must not become a root; detection still wins" + ); +} + +/// COHERENCE §1.2: background wiring must leave an attributed trace +/// rather than discard a failure. A throwing root resolver is a config +/// bug, and the per-directory memo would otherwise bury it permanently. +/// +/// The bite: drop the reporting arm and `*errors*` stays empty while the +/// attach still succeeds — the exact silence §1.2 names as the canonical +/// anti-pattern, in the function it cites. +#[test] +fn a_throwing_root_resolver_leaves_an_attributed_trace() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let file = fx.write("proj/src/main.rs", "fn main() {}\n"); + let mut state = editor(); + fx.bind(&state); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.rust = {{ + command = "{}", + root = function(_) error("resolver blew up") end, + }} + "#, + fake_lsp_path() + ), + ); + open(&state, &file); + settle(&mut state); + + let msg = status(&state); + assert!( + msg.contains("root resolver"), + "a raising resolver must leave an attributed trace; got: {msg:?}" + ); + assert!( + msg.contains("rust"), + "the trace must name the language that owns it; got: {msg:?}" + ); + assert!( + msg.contains("resolver blew up"), + "the underlying error text must survive; got: {msg:?}" + ); + + // ...and the failure must degrade to a decline, not a failed attach: + // detection still wins and the buffer still gets its server. + let rows = rows(&state); + assert_eq!(rows.len(), 1, "the attach must still succeed: {rows:?}"); + assert_eq!( + rows[0].split('|').nth(1).unwrap(), + file_uri(&fx.dir("proj")), + "a declining resolver falls through to the marker walk" + ); +} + +/// The decline path stays silent. Without this, "report failures" could +/// be satisfied by reporting *every* resolution, which would spam +/// `*errors*` on every attach in a Lean project. +#[test] +fn a_resolver_returning_nil_declines_silently() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let file = fx.write("proj/src/main.rs", "fn main() {}\n"); + let mut state = editor(); + fx.bind(&state); + exec( + &state, + &format!( + "pmacs.lsp.config.rust = {{ command = \"{}\", root = function(_) return nil end }}", + fake_lsp_path() + ), + ); + open(&state, &file); + settle(&mut state); + + let msg = status(&state); + assert!( + !msg.contains("root resolver"), + "returning nil is the documented decline, not a failure; got: {msg:?}" + ); + assert_eq!( + rows(&state)[0].split('|').nth(1).unwrap(), + file_uri(&fx.dir("proj")) + ); +} diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 04b2c27..b7c2e9c 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -1087,3 +1087,228 @@ fn a28_a30_a_v18_semantic_peer_has_no_terminal_surface() { .terminate(terminal_buffer, &mut state.process_supervisor.borrow_mut()) .expect("terminate child"); } + +// ---- GPU terminal input: the double terminal-layout sync ----------------- +// +// Acceptance 1, 4 and 7 of `docs/gpu-terminal-input-framing.md`, on the real +// path: real daemon, real PTY child, real `pmacs-gpu` attach client. +// +// `a37` above passes on the broken tree, and these are shaped around exactly +// why. Its child prints 400 rows on a timer, so a frame storm hides inside +// legitimate output; its only frame-count assertion is `frames >= 2`; and its +// resize assertion is satisfied by a geometry that oscillates THROUGH the +// asserted width. The children below are therefore deliberately QUIET, and +// the assertions are upper bounds. + +/// A terminal child that produces nothing on its own and prints one fresh, +/// DISTINCT breadcrumb per `SIGWINCH`. +/// +/// Distinctness is load-bearing: `cell::diff` skips both spaces and +/// already-matching cells, so a repeated identical marker can never be +/// asserted on — the second and later copies would paint nothing. +#[cfg(feature = "crdt")] +const WINCH_PROBE_INIT_LUA: &str = r#" +pmacs.command.define { + name = "vterm-probe.open", + description = "Open a quiet terminal that counts SIGWINCH.", + fn = function() + return pmacs.terminal.open { + command = "/bin/sh", + args = { "-c", + "n=0; trap 'n=$((n+1)); printf \"WINCH %d\r\n\" \"$n\"' WINCH; " .. + "printf 'READY\r\n'; while :; do sleep 0.2; done" }, + } + end, +} +pmacs.keymap.bind { scope = "global", sequence = "C-M-t", command = "vterm-probe.open" } +"#; + +/// A terminal child that echoes input by copying stdin to stdout. +/// +/// `cat` is the right instrument precisely because it does NOT echo: termios +/// `ECHO` is off on a `TerminalMode::Raw` PTY, so nothing in the kernel line +/// discipline reflects the byte. `cat` copies it exactly once, which makes a +/// single typed character produce a single unambiguous cell. +#[cfg(feature = "crdt")] +const CAT_PROBE_INIT_LUA: &str = r#" +pmacs.command.define { + name = "vterm-probe.open", + description = "Open a terminal child that copies stdin to stdout.", + fn = function() + return pmacs.terminal.open { + command = "/bin/sh", + args = { "-c", "printf 'READY\r\n'; exec cat" }, + } + end, +} +pmacs.keymap.bind { scope = "global", sequence = "C-M-t", command = "vterm-probe.open" } +"#; + +/// Run the headless GPU probe against a daemon built from `init_lua`, and +/// return its parsed report. `observe_ms` selects quiet-observation mode. +#[cfg(feature = "crdt")] +fn run_gpu_probe( + init_lua: &str, + observe_ms: Option, +) -> Option> { + use std::path::{Path, PathBuf}; + + fn gpu_binary() -> PathBuf { + Path::new(env!("CARGO_BIN_EXE_pmacs")) + .parent() + .expect("test binary directory") + .join("pmacs-gpu") + } + + let required = std::env::var_os("PMACS_REQUIRE_GPU").is_some(); + let binary = gpu_binary(); + if !binary.exists() { + assert!( + !required, + "PMACS_REQUIRE_GPU is set but {} is not built", + binary.display() + ); + eprintln!("skipping: {} is not built", binary.display()); + return None; + } + + let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( + &[ + ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"), + ("PMACS_INSTANCE_MULTI_FRONTEND", "1"), + ], + init_lua, + ); + let report = daemon + .socket_path() + .parent() + .expect("socket parent") + .join("gpu-probe.txt"); + let mut command = std::process::Command::new(&binary); + command + .arg("--headless-probe") + .arg(daemon.socket_path()) + .arg(&report) + .env("PMACS_GPU_PROBE_OPEN_KEY", "t"); + if let Some(ms) = observe_ms { + command.env("PMACS_GPU_PROBE_OBSERVE_MS", ms.to_string()); + } + let output = command.output().expect("run the headless GPU probe"); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let no_adapter = output.status.code() == Some(3); + assert!( + no_adapter && !required, + "headless GPU probe failed (status {:?}):\n{stderr}", + output.status.code() + ); + eprintln!("skipping: no wgpu adapter available"); + return None; + } + let text = std::fs::read_to_string(&report).expect("probe report"); + Some( + text.lines() + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect(), + ) +} + +/// Acceptance 1 and 7: a GPU session showing a quiet terminal must settle. +/// +/// Both assertions are upper bounds over a fixed observation window, which is +/// the only shape that can see this defect. On the pre-fix tree the dispatcher +/// resized the PTY twice per tick forever, so the child took a `SIGWINCH` +/// storm and the daemon emitted a terminal frame per tick — measured at ~730 +/// frames in 20 s against a child that printed one line and then slept. +#[cfg(feature = "crdt")] +#[test] +fn gpu_terminal_geometry_settles_and_stops_signalling_the_child() { + const OBSERVE_MS: u64 = 4_000; + let Some(facts) = run_gpu_probe(WINCH_PROBE_INIT_LUA, Some(OBSERVE_MS)) else { + return; + }; + let report = || format!("{facts:#?}"); + + assert_eq!( + facts.get("entered_terminal_mode").map(String::as_str), + Some("true"), + "precondition: the GPU entered terminal mode from a real frame: {}", + report() + ); + // Non-vacuity for the whole test: the child really did run, and the + // breadcrumb mechanism really does paint. + let screen = facts.get("last_frame_text").cloned().unwrap_or_default(); + assert!( + screen.contains("READY"), + "precondition: the child's own output must reach the frame: {}", + report() + ); + + // Acceptance 1 — a quiet child must not produce a frame per tick. The + // bound is generous: the session legitimately emits a first frame, plus a + // frame for the geometry it settles at, plus the WINCH breadcrumb. + let frames: u32 = facts + .get("frames") + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + assert!( + (1..=12).contains(&frames), + "a quiet terminal must settle, got {frames} frames in {OBSERVE_MS} ms \ + (pre-fix: one per dispatcher tick): {}", + report() + ); + + // Acceptance 7 — bounded SIGWINCH, counted by the child itself through + // the real PTY. At most one resize is legitimate here (the frontend's + // first declaration); the probe requests none in quiet mode. + assert!( + !screen.contains("WINCH 3"), + "the child must not be signalled repeatedly: {}", + report() + ); +} + +/// Acceptance 4: a character typed through the real GPU attach client reaches +/// the child and its copy comes back in a rendered frame. +/// +/// **This is a keep-working pin, not a fix discriminator** — it passes on the +/// pre-fix tree too. Key transport was never the defect (falsified hypothesis +/// 2 in the framing), and this exists so that a future change to the routing +/// or transport cannot quietly break what the resize fix was not about. +#[cfg(feature = "crdt")] +#[test] +fn gpu_terminal_input_reaches_the_child_and_returns_in_a_frame() { + let Some(facts) = run_gpu_probe(CAT_PROBE_INIT_LUA, None) else { + return; + }; + let report = || format!("{facts:#?}"); + + assert_eq!( + facts.get("entered_terminal_mode").map(String::as_str), + Some("true"), + "precondition: terminal mode: {}", + report() + ); + let frames: u32 = facts + .get("frames") + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + assert!( + frames >= 1, + "precondition: the child ran and painted: {}", + report() + ); + // The probe types `x`; `cat` copies it back exactly once. The observation + // is LATCHED across frames rather than read off the last one: the probe + // also requests a geometry change, and a reflow rewrites the visible grid. + // "did the byte come back" and "is it still on screen at the end" are + // different questions, and only the first is about input reaching the + // child. + assert_eq!( + facts.get("input_echo_observed").map(String::as_str), + Some("true"), + "the typed character must reach the child and return: {}", + report() + ); +}