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:
commit
a72ce72349
16
AGENTS.md
16
AGENTS.md
|
|
@ -1,11 +1,15 @@
|
|||
# pmacs agent instructions
|
||||
|
||||
**Start here: read `docs/agent-handoff.md`, then
|
||||
**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then
|
||||
`docs/active-work.md`, before taking on any work.** The handoff carries
|
||||
durable project state, working method, substrate invariants, and the
|
||||
standing backlog. The active-work ledger carries volatile branches,
|
||||
standing backlog. `COHERENCE.md` carries the product-coherence thesis
|
||||
and its audited ground truth (scorecard, per-concern gaps, priority
|
||||
order) — it is the standard new work gets evaluated against, not just a
|
||||
backlog item; read it before framing anything and cite the section a
|
||||
framing doc serves. The active-work ledger carries volatile branches,
|
||||
checkpoints, verification, and exact cross-machine recovery commands.
|
||||
Keep both updated according to their own update protocols.
|
||||
Keep all three updated according to their own update protocols.
|
||||
|
||||
Always true, independent of the handoff:
|
||||
|
||||
|
|
@ -14,7 +18,11 @@ Always true, independent of the handoff:
|
|||
(`pmacs-protocol`). `#![forbid(unsafe_code)]`.
|
||||
- Workflow: framing doc in `docs/` -> user approval -> branch -> implement
|
||||
-> full gate suite -> PR -> user review rounds -> user says when to
|
||||
merge. Never merge unprompted. One feature, one branch, one PR.
|
||||
merge. Never merge unprompted. One feature, one branch, one PR. A
|
||||
framing doc for coherence-affecting work should state its coherence
|
||||
impact (journey steps touched, interaction islands added, config
|
||||
registry adoption, background-work attribution) per `COHERENCE.md`
|
||||
§20.
|
||||
- Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace
|
||||
--all-targets -- -D warnings` (as its own step); `cargo test --lib`;
|
||||
`cargo test --lib --features crdt`; the touched acceptance suites;
|
||||
|
|
|
|||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -1,11 +1,15 @@
|
|||
# pmacs agent instructions
|
||||
|
||||
**Start here: read `docs/agent-handoff.md`, then
|
||||
**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then
|
||||
`docs/active-work.md`, before taking on any work.** The handoff carries
|
||||
durable project state, working method, substrate invariants, and the
|
||||
standing backlog. The active-work ledger carries volatile branches,
|
||||
standing backlog. `COHERENCE.md` carries the product-coherence thesis
|
||||
and its audited ground truth (scorecard, per-concern gaps, priority
|
||||
order) — it is the standard new work gets evaluated against, not just a
|
||||
backlog item; read it before framing anything and cite the section a
|
||||
framing doc serves. The active-work ledger carries volatile branches,
|
||||
checkpoints, verification, and exact cross-machine recovery commands.
|
||||
Keep both updated according to their own update protocols.
|
||||
Keep all three updated according to their own update protocols.
|
||||
|
||||
Always true, independent of the handoff:
|
||||
|
||||
|
|
@ -14,7 +18,11 @@ Always true, independent of the handoff:
|
|||
(`pmacs-protocol`). `#![forbid(unsafe_code)]`.
|
||||
- Workflow: framing doc in `docs/` -> user approval -> branch -> implement
|
||||
-> full gate suite -> PR -> user review rounds -> user says when to
|
||||
merge. Never merge unprompted. One feature, one branch, one PR.
|
||||
merge. Never merge unprompted. One feature, one branch, one PR. A
|
||||
framing doc for coherence-affecting work should state its coherence
|
||||
impact (journey steps touched, interaction islands added, config
|
||||
registry adoption, background-work attribution) per `COHERENCE.md`
|
||||
§20.
|
||||
- Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace
|
||||
--all-targets -- -D warnings` (as its own step); `cargo test --lib`;
|
||||
`cargo test --lib --features crdt`; the touched acceptance suites;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
18
Cargo.toml
18
Cargo.toml
|
|
@ -241,6 +241,24 @@ codebook-tree-sitter-latex = "0.6"
|
|||
# engine (see `crate::syntax::BUILTIN_LANGUAGES`).
|
||||
tree-sitter-html = "0.23"
|
||||
tree-sitter-css = "0.25"
|
||||
# Lean 4 (`.lean`) — Arc 8 Stage 1 (`docs/lean4-mode-framing.md`, Q#LN1).
|
||||
# `leanprover` ships no tree-sitter grammar (Lean parses with its own
|
||||
# kernel), so both candidates are third-party. The obvious-looking
|
||||
# `tree-sitter-lean4` is NOT usable: it depends on `tree-sitter = "0.25"`
|
||||
# DIRECTLY rather than the shared `tree-sitter-language` ABI crate, which
|
||||
# `^0.25` makes incompatible with our 0.26 and would fork the graph (the
|
||||
# same defect that rules out `tree-sitter-dockerfile` above); it exports
|
||||
# only `pub fn language()` while its README advertises a `LANGUAGE` const
|
||||
# that does not exist; and its package `include` omits `queries/`, so it
|
||||
# ships no highlights at all. `arborium-lean` is a republish from the
|
||||
# arborium grammar collection that does it correctly: `tree-sitter-language
|
||||
# 0.1` as its sole runtime dep, a pre-generated ABI-15 `parser.c` plus
|
||||
# `scanner.c` (no CLI at build time), and `HIGHLIGHTS_QUERY` /
|
||||
# `INJECTIONS_QUERY` / `LOCALS_QUERY` constants. Note the shape: it exports
|
||||
# `const fn language() -> LanguageFn`, so the entry in
|
||||
# `crate::syntax::BUILTIN_LANGUAGES` reads `arborium_lean::language().into()`
|
||||
# rather than the `LANGUAGE.into()` every other entry uses.
|
||||
arborium-lean = "2.18"
|
||||
# T M4.4 process supervisor: signal sending without `unsafe`. Keep
|
||||
# the feature surface tight to keep build time low. `poll` feeds the
|
||||
# compile-mode group readers (cancellable poll-based reads, Q#CM3).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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))];
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
297
src/daemon.rs
297
src/daemon.rs
|
|
@ -1536,23 +1536,7 @@ fn dispatcher_loop(
|
|||
// Accepted terminal context controls PTY size. Apply any focus,
|
||||
// window, or resize changes before consuming another child-output
|
||||
// batch so screen reflow and subsequent bytes share one geometry.
|
||||
for frontend_id in &attached_fids {
|
||||
if let Some(size) = term_sizes.get(frontend_id).copied() {
|
||||
editor.sync_terminal_layout(*frontend_id, size);
|
||||
}
|
||||
// Vterm Stage 3 — the semantic twin, right beside the grid
|
||||
// sync so both frontend kinds resize the screen before the
|
||||
// next child-output drain. The frontend declared a CONTENT
|
||||
// rectangle, so this consumes the size directly instead of
|
||||
// running the TUI placement helper, which would subtract a
|
||||
// modeline the GPU never drew.
|
||||
if let Some((buffer_id, size)) = semantic_states
|
||||
.get(frontend_id)
|
||||
.and_then(crate::semantic_render::SemanticRenderState::terminal_viewport)
|
||||
{
|
||||
editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size);
|
||||
}
|
||||
}
|
||||
sync_terminal_layouts_for_tick(editor, &attached_fids, &term_sizes, &semantic_states);
|
||||
|
||||
// `tick_async` last: the M4.5 async bridge settles awaiters
|
||||
// inside `tick_lsp` (via the message bus); draining + resuming
|
||||
|
|
@ -3064,6 +3048,56 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre
|
|||
}
|
||||
}
|
||||
|
||||
/// One dispatcher tick's terminal-layout step, for every attached frontend.
|
||||
///
|
||||
/// Extracted from the dispatcher loop so the grid/semantic exclusivity is
|
||||
/// **structural** rather than two adjacent `if`s, and so acceptance tests can
|
||||
/// drive the real loop body instead of re-implementing it (Q#GT1).
|
||||
///
|
||||
/// The shape that matters: liveness is frontend-kind NEUTRAL and runs for
|
||||
/// everyone, exactly once; the geometry arms are EXCLUSIVE alternatives keyed
|
||||
/// on the same `semantic_states` membership that session establishment uses,
|
||||
/// so a session can never be caught by both.
|
||||
///
|
||||
/// Before this existed, both arms ran for every frontend. A semantic session
|
||||
/// has a `term_sizes` entry (from `AttachRequest`) *and* a terminal
|
||||
/// declaration, so its PTY was resized twice per tick, forever: the grid arm
|
||||
/// installed the TUI placement size, the semantic arm installed the declared
|
||||
/// content rectangle, and each arm's own idempotence guard saw only the size
|
||||
/// the other had just written. The child got a `SIGWINCH` storm at tick
|
||||
/// cadence, which is what made typing into a GPU terminal impossible while
|
||||
/// output kept flowing.
|
||||
fn sync_terminal_layouts_for_tick(
|
||||
editor: &mut EditorState,
|
||||
attached_fids: &[FrontendId],
|
||||
term_sizes: &HashMap<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);
|
||||
|
|
|
|||
157
src/editor.rs
157
src/editor.rs
|
|
@ -525,6 +525,17 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/window.lua"),
|
||||
)
|
||||
.expect("load window builtin chunk");
|
||||
// Dired Stage 1: the directory view. Loaded AFTER window.lua,
|
||||
// whose `window.panel-height` setting a `display = "panel"`
|
||||
// listing resolves, and after the pre-runtime tables it drives
|
||||
// (`pmacs.config` / `command` / `keymap` / `buffer` / `editor` /
|
||||
// `minibuffer` / `path`, plus `pmacs.fs` from fs.lua above).
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/dired.lua"),
|
||||
include_str!("../builtin/runtime/dired.lua"),
|
||||
)
|
||||
.expect("load dired builtin chunk");
|
||||
// Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT:
|
||||
// compile.lua must load AFTER lsp.lua. It takes over
|
||||
// `M-g n` / `M-g p` for the unified error dispatchers, and
|
||||
|
|
@ -1189,14 +1200,84 @@ impl EditorState {
|
|||
let _ = self.terminal_manager.borrow_mut().release_controller(key);
|
||||
}
|
||||
|
||||
/// Reconcile panels and release a controller whose window moved away.
|
||||
///
|
||||
/// **Frontend-kind neutral, and deliberately so** (Q#GT1/Q#GT4): this
|
||||
/// half reads only `core.views`, `core.windows`, and the controller —
|
||||
/// never a grid size — so it is the half the dispatcher runs for EVERY
|
||||
/// attached frontend once per tick. It was previously fused into
|
||||
/// [`Self::sync_terminal_layout`], which meant a semantic frontend got
|
||||
/// its controller-liveness release only as a side effect of a grid
|
||||
/// resize it should never have received.
|
||||
///
|
||||
/// [`Self::sync_semantic_terminal_layout`] cannot substitute for this:
|
||||
/// when a GPU window switches away from its terminal, the buffer-follow
|
||||
/// snapshot clears the viewport declaration
|
||||
/// (`SemanticRenderState::on_buffer_snapshot_sent`), so the semantic arm
|
||||
/// stops running entirely in exactly the case that needs the release.
|
||||
///
|
||||
/// Returns `true` while `frontend_id` still holds a live controller.
|
||||
pub fn sync_terminal_controller_liveness(&mut self, frontend_id: FrontendId) -> bool {
|
||||
// Bottom-panel arc (Q#BP2b): a panel that just became
|
||||
// unsatisfiable must have released its controller before any
|
||||
// resize runs, or the child would be resized against a dead rect.
|
||||
// This is the contract's only per-tick enforcement point, and it
|
||||
// stays neutral so semantic frontends keep it (Q#GT7).
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
let Some(key) = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.controller_view_for_frontend(frontend_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let core = self.core.borrow();
|
||||
let Some(view) = core.views.get(&frontend_id) else {
|
||||
drop(core);
|
||||
let _ = self.terminal_manager.borrow_mut().release_controller(key);
|
||||
return false;
|
||||
};
|
||||
if view.active != key.window_id
|
||||
|| core
|
||||
.windows
|
||||
.get(&key.window_id)
|
||||
.is_none_or(|window| window.buffer_id != key.buffer_id)
|
||||
{
|
||||
drop(core);
|
||||
let _ = self.terminal_manager.borrow_mut().release_controller(key);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Resize the one session durably controlled by `frontend_id`.
|
||||
///
|
||||
/// This is called before process drain and paint, never from rendering.
|
||||
///
|
||||
/// Composition of the two halves, preserved verbatim for the in-process
|
||||
/// `editor::run` loop and `LOCAL`. The daemon dispatcher calls the halves
|
||||
/// separately, because only the geometry half is grid-specific.
|
||||
pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool {
|
||||
// Bottom-panel arc (Q#BP2b): a panel that just became
|
||||
// unsatisfiable must have released its controller before this
|
||||
// runs, or the child would be resized against a dead rect.
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
self.sync_terminal_controller_liveness(frontend_id)
|
||||
&& self.sync_terminal_grid_geometry(frontend_id, term_size)
|
||||
}
|
||||
|
||||
/// The grid half: TUI placement plus the resize it implies.
|
||||
///
|
||||
/// **Grid frontends only** (Q#GT1). The placement lookup below is why:
|
||||
/// a semantic frontend has no `window_placements` entry at all, so the
|
||||
/// "no placement" arm would release its controller on EVERY tick. That
|
||||
/// release reads like liveness and is not — it is grid geometry, and
|
||||
/// moving it into [`Self::sync_terminal_controller_liveness`] would
|
||||
/// reintroduce this framing's own defect in a new place.
|
||||
///
|
||||
/// Assumes liveness already ran: the controller is live and its window
|
||||
/// still shows the terminal.
|
||||
pub fn sync_terminal_grid_geometry(
|
||||
&mut self,
|
||||
frontend_id: FrontendId,
|
||||
term_size: CellSize,
|
||||
) -> bool {
|
||||
let Some(key) = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
|
|
@ -1206,23 +1287,11 @@ impl EditorState {
|
|||
};
|
||||
let content = {
|
||||
let core = self.core.borrow();
|
||||
let Some(view) = core.views.get(&frontend_id) else {
|
||||
let _ = self.terminal_manager.borrow_mut().release_controller(key);
|
||||
return false;
|
||||
};
|
||||
if view.active != key.window_id
|
||||
|| core
|
||||
.windows
|
||||
.get(&key.window_id)
|
||||
.is_none_or(|window| window.buffer_id != key.buffer_id)
|
||||
{
|
||||
let _ = self.terminal_manager.borrow_mut().release_controller(key);
|
||||
return false;
|
||||
}
|
||||
let Some(placement) = window_placements(&core, frontend_id, term_size)
|
||||
.get(&key.window_id)
|
||||
.copied()
|
||||
else {
|
||||
drop(core);
|
||||
let _ = self.terminal_manager.borrow_mut().release_controller(key);
|
||||
return false;
|
||||
};
|
||||
|
|
@ -5648,17 +5717,25 @@ mod tests {
|
|||
|
||||
// ---- T M2.11 acceptance --------------------------------------------------
|
||||
|
||||
/// Every chord in the default global keymap must round-trip through
|
||||
/// Every chord in the default keymap must round-trip through
|
||||
/// `pmacs.describe.key`: returning a non-nil table whose `command`
|
||||
/// matches the binding the keymap stack stores.
|
||||
///
|
||||
/// `describe.key` resolves against the **effective context**
|
||||
/// (buffer-local → mode → global), so a mode-scoped default is
|
||||
/// asserted with a buffer that carries that mode rather than
|
||||
/// context-free. Dired is the first builtin to bind mode-scoped keys
|
||||
/// (#129's first non-detection consumer), and without the mode in
|
||||
/// place its `n` / `p` / `g` correctly resolve to nothing.
|
||||
#[test]
|
||||
fn describe_key_identifies_every_default_binding() {
|
||||
use crate::keymap_stack::Scope;
|
||||
let s = EditorState::new();
|
||||
let kms = s.lua_host.keymaps().borrow();
|
||||
let bindings: Vec<(String, String)> = kms
|
||||
let bindings: Vec<(Scope, String, String)> = kms
|
||||
.iter_all()
|
||||
.into_iter()
|
||||
.map(|(_, seq, b)| (crate::key::display_sequence(&seq), b.command))
|
||||
.map(|(scope, seq, b)| (scope, crate::key::display_sequence(&seq), b.command))
|
||||
.collect();
|
||||
drop(kms);
|
||||
// Sanity floor: the default keymap binds at least the M1 surface.
|
||||
|
|
@ -5667,18 +5744,50 @@ mod tests {
|
|||
"default keymap unexpectedly small: {} bindings",
|
||||
bindings.len()
|
||||
);
|
||||
let modes: usize = bindings
|
||||
.iter()
|
||||
.filter(|(scope, _, _)| matches!(scope, Scope::Mode(_)))
|
||||
.count();
|
||||
assert!(
|
||||
modes >= 1,
|
||||
"a mode-scoped default is expected since dired Stage 1; \
|
||||
found none, so the mode arm below asserts nothing"
|
||||
);
|
||||
|
||||
for (seq, expected_command) in &bindings {
|
||||
for (scope, seq, expected_command) in &bindings {
|
||||
let mode = match scope {
|
||||
Scope::Mode(name) => Some(name.clone()),
|
||||
// No buffer-scoped defaults exist; a future one would
|
||||
// need its own buffer context here.
|
||||
Scope::Buffer(_) => continue,
|
||||
Scope::Global => None,
|
||||
};
|
||||
// Set the context explicitly on EVERY iteration, including
|
||||
// the global one: a mode left over from a previous iteration
|
||||
// legitimately shadows a global binding of the same chord
|
||||
// (dired's mode-scoped `RET` shadows
|
||||
// `edit.newline-and-indent`, which is the point of the
|
||||
// mode), so a leaked mode would make this assert the wrong
|
||||
// thing.
|
||||
let context = match &mode {
|
||||
Some(name) => {
|
||||
format!("pmacs.buffer.set_major_mode(pmacs.window.buffer(), {name:?}); ")
|
||||
}
|
||||
None => "pmacs.buffer.set_major_mode(pmacs.window.buffer(), nil); ".to_owned(),
|
||||
};
|
||||
let script = format!(
|
||||
"local r = pmacs.describe.key({seq:?}); \
|
||||
"{context}local r = pmacs.describe.key({seq:?}); \
|
||||
if r == nil then return 'nil' else return r.command end"
|
||||
);
|
||||
let got: String = s.lua_host.lua().load(&script).eval().unwrap_or_else(|e| {
|
||||
panic!("describe.key({seq}) raised: {e}");
|
||||
});
|
||||
assert_eq!(
|
||||
&got, expected_command,
|
||||
"describe.key for {seq:?} returned {got:?}, expected {expected_command:?}"
|
||||
&got,
|
||||
expected_command,
|
||||
"describe.key for {seq:?} (scope {}) returned {got:?}, \
|
||||
expected {expected_command:?}",
|
||||
scope.render()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
329
src/fs.rs
|
|
@ -42,6 +42,28 @@ use crate::worker::CancellationToken;
|
|||
/// directories.
|
||||
const READDIR_CANCEL_POLL_EVERY: usize = 32;
|
||||
|
||||
/// How many *consecutive* `readdir` iterator errors a tolerant listing
|
||||
/// records before giving up and failing (dired Q#DR6).
|
||||
///
|
||||
/// [`std::fs::ReadDir`] is not obliged to terminate after yielding an
|
||||
/// `Err`: a directory pulled out from under a stalled network mount can
|
||||
/// keep producing them. Tolerant mode records-and-continues, so without
|
||||
/// a bound that is an unbounded error vector on a worker thread.
|
||||
///
|
||||
/// Cancellation is **not** an adequate backstop here, which is the
|
||||
/// reason this constant exists rather than a comment saying it is: a
|
||||
/// dired listing carries no supersede key and nothing cancels it, so the
|
||||
/// only thing that would stop the loop is the directory itself. A
|
||||
/// directory whose iterator produces nothing but errors has no partial
|
||||
/// answer worth rendering, so the listing fails with the last error the
|
||||
/// way an unopenable directory does.
|
||||
///
|
||||
/// Deliberately untested: forcing a real `readdir` to yield errors
|
||||
/// repeatedly is not portable, and faking it would need the walk to be
|
||||
/// generic over its iterator — a refactor with no other consumer. The
|
||||
/// counter resets on any entry that materializes.
|
||||
const READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS: usize = 1024;
|
||||
|
||||
/// One directory entry as returned by [`read_dir_blocking`].
|
||||
///
|
||||
/// The shape is what `dired` / `magit-class` / `outline-class`
|
||||
|
|
@ -116,6 +138,59 @@ impl FsEntryKind {
|
|||
}
|
||||
}
|
||||
|
||||
/// Per-entry tolerance for [`read_dir_blocking`] (dired Q#DR6).
|
||||
///
|
||||
/// The M8.1 primitive was all-or-nothing: five per-entry conditions
|
||||
/// failed the *entire* listing, which makes a plain refresh of a busy
|
||||
/// directory (`/tmp`, a build tree) fail outright. The module doc used
|
||||
/// to say a per-entry-tolerant wrapper was "the package's job" --- it
|
||||
/// cannot be: the primitive hands Lua one structured error and no
|
||||
/// partial vec, so there is nothing to be tolerant *with*.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ReadDirTolerance {
|
||||
/// Any per-entry failure fails the whole listing. The original
|
||||
/// M8.1 contract, and still the default at every Lua call site
|
||||
/// that does not opt in.
|
||||
Fatal,
|
||||
/// Per-entry failures are recorded in [`FsDirListing::errors`] and
|
||||
/// enumeration continues. A failure on the *parent* `read_dir`
|
||||
/// stays fatal (a directory you cannot open has no partial
|
||||
/// answer), and so does a non-UTF-8 entry **name** --- see
|
||||
/// [`FsError::NonUtf8Path`].
|
||||
PerEntry,
|
||||
}
|
||||
|
||||
/// One per-entry failure recorded by a tolerant [`read_dir_blocking`].
|
||||
///
|
||||
/// `name` is optional because a per-entry `readdir` *iterator* error
|
||||
/// has no filename to report: the entry never materialized, and the
|
||||
/// underlying error is about the parent directory. Every other arm has
|
||||
/// an entry in hand and names it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FsDirEntryError {
|
||||
/// Basename of the entry that failed, when one is known.
|
||||
pub name: Option<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!(
|
||||
|
|
|
|||
296
src/highlight.rs
296
src/highlight.rs
|
|
@ -174,6 +174,46 @@ impl Theme {
|
|||
// prefix-walks to `tag`.
|
||||
("tag", fg(5)),
|
||||
("attribute", fg(3)),
|
||||
// Lean 4 (framing Q#LN4). These four are the captures the Lean
|
||||
// query uses that the set above lacks — but three of them are
|
||||
// NOT Lean-only, and adding them here changes languages that
|
||||
// already ship. That is the #146 lesson (`attribute`, above,
|
||||
// retro-painted rust/lua/yaml) and it is deliberate, not
|
||||
// incidental:
|
||||
//
|
||||
// * `constructor` reaches SEVEN entries — rust, lua, python,
|
||||
// javascript, and (because `tree_sitter_javascript::
|
||||
// HIGHLIGHT_QUERY` is concatenated base-first into them)
|
||||
// javascriptreact, typescript, typescriptreact. Its shape is
|
||||
// not "constructors": rust/python/javascript tag every
|
||||
// capitalized identifier (`#match? "^[A-Z]"`), and lua tags
|
||||
// every table-constructor brace. So this recolors `Some`,
|
||||
// `None`, `Ok`, `Err`, every class-cased name, and every Lua
|
||||
// `{}`. All of those render as unstyled default text today.
|
||||
// * `character` reaches zig only.
|
||||
// * `keyword.conditional` reaches cmake and zig, which
|
||||
// currently flatten it to `keyword`; giving it
|
||||
// `keyword.control`'s style makes their conditionals read the
|
||||
// way rust's already do.
|
||||
// * `warning` reaches no other grammar. It exists for Lean's
|
||||
// `sorry` — an unproved goal, the single most important thing
|
||||
// to see in a proof file.
|
||||
//
|
||||
// The alternative was an in-repo query overlay renaming these
|
||||
// into the existing vocabulary (the #144 LaTeX pattern), which
|
||||
// would fork a 213-line query we would then own and hand-merge
|
||||
// on every crate bump. There is no middle option: styling Lean's
|
||||
// constructors without touching the other seven entries requires
|
||||
// renaming the capture, which requires the overlay.
|
||||
("constructor", fg(11)),
|
||||
("character", fg(2)),
|
||||
("keyword.conditional", fg_bold(13)),
|
||||
// Bold BRIGHT red, deliberately the loudest entry in the table
|
||||
// and deliberately distinct from `number`'s plain `fg(1)`: in a
|
||||
// proof file `sorry` means "this is admitted, not proved", which
|
||||
// is the one thing a reader must never skim past. Plain `fg(1)`
|
||||
// would have collided with every numeric literal on colour alone.
|
||||
("warning", fg_bold(9)),
|
||||
];
|
||||
let by_capture = entries
|
||||
.iter()
|
||||
|
|
@ -1484,6 +1524,262 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Paint `src` as `language` into a one-row grid and return the style
|
||||
/// at column `col`. Shared by the Q#LN4 retro-paint pins below.
|
||||
fn painted_fg_at(
|
||||
language_name: &str,
|
||||
file: &str,
|
||||
src: &str,
|
||||
col: u32,
|
||||
) -> pmacs_protocol::cell::Color {
|
||||
painted_style_at(language_name, file, src, col).fg
|
||||
}
|
||||
|
||||
/// As [`painted_fg_at`], but returns the whole style — needed where a
|
||||
/// colour alone does not discriminate (Lean's `warning` vs `number`).
|
||||
fn painted_style_at(
|
||||
language_name: &str,
|
||||
file: &str,
|
||||
src: &str,
|
||||
col: u32,
|
||||
) -> pmacs_protocol::cell::Style {
|
||||
use crate::buffer::{Buffer, BufferId, EditOp};
|
||||
use crate::cell::{Cell, CellSize};
|
||||
use crate::syntax::{ParseView, SyntaxRegistry};
|
||||
|
||||
let reg = SyntaxRegistry::new();
|
||||
let language = reg.language(language_name).expect("grammar loads");
|
||||
let mut buf = Buffer::new(BufferId::next(), file);
|
||||
buf.apply_edit(EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: src.as_bytes(),
|
||||
})
|
||||
.unwrap();
|
||||
let view = ParseView::new(&buf, language, language_name.to_owned());
|
||||
let handle = view.handle();
|
||||
let _vid = buf.attach_view(Box::new(view));
|
||||
let mut req = handle.make_request();
|
||||
req.injection_aliases = reg.injection_alias_snapshot();
|
||||
let bundle = crate::syntax::run_parse(req).expect("parse");
|
||||
handle.install(reg.resolve_layer_queries(&bundle));
|
||||
|
||||
let mut hv = SyntaxHighlightView::new(handle, reg.theme());
|
||||
let (rows, cols) = (1usize, 40usize);
|
||||
let mut backing: Vec<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`,
|
||||
|
|
|
|||
|
|
@ -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)?)?;
|
||||
|
|
|
|||
176
src/syntax.rs
176
src/syntax.rs
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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"))
|
||||
);
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue