From e547a90e378516a26f069798d7c4857ade909c5d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:42:25 -0400 Subject: [PATCH 01/27] fix(gpu): stop the minimap dividing by zero on an all-blank slab `dominant_line_shape` averages only the lines in a bucket that have content, then guarded the result with `bool::then_some`. `then_some` takes its argument by value, so the `MinimapLineShape` literal --- and with it `indent_sum / count` --- is evaluated before the `count > 0` guard is ever consulted. When a bucket holds no contentful lines the division panics and takes the GPU frontend down. This is reachable in ordinary use, not at an edge: the bucketing branch runs whenever a file has more lines than the minimap has pixel rows, and it is exactly then that a run of blank lines can fill a whole downsampled row. A whitespace-only line counts as blank too --- `minimap_line_shape` subtracts the indent from the total, so `content_cols` is zero. Switch to `bool::then`, which defers the body into a closure so the zero case short-circuits to `None`. The call site already treats `None` as "draw no stroke for this row", so no other change is needed. Three tests, two of which fail against the previous line: * a 10,000-line all-blank file driven through `minimap_rects`, which reproduces the original panic through the real downsampling path; * `dominant_line_shape` on an empty bucket; * a mixed bucket, asserting the average still ignores blank lines --- a companion guard so the fix cannot regress into counting the whole slice. A comment records why this must not be "simplified" back: clippy's `unnecessary_lazy_evaluations` pushes in precisely the wrong direction here, and does not fire on a body that can panic. Co-Authored-By: Claude Opus 5 (1M context) --- pmacs-gpu/src/main.rs | 72 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 29acbe3..6372189 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8109,7 +8109,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 +10688,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))]; From a38296583b81c83848c0217e8d76765b4173e8c7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:44:53 -0400 Subject: [PATCH 02/27] docs: frame Lean 4 mode (Arc 8) The approved framing for Arc 8, revision 4, after three review rounds. Seven stages: grammar/mode, multi-root LSP affinity, the Lean language server, the Unicode input method, the goal view, the #eval output channel, and module hierarchy. 19 decisions, 64 acceptance criteria. Committed as this branch first commit per the house workflow; the implementation of Stage 1 follows. Co-Authored-By: Claude Opus 5 (1M context) --- docs/lean4-mode-framing.md | 1467 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1467 insertions(+) create mode 100644 docs/lean4-mode-framing.md 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. From 6ea8d2756e551dc1b33015c53b0b1e792ca53f5c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:49:01 -0400 Subject: [PATCH 03/27] feat(syntax): bundle the Lean 4 grammar (Arc 8 Stage 1, Q#LN1-3) Adds `arborium-lean` 2.18 and one `BUILTIN_LANGUAGES` entry, closing the framing's open verification obligation on the crate choice. Why this crate and not `tree-sitter-lean4` (Q#LN1): the latter depends on `tree-sitter = "0.25"` directly rather than the shared `tree-sitter-language` ABI crate, and `^0.25` excludes our 0.26, so it would fork the graph exactly as the dead `tree-sitter-dockerfile` does. It also 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` uses `tree-sitter-language 0.1` as its sole runtime dep, ships a pre-generated ABI-15 parser plus scanner, and exports real query constants. `cargo tree -d` reports no duplicate `tree-sitter`. The entry is named `lean4`, not `lean` (Q#LN2): `ensure_server` passes `LanguageEntry.name` through as the `didOpen` language_id, and the Lean ecosystem's id is `lean4` -- `lean` is Lean 3, which is end-of-life. It claims `.lean` only; `.olean` is a compiled binary and `.ilean` is JSON metadata (Q#LN3). Four tests. The load-bearing one is `lean4_grammar_loads_and_parses`, which discharges the half of Q#LN1 that could not be settled by reading: `arborium-lean` exports `const fn language() -> LanguageFn` rather than the `LANGUAGE` const every other entry uses, and its README demonstrates usage against a patched tree-sitter core. Neither is supposed to matter, but "supposed to" is not evidence. The fixture parses without error, and -- the part that actually guards a misbuild -- its Unicode operators produce structure rather than degrading silently: the grammar must see a `(arrow)` for the arrow, a `(forall)` for the universal quantifier, and a `(comparison)` for the inequality. The error-free claim is deliberately scoped to the committed fixture. Lean's syntax is user-extensible via macros, so a static grammar mis-parses some legal input by construction; the framing scores that as bet 3 rather than the doc overselling it. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 33 ++++++++++ Cargo.toml | 18 ++++++ src/syntax.rs | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) 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/src/syntax.rs b/src/syntax.rs index efbbd4d..edec588 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -1130,6 +1130,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 +2421,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 From 5207d40cafb633aa994b17ebf251694a76e4ce0c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:51:27 -0400 Subject: [PATCH 04/27] feat(theme): add the four Lean 4 capture entries (Arc 8 Stage 1, Q#LN4) `constructor`, `character`, `keyword.conditional`, and `warning` are the captures the Lean query uses that the global theme table lacked. Three of them are not Lean-only, so this is a deliberate retro-paint of already shipped languages -- the #146 lesson applied on purpose rather than discovered afterwards. The blast radius, measured rather than assumed: * `constructor` reaches SEVEN language entries, not four. The emitting crates are rust, lua, python and javascript, but `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated base-first into javascriptreact, typescript and typescriptreact as well. * Its shape is not "constructors". rust/python/javascript tag every capitalized identifier (`#match? "^[A-Z]"`); lua tags every table-constructor brace. So this recolors `None`, every class-cased name, and every Lua `{}` -- all of which rendered as unstyled default text before. * `character` reaches zig only; `keyword.conditional` reaches cmake and zig, which previously flattened it to `keyword`; `warning` reaches no other grammar and exists for Lean's `sorry`. The alternative was an in-repo overlay renaming the captures (the #144 LaTeX pattern), which forks 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. Pinned in both directions, per #146: * the positive breadth pin asserts all seven entries emit `@constructor` at the QUERY level -- chosen over per-fixture checks because the base-query composition is the fragile part; if someone stops concatenating the JS base into `typescript`, this fails while any single-language fixture still passes; * two grid pins prove the theme entry reaches painted cells, and a third records that a variant in CALL position keeps `@function` -- the difference between "capitalized identifiers recolor" and "enum variants recolor", only the first of which is true; * the negative pin asserts ten languages (markdown, json, yaml, html, css, c, cpp, go, toml, bash) emit none of the four names, with a non-vacuity check that the same predicate finds each name where it does occur. Rev 1 of the framing named Lua and Python in that negative pin, which was a self-contradiction -- both are retro-painted by `constructor`, so the assertion would have been vacuous in the #155 R2 shape. Review round 1 caught it. Full lib suite (1,824) and the required-GPU gate (152) pass unchanged, so no existing assertion depended on these captures being unstyled. Co-Authored-By: Claude Opus 5 (1M context) --- src/highlight.rs | 193 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/src/highlight.rs b/src/highlight.rs index 2c7369f..633ec6f 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -174,6 +174,41 @@ 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)), + ("warning", fg_bold(1)), ]; let by_capture = entries .iter() @@ -1484,6 +1519,164 @@ 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 { + 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.fg + } + + /// 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_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 8. + 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`, From 0c922682c0d575cc6e270c393382fe9de0364a70 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:59:48 -0400 Subject: [PATCH 05/27] feat(lean4): editing surface + Stage 1 acceptance (Q#LN5, LN6, LN17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Arc 8 Stage 1: the Lua-side tables that turn a recognized grammar into a usable mode, plus the acceptance suite for all twelve framing criteria. comment.lua -- `lean4 = "--"` (Q#LN5). Line comments only; Lean's block comment `/- -/` and docstring `/-- -/` belong to the comment arc's own named deferral and this lane does not front-run it. pair.lua -- `⟨⟩`, `⦃⦄`, `⟮⟯` alongside the ASCII brackets (Q#LN6). The anonymous constructor is among the most-typed constructs in Lean; omitting it would make the pair set feel broken. The other two 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 sit outside the nine built-in pair chars, so per Q#AP1 their undo is cross-peer-degraded -- the documented, pre-existing limitation of user-extended pairs. No `''`: Lean uses the prime as an identifier suffix (`h'`, `foo'`), the same reason Rust excludes it. syntax.lua -- the `lean` -> `lean4` modeline alias (Q#LN2), so an Emacs `-*- mode: lean -*-` or a Vim `ft=lean` line is not stranded by the entry being named `lean4`. syntax.rs -- the `lean` -> `lean4` injection alias (Q#LN17), so both ```lean and ```lean4 fences highlight. The Lean 3 spelling is mapped forward deliberately: a ```lean fence is overwhelmingly Lean 4 in practice. highlight.rs -- `warning` moves from bold red to bold BRIGHT red. Writing the test found the collision: `number` is plain `fg(1)`, so `sorry` and the literal `42` beside it were the same colour, differing only in the bold flag. `sorry` means "admitted, not proved" and is the one token in a proof file a reader must never skim past, so it now gets the loudest entry in the table and the test asserts the full style rather than the colour. Twelve criteria, seventeen tests. Notes on the ones that could have been vacuous: * acc4 uses a `.txt` fixture, not `.lean` -- on a `.lean` path the extension alone yields `lean4` and the assertion would pass with the alias table empty. acc4b removes the alias and pins that the raw name survives, so acc4 cannot silently stop testing anything. * acc11 goes through the real `_parse_now` injection path and asserts a `lean4` CHILD LAYER appears. `pmacs.parse.injection_aliases` is a documented write-only proxy, so an alias-table read would have proven nothing about the parser; acc11b pins that a misspelled fence still resolves to nothing. * acc12 asserts through the process supervisor and the server list that opening a Lean buffer spawns nothing. This is not decorative: the machine this arc was scouted on has elan installed with no default toolchain, where `lake --version` itself fails, and Stage 1 must be unaffected by that. Gates: fmt and strict workspace clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1 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. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/comment.lua | 4 + builtin/runtime/pair.lua | 16 ++ builtin/runtime/syntax.lua | 5 + src/highlight.rs | 106 +++++++++- src/syntax.rs | 6 + tests/lean4_stage1_acceptance.rs | 320 +++++++++++++++++++++++++++++++ 6 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 tests/lean4_stage1_acceptance.rs 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/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/src/highlight.rs b/src/highlight.rs index 633ec6f..642b0a0 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -208,7 +208,12 @@ impl Theme { ("constructor", fg(11)), ("character", fg(2)), ("keyword.conditional", fg_bold(13)), - ("warning", fg_bold(1)), + // 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() @@ -1527,6 +1532,17 @@ mod tests { 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}; @@ -1564,7 +1580,7 @@ mod tests { folds: None, }; hv.render(&buf, viewport, &mut grid); - grid.get(CellCoord::new(0, col)).style.fg + grid.get(CellCoord::new(0, col)).style } /// Does `language`'s compiled highlight query use `capture`? @@ -1576,6 +1592,92 @@ mod tests { 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 diff --git a/src/syntax.rs b/src/syntax.rs index edec588..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())) diff --git a/tests/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs new file mode 100644 index 0000000..ae65545 --- /dev/null +++ b/tests/lean4_stage1_acceptance.rs @@ -0,0 +1,320 @@ +//! 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_spawns_no_process_and_needs_no_lean_toolchain() { + // 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). + // + // Asserted through the process supervisor rather than by inspection: + // opening the file leaves the child-process list exactly as it was. + 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"); + let servers: i64 = eval(&s, "return #pmacs.lsp.list()"); + assert_eq!(servers, 0, "Stage 1 attaches no language server"); +} From 34767d332d30b8a8c42176fc63e9a93b35f02a21 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 10:18:27 -0400 Subject: [PATCH 06/27] fix(test): make acc12 pin the claim it names (review round 1) Review finding: acc12's server-list assertion could not fail for the regression class it was written to catch. The shared `editor()` helper runs `pmacs.lsp.config = {}` before any buffer opens, so `#pmacs.lsp.list() == 0` holds for every language regardless of what Stage 1 ships -- a Stage-3 front-run that added `pmacs.lsp.config.lean4` in a builtin runtime file would have slipped straight past it. The same vacuous-assertion shape as #155 R2. acc12 now asserts the actual claim against a PRISTINE `EditorState`, before any config wipe: no builtin runtime file defines `pmacs.lsp.config.lean4`. A non-vacuity check pins that the same lookup finds `pmacs.lsp.config.rust`, so this cannot pass merely because the table is empty or absent. Bite-verified: adding `pmacs.lsp.config.lean4 = ... { command = "lake", args = { "serve" } }` to `builtin/runtime/lsp.lua` fails the test; the stub was reverted. The process-list half is kept and its comment now says why it survives the wipe: a direct probe spawn from a future `lean.lua` shows up there whatever `pmacs.lsp.config` contains. Also fixes a stale column in a `highlight.rs` comment -- the Lua table brace in `local t = {}` is at col 10, which is what the code already used. Gates rerun: fmt and strict workspace clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1 9/9; M4 121; required GPU 152; isolated-config workspace sweep 3,150 across 90 suites; diff check clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/highlight.rs | 3 ++- tests/lean4_stage1_acceptance.rs | 32 ++++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/highlight.rs b/src/highlight.rs index 642b0a0..de8ffe0 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -1737,7 +1737,8 @@ mod tests { Color::Indexed(4), "a called variant keeps @function, not @constructor" ); - // Lua tags the table-constructor BRACES, not a name: `{` at col 8. + // 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), diff --git a/tests/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs index ae65545..d48a86c 100644 --- a/tests/lean4_stage1_acceptance.rs +++ b/tests/lean4_stage1_acceptance.rs @@ -305,16 +305,36 @@ fn acc11b_an_unknown_fence_name_still_injects_nothing() { // --------------------------------------------------------------------------- #[test] -fn acc12_stage1_spawns_no_process_and_needs_no_lean_toolchain() { +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). - // - // Asserted through the process supervisor rather than by inspection: - // opening the file leaves the child-process list exactly as it was. + + // 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"); - let servers: i64 = eval(&s, "return #pmacs.lsp.list()"); - assert_eq!(servers, 0, "Stage 1 attaches no language server"); } From 1a5805366a4b317dca2ed81e5a436c54bbb0c17f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 10:18:38 -0400 Subject: [PATCH 07/27] docs: record the Lean 4 lane in the active-work ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 flagged that neither ledger knew about this branch, and `docs/active-work.md`'s stated job is exactly the volatile open lanes. Records the branch, base, framing revision, what Stage 1 ships, the discharged Q#LN1 obligation, the Q#LN4 blast radius, and the four implementation findings that are not in the framing (the `warning` colour collision with `number`, `Some(1)` resolving to `@function` rather than `@constructor`, the `module > declaration > def` nesting, and `injection_aliases` being a write-only proxy). Also carries forward the two Stage 2 corrections the framing already holds, since that lane starts next. Deliberately ADDITIVE ONLY -- one new section, zero deleted lines. PR #156 is open against both this file and `docs/agent-handoff.md` and owns the snapshot header, the canonical-base line, and the bottom-panel lane's status. Touching those here would collide with a PR already in review, which is the "frozen reviewed PRs do not absorb moving overlapping work" lesson from #135/#137. `docs/agent-handoff.md` is deliberately untouched: its §1 snapshot describes what is ON `main`, so it gets updated when this merges, not while it is in review. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 70 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index fb835a6..f55627e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,6 +54,76 @@ git status --short --branch The `git log` command must expose `0dd16a5` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Lean 4 lane (Arc 8) — Stage 1 IN REVIEW (PR #160) + +- Portable branch: `githubsucks/lean4-stage1`, worked in the shared + checkout (no sibling worktree), based on `githubsucks/main` @ `e745068`. +- 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 is multi-root LSP server affinity** — pure substrate, no Lean + content, and it changes `ensure_server`, which every LSP language + shares. It is sequenced next because Lean is the language that makes its + absence a correctness failure rather than an inconvenience. Two + corrections the framing already carries for it: `root` is computed at + `lsp.lua:537`, **after** the reuse loop, so the fix must hoist it; and + `project_root_for` never returns nil for a file with a path, so the + affinity key must be the root only when a root was actually *detected*, + or markerless scratch files fragment into one server per directory for + every language. + ## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW - Portable branch: `githubsucks/bottom-panel`, worktree From 1ae5963e9d9a16b8228559b8d37359cf4ac4aeda Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 10:36:11 -0400 Subject: [PATCH 08/27] feat(lsp): one server per detected project root (Q#LN15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ensure_server` reused any live server whose `language_id` matched, regardless of project root — its own comment documented this as a known post-v0.1 limitation. For project-model-strict servers that is a correctness failure, not a rough edge: `lake serve` is bound to one Lake package, rust-analyzer and gopls to one workspace, so the second project a user opens gets a server that cannot resolve its imports. Server affinity is now keyed on the project root, with one rule that keeps the change 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 that has a path — its last resort is the file's own directory — so a naive `(language_id, root)` key 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. It now returns `root, source` with source one of "config" / "detected" / "fallback", and only the first two become an affinity key. Matching is on the spawned spec's `root_uri`, nil matching nil, so the fallback spawn passes `root_uri = nil` for the key and the stored spec to agree. `cwd` still carries the directory, and `build_initialize` (src/lsp.rs) 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`. The initialize payload for that case is therefore byte-identical to before; only what the reuse loop matches on changes. `build_initialize` is the only reader of `spec.root_uri` in the tree. Two consequences, both deliberate and both asserted rather than 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. We cannot know which root it was meant to serve, and guessing wrongly routes a project's files to the wrong server. - Opening files across N project roots spawns N servers. rust-analyzer has the same property and no editor caps it by default; `pmacs.lsp.stop` is the manual escape and an LRU reaping policy stays deferred. `config[language].root` may now be a `function(path) -> string|nil` as well as a string, for languages whose root rule the shared marker walk cannot express — an innermost-wins walk cannot find an *outermost* marker. A resolver returning nil declines and falls through to the marker walk. Results are memoized per directory because hoisting the root computation above the reuse loop puts it 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 old one computed. `pmacs.lsp.list()` rows gain `root_uri` and `cwd`. `root_uri` is the spec field verbatim, deliberately not the URI the server was initialized with. No protocol change. No Lean content: this is the shared affinity function for every LSP language, so it ships as its own PR and is exercised through rust, python, go and typescript against `pmacs_fake_lsp`. tests/lsp_multi_root_acceptance.rs covers acceptance 13-21. 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 would turn the markerless cases into detected ones — the assertions would still pass while testing nothing. Refs docs/lean4-mode-framing.md Q#LN15, acceptance 13-21. --- builtin/runtime/lsp.lua | 108 +++++- src/lua_bindings/mod.rs | 16 +- tests/lsp_multi_root_acceptance.rs | 511 +++++++++++++++++++++++++++++ 3 files changed, 618 insertions(+), 17 deletions(-) create mode 100644 tests/lsp_multi_root_acceptance.rs diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 4181156..07d0aeb 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,106 @@ 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. +-- +-- 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(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) + if not ok or 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(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 +620,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/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 29e3b47..3a92520 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -9923,12 +9923,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/tests/lsp_multi_root_acceptance.rs b/tests/lsp_multi_root_acceptance.rs new file mode 100644 index 0000000..b354283 --- /dev/null +++ b/tests/lsp_multi_root_acceptance.rs @@ -0,0 +1,511 @@ +//! 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 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" + ); +} From 2a0884b377b3d98380cdf3616d521d56a4399853 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 10:45:50 -0400 Subject: [PATCH 09/27] feat(find-file): open a file by path with C-x C-f Dired arc Stage 0 (docs/dired-framing.md section 10, Q#DR11). Until now pmacs had no discoverable way to open a file by path: no find-file command and no C-x C-f binding, so 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. The command prompts with completion rooted at the active buffer's directory, or the process cwd when the buffer has no backing path, and opens the result through pmacs.window.display_file. A path that does not exist yet creates a buffer bound to it with the "[new file]" status, which is Emacs parity and comes from resolve_target_buffer rather than anything added here. Nothing is written to disk until the user saves. Two substrate facts shape the design and are documented at the command rather than left to be rediscovered. Completion is flat: the files source lists one directory and yields bare basenames, and a custom function source could not do better, because sources are called with no arguments and run synchronously outside any coroutine, so a callback can neither see the input to re-root on nor await a directory listing. Hierarchical completion is a named Rust change in the framing. A selected candidate shadows typed text: recompute_candidates selects index 0 whenever the candidate list is non-empty, and resolve_accepted_value returns the candidate over the typed contents. So typed text reaches the accept handler exactly when the input filters every candidate away, which for basename candidates under a subsequence filter means when it contains a separator. That makes the deeper-path case work verbatim and leaves one hole: a new bare name that is a subsequence of an existing entry opens the existing file. The acceptance pins that as a decision rather than an accident; closing it needs a Rust change to accept semantics that Stage 0 deliberately does not make. A leading tilde is expanded before the path reaches the core, because get_or_load_buffer normalizes the path it stores but loads from the raw one -- so an unexpanded tilde path deduplicates against an already-open buffer yet fails to load a file that is not open yet. The prompt field starts empty and names its root in the prompt string instead: any prefill would contain a separator and silently disable completion. Acceptance is dispatch-driven throughout -- a real C-x C-f, real typing, a real RET -- so a dead binding cannot pass vacuously and the Lua lifecycle accept(), which bypasses the path interactive input takes, is not used. --- builtin/commands/default.lua | 110 +++++++++++++ builtin/keymaps/default.lua | 1 + tests/find_file_acceptance.rs | 291 ++++++++++++++++++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 tests/find_file_acceptance.rs diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 04c49fe..bc04e91 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -611,6 +611,116 @@ 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 one documented hole: 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. `acc4` pins that as a known +-- behavior rather than letting it be an accident. +-- +-- 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/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs new file mode 100644 index 0000000..d975cd8 --- /dev/null +++ b/tests/find_file_acceptance.rs @@ -0,0 +1,291 @@ +// 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:?}" + ); +} + +/// 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}" + ); +} From 92f57d8894366c4c4b478bf71ba824a3591dc043 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 11:03:54 -0400 Subject: [PATCH 10/27] docs: record Stage 1's landing and the Stage 2 affinity lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 merged as #160 (`main` @ `0827dd1`); the Lean lane header and branch line now say so, and Stage 2 gets its own subsection. Edits stay inside the Lean lane. PR #156 is still open against both this file and `docs/agent-handoff.md`, and it rewrites the snapshot header, the canonical-base line, and the whole bottom-panel lane — so those are left alone rather than merged twice. `agent-handoff.md` is untouched for the same reason plus its own: §1 describes what is on `main`, so it updates at merge, not during review. Records the one finding this stage turned up but did not fix: `ensure_server` never forwards `cfg.restart` to `pmacs.lsp.spawn`, so a `restart` in `pmacs.lsp.config[lang]` is silently dropped on the auto-attach path. Pre-existing, and out of scope for a PR whose acceptance 16 pins existing attach behavior as unchanged. --- docs/active-work.md | 70 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index f55627e..5bacaa1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,10 +54,11 @@ git status --short --branch The `git log` command must expose `0dd16a5` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Lean 4 lane (Arc 8) — Stage 1 IN REVIEW (PR #160) +## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #TBD) -- Portable branch: `githubsucks/lean4-stage1`, worked in the shared - checkout (no sibling worktree), based on `githubsucks/main` @ `e745068`. +- 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: @@ -113,16 +114,59 @@ If it does not, stop and repair the remote/fetch configuration. 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 is multi-root LSP server affinity** — pure substrate, no Lean - content, and it changes `ensure_server`, which every LSP language - shares. It is sequenced next because Lean is the language that makes its - absence a correctness failure rather than an inconvenience. Two - corrections the framing already carries for it: `root` is computed at - `lsp.lua:537`, **after** the reuse loop, so the fix must hoist it; and - `project_root_for` never returns nil for a file with a path, so the - affinity key must be the root only when a root was actually *detected*, - or markerless scratch files fragment into one server per directory for - every language. +### 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. ## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW From 4a2aa925107aea73964aaf40e21991cdcc70bacb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 11:13:57 -0400 Subject: [PATCH 11/27] style: rustfmt the find-file acceptance harness --- tests/find_file_acceptance.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs index d975cd8..ad4c5b2 100644 --- a/tests/find_file_acceptance.rs +++ b/tests/find_file_acceptance.rs @@ -93,7 +93,10 @@ fn editor_in(dir: &std::path::Path) -> EditorState { 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:?})")); + exec( + &state, + &format!("pmacs.buffer.find_or_open({anchor_str:?})"), + ); state } From 35085b54d1f24f35759390859f72f80d95180e54 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 11:19:43 -0400 Subject: [PATCH 12/27] fix: rustfmt the acceptance suite and pin two untested arms (round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocker was process, not design. The test file was committed before `cargo fmt` ran, so the reflow of five over-width assertions sat uncommitted in the working tree while the branch as pushed failed the first gate in CLAUDE.md. The "fmt clean" reported on the PR described the worktree, not the branch. Gate results are only meaningful run against the pushed tree, so this commit lands the formatting first and the gates are re-run against it. Two pins review asked for, each covering a branch the nine acceptance tests left untested: - A **string** `config.root` as an affinity key. acc17 covers only the function form, so `return configured, "config"` had no test. The bite puts both files in their own marked project: drop the config arm and they key on their own detected roots and spawn two servers, so one server on the configured root is only reachable if the override wins. - `root = false` reads as unset. Defended by a truthiness check rather than `~= nil`, previously by comment alone. Under `~= nil` the config arm returns `false, "config"` and `file_uri_for(false)` returns nil, so the file lands on a rootless server instead of its detected project. Each was falsified against exactly the mutation it targets and neither against the other. Also documents an asymmetry review caught: `project_root_for`'s "detected" arm is canonicalized for free because `pmacs.project.detect` canonicalizes before walking, but a **configured** root — string or resolver return — 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 therefore different keys for one directory, silently yielding two servers for one project. There is no Lua-side canonicalizer to normalize it, and Stage 3's Lean resolver is the first real consumer, so the obligation is stated in the `config.root` doc comment where that resolver's author will read it. --- builtin/runtime/lsp.lua | 9 +++ docs/active-work.md | 22 +++++- tests/lsp_multi_root_acceptance.rs | 118 +++++++++++++++++++++++++++-- 3 files changed, 141 insertions(+), 8 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 07d0aeb..3aca1ac 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -522,6 +522,15 @@ end -- *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 diff --git a/docs/active-work.md b/docs/active-work.md index 5bacaa1..c3fd5f9 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,7 +54,7 @@ git status --short --branch The `git log` command must expose `0dd16a5` 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 #TBD) +## 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` @@ -167,6 +167,26 @@ If it does not, stop and repair the remote/fetch configuration. 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`. ## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW diff --git a/tests/lsp_multi_root_acceptance.rs b/tests/lsp_multi_root_acceptance.rs index b354283..518c964 100644 --- a/tests/lsp_multi_root_acceptance.rs +++ b/tests/lsp_multi_root_acceptance.rs @@ -181,8 +181,16 @@ fn acc13_list_rows_carry_root_uri_and_cwd() { 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"); + 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" + ); } // --------------------------------------------------------------------------- @@ -207,8 +215,14 @@ fn acc14_two_project_roots_of_one_language_spawn_two_servers() { 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:?}"); + assert!( + roots.contains(&file_uri(&fx.dir("a")).as_str()), + "{roots:?}" + ); + assert!( + roots.contains(&file_uri(&fx.dir("b")).as_str()), + "{roots:?}" + ); } // --------------------------------------------------------------------------- @@ -232,7 +246,10 @@ fn acc15_two_files_in_one_root_reuse_a_single_server() { 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"))); + assert_eq!( + rows[0].split('|').nth(1).unwrap(), + file_uri(&fx.dir("proj")) + ); } // --------------------------------------------------------------------------- @@ -263,7 +280,11 @@ fn acc16_shipped_languages_are_unchanged_for_the_single_root_case() { settle(&mut state); let rows = rows(&state); - assert_eq!(rows.len(), 1, "{language}: expected one server, got {rows:?}"); + 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!( @@ -377,7 +398,10 @@ fn acc18_hand_spawned_server_without_root_uri_is_not_adopted() { 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(&""), + "hand-spawned reads back nil: {roots:?}" + ); assert!( roots.contains(&file_uri(&proj).as_str()), "the attach's own server carries the root: {roots:?}" @@ -509,3 +533,83 @@ fn acc21_detected_root_and_markerless_file_get_different_servers() { "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" + ); +} From 0b0d5acd81b6b21a0984a938dac33961e6633d1b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 11:33:46 -0400 Subject: [PATCH 13/27] fix(find-file): review round 1 -- name the real test, pin two gaps Three of the five review findings land here; the other two are recorded as named deferrals in the framing on the dired branch. Finding 1: the command comment cited "acc4", a name from a draft scheme that no test carries. It now names the real test, and the comment splits the shadowing consequence into the two cases that actually exist -- a new bare name that matches an entry (shadowed) versus one that matches nothing (creates normally) -- each pointing at its test. Finding 2: the everyday new-file flow had no test. Typing a bare name that is not a subsequence of any entry is the path users hit first, and the only route combining free text with a relative join; every existing new-file test used a name containing a separator. find_file_bare_new_name_creates_in_the_root covers it, asserting the parent is the prompt's root so the join itself is pinned. Finding 3: the failure arm was never exercised, and as the review noted, deleting the pcall would have passed the whole suite. Accepting a directory candidate reaches display_file, whose load fails because File::open on a directory succeeds and the read returns EISDIR; find_file_accepting_a_directory_reports_instead_of_raising pins that this surfaces as the command's status message, leaves the active buffer alone, and closes the prompt. Verified by manual revert: with the pcall replaced by a direct call, that test and only that test fails. scripts/bite could not isolate it, since the guard and its test have no separating commit. Finding 4 is documented at the command rather than left implicit: accepting on empty input opens the first-sorted candidate, because fuzzy_score returns Some(0) for an empty needle and filter_and_sort breaks the tie lexicographically, so dotfiles lead and a directory can lead. M-x and switch-buffer share the mechanism, so it is inherited rather than introduced, and it is listed in the framing beside the accept-semantics change that would close it. --- builtin/commands/default.lua | 21 ++++++++-- tests/find_file_acceptance.rs | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index bc04e91..2a13c21 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -637,10 +637,23 @@ cmd { name = "editor.switch-buffer", -- 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 one documented hole: 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. `acc4` pins that as a known --- behavior rather than letting it be an accident. +-- 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, diff --git a/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs index ad4c5b2..fb793e3 100644 --- a/tests/find_file_acceptance.rs +++ b/tests/find_file_acceptance.rs @@ -194,6 +194,85 @@ fn find_file_nonexistent_path_creates_a_new_file_buffer() { ); } +/// 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 From 066b8652b8778fcca11dfeeb62943a00ab047b3b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 11:37:21 -0400 Subject: [PATCH 14/27] docs: add COHERENCE.md as a required doc, audited against the codebase COHERENCE.md states the product-coherence thesis (pmacs should be immediately excellent, progressively understandable, completely inspectable, and ultimately replaceable) and, per-section, the audited ground truth of how the codebase measures against it: a scorecard across 19 concerns, the golden-journey verdict table (breaks at "open a real project" -- `pmacs .` exits 1), the six hardcoded key-interception shadows with no transient-keymap mechanism to migrate them to, the discoverability substrate-without-surface gap, the package/worker identity gap, and three cross-cutting patterns (substrate without surface, the silence asymmetry, per-arc coherence debt) that explain most of the individual findings. CLAUDE.md and AGENTS.md now list it as required reading alongside agent-handoff.md and active-work.md, and ask new framing docs to state their coherence impact. No runtime code changes. --- AGENTS.md | 16 +- CLAUDE.md | 16 +- COHERENCE.md | 1547 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1571 insertions(+), 8 deletions(-) create mode 100644 COHERENCE.md 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..1594b69 --- /dev/null +++ b/COHERENCE.md @@ -0,0 +1,1547 @@ +# 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`, PR #162, `docs/dired-framing.md`), bottom +panel Stage 1 (merged #155), multi-root LSP affinity (branch +`lsp-multi-root-affinity`), 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 exists as a frozen test fixture + (`tests/fixtures/pmacs-dired/init.lua`). Being fixed now: dired Stage + 0 (PR #162). + +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. + +**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. + +### 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** | `pmacs .` exits 1 (above). No directory handling anywhere | +| 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: missing → in flight (PR #162). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit; `M-.`/`M-?`/`C-c o` 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 | +| 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 ✗ (no find-file at audit; PR #162 in flight) · 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 GPU **optimistic key classifier** + (`crate::optimistic::classify_key`) — the latter is classification, + not routing, and is kept honest by `dispatch_idle_for`. + +**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 in flight**: the multi-root LSP server-affinity work + (branch `lsp-multi-root-affinity`) makes *(language, found-root)* the + server identity — the first time a root functions as an identity key + rather than a spawn parameter. 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. +- **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). +- 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; a find-file surface (in flight, +PR #162); 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. From 7c01c9322650606adb6ef64e7b4d8da20d1b3bc2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 14:09:49 -0400 Subject: [PATCH 15/27] docs: dired arc framing (revision 5) + post-merge doc refresh Lands the approved dired framing on main as its own docs PR, and brings the two required docs current after find-file merged as #162. The framing was approved after two review rounds (seven findings, then six) and revised twice more since: revision 4 recorded what implementing Stage 0 falsified in the approved text, and revision 5 adds the coherence impact statement that #163 made mandatory for every framing. The coherence statement is new work, not a restatement. COHERENCE.md section 20 Priority 1 already names this arc -- a find-file surface and directory-argument handling -- so the framing now states which journey steps it touches (7, and partially 3), that it adds no interaction island because its keys are a mode-scoped keymap through the ordinary registry and wdired is a mode swap rather than a modal layer, that it adopts the config registry for dired.kill-when-opening, and that it inherits the worker-attribution gap for its read_dir jobs without worsening it. It also draws the boundary against the adjacent Journey Stage 1 arc: CLI directory handling belongs there, the two meet at resolve_target_buffer, and dired supplies the buffer a directory should resolve to rather than growing a second directory surface. One convergence worth recording: section 2 grades the golden journey broken at step 3 because pmacs on a directory exits 1, and the mechanism it cites -- File::open succeeding on a directory, then read_to_end returning EISDIR -- is the same one Stage 0 pinned in its accepting-a-directory test, where the pcall turns it into a status message instead. The handoff snapshot was stale through eight merges. It now anchors on main at 2af1ab3, records COHERENCE.md as required reading and a required framing input, and carries the two minibuffer facts find-file established: a custom completion source cannot descend directories, and a selected candidate shadows typed text -- both of which apply to M-x and switch-buffer, not just find-file. The ledger gains the dired lane with Stage 1's scope, the reason its one Rust change cannot be done in Lua, and the rebase note for the dired branch, whose framing commits become redundant when this lands. --- docs/active-work.md | 41 +- docs/agent-handoff.md | 59 +- docs/dired-framing.md | 1209 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1304 insertions(+), 5 deletions(-) create mode 100644 docs/dired-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index f55627e..6dc235f 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. @@ -124,6 +124,45 @@ If it does not, stop and repair the remote/fetch configuration. or markerless scratch files fragment into one server per directory for every language. +## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next + +- Approved framing: `docs/dired-framing.md` (revision 5), landing as its + own docs PR off `githubsucks/main` @ `2af1ab3`, branch + `githubsucks/dired-framing`, worktree `../pmacs-dired-framing`. The + repo's `-framing`-branch convention (`vterm-framing`, + `gpu-initial-target-framing`, `tab-width-parity-framing`). +- **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 (the dired view) is next and unstarted.** Branch `dired` + (worktree `../pmacs-dired-arc`) carries the framing commits only and is + based on the now-superseded `0827dd1`; **rebase it onto the `main` + resulting from the framing PR before implementing**, or cut a fresh + branch — its framing commits become redundant once the docs PR lands. +- Stage 1's scope, from the framing §10: `builtin/runtime/dired.lua`; the + `dired` major mode + mode keymap; buffer-per-directory with lexical + canonicalization and the ownership check; read-only intercept + + `set_round_trip_input`; visit routing through `window.display_file`; + parent/sort/revert/quit; `C-x d` (with the `display` opt) / `C-x C-j`; + cursor preservation by basename; the `dired.kill-when-opening` config + key; **and the tolerant `read_dir` opt** — the only Rust in the stage. +- The one Rust change is load-bearing and is why Stage 1 is not + pure-Lua: `read_dir_blocking` (`src/fs.rs:201`) fails the **entire + listing** on any of five per-entry conditions, and the tolerant wrapper + its own module doc delegates to package authors **cannot be written in + Lua** — the primitive returns one error and no partial vec. +- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` + §20 Priority 1, which names this work explicitly; journey steps 7 and + (partially) 3; **adds no interaction island** — keys are a mode-scoped + keymap, and wdired is 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. +- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut + 1): CLI directory-argument handling (`pmacs .` exits 1) belongs there, + not here. 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. + ## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW - Portable branch: `githubsucks/bottom-panel`, worktree diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index fbdf4df..a844230 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,7 +1,12 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-24, after GPU initial-target (#148, protocol v20) -landed, following 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 @@ -19,9 +24,55 @@ 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` @ `0dd16a5` (GPU initial-target #148 atop folding Stage 2 landed-doc +- `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. +- **GPU initial target LANDED — #148** + (`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review + rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path 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 = diff --git a/docs/dired-framing.md b/docs/dired-framing.md new file mode 100644 index 0000000..87b4573 --- /dev/null +++ b/docs/dired-framing.md @@ -0,0 +1,1209 @@ +# Dired — framing + +**Revision 5 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162.** +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. 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. + +## 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`. From ebcd2c4f6f083c68f711bf52fc199beb172c3092 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 14:24:59 -0400 Subject: [PATCH 16/27] =?UTF-8?q?fix(lsp):=20attribute=20a=20failing=20roo?= =?UTF-8?q?t=20resolver=20(COHERENCE=20=C2=A71.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COHERENCE.md §1.2 makes "a `pcall` around background wiring must log attributed failure, never discard it" a standing rule, and names `ensure_server`'s swallowed spawn failure as its canonical case — the exact function this branch modifies. Round 1 deferred the resolver's silent `pcall` as a Stage 3 concern. Under that rule it is not a deferral, it is a fresh instance of the named anti-pattern added by a PR touching the cited function, made worse by the memo: a raised error is buried permanently for that directory and never observed again. A resolver that raises, or returns a non-string non-nil, now leaves an attributed trace naming the language and the directory. Returning nil remains the documented decline and stays silent — pinned, so "report failures" cannot be satisfied by reporting every resolution. The report goes through `pmacs.editor.set_status`, NOT `pmacs.error`, and that choice is the finding: **`pmacs.error` does not exist.** Fifteen call sites across `async.lua` (5), `syntax.lua` (4), `lsp.lua`, `mcp.lua`, `fs.lua`, `editops.lua`, `autosave.lua`, and `commands/default.lua` report background failures through it, each guarded `if pmacs.error then ...`. It is defined nowhere in production; the only assignment in the tree is a test stub at `src/editor.rs:9881`, and `type(pmacs.error)` is nil in a fresh `EditorState` (probed, not inferred). `pmacs.errors` (plural) in compile.lua is an unrelated namespace. So all fifteen reports are dead, and the guard makes the silence look deliberate — which is why nobody noticed. Writing the test is what caught it: the first version of this fix used `pmacs.error` and its pin failed against a working implementation. Both bites recorded: dropping the report entirely fails the pin, and so does reporting ONLY through `pmacs.error` — the dead-channel variant this nearly shipped. Not fixed here, deliberately: defining `pmacs.error`, the fifteen dead sites, and surfacing the spawn failure itself. That last is Priority 1 work and a user-visible product behavior — what message, where, with what guidance — so it needs its own framing rather than being smuggled into an affinity PR. --- builtin/runtime/lsp.lua | 35 +++++++++++- tests/lsp_multi_root_acceptance.rs | 89 ++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 3aca1ac..6021134 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -540,7 +540,7 @@ end -- resolver can never serve a root the previous one computed. local root_resolver_memo = setmetatable({}, { __mode = "k" }) -local function resolve_root_fn(resolver, path) +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] @@ -555,7 +555,36 @@ local function resolve_root_fn(resolver, path) return hit or nil end local ok, resolved = pcall(resolver, path) - if not ok or type(resolved) ~= "string" then resolved = nil end + -- 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 @@ -570,7 +599,7 @@ local function project_root_for(language, path) end if not path then return nil, nil end if configured then - local resolved = resolve_root_fn(configured, path) + local resolved = resolve_root_fn(language, configured, path) if resolved then return resolved, "config" end end local ok, det = pcall(pmacs.project.detect, path) diff --git a/tests/lsp_multi_root_acceptance.rs b/tests/lsp_multi_root_acceptance.rs index 518c964..39ac68f 100644 --- a/tests/lsp_multi_root_acceptance.rs +++ b/tests/lsp_multi_root_acceptance.rs @@ -156,6 +156,10 @@ fn rows(state: &EditorState) -> Vec { } } +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") @@ -613,3 +617,88 @@ fn config_root_false_reads_as_unset_and_detection_still_wins() { "`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")) + ); +} From b5bea3b2f536d20ac5d7029cafe0796be7e54760 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 14:28:56 -0400 Subject: [PATCH 17/27] docs(coherence): record the dead reporting channel and #161's slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rides this PR per COHERENCE.md §25 ("when a PR changes any audited claim here, updating this file rides that PR"). §0 and §7: multi-root LSP affinity moves from in-flight-branch to PR #161, and §7 gains the rule the slice actually establishes — a *fallback* root is deliberately NOT an identity, so markerless files keep sharing one server per language. That is the part a reader would otherwise assume went the other way. §1.2 gains the finding this PR turned up, which sharpens the audit rather than restating it. The section recorded that background failures produce "no `*errors*` entry"; the sharper fact is that **the channel 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`, `commands/default.lua` — report through `pmacs.error`, each guarded `if pmacs.error then ...`. It is defined nowhere 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` (probed, not inferred). `pmacs.errors` plural in compile.lua is an unrelated namespace. All fifteen are dead, and the guard is what kept it unnoticed — it makes the silence read as deliberate. Hence the corollary now recorded beside the rule: report through a channel with a **test that observes it**, or the guard is indistinguishable from the silence it was meant to fix. Also a frequency note: per-root affinity makes the preconfigured-but-missing-server failure fire once per project root rather than once per language per session. Unchanged in kind, strictly more frequent. Surfacing it stays Priority 1 work needing its own framing — it is user-visible product behavior (what message, where, with what guidance), not a substrate fix to smuggle into an affinity PR. Line-number citations in the touched sections re-verified per §25; symbols are authoritative where they drifted. --- COHERENCE.md | 49 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 1594b69..954e621 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -120,9 +120,8 @@ asymmetry**, and **per-arc coherence debt**. Coherence-shaped work already in flight at audit time: find-file / dired Stage 0 (`C-x C-f`, PR #162, `docs/dired-framing.md`), bottom -panel Stage 1 (merged #155), multi-root LSP affinity (branch -`lsp-multi-root-affinity`), the config registry foundation (merged -#127). +panel Stage 1 (merged #155), multi-root LSP affinity (PR #161), the +config registry foundation (merged #127). --- @@ -230,9 +229,37 @@ 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. +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 @@ -726,12 +753,14 @@ per-subsystem conventions.** 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 in flight**: the multi-root LSP server-affinity work - (branch `lsp-multi-root-affinity`) makes *(language, found-root)* the - server identity — the first time a root functions as an identity key - rather than a spawn parameter. Note it is again per-subsystem: LSP - learns roots; compile, search, index, and trust do not share the - object. +- **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 From f71055a20656d4d09203bc7c4d6196646dfd8d9d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 14:54:58 -0400 Subject: [PATCH 18/27] feat(dired): the directory view (Stage 1) Dired is the file surface, not a rider on one: before Stage 0 (#162) pmacs had no way to open a file by path, and browsing is the half a user reaches for when they do not already know the path. Stage 1 ships the view. builtin/runtime/dired.lua: one buffer per directory named by the canonical path (Q#DR2) with an ownership check before any paint (F7); read-only intercept plus round-trip input (Q#DR3); a `dired` major mode carrying mode-scoped keys (Q#DR8) -- RET/f visit, ^ parent, n/p, g revert, q quit, s sort; cursor re-seated by basename across every wholesale repaint (Q#DR9); file visits through `pmacs.window.display_file` and directory descent through dired's own window (Q#DR10); `C-x d` / `C-x C-j`; and `dired.kill-when-opening` through the config registry. Two Rust changes, both narrow: * `read_dir` grows per-entry tolerance behind an opt (Q#DR6). Five per-entry conditions used to fail the entire listing, so a plain refresh of a busy directory could just fail; the module doc's claim that a tolerant wrapper was "the package's job" was false, because the primitive hands Lua one structured error and no partial vec. Per-entry readdir/lstat/readlink failures and non-UTF-8 symlink targets now land in an `errors` channel; parent-level failures and non-UTF-8 *names* stay fatal. The tolerance travels in the settled payload, so the Lua boundary keeps the bare-array shape the frozen M8.2 fixture consumes and never has to look the job back up. The read ops' opts parsing now rejects unknown keys, so a typo'd `tolerant` cannot silently degrade to the fatal contract. * `normalize_buffer_path` is exposed as `pmacs.path.canonicalize` rather than mirrored in Lua. Q#DR2 named exposure the preferred end state; it needs no borrow plumbing, so dired's name-dedup and `display_file`'s `find_buffer_for_path` dedup cannot fork, and the mirror's Stage 2 removal is not owed. tests/dired_acceptance.rs covers framing items 1-16 (22 tests), driven through real key dispatch. Item 17 is the m8_1/m8_2/m8_3 gate. One framing claim is corrected by the substrate: R2-3 expected a dedicated dired panel to carry its dedication across a descent, but `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; both arms are pinned. --- builtin/runtime/dired.lua | 832 +++++++++++++++++++++ builtin/runtime/fs.lua | 59 +- src/async_runtime.rs | 48 +- src/editor.rs | 11 + src/editor_core.rs | 9 +- src/fs.rs | 304 +++++++- src/lua_bindings/mod.rs | 105 ++- src/workers_buffer.rs | 14 +- tests/dired_acceptance.rs | 1469 +++++++++++++++++++++++++++++++++++++ 9 files changed, 2779 insertions(+), 72 deletions(-) create mode 100644 builtin/runtime/dired.lua create mode 100644 tests/dired_acceptance.rs diff --git a/builtin/runtime/dired.lua b/builtin/runtime/dired.lua new file mode 100644 index 0000000..78ef3b4 --- /dev/null +++ b/builtin/runtime/dired.lua @@ -0,0 +1,832 @@ +-- 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. Two consequences: errors must be `pcall`ed and reported +-- here (an uncaught raise inside `pmacs.async` goes to *errors*, not +-- the status line), and `pmacs.window.*` calls made after the await +-- act for the *ambient* active frontend, since interactive origin +-- does not survive the tick boundary. + +-- 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. +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 + +local function fmt_size(n) + return string.format("%" .. SIZE_BYTES .. "d", n) +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. +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 +-- --------------------------------------------------------------------------- + +local READ_ONLY_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, READ_ONLY_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`/`stat` are lstat-based, so the only way to learn + -- whether a link points at a directory is to try to list it. A + -- symlinked directory is an ordinary thing to walk into, and the + -- probe costs one syscall on symlink lines only. + pmacs.async(function() + local ok = pcall(function() + return pmacs.fs.read_dir(target, { tolerant = true }):await() + end) + if ok then + local descended, err = pcall(open_directory, target, nil, handle) + if not descended then report("dired", err) end + 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) + seat_cursor(handle, name, line) + 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/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/editor.rs b/src/editor.rs index 79f1225..5951f01 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 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..1767234 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -116,6 +116,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 +245,97 @@ 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(); 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. + record_entry_error( + &mut errors, + None, + FsError::Io { + path: parent_str.clone(), + source, + }, + )?; + continue; + } + }; 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 +346,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 +621,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 +653,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 +672,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 +764,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 +777,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 +890,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/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 29e3b47..4314e18 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,39 @@ 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. +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 +6513,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 +6638,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 +6859,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 +7014,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)?)) diff --git a/src/workers_buffer.rs b/src/workers_buffer.rs index 05e9a84..863f4ed 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([_, ..]) => format!( + "ok ({} entries, {} unreadable)", + listing.entries.len(), + listing.errors.as_ref().map_or(0, Vec::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..bcbd96f --- /dev/null +++ b/tests/dired_acceptance.rs @@ -0,0 +1,1469 @@ +// 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:?}" + ); +} + +// --------------------------------------------------------------------------- +// 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" + ); +} + +/// 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. +#[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" + ); +} + +// --------------------------------------------------------------------------- +// 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:?}" + ); +} From e7fa9e9720641d2a4f2d17575be77241e5eb1f9e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:03:19 -0400 Subject: [PATCH 19/27] test(dired): teach describe.key about mode scope; drop one overclaim `describe_key_identifies_every_default_binding` iterated every binding in the stack and asserted `pmacs.describe.key` resolves it context-free. That held only because no builtin had ever bound a mode-scoped key: dired is #129's first non-detection consumer, so its `n` / `p` / `g` correctly resolved to nothing and the test went red on the feature rather than on a defect. It now sets the effective context per binding -- the mode for a mode-scoped default, and explicitly NO mode for a global one, because a leaked mode legitimately shadows a global chord of the same name (dired's `RET` shadows `edit.newline-and-indent`, which is the point of the mode) and would make the assertion compare the wrong pair. A floor assertion keeps the new arm from going vacuous if the last mode-scoped default is ever removed. Also corrects a doc comment rather than leaving it to be believed: acceptance 3c does not pin the descent ROUTING. Dired holds focus in its own panel, so a raw `switch_buffer` lands in the same window and the mutation is vacuous against that test; dedication is what distinguishes the two paths, so the discriminating pin is the dedicated-panel test next to it. Verified by mutation, not assumed. --- src/editor.rs | 54 ++++++++++++++++++++++++++++++++++----- tests/dired_acceptance.rs | 8 ++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 5951f01..79444f4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -5659,17 +5659,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. @@ -5678,18 +5686,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/tests/dired_acceptance.rs b/tests/dired_acceptance.rs index bcbd96f..73b06f8 100644 --- a/tests/dired_acceptance.rs +++ b/tests/dired_acceptance.rs @@ -700,6 +700,14 @@ fn dired_canonicalization_is_the_cores_own_normalizer() { /// 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(); From 8b685dc127af1198b01722d1768b0302fa5400b1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:08:24 -0400 Subject: [PATCH 20/27] docs: record dired Stage 1 (PR #165) and what it falsified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/dired-framing.md rev 6: §0 gains the Stage 1 implementation notes (S1-1..S1-9) -- the normalizer is exposed rather than mirrored (so B2 is false by one small binding, in the direction Q#DR2 preferred); R2-3's dedication claim is falsified by the display policy; acceptance 3c cannot pin the descent routing and now says so; dired is the first builtin to bind a mode-scoped key, which one pre-existing lib test assumed impossible; `C-x d` takes no completion source on purpose; ownership is the handle table alone; the mark column ships blank; a symlinked directory needs a probe; and interactive origin does not survive an await. COHERENCE.md, per its §25 (an audited claim this PR changes updates here, riding the PR): §1.1's interactive-file-opening fact, §2's journey step 7, §4's beginner-level `files`, §14's tree bullet (Stage 1 landed a flat listing and did NOT invent a tree convention), and §15's Priority 1 list. Step 3 stays **Missing at the CLI** with the mechanism spelled out: `pmacs .` still exits 1, and this arc deliberately does not claim the CLI path -- it supplies the buffer a directory should resolve to. docs/active-work.md: the dired lane rewritten for Stage 1, including why the branch is a fresh cut rather than a rebase of `dired`, the durable substrate facts, the bite results (one VACUOUS, recorded rather than relabelled), and the verification. Its canonical-base line was four merges stale and now names 8c86d34. docs/agent-handoff.md: one forward pointer only. The handoff describes merged state, so it absorbs the substance when this merges. --- COHERENCE.md | 37 +++++++---- docs/active-work.md | 139 +++++++++++++++++++++++++++++++----------- docs/agent-handoff.md | 5 ++ docs/dired-framing.md | 94 +++++++++++++++++++++++++++- 4 files changed, 223 insertions(+), 52 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 1594b69..b1557e7 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -119,10 +119,10 @@ 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`, PR #162, `docs/dired-framing.md`), bottom -panel Stage 1 (merged #155), multi-root LSP affinity (branch -`lsp-multi-root-affinity`), the config registry foundation (merged -#127). +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 (branch `lsp-multi-root-affinity`), the config +registry foundation (merged #127). --- @@ -192,9 +192,13 @@ working, unreachable capability: 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 exists as a frozen test fixture - (`tests/fixtures/pmacs-dired/init.lua`). Being fixed now: dired Stage - 0 (PR #162). + 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 @@ -331,11 +335,11 @@ Full verdict table: |---|---|---|---| | 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime | | 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on | -| 3 | Open real project | **Missing** | `pmacs .` exits 1 (above). No directory handling anywhere | +| 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: missing → in flight (PR #162). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit; `M-.`/`M-?`/`C-c o` bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI | +| 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 | | 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 | @@ -428,7 +432,8 @@ level is the one missing. Audited level-by-level: **Beginner** (should see: files, buffers, search, diagnostics, terminal, build actions, menus, missing-tool guidance): -- files ✗ (no find-file at audit; PR #162 in flight) · buffers ✓ (`C-x +- 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 △ @@ -1139,7 +1144,11 @@ Primitive-by-primitive against the list above: hierarchy, package dependency graph, worker trees, git status) will each need it; building it once *before* dired's directory view and the workers tree harden their own conventions is exactly this - section's point. + 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 @@ -1346,8 +1355,10 @@ missing runtime entity — a real arc). 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; a find-file surface (in flight, -PR #162); surfacing the LSP spawn failure with guidance (§1.2); a +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. diff --git a/docs/active-work.md b/docs/active-work.md index 6dc235f..b83a1bc 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -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` @ `0dd16a5` (GPU initial-target #148 atop 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,7 +52,7 @@ git worktree list git status --short --branch ``` -The `git log` command must expose `0dd16a5` 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 IN REVIEW (PR #160) @@ -124,44 +125,108 @@ If it does not, stop and repair the remote/fetch configuration. or markerless scratch files fragment into one server per directory for every language. -## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next +## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) -- Approved framing: `docs/dired-framing.md` (revision 5), landing as its - own docs PR off `githubsucks/main` @ `2af1ab3`, branch - `githubsucks/dired-framing`, worktree `../pmacs-dired-framing`. The - repo's `-framing`-branch convention (`vterm-framing`, - `gpu-initial-target-framing`, `tab-width-parity-framing`). +- 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 (the dired view) is next and unstarted.** Branch `dired` - (worktree `../pmacs-dired-arc`) carries the framing commits only and is - based on the now-superseded `0827dd1`; **rebase it onto the `main` - resulting from the framing PR before implementing**, or cut a fresh - branch — its framing commits become redundant once the docs PR lands. -- Stage 1's scope, from the framing §10: `builtin/runtime/dired.lua`; the - `dired` major mode + mode keymap; buffer-per-directory with lexical - canonicalization and the ownership check; read-only intercept + - `set_round_trip_input`; visit routing through `window.display_file`; - parent/sort/revert/quit; `C-x d` (with the `display` opt) / `C-x C-j`; - cursor preservation by basename; the `dired.kill-when-opening` config - key; **and the tolerant `read_dir` opt** — the only Rust in the stage. -- The one Rust change is load-bearing and is why Stage 1 is not - pure-Lua: `read_dir_blocking` (`src/fs.rs:201`) fails the **entire - listing** on any of five per-entry conditions, and the tolerant wrapper - its own module doc delegates to package authors **cannot be written in - Lua** — the primitive returns one error and no partial vec. -- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` - §20 Priority 1, which names this work explicitly; journey steps 7 and - (partially) 3; **adds no interaction island** — keys are a mode-scoped - keymap, and wdired is 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. +- **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. +- Verification on this branch: `cargo fmt --check` clean; strict workspace + Clippy clean; 1,829 default + 2,006 CRDT library tests; dired acceptance + 22 default + 22 CRDT; m8_1 10 / m8_2 15 / m8_3 32 unchanged; M4 121; + required GPU 155; **isolated-`XDG_CONFIG_HOME` workspace sweep 3,186 + passed across 92 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. 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. + 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. ## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index a844230..fe51463 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -70,6 +70,11 @@ commands, read `docs/active-work.md` immediately after this file. against an open buffer yet fails to load one that is not open — find-file expands the tilde Lua-side. Loading through the normalized path is a named deferral. + - **Stage 1 (the directory view) is IN REVIEW as PR #165** — the + builtin `dired.lua`, the per-entry-tolerant `read_dir` opt, and + `pmacs.path.canonicalize`. Its branch state, substrate facts, and + verification live in `docs/active-work.md`; this section absorbs them + when it merges. - **GPU initial target LANDED — #148** (`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path diff --git a/docs/dired-framing.md b/docs/dired-framing.md index 87b4573..3f6b8a5 100644 --- a/docs/dired-framing.md +++ b/docs/dired-framing.md @@ -1,11 +1,13 @@ # Dired — framing -**Revision 5 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162.** +**Revision 6 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162; +Stage 1 IN REVIEW as PR #165.** 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. Deliberately +(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5; rev 6 records what Stage +1's implementation falsified (§0, S1-1…S1-9). 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 @@ -203,6 +205,94 @@ the correction belongs here rather than only in the code. 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. + ## 0.5. Coherence impact (`COHERENCE.md` §20) Required of every framing since #163. This arc was scouted and approved From 9a0df216184729eaad7374cf9840f339941edd05 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:09:07 -0400 Subject: [PATCH 21/27] docs: frame the GPU terminal double layout-sync defect Reported symptom: text input in a terminal does not work on the GPU frontend while the TUI is fine. Root cause, measured against a real daemon, a real PTY child, and the real pmacs-gpu attach client: the dispatcher loop applies BOTH terminal-layout syncs to EVERY attached frontend each tick. A semantic session satisfies both conditions -- it has a term_sizes entry from AttachRequest and a semantic terminal declaration -- so the PTY is resized twice per tick forever, because the grid arm runs the TUI placement helper that subtracts a modeline the GPU never draws. The result is a SIGWINCH storm plus screen reflow at tick cadence, which makes an interactive line editor unusable while child output keeps flowing. Measured 730 terminal frames in 20 s for a static screen. Revision 2 answers Q#GT4 from the code rather than deferring it, which changes the fix from a one-line guard into a split of sync_terminal_layout: the grid arm is also the only per-tick controller-liveness release a GPU session gets, and the semantic arm neither performs it nor can be made to, because the buffer-follow snapshot clears the viewport declaration that would drive it. The doc also records three falsified hypotheses, why the Stage 3 real-path acceptance passes on the broken tree, and a COHERENCE.md section 6 citation error (two replica frontends have two different optimistic classifiers; the audit names only one). No code changes in this commit. --- docs/gpu-terminal-input-framing.md | 415 +++++++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 docs/gpu-terminal-input-framing.md diff --git a/docs/gpu-terminal-input-framing.md b/docs/gpu-terminal-input-framing.md new file mode 100644 index 0000000..a383c14 --- /dev/null +++ b/docs/gpu-terminal-input-framing.md @@ -0,0 +1,415 @@ +# 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. +- `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 guard as well as against `main`. +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: reverting the split fails 2, 3, 6 and 7 specifically. + Criteria 1 and 7 fail against `main`; criterion 6 fails against **both** + `main` and revision 1's guard, which is the point of keeping B1's + half-false score on the record. + +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. From 531fdf404ed0c082563065763b7d5db9372c0fd5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:21:26 -0400 Subject: [PATCH 22/27] fix(dired): address PR #165 review round 1 F1 (real, small-window misbehavior). `dired.revert`'s re-seat runs after the read settles, and `pmacs.editor.move_to_line` is AMBIENT -- it moves whatever window is active. A user who switched buffers (or hit `q`) while the re-read was in flight had an unrelated buffer's cursor moved to a line index that only means something in the dired listing. The paint was already safe because it names its buffer; the seat now runs only while dired is still the active buffer, and `seat_cursor`'s doc says which callers are unconditionally in the right place and why. Pinned by a test that starts the revert, switches to a six-line file before the pump, and asserts that buffer's cursor never moved -- and that the dired buffer still reverts when it IS active. F2 (a trap set for Stage 3). `fmt_size` used `%10d`, so a size past ten digits -- 10 GB and up, ordinary for VM images and core dumps -- widened the field and shifted mtime and name right on that line alone. Cosmetic today, but `_layout` is exported as a contract and Stage 3's column-classifying intercept is planned against it. It now takes `fmt_mtime`'s discipline: exact bytes while they fit, else a fixed-width magnitude, so precision yields to the invariant rather than the other way round. This is not the deferred human-readable column -- the exact count still shows right up to where it cannot. Pinned with a sparse 12 GB fixture that skips if the filesystem refuses it. F3 (honesty and a doubled read). The symlink arm claimed the probe cost "one syscall"; it was a full `read_dir` -- opendir plus one lstat per child -- and on success `open_directory` immediately read the same directory again. Since `open_directory` reads before touching editor state and raises having changed nothing (acceptance 15's invariant), its failure IS the "not a directory" answer: the probe is gone, one read remains, and the comment says what it actually does. New test pins both arms -- a symlink to a directory descends under the path the user walked (canonicalization is lexical, so the link is not resolved), and a symlink to a file opens with the target's contents. F4 (deliberate failure mode). A tolerant listing recorded readdir iterator errors without bound, and `std::fs::ReadDir` need not terminate after yielding one. Cancellation is NOT an adequate backstop here -- which is the reason for a constant rather than a comment saying it is: a dired listing carries no supersede key, so nothing cancels it. A directory whose iterator produces nothing but errors now fails with the last error the way an unopenable directory does, after READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS; the counter resets on any entry that materializes. Documented as untested and why: faking a failing iterator needs the walk generic over it, a refactor with no other consumer. Smaller notes, all taken: READ_ONLY_LIMIT renamed NAME_VARIANT_LIMIT (it caps `<2>`..`<99>`, nothing read-only); `fmt_perms`' omission of setuid/setgid/sticky documented as a decision tied to the M8.3 fixture's nine-bit parser; `format_outcome` binds the slice in the pattern instead of re-traversing; and `pmacs.path.canonicalize`'s `to_string_lossy` is noted as inside the existing non-UTF-8-path deferral rather than an exception to it. Process note, learned the hard way twice now: the round-1 dired.lua fixes were briefly wiped because a mutation-bite helper restores with `git checkout --`, which reverts to HEAD -- so a fix must be committed BEFORE it is bitten, not after. --- builtin/runtime/dired.lua | 78 +++++++++++++---- src/fs.rs | 41 +++++++-- src/lua_bindings/mod.rs | 7 ++ src/workers_buffer.rs | 4 +- tests/dired_acceptance.rs | 179 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 26 deletions(-) diff --git a/builtin/runtime/dired.lua b/builtin/runtime/dired.lua index 78ef3b4..ceaf3d5 100644 --- a/builtin/runtime/dired.lua +++ b/builtin/runtime/dired.lua @@ -224,6 +224,11 @@ end -- `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 "-" @@ -244,8 +249,33 @@ local function kind_char(kind) 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) - return string.format("%" .. SIZE_BYTES .. "d", 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) @@ -348,6 +378,13 @@ 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 @@ -416,7 +453,8 @@ end -- Buffer ownership -- --------------------------------------------------------------------------- -local READ_ONLY_LIMIT = 99 +-- 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 @@ -435,7 +473,7 @@ local function claim_handle(path) local name = buffer_name(path) if buffer_named(name) then local unique = nil - for i = 2, READ_ONLY_LIMIT do + for i = 2, NAME_VARIANT_LIMIT do local candidate = string.format("%s<%d>", name, i) if buffer_named(candidate) == nil then unique = candidate @@ -680,19 +718,20 @@ pmacs.command.define { return end if entry.kind == "symlink" then - -- `read_dir`/`stat` are lstat-based, so the only way to learn - -- whether a link points at a directory is to try to list it. A - -- symlinked directory is an ordinary thing to walk into, and the - -- probe costs one syscall on symlink lines only. + -- `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 ok = pcall(function() - return pmacs.fs.read_dir(target, { tolerant = true }):await() - end) - if ok then - local descended, err = pcall(open_directory, target, nil, handle) - if not descended then report("dired", err) end - return - end + 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) @@ -742,7 +781,14 @@ pmacs.command.define { handle.entries = entries handle.errors = errors paint(handle) - seat_cursor(handle, name, line) + -- 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, } diff --git a/src/fs.rs b/src/fs.rs index 1767234..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` @@ -277,6 +299,7 @@ pub fn read_dir_blocking( 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); @@ -286,17 +309,19 @@ pub fn read_dir_blocking( Err(source) => { // R2-2: the entry never materialized, so there is no // name to report and the error names the parent. - record_entry_error( - &mut errors, - None, - FsError::Io { - path: parent_str.clone(), - source, - }, - )?; + 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(); // Resolved first so a later per-entry failure can name it. let name = path_to_utf8_string(&entry.file_name(), &parent_str)?; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 4314e18..d689f3f 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -3574,6 +3574,13 @@ impl UserData for AnsiParserLua { /// 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( diff --git a/src/workers_buffer.rs b/src/workers_buffer.rs index 863f4ed..6a6eeb4 100644 --- a/src/workers_buffer.rs +++ b/src/workers_buffer.rs @@ -207,10 +207,10 @@ fn format_outcome(outcome: &JobOutcome) -> String { // tolerant listing that dropped half a directory is not the // same observable outcome as a clean one. match listing.errors.as_deref() { - Some([_, ..]) => format!( + Some(errors @ [_, ..]) => format!( "ok ({} entries, {} unreadable)", listing.entries.len(), - listing.errors.as_ref().map_or(0, Vec::len) + errors.len() ), _ => format!("ok ({} entries)", listing.entries.len()), } diff --git a/tests/dired_acceptance.rs b/tests/dired_acceptance.rs index 73b06f8..7d0f2c5 100644 --- a/tests/dired_acceptance.rs +++ b/tests/dired_acceptance.rs @@ -417,6 +417,75 @@ fn dired_renders_a_header_and_one_line_per_entry() { ); } +/// 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 // --------------------------------------------------------------------------- @@ -474,6 +543,54 @@ fn dired_visit_dispatches_on_entry_kind() { ); } +/// 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 @@ -1018,6 +1135,68 @@ fn dired_revert_reseats_the_cursor_by_basename() { ); } +/// 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 // --------------------------------------------------------------------------- From b775f1703a46db84c6036db8bc5b3e987a133c43 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:22:04 -0400 Subject: [PATCH 23/27] fix(daemon): stop resizing a semantic frontend's PTY twice per tick The dispatcher loop applied BOTH terminal-layout syncs to EVERY attached frontend. A semantic session satisfies both conditions, because it has a term_sizes entry from AttachRequest and a semantic terminal declaration, so its PTY was resized twice on every 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 only ever saw the size the other had just written. The child took a SIGWINCH storm at tick cadence and the screen reflowed continuously, which is what made typing into a GPU terminal impossible while output kept flowing. The grid arm is also the only per-tick controller-liveness release a semantic frontend gets, so simply skipping it for those frontends trades one defect for another: a GPU window that switches away from its terminal would hold the controller forever, and no peer could resize that PTY again. The semantic arm cannot take over that job, because the buffer-follow snapshot clears the viewport declaration that would drive it. sync_terminal_layout is therefore split into a frontend-kind-neutral half (panel reconciliation plus controller liveness, which read only views, windows and the controller) and a grid-only geometry half (TUI placement plus the resize). The dispatcher runs the neutral half for every attached frontend once per tick, then exactly one geometry arm per frontend kind. sync_terminal_layout survives as the composition of both halves, so the in-process editor loop and LOCAL are unchanged. The loop body is extracted into sync_terminal_layouts_for_tick, which makes the grid/semantic exclusivity structural rather than two adjacent ifs, and lets the tests drive the real loop body instead of a re-implementation. The release that fires when a window has no placement stays in the grid half deliberately: a semantic frontend has no window_placements entry at all, so moving it into the neutral half would release a GPU session's controller on every tick. Bite-verified against two pre-images, because one is not enough here -- the naive guard fixes the storm and introduces the controller leak, so a single revert would score the fix complete when it is not: pin main naive guard 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 acceptance: a quiet child that counts 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. Acceptance 4 (input reaches the child and returns) is a keep-working pin and passes on both sides -- key transport was never the defect. No protocol change; stays v20. --- docs/gpu-terminal-input-framing.md | 33 +++- pmacs-gpu/src/main.rs | 41 +++- src/daemon.rs | 297 +++++++++++++++++++++++++++-- src/editor.rs | 92 +++++++-- tests/vterm_stage3_acceptance.rs | 225 ++++++++++++++++++++++ 5 files changed, 644 insertions(+), 44 deletions(-) diff --git a/docs/gpu-terminal-input-framing.md b/docs/gpu-terminal-input-framing.md index a383c14..0bbccef 100644 --- a/docs/gpu-terminal-input-framing.md +++ b/docs/gpu-terminal-input-framing.md @@ -295,6 +295,16 @@ change. Stays v20. ## 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. @@ -349,16 +359,29 @@ mutation (`src/terminal/screen.rs:1467`), so with a quiet child it is a 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 guard as well as against `main`. + 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: reverting the split fails 2, 3, 6 and 7 specifically. - Criteria 1 and 7 fail against `main`; criterion 6 fails against **both** - `main` and revision 1's guard, which is the point of keeping B1's - half-false score on the record. +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. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 6372189..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(); 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..d4321b5 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1189,14 +1189,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 +1276,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; }; 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() + ); +} From 14881b26c079588a50ce9080309aa02feacac4ab Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:24:25 -0400 Subject: [PATCH 24/27] docs: record dired Stage 1 review round 1 Framing rev 7 adds S1-10..S1-12 -- the three findings that changed behavior, each stated as the durable lesson rather than as a diff: painting takes a buffer and seating takes the world, so any post-await cursor operation needs an active-buffer guard; the rendered columns are a contract Stage 3 is planned against, so precision yields to width; and `open_directory`'s changed-nothing-on-failure invariant is itself a probe, which is why the symlink descent no longer lists the target twice. Plus the tolerant-channel note: cancellation was never a backstop for a dired listing, because nothing cancels one. The ledger records the round, the updated counts (dired 25 + 25 CRDT, sweep 3,189 across 92), and the process lesson that cost me the fixes once: a mutation-bite helper restores with `git checkout --`, so a fix must be committed before it is bitten. --- docs/active-work.md | 20 ++++++++++++++++-- docs/dired-framing.md | 48 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index b83a1bc..2897d07 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -206,10 +206,26 @@ If it does not, stop and repair the remote/fetch configuration. 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. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,829 default + 2,006 CRDT library tests; dired acceptance - 22 default + 22 CRDT; m8_1 10 / m8_2 15 / m8_3 32 unchanged; M4 121; - required GPU 155; **isolated-`XDG_CONFIG_HOME` workspace sweep 3,186 + **25 default + 25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; M4 121; + required GPU 155; **isolated-`XDG_CONFIG_HOME` workspace sweep 3,189 passed across 92 suites, zero failures**; `git diff --check` clean. The sweep needs the isolated config for the reason recorded in the bottom-panel lane below. diff --git a/docs/dired-framing.md b/docs/dired-framing.md index 3f6b8a5..ada853e 100644 --- a/docs/dired-framing.md +++ b/docs/dired-framing.md @@ -1,13 +1,14 @@ # Dired — framing -**Revision 6 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162; -Stage 1 IN REVIEW as PR #165.** +**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). Deliberately +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 @@ -293,6 +294,47 @@ in the code, per the rev-4 precedent. 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 From adf90e535185b5920846e0a3fba3effe629bf9fa Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:28:47 -0400 Subject: [PATCH 25/27] docs: record the GPU terminal input fix in the three required docs COHERENCE.md section 25 and the handoff/ledger update protocols make these ride the PR. COHERENCE.md: - Section 6 named one optimistic key classifier and attributed it to the GPU. There are two, one per replica frontend: crate::optimistic::classify_key belongs to the pmacs --attach TUI replica, and pmacs-gpu has its own unrelated optimistic_insert_text / optimistic_crdt_insert. The section's "kept honest by dispatch_idle_for" claim is confirmed for both, which this investigation verified rather than assumed. - Section 16 graded per-frontend degradation strong on the evidence of per-frontend fold projection. That grade stands, but the practice is enforced by convention rather than structure, and this defect is the counter-example; the note says so and points at what is now structural. - Section 2 step 8 records that the terminal was broken outright on the GPU frontend, not merely undiscoverable. docs/agent-handoff.md section 5 gains four lessons: adjacency does not make two operations alternatives (and two individually sound idempotence guards can be jointly useless); bite against every pre-image the fix could have taken, since the obvious guard here fixes the storm and introduces a controller leak; a quiet child is an instrument, because a frame storm hides inside a chatty fixture and a geometric readout is satisfied by an oscillating geometry; and TerminalMode::Raw makes sh-based input fixtures useless because there is no ICRNL. docs/active-work.md gains the lane entry with the branch, the bite matrix, the named out-of-scope items, and the gate results. --- COHERENCE.md | 25 +++++++++++++++++---- docs/active-work.md | 52 +++++++++++++++++++++++++++++++++++++++++++ docs/agent-handoff.md | 36 ++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 954e621..dfe9cfd 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -363,7 +363,7 @@ Full verdict table: | 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config | | 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | | 7 | Find symbol / file | **File: missing → in flight (PR #162). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit; `M-.`/`M-?`/`C-c o` 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 | +| 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) | @@ -671,9 +671,18 @@ Facts that define the gap: 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 GPU **optimistic key classifier** - (`crate::optimistic::classify_key`) — the latter is classification, - not routing, and is kept honest by `dispatch_idle_for`. + `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, @@ -1245,6 +1254,14 @@ its asks are already practiced.** 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. diff --git a/docs/active-work.md b/docs/active-work.md index b426f38..1cff0f2 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -227,6 +227,58 @@ If it does not, stop and repair the remote/fetch configuration. 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 (window placement + side windows) — Stage 1 IN REVIEW - Portable branch: `githubsucks/bottom-panel`, worktree diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index a844230..d1f4943 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -704,6 +704,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 From 08e2807fcca236fdff5f764eb91cc5963a59adf3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 16:14:06 -0400 Subject: [PATCH 26/27] fix(dired): correct the reporting-channel claim #161 falsified The module doc said an uncaught raise inside a `pmacs.async` coroutine "goes to *errors*, not the status line". #161's COHERENCE finding shows that is wrong, and in the worse direction: `pmacs.error` is never defined in production, so `step()`'s guarded report is dead and the raise falls through to a bare `error()` inside `pmacs._async.tick()` -- whose result `EditorState::tick_async` discards with `let _ =`. The failure reaches nowhere at all, and dired would look like it silently did nothing. So the per-coroutine `pcall` plus `pmacs.editor.set_status` is load-bearing, not tidy, and the doc now says which channel is dead, which is live, and that the acceptance suite observes the live one -- the corollary COHERENCE draws from that finding. The ledger records the integration, the reruns on the merged tree, and the ops lesson that cost three CI runs: a conflicting PR has no merge ref, so GitHub creates no `pull_request` run and nothing reports the absence. --- builtin/runtime/dired.lua | 27 ++++++++++++++++++++++----- docs/active-work.md | 33 ++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/builtin/runtime/dired.lua b/builtin/runtime/dired.lua index ceaf3d5..9c6bc92 100644 --- a/builtin/runtime/dired.lua +++ b/builtin/runtime/dired.lua @@ -48,11 +48,28 @@ -- 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. Two consequences: errors must be `pcall`ed and reported --- here (an uncaught raise inside `pmacs.async` goes to *errors*, not --- the status line), and `pmacs.window.*` calls made after the await --- act for the *ambient* active frontend, since interactive origin --- does not survive the tick boundary. +-- 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 diff --git a/docs/active-work.md b/docs/active-work.md index 565375b..76a65fd 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -286,13 +286,32 @@ If it does not, stop and repair the remote/fetch configuration. 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. -- Verification on this branch: `cargo fmt --check` clean; strict workspace - Clippy clean; 1,829 default + 2,006 CRDT library tests; dired acceptance - **25 default + 25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; M4 121; - required GPU 155; **isolated-`XDG_CONFIG_HOME` workspace sweep 3,189 - passed across 92 suites, zero failures**; `git diff --check` clean. The - sweep needs the isolated config for the reason recorded in the - bottom-panel lane below. +- **Canonical main integrated at `46a1b8f`** (multi-root LSP affinity + #161), merged rather than rebased per the #135/#137 precedent so the + review anchors stay addressable. Two 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. +- Verification on the merged tree: `cargo fmt --check` clean; strict + workspace Clippy clean; 1,829 default + 2,006 CRDT library tests; dired + acceptance **25 default + 25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 + unchanged; multi-root 13 (main's new suite, green under this lane's + `mod.rs` changes); M4 121; required GPU 155; + **isolated-`XDG_CONFIG_HOME` workspace sweep 3,202 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 From b3c8230a84be37cd1a1aecc91f31b2e9477246dc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 16:44:57 -0400 Subject: [PATCH 27/27] docs: record the second main integration and its gate rerun Main advanced twice inside one review round (#161, then #166), the second landing while the first integration's sweep was still running. The ledger now names both integrations, how each doc conflict was resolved, and the verification numbers for the twice-merged tree -- plus the lesson that a lane in review against a fast-moving main reruns its gates per integration, not per push. --- docs/active-work.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 608d527..c934c30 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -286,9 +286,13 @@ If it does not, stop and repair the remote/fetch configuration. 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 at `46a1b8f`** (multi-root LSP affinity - #161), merged rather than rebased per the #135/#137 precedent so the - review anchors stay addressable. Two things worth carrying: +- **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 @@ -303,12 +307,18 @@ If it does not, stop and repair the remote/fetch configuration. `let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` + `set_status` load-bearing rather than tidy, and the comment now says so. -- Verification on the merged tree: `cargo fmt --check` clean; strict - workspace Clippy clean; 1,829 default + 2,006 CRDT library tests; dired - acceptance **25 default + 25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 - unchanged; multi-root 13 (main's new suite, green under this lane's - `mod.rs` changes); M4 121; required GPU 155; - **isolated-`XDG_CONFIG_HOME` workspace sweep 3,202 passed across 93 + - **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.