Merge canonical main into the bottom-panel landed-doc lane

`main` moved through #158-#166 (Lean 4 Stage 2, COHERENCE.md, find-file,
the dired framing and Stage 1, the GPU terminal input fix) while this
documentation branch waited. Both required docs conflicted; neither
conflict was a code signal.

Resolution:

- `docs/active-work.md`: main's ledger is the base — every lane it has
  gained since this branch was cut is kept verbatim. Only the
  bottom-panel lane is replaced with this branch's "Stage 1 MERGED;
  Stage 2 (GPU band) is next" section, and only the bottom-panel entry
  is added to "Closed since the last snapshot".
- `docs/agent-handoff.md`: main's version is the base. This branch's §1
  bottom-panel bullet, its §1 roadmap Arc 7 entry (which also records
  that DAP is now unblocked), and its four §5 ops lessons are inserted
  at their anchors.

One repair rides along. Main's `docs/agent-handoff.md` carried a
garbled fragment at §1: a duplicated, truncated "GPU initial target
LANDED — #148" bullet whose body was the tail of the old head-of-`main`
anchor bullet, leaving the `SUPPORTED=[6..=20]` protocol enumeration
orphaned mid-sentence. The fragment is removed and the enumeration is
restored as its own bullet.

No code changes; the merged tree's non-doc content is main's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-25 17:47:51 -04:00
commit a72ce72349
33 changed files with 11339 additions and 167 deletions

View File

@ -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;

View File

@ -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;

1605
COHERENCE.md Normal file

File diff suppressed because it is too large Load Diff

33
Cargo.lock generated
View File

@ -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",

View File

@ -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).

View File

@ -611,6 +611,129 @@ cmd { name = "editor.switch-buffer",
}
end }
-- find-file (dired arc Stage 0; docs/dired-framing.md Q#DR11) ----------------
--
-- Until now pmacs had no discoverable way to open a file by path: a file
-- entered a session only from the CLI, an LSP jump, a project-search
-- visit, or `C-x C-r` (whose prompt does pass free text through, but
-- completes only over the recent list). This is that surface.
--
-- Two substrate facts shape it, and both are load-bearing:
--
-- 1. COMPLETION IS FLAT. `source = "files"` lists ONE directory and
-- yields bare basenames (`minibuffer.rs` `list_directory`), capped at
-- the shared candidate limit. A custom function source could not do
-- better: sources are called with NO arguments, so a callback cannot
-- see the input to re-root on, and it runs synchronously outside any
-- coroutine, where `Handle:await()` raises --- so it cannot list a
-- directory either. Hierarchical completion is a named Rust change in
-- the framing, not something this command can fake.
--
-- 2. A SELECTED CANDIDATE SHADOWS TYPED TEXT. `recompute_candidates`
-- sets `selected = Some(0)` whenever the candidate list is non-empty,
-- and `resolve_accepted_value` returns the CANDIDATE whenever
-- anything is selected. So `on_accept` receives typed text only when
-- the input filters every candidate away --- which, since candidates
-- are basenames and the filter is a subsequence match, is exactly
-- when the input contains a `/`. That makes the deeper-path case work
-- (`sub/inner.txt` matches no basename, so it arrives verbatim) and
-- leaves TWO documented consequences, each pinned by a test rather
-- than left to be rediscovered:
--
-- (a) typing a NEW bare name that happens to be a subsequence of an
-- existing entry opens the existing file instead of creating the
-- new one --- `find_file_selected_candidate_shadows_typed_text`.
-- A new bare name that matches nothing is unaffected and creates
-- normally (`find_file_bare_new_name_creates_in_the_root`).
-- (b) accepting on EMPTY input opens the first candidate in sort
-- order. `fuzzy_score` returns `Some(0)` for an empty needle, so
-- everything ties and `filter_and_sort` falls back to
-- lexicographic order --- which puts dotfiles first, and can put
-- a DIRECTORY first, in which case the open fails and reports.
-- This is the same mechanism `M-x` and `switch-buffer` already
-- have, so it is inherited rather than introduced; it is recorded
-- as decided, not overlooked, and listed in the framing's
-- deferrals beside the accept-semantics fix that would close it.
--
-- The root is the active buffer's directory, or the process cwd when the
-- buffer has no backing path (`source_root` defaults to "." Rust-side,
-- so the nil case needs no special handling here). It appears in the
-- prompt because the field itself must stay empty: any prefill would
-- contain a `/` and filter every candidate away, killing completion.
-- Directory part of a path. "/a/b" -> "/a"; "/a" -> "/"; "a" -> nil.
local function find_file_dirname(path)
local dir = path:match("^(.*)/[^/]*$")
if dir == nil then return nil end
if dir == "" then return "/" end
return dir
end
-- Expand a leading `~` component using $HOME: `~` -> $HOME, `~/x` ->
-- $HOME/x. `~user` is left alone (no passwd lookup), matching the core's
-- own `expand_tilde`.
--
-- This has to happen HERE, before the path reaches the core, because
-- `get_or_load_buffer` normalizes the path it STORES but loads from the
-- raw one --- so a `~/...` path deduplicates against an already-open
-- buffer yet fails to load when the file is not open yet. Expanding up
-- front makes both halves agree.
local function find_file_expand_tilde(path)
local home = os.getenv("HOME")
if home == nil or home == "" then return path end
if home:sub(-1) == "/" then home = home:sub(1, -2) end
if path == "~" then return home end
local rest = path:match("^~/(.*)$")
if rest == nil then return path end
return home .. "/" .. rest
end
-- Turn an accepted value into a path. The value is either a bare
-- basename (a selected candidate) or whatever the user typed, so a
-- non-absolute value joins onto the prompt's root --- which resolves
-- both cases to the same file when they name the same one.
local function find_file_resolve(root, value)
local path = find_file_expand_tilde(value)
if path:sub(1, 1) == "/" then return path end
local base = root or "."
if base:sub(-1) == "/" then return base .. path end
return base .. "/" .. path
end
-- The active buffer's directory, or nil when it has no backing path.
local function find_file_root()
local buf = pmacs.window.buffer()
if buf == nil then return nil end
local ok, path = pcall(function() return buf:path() end)
if not (ok and path) then return nil end
return find_file_dirname(path)
end
cmd { name = "find-file",
description = "Open a file by path, completing within one directory.",
fn = function()
local root = find_file_root()
pmacs.minibuffer.read {
prompt = "Find file (" .. (root or ".") .. "): ",
source = "files",
source_root = root,
history = "find-file",
on_accept = function(value)
if value == nil or value == "" then return end
local path = find_file_resolve(root, value)
-- A path that does not exist yet CREATES a buffer bound to
-- it: `display_file` routes through `resolve_target_buffer`,
-- which on NotFound creates, binds, and sets "[new file]".
-- That is Emacs parity and deliberate, so only a real
-- failure (a directory, a permission error) reaches here.
local ok, err = pcall(pmacs.window.display_file, path, { select = true })
if not ok then
pmacs.editor.set_status("find-file: " .. tostring(err))
end
end,
}
end }
-- Command palette (M-x) ------------------------------------------------------
--
-- Opens the minibuffer with a "commands" completion source, then

View File

@ -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 <right>", "editor.next-buffer")

View File

@ -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

895
builtin/runtime/dired.lua Normal file
View File

@ -0,0 +1,895 @@
-- dired.lua --- the directory view (dired arc Stage 1).
--
-- Dired is not a convenience rider on an existing file surface: until
-- Stage 0 (`C-x C-f`, #162) there was no way to open a file by path at
-- all, and browsing is the half a user reaches for when they do NOT
-- already know the path. So this is a primary surface, and the one
-- thing it may never do is refuse to render a listing --- hence the
-- per-entry-tolerant `read_dir` opt it drives (Q#DR6), the only Rust
-- this stage needed besides exposing the path normalizer.
--
-- Framing: docs/dired-framing.md (Q#DR1-DR10). Stage 1 is the view:
-- listing, navigation, sort, revert, quit. Marks and operations are
-- Stage 2; the editable wdired layer is Stage 3.
--
-- Public surface:
--
-- pmacs.dired.open(path [, opts]) -- awaits; run inside pmacs.async
-- opts.display = "current" | "panel" (Q#BP11b, default "current")
-- opts.select_name = "<basename>" -- 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 (<down> / <up> 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:<canonical path>*`
-- (Q#DR2). Navigation *opens the target's buffer*; it never mutates
-- the current one. That is Emacs behavior, and it is also the only
-- way to keep the name honest --- there is no
-- `pmacs.buffer.set_name`, so the M8.2 fixture's in-place repaint
-- leaves a buffer named after a directory it no longer shows.
--
-- 2. THE CANONICAL FORM IS THE CORE'S, not a copy of it
-- (`pmacs.path.canonicalize` is `normalize_buffer_path` itself).
-- Dired's name-dedup and `display_file`'s `find_buffer_for_path`
-- dedup have to agree; two implementations that disagree on `//tmp`
-- or a `..` at root would mint two buffers for one directory with no
-- error anywhere.
--
-- 3. EVERY LISTING IS ASYNC. `pmacs.fs.read_dir` is worker-dispatched,
-- so each command spawns a coroutine and the work after the first
-- `:await()` resumes on a later tick --- outside interactive
-- dispatch. Three consequences:
--
-- * Errors MUST be `pcall`ed and reported here, and that is
-- load-bearing rather than tidy. An uncaught raise inside a
-- `pmacs.async` coroutine reaches `step()`, which reports through
-- `pmacs.error` --- a channel that **is never defined in
-- production** (`COHERENCE.md` §1.1) --- and so falls through to a
-- bare `error()` inside `pmacs._async.tick()`, whose result
-- `EditorState::tick_async` discards with `let _ =`. The failure
-- would not reach the status line, the `*errors*` buffer, or a log:
-- it would reach nowhere, and dired would look like it silently did
-- nothing.
-- * Reporting therefore goes through `pmacs.editor.set_status`, which
-- exists and which the acceptance suite observes --- the corollary
-- COHERENCE draws from that dead channel: report through a surface
-- a test can see, or the guard is indistinguishable from the
-- silence it was meant to fix.
-- * `pmacs.window.*` calls made after the await act for the *ambient*
-- active frontend, since interactive origin does not survive the
-- tick boundary; and `pmacs.editor.move_to_line` acts on the
-- ambient *buffer*, which is why every post-await re-seat is
-- guarded (see `seat_cursor`).
-- Emacs 28's dired-kill-when-opening-new-dired-buffer, as a setting
-- rather than a hardcoded policy: buffer-per-directory accumulates
-- buffers when walking a deep tree, and Emacs users differ on whether
-- that is a feature.
pmacs.config.define {
name = "dired.kill-when-opening",
description = "Kill the dired buffer being left when descending or ascending.",
type = "boolean",
default = false,
mutability = "live",
}
-- ---------------------------------------------------------------------------
-- Layout
-- ---------------------------------------------------------------------------
--
-- The mark column is column 0 (Q#DR4), so every other column sits two
-- bytes right of the M8.2 fixture's offsets. Stage 1 always renders it
-- blank: filling it in is Stage 2's job, but reserving it now means
-- Stage 2 does not have to move every column, and Stage 3's
-- column-classifying intercept can be written against constants that
-- did not shift under it. Offsets are computed from the widths for the
-- same reason --- the fixture hardcoded `NAME_START = 39` and paid for
-- it in every wdired test.
local MARK_BYTES = 2
local KIND_BYTES = 1
local PERMS_BYTES = 9
local SIZE_BYTES = 10
local MTIME_BYTES = 16
local MARK_START = 0
local KIND_START = MARK_START + MARK_BYTES -- 2
local PERMS_START = KIND_START + KIND_BYTES -- 3
local PERMS_END = PERMS_START + PERMS_BYTES -- 12 (exclusive)
local SIZE_START = PERMS_END + 1 -- 13
local MTIME_START = SIZE_START + SIZE_BYTES + 1 -- 24
local NAME_START = MTIME_START + MTIME_BYTES + 1 -- 41
local BLANK_MARK = string.rep(" ", MARK_BYTES)
local SORT_MODES = { "name", "mtime", "size" }
-- ---------------------------------------------------------------------------
-- Per-buffer state
-- ---------------------------------------------------------------------------
--
-- handles: array of { buf, path, entries, errors, sort_mode, prev }.
--
-- Keyed by linear scan over `BufferIdLua.__eq` rather than by table
-- key: two BufferIdLua values for the same buffer are distinct
-- userdata, so a `handles[buf]` lookup would miss. The scan is over a
-- handful of dired buffers. Dead buffers are compacted out first, so a
-- command in a removed dired buffer sees "not in dired" rather than
-- operating on dead state (the M8.2 fixture's `find_handle` lesson).
local handles = {}
local function live_handles()
local live = {}
for _, h in ipairs(handles) do
local ok, valid = pcall(h.buf.is_valid, h.buf)
if ok and valid then live[#live + 1] = h end
end
handles = live
return live
end
local function handle_for_buffer(buf)
if buf == nil then return nil end
for _, h in ipairs(live_handles()) do
if h.buf == buf then return h end
end
return nil
end
local function handle_for_path(path)
for _, h in ipairs(live_handles()) do
if h.path == path then return h end
end
return nil
end
local function active_handle()
return handle_for_buffer(pmacs.window.buffer())
end
-- ---------------------------------------------------------------------------
-- Paths and names
-- ---------------------------------------------------------------------------
local canonicalize = pmacs.path.canonicalize
local function join_path(dir, name)
if dir:sub(-1) == "/" then return dir .. name end
return dir .. "/" .. name
end
-- Parent of a canonical directory, through the same normalizer: `..`
-- against the root folds away, so `/` is its own parent and no separate
-- root special case can drift out of agreement with the canonical form.
local function parent_path(path)
return canonicalize(join_path(path, ".."))
end
local function basename(path)
return path:match("([^/]+)/*$")
end
local function dirname(path)
local dir = path:match("^(.*)/[^/]*$")
if dir == nil then return nil end
if dir == "" then return "/" end
return dir
end
local function buffer_name(path)
return "*dired:" .. path .. "*"
end
local function buffer_named(name)
for _, id in ipairs(pmacs.buffer.list()) do
local ok, described = pcall(pmacs.describe.buffer, id)
if ok and described and described.name == name then return id end
end
return nil
end
-- The directory a prompt or a jump should start from: the active
-- buffer's own directory, else the process cwd (which the normalizer
-- yields for a bare "." because it absolutizes against it).
local function current_directory()
local buf = pmacs.window.buffer()
if buf ~= nil then
local ok, path = pcall(function() return buf:path() end)
if ok and path then
local dir = dirname(path)
if dir then return canonicalize(dir) end
end
local h = handle_for_buffer(buf)
if h then return h.path end
end
return canonicalize(".")
end
-- ---------------------------------------------------------------------------
-- Failure reporting
-- ---------------------------------------------------------------------------
-- `Handle:await()` raises structured tables (R45), so `tostring` on a
-- failure yields "table: 0x...". Every user-visible dired failure goes
-- through here.
local function failure_message(err)
if type(err) == "table" then
return tostring(err.message or err.tag or "error")
end
return tostring(err)
end
local function report(where, err)
pmacs.editor.set_status(where .. ": " .. failure_message(err))
end
-- ---------------------------------------------------------------------------
-- Rendering
-- ---------------------------------------------------------------------------
-- `rwxr-xr-x`, without the leading kind char (rendered separately so a
-- symlink shows `l` and a directory `d`). Arithmetic rather than bit
-- ops: this file has to run on LuaJIT (5.1) as well as Lua 5.4.
--
-- The nine basic bits only: setuid / setgid / sticky are deliberately
-- not surfaced as Emacs's `s` / `t`, matching the M8.3 fixture's
-- `parse_perm_string`, which edits exactly these nine. Rendering a bit
-- Stage 3 could not accept back would be worse than omitting it.
local function fmt_perms(mode)
local function tri(bits)
local r = (bits >= 4) and "r" or "-"
local w = ((bits % 4) >= 2) and "w" or "-"
local x = ((bits % 2) >= 1) and "x" or "-"
return r .. w .. x
end
return tri(math.floor(mode / 64) % 8)
.. tri(math.floor(mode / 8) % 8)
.. tri(mode % 8)
end
local function kind_char(kind)
if kind == "dir" then return "d"
elseif kind == "symlink" then return "l"
elseif kind == "file" then return "-"
else return "?" -- device, fifo, socket
end
end
-- Exact bytes while they fit the column; a magnitude past that.
--
-- `%10d` holds ten digits, so a file of 10 GB or more (VM images, core
-- dumps --- ordinary things) widens the field and shifts mtime and name
-- right on that line alone. That is only cosmetic today, but
-- `_layout.NAME_START` is exported as a contract and Stage 3's
-- column-classifying intercept is planned against these constants, so a
-- line that violates them now is a Stage 3 trap. Same discipline as
-- `fmt_mtime`: the width is the invariant, and precision yields to it.
--
-- This is NOT the deferred human-readable size column (§13): the exact
-- byte count is still what a listing shows, right up to the point where
-- it cannot be shown at all.
local SIZE_UNITS = { "K", "M", "G", "T", "P", "E" }
local function fmt_size(n)
local exact = string.format("%" .. SIZE_BYTES .. "d", n)
if #exact <= SIZE_BYTES then return exact end
local value, unit = n, SIZE_UNITS[#SIZE_UNITS]
for _, suffix in ipairs(SIZE_UNITS) do
value = value / 1024
unit = suffix
if value < 1024 then break end
end
local scaled = string.format("%.1f%s", value, unit)
if #scaled > SIZE_BYTES then scaled = scaled:sub(1, SIZE_BYTES) end
return string.rep(" ", SIZE_BYTES - #scaled) .. scaled
end
local function fmt_mtime(secs)
-- Explicit format string, so the width is fixed and the result does
-- not move with LC_TIME. A pre-epoch mtime is legal and `os.date`'s
-- behavior on a negative time is platform-dependent, so a
-- non-conforming result degrades to a fixed-width placeholder rather
-- than shifting every column right of it.
local ok, formatted = pcall(os.date, "%Y-%m-%d %H:%M", secs)
if ok and type(formatted) == "string" and #formatted == MTIME_BYTES then
return formatted
end
return string.rep("?", MTIME_BYTES)
end
-- POSIX permits any byte but `/` and NUL in a filename, including `\n`.
-- Rendering one verbatim would break the one-line-per-entry invariant
-- that cursor-line -> entry resolution rests on (and that Stage 3's
-- intercept will rest on harder), so control bytes are escaped. The
-- backslash goes first, which is what makes the encoding invertible ---
-- Stage 3 needs the exact inverse so a no-op commit cannot fire a
-- spurious rename. Carried over from the M8.2 fixture as decided
-- design, not re-litigated.
local function escape_displayable(s)
if s == nil then return "" end
s = s:gsub("\\", "\\\\")
s = s:gsub("\n", "\\n")
s = s:gsub("\r", "\\r")
s = s:gsub("\t", "\\t")
-- NUL is deliberately absent from the class: the kernel forbids it in
-- a filename, so the fixture's `%z` (removed from Lua 5.2's pattern
-- syntax) was covering a case that cannot occur.
s = s:gsub("[\1-\8\11\12\14-\31]", function(ch)
return string.format("\\x%02X", string.byte(ch))
end)
return s
end
local function render_entry(entry)
local target = ""
if entry.symlink_target then
target = " -> " .. escape_displayable(entry.symlink_target)
elseif entry.kind == "symlink" then
-- A tolerant listing keeps a symlink whose target could not be
-- represented (non-UTF-8) or read; say so rather than rendering a
-- bare `l` line that looks like a complete entry.
target = " -> ?"
end
return string.format(
"%s%s%s %s %s %s%s",
BLANK_MARK, kind_char(entry.kind), fmt_perms(entry.mode),
fmt_size(entry.size), fmt_mtime(entry.mtime),
escape_displayable(entry.name), target)
end
-- Header (line 0) + one line per entry + the unreadable-count footer.
-- The footer exists because a tolerant listing that silently dropped
-- entries is worse than one that failed: the user has to know the view
-- is incomplete (and Stage 3's wdired refuses to open on one).
local function render_text(handle)
local lines = { handle.path .. ":" }
for _, entry in ipairs(handle.entries) do
lines[#lines + 1] = render_entry(entry)
end
local unreadable = #handle.errors
if unreadable > 0 then
lines[#lines + 1] = string.format("%d entries unreadable", unreadable)
end
return table.concat(lines, "\n")
end
-- Dired's own writes are the only ones that reach the buffer: the
-- read-only intercept rejects everything else, and this bypasses it.
local function paint(handle)
local text = render_text(handle)
handle.buf:replace(0, handle.buf:len(), text, { bypass_intercept = true })
end
-- ---------------------------------------------------------------------------
-- Cursor
-- ---------------------------------------------------------------------------
--
-- Entry i renders on line i (line 0 is the header), so the entry under
-- the cursor is `entries[cursor_line()]`.
local function entry_at_cursor(handle)
local line = pmacs.editor.cursor_line()
if line < 1 then return nil end
return handle.entries[line], line
end
local function index_of_name(handle, name)
if name == nil then return nil end
for i, entry in ipairs(handle.entries) do
if entry.name == name then return i end
end
return nil
end
-- Re-seat by BASENAME (Q#DR9), falling back to the nearest surviving
-- line. Every repaint is wholesale, so without this a revert, a sort,
-- or any Stage 2 operation would drop the cursor to the header.
--
-- `move_to_line` is AMBIENT --- it moves the active window's cursor, not
-- `handle.buf`'s --- so every caller that can run after an `:await()`
-- has to check that dired is still the active buffer first. Painting is
-- safe either way (it names the buffer); seating is not. Callers that
-- activate the buffer themselves (an open, which displays first) are
-- unconditionally in the right place.
local function seat_cursor(handle, name, fallback_line)
local count = #handle.entries
if count == 0 then
pmacs.editor.move_to_line(0)
return
end
local target = index_of_name(handle, name)
if target == nil then
target = math.max(1, math.min(fallback_line or 1, count))
end
pmacs.editor.move_to_line(target)
end
-- ---------------------------------------------------------------------------
-- Sorting
-- ---------------------------------------------------------------------------
local function sort_entries(entries, mode)
if mode == "name" then
table.sort(entries, function(a, b) return a.name < b.name end)
elseif mode == "mtime" then
-- Newest first, name as a stable tiebreak so a directory of
-- same-second files renders deterministically.
table.sort(entries, function(a, b)
if a.mtime ~= b.mtime then return a.mtime > b.mtime end
return a.name < b.name
end)
elseif mode == "size" then
table.sort(entries, function(a, b)
if a.size ~= b.size then return a.size > b.size end
return a.name < b.name
end)
else
error("dired: unknown sort mode: " .. tostring(mode))
end
end
local function next_sort_mode(mode)
for i, candidate in ipairs(SORT_MODES) do
if candidate == mode then
return SORT_MODES[(i % #SORT_MODES) + 1]
end
end
return SORT_MODES[1]
end
-- ---------------------------------------------------------------------------
-- Reading
-- ---------------------------------------------------------------------------
-- Read and sort one directory without touching editor state, so a
-- failure happens before any side effect is committed (acceptance 15).
-- Must run inside `pmacs.async`.
--
-- Always tolerant (Q#DR6): a plain refresh of a busy directory must not
-- fail because one child was unlinked between `readdir` and `lstat`.
-- Parent-level failures and non-UTF-8 *names* still raise.
local function read_listing(path, sort_mode)
local listing = pmacs.fs.read_dir(path, { tolerant = true }):await()
local entries = listing.entries
sort_entries(entries, sort_mode)
return entries, listing.errors
end
-- ---------------------------------------------------------------------------
-- Buffer ownership
-- ---------------------------------------------------------------------------
-- How far the `<2>`, `<3>`, ... disambiguation walks before giving up.
local NAME_VARIANT_LIMIT = 99
-- `pmacs.buffer.create` takes any caller-chosen name, so a foreign
-- buffer may already be called `*dired:/tmp*`. Painting into it through
-- `bypass_intercept` would clobber a user's data, so found-by-name is
-- NOT adoption: ownership means "this buffer is in dired's own handle
-- table" (F7).
--
-- That is deliberately narrower than the framing's "in the handle table
-- OR major_mode == dired": a foreign buffer that also carries the mode
-- is precisely the case the check exists to refuse, and a builtin's
-- handle table cannot be lost the way a reloadable package's can.
local function claim_handle(path)
local existing = handle_for_path(path)
if existing then return existing end
local name = buffer_name(path)
if buffer_named(name) then
local unique = nil
for i = 2, NAME_VARIANT_LIMIT do
local candidate = string.format("%s<%d>", name, i)
if buffer_named(candidate) == nil then
unique = candidate
break
end
end
if unique == nil then
error(string.format("dired: %s is taken and no free variant remains", name))
end
name = unique
end
local buf = pmacs.buffer.create(name)
-- Read-only by the listview idiom (Q#DR3): every non-bypass edit is
-- rejected, and the intercept lives as long as the buffer.
pmacs.buffer.add_intercept(buf, function()
error(name .. " is read-only")
end)
-- Q#DR3/Q#P6: while this buffer is active a semantic frontend must
-- round-trip keys, or optimistic apply would swallow the single-key
-- bindings (`g` would insert a `g` into a CRDT mirror instead of
-- reverting) and bypass the intercept entirely.
pmacs.buffer.set_round_trip_input(buf, true)
-- Q#DR8: the mode is what carries the keymap, and dired is #129's
-- first consumer of mode-scoped keys outside language detection.
pmacs.buffer.set_major_mode(buf, "dired")
local handle = {
buf = buf,
path = path,
entries = {},
errors = {},
sort_mode = SORT_MODES[1],
prev = nil,
}
handles[#handles + 1] = handle
return handle
end
-- ---------------------------------------------------------------------------
-- Display
-- ---------------------------------------------------------------------------
local function drop_handle(handle)
for i, candidate in ipairs(handles) do
if candidate == handle then
table.remove(handles, i)
return
end
end
end
-- Kill the dired buffer being left, when the user asked for it.
-- Deliberately after the new buffer is displayed: `pmacs.buffer.kill`
-- redirects windows showing the doomed buffer, and doing that first
-- would fight the display we are about to perform.
local function kill_departed(departed, arriving)
if departed == nil or departed == arriving then return end
if not pmacs.config.get("dired.kill-when-opening") then return end
local ok, err = pcall(pmacs.buffer.kill, departed.buf)
if ok then
drop_handle(departed)
else
-- A buffer that could not be killed keeps its handle: dropping it
-- would leave a live dired buffer no command recognizes.
report("dired", err)
end
end
-- Where a dired buffer goes.
--
-- A fresh `dired` takes the standard adopter opt (Q#BP11b): omitted or
-- "current" is the raw switch every other adopter defaults to in
-- Stages 1-2, "panel" is the bottom side window.
--
-- Navigation (`departed ~= nil`) instead reuses the window dired
-- already occupies, which is the opposite routing from a file visit and
-- deliberately so (Q#DR10): the next directory is the same kind of
-- thing as the current one and belongs in the same slot, while a file
-- is not a dired buffer and belongs in the document area.
local function display(handle, opts, departed)
local side = nil
if departed ~= nil then
-- Dired's own window, not the request's: walking a tree in a side
-- window keeps the side window.
local params = pmacs.window.params()
side = params and params.side
elseif opts and opts.display == "panel" then
side = "bottom"
end
if side ~= nil then
-- A side slot DEDICATED to another buffer refuses the replacement
-- and this falls back to the document window (Q#BP3 2.iii). That is
-- both the substrate's documented policy and Emacs's, so dired does
-- not try to unpin the user's panel.
pmacs.window.display(handle.buf, { side = side, select = true })
else
pmacs.window.switch_buffer(handle.buf)
end
end
-- ---------------------------------------------------------------------------
-- Public: open a directory
-- ---------------------------------------------------------------------------
pmacs.dired = pmacs.dired or {}
local OPEN_OPTS = { display = true, select_name = true }
-- Open `path`'s dired buffer, replacing `departed` (a handle) in the
-- window it occupies when this is a navigation rather than a fresh
-- open. Awaits, so it must run inside `pmacs.async`; raises on a read
-- failure, having changed nothing. Returns the buffer.
local function open_directory(path, opts, departed)
if type(path) ~= "string" then
error("pmacs.dired.open: path must be a string, got " .. type(path))
end
opts = opts or {}
-- Validated up front, before the read and before any buffer exists,
-- so a bad opt leaves nothing to roll back (the
-- `parse_adopter_placement` discipline).
for key in pairs(opts) do
if not OPEN_OPTS[key] then
error(string.format("pmacs.dired.open: unknown opts key %q", tostring(key)))
end
end
local wanted = opts.display
if wanted ~= nil and wanted ~= "current" and wanted ~= "panel" then
error(string.format('pmacs.dired.open: unknown display %q (expected "current" or "panel")',
tostring(wanted)))
end
local canonical = canonicalize(path)
-- Read first: a failure must leave no buffer, no window change, and
-- no handle behind.
local sort_mode = (handle_for_path(canonical) or {}).sort_mode or SORT_MODES[1]
local entries, errors = read_listing(canonical, sort_mode)
local handle = claim_handle(canonical)
handle.entries = entries
handle.errors = errors
handle.sort_mode = sort_mode
-- `q` returns to the buffer you came from, never to another dired
-- buffer (which would trap `q` walking back down the tree); on a
-- descent the arriving buffer inherits the departing one's origin.
if departed ~= nil then
handle.prev = departed.prev
else
local active = pmacs.window.buffer()
if active ~= nil and handle_for_buffer(active) == nil then
handle.prev = active
end
end
paint(handle)
display(handle, opts, departed)
-- Seating happens after the display: `switch_buffer` zeroes the
-- window cursor, so an earlier seat would be discarded.
seat_cursor(handle, opts.select_name, 1)
kill_departed(departed, handle)
return handle.buf
end
function pmacs.dired.open(path, opts)
return open_directory(path, opts, nil)
end
-- Every interactive entry point funnels through here: spawn the
-- coroutine the await needs, and turn a failure into a status message
-- rather than an uncaught raise inside `pmacs.async` (which would land
-- in *errors* and leave the user with a silent no-op).
local function open_async(path, opts, departed, where)
pmacs.async(function()
local ok, err = pcall(open_directory, path, opts, departed)
if not ok then report(where or "dired", err) end
end)
end
-- ---------------------------------------------------------------------------
-- Commands
-- ---------------------------------------------------------------------------
pmacs.command.define {
name = "dired",
description = "Open a directory listing (dired).",
fn = function()
local root = current_directory()
-- No completion source, deliberately. `source = "files"` would make
-- RET-on-empty open whatever sorts first (the minibuffer selects
-- candidate 0 whenever the list is non-empty, and a selected
-- candidate shadows typed text --- S0-1/S0-4), and RET-on-the-
-- default-directory is exactly the gesture `C-x d` exists for. The
-- field is prefilled instead, which is Emacs's own shape here.
pmacs.minibuffer.read {
prompt = "Dired: ",
initial = root,
history = "dired",
on_accept = function(value)
if value == nil or value == "" then return end
open_async(value, nil, nil, "dired")
end,
}
end,
}
pmacs.command.define {
name = "dired-jump",
description = "Open dired on the current file's directory, cursor on that file.",
fn = function()
local buf = pmacs.window.buffer()
local path = nil
if buf ~= nil then
local ok, value = pcall(function() return buf:path() end)
if ok then path = value end
end
if path == nil then
pmacs.editor.set_status("dired-jump: this buffer has no file")
return
end
local dir = dirname(path)
if dir == nil then
pmacs.editor.set_status("dired-jump: cannot find the directory of " .. path)
return
end
open_async(dir, { select_name = basename(path) }, nil, "dired-jump")
end,
}
pmacs.command.define {
name = "dired.visit",
description = "Visit the entry under the cursor (descend a directory, open a file).",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry = entry_at_cursor(handle)
-- The header and the unreadable-count footer are not entries.
if entry == nil then return end
local target = join_path(handle.path, entry.name)
if entry.kind == "dir" then
open_async(target, nil, handle, "dired")
return
end
if entry.kind == "symlink" then
-- `read_dir` and `stat` are both lstat-based, so nothing in the
-- entry says whether the link points at a directory --- the only
-- way to find out is to try to list it. A symlinked directory is
-- an ordinary thing to walk into, so try the descent and fall back
-- to a file visit.
--
-- `open_directory` is the try: it reads before touching any editor
-- state and raises having changed nothing (acceptance 15), so its
-- failure IS the "not a directory" answer. An explicit probe
-- followed by the real open would list the whole directory TWICE
-- --- opendir plus one lstat per child, each time.
pmacs.async(function()
local descended = pcall(open_directory, target, nil, handle)
if descended then return end
local visited, err = pcall(pmacs.window.display_file, target, { select = true })
if not visited then report("dired", err) end
end)
return
end
-- Q#DR10: `display_file`, never `find_or_open`, which switches the
-- active window in both branches before firing hooks --- in a
-- panel-displayed dired that would replace the panel with the
-- visited file, i.e. the panel swallows itself.
local ok, err = pcall(pmacs.window.display_file, target, { select = true })
if not ok then report("dired", err) end
end,
}
pmacs.command.define {
name = "dired.parent",
description = "Open the parent directory.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local parent = parent_path(handle.path)
if parent == handle.path then
pmacs.editor.set_status("dired: already at the filesystem root")
return
end
-- Seat on the directory we came from, the way Emacs's `^` does.
open_async(parent, { select_name = basename(handle.path) }, handle, "dired")
end,
}
pmacs.command.define {
name = "dired.revert",
description = "Re-read the directory, keeping the cursor on its entry.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry, line = entry_at_cursor(handle)
local name = entry and entry.name
pmacs.async(function()
local ok, entries, errors = pcall(read_listing, handle.path, handle.sort_mode)
if not ok then
-- On failure `entries` carries the raised value, not a listing.
report("dired", entries)
return
end
if not handle.buf:is_valid() then return end
handle.entries = entries
handle.errors = errors
paint(handle)
-- The re-read settles a tick or more later, and the user may have
-- left (a buffer switch, or `q`) in the meantime. The paint names
-- its buffer and is safe; seating is ambient, so a stale seat here
-- would move an unrelated buffer's cursor to a line index that
-- only means something in this listing.
if pmacs.window.buffer() == handle.buf then
seat_cursor(handle, name, line)
end
end)
end,
}
pmacs.command.define {
name = "dired.sort-cycle",
description = "Cycle the sort mode: name -> mtime -> size.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry, line = entry_at_cursor(handle)
local name = entry and entry.name
-- A pure reorder of the entries already in hand: sort is a display
-- decision, not a reason to re-read the directory.
handle.sort_mode = next_sort_mode(handle.sort_mode)
sort_entries(handle.entries, handle.sort_mode)
paint(handle)
seat_cursor(handle, name, line)
pmacs.editor.set_status("dired: sorted by " .. handle.sort_mode)
end,
}
pmacs.command.define {
name = "dired.quit",
description = "Leave dired, restoring the previous buffer.",
fn = function()
local handle = active_handle()
if handle == nil then return end
-- Q#BP11b, matching `listview.quit`: `q` keeps its name and its
-- user-visible behavior, delegating to `window.quit` only when
-- dired really is in a side window.
local params = pmacs.window.params()
if params and params.side and params.quit_action then
pmacs.window.quit()
return
end
local target = handle.prev
if not (target and target:is_valid()) then
target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*")
end
pmacs.window.switch_buffer(target)
end,
}
-- ---------------------------------------------------------------------------
-- Keys
-- ---------------------------------------------------------------------------
-- Global: both sequences are unbound repo-wide, and both are the Emacs
-- defaults.
pmacs.keymap.bind { scope = "global", sequence = "C-x d", command = "dired" }
pmacs.keymap.bind { scope = "global", sequence = "C-x C-j", command = "dired-jump" }
-- In-buffer keys are MODE-scoped (Q#DR8), bound once here rather than
-- per buffer: a second dired buffer needs no `keymap.bind` of its own,
-- and Stage 3's wdired swap changes the whole keymap with the mode
-- instead of unbinding key by key.
local function bind(sequence, command)
pmacs.keymap.bind { scope = "mode", mode = "dired", sequence = sequence, command = command }
end
bind("RET", "dired.visit")
bind("f", "dired.visit")
bind("^", "dired.parent")
bind("n", "cursor.down")
bind("<down>", "cursor.down")
bind("p", "cursor.up")
bind("<up>", "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,
}

View File

@ -12,7 +12,10 @@
-- `symlink_target` is present only on symlink entries.
-- `opts` may contain `supersede = "<key>"` 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) -> { <entry>, ... }
-- read_dir(path, { tolerant = true }) -> { entries = { <entry>, ... },
-- 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

View File

@ -30,9 +30,13 @@ pmacs.lsp = pmacs.lsp or {}
-- env (table) extra environment
-- init_options (table) `initializationOptions`
-- settings (table) answered to `workspace/configuration`
-- root (string) optional explicit project root; overrides
-- root (string|function) optional explicit project root; overrides
-- the `pmacs.project.detect` marker walk used
-- to set `rootUri`/`cwd` (see project_root_for)
-- to set `rootUri`/`cwd`. A `function(path) ->
-- string|nil` is resolved per file and
-- memoized per directory; returning nil
-- declines and falls through to the marker
-- walk (see project_root_for)
pmacs.lsp.config = pmacs.lsp.config or {}
-- Default rust-analyzer config. Users replace any field from init.lua
@ -507,34 +511,144 @@ end
-- the rest of the editor uses, honoring set_search_boundary,
-- 3. the file's own directory (a lone file still gets a sane root
-- rather than leaking the editor cwd).
-- This is single-root: it fixes which root the one per-language server
-- uses, NOT one-server-per-root scoping (still deferred post-v0.1).
-- Returns `root, source`, where `source` is "config", "detected", or
-- "fallback" — and nil alongside a nil root. The source matters because
-- only the first two mean a root was actually *found*; `ensure_server`
-- keys server affinity on those and treats the fallback as rootless.
--
-- `config[language].root` may be a `function(path) -> string|nil` as
-- well as a plain string, for languages whose root rule the shared
-- marker walk cannot express (an innermost-wins walk cannot find an
-- *outermost* marker). A resolver that returns nil declines, and
-- resolution falls through to the marker walk.
--
-- **A configured root — string or resolver return — MUST be a canonical
-- absolute path.** The `"detected"` arm is canonicalized for free
-- (`pmacs.project.detect` canonicalizes before walking), but a
-- configured one is fed to `file_uri_for` exactly as written, and the
-- affinity key is that URI. On macOS a resolver returning `/var/…` and
-- a detected `/private/var/…` are different keys for the same
-- directory, which silently yields two servers for one project. There
-- is no Lua-side canonicalizer to normalize this for you.
--
-- Resolver results are memoized per directory, because `ensure_server`
-- resolves the root on the *reuse* path as well as the spawn path — so
-- an unmemoized filesystem-walking resolver would re-walk on every
-- attach rather than once per project. The memo is keyed by the
-- resolver function itself, weakly: replacing `config[lang].root`
-- installs a new key and the old memo is collected, so a swapped
-- resolver can never serve a root the previous one computed.
local root_resolver_memo = setmetatable({}, { __mode = "k" })
local function resolve_root_fn(language, resolver, path)
local dir = dir_of(path)
if not dir then return nil end
local memo = root_resolver_memo[resolver]
if not memo then
memo = {}
root_resolver_memo[resolver] = memo
end
local hit = memo[dir]
-- `false` is the memoized form of "this resolver declined"; nil means
-- "not yet asked", so the two must stay distinguishable.
if hit ~= nil then
return hit or nil
end
local ok, resolved = pcall(resolver, path)
-- COHERENCE §1.2: background wiring must not DISCARD a failure. A
-- resolver that raises, or that returns something other than a string
-- or nil, is a config bug — and the memo below would otherwise bury
-- it permanently for this directory, so it is never observed again.
-- Returning nil is the documented decline and stays silent.
local failure
if not ok then
failure = "raised: " .. tostring(resolved)
elseif resolved ~= nil and type(resolved) ~= "string" then
failure = "returned a " .. type(resolved) .. "; want string or nil"
end
if failure then
local msg = string.format(
"LSP: %s root resolver for %s %s", language, dir, failure)
-- Report on the channel that EXISTS. `pmacs.error` is referenced by
-- fifteen guarded call sites across the runtime and is defined
-- nowhere in production (only by a test stub in `src/editor.rs`), so
-- `if pmacs.error then ...` alone would be a sixteenth report that
-- never fires — the unwired-guard shape, not a fix for it. The
-- status line is what lsp.lua already uses for every other LSP
-- error. The `pmacs.error` arm rides along so this upgrades for free
-- if that channel is ever built.
--
-- Both reports are pcall'd: a broken reporting channel must not turn
-- a declined root into a failed attach.
pcall(pmacs.editor.set_status, msg)
if pmacs.error then pcall(pmacs.error, msg) end
resolved = nil
end
if type(resolved) ~= "string" then resolved = nil end
memo[dir] = resolved or false
return resolved
end
local function project_root_for(language, path)
local cfg = pmacs.lsp.config[language]
if cfg and cfg.root then return cfg.root end
if not path then return nil end
local configured = cfg and cfg.root
-- Truthiness, not `~= nil`: `root = false` has always read as "unset",
-- and a `false` leaking through as a root would reach `file_uri_for`.
if configured and type(configured) ~= "function" then
return configured, "config"
end
if not path then return nil, nil end
if configured then
local resolved = resolve_root_fn(language, configured, path)
if resolved then return resolved, "config" end
end
local ok, det = pcall(pmacs.project.detect, path)
if ok and det and det.root then return det.root end
return dir_of(path)
if ok and det and det.root then return det.root, "detected" end
return dir_of(path), "fallback"
end
local function ensure_server(language, path)
local cfg = pmacs.lsp.config[language]
if not cfg or not cfg.command then return nil end
-- Reuse an existing same-language server if one is up. Multi-root
-- scoping (one server per project root) ships post-v0.1, so the
-- first file that attaches a given language fixes that server's
-- root; later files of the same language reuse it regardless of
-- their own project (known, documented limitation).
-- Reuse an existing same-language server *serving the same root*.
-- One server per project root: `lake serve` is bound to one Lake
-- package and rust-analyzer/gopls to one workspace, so handing the
-- second project's files to the first project's server yields
-- unresolvable imports and empty diagnostics.
--
-- The affinity key is the root only when a root was actually FOUND
-- (config override or marker walk). `project_root_for` never returns
-- nil for a file that has a path — its last resort is the file's own
-- directory — so keying on the fallback would give every directory
-- of loose scratch files its own server, for every language: two
-- stray .py files in different directories would spawn two pyrights
-- where today they share one. The fallback therefore keys on nil.
--
-- Matching is on the spawned spec's `root_uri`, nil matching nil, so
-- the fallback spawn must pass `root_uri = nil` for the key and the
-- stored spec to agree. `cwd` still carries the directory and
-- `build_initialize` derives the identical `rootUri` from it when the
-- field is None (src/lsp.rs), so the initialize payload is unchanged
-- for that case — only what this loop matches on changes.
--
-- Consequence, deliberate: a server hand-spawned from `init.lua` with
-- only `cwd` set also reads back nil, so a root-bearing attach will
-- not adopt it. We cannot know which root it was meant to serve, and
-- guessing wrongly routes a project's files to the wrong server.
local root, source = project_root_for(language, path)
local key_uri = nil
if source == "config" or source == "detected" then
key_uri = file_uri_for(root)
end
for _, info in ipairs(pmacs.lsp.list()) do
if info.language_id == language and info.state then
if info.language_id == language and info.state
and info.root_uri == key_uri then
local kind = info.state.kind
if kind ~= "crashed" and kind ~= "stopped" then
return info.id
end
end
end
local root = project_root_for(language, path)
local ok, sid = pcall(pmacs.lsp.spawn, {
label = "default-" .. language,
language_id = language,
@ -544,7 +658,7 @@ local function ensure_server(language, path)
init_options = cfg.init_options,
settings = cfg.settings,
cwd = root,
root_uri = root and file_uri_for(root) or nil,
root_uri = key_uri,
})
if ok then return sid end
return nil

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,6 @@
# Active work — cross-machine resume ledger
**Snapshot: 2026-07-24.** This file records volatile work that has not
**Snapshot: 2026-07-25.** This file records volatile work that has not
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent
backlog.
@ -14,10 +14,11 @@ backlog.
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `e745068` (bottom-panel Stage 1 #155 atop GPU
initial-target #148, folding Stage 2 landed-doc refresh #150, folding
Stage 2 #149, the ledger refresh #147, web grammars HTML+CSS #146, and
the LaTeX Stage 1 #144 / inline-math framing #145 pair; protocol v20).
`githubsucks/main` @ `8c86d34` (the dired framing #164 atop find-file
#162, COHERENCE.md #163, Lean 4 Stage 1 #160, the minimap blank-slab fix
#159, bottom-panel Stage 1 #155, the inline-math re-scout #154, the vterm
PTY-flake fix #153, and the GPU initial-target doc refresh #152; protocol
v20).
- On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names
the canonical URL. This difference is why all recovery begins by
@ -51,9 +52,343 @@ git worktree list
git status --short --branch
```
The `git log` command must expose `e745068` or a newer intentional main.
The `git log` command must expose `8c86d34` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration.
## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161)
- Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review
round, all twelve checks green). Branch `githubsucks/lean4-stage1`
retained; it was worked in the shared checkout (no sibling worktree).
- Approved framing: `docs/lean4-mode-framing.md` revision 4, committed as
the branch's first commit (`a382965`) after three review rounds. **Seven
stages**, 19 decisions (Q#LN1–19), 64 acceptance criteria. North star:
match or exceed VS Code's Lean support.
- **Stage 1 implemented; no wire change (protocol stays v20), no LSP, no
frontend change.** Four commits: framing, grammar, theme captures,
editing surface + acceptance.
- `Cargo.toml` + `src/syntax.rs`: `arborium-lean` 2.18 and one
`BUILTIN_LANGUAGES` entry named **`lean4`** (Q#LN2 — the name becomes
the `didOpen` language_id), claiming `.lean` only.
- `src/highlight.rs`: four capture entries — `constructor`, `character`,
`keyword.conditional`, `warning`.
- `builtin/runtime/{comment,pair,syntax}.lua`: `--` comments, the
`⟨⟩ ⦃⦄ ⟮⟯` pair set, the `lean` → `lean4` modeline alias.
- `tests/lean4_stage1_acceptance.rs` plus unit tests in `syntax.rs` /
`highlight.rs`: 12 criteria, 17 tests.
- **Q#LN1's open obligation is discharged.** `tree-sitter-lean4` is
unusable (depends on `tree-sitter ^0.25` directly against our 0.26,
exports no `LANGUAGE` const despite its README, packages no queries);
`arborium-lean` rides `tree-sitter-language 0.1` with a pre-generated
ABI-15 parser. `cargo tree -d` shows no duplicate core. The parse smoke
pins the failure mode that matters: `→`/`∀`/`≥` must produce
`(arrow)`/`(forall)`/`(comparison)`, since a mismatched-core build
degrades silently on exactly those characters rather than failing loudly.
- **Q#LN4 is a deliberate retro-paint of seven language entries**, not
four: `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated
base-first into javascriptreact/typescript/typescriptreact. Its shape is
"every capitalized identifier" (`#match? "^[A-Z]"`) plus every Lua table
brace — not "constructors". Pinned in both directions per #146.
- Implementation findings not in the framing:
- `warning` had to move from bold red to bold **bright** red: `number`
is plain `fg(1)`, so `sorry` and an adjacent numeric literal were the
same colour. Found by writing the test.
- `Some(1)` is **not** `@constructor` — in call position a narrower
`@function` pattern wins. Only bare or pattern-position capitalized
identifiers reach it. Pinned so the blast-radius claim stays honest.
- Lean node kinds nest: `module > declaration > def|theorem`.
- `pmacs.parse.injection_aliases` is a documented **write-only** Lua
proxy (canonical map is Rust-side), so fence tests must drive
`_parse_now` and inspect layer languages, never read the table back.
- **Review round 1 addressed.** The finding: acc12's server-list assertion
could not fail for the regression it named — the shared `editor()`
helper wipes `pmacs.lsp.config` before any buffer opens, so
`#pmacs.lsp.list() == 0` holds for every language regardless of what
Stage 1 ships. It now asserts against a **pristine** `EditorState` that
`pmacs.lsp.config.lean4` is nil, with a non-vacuity check that the same
lookup finds `rust`; bite-verified by adding a `lean4` config to
`lsp.lua` and watching it fail. Also fixed a stale column in a
`highlight.rs` comment.
- Verification on this branch: `cargo fmt --check` clean; strict workspace
Clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1
9/9; comment toggle 14; auto-pair 45; injection 4; M4 121; required GPU
152; **isolated-config workspace sweep 3,150 across 90 suites**;
`git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME`
for the reason recorded in the bottom-panel lane below.
### Stage 2 — multi-root LSP server affinity (Q#LN15)
- Portable branch: `githubsucks/lsp-multi-root-affinity`, shared checkout,
based on `githubsucks/main` @ `0827dd1`. Named for the substrate, not
for Lean: **the diff contains no Lean content**, because `ensure_server`
is the one server-affinity function every LSP language shares and a
cross-cutting change to it must not be reviewable only as a Lean
feature.
- Three files, no protocol change: `src/lua_bindings/mod.rs` (the
`lsp.list()` row builder gains `root_uri` + `cwd`),
`builtin/runtime/lsp.lua` (`project_root_for` returns `root, source`;
`ensure_server` hoists it above the reuse loop and matches on it),
`tests/lsp_multi_root_acceptance.rs` (9 tests, acceptance 13–21).
- **The rule that keeps this from regressing every other language: the
affinity key is the root only when a root was actually FOUND.**
`project_root_for` never returns nil for a file with a path — its last
resort is the file's own directory — so a naive `(language_id, root)`
key gives every directory of loose scratch files its own server, for
every language. `source` is `"config" | "detected" | "fallback"` and
only the first two become a key.
- **Wire-identical for the fallback case, and that is provable rather
than hoped.** Matching is on the spawned spec's `root_uri` (nil matching
nil), so the fallback spawn passes `root_uri = nil`; `cwd` still carries
the directory and `build_initialize` derives the identical `rootUri`
from `cwd` when the field is None, using a percent-encoder with the same
allowed set as Lua's `file_uri_for`. `build_initialize` (`src/lsp.rs`)
is the **only** reader of `spec.root_uri` in the tree.
- Deliberate behavior change, asserted not discovered: a server
hand-spawned from `init.lua` with only `cwd` set also reads back nil, so
a root-bearing attach will not adopt it.
- `config[language].root` may now be a `function(path) -> string|nil`,
memoized per directory — needed because the hoist puts root resolution
on every attach rather than every spawn. The memo is keyed **weakly by
the resolver function itself**, so replacing `config[lang].root` cannot
serve a root the previous resolver computed. This is Q#LN8's
generalization landing early; the Lean resolver that uses it is Stage 3.
- Bite-verified three ways: 5/9 fail against the pre-change `lsp.lua`,
8/9 against the pre-change `mod.rs`, and — the one that matters most —
installing the naive always-key-on-root variant fails acceptance 20 and
21 exactly as Q#LN15 part 2 predicts. The four that survive the first
bite (13, 15, 16, 19) are the regression pins; passing on both sides is
their job.
- Every fixture sets `pmacs.project.set_search_boundary` at its own
tempdir root. Without it the marker walk climbs to the filesystem root
and a stray `.git` above the temp directory turns the markerless cases
into detected ones — the assertions would still pass while testing
nothing.
- **Found but not fixed here (pre-existing, own lane):** `ensure_server`
never forwards `cfg.restart` to `pmacs.lsp.spawn`, so a
`restart = "never"` in `pmacs.lsp.config[lang]` is silently dropped on
the auto-attach path. At least one existing test sets it believing it
takes effect. Out of scope for a PR whose acceptance 16 pins existing
attach behavior as unchanged.
- **Review round 1 addressed.** The blocker was process, not design: the
test file was committed *before* `cargo fmt` ran, so the fix sat
uncommitted in the working tree and the branch as pushed failed the
first gate. The reported "fmt clean" described the worktree, not the
branch — gate results are only meaningful when run against the pushed
tree. Also added the two pins review asked for (a **string** `config
.root` as an affinity key — acc17 only covered the function form; and
`root = false` reading as unset), each bite-verified against exactly
the mutation it targets and neither against the other. And documented
the canonicalization obligation: the `"detected"` arm is canonicalized
for free, a **configured** root is not, so on macOS a resolver
returning `/var/…` and a detected `/private/var/…` are different keys
for one directory. Stage 3's Lean resolver is the first real consumer,
so the obligation is written at the point of use.
- Verification on this branch: `cargo fmt --check` clean; strict
workspace Clippy clean; 1,826 default + 2,003 CRDT library tests;
multi-root 11/11; M4 121; statusline 7; completion popup 9; auto-pair
45; required GPU 155; **isolated-config workspace sweep 3,164 across 91
suites**; `git diff --check` clean. The sweep needs an isolated
`XDG_CONFIG_HOME` and `-- --skip basedpyright`.
## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165)
- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the
approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1
implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3
(wdired) each get their own detailed framing after the prior stage lands.
- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`,
2026-07-25, one review round, 12/12 CI green). Durable facts moved to
`docs/agent-handoff.md` §1 per rule 3 below.
- **Stage 1 branch: `githubsucks/dired-stage1`**, worktree
`../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the
framing merge #164). **A fresh cut, not a rebase:** the older `dired`
branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the
superseded `0827dd1` and carried only the framing content #164 already
put on `main`, so merging it would have reconciled two histories of one
document. It is left untouched and carries nothing unmerged.
- **Stage 1 implemented; no wire change (protocol stays v20).** What
landed on the branch:
- `builtin/runtime/dired.lua`: one buffer per directory named
`*dired:<canonical path>*` with the handle-table ownership check;
read-only intercept + `set_round_trip_input`; the `dired` major mode
and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`);
basename cursor re-seating across every wholesale repaint;
`display_file` for file visits and same-window reuse for directory
descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting.
Loaded after `window.lua`.
- `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`,
and one walk that either fails on a per-entry condition or records it
(Q#DR6). `src/async_runtime.rs` carries the listing in
`ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs`
keys the Lua result **shape** on `errors.is_some()`, so the bare array
the frozen M8.2 fixture consumes with `ipairs` is untouched;
`builtin/runtime/fs.lua` validates read-op opts and **rejects unknown
keys** (a typo'd `tolerant` used to degrade silently to fatal).
- `src/editor_core.rs` + `src/lua_bindings/mod.rs`:
`normalize_buffer_path` is `pub` and exposed as
`pmacs.path.canonicalize` — Q#DR2's preferred end state, so no Lua
mirror exists and Stage 2 owes no mirror removal. This makes B2
("tolerant `read_dir` is the only Rust change") false by one small
binding, deliberately.
- `tests/dired_acceptance.rs`: 22 tests over framing items 1–16,
dispatch-driven; item 17 is the m8_1/m8_2/m8_3 additivity gate.
- **The framing claim the substrate falsified (S1-2):** R2-3 expected a
dedicated dired panel to carry its dedication across a descent.
`display_buffer` never replaces the buffer in a slot dedicated to
another one — it discards every side-specific parameter and falls back
to the document window (Q#BP3 2.iii), and the exact-window arm errors.
Dired does not unpin the user's panel; both arms are pinned.
- **The vacuity the bites found (S1-3):** acceptance 3c cannot pin the
descent *routing*. Dired holds focus in its own panel, so a raw
`switch_buffer` lands in the same window and every 3c assertion holds
either way. Dedication is the only discriminator, so the
dedicated-panel test is the real pin — and the vacuity is documented at
the assertion rather than relabelled.
- **The pre-existing test dired's first mode-scoped binding broke
(S1-4):** `describe_key_identifies_every_default_binding` asserted every
binding in the stack resolves through `describe.key` context-free, which
held only while the modes table was empty. It now sets the effective
context per binding and explicitly *clears* the mode for global ones,
because a leaked mode legitimately shadows a global chord of the same
name (dired's `RET` shadows `edit.newline-and-indent`).
- Durable substrate facts, independent of this arc:
- `pmacs.buffer.kill` (not `remove`) redirects windows off a doomed
buffer before removal, so `kill-when-opening` kills **after** the
replacement is displayed.
- Interactive origin does **not** survive an await: work resumed in
`tick_async` sees no `InteractiveCommandOrigin`, so `pmacs.window.*`
acts for the *ambient* active frontend (S1-9).
- Kinds are lstat-based in both `read_dir` and `stat`, so nothing in an
entry says whether a symlink points at a directory; `RET` probes by
trying to list it (S1-8).
- A path-backed buffer's *name* is its full path, not its basename —
worth knowing before writing any name assertion.
- `C-x d` takes **no** completion source on purpose (S1-5): with one,
RET on an empty field opens whatever sorts first, and
RET-on-where-you-are is the gesture the binding exists for. The field
is prefilled instead.
- **Bite verification:** 15 claims, each mutated in place and required to
fail the test that names it. `dired.lua` is new, so `scripts/bite`'s
file swap does not apply; every mutation was applied and reverted with
`git checkout --`. One came back VACUOUS and is recorded above.
- **Review round 1 addressed** (framing rev 7, S1-10…S1-12). Three
behavioral fixes, each bite-verified: `dired.revert`'s re-seat is
guarded on the active buffer (an ambient `move_to_line` after an await
moved an unrelated buffer's cursor — the buffer-level instance of
S1-9); `fmt_size` keeps the column width past ten digits, because
`_layout` is a contract Stage 3 is planned against; and the symlink
descent dropped its probe, since `open_directory`'s
changed-nothing-on-failure invariant *is* the probe (it was listing the
target directory twice). Plus a consecutive-`readdir`-error cap, because
**nothing cancels a dired listing** — it carries no supersede key, so
cancellation was never the backstop the tolerant loop implicitly relied
on. Naming/comment findings taken as-is.
- Durable process lesson, hit twice now: a mutation-bite helper restores
with `git checkout --`, which reverts to **HEAD** — so a fix must be
committed *before* it is bitten. Round 1's fixes were briefly wiped by
exactly that.
- **Canonical main integrated twice** — at `46a1b8f` (multi-root LSP
affinity #161) and again at `b889873` (GPU terminal input #166), both
merged rather than rebased per the #135/#137 precedent so the review
anchors stay addressable. Each conflict was a single doc hunk resolved
as the union: this lane owns COHERENCE's journey step 7 file half, #161
owns the in-flight list, #166 owns step 8's GPU-terminal addendum.
Three things worth carrying:
- **A conflicting PR silently stops running CI.** GitHub builds
`pull_request` runs against the merge ref, which does not exist while
the PR conflicts, so no run is created and nothing reports a
failure — the checks list simply stays as it was. Three pushes to
this branch produced no CI at all before the cause was found. Watch
`mergeable` on a long-lived lane, not just the check list.
- #161's own COHERENCE finding **falsified a claim in this lane's
module doc**: `pmacs.error` is never defined in production, so an
uncaught raise inside a `pmacs.async` coroutine does not reach
`*errors*` as the comment said. It reaches a bare `error()` inside
`pmacs._async.tick()`, whose result `tick_async` discards with
`let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` +
`set_status` load-bearing rather than tidy, and the comment now says
so.
- **A lane in review against a fast-moving `main` needs its gates rerun
per integration, not per push.** Main advanced twice inside this
review round, and the second time landed while the first
integration's sweep was still running. The numbers below describe the
twice-merged tree.
- Verification on the twice-merged tree (`main` @ `b889873`):
`cargo fmt --check` clean; strict workspace Clippy clean; **1,832
default + 2,009 CRDT** library tests; dired acceptance **25 default +
25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; multi-root 13 and
vterm Stage 3 5 (both suites main added, green under this lane's
`mod.rs` and `editor.rs` changes); M4 121; required GPU 155;
**isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 passed across 93
suites, zero failures**; `git diff --check` clean. The sweep needs the
isolated config for the reason recorded in the bottom-panel lane
below.
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` §20
Priority 1, which names this work explicitly; journey step 7's file half
goes from no surface to a surface; **adds no interaction island** — keys
are a mode-scoped keymap, and wdired will be a mode swap; adopts
`pmacs.config` for `dired.kill-when-opening`; inherits §9's
worker-attribution gap for its `read_dir` jobs without worsening it. The
audited claims this changes are updated in `COHERENCE.md` itself, per its
§25.
- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut
1): CLI directory-argument handling (`pmacs .` exits 1) belongs there,
not here — Stage 1 does **not** fix it. The two meet at
`resolve_target_buffer`; dired supplies the buffer a directory should
resolve *to*, and `pmacs .` should route into it rather than growing a
second directory surface.
## GPU terminal input lane — IN REVIEW
- Portable branch: `githubsucks/gpu-terminal-input`, worktree
`../pmacs-gui-term-input`, based on `githubsucks/main` @ `46a1b8f`.
- Approved framing: `docs/gpu-terminal-input-framing.md` revision 2,
committed as the branch's first commit (`9a0df21`). Bug fix, not a
feature; **no protocol change (stays v20)**.
- Reported as "text input within the terminal doesn't work on GUI, this is
fine in TUI". Root cause: the dispatcher applied **both** terminal-layout
syncs to **every** attached frontend, and a semantic session satisfies both
conditions (a `term_sizes` entry from `AttachRequest` *and* a terminal
declaration). Its PTY was resized twice per tick forever — grid arm installs
the TUI placement size, semantic arm installs the declared content
rectangle, each arm's idempotence guard seeing only what the other just
wrote — so the child took a `SIGWINCH` storm at tick cadence.
- **The fix is a split, not a guard.** The grid arm is also the only per-tick
controller-liveness release a semantic frontend gets, and
`sync_semantic_terminal_layout` cannot take that over: the buffer-follow
snapshot clears the viewport declaration (`on_buffer_snapshot_sent`), so
that arm stops running in exactly the switch-away case that needs the
release. `sync_terminal_layout` is therefore split into a
frontend-kind-neutral half (panel reconcile + liveness) and a grid-only
geometry half, with the loop body extracted to
`sync_terminal_layouts_for_tick` so the exclusivity is structural and tests
drive the real thing.
- **Trap for anyone touching this again:** the release at the "no
`window_placements` entry" arm reads like liveness and is grid geometry. A
semantic frontend has no placement entry at all, so moving it into the
neutral half releases a GPU controller every tick.
- Bite-verified against **two** pre-images, because the naive guard fixes the
storm and introduces the leak:
| pin | `main` | naive guard | the split |
|---|---|---|---|
| settle (acc 2+3) | FAIL | pass | pass |
| controller release (acc 6) | pass | FAIL | pass |
| grid still resizes (acc 5) | pass | pass | pass |
- Real-path evidence: a quiet child trapping `SIGWINCH` reports **144 frames
in 4 s and `WINCH 1..12` on screen** against the pre-fix tree, versus a
settled screen with the fix.
- **Deliberately out of scope, named:** interactive-shell echo on a raw-mode
PTY (Q#GT5 — reproduces in-process too, so it is not the GUI/TUI
asymmetry), and a geometry change appearing to clear the visible screen
(reproduces pre-fix; why acceptance 4 latches its observation across
frames).
- Verification on this branch: `cargo fmt --check` clean; strict workspace
Clippy clean; 1,829 default + 2,006 CRDT library tests; vterm Stage 1/2/3
10 / 6 / 9 CRDT; bottom-panel Stage 1 46; M4 121; required GPU 155;
**isolated-config workspace sweep 3,177 across 92 suites, zero failures**;
`git diff --check` clean. Gates were run against the committed tree.
## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 (GPU band) is next
Stage 1 is on `main`; nothing in this arc is in flight. Stage 2 has **no

View File

@ -1,8 +1,12 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-24, after bottom-panel Stage 1 (#155) landed,
following GPU initial-target (#148, protocol v20),
folding Stage 2 (#149) and its landed-doc refresh (#150),
**Last updated: 2026-07-25, after find-file (#162) landed — the dired
arc's Stage 0 — following COHERENCE.md (#163), Lean 4 Stage 1 (#160), the
minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the
inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the
GPU initial-target doc refresh (#152); and before that GPU
initial-target (#148, protocol v20),
following folding Stage 2 (#149) and its landed-doc refresh (#150),
web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math framing pair
(#144/#145), folding Stage 1 (#142), one-command GPU invocation (#141), the
documentation refresh (#140), Vterm Stage 3 (#135), tab-width rendering
@ -20,13 +24,58 @@ reads it the way you just did.
For volatile branches, checkpoints, verification, and recovery
commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-24)
## 1. Where the project stands (2026-07-25)
- `main` @ `e745068` (bottom-panel Stage 1 #155 atop GPU initial-target #148,
folding Stage 2 landed-doc refresh #150, folding Stage 2 #149, ledger
refresh #147, web grammars #146,
LaTeX Stage 1 #144 / inline-math framing #145, and folding Stage 1 #142),
protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
- `main` @ `2af1ab3` (find-file #162 atop COHERENCE.md #163, Lean 4 Stage 1
#160, minimap blank-slab #159, bottom-panel Stage 1 #155, inline-math
re-scout #154, vterm PTY-flake #153, and doc refresh #152). Protocol
unchanged at **v20**. The bullets below describe the arcs in their own
terms; this line is the head-of-`main` anchor.
- **`COHERENCE.md` is now required reading and a required framing input
— #163.** It carries the product-coherence thesis, an audited
scorecard, per-concern gaps, and §20's priority order, and it is the
standard new work is evaluated against. Per `CLAUDE.md`, **every new
framing doc must state its coherence impact** — journey steps touched,
interaction islands added, config-registry adoption, background-work
attribution. Its §2 grades the golden journey **broken at step 3**
(`pmacs .` exits 1).
- **find-file LANDED — #162** (`docs/dired-framing.md` §10, Q#DR11; merge
`2af1ab3`; one review round). `C-x C-f` is the dired arc's **Stage 0**:
pmacs previously had no discoverable way to open a file by path — no
such command existed and `pmacs.buffer.find_or_open` had no interactive
caller. Pure Lua in `builtin/commands/default.lua`, one keymap line, an
8-test dispatch-driven acceptance suite; no Rust, no protocol change.
Two substrate facts it documents, both worth knowing before touching
any minibuffer prompt:
- **Completion over files is flat and cannot be made hierarchical from
Lua.** A custom `source` function is called with **zero arguments**
(`minibuffer.rs:591`) and runs synchronously outside any coroutine,
where `Handle:await()` raises — so it can neither see the input to
re-root on nor list a directory. Only the Rust
`CompletionSource::Files { root }` can list, and it is
single-directory and 1024-capped.
- **A selected candidate SHADOWS typed text.** `recompute_candidates`
sets `selected = Some(0)` whenever the list is non-empty
(`minibuffer.rs:372-377`) and `resolve_accepted_value` returns the
candidate over the typed contents (`:564-574`). So free-text accept
fires only when the input filters every candidate away — for
basename candidates under a subsequence filter, when it contains a
`/`. This applies to `M-x` and `switch-buffer` too. Consequences are
pinned as decisions, including the hole where a new bare name that is
a subsequence of an existing entry opens the existing file, and the
empty-input case (`fuzzy_score` gives `Some(0)` for an empty needle
and ties break lexicographically, so dotfiles lead).
- Also: `get_or_load_buffer` computes a normalized path but **loads
from the raw one** (`editor_core.rs:842-856`), so a `~/…` path dedups
against an open buffer yet fails to load one that is not open —
find-file expands the tilde Lua-side. Loading through the normalized
path is a named deferral.
- **Stage 1 (the directory view) is IN REVIEW as PR #165** — the
builtin `dired.lua`, the per-entry-tolerant `read_dir` opt, and
`pmacs.path.canonicalize`. Its branch state, substrate facts, and
verification live in `docs/active-work.md`; this section absorbs them
when it merges.
- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
`FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 =
the GPU initial-target semantic bootstrap family).
- **Bottom panel Stage 1 (window placement + TUI side windows) LANDED —
@ -729,6 +778,42 @@ final variant — its own round-trip cannot detect a discriminant shift.
## 5. Hard-won ops lessons
- **Two operations that must be alternatives are not made alternatives by
being adjacent.** The dispatcher applied its grid and semantic
terminal-layout syncs to every attached frontend; a semantic session
satisfies both conditions, so its PTY was resized twice per tick forever
and the child took a `SIGWINCH` storm that made a GPU terminal untypable
while output still flowed. Each arm had a correct `old_size == size`
idempotence guard — **individually sound, jointly useless**, because each
saw only the size the other had just written. Write mutually exclusive
per-frontend-kind work as one `if`/`else` keyed on the same fact session
establishment uses, and extract the loop body so a test can drive the real
thing.
- **Bite against every pre-image the fix could plausibly have taken, not just
`main`.** For the same defect, the obvious one-line guard (skip the grid arm
for semantic frontends) *does* fix the storm — and silently introduces a
controller leak, because that arm was also the only per-tick
controller-liveness release a semantic frontend got. A single revert would
have scored the fix complete. The pin that catches it (`acc 6`) deliberately
**passes on `main`** and fails only against the naive guard: today's defect
supplies the release by the accident of running an arm it should not.
- **A quiet child is an instrument.** A frame storm is invisible against a
fixture that legitimately emits hundreds of frames, and an assertion like
`frames >= 2` cannot see one. The same applies to geometry: a
"did a frame at the new width arrive" readout is satisfied by a geometry
*oscillating through* that width. Assert upper bounds over a fixed window
against a child that produces nothing, and let the child self-report the
signal you care about (a `SIGWINCH` trap printing a **fresh distinct**
breadcrumb per signal — repeated identical markers paint nothing, because
`cell::diff` skips already-matching cells).
- **`TerminalMode::Raw` makes `sh`-based input fixtures useless.** There is no
`ICRNL`, so Enter delivers CR and a `read -r` loop waits forever for a LF
that never comes — the test then "proves" input never arrived. Use
`exec cat`, which copies stdin to stdout byte by byte. It is also the right
echo instrument for the opposite reason people assume: termios `ECHO` is
*off* in raw mode, so nothing double-echoes and one keystroke yields exactly
one cell.
- **The checkout may be shared with the user.** Check `git status` for
foreign uncommitted work before any stash/checkout/branch surgery;
never assume dirty files are yours. (Their uncommitted fix was nearly
@ -950,14 +1035,6 @@ setup (reader → editable → kernel execution) now has its JSON grammar
prerequisite, but remains a real arc, not a one-shot.
GPU: auto-reconnect after daemon restart, splits/multi-buffer, gutter
riders (whitespace guides, folding, git markers).
Bottom panel (SHIPPED Stage 1, #155; full list in its framing "Deferred"):
left/right/top side windows, multiple slots per side, rehoming a leaf across
the tree, the whole `no_other_window` parameter, manual panel hide/show and
`window.toggle-panel`, `display-buffer-alist`-style user rules, panel
persistence (blocked on settings persistence), `OSC 22` pointer shape in the
TUI, per-panel statusline segments on the wire, proportional-font panels,
`window-configuration` registers, atomic windows, panel-local keymaps, and
horizontal (`C-x {`/`}`) resize.
Themes (full list in theme-faces framing rev 9 "Deferred (named)"):
popup/menu/dropdown bg + selected-row faces, `ui.background` /
`ui.caret`, `ui.modeline.inactive`, minimap chrome, peer-cursor

1341
docs/dired-framing.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,438 @@
# GPU terminal input — the double terminal-layout sync
**Revision 2 — approved 2026-07-25. Scouted against canonical `main` @
`8c86d34`; implemented on branch `gpu-terminal-input` off `main` @ `46a1b8f`,
whose only delta (#161) touches no file on this fix surface. Protocol stays
v20.**
Revision 2 answers Q#GT4 from the code instead of deferring it, which changes
the proposed fix from a one-line guard to a **split of `sync_terminal_layout`
into a frontend-kind-neutral liveness half and a grid-only geometry half**;
rescores B1 as half-false; gives acceptance criteria 2 and 3 a landable
observation seam; and corrects four line citations plus the criterion-4
rationale. Revision 1's diagnosis is unchanged — the defect, its measurements,
and the three falsified hypotheses all stand.
Reported symptom: *"Text input within the terminal doesn't work on GUI, this
is fine in TUI."*
This is a bug-fix framing, not a feature. It repairs a defect in Vterm Stage 3
(#135) that ships on `main` today, and it closes the acceptance hole that let
the defect ship: the Stage 3 real-path acceptance drives a terminal session
end to end, and *still could not see this*.
## Summary of the defect
Every dispatcher tick, the daemon applies **both** terminal-layout syncs to
**every** attached frontend:
```rust
// src/daemon.rs:1536-1554 (current main)
for frontend_id in &attached_fids {
if let Some(size) = term_sizes.get(frontend_id).copied() {
editor.sync_terminal_layout(*frontend_id, size); // GRID path
}
if let Some((buffer_id, size)) = semantic_states
.get(frontend_id)
.and_then(SemanticRenderState::terminal_viewport)
{
editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size); // SEMANTIC path
}
}
```
They are written as twins — the comment on the semantic arm even says *"right
beside the grid sync"* — but they are applied as **siblings, not
alternatives**. A GPU session has an entry in `term_sizes` (its `AttachRequest`
carries an initial cell size, and `Resize` events maintain it) *and* a
semantic terminal declaration. So both run, every tick.
The two disagree by construction, and the semantic arm's own doc comment says
why:
> the frontend declared a CONTENT rectangle, so this consumes the size
> directly instead of running the TUI placement helper, **which would subtract
> a modeline the GPU never drew**.
That is exactly what the grid arm then does. Measured, on a real daemon with a
real PTY and the real GPU attach client:
```
PROBE sync_semantic old=Some(24x80) declared=25x92
PROBE manager.resize BufferId(3) 25x92
PROBE manager.resize BufferId(3) 22x80
PROBE sync_semantic old=Some(22x80) declared=25x92
PROBE manager.resize BufferId(3) 25x92
PROBE manager.resize BufferId(3) 22x80
...
```
The PTY is resized **twice per dispatcher tick, forever**. Each resize is a
`TIOCSWINSZ` + `SIGWINCH` to the child and a screen reflow in
`TerminalScreen`, so the child gets a SIGWINCH storm at tick cadence and the
screen alternates between two geometries. An interactive line editor
(readline, zle, fish's reader) redraws on every SIGWINCH, so what the user
types is continuously destroyed before it can settle — while ordinary child
*output* keeps flowing, which is why the terminal looks alive.
Measured user-visible effect, real bash `-i` in the real GPU path, typing one
character:
| | frames for a static screen | typed `Z` ever visible at the prompt |
|---|---|---|
| `main` today | **730** in a 20 s window | **no** |
| with the guard | **2** | (see Q#GT5 — a separate question) |
The TUI is unaffected: a grid session has no semantic terminal declaration, so
only one arm ever runs for it. This is a **frontend-kind** defect, which is
why it presents as "GUI broken, TUI fine".
## Ground truth (measured this session, not inferred)
Everything below was established against `main` @ `8c86d34` with a real
daemon, a real PTY child, and the real `pmacs-gpu` attach client. The probe
harness is preserved (see "Verification plan").
### What is *not* wrong — three hypotheses falsified
Recording these because each is a plausible-looking cause that a future
reader (or a review round) will re-propose.
1. **The GPU's optimistic-CRDT path is not implicated.** The first hypothesis
was that a typed character becomes a `CrdtOp` against the read-only
terminal identity buffer and is dropped. It does not. Terminal buffers are
already marked round-trip — `core.set_round_trip_input(buffer_id, true)`
at `src/terminal/session.rs:338`, beside `set_read_only(true)` — so
`dispatch_idle_for` returns **false** while a terminal window is focused,
the daemon publishes `DispatchIdle { idle: false }`, and the GPU's
`daemon_intercepts_keys()` is true. Measured on the wire:
`dispatch_idle_in_terminal=false`, `intercept_in_terminal=true`,
`input_route=send_key(intercept)`. The optimistic gate is shut.
2. **Key transport is not implicated.** The keystroke reaches the daemon,
resolves a terminal view key, encodes, and is written to the PTY without
error: `PROBE dispatch_key ... terminal_key=Some(TerminalViewKey { .. })`,
`PROBE terminal transport encode=Some([90])`, `PROBE after send status=""`.
With a `cat` child the byte comes back on screen through the whole real GPU
path (`echoed_typed_char=true`).
3. **The `pmacs --attach` TUI replica does *not* share the defect.** It gates
its optimistic path on `dispatch_idle` alone (`src/attach.rs:843`), and
that signal is already correct for terminals per (1).
### The mechanism
- `EditorInstance::sync_terminal_layout` (`src/editor.rs:1195`) is the grid
path: it runs the TUI placement helper over the frontend's *frame* size.
- `EditorInstance::sync_semantic_terminal_layout` (`src/editor.rs:1331`) is
the semantic path: it consumes a declared *content* rectangle directly.
- Both resolve the same controller and call `TerminalManager::resize` on the
same session. Each has a correct `old_size == size` idempotence guard
(`src/editor.rs:1239` grid, `src/editor.rs:1360` semantic) — the guards are
individually sound and jointly useless, because each arm sees the size the
*other* just installed.
- `TerminalViewStore::record_view_size` (`src/terminal/view.rs:276-292`)
returns `true` for any valid declaration with no unchanged-size dedupe,
which is why the semantic arm re-fires every tick against the grid arm's
flip rather than settling.
- Loop order is grid first, semantic second, so the screen *ends* each tick at
the declared size. That is why rendering looks alive while the child is
whipsawed — and why a frame-based assertion is the wrong instrument
(acceptance criteria 2 and 3).
- `TerminalScreen::changed` bumps `generation` per mutation
(`src/terminal/screen.rs:1467`), which is why generation advances by
**exactly 2** per tick — one bump per resize.
- Frame suppression is full-struct equality
(`self.last_terminal_frame.as_ref() == Some(&frame)`,
`src/semantic_render.rs:882`). It is behaving correctly: the frames really
do differ. The churn is upstream, and fixing the churn fixes the frame
storm. **No suppression change is proposed.**
### Why the Stage 3 acceptance could not catch it
`a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session`
(`tests/vterm_stage3_acceptance.rs:637`) is a genuine real-daemon +
real-PTY + real-wgpu path, and it still passes on the broken tree. Three
reasons, each worth keeping:
1. Its child is `sh` printing 400 rows on a timer. **A frame storm is
invisible against a child that legitimately produces ~400 frames**, and its
only frame-count assertion is `frames >= 2`.
2. Its input step is `client.send_key(...)` called **directly**
(`pmacs-gpu/src/main.rs:784-785`), so it pins transport, not routing — and
it asserts nothing about the result of that input reaching the child.
3. It resizes **once, deliberately**, and asserts the new width comes back.
A geometry that oscillates *through* the asserted width satisfies that
assertion. This is the project's own "a geometric readout is not a state
predicate" lesson (`docs/active-work.md`, bottom-panel round 2) in a new
place: `observed_resized_frame` says "a frame at this width arrived", not
"the geometry settled at this width".
## Decisions
**Q#GT1 — Where does the fix go?** `sync_terminal_layout` is **split**, and
only its geometry half is gated by frontend kind. A bare "skip the grid arm
for semantic frontends" guard is wrong — see Q#GT4, which establishes that the
grid arm is also the only per-tick controller-liveness release. Not by
removing `term_sizes` for semantic sessions: semantic key and mouse dispatch
hard-depend on it (`src/daemon.rs:2191-2213`).
The function has three separable concerns
(`src/editor.rs:1195-1260`), and they do not split where the name suggests:
| lines | concern | frontend kind |
|---|---|---|
| 1199 | `reconcile_panel_layout` (Q#BP2b per-tick defensive) | **neutral** |
| 1200-1221 | controller liveness: released when the frontend has no view, or its active window no longer shows that terminal | **neutral** — reads only `core.views` / `core.windows` / the controller, never `term_size` |
| 1222-1259 | TUI placement (`window_placements`) + `resize` | **grid only** |
So the daemon loop becomes: run the neutral half for every attached frontend
every tick, then exactly one geometry arm per frontend kind.
`sync_terminal_layout` survives as the composition of both halves, so
`editor::run`'s in-process loop and `LOCAL` keep byte-identical behavior. The
liveness half must run **once** per frontend per tick — reconciliation is
idempotent, so a double call is safe rather than wrong, but the loop should
not pay for it.
**The trap inside the split:** the third release, at `src/editor.rs:1226`
(no placement found for the window), looks like liveness and is **not** — it
is grid geometry. A semantic frontend has no `window_placements` entry at all,
so moving that arm into the neutral half would release a GPU session's
controller on every single tick. That would be a new defect of exactly the
family this framing fixes, so it stays in the grid half.
**Q#GT2 — Which arm wins for a semantic frontend?** The semantic one,
unconditionally. It is the only arm that consumes a *content* rectangle; the
grid arm's modeline subtraction is meaningless for a frontend that draws no
modeline into the terminal band. A GPU frontend that has not yet declared a
terminal viewport gets **neither** arm, which is correct: the terminal keeps
the geometry it was opened with until the frontend declares one.
**Q#GT3 — Is the guard "no semantic state" or "not a semantic session"?**
`semantic_states` keyed by frontend id is the same map the semantic arm reads
one line later, so the two arms become provably exclusive by construction
rather than by two independent predicates that could drift apart. Rejected
alternative: keying on the negotiated `semantic_render` capability bit — it is
the *same* fact one indirection away, and the pair could then disagree.
**Q#GT4 — Does anything else in `sync_terminal_layout` need to keep running
for a semantic frontend? Yes: the controller-liveness release, and the
semantic arm neither performs it nor can be made to.** Revision 1 left this
open; the code answers it.
`release_controller` is called from exactly five sites, all in
`src/editor.rs`: the three grid-arm early returns (1210, 1219, 1226),
`dispatch_focus(gained = false)` (1189), and `reconcile_panel_layout`'s
unsatisfiable-panel path (848). **`sync_semantic_terminal_layout` releases
nothing.** When the window has switched away, `semantic_terminal_key` returns
`None` (`src/editor.rs:1278` — `window.buffer_id != buffer_id`) and the arm
returns `false` without touching the controller.
Growing a release inside the semantic arm — revision 1's stated fallback for
B1 — **cannot work**, and the reason is worth keeping: when a GPU window
switches from the terminal to a document, the buffer-follow snapshot clears
the viewport declaration (`on_buffer_snapshot_sent` sets
`terminal_viewport = None`, `src/semantic_render.rs:574`), so
`terminal_viewport()` returns `None` and the semantic arm **stops running
entirely** for that frontend. A release placed inside it would never execute
in precisely the scenario that needs it.
Nor do the other two sites cover it: `dispatch_focus(false)` fires on
whole-frontend focus loss, not on a window or buffer switch, and semantic
sessions are not panel-capable yet (`panel_capable_for` is false for them —
`src/daemon.rs:1893-1898`), so 848 never fires either.
Consequence of shipping revision 1's guard as written: a GPU frontend that
switches away from its terminal **holds the controller indefinitely**. Because
another frontend's grid sync early-returns on a
`controller_view_for_frontend` mismatch, that peer then cannot resize the PTY
until it explicitly re-claims. This is why Q#GT1 splits the function instead
of gating it.
**Q#GT7 — The per-tick defensive panel reconcile stays for semantic
frontends.** It is the only per-tick pre-paint reconcile the Q#BP2b contract
names (`src/editor.rs:822-830`), and today the grid arm supplies it for GPU
sessions too. Putting it in the neutral half of the split preserves that
exactly. It is harmless-either-way today — semantic sessions have unknown
frame geometry until the bottom-panel GPU band lands — but "harmless today"
is not a reason to remove a contract's only per-tick enforcement point in a
PR about something else.
**Q#GT5 — Typed characters not echoing by an interactive shell is a
*separate* question and is deliberately out of scope.** Measured: with `bash
--norc -i` on a `TerminalMode::Raw` PTY, typed characters are not echoed to
the screen — **and this reproduces identically in-process**, i.e. on the TUI's
own path, where the user reports the terminal works. Because it is not
frontend-specific it cannot be the GUI/TUI asymmetry, and folding it in would
make this PR two features. It gets its own scout: whether `TerminalMode::Raw`
is the right mode for a `pmacs.terminal.open` child, and what pmacs owes a
child that expects to own its termios. Named, not silently dropped.
**Q#GT6 — Protocol impact: none.** No wire shape, no negotiation, no version
change. Stays v20.
## Bets
- **B1 — SCORED HALF-FALSE before implementation (revision 2).** "Removing the
grid arm for semantic frontends removes the storm without removing any
behavior a GPU session relies on." The first clause holds (measured). The
second is **false**: it also removes the only controller-liveness release
and the only per-tick Q#BP2b reconcile a GPU session gets (Q#GT4, Q#GT7).
Its stated contingency — "the semantic arm grows the release" — is false
too, for a structural reason (`terminal_viewport` is cleared by the very
snapshot that signals the switch-away). Hence the split in Q#GT1. Recorded
rather than deleted: the failure mode is one a reviewer or a future
simplification will re-propose.
- **B2.** The user's reported symptom is this defect. *Partially scored: the
storm is proven and GUI-only, and its shape (line editor unusable, output
still flowing) matches the report. Not fully scored until the user, or an
acceptance running the **user's own shell**, confirms typing works after the
fix. Q#GT5 is the reason this bet is stated rather than assumed.*
- **B3.** No other pair of per-frontend-kind daemon operations is applied as
siblings rather than alternatives. *Scored by an explicit audit of the
dispatcher's per-frontend loop during implementation — this defect's shape
is "twins applied as siblings", and it would be negligent to fix one
instance without looking for others.*
## Deferred (named)
- Interactive-shell echo on a raw-mode PTY (Q#GT5) — its own scout.
- **A geometry change appears to clear the visible screen.** Observed while
building acceptance 4: after the probe's deliberate 25×92 → 20×71 resize,
the next frame's visible grid is entirely blank even though the content
(two short lines near the top) should survive a shrink of that size. It
reproduces on the pre-fix tree, so it is neither caused nor fixed here, and
it is why acceptance 4 latches its observation across frames instead of
reading the final one. Not investigated: it could be correct reflow
behaviour given where the child leaves its cursor (frames show the cursor
on the bottom row), or a real reflow defect. Named because the next person
to write a resize assertion will hit it.
- `TerminalFrame` suppression including `screen_generation` in its equality:
correct today and load-bearing for correctness, but it means any future
content-neutral generation bump re-emits a frame. Recorded, not changed.
- The `a37` probe's structural weaknesses beyond what the acceptance below
fixes (it still cannot exercise `App::window_event`'s routing, because that
logic is inline in the winit handler with no extractable seam). Making GPU
key routing testable is a real refactor and belongs to its own lane.
## Acceptance criteria
**The observation seam (revision 2).** Criteria 2, 3 and 6 assert daemon-side
state, and `TestDaemon` runs the daemon as a **subprocess**
(`tests/common/daemon.rs:90`), so nothing in-process can see it and the
scouting instrumentation does not land. The seam that does land: **extract the
dispatcher loop's per-frontend terminal-layout step into a named function**
that takes `(&mut EditorState, &[FrontendId], &term_sizes, &semantic_states)`.
That is required by Q#GT1's split anyway, it makes the grid/semantic
exclusivity structural rather than two adjacent `if`s, and it lets an
in-process test in the style of the existing `src/daemon.rs` unit tests
(3375ff) drive **the real loop body** rather than a re-implementation — which
is the a37 lesson applied to this PR's own tests.
The observable is `TerminalScreen::generation`, reachable through
`TerminalManager::snapshot(..).generation`. It advances once per screen
mutation (`src/terminal/screen.rs:1467`), so with a quiet child it is a
**state predicate**, not a readout: "the geometry settled" is exactly
"generation stopped advancing".
1. On a real daemon + real PTY + real GPU attach, a terminal session that
receives no child output produces a **bounded** number of terminal frames
(settling to zero new frames once the screen is static) — not one per tick.
Fails on `main` with ~730 frames in 20 s; passes with ≤ a small constant.
2. Driving the extracted loop body N times against a semantic frontend with a
fixed declaration and a quiet child: `TerminalManager::resize` takes effect
**exactly once** (generation advances once, then is constant for the
remaining N-1 iterations). Fails on `main`, where generation advances by
two per iteration.
3. After a declaration, `screen_size(buffer)` **equals the declared content
rectangle and stays equal** across subsequent iterations — the state
predicate, not the "a frame at this width arrived" readout that
`observed_resized_frame` provides today.
4. A character sent through the real GPU attach client reaches the child and
its echo appears in a rendered frame. **This is a keep-working pin, not a
fix discriminator: it already passes on today's broken `main`** (falsified
hypothesis 2 measured `echoed_typed_char=true`). Pinned with a `cat` child
— not because `cat` echoes (termios `ECHO` is off in raw mode; nothing
echoes) but because `cat` *copies stdin to stdout*, so the byte comes back
exactly once, with no line discipline and no double echo to disambiguate.
5. A grid (TUI) session's terminal resize behavior is **unchanged** — pinned
against the existing Stage 2 real-TUI PTY smoke, which must stay green
without modification.
6. A semantic frontend whose window stops showing the terminal **releases its
controller** (Q#GT4), pinned through the extracted loop body — driven by an
actual buffer switch, not by calling the release directly. This one bites
against **revision 1's naive guard**, and deliberately **passes on `main`**:
today's sibling arms do supply the release, by the accident of the grid arm
running for a frontend it should never have run for. It is the pin that
stops the fix from trading one defect for another.
7. End-to-end SIGWINCH count through the real PTY: a child trapping `WINCH`
and printing a **fresh distinct breadcrumb per signal** (`WINCH 1`,
`WINCH 2`, …) shows a bounded count. The distinctness is load-bearing —
the established PTY-paint trap is that cell diffing skips both spaces and
already-matching cells, so a repeated identical marker can assert nothing.
8. Bite-verified against **two** pre-images, because one is not enough here —
the naive guard fixes the storm and introduces a different defect, so a
single revert would score the fix complete when it is not. Measured
(`cargo test --lib`, manual revert since these tests share `src/daemon.rs`
with the production code):
| pin | `main` (sibling arms) | rev-1 naive guard | the split |
|---|---|---|---|
| acc 2+3 settle | **FAIL** | pass | pass |
| acc 6 controller release | pass | **FAIL** | pass |
| acc 5 grid still resizes | pass | pass | pass |
The middle column is B1's half-false score made executable: the naive
guard's first clause holds (the storm stops) and its second does not.
Criteria 1, 2 and 7 are deliberately expressed as **quiet-child** assertions,
because the existing acceptance's chatty child is exactly what hid this.
## Coherence impact (`COHERENCE.md` §20)
- **§2 golden journey, step 8 ("Open a terminal")** — currently graded *"Works
but undiscoverable"*. On the GPU frontend it does not work; this restores
the step for the frontend the document calls the more capable one. Priority
1 explicitly treats journey regressions as release blockers.
- **§16 Productize the Semantic Frontend Architecture** — graded *strong*,
with "graceful per-frontend degradation is practiced, not aspirational" as
its evidence, citing per-frontend fold projection. This defect is the
counter-example: a per-frontend-kind operation applied to both kinds at
once. The section's claim survives, but the audit should record that the
practice is enforced by convention, not by structure — two arms that must be
alternatives are currently just two adjacent `if`s. Q#GT1's extracted loop
body makes this one structural; the audit note should say the *pattern* is
still convention-enforced everywhere else (B3).
- **§6 Eliminate Hardcoded Interaction Islands** — the audit's row 6 note that
the GPU optimistic classifier "is kept honest by `dispatch_idle_for`" is
**confirmed correct** by this investigation (falsified hypothesis 1), and the
§6 citation `crate::optimistic::classify_key` should be corrected: that
symbol is `src/optimistic.rs`, the **`pmacs --attach` TUI replica's**
classifier. The GPU's separate, unrelated classifier is
`optimistic_insert_text` / `optimistic_crdt_insert` in
`pmacs-gpu/src/main.rs`. Two replica frontends, two classifiers; the audit
conflates them.
- **§19 Product Coherence Acceptance Tests** — this is a concrete instance of
the section's thesis. Every subsystem test passed; the defect lives in how
two correct subsystems compose per frontend kind. Criterion 1's quiet-child
shape is the transferable technique.
- No interaction island added, no config registry surface, no background-work
attribution change.
## Verification plan
Full gate suite per `CLAUDE.md`, plus:
- `cargo test --features crdt --test vterm_stage3_acceptance` (the suite this
repairs) and `--test vterm_stage2_acceptance` (the TUI no-regression pin).
- `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`.
- The scouting harness is preserved and should be re-run against the branch:
a quiet-child variant of the `a37` probe plus daemon-side resize tracing,
saved as `scratch_gui_terminal_input.rs`, `scratch_inproc_input.rs`, and
`gui-terminal-probe-instrumentation.patch`. The instrumentation is scratch;
the acceptance criteria above are what lands.
- Manual confirmation with the user's own shell (fish) in a real GPU window,
since B2 is not fully scored by any automated test (Q#GT5).
**Ops.** This doc is currently untracked in a detached-HEAD worktree
(`../pmacs-gui-term-input`), so it does not travel. On approval it becomes the
branch's first commit before any implementation, per the standing workflow —
no cross-machine expectation should attach to it until then.

1467
docs/lean4-mode-framing.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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::<u64>().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<String>,
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<String>,
}
/// The character the probe types into the child. Distinct from anything the
/// acceptance children print themselves, so its appearance is unambiguous.
const PROBE_INPUT_CHAR: char = 'x';
/// One-line printable text of a terminal frame, for probe reporting.
fn frame_probe_text(frame: &TerminalFrame) -> String {
let mut text = String::new();
@ -8109,7 +8140,17 @@ fn dominant_line_shape(
indent_sum += shape.indent_cols;
content_sum += shape.content_cols;
}
(count > 0).then_some(MinimapLineShape {
// `then`, NOT `then_some`: `bool::then_some` takes its argument by
// value, so the struct literal --- and with it `indent_sum / count`
// --- is evaluated before the guard is ever consulted. A slab of
// all-blank source lines makes `count` zero and panics the frontend
// on the division. `bool::then` defers the body into a closure, so
// the zero case short-circuits to `None`.
//
// Clippy's `unnecessary_lazy_evaluations` lint pushes in exactly the
// wrong direction here; it does not fire on a body that can panic,
// but do not "simplify" this back.
(count > 0).then(|| MinimapLineShape {
indent_cols: indent_sum / count,
content_cols: content_sum.div_ceil(count),
})
@ -10678,6 +10719,66 @@ mod tests {
);
}
#[test]
fn minimap_downsampling_survives_a_slab_of_blank_lines() {
// Regression: `dominant_line_shape` counted only lines with
// content, then built its average with `then_some` --- which
// evaluates its argument eagerly, so `indent_sum / count`
// divided by zero whenever a downsampled pixel row covered
// nothing but blank lines. Reachable on any long file with a
// run of blank lines, which is precisely when the bucketing
// branch runs at all.
let red = style_with_fg(CellColor::Rgb(255, 0, 0));
let lines = vec![red; 10_000];
// Every line blank: `minimap_line_shape("")` yields
// `content_cols == 0`, so `has_content()` is false throughout
// and every bucket counts zero contentful lines.
let shapes = vec![
MinimapLineShape {
indent_cols: 0,
content_cols: 0,
};
lines.len()
];
let rects = minimap_rects(&lines, &shapes, 240, 120, 0, 30, FontMetrics::default());
// The strokes are all suppressed (no content to draw), but the
// thumb still paints --- the point is that this returns at all.
assert!(
rects.len() <= 8,
"blank slabs must emit no line strokes, got {}",
rects.len()
);
}
#[test]
fn minimap_downsampling_averages_only_contentful_lines() {
// Guards the other half: a bucket that mixes blank and
// contentful lines must average over the contentful ones only,
// so the fix cannot regress into `count = slice.len()`.
let blank = MinimapLineShape {
indent_cols: 0,
content_cols: 0,
};
let solid = MinimapLineShape {
indent_cols: 4,
content_cols: 20,
};
let shapes = [blank, solid, solid, blank];
let shape = dominant_line_shape(&shapes, 0, 4).expect("bucket has contentful lines");
assert_eq!(shape.indent_cols, 4, "blank lines must not dilute indent");
assert_eq!(shape.content_cols, 20, "blank lines must not dilute length");
}
#[test]
fn minimap_dominant_line_shape_is_none_for_an_empty_bucket() {
let shape = dominant_line_shape(&[], 0, 0);
assert!(shape.is_none(), "an empty bucket has no shape");
}
#[test]
fn minimap_hidden_when_surface_is_too_narrow() {
let lines = [style_with_fg(CellColor::Rgb(255, 0, 0))];

View File

@ -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<FsDirEntry>),
/// 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<FsDirEntry>),
/// 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())

View File

@ -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<FrontendId, CellSize>,
semantic_states: &HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
) {
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);

View File

@ -525,6 +525,17 @@ impl EditorState {
include_str!("../builtin/runtime/window.lua"),
)
.expect("load window builtin chunk");
// Dired Stage 1: the directory view. Loaded AFTER window.lua,
// whose `window.panel-height` setting a `display = "panel"`
// listing resolves, and after the pre-runtime tables it drives
// (`pmacs.config` / `command` / `keymap` / `buffer` / `editor` /
// `minibuffer` / `path`, plus `pmacs.fs` from fs.lua above).
lua_host
.eval(
Some("@pmacs/builtin/runtime/dired.lua"),
include_str!("../builtin/runtime/dired.lua"),
)
.expect("load dired builtin chunk");
// Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT:
// compile.lua must load AFTER lsp.lua. It takes over
// `M-g n` / `M-g p` for the unified error dispatchers, and
@ -1189,14 +1200,84 @@ impl EditorState {
let _ = self.terminal_manager.borrow_mut().release_controller(key);
}
/// Reconcile panels and release a controller whose window moved away.
///
/// **Frontend-kind neutral, and deliberately so** (Q#GT1/Q#GT4): this
/// half reads only `core.views`, `core.windows`, and the controller —
/// never a grid size — so it is the half the dispatcher runs for EVERY
/// attached frontend once per tick. It was previously fused into
/// [`Self::sync_terminal_layout`], which meant a semantic frontend got
/// its controller-liveness release only as a side effect of a grid
/// resize it should never have received.
///
/// [`Self::sync_semantic_terminal_layout`] cannot substitute for this:
/// when a GPU window switches away from its terminal, the buffer-follow
/// snapshot clears the viewport declaration
/// (`SemanticRenderState::on_buffer_snapshot_sent`), so the semantic arm
/// stops running entirely in exactly the case that needs the release.
///
/// Returns `true` while `frontend_id` still holds a live controller.
pub fn sync_terminal_controller_liveness(&mut self, frontend_id: FrontendId) -> bool {
// Bottom-panel arc (Q#BP2b): a panel that just became
// unsatisfiable must have released its controller before any
// resize runs, or the child would be resized against a dead rect.
// This is the contract's only per-tick enforcement point, and it
// stays neutral so semantic frontends keep it (Q#GT7).
self.reconcile_panel_layout(frontend_id);
let Some(key) = self
.terminal_manager
.borrow()
.controller_view_for_frontend(frontend_id)
else {
return false;
};
let core = self.core.borrow();
let Some(view) = core.views.get(&frontend_id) else {
drop(core);
let _ = self.terminal_manager.borrow_mut().release_controller(key);
return false;
};
if view.active != key.window_id
|| core
.windows
.get(&key.window_id)
.is_none_or(|window| window.buffer_id != key.buffer_id)
{
drop(core);
let _ = self.terminal_manager.borrow_mut().release_controller(key);
return false;
}
true
}
/// Resize the one session durably controlled by `frontend_id`.
///
/// This is called before process drain and paint, never from rendering.
///
/// Composition of the two halves, preserved verbatim for the in-process
/// `editor::run` loop and `LOCAL`. The daemon dispatcher calls the halves
/// separately, because only the geometry half is grid-specific.
pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool {
// Bottom-panel arc (Q#BP2b): a panel that just became
// unsatisfiable must have released its controller before this
// runs, or the child would be resized against a dead rect.
self.reconcile_panel_layout(frontend_id);
self.sync_terminal_controller_liveness(frontend_id)
&& self.sync_terminal_grid_geometry(frontend_id, term_size)
}
/// The grid half: TUI placement plus the resize it implies.
///
/// **Grid frontends only** (Q#GT1). The placement lookup below is why:
/// a semantic frontend has no `window_placements` entry at all, so the
/// "no placement" arm would release its controller on EVERY tick. That
/// release reads like liveness and is not — it is grid geometry, and
/// moving it into [`Self::sync_terminal_controller_liveness`] would
/// reintroduce this framing's own defect in a new place.
///
/// Assumes liveness already ran: the controller is live and its window
/// still shows the terminal.
pub fn sync_terminal_grid_geometry(
&mut self,
frontend_id: FrontendId,
term_size: CellSize,
) -> bool {
let Some(key) = self
.terminal_manager
.borrow()
@ -1206,23 +1287,11 @@ impl EditorState {
};
let content = {
let core = self.core.borrow();
let Some(view) = core.views.get(&frontend_id) else {
let _ = self.terminal_manager.borrow_mut().release_controller(key);
return false;
};
if view.active != key.window_id
|| core
.windows
.get(&key.window_id)
.is_none_or(|window| window.buffer_id != key.buffer_id)
{
let _ = self.terminal_manager.borrow_mut().release_controller(key);
return false;
}
let Some(placement) = window_placements(&core, frontend_id, term_size)
.get(&key.window_id)
.copied()
else {
drop(core);
let _ = self.terminal_manager.borrow_mut().release_controller(key);
return false;
};
@ -5648,17 +5717,25 @@ mod tests {
// ---- T M2.11 acceptance --------------------------------------------------
/// Every chord in the default global keymap must round-trip through
/// Every chord in the default keymap must round-trip through
/// `pmacs.describe.key`: returning a non-nil table whose `command`
/// matches the binding the keymap stack stores.
///
/// `describe.key` resolves against the **effective context**
/// (buffer-local → mode → global), so a mode-scoped default is
/// asserted with a buffer that carries that mode rather than
/// context-free. Dired is the first builtin to bind mode-scoped keys
/// (#129's first non-detection consumer), and without the mode in
/// place its `n` / `p` / `g` correctly resolve to nothing.
#[test]
fn describe_key_identifies_every_default_binding() {
use crate::keymap_stack::Scope;
let s = EditorState::new();
let kms = s.lua_host.keymaps().borrow();
let bindings: Vec<(String, String)> = kms
let bindings: Vec<(Scope, String, String)> = kms
.iter_all()
.into_iter()
.map(|(_, seq, b)| (crate::key::display_sequence(&seq), b.command))
.map(|(scope, seq, b)| (scope, crate::key::display_sequence(&seq), b.command))
.collect();
drop(kms);
// Sanity floor: the default keymap binds at least the M1 surface.
@ -5667,18 +5744,50 @@ mod tests {
"default keymap unexpectedly small: {} bindings",
bindings.len()
);
let modes: usize = bindings
.iter()
.filter(|(scope, _, _)| matches!(scope, Scope::Mode(_)))
.count();
assert!(
modes >= 1,
"a mode-scoped default is expected since dired Stage 1; \
found none, so the mode arm below asserts nothing"
);
for (seq, expected_command) in &bindings {
for (scope, seq, expected_command) in &bindings {
let mode = match scope {
Scope::Mode(name) => Some(name.clone()),
// No buffer-scoped defaults exist; a future one would
// need its own buffer context here.
Scope::Buffer(_) => continue,
Scope::Global => None,
};
// Set the context explicitly on EVERY iteration, including
// the global one: a mode left over from a previous iteration
// legitimately shadows a global binding of the same chord
// (dired's mode-scoped `RET` shadows
// `edit.newline-and-indent`, which is the point of the
// mode), so a leaked mode would make this assert the wrong
// thing.
let context = match &mode {
Some(name) => {
format!("pmacs.buffer.set_major_mode(pmacs.window.buffer(), {name:?}); ")
}
None => "pmacs.buffer.set_major_mode(pmacs.window.buffer(), nil); ".to_owned(),
};
let script = format!(
"local r = pmacs.describe.key({seq:?}); \
"{context}local r = pmacs.describe.key({seq:?}); \
if r == nil then return 'nil' else return r.command end"
);
let got: String = s.lua_host.lua().load(&script).eval().unwrap_or_else(|e| {
panic!("describe.key({seq}) raised: {e}");
});
assert_eq!(
&got, expected_command,
"describe.key for {seq:?} returned {got:?}, expected {expected_command:?}"
&got,
expected_command,
"describe.key for {seq:?} (scope {}) returned {got:?}, \
expected {expected_command:?}",
scope.render()
);
}
}

View File

@ -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

329
src/fs.rs
View File

@ -42,6 +42,28 @@ use crate::worker::CancellationToken;
/// directories.
const READDIR_CANCEL_POLL_EVERY: usize = 32;
/// How many *consecutive* `readdir` iterator errors a tolerant listing
/// records before giving up and failing (dired Q#DR6).
///
/// [`std::fs::ReadDir`] is not obliged to terminate after yielding an
/// `Err`: a directory pulled out from under a stalled network mount can
/// keep producing them. Tolerant mode records-and-continues, so without
/// a bound that is an unbounded error vector on a worker thread.
///
/// Cancellation is **not** an adequate backstop here, which is the
/// reason this constant exists rather than a comment saying it is: a
/// dired listing carries no supersede key and nothing cancels it, so the
/// only thing that would stop the loop is the directory itself. A
/// directory whose iterator produces nothing but errors has no partial
/// answer worth rendering, so the listing fails with the last error the
/// way an unopenable directory does.
///
/// Deliberately untested: forcing a real `readdir` to yield errors
/// repeatedly is not portable, and faking it would need the walk to be
/// generic over its iterator — a refactor with no other consumer. The
/// counter resets on any entry that materializes.
const READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS: usize = 1024;
/// One directory entry as returned by [`read_dir_blocking`].
///
/// The shape is what `dired` / `magit-class` / `outline-class`
@ -116,6 +138,59 @@ impl FsEntryKind {
}
}
/// Per-entry tolerance for [`read_dir_blocking`] (dired Q#DR6).
///
/// The M8.1 primitive was all-or-nothing: five per-entry conditions
/// failed the *entire* listing, which makes a plain refresh of a busy
/// directory (`/tmp`, a build tree) fail outright. The module doc used
/// to say a per-entry-tolerant wrapper was "the package's job" --- it
/// cannot be: the primitive hands Lua one structured error and no
/// partial vec, so there is nothing to be tolerant *with*.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReadDirTolerance {
/// Any per-entry failure fails the whole listing. The original
/// M8.1 contract, and still the default at every Lua call site
/// that does not opt in.
Fatal,
/// Per-entry failures are recorded in [`FsDirListing::errors`] and
/// enumeration continues. A failure on the *parent* `read_dir`
/// stays fatal (a directory you cannot open has no partial
/// answer), and so does a non-UTF-8 entry **name** --- see
/// [`FsError::NonUtf8Path`].
PerEntry,
}
/// One per-entry failure recorded by a tolerant [`read_dir_blocking`].
///
/// `name` is optional because a per-entry `readdir` *iterator* error
/// has no filename to report: the entry never materialized, and the
/// underlying error is about the parent directory. Every other arm has
/// an entry in hand and names it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsDirEntryError {
/// Basename of the entry that failed, when one is known.
pub name: Option<String>,
/// 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<FsDirEntry>,
/// Per-entry failures; `None` in [`ReadDirTolerance::Fatal`] mode.
pub errors: Option<Vec<FsDirEntryError>>,
}
/// Errors produced by [`read_dir_blocking`] / [`stat_blocking`] /
/// [`rename_blocking`] / [`chmod_blocking`] / [`remove_blocking`].
///
@ -192,50 +267,100 @@ pub enum FsError {
/// `to_string_lossy` would have mangled dired/wdired round-trips).
///
/// Errors on the *parent* `read_dir` call surface as
/// [`FsError::Io`]. Errors on individual entries (a single broken
/// symlink, a permission-denied stat) currently propagate the same
/// way --- the cleanest behavior at this primitive layer is "fail
/// fast and let the caller decide whether a partial listing is
/// acceptable"; dired-class will likely want a per-entry-tolerant
/// wrapper but that's the package's job, not the primitive's.
/// [`FsError::Io`] regardless of `tolerance`: a directory you cannot
/// open has no partial answer.
///
/// Errors on individual entries (a permission-denied `lstat`, a child
/// unlinked between `readdir` and `lstat`, a `readlink` failure, a
/// non-UTF-8 symlink target) are governed by `tolerance`. Under
/// [`ReadDirTolerance::Fatal`] they fail the whole listing, which is
/// the M8.1 contract every existing caller relies on; under
/// [`ReadDirTolerance::PerEntry`] they land in
/// [`FsDirListing::errors`] and enumeration continues (dired Q#DR6).
///
/// A non-UTF-8 entry **name** is fatal in both modes. That is not a
/// listing problem but a path-representation one: [`FsDirEntry::name`]
/// is a `String` and every `pmacs.fs` op takes a `String` path, so a
/// tolerantly-rendered non-UTF-8 name would be a name the caller could
/// not pass back through `rename`. Byte-preserving paths are the named
/// deferral (see [`FsError::NonUtf8Path`]). A non-UTF-8 *target*
/// differs in kind --- the entry's own name is fine and nothing needs
/// to round-trip the target --- so it joins the per-entry channel.
pub fn read_dir_blocking(
path: &Path,
cancel: &CancellationToken,
) -> Result<Vec<FsDirEntry>, FsError> {
tolerance: ReadDirTolerance,
) -> Result<FsDirListing, FsError> {
let iter = std::fs::read_dir(path).map_err(|source| FsError::Io {
path: path.display().to_string(),
source,
})?;
let mut out: Vec<FsDirEntry> = Vec::new();
let mut errors: Option<Vec<FsDirEntryError>> =
matches!(tolerance, ReadDirTolerance::PerEntry).then(Vec::new);
let parent_str = path.display().to_string();
let mut consecutive_entry_errors = 0usize;
for (i, entry_result) in iter.enumerate() {
if i % READDIR_CANCEL_POLL_EVERY == 0 && cancel.is_cancelled() {
return Err(FsError::Cancelled);
}
let entry = entry_result.map_err(|source| FsError::Io {
path: parent_str.clone(),
source,
})?;
let entry = match entry_result {
Ok(entry) => entry,
Err(source) => {
// R2-2: the entry never materialized, so there is no
// name to report and the error names the parent.
let error = FsError::Io {
path: parent_str.clone(),
source,
};
consecutive_entry_errors += 1;
if consecutive_entry_errors > READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS {
return Err(error);
}
record_entry_error(&mut errors, None, error)?;
continue;
}
};
consecutive_entry_errors = 0;
let entry_path = entry.path();
let metadata = std::fs::symlink_metadata(&entry_path).map_err(|source| FsError::Io {
path: entry_path.display().to_string(),
source,
})?;
let kind = classify(&metadata);
let symlink_target = if matches!(kind, FsEntryKind::Symlink) {
match std::fs::read_link(&entry_path) {
Ok(t) => Some(path_to_utf8_string(t.as_os_str(), &parent_str)?),
Err(source) => {
return Err(FsError::Io {
// Resolved first so a later per-entry failure can name it.
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
let metadata = match std::fs::symlink_metadata(&entry_path) {
Ok(metadata) => metadata,
Err(source) => {
record_entry_error(
&mut errors,
Some(&name),
FsError::Io {
path: entry_path.display().to_string(),
source,
});
}
},
)?;
continue;
}
} else {
None
};
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
let kind = classify(&metadata);
let mut symlink_target = None;
if matches!(kind, FsEntryKind::Symlink) {
match std::fs::read_link(&entry_path) {
// A target we cannot represent leaves the entry in the
// listing with its target unknown, not the entry out of
// it: one weird symlink in `/tmp` used to take the
// whole directory down.
Ok(target) => match path_to_utf8_string(target.as_os_str(), &parent_str) {
Ok(target) => symlink_target = Some(target),
Err(error) => record_entry_error(&mut errors, Some(&name), error)?,
},
Err(source) => record_entry_error(
&mut errors,
Some(&name),
FsError::Io {
path: entry_path.display().to_string(),
source,
},
)?,
}
}
out.push(FsDirEntry {
name,
kind,
@ -246,7 +371,33 @@ pub fn read_dir_blocking(
symlink_target,
});
}
Ok(out)
Ok(FsDirListing {
entries: out,
errors,
})
}
/// Route one per-entry failure: append it to the tolerant channel, or
/// propagate it when the caller asked for the fatal contract.
///
/// `errors.is_none()` *is* [`ReadDirTolerance::Fatal`] --- keeping the
/// mode in the accumulator rather than passing it separately makes the
/// two impossible to disagree.
fn record_entry_error(
errors: &mut Option<Vec<FsDirEntryError>>,
name: Option<&str>,
error: FsError,
) -> Result<(), FsError> {
match errors {
Some(list) => {
list.push(FsDirEntryError {
name: name.map(ToOwned::to_owned),
message: error.to_string(),
});
Ok(())
}
None => Err(error),
}
}
/// Convert an [`std::ffi::OsStr`] to `String` strictly. Returns
@ -495,12 +646,23 @@ mod tests {
CancellationToken::new()
}
/// The fatal-mode shorthand every pre-Q#DR6 test used.
fn read_dir_fatal(path: &Path, cancel: &CancellationToken) -> Result<Vec<FsDirEntry>, FsError> {
read_dir_blocking(path, cancel, ReadDirTolerance::Fatal).map(|listing| {
assert!(
listing.errors.is_none(),
"fatal mode must not open a per-entry channel"
);
listing.entries
})
}
#[test]
fn read_dir_returns_entries_with_lstat_metadata() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("a.txt"), b"hello").expect("write");
std::fs::create_dir(td.path().join("subdir")).expect("mkdir");
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir");
let entries = read_dir_fatal(td.path(), &token()).expect("read_dir");
let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, vec!["a.txt", "subdir"]);
@ -516,7 +678,7 @@ mod tests {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
symlink("real.txt", td.path().join("link")).expect("symlink");
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir");
let entries = read_dir_fatal(td.path(), &token()).expect("read_dir");
let link = entries.iter().find(|e| e.name == "link").unwrap();
assert_eq!(link.kind, FsEntryKind::Symlink);
assert_eq!(link.symlink_target.as_deref(), Some("real.txt"));
@ -535,7 +697,7 @@ mod tests {
}
let cancel = token();
cancel.cancel();
let err = read_dir_blocking(td.path(), &cancel).expect_err("must observe cancel");
let err = read_dir_fatal(td.path(), &cancel).expect_err("must observe cancel");
assert!(matches!(err, FsError::Cancelled), "got {err:?}");
}
@ -627,7 +789,7 @@ mod tests {
fn read_dir_on_missing_path_reports_io_error() {
let td = tempfile::tempdir().expect("tempdir");
let missing = td.path().join("does-not-exist");
let err = read_dir_blocking(&missing, &token()).expect_err("must error");
let err = read_dir_fatal(&missing, &token()).expect_err("must error");
match err {
FsError::Io { path, .. } => {
assert!(
@ -640,6 +802,109 @@ mod tests {
}
}
#[test]
fn read_dir_tolerant_opens_an_empty_error_channel_on_a_clean_directory() {
// `Some(vec![])` rather than `None` is the whole shape
// contract: the Lua boundary keys the bare-array-vs-table
// result on `errors.is_some()`, so a clean tolerant listing
// must still carry the channel.
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("a.txt"), b"x").expect("write");
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
.expect("tolerant read_dir");
assert_eq!(listing.entries.len(), 1);
assert_eq!(listing.errors.as_deref(), Some(&[][..]));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn read_dir_tolerant_keeps_an_entry_whose_symlink_target_is_not_utf8() {
use std::os::unix::ffi::OsStrExt;
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
// A legal Unix symlink target that is not representable as a
// Rust `String`. Before Q#DR6 this single entry took the whole
// listing down.
symlink(
std::ffi::OsStr::from_bytes(b"tgt-\xff"),
td.path().join("weird"),
)
.expect("symlink");
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
.expect("tolerant read_dir must survive a non-UTF-8 target");
let weird = listing
.entries
.iter()
.find(|e| e.name == "weird")
.expect("the entry itself must be listed");
assert_eq!(weird.kind, FsEntryKind::Symlink);
assert!(
weird.symlink_target.is_none(),
"an unrepresentable target reports as unknown"
);
assert!(
listing.entries.iter().any(|e| e.name == "real.txt"),
"the readable sibling must survive too"
);
let errors = listing.errors.expect("tolerant mode opens the channel");
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
assert_eq!(errors[0].name.as_deref(), Some("weird"));
// The same directory under the fatal contract still fails
// whole-listing --- the opt is what changes behavior, not the
// walk.
let err = read_dir_fatal(td.path(), &token()).expect_err("fatal mode must still fail");
assert!(
matches!(err, FsError::NonUtf8Path { .. }),
"expected NonUtf8Path, got {err:?}"
);
}
#[test]
fn read_dir_tolerant_records_a_failed_lstat_and_lists_nothing_else_wrong() {
use std::os::unix::fs::PermissionsExt;
// Failure mode 1 from the framing: a directory readable but not
// searchable. `readdir` yields the names; every child `lstat`
// fails with EACCES.
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("no-search");
std::fs::create_dir(&dir).expect("mkdir");
std::fs::write(dir.join("child"), b"x").expect("write child");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).expect("chmod 400");
let searchable = std::fs::symlink_metadata(dir.join("child")).is_ok();
if searchable {
// Running as root (or on a filesystem that ignores the
// bits): the premise cannot be established, so assert
// nothing rather than pass vacuously.
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.expect("restore perms");
eprintln!("lstat still succeeds without search permission; skipping");
return;
}
let tolerant = read_dir_blocking(&dir, &token(), ReadDirTolerance::PerEntry);
let fatal = read_dir_fatal(&dir, &token());
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.expect("restore perms");
let listing = tolerant.expect("tolerant read_dir must not fail the listing");
assert!(
listing.entries.is_empty(),
"the unreadable child cannot be described: {:?}",
listing.entries
);
let errors = listing.errors.expect("tolerant mode opens the channel");
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
assert_eq!(
errors[0].name.as_deref(),
Some("child"),
"an lstat failure has an entry in hand and must name it"
);
let err = fatal.expect_err("fatal mode must still fail the whole listing");
assert!(matches!(err, FsError::Io { .. }), "got {err:?}");
}
#[cfg(not(target_os = "macos"))]
#[test]
fn read_dir_on_non_utf8_entry_name_reports_structured_error() {
@ -650,7 +915,7 @@ mod tests {
// Rust `String`.
let bad_name = std::ffi::OsStr::from_bytes(b"bad-\xff-name");
std::fs::write(td.path().join(bad_name), b"").expect("write entry");
let err = read_dir_blocking(td.path(), &token()).expect_err("must error on non-UTF-8");
let err = read_dir_fatal(td.path(), &token()).expect_err("must error on non-UTF-8");
match err {
FsError::NonUtf8Path { parent, bytes } => {
assert!(

View File

@ -174,6 +174,46 @@ impl Theme {
// prefix-walks to `tag`.
("tag", fg(5)),
("attribute", fg(3)),
// Lean 4 (framing Q#LN4). These four are the captures the Lean
// query uses that the set above lacks — but three of them are
// NOT Lean-only, and adding them here changes languages that
// already ship. That is the #146 lesson (`attribute`, above,
// retro-painted rust/lua/yaml) and it is deliberate, not
// incidental:
//
// * `constructor` reaches SEVEN entries — rust, lua, python,
// javascript, and (because `tree_sitter_javascript::
// HIGHLIGHT_QUERY` is concatenated base-first into them)
// javascriptreact, typescript, typescriptreact. Its shape is
// not "constructors": rust/python/javascript tag every
// capitalized identifier (`#match? "^[A-Z]"`), and lua tags
// every table-constructor brace. So this recolors `Some`,
// `None`, `Ok`, `Err`, every class-cased name, and every Lua
// `{}`. All of those render as unstyled default text today.
// * `character` reaches zig only.
// * `keyword.conditional` reaches cmake and zig, which
// currently flatten it to `keyword`; giving it
// `keyword.control`'s style makes their conditionals read the
// way rust's already do.
// * `warning` reaches no other grammar. It exists for Lean's
// `sorry` — an unproved goal, the single most important thing
// to see in a proof file.
//
// The alternative was an in-repo query overlay renaming these
// into the existing vocabulary (the #144 LaTeX pattern), which
// would fork a 213-line query we would then own and hand-merge
// on every crate bump. There is no middle option: styling Lean's
// constructors without touching the other seven entries requires
// renaming the capture, which requires the overlay.
("constructor", fg(11)),
("character", fg(2)),
("keyword.conditional", fg_bold(13)),
// Bold BRIGHT red, deliberately the loudest entry in the table
// and deliberately distinct from `number`'s plain `fg(1)`: in a
// proof file `sorry` means "this is admitted, not proved", which
// is the one thing a reader must never skim past. Plain `fg(1)`
// would have collided with every numeric literal on colour alone.
("warning", fg_bold(9)),
];
let by_capture = entries
.iter()
@ -1484,6 +1524,262 @@ mod tests {
);
}
/// Paint `src` as `language` into a one-row grid and return the style
/// at column `col`. Shared by the Q#LN4 retro-paint pins below.
fn painted_fg_at(
language_name: &str,
file: &str,
src: &str,
col: u32,
) -> pmacs_protocol::cell::Color {
painted_style_at(language_name, file, src, col).fg
}
/// As [`painted_fg_at`], but returns the whole style — needed where a
/// colour alone does not discriminate (Lean's `warning` vs `number`).
fn painted_style_at(
language_name: &str,
file: &str,
src: &str,
col: u32,
) -> pmacs_protocol::cell::Style {
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::cell::{Cell, CellSize};
use crate::syntax::{ParseView, SyntaxRegistry};
let reg = SyntaxRegistry::new();
let language = reg.language(language_name).expect("grammar loads");
let mut buf = Buffer::new(BufferId::next(), file);
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: src.as_bytes(),
})
.unwrap();
let view = ParseView::new(&buf, language, language_name.to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let mut req = handle.make_request();
req.injection_aliases = reg.injection_alias_snapshot();
let bundle = crate::syntax::run_parse(req).expect("parse");
handle.install(reg.resolve_layer_queries(&bundle));
let mut hv = SyntaxHighlightView::new(handle, reg.theme());
let (rows, cols) = (1usize, 40usize);
let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
let mut grid = CellGrid {
cells: &mut backing,
stride: cols as u32,
size: CellSize::new(rows as u32, cols as u32),
};
let viewport = Viewport {
buffer_start: 0,
buffer_end: u64::MAX,
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
};
hv.render(&buf, viewport, &mut grid);
grid.get(CellCoord::new(0, col)).style
}
/// Does `language`'s compiled highlight query use `capture`?
fn query_uses_capture(language: &str, capture: &str) -> bool {
let reg = crate::syntax::SyntaxRegistry::new();
let Some(query) = reg.highlights_query(language) else {
panic!("{language} has no highlights query");
};
query.capture_names().contains(&capture)
}
#[test]
fn lean4_grid_paints_comment_keyword_name_operator_and_number() {
// Framing acceptance 5: the grammar plus the crate query plus the
// theme table actually produce distinct styles on a painted grid.
// Asserted end-to-end rather than at the query level because a
// capture that resolves to `Style::default()` is indistinguishable
// from no capture at all to a reader.
use pmacs_protocol::cell::Color;
// `-- c` — the whole comment run.
assert_eq!(
painted_fg_at("lean4", "a.lean", "-- c\n", 0),
Color::Indexed(8),
"a Lean line comment paints the comment style"
);
// `def foo : Nat := 42`
let src = "def foo : Nat := 42\n";
assert_eq!(
painted_fg_at("lean4", "a.lean", src, 0),
Color::Indexed(5),
"`def` paints the keyword style"
);
assert_eq!(
painted_fg_at("lean4", "a.lean", src, 4),
Color::Indexed(4),
"the definition's name paints the function style"
);
assert_eq!(
painted_fg_at("lean4", "a.lean", src, 14),
Color::Indexed(6),
"`:=` paints the operator style"
);
assert_eq!(
painted_fg_at("lean4", "a.lean", src, 17),
Color::Indexed(1),
"a numeric literal paints the number style"
);
// A string literal, and `theorem` as a second declaration keyword.
assert_eq!(
painted_fg_at("lean4", "a.lean", "def s := \"hi\"\n", 9),
Color::Indexed(2),
"a string literal paints the string style"
);
assert_eq!(
painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 0),
Color::Indexed(5),
"`theorem` paints the keyword style"
);
assert_eq!(
painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 8),
Color::Indexed(4),
"the theorem's name paints the function style"
);
}
#[test]
fn lean4_sorry_paints_the_warning_style_distinctly_from_a_number() {
// Framing acceptance 6. `sorry` admits a goal without proving it —
// in a proof file it is the single most important token to notice,
// and it is why Q#LN4 adds a `warning` entry at all.
//
// The style is asserted in FULL, not by colour: `number` and the
// first-choice `warning` colour were both indexed red, so a
// colour-only assertion would have passed with `sorry` painted
// exactly like the literal `42` beside it. That is the whole failure
// this test exists to prevent.
use pmacs_protocol::cell::Color;
let sorry = painted_style_at("lean4", "a.lean", "theorem t : True := sorry\n", 20);
assert_eq!(
sorry.fg,
Color::Indexed(9),
"`sorry` paints the warning colour"
);
assert!(sorry.bold, "`sorry` is bold");
let number = painted_style_at("lean4", "a.lean", "def n := 42\n", 9);
assert_ne!(
(sorry.fg, sorry.bold),
(number.fg, number.bold),
"`sorry` must be visually distinct from a numeric literal"
);
}
#[test]
fn lean4_constructor_capture_retro_paints_the_whole_javascript_family() {
// Framing acceptance 7 (Q#LN4), the breadth half. `constructor` was
// added for Lean, but four crates emit it — and because
// `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated
// base-first into the react/typescript entries
// (`src/syntax.rs`), it reaches SEVEN language entries, not four.
//
// Asserted at the query level rather than per-fixture precisely
// because the composition is the fragile part: if someone stops
// concatenating the JS base query into `typescript`, this fails
// while any single-language fixture would still pass.
for language in [
"rust",
"lua",
"python",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
] {
assert!(
query_uses_capture(language, "constructor"),
"`{language}` emits @constructor, so Q#LN4's entry retro-paints it"
);
}
}
#[test]
fn lean4_capture_additions_paint_rust_constructors_and_lua_braces() {
// Framing acceptance 7, the "actually reaches painted cells" half —
// a query-name check alone would not prove the theme entry resolves.
// Both of these rendered as unstyled default text before Q#LN4.
use pmacs_protocol::cell::Color;
// `None` at col 8 — a bare capitalized identifier, which is what the
// rust query's `#match? "^[A-Z]"` tags. Note that `Some(1)` does NOT
// work here: in call position a narrower `@function` pattern wins and
// paints fg 4. The distinction is worth keeping in the test, because
// it is the difference between "capitalized identifiers recolor" and
// "enum variants recolor" — only the former is true.
assert_eq!(
painted_fg_at("rust", "a.rs", "let x = None;\n", 8),
Color::Indexed(11),
"a bare Rust capitalized identifier paints the shared @constructor style"
);
// `Some` in pattern position (col 10) does reach @constructor.
assert_eq!(
painted_fg_at("rust", "a.rs", "match v { Some(z) => z, None => 0 };\n", 10),
Color::Indexed(11),
"a Rust pattern-position variant paints the shared @constructor style"
);
// ...but in CALL position the narrower @function pattern wins. Pinned
// so the blast radius recorded in the framing stays accurate.
assert_eq!(
painted_fg_at("rust", "a.rs", "let e = Err(1);\n", 8),
Color::Indexed(4),
"a called variant keeps @function, not @constructor"
);
// Lua tags the table-constructor BRACES, not a name: `{` at col 10
// of `local t = {}`.
assert_eq!(
painted_fg_at("lua", "a.lua", "local t = {}\n", 10),
Color::Indexed(11),
"a Lua table brace paints the shared @constructor style"
);
}
#[test]
fn lean4_capture_additions_do_not_reach_unrelated_languages() {
// Framing acceptance 8 — the negative pin, redrawn in review round 1.
//
// Rev 1 named Lua and Python here, which was a self-contradiction:
// both are retro-painted by `constructor`, so a "nothing moved"
// assertion over them would have been vacuous — the #155 R2 shape.
// These ten emit NONE of the four names, verified by grep over the
// crate queries in the dependency graph.
//
// Stated at the query level, which is stronger than a fixture
// snapshot: it holds for every construct in the language, not just
// the one a fixture happened to exercise.
const ADDED: [&str; 4] = ["constructor", "character", "keyword.conditional", "warning"];
for language in [
"markdown", "json", "yaml", "html", "css", "c", "cpp", "go", "toml", "bash",
] {
for capture in ADDED {
assert!(
!query_uses_capture(language, capture),
"`{language}` must not emit @{capture}; Q#LN4 would silently restyle it"
);
}
}
// Non-vacuity: the same predicate must find each name where it DOES
// occur. Without this, a `query_uses_capture` that always returned
// false would pass the loop above.
assert!(query_uses_capture("lean4", "constructor"));
assert!(query_uses_capture("zig", "character"));
assert!(query_uses_capture("cmake", "keyword.conditional"));
assert!(query_uses_capture("lean4", "warning"));
}
#[test]
fn web_grid_paints_html_tag_and_attribute() {
// Q#WEB4 acceptance: the two capture entries this lane adds (`tag`,

View File

@ -2439,6 +2439,7 @@ pub fn install(
)?;
pmacs.set("instance", install_instance_module(lua, registry)?)?;
pmacs.set("ansi", install_ansi_module(lua)?)?;
pmacs.set("path", install_path_module(lua)?)?;
pmacs.set("packages", install_packages_module(lua)?)?;
pmacs.set("state", install_state_module(lua)?)?;
pmacs.set("session", install_session_module(lua)?)?;
@ -3555,6 +3556,46 @@ impl UserData for AnsiParserLua {
}
}
/// Build the `pmacs.path.*` table: pure path arithmetic, no
/// filesystem access and no editor state.
///
/// `canonicalize(path)` is [`crate::editor_core::normalize_buffer_path`]
/// itself — the function the buffer registry's path keys already go
/// through on write and that `find_buffer_for_path` looks up with. It
/// expands a leading `~`, absolutizes against the process cwd, folds
/// `.` / `..` lexically, and drops redundant separators (so a trailing
/// slash disappears everywhere except at root). Symlinks are
/// deliberately **not** resolved: dired's `..` must return where the
/// user navigated from, and a not-yet-created "[new file]" path has
/// nothing to resolve.
///
/// Exposed rather than mirrored in Lua because dired keys one buffer per
/// directory on this form (Q#DR2). Two implementations that disagree on
/// an edge (`//tmp`, `~` with `HOME` unset, a `..` that would escape
/// root) would mint two buffers for one directory with no error
/// anywhere.
///
/// The result crosses the boundary through `to_string_lossy`, so a
/// non-UTF-8 `$HOME` (or a non-UTF-8 argument) can yield a Lua string
/// that no longer names the `PathBuf` the registry keys on. That is the
/// same limit `pmacs.fs` already documents — byte-preserving paths are
/// post-v0.1 work that widens every path in the API — and it is recorded
/// here so this binding is not read as an exception to it.
fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
let path = lua.create_table()?;
path.set(
"canonicalize",
lua.create_function(|_, raw: String| {
Ok(
crate::editor_core::normalize_buffer_path(std::path::PathBuf::from(raw))
.to_string_lossy()
.into_owned(),
)
})?,
)?;
Ok(path)
}
/// Build the `pmacs.ansi.*` table. The only entry today is
/// `parser()`; future additions (e.g. an event-table-validator
/// helper) live alongside it.
@ -6479,6 +6520,40 @@ fn fs_dir_entry_to_lua(lua: &Lua, entry: &crate::fs::FsDirEntry) -> mlua::Result
Ok(t)
}
/// Convert a settled `read_dir` listing to its Lua result value.
///
/// The shape is chosen by the listing itself (dired Q#DR6): a fatal-mode
/// listing carries no error channel and stays the **bare array** the
/// M8.1 surface documents --- the frozen M8.2 fixture consumes it with
/// `ipairs` --- while a tolerant listing becomes
/// `{ entries = { … }, errors = { { name = …?, message = … }, … } }`.
/// Keying on the payload rather than on the job keeps the additive
/// promise checkable in one place.
fn fs_dir_listing_to_lua(lua: &Lua, listing: crate::fs::FsDirListing) -> mlua::Result<mlua::Value> {
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<mlua::Value> {
match payload {
StreamPayload::U64(v) => Ok(mlua::Value::Integer(i64::try_from(v).unwrap_or(i64::MAX))),
@ -6570,9 +6645,23 @@ pub fn install_async(
let rt = runtime.clone();
async_mod.set(
"_dispatch_fs_read_dir",
lua.create_function(move |_, (path, key): (String, Option<String>)| {
Ok(rt.dispatch_fs_read_dir(std::path::PathBuf::from(path), key.as_deref()))
})?,
lua.create_function(
move |_, (path, key, tolerant): (String, Option<String>, Option<bool>)| {
// dired Q#DR6: the tolerance is decided at dispatch
// and travels in the settled payload, so the result
// conversion below never has to look the job back up.
let tolerance = if tolerant == Some(true) {
crate::fs::ReadDirTolerance::PerEntry
} else {
crate::fs::ReadDirTolerance::Fatal
};
Ok(rt.dispatch_fs_read_dir(
std::path::PathBuf::from(path),
tolerance,
key.as_deref(),
))
},
)?,
)?;
}
@ -6777,16 +6866,15 @@ pub fn install_async(
i64::try_from(duration_ms).unwrap_or(i64::MAX),
));
}
Some(JobOutcome::Complete(JobResult::ReadDir(entries))) => {
Some(JobOutcome::Complete(JobResult::ReadDir(listing))) => {
// Lua surface for fs.read_dir settle:
// status "ok", value = array of per-entry
// tables. T M8.1.
// tables (T M8.1), or the
// `{ entries = …, errors = … }` table when the
// caller opted into per-entry tolerance
// (dired Q#DR6).
out.push_back(mlua::Value::String(lua.create_string("ok")?));
let t = lua.create_table_with_capacity(entries.len(), 0)?;
for (i, entry) in entries.into_iter().enumerate() {
t.set(i + 1, fs_dir_entry_to_lua(lua, &entry)?)?;
}
out.push_back(mlua::Value::Table(t));
out.push_back(fs_dir_listing_to_lua(lua, listing)?);
}
Some(JobOutcome::Complete(JobResult::Stat(entry))) => {
// Lua surface for fs.stat settle: status
@ -6933,9 +7021,9 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
"ok",
mlua::Value::Integer(i64::try_from(*duration_ms).unwrap_or(i64::MAX)),
),
JobOutcome::Complete(JobResult::ReadDir(entries)) => (
JobOutcome::Complete(JobResult::ReadDir(listing)) => (
"ok",
mlua::Value::Integer(i64::try_from(entries.len()).unwrap_or(i64::MAX)),
mlua::Value::Integer(i64::try_from(listing.entries.len()).unwrap_or(i64::MAX)),
),
JobOutcome::Complete(JobResult::Stat(entry)) => {
("ok", mlua::Value::String(lua.create_string(&entry.name)?))
@ -9923,12 +10011,26 @@ pub fn install_lsp(
let ids: Vec<LspServerId> = 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)?)?;

View File

@ -251,6 +251,12 @@ pub fn default_injection_aliases() -> HashMap<String, String> {
("golang", "go"),
("yml", "yaml"),
("md", "markdown"),
// Lean 4 (framing Q#LN17). A ```lean fence is overwhelmingly Lean 4
// in practice, so the Lean 3 spelling is deliberately mapped forward
// rather than left unresolved. `lean4` needs no alias — it is the
// entry name. `lean4-mode` does the equivalent through
// `markdown-code-lang-modes`.
("lean", "lean4"),
]
.into_iter()
.map(|(a, b)| (a.to_owned(), b.to_owned()))
@ -1130,6 +1136,33 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[
locals_query: &[],
injections_query: &[],
},
// Lean 4 (framing `docs/lean4-mode-framing.md`, Arc 8 Stage 1).
//
// The entry is named `lean4`, not `lean` (Q#LN2): this name becomes the
// `language_id` sent in `didOpen` — `ensure_server` at
// `builtin/runtime/lsp.lua:540` passes it straight through — and the
// Lean ecosystem's id is `lean4` (`lean` is Lean 3, which is
// end-of-life). The grammar's own C symbol is `tree_sitter_lean`; that
// is arborium's business, not ours. Stage 3 adds
// `pmacs.lsp.config.lean4` against this name.
//
// Note the loader shape: `arborium-lean` exports `const fn language() ->
// LanguageFn` rather than a `LANGUAGE` const, so this is the one entry
// that calls a function to get the `LanguageFn` before `.into()`.
//
// `.olean` (compiled artifacts) and `.ilean` (JSON metadata) are
// deliberately unclaimed (Q#LN3). Locals and injections are empty
// because the crate ships both as empty strings — Lean has no embedded
// sublanguage worth injecting, and its scoping is far beyond what a
// tree-sitter locals query could model.
LanguageEntry {
name: "lean4",
extensions: &["lean"],
loader: || arborium_lean::language().into(),
highlights_query: &[arborium_lean::HIGHLIGHTS_QUERY],
locals_query: &[],
injections_query: &[],
},
];
/// LaTeX highlights overlay (framing Q#LX2). The chosen grammar crate
@ -2394,6 +2427,149 @@ mod tests {
}
}
#[test]
fn builtin_languages_include_lean4() {
// Framing acceptance 1/3 (`docs/lean4-mode-framing.md`). The entry is
// named `lean4` because that name becomes the `didOpen` language_id
// (Q#LN2), and it claims `.lean` ONLY: `.olean` is a compiled binary
// artifact and `.ilean` is JSON metadata (Q#LN3).
let lean = BUILTIN_LANGUAGES
.iter()
.find(|l| l.name == "lean4")
.expect("`lean4` language entry must be present");
assert!(lean.extensions.contains(&"lean"), "`lean4` claims `.lean`");
for unclaimed in ["olean", "ilean"] {
assert!(
!lean.extensions.contains(&unclaimed),
"`lean4` must not claim `.{unclaimed}`"
);
}
assert!(
lean.highlights_query
.contains(&arborium_lean::HIGHLIGHTS_QUERY),
"`lean4` drives highlighting from the crate's query constant, not an overlay"
);
assert!(
lean.locals_query.is_empty() && lean.injections_query.is_empty(),
"`lean4` ships neither locals nor injections (Q#LN1)"
);
}
#[test]
fn lean4_grammar_loads_and_parses() {
// Framing acceptance 2 and the open half of Q#LN1: `arborium-lean`
// exports `const fn language() -> LanguageFn` (not the `LANGUAGE`
// const every other entry uses) over `tree-sitter-language 0.1`, and
// its README demonstrates usage against a `tree_sitter_patched_
// arborium` core. Neither is supposed to matter — the LanguageFn ABI
// is shared — but "supposed to" is not evidence, so this pins that
// OUR `tree-sitter` 0.26 core accepts it and produces a real tree.
//
// The fixture exercises the grammar's external scanner (`scanner.c`
// supplies a NEWLINE token, so layout-sensitive `def`/`theorem`
// bodies depend on it) and the Unicode operators that make Lean
// Lean — `→`, `∀`, `≥` — which a byte-oriented misbuild would shred.
let reg = SyntaxRegistry::new();
let language = reg
.language("lean4")
.expect("`lean4` language loads from BUILTIN_LANGUAGES");
let mut buf = fresh_buffer("Basic.lean");
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: "-- a comment\n\
def fibonacci : Nat → Nat\n\
\x20 | 0 => 0\n\
\x20 | n + 1 => n\n\
\n\
theorem fib_nonneg : ∀ n, fibonacci n ≥ 0 := by\n\
\x20 intro n\n\
\x20 exact Nat.zero_le _\n"
.as_bytes(),
})
.unwrap();
let view = ParseView::new(&buf, language, "lean4".to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let bundle = parse_synchronously(&handle);
assert_eq!(
bundle.root_tree().root_node().kind(),
"module",
"Lean grammar roots at module"
);
let sexp = bundle.root_tree().root_node().to_sexp();
// This specific committed fixture parses cleanly. The claim is
// scoped to the fixture on purpose: Lean's syntax is user-extensible
// via macros, so a static grammar necessarily mis-parses some legal
// input (the upstream grammar says so itself, and the framing scores
// it as bet 3). What a clean parse HERE proves is that the crate is
// wired correctly, not that Lean is fully parseable.
assert!(
!bundle.root_tree().root_node().has_error(),
"the fixture parses without error; got {sexp}"
);
// `def` and `theorem` sit under a `declaration` wrapper, not directly
// under `module`.
for expected in ["(comment)", "(def ", "(theorem "] {
assert!(
sexp.contains(expected),
"expected `{expected}` in the tree; got {sexp}"
);
}
// The load-bearing part of this test. A grammar built against a
// mismatched core, or one whose scanner mis-handles multibyte input,
// does not fail loudly — it produces a tree that silently degrades on
// exactly the characters Lean is made of. `→` must become an `arrow`,
// `∀` a `forall`, and `≥` a `comparison`; if these three hold, the
// UTF-8 path through the parser is sound.
for expected in ["(arrow ", "(forall ", "(comparison "] {
assert!(
sexp.contains(expected),
"Unicode operator did not produce `{expected}`; got {sexp}"
);
}
}
#[test]
fn lean4_highlights_resolve() {
// The crate's 213-line query must COMPILE against the grammar it
// ships with — the node-name compatibility gate. A query referencing
// a node this grammar version lacks fails here rather than silently
// producing no spans at runtime.
let reg = SyntaxRegistry::new();
let query = reg
.highlights_query("lean4")
.expect("lean4 highlights compile against the grammar");
let names = query.capture_names();
// The four capture names Q#LN4 adds to the GLOBAL theme table are
// present here — this is the forward direction of that decision; the
// reverse direction (what they do to other languages) is pinned in
// `highlight.rs`.
for expected in ["constructor", "character", "keyword.conditional", "warning"] {
assert!(
names.contains(&expected),
"lean4 query uses `@{expected}`, which Q#LN4 adds to the theme; got {names:?}"
);
}
}
#[test]
fn language_for_path_resolves_lean_extension() {
let reg = SyntaxRegistry::new();
assert_eq!(
reg.language_name_for_path("Mathlib/Data/Nat/Basic.lean")
.as_deref(),
Some("lean4"),
"`.lean` resolves to the lean4 grammar"
);
for unclaimed in ["Basic.olean", "Basic.ilean"] {
assert_ne!(
reg.language_name_for_path(unclaimed).as_deref(),
Some("lean4"),
"{unclaimed} must not resolve to lean4"
);
}
}
#[test]
fn builtin_languages_include_html_and_css() {
// Both crate grammars export their query constants (no overlay). HTML

View File

@ -202,8 +202,18 @@ fn format_outcome(outcome: &JobOutcome) -> String {
JobOutcome::Complete(JobResult::Parse { duration_ms }) => {
format!("ok (parse {duration_ms}ms)")
}
JobOutcome::Complete(JobResult::ReadDir(entries)) => {
format!("ok ({} entries)", entries.len())
JobOutcome::Complete(JobResult::ReadDir(listing)) => {
// Per-entry failures (dired Q#DR6) are counted here too: a
// tolerant listing that dropped half a directory is not the
// same observable outcome as a clean one.
match listing.errors.as_deref() {
Some(errors @ [_, ..]) => format!(
"ok ({} entries, {} unreadable)",
listing.entries.len(),
errors.len()
),
_ => format!("ok ({} entries)", listing.entries.len()),
}
}
JobOutcome::Complete(JobResult::Stat(entry)) => {
format!("ok (stat {:?})", entry.name)

1656
tests/dired_acceptance.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,373 @@
// tests/find_file_acceptance.rs --- dired arc Stage 0 (`C-x C-f`) acceptance.
//! Acceptance for `find-file`, the dired arc's Stage 0
//! (`docs/dired-framing.md` §14, items 0a-0d, Q#DR11).
//!
//! Dispatch-driven throughout: the prompt is opened with a real
//! `C-x C-f`, filled by typing real keys, and completed with a real
//! RET. `pmacs.command.invoke` would bypass the binding (a dead
//! keymap entry would pass vacuously) and the Lua lifecycle
//! `minibuffer.accept()` bypasses the dispatch path interactive input
//! actually takes --- the editops suite's discipline, for the same
//! reasons.
//!
//! Fixtures use `.txt` files so no `buffer.after-load` hook spawns a
//! language server.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn ctrl(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(c), KeyModifiers::CONTROL),
);
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(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::<bool>(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<String> {
eval::<Option<String>>(
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<String> {
eval::<Vec<String>>(s, "return pmacs.minibuffer.candidates()")
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// An editor whose active buffer is a real file inside `dir`, so
/// find-file's root resolves to that directory.
fn editor_in(dir: &std::path::Path) -> EditorState {
let anchor = dir.join("anchor.txt");
std::fs::write(&anchor, b"anchor\n").expect("write anchor");
let state = EditorState::new();
state.lua_host.reopen_init_phase_for_testing();
let anchor_str = anchor.display().to_string();
exec(
&state,
&format!("pmacs.buffer.find_or_open({anchor_str:?})"),
);
state
}
/// 0a --- completion is flat: it offers the root's own entries and
/// never descends into a subdirectory.
#[test]
fn find_file_completion_lists_the_root_only_and_does_not_descend() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("alpha.txt"), b"a").expect("write");
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
std::fs::write(td.path().join("sub").join("inner.txt"), b"i").expect("write");
let mut s = editor_in(td.path());
open_prompt(&mut s);
let cands = candidates(&s);
assert!(
cands.iter().any(|c| c == "alpha.txt"),
"root entry must be offered; got {cands:?}"
);
assert!(
cands.iter().any(|c| c == "sub"),
"the subdirectory itself must be offered; got {cands:?}"
);
assert!(
!cands.iter().any(|c| c == "inner.txt"),
"completion must NOT descend into subdirectories; got {cands:?}"
);
}
/// 0b --- free text carries the deeper case. `sub/inner.txt` matches no
/// bare-basename candidate, so it reaches `on_accept` verbatim and is
/// joined onto the prompt's root.
#[test]
fn find_file_free_text_opens_a_path_below_the_root() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
let inner = td.path().join("sub").join("inner.txt");
std::fs::write(&inner, b"deep contents\n").expect("write");
let mut s = editor_in(td.path());
open_prompt(&mut s);
type_str(&mut s, "sub/inner.txt");
assert!(
candidates(&s).is_empty(),
"a needle containing '/' must filter every basename candidate away, \
or the selection would shadow the typed text"
);
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a file must be open");
assert_eq!(
std::fs::canonicalize(&path).expect("canonicalize opened"),
std::fs::canonicalize(&inner).expect("canonicalize fixture"),
"free text must open the deeper path"
);
let text: String = eval(&s, "return pmacs.window.buffer():slice(0, 13)");
assert_eq!(text, "deep contents", "the file's real contents must load");
}
/// 0c --- a path that does not exist creates a `[new file]` buffer
/// bound to it, rather than erroring. The name contains a `/` so the
/// candidate list is empty and the typed text is what arrives (see
/// `find_file_selected_candidate_shadows_typed_text` for the other
/// half of that rule).
#[test]
fn find_file_nonexistent_path_creates_a_new_file_buffer() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
let fresh = td.path().join("sub").join("brand-new.txt");
assert!(!fresh.exists(), "fixture must not exist yet");
let mut s = editor_in(td.path());
open_prompt(&mut s);
type_str(&mut s, "sub/brand-new.txt");
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a buffer must be bound to the new path");
assert!(
path.ends_with("sub/brand-new.txt"),
"the buffer must be bound to the typed path; got {path}"
);
let len: usize = eval(&s, "return pmacs.window.buffer():len()");
assert_eq!(len, 0, "a new-file buffer starts empty");
assert!(
!fresh.exists(),
"find-file must not create the file on disk --- only the buffer"
);
let line = status(&s);
assert!(
line.contains("[new file]"),
"the new-file status must surface; got {line:?}"
);
}
/// The everyday new-file flow: a BARE name, no separator, matching no
/// existing entry. The candidate list empties on its own, so the typed
/// text arrives and joins onto the root. This is the path users hit
/// first, and it is the only route through `find_file_resolve` that
/// combines free text with a relative join.
#[test]
fn find_file_bare_new_name_creates_in_the_root() {
let td = tempfile::tempdir().expect("tempdir");
let fresh = td.path().join("zzz.txt");
let mut s = editor_in(td.path());
open_prompt(&mut s);
// "zzz.txt" is not a subsequence of "anchor.txt" (no 'z' in it), so
// nothing survives the filter and the typed name is what accepts.
type_str(&mut s, "zzz.txt");
assert!(
candidates(&s).is_empty(),
"fixture premise: a bare non-matching name must empty the list; got {:?}",
candidates(&s)
);
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a buffer must be bound to the new path");
assert_eq!(
std::path::Path::new(&path).parent(),
Some(td.path()),
"a bare name must join onto the prompt's root; got {path}"
);
assert!(
path.ends_with("zzz.txt"),
"the buffer must carry the typed name; got {path}"
);
let len: usize = eval(&s, "return pmacs.window.buffer():len()");
assert_eq!(len, 0, "a new-file buffer starts empty");
assert!(!fresh.exists(), "nothing is written to disk until save");
}
/// The failure arm. Accepting a DIRECTORY candidate reaches
/// `display_file`, whose load fails (opening a directory succeeds, the
/// read does not), and the command's `pcall` must turn that into a
/// status message rather than letting the error escape mid-dispatch.
/// Without the `pcall` this test fails, which is the point --- the
/// guard is pinned through the real accept path, not asserted directly.
#[test]
fn find_file_accepting_a_directory_reports_instead_of_raising() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
let mut s = editor_in(td.path());
let before = active_path(&s).expect("the anchor must be open");
open_prompt(&mut s);
// Only the directory matches: "anchor.txt" contains no 's'.
type_str(&mut s, "sub");
assert_eq!(
candidates(&s),
vec!["sub".to_string()],
"fixture premise: the directory must be the sole candidate"
);
press(&mut s, KeyCode::Enter);
let line = status(&s);
assert!(
line.starts_with("find-file: "),
"the failure must surface as this command's status message; got {line:?}"
);
assert_eq!(
active_path(&s).as_deref(),
Some(before.as_str()),
"a failed open must leave the active buffer alone"
);
assert!(
!eval::<bool>(&s, "return pmacs.minibuffer.is_active()"),
"the prompt must have closed even though the open failed"
);
}
/// 0d --- with no backing path, the prompt roots at the process cwd
/// (`source_root` is omitted, and the Rust side defaults to ".").
/// The test crate's cwd is the crate root, so `Cargo.toml` is a
/// stable, real candidate there.
#[test]
fn find_file_without_a_backing_path_roots_at_the_process_cwd() {
let mut s = EditorState::new();
s.lua_host.reopen_init_phase_for_testing();
assert!(
active_path(&s).is_none(),
"the scratch buffer must have no backing path"
);
open_prompt(&mut s);
let cands = candidates(&s);
assert!(
cands.iter().any(|c| c == "Cargo.toml"),
"a pathless buffer must root the prompt at the process cwd; got {cands:?}"
);
// The field must start EMPTY. Any prefill (e.g. Emacs's
// directory-in-the-field) would contain a `/`, which filters every
// basename candidate away and silently disables completion --- the
// reason the root is named in the prompt string instead.
let typed: String = eval(&s, "return pmacs.minibuffer.contents()");
assert_eq!(
typed, "",
"the prompt field must start empty or completion is dead on arrival"
);
}
/// The documented hole in Q#DR11, pinned so it is a decision rather
/// than an accident: `recompute_candidates` selects index 0 whenever
/// the list is non-empty and `resolve_accepted_value` returns the
/// SELECTED CANDIDATE over the typed text, so typing a new bare name
/// that is a subsequence of an existing entry opens the existing file.
/// Fixing this needs a Rust change to accept semantics, which Stage 0
/// deliberately does not make.
#[test]
fn find_file_selected_candidate_shadows_typed_text() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("notes.md"), b"existing\n").expect("write");
let mut s = editor_in(td.path());
open_prompt(&mut s);
// "nots" is a subsequence of "notes.md", so the candidate survives
// the filter and shadows the typed name.
type_str(&mut s, "nots");
assert_eq!(
candidates(&s),
vec!["notes.md".to_string()],
"the fixture depends on 'nots' matching 'notes.md'"
);
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a file must be open");
assert!(
path.ends_with("notes.md"),
"documented behavior: the selected candidate wins over typed text; got {path}"
);
}
/// A leading `~` is expanded before the path reaches the core. This
/// matters because `get_or_load_buffer` normalizes the path it STORES
/// but loads from the RAW one, so an unexpanded `~/...` would dedup
/// against an open buffer yet fail to load a file that is not open.
#[test]
fn find_file_expands_a_leading_tilde() {
let Some(home) = std::env::var_os("HOME") else {
eprintln!("HOME unset; skipping tilde expansion pin");
return;
};
let home = home.to_string_lossy().into_owned();
if home.is_empty() || !std::path::Path::new(&home).is_dir() {
eprintln!("HOME is not a usable directory; skipping");
return;
}
let mut s = EditorState::new();
s.lua_host.reopen_init_phase_for_testing();
open_prompt(&mut s);
// Contains a '/', so the typed text reaches on_accept verbatim.
// The leaf does not exist, so this lands on the new-file path and
// touches no disk state.
type_str(&mut s, "~/pmacs-find-file-tilde-probe.txt");
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a buffer must be bound");
assert!(
!path.contains('~'),
"the tilde must be expanded, not passed through; got {path}"
);
assert!(
path.starts_with(&home),
"the expansion must use $HOME; got {path} with HOME={home}"
);
}

View File

@ -0,0 +1,340 @@
//! Lean 4 mode, Stage 1 acceptance (Arc 8, `docs/lean4-mode-framing.md`).
//!
//! Covers the framing's Stage 1 criteria that live above the Rust
//! substrate — major mode, modeline aliasing, comment toggle, the pair
//! set, and markdown fence injection. Criteria 1, 2, and the Q#LN4
//! retro-paint pins (7, 8) are unit tests in `src/syntax.rs` and
//! `src/highlight.rs`, where the theme table and grammar registry live.
//!
//! Dispatch-driven, following `comment_toggle_acceptance`: `M-;` and
//! typed characters go through `dispatch_key` so the real command
//! boundary and typed-edit provenance are exercised. Buffers are
//! file-backed (language detection needs a path); each editor gets a
//! private tempdir `StateDir` and an emptied `pmacs.lsp.config` so
//! nothing spawns a language server — Stage 1 has no LSP at all.
//!
//! Criterion 12 is the reason this suite touches no process: it must
//! pass on a machine with no `lean`, no `lake`, and no configured elan
//! toolchain. That is not hypothetical — the machine this arc was
//! scouted on has elan installed with no default toolchain, where
//! `lake --version` itself fails.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::StateDir;
use pmacs::protocol::FrontendId;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
fn fresh_state_dir() -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"pmacs-lean4-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn editor(state_dir: &std::path::Path) -> EditorState {
let s = EditorState::new();
s.lua_host.lua().remove_app_data::<StateDir>();
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<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn buffer_text(s: &EditorState) -> String {
let b: mlua::String = eval(
s,
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
fn 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<String> {
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<String> {
let state = EditorState::new();
let buf_id = state
.lua_host
.registry()
.borrow_mut()
.create_from_bytes("doc.md".to_owned(), src);
state
.lua_host
.lua()
.globals()
.set("BUF", pmacs::lua_bindings::BufferIdLua(buf_id))
.expect("bind BUF");
state
.lua_host
.lua()
.load("pmacs.parse._parse_now(BUF, 'markdown')")
.exec()
.expect("synchronous parse");
let bundle = state
.syntax_registry
.view(buf_id)
.and_then(|h| h.current())
.expect("installed bundle");
bundle
.layers
.iter()
.map(|l| l.language_name.clone())
.collect()
}
#[test]
fn acc11_lean_and_lean4_markdown_fences_both_inject_the_lean_grammar() {
// Both spellings must resolve to the same grammar: `lean4` is the entry
// name and `lean` goes through the injection alias. A ```lean fence is
// overwhelmingly Lean 4 in practice, which is why the Lean 3 spelling
// is mapped forward rather than left unresolved (Q#LN17).
for fence in ["lean", "lean4"] {
let src = format!("# Doc\n\n```{fence}\ndef x : Nat := 1\n```\n");
let langs = markdown_layer_languages(src.as_bytes());
assert!(
langs.iter().any(|l| l == "lean4"),
"```{fence} injects a lean4 child layer; got {langs:?}"
);
}
}
#[test]
fn acc11b_an_unknown_fence_name_still_injects_nothing() {
// Non-vacuity guard for acc11: the alias must be what resolves `lean`,
// not some catch-all that would light up any fence name.
let langs = markdown_layer_languages(b"# Doc\n\n```leen\ndef x := 1\n```\n");
assert!(
!langs.iter().any(|l| l == "lean4"),
"a misspelled fence must not reach the lean4 grammar; got {langs:?}"
);
}
// ---------------------------------------------------------------------------
// Criterion 12 — no toolchain required
// ---------------------------------------------------------------------------
#[test]
fn acc12_stage1_ships_no_lsp_config_and_spawns_no_process() {
// Stage 1 is grammar + Lua tables only. Opening a Lean file must not
// reach for `lake`, `lean`, or `elan` — the LSP arrives in Stage 3, and
// even then it is fallible by design (Q#LN7).
// The load-bearing assertion, and it must run against a PRISTINE editor.
// The shared `editor()` helper wipes `pmacs.lsp.config` before any
// buffer opens, so an assertion about the server list under that harness
// holds for every language regardless of what Stage 1 ships — it could
// not fail for the regression it names. This checks the real claim
// directly: no builtin runtime file defines a Lean server config. A
// Stage-3 front-run adding `pmacs.lsp.config.lean4` fails here.
let pristine = EditorState::new();
let no_lean_config: bool = eval(&pristine, "return pmacs.lsp.config.lean4 == nil");
assert!(
no_lean_config,
"Stage 1 defines no `pmacs.lsp.config.lean4`; the LSP is Stage 3"
);
// Non-vacuity: the same lookup finds the configs that DO ship, so this
// is not passing because `pmacs.lsp.config` is empty or absent.
let rust_config_exists: bool = eval(&pristine, "return pmacs.lsp.config.rust ~= nil");
assert!(
rust_config_exists,
"the config table is populated, so the lean4 absence above is meaningful"
);
// And nothing is spawned by opening the file. This half retains its
// value under the wiped config: a direct probe spawn from `lean.lua`
// would show up here whatever `pmacs.lsp.config` contains.
let s = editor_visiting("Basic.lean", "def x : Nat := 1\n");
let procs: i64 = eval(&s, "return #pmacs.process.list()");
assert_eq!(procs, 0, "opening a Lean buffer spawns no child process");
}

View File

@ -0,0 +1,704 @@
//! Arc 8 Stage 2 acceptance — multi-root LSP server affinity.
//!
//! `docs/lean4-mode-framing.md` Q#LN15, acceptance 13–21.
//!
//! This suite deliberately contains **no Lean content**. `ensure_server`
//! (`builtin/runtime/lsp.lua`) is the single server-affinity function for
//! every LSP language in pmacs, so the change is exercised through the
//! four languages that already shipped attach paths — rust, python, go,
//! typescript — driven against `pmacs_fake_lsp` so nothing here needs a
//! real toolchain on PATH.
//!
//! Every fixture calls `pmacs.project.set_search_boundary` at its own
//! tempdir root. Without it the marker walk climbs to the filesystem
//! root, and a stray `.git` above the temp directory would silently turn
//! the "markerless" cases into detected ones — the assertions would still
//! pass while testing nothing.
use std::path::{Path, PathBuf};
use std::time::Duration;
use pmacs::editor::EditorState;
fn exec(state: &EditorState, source: &str) {
state.lua_host.lua().load(source.to_owned()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
}
fn fake_lsp_path() -> String {
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
}
/// A fresh editor with the shipped language configs cleared, so the only
/// server any test can spawn is the fake one it configures itself.
fn editor() -> EditorState {
let state = EditorState::new();
exec(&state, "pmacs.lsp.config = {}");
state
}
fn lua_str(path: &Path) -> String {
path.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
}
/// 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<String> {
let joined: String = eval(
state,
r#"
local out = {}
for _, s in ipairs(pmacs.lsp.list()) do
out[#out + 1] = table.concat({
s.language_id or "",
s.root_uri or "",
s.cwd or "",
(s.state and s.state.kind) or "",
}, "|")
end
table.sort(out)
return table.concat(out, "\n")
"#,
);
if joined.is_empty() {
Vec::new()
} else {
joined.lines().map(str::to_owned).collect()
}
}
fn status(state: &EditorState) -> String {
state.core.borrow().status.clone()
}
fn count(state: &EditorState) -> usize {
let n: i64 = eval(state, "return #pmacs.lsp.list()");
usize::try_from(n).expect("server count is non-negative")
}
// ---------------------------------------------------------------------------
// Acceptance 13 — `lsp.list()` rows carry `root_uri` and `cwd`.
// ---------------------------------------------------------------------------
#[test]
fn acc13_list_rows_carry_root_uri_and_cwd() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
let mut state = editor();
fx.bind(&state);
configure(&state, "rust");
open(&state, &file);
settle(&mut state);
let proj = fx.dir("proj");
let rows = rows(&state);
assert_eq!(rows.len(), 1, "{rows:?}");
let fields: Vec<&str> = rows[0].split('|').collect();
assert_eq!(fields[0], "rust");
assert_eq!(
fields[1],
file_uri(&proj),
"root_uri must be the project root"
);
assert_eq!(
fields[2],
proj.display().to_string(),
"cwd must be the root"
);
}
// ---------------------------------------------------------------------------
// Acceptance 14 — two roots, same language, two servers.
// ---------------------------------------------------------------------------
#[test]
fn acc14_two_project_roots_of_one_language_spawn_two_servers() {
let fx = Fixture::new();
fx.write("a/Cargo.toml", "[package]\nname = \"a\"\n");
fx.write("b/Cargo.toml", "[package]\nname = \"b\"\n");
let first = fx.write("a/src/main.rs", "fn main() {}\n");
let second = fx.write("b/src/main.rs", "fn main() {}\n");
let mut state = editor();
fx.bind(&state);
configure(&state, "rust");
open(&state, &first);
settle(&mut state);
open(&state, &second);
settle(&mut state);
let rows = rows(&state);
assert_eq!(rows.len(), 2, "one server per project root: {rows:?}");
let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect();
assert!(
roots.contains(&file_uri(&fx.dir("a")).as_str()),
"{roots:?}"
);
assert!(
roots.contains(&file_uri(&fx.dir("b")).as_str()),
"{roots:?}"
);
}
// ---------------------------------------------------------------------------
// Acceptance 15 — same root, two files, one server. The pre-change
// behavior, pinned so the fix cannot degrade into "always spawn".
// ---------------------------------------------------------------------------
#[test]
fn acc15_two_files_in_one_root_reuse_a_single_server() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let first = fx.write("proj/src/main.rs", "fn main() {}\n");
let second = fx.write("proj/src/other.rs", "pub fn other() {}\n");
let mut state = editor();
fx.bind(&state);
configure(&state, "rust");
open(&state, &first);
settle(&mut state);
open(&state, &second);
settle(&mut state);
let rows = rows(&state);
assert_eq!(rows.len(), 1, "same root must reuse: {rows:?}");
assert_eq!(
rows[0].split('|').nth(1).unwrap(),
file_uri(&fx.dir("proj"))
);
}
// ---------------------------------------------------------------------------
// Acceptance 16 — per-language regression pin. The single-root case is
// all the shipped attach paths ever exercised; it must be untouched.
// ---------------------------------------------------------------------------
#[test]
fn acc16_shipped_languages_are_unchanged_for_the_single_root_case() {
// (language id, project marker, two source files under it)
let cases: [(&str, &str, &str, &str); 4] = [
("rust", "Cargo.toml", "one.rs", "two.rs"),
("python", "pyproject.toml", "one.py", "two.py"),
("go", "go.mod", "one.go", "two.go"),
("typescript", "package.json", "one.ts", "two.ts"),
];
for (language, marker, first_name, second_name) in cases {
let fx = Fixture::new();
fx.write(&format!("proj/{marker}"), "{}\n");
let first = fx.write(&format!("proj/src/{first_name}"), "\n");
let second = fx.write(&format!("proj/src/{second_name}"), "\n");
let mut state = editor();
fx.bind(&state);
configure(&state, language);
open(&state, &first);
settle(&mut state);
open(&state, &second);
settle(&mut state);
let rows = rows(&state);
assert_eq!(
rows.len(),
1,
"{language}: expected one server, got {rows:?}"
);
let fields: Vec<&str> = rows[0].split('|').collect();
assert_eq!(fields[0], language, "{language}: language_id");
assert_eq!(
fields[1],
file_uri(&fx.dir("proj")),
"{language}: root must be the marker directory"
);
}
}
// ---------------------------------------------------------------------------
// Acceptance 17 — hoist pin. `project_root_for` now runs on the *reuse*
// path, and a function-valued `root` is memoized per directory.
// ---------------------------------------------------------------------------
#[test]
fn acc17_function_root_runs_on_the_reuse_path_and_memoizes_per_directory() {
let fx = Fixture::new();
let shared = fx.dir("shared");
std::fs::create_dir_all(&shared).unwrap();
let a1 = fx.write("one/a.rs", "fn a() {}\n");
let a2 = fx.write("one/b.rs", "fn b() {}\n");
let b1 = fx.write("two/c.rs", "fn c() {}\n");
let mut state = editor();
fx.bind(&state);
// A resolver that answers the same root for every directory: the
// second directory therefore REUSES the first directory's server,
// which is exactly the path the hoist put the resolver on.
exec(
&state,
&format!(
r#"
_G.ROOT_CALLS = 0
pmacs.lsp.config.rust = {{
command = "{}",
root = function(_)
_G.ROOT_CALLS = _G.ROOT_CALLS + 1
return "{}"
end,
}}
"#,
fake_lsp_path(),
lua_str(&shared)
),
);
open(&state, &a1);
settle(&mut state);
assert_eq!(eval::<i64>(&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::<i64>(&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::<i64>(&state, "return _G.ROOT_CALLS"),
2,
"a new directory must consult the resolver on the reuse path"
);
let rows = rows(&state);
assert_eq!(rows.len(), 1, "one resolved root, one server: {rows:?}");
assert_eq!(rows[0].split('|').nth(1).unwrap(), file_uri(&shared));
}
// ---------------------------------------------------------------------------
// Acceptance 18 — a hand-spawned server carrying only `cwd` is not
// adopted by a root-bearing attach. A deliberate behavior change.
// ---------------------------------------------------------------------------
#[test]
fn acc18_hand_spawned_server_without_root_uri_is_not_adopted() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
let proj = fx.dir("proj");
let mut state = editor();
fx.bind(&state);
configure(&state, "rust");
// Exactly what an init.lua would write: cwd, no root_uri.
exec(
&state,
&format!(
r#"
pmacs.lsp.spawn({{
label = "hand-rolled",
language_id = "rust",
command = "{}",
cwd = "{}",
}})
"#,
fake_lsp_path(),
lua_str(&proj)
),
);
settle(&mut state);
assert_eq!(count(&state), 1, "the hand-spawned server is up");
open(&state, &file);
settle(&mut state);
let rows = rows(&state);
assert_eq!(rows.len(), 2, "the attach must not adopt it: {rows:?}");
let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect();
assert!(
roots.contains(&""),
"hand-spawned reads back nil: {roots:?}"
);
assert!(
roots.contains(&file_uri(&proj).as_str()),
"the attach's own server carries the root: {roots:?}"
);
}
// ---------------------------------------------------------------------------
// Acceptance 19 — a dead server in the matching root is not reused.
// ---------------------------------------------------------------------------
#[test]
fn acc19_stopped_server_in_the_matching_root_is_not_reused() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let first = fx.write("proj/src/main.rs", "fn main() {}\n");
let second = fx.write("proj/src/other.rs", "pub fn other() {}\n");
let mut state = editor();
fx.bind(&state);
configure(&state, "rust");
open(&state, &first);
settle(&mut state);
let original: i64 = eval(&state, "return pmacs.lsp.list()[1].id:raw()");
exec(&state, "pmacs.lsp.stop(pmacs.lsp.list()[1].id)");
for _ in 0..200 {
settle(&mut state);
let dead: bool = eval(
&state,
r#"
for _, s in ipairs(pmacs.lsp.list()) do
local k = s.state and s.state.kind
if k == "stopped" or k == "crashed" then return true end
end
return false
"#,
);
if dead {
break;
}
}
open(&state, &second);
settle(&mut state);
let live: i64 = eval(
&state,
r#"
for _, s in ipairs(pmacs.lsp.list()) do
local k = s.state and s.state.kind
if k ~= "stopped" and k ~= "crashed" then
return s.id:raw()
end
end
return -1
"#,
);
assert_ne!(live, -1, "a replacement server must exist");
assert_ne!(live, original, "the dead server must not be reused");
}
// ---------------------------------------------------------------------------
// Acceptance 20 — the loose-file pin (Q#LN15 part 2). This is the
// no-change case, and the one a naive `(language_id, root)` key breaks.
// ---------------------------------------------------------------------------
#[test]
fn acc20_markerless_files_in_different_directories_share_one_server() {
let fx = Fixture::new();
let first = fx.write("loose_a/one.rs", "fn one() {}\n");
let second = fx.write("loose_b/two.rs", "fn two() {}\n");
let mut state = editor();
fx.bind(&state);
configure(&state, "rust");
open(&state, &first);
settle(&mut state);
open(&state, &second);
settle(&mut state);
let rows = rows(&state);
assert_eq!(
rows.len(),
1,
"loose files must keep sharing one server: {rows:?}"
);
let fields: Vec<&str> = rows[0].split('|').collect();
assert_eq!(fields[1], "", "the fallback root is not an affinity key");
assert_eq!(
fields[2],
fx.dir("loose_a").display().to_string(),
"cwd still carries the first file's directory"
);
}
// ---------------------------------------------------------------------------
// Acceptance 21 — detected and fallback are different servers, and the
// fallback one still carries its directory as `cwd`.
// ---------------------------------------------------------------------------
#[test]
fn acc21_detected_root_and_markerless_file_get_different_servers() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let inside = fx.write("proj/src/main.rs", "fn main() {}\n");
let loose = fx.write("loose/stray.rs", "fn stray() {}\n");
let mut state = editor();
fx.bind(&state);
configure(&state, "rust");
open(&state, &inside);
settle(&mut state);
open(&state, &loose);
settle(&mut state);
let rows = rows(&state);
assert_eq!(rows.len(), 2, "detected and fallback must differ: {rows:?}");
let detected = rows
.iter()
.find(|r| r.split('|').nth(1).unwrap() == file_uri(&fx.dir("proj")))
.unwrap_or_else(|| panic!("no server rooted at the project: {rows:?}"));
assert_eq!(
detected.split('|').nth(2).unwrap(),
fx.dir("proj").display().to_string()
);
let fallback = rows
.iter()
.find(|r| r.split('|').nth(1).unwrap().is_empty())
.unwrap_or_else(|| panic!("no rootless server: {rows:?}"));
assert_eq!(
fallback.split('|').nth(2).unwrap(),
fx.dir("loose").display().to_string(),
"the markerless server keeps the fallback directory as cwd"
);
}
// ---------------------------------------------------------------------------
// Review-round-1 pins. Neither is a numbered acceptance criterion; both
// cover a branch the nine above leave untested.
// ---------------------------------------------------------------------------
/// A *string* `config.root` is an affinity key. acc17 covers the function
/// form; without this the `return configured, "config"` arm has no test.
///
/// The bite: both files sit in their own marked project, so if the config
/// arm were dropped they would key on their own detected roots and spawn
/// two servers. One server keyed on the configured root is only possible
/// if the override wins.
#[test]
fn config_string_root_overrides_detection_as_the_affinity_key() {
let fx = Fixture::new();
fx.write("a/Cargo.toml", "[package]\nname = \"a\"\n");
fx.write("b/Cargo.toml", "[package]\nname = \"b\"\n");
let first = fx.write("a/src/main.rs", "fn main() {}\n");
let second = fx.write("b/src/main.rs", "fn main() {}\n");
let shared = fx.dir("shared");
std::fs::create_dir_all(&shared).unwrap();
let mut state = editor();
fx.bind(&state);
exec(
&state,
&format!(
"pmacs.lsp.config.rust = {{ command = \"{}\", root = \"{}\" }}",
fake_lsp_path(),
lua_str(&shared)
),
);
open(&state, &first);
settle(&mut state);
open(&state, &second);
settle(&mut state);
let rows = rows(&state);
assert_eq!(
rows.len(),
1,
"a configured root outranks both detected roots: {rows:?}"
);
let fields: Vec<&str> = rows[0].split('|').collect();
assert_eq!(fields[1], file_uri(&shared), "keyed on the configured root");
assert_eq!(fields[2], shared.display().to_string());
}
/// `root = false` reads as unset, as it always has. Defended in
/// `project_root_for` by a truthiness check rather than `~= nil`; this
/// pins the behavior instead of trusting the comment.
///
/// The bite: under a `~= nil` test the config arm would return
/// `false, "config"`, and `file_uri_for(false)` returns nil — so the file
/// would land on a rootless server instead of its detected project.
#[test]
fn config_root_false_reads_as_unset_and_detection_still_wins() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
let mut state = editor();
fx.bind(&state);
exec(
&state,
&format!(
"pmacs.lsp.config.rust = {{ command = \"{}\", root = false }}",
fake_lsp_path()
),
);
open(&state, &file);
settle(&mut state);
let rows = rows(&state);
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!(
rows[0].split('|').nth(1).unwrap(),
file_uri(&fx.dir("proj")),
"`false` must not become a root; detection still wins"
);
}
/// COHERENCE §1.2: background wiring must leave an attributed trace
/// rather than discard a failure. A throwing root resolver is a config
/// bug, and the per-directory memo would otherwise bury it permanently.
///
/// The bite: drop the reporting arm and `*errors*` stays empty while the
/// attach still succeeds — the exact silence §1.2 names as the canonical
/// anti-pattern, in the function it cites.
#[test]
fn a_throwing_root_resolver_leaves_an_attributed_trace() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
let mut state = editor();
fx.bind(&state);
exec(
&state,
&format!(
r#"
pmacs.lsp.config.rust = {{
command = "{}",
root = function(_) error("resolver blew up") end,
}}
"#,
fake_lsp_path()
),
);
open(&state, &file);
settle(&mut state);
let msg = status(&state);
assert!(
msg.contains("root resolver"),
"a raising resolver must leave an attributed trace; got: {msg:?}"
);
assert!(
msg.contains("rust"),
"the trace must name the language that owns it; got: {msg:?}"
);
assert!(
msg.contains("resolver blew up"),
"the underlying error text must survive; got: {msg:?}"
);
// ...and the failure must degrade to a decline, not a failed attach:
// detection still wins and the buffer still gets its server.
let rows = rows(&state);
assert_eq!(rows.len(), 1, "the attach must still succeed: {rows:?}");
assert_eq!(
rows[0].split('|').nth(1).unwrap(),
file_uri(&fx.dir("proj")),
"a declining resolver falls through to the marker walk"
);
}
/// The decline path stays silent. Without this, "report failures" could
/// be satisfied by reporting *every* resolution, which would spam
/// `*errors*` on every attach in a Lean project.
#[test]
fn a_resolver_returning_nil_declines_silently() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
let mut state = editor();
fx.bind(&state);
exec(
&state,
&format!(
"pmacs.lsp.config.rust = {{ command = \"{}\", root = function(_) return nil end }}",
fake_lsp_path()
),
);
open(&state, &file);
settle(&mut state);
let msg = status(&state);
assert!(
!msg.contains("root resolver"),
"returning nil is the documented decline, not a failure; got: {msg:?}"
);
assert_eq!(
rows(&state)[0].split('|').nth(1).unwrap(),
file_uri(&fx.dir("proj"))
);
}

View File

@ -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<u64>,
) -> Option<std::collections::HashMap<String, String>> {
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()
);
}