Merge pull request #113 from levineuwirth/compile-mode

feat(compile): compile-mode, unified next-error, grep-mode upgrade, shell-command (Arc 5 stage 1)
This commit is contained in:
Levi Neuwirth 2026-07-14 10:09:07 +00:00 committed by GitHub
commit 98323df140
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 7632 additions and 111 deletions

View File

@ -154,9 +154,9 @@ tree-sitter-cpp = "0.23"
# maintenance status — pick the upstream one.
tree-sitter-md = "0.5"
# T M4.4 process supervisor: signal sending without `unsafe`. Keep
# the feature surface tight to keep build time low (no syscalls
# beyond `kill(2)` for v0.1).
nix = { version = "0.29", default-features = false, features = ["signal", "user", "fs", "term", "socket"] }
# the feature surface tight to keep build time low. `poll` feeds the
# compile-mode group readers (cancellable poll-based reads, Q#CM3).
nix = { version = "0.29", default-features = false, features = ["signal", "user", "fs", "term", "socket", "poll"] }
# T M4.4 PTY mode: portable abstraction over openpty / fork+exec
# with controlling-tty wiring. The crate uses internal `unsafe`
# but exposes a fully safe API; pmacs's own `unsafe_code = "forbid"`

View File

@ -644,7 +644,8 @@ cmd { name = "editor.execute-command",
}
end }
-- Workers and project search (T M3.6, T M3.7) -------------------------------
-- Workers and project search (T M3.6, T M3.7; grep-mode upgrade per
-- docs/compile-mode-framing.md Q#CM7) ---------------------------------------
--
-- `pmacs.workers.grep` is the runtime API; the user-facing entry
-- point is `M-x project.search`, which prompts for a query and
@ -653,19 +654,206 @@ cmd { name = "editor.execute-command",
-- `supersede = "search"` key (M3.6 acceptance: cancel within 50 ms).
-- `pmacs.project.search(query, opts)` is the same logic without
-- the prompt --- callable from Lua scripts and tests.
--
-- The panel is a first-class locations buffer: read-only intercept
-- with bypass writes, RET visits, n/p walk match lines, q restores,
-- undo chords are no-ops, and each run claims the unified
-- next-error source so M-g n walks matches. The worker's matches
-- are already structured — no regex parsing. Every producer write
-- runs the revision check BEFORE mutating so a no-hook external
-- edit cannot be masked by the next batch advancing the expected
-- revision.
pmacs.project = pmacs.project or {}
local SEARCH_RESULTS_NAME = "*search-results*"
local GREP_DESYNC_MARKER = "\n[output desynced by external edit]\n"
local active_search_id = nil
local active_search_stream = nil
-- Panel state: buffer incarnation, the root this search ran with
-- (retained across interactive supersedes issued from inside the
-- pathless panel), match locations, and the revision guard.
local search_panel = nil
local function search_results_buffer()
local function grep_count_newlines(s)
local n = 0
local i = 0
while true do
i = s:find("\n", i + 1, true)
if not i then return n end
n = n + 1
end
end
local function search_panel_alive()
return search_panel ~= nil
and search_panel.buf ~= nil
and search_panel.buf:is_valid()
end
-- Resync after an external edit (the Q#CM2 discipline, grep shape):
-- drop row anchors (a revision carries no edit range), append the
-- marker, and recompute the row epoch. The match-location list
-- survives for M-g n.
local function search_panel_resync()
local p = search_panel
for _, m in ipairs(p.matches) do
m.row = nil
end
local buf = p.buf
buf:insert(buf:len(), GREP_DESYNC_MARKER, { bypass_intercept = true })
p.next_row = grep_count_newlines(buf:slice(0, buf:len()))
p.expected_rev = buf:revision()
end
local function search_panel_check_rev()
if not search_panel_alive() then return false end
local p = search_panel
if p.expected_rev ~= nil and p.buf:revision() ~= p.expected_rev then
search_panel_resync()
end
return true
end
-- Revision-checked producer append: check BEFORE the write (so a
-- mismatch is marked rather than masked), record after. Returns the
-- row the text landed on, or nil when the panel is gone.
local function search_panel_append(text)
if not search_panel_check_rev() then return nil end
local p = search_panel
local row = p.next_row
local buf = p.buf
buf:insert(buf:len(), text, { bypass_intercept = true })
p.expected_rev = buf:revision()
p.next_row = row + grep_count_newlines(text)
return row
end
local SEARCH_UNDO_CHORDS = { "C-/", "C-_", "C-4", "C-x u", "C-?", "C-S-_", "C-x r" }
local function ensure_search_panel()
if search_panel_alive() then return search_panel end
local p = search_panel or { matches = {}, match_index = 0 }
search_panel = p
local buf
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == SEARCH_RESULTS_NAME then
return id
buf = id
break
end
end
return pmacs.buffer.create(SEARCH_RESULTS_NAME)
p.buf = buf or pmacs.buffer.create(SEARCH_RESULTS_NAME)
pmacs.buffer.add_intercept(p.buf, function()
error(SEARCH_RESULTS_NAME .. " is read-only")
end)
pmacs.buffer.set_round_trip_input(p.buf, true)
-- Kill-mid-search safety (Q#CM7): cancel the stream and
-- invalidate the id so late callbacks drop instead of writing
-- through a stale handle; the next search recreates the buffer.
pcall(pmacs.buffer.on_removed, p.buf, function()
if active_search_stream then
pcall(function() active_search_stream:cancel() end)
end
active_search_id = nil
active_search_stream = nil
p.buf = nil
end)
local function bind(seq, command)
pmacs.keymap.bind {
scope = "buffer", buffer = p.buf, sequence = seq, command = command,
}
end
bind("RET", "project-search.visit")
bind("n", "project-search.next-line")
bind("p", "project-search.previous-line")
bind("q", "project-search.quit")
for _, seq in ipairs(SEARCH_UNDO_CHORDS) do
bind(seq, "compile.undo-noop")
end
return p
end
-- Immediate command-path recovery for the panel (the Q#CM2 trigger
-- the compile slots already have): M-x/menu edits fire
-- buffer.after-edit, and after a COMPLETED search no producer write
-- or navigation may ever come — without this, an M-x buffer.undo
-- left corrupted output unmarked indefinitely (PR #113 round-1
-- finding 2). Hook edits don't re-fire the hook, so the resync
-- marker can be appended from here safely.
pmacs.hook.add("buffer.after-edit", function()
local cur = pmacs.window.buffer()
if cur and search_panel_alive() and cur == search_panel.buf then
search_panel_check_rev()
end
end)
-- Cursor walk via primitives (the lsp.lua visit idiom; 0-based).
-- Movement-bounded like compile.lua's walk: a match pointing past
-- EOF/EOL clamps instead of looping (the file may have changed on
-- disk since the worker scanned it).
local function search_move_cursor_to(line, col)
pmacs.editor.move_line_start()
while pmacs.editor.cursor_line() > 0 do
pmacs.editor.move_up()
end
for _ = 1, line do
local before = pmacs.editor.cursor_line()
pmacs.editor.move_down()
if pmacs.editor.cursor_line() == before then break end
end
local row = pmacs.editor.cursor_line()
for _ = 1, col do
local before = pmacs.editor.cursor()
pmacs.editor.move_right()
if pmacs.editor.cursor() == before then break end
if pmacs.editor.cursor_line() ~= row then
pmacs.editor.move_left()
break
end
end
end
local function visit_match(idx)
local p = search_panel
local m = p and p.matches[idx]
if not m then return end
-- Worker match paths are relative to the search root, not the
-- cwd — resolve against the root this search ran with.
local path = m.file
if path:sub(1, 1) ~= "/" then
local root = p.root or "."
if root:sub(-1) ~= "/" then root = root .. "/" end
path = root .. path
end
pmacs.editor.push_jump()
local ok, err = pcall(pmacs.buffer.find_or_open, path)
if not ok then
pmacs.editor.jump_back()
pmacs.editor.set_status("search: failed to open " .. path .. ": " .. tostring(err))
return
end
search_move_cursor_to(m.line, m.col)
p.match_index = idx
end
-- Root resolution (Q#CM7): explicit opt > the panel's stored root
-- when searching from inside the pathless panel (the natural
-- supersede path would otherwise silently degrade to ".") > the
-- active file's project root > ".".
local function resolve_search_root(opts)
if opts.root then return opts.root end
local cur = pmacs.window.buffer()
if cur and search_panel_alive() and cur == search_panel.buf and search_panel.root then
return search_panel.root
end
if cur then
local okp, path = pcall(function() return cur:path() end)
if okp and path then
local okd, proj = pcall(pmacs.project.detect, path)
if okd and proj and proj.root then return proj.root end
end
end
return "."
end
function pmacs.project.search(query, opts)
@ -673,18 +861,61 @@ function pmacs.project.search(query, opts)
return nil
end
opts = opts or {}
local root = opts.root or "."
local buf = search_results_buffer()
local root = resolve_search_root(opts)
local p = ensure_search_panel()
-- q-target discipline: never capture a generated buffer.
local cur = pmacs.window.buffer()
if cur
and not (pmacs.compile
and pmacs.compile.is_generated_buffer
and pmacs.compile.is_generated_buffer(cur))
then
p.prev = cur
end
p.root = root
p.matches = {}
p.match_index = 0
-- Replace any prior contents in one shot, then append a header.
-- The buffer is reused across searches; clearing here is what
-- gives the user a fresh page per query.
if buf:len() > 0 then buf:delete(0, buf:len()) end
buf:insert(0, "Searching for: " .. query .. "\n\n")
local buf = p.buf
if buf:len() > 0 then buf:delete(0, buf:len(), { bypass_intercept = true }) end
local header = "Searching for: " .. query .. "\n\n"
buf:insert(0, header, { bypass_intercept = true })
p.next_row = grep_count_newlines(header)
p.expected_rev = buf:revision()
pmacs.window.switch_buffer(buf)
local stream = pmacs.workers.grep(
{ root = root, pattern = query },
{ supersede = "search" })
active_search_id = stream:id()
active_search_stream = stream
-- Claim the unified next-error source (Q#CM5): M-g n walks the
-- match list, which survives desync epochs. Guarded: this chunk
-- loads before the runtime chunks, and a minimal harness may
-- invoke search without compile.lua installed.
local claim = pmacs.errors and pmacs.errors.claim or function() end
claim {
name = "grep",
next = function()
if not p.matches or #p.matches == 0 then
pmacs.editor.set_status("search: no matches")
return
end
if p.match_index >= #p.matches then
pmacs.editor.set_status("no more errors")
return
end
visit_match(p.match_index + 1)
end,
previous = function()
if p.match_index <= 1 then
pmacs.editor.set_status("no more errors")
return
end
visit_match(p.match_index - 1)
end,
}
-- Capture the id at registration time so callbacks for a
-- superseded predecessor (whose worker hasn't yet observed
-- cancel) drop their late batches instead of polluting the
@ -692,18 +923,112 @@ function pmacs.project.search(query, opts)
local my_id = active_search_id
stream:on_batch(function(items)
if my_id ~= active_search_id then return end
if not search_panel_alive() then return end
for _, m in ipairs(items) do
buf:insert(buf:len(), string.format("%s:%d:%d: %s\n",
local row = search_panel_append(string.format("%s:%d:%d: %s\n",
m.file, m.line, m.match_start, m.text))
if row then
-- Grep normalization (Q#CM7): line is 1-based → minus one;
-- match_start is already a 0-based byte offset in the line.
p.matches[#p.matches + 1] = {
file = m.file,
line = m.line - 1,
col = m.match_start,
row = row,
}
end
end
end)
stream:on_close(function(status, _value)
if my_id ~= active_search_id then return end
buf:insert(buf:len(), string.format("\n-- search %s --\n", status))
if not search_panel_alive() then return end
search_panel_append(string.format("\n-- search %s --\n", status))
end)
return stream
end
local function match_on_row(row)
local p = search_panel
if not p then return nil end
for i, m in ipairs(p.matches) do
if m.row == row then return i end
end
return nil
end
local function active_is_search_panel()
local cur = pmacs.window.buffer()
return cur ~= nil and search_panel_alive() and cur == search_panel.buf
end
cmd { name = "project-search.visit",
description = "Visit the match on the current line of *search-results*.",
fn = function()
if not active_is_search_panel() then return end
if not search_panel_check_rev() then return end
local idx = match_on_row(pmacs.editor.cursor_line())
if not idx then
pmacs.editor.set_status("no match on this line")
return
end
visit_match(idx)
end }
local function search_step_line(direction)
if not active_is_search_panel() then return end
if not search_panel_check_rev() then return end
local from = pmacs.editor.cursor_line()
local best = nil
for _, m in ipairs(search_panel.matches) do
if m.row then
if direction > 0 and m.row > from and (not best or m.row < best) then
best = m.row
elseif direction < 0 and m.row < from and (not best or m.row > best) then
best = m.row
end
end
end
if not best then
pmacs.editor.set_status("no more errors")
return
end
local cur = from
while cur < best do
pmacs.editor.move_down()
cur = cur + 1
end
while cur > best do
pmacs.editor.move_up()
cur = cur - 1
end
pmacs.editor.move_line_start()
end
cmd { name = "project-search.next-line",
description = "Move to the next match line within *search-results*.",
fn = function() search_step_line(1) end }
cmd { name = "project-search.previous-line",
description = "Move to the previous match line within *search-results*.",
fn = function() search_step_line(-1) end }
cmd { name = "project-search.quit",
description = "Leave *search-results*, restoring the previous buffer.",
fn = function()
if not active_is_search_panel() then return end
local target = search_panel.prev
if not (target and target:is_valid()) then
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == "*scratch*" then
target = id
break
end
end
target = target or pmacs.buffer.create("*scratch*")
end
pmacs.window.switch_buffer(target)
end }
cmd { name = "project.search",
description = "Parallel grep across the project; new queries cancel the predecessor.",
fn = function()

1136
builtin/runtime/compile.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-12, on the laptop, by the editops session
(post-#111 merge; scripts/bite micro-PR).** This file is the
**Last updated: 2026-07-13, on the laptop, by the compile-mode
session (on-branch snapshot).** This file is the
bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the `docs/*-framing.md`
files ARE your memory. Read this fully before taking on work, seed
@ -9,10 +9,26 @@ your persistent memory from it, and **update this file (and commit
it) whenever project state changes materially** — the next machine
reads it the way you just did.
## 1. Where the project stands (2026-07-12)
## 1. Where the project stands (2026-07-13)
- `main` @ `f0a05c5` (editops #111 merged), protocol **v15**
- `main` @ `0efb5cd` (scripts/bite #112 merged), protocol **v15**
(`SUPPORTED=[6..15]`).
- **IN FLIGHT: compile-mode (Arc 5 stage 1) on branch
`compile-mode`** — the user chose it over themes (Arc 4) in the
decision discussion. Framing `docs/compile-mode-framing.md` at
revision 6 (five pre-branch review rounds; approved for build).
Shape: `compile.run` streams `/bin/sh -c "exec 2>&1; <cmd>"`
(pipes, `stdin="null"`, `group=true`, TERM=dumb) into an
intercept-read-only `*compilation*` buffer via a Lua-side ANSI
parser; once-per-newline error rules; unified `error.next`/
`error.previous` dispatcher (M-g n/p taken over from diag with a
behavior-preserving fallback; `` C-x ` ``; M-! shell-command);
buffer-revision external-edit guard with desync marker + anchor
epochs; grep-mode upgrade of `project.search`. New substrate other
code can use: `ProcessSpec.stdin/group` (group lifecycle: reap
ledger, in-drain enforcement, cancellable poll readers),
`buf:revision()`, jump_back now fires `buffer.after-switch`,
`pmacs.errors.claim`. All gates green; PR next.
- **Editing-conveniences pack (editops, #111) landed** — the Lua
parallel lane. Framing `docs/editing-conveniences-framing.md` at
revision 6 (three pre-branch rounds, one adopted post-approval
@ -41,9 +57,11 @@ reads it the way you just did.
`buf:path()`, `pmacs.lsp.buffer_language(buf)`,
`PMACS_FAKE_LSP_CHANGE_SINK` (fake-LSP doc-sync replay),
`TestDaemon::spawn_with_config` (init.lua-carrying daemon fixture).
- **NEXT: the user wants a decision discussion — compile-mode (Arc 5
stage 1) vs themes (Arc 4). Do not pick unilaterally; frame the
tradeoff and ask.**
- **NEXT after compile-mode merges: themes (Arc 4) is the standing
runner-up from the decision discussion** — scout fresh before
framing (protocol bump v15→16 for a ThemeFacts channel, the
LineNumbers/Q#UX1 control-plane template, glyphon font reload is
the hard part).
- Auto-indent (#109) landed earlier: RET binds
`edit.newline-and-indent`; plain Enter round-trips on both
frontends; shared search invalidation (Q#AI8), empty-selection

1183
docs/compile-mode-framing.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -287,6 +287,15 @@ pub struct AnsiParser {
/// Current SGR state. Mutated by SGR parameters; emitted as
/// [`AnsiEvent::SetStyle`] when it changes.
current_style: Style,
/// The style the CONSUMER last received via an emitted
/// `SetStyle` event. Outside alternate-screen this always equals
/// `current_style` (every SGR emits immediately); inside, SGR
/// events are suppressed while `current_style` keeps advancing,
/// so the two drift apart — and alternate-screen exit (ordinary
/// or via [`Self::finish`]) must resynchronize the consumer from
/// this field, not from an internal comparison against default
/// (PR #113 round-4 finding 2).
emitted_style: Style,
/// Byte count consumed in the current Ignore state. Reset to
/// zero on every entry to an Ignore state: this is a per-state
/// budget, not a global counter, so each malformed sequence
@ -339,6 +348,7 @@ impl AnsiParser {
Self {
state: State::Ground,
current_style: Style::default(),
emitted_style: Style::default(),
ignore_byte_count: 0,
text_run: String::new(),
utf8_buf: Vec::new(),
@ -352,7 +362,9 @@ impl AnsiParser {
/// Reset the parser to ground state. The running style is *not*
/// reset --- callers that want a clean style should pair this
/// with their own `SetStyle(Style::default())`.
/// with their own `SetStyle(Style::default())`. Neither is
/// `emitted_style`: a mid-stream reset changes nothing about
/// what the consumer has already been shown.
pub fn reset(&mut self) {
self.state = State::Ground;
self.ignore_byte_count = 0;
@ -393,6 +405,55 @@ impl AnsiParser {
events
}
/// Stream-end finalization. The feed-boundary contract keeps an
/// incomplete UTF-8 sequence buffered because its trailing bytes
/// are expected in the next feed — but at process EOF there IS no
/// next feed, so the pending prefix can never complete. Emit
/// U+FFFD for it (the same posture `flush_text_run` takes when a
/// control byte interrupts a sequence) and flush the resulting
/// text run.
///
/// The parser is then fully reset — in-flight CSI/OSC/escape
/// state, alt-screen suppression, AND the running SGR style — so
/// a `feed` after `finish` parses a NEW stream from a clean
/// slate rather than continuing a pre-EOF escape sequence,
/// staying suppressed, or inheriting stale color (PR #113
/// round-2 finding 4; round-3 finding 2). The reset is
/// OBSERVABLE: consumers that mirror parser state from the event
/// stream receive balancing events — an `AlternateScreenExit`
/// for an unclosed enter, a default `SetStyle` whenever the
/// style the consumer LAST RECEIVED was non-default. The
/// comparison is against `emitted_style`, not `current_style`:
/// an SGR reset inside the alternate screen leaves the internal
/// style default while the consumer still shows pre-enter color
/// (round-4 finding 2). Idempotent once drained. First consumer:
/// compile-mode's terminal-event path (Q#CM4).
pub fn finish(&mut self) -> Vec<AnsiEvent> {
let mut events = Vec::new();
self.flush_pending_utf8_as_replacement();
if !self.text_run.is_empty() && !self.alt_screen_active {
let run = std::mem::take(&mut self.text_run);
events.push(AnsiEvent::Text(run));
} else {
self.text_run.clear();
}
// Balancing state events, in unwind order. `reset` alone
// deliberately preserves alt-screen suppression (a
// mid-stream reset must not unhide alt-screen contents); a
// stream END does end it, observably.
if self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
}
if self.emitted_style != Style::default() {
events.push(AnsiEvent::SetStyle(Style::default()));
}
self.current_style = Style::default();
self.emitted_style = Style::default();
self.reset();
events
}
fn feed_byte(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
// ESC-anywhere rule: ECMA-48 §10.2 ("Cancel"). Aborts any
// in-progress sequence and starts a fresh Escape state. The
@ -478,6 +539,7 @@ impl AnsiParser {
if self.alt_screen_active {
return;
}
self.emitted_style = self.current_style;
events.push(AnsiEvent::SetStyle(self.current_style));
}
@ -830,6 +892,15 @@ impl AnsiParser {
} else if !set && self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
// SGR changes inside the alternate
// screen advanced `current_style` while
// their events were suppressed; the
// consumer still holds the pre-enter
// style. Resynchronize the effective
// style on exit (round-4 finding 2).
if self.current_style != self.emitted_style {
self.emit_set_style(events);
}
}
}
}
@ -1760,4 +1831,168 @@ mod tests {
let evs_text = p.feed(b"hi");
assert_eq!(collect_text(&evs_text), "hi");
}
// -----------------------------------------------------------------
// Stream-end finish() (compile-mode Q#CM4; PR #113 rounds 12)
// -----------------------------------------------------------------
#[test]
fn finish_flushes_truncated_utf8_as_replacement() {
let mut p = AnsiParser::new();
// 0xC3 opens a two-byte sequence that never completes.
let evs = p.feed(b"abc\xC3");
assert_eq!(collect_text(&evs), "abc", "prefix buffered across feeds");
let evs = p.finish();
assert_eq!(
collect_text(&evs),
"\u{FFFD}",
"stream end must surface the pending prefix as U+FFFD"
);
// Idempotent once drained.
assert!(p.finish().is_empty(), "second finish drains nothing");
}
#[test]
fn feed_after_finish_starts_a_fresh_stream() {
// Mid-CSI at stream end: without the finish-time reset, a
// subsequent feed would keep consuming bytes as CSI
// parameters instead of parsing a new stream (PR #113
// round-2 finding 4).
let mut p = AnsiParser::new();
let _ = p.feed(b"\x1b[3"); // incomplete CSI
let _ = p.finish();
let evs = p.feed(b"plain");
assert_eq!(
collect_text(&evs),
"plain",
"post-finish feeds must not continue a pre-EOF escape"
);
}
#[test]
fn finish_ends_alt_screen_suppression() {
let mut p = AnsiParser::new();
let _ = p.feed(b"\x1b[?1049hhidden"); // enter alt screen
let _ = p.finish();
let evs = p.feed(b"visible");
assert_eq!(
collect_text(&evs),
"visible",
"a stream END ends suppression; a new stream starts unsuppressed"
);
}
#[test]
fn finish_emits_balancing_events_for_consumer_state() {
// A consumer mirrors parser state from the event stream
// alone (PR #113 round-3 finding 2): apply every event to a
// consumer-side mirror and require finish() to unwind it —
// not merely make subsequent text visible.
let mut p = AnsiParser::new();
let mut consumer_alt = false;
let mut consumer_style = Style::default();
let apply = |evs: &[AnsiEvent], alt: &mut bool, style: &mut Style| {
for ev in evs {
match ev {
AnsiEvent::AlternateScreenEnter => *alt = true,
AnsiEvent::AlternateScreenExit => *alt = false,
AnsiEvent::SetStyle(s) => *style = *s,
_ => {}
}
}
};
let evs = p.feed(b"\x1b[31mred\x1b[?1049h");
apply(&evs, &mut consumer_alt, &mut consumer_style);
assert!(consumer_alt, "enter observed");
assert_ne!(consumer_style, Style::default(), "red observed");
let evs = p.finish();
apply(&evs, &mut consumer_alt, &mut consumer_style);
assert!(
!consumer_alt,
"finish must balance the unclosed AlternateScreenEnter"
);
assert_eq!(
consumer_style,
Style::default(),
"finish must reset the running style observably"
);
}
/// Consumer mirror for the round-4 alt-screen style-desync
/// tests: alt flag + last received style, driven purely by
/// emitted events.
fn mirror(evs: &[AnsiEvent], alt: &mut bool, style: &mut Style) {
for ev in evs {
match ev {
AnsiEvent::AlternateScreenEnter => *alt = true,
AnsiEvent::AlternateScreenExit => *alt = false,
AnsiEvent::SetStyle(s) => *style = *s,
_ => {}
}
}
}
#[test]
fn alt_screen_exit_resyncs_suppressed_style_changes() {
// SGR events are suppressed inside the alternate screen
// while `current_style` keeps advancing; an ordinary exit
// must resynchronize the consumer's effective style (PR #113
// round-4 finding 2). Both drift directions: a reset the
// consumer never saw, and a color it never saw.
let mut p = AnsiParser::new();
let mut alt = false;
let mut style = Style::default();
let evs = p.feed(b"\x1b[31mred\x1b[?1049h\x1b[0m\x1b[?1049l");
mirror(&evs, &mut alt, &mut style);
assert!(!alt, "exit observed");
assert_eq!(
style,
Style::default(),
"the suppressed SGR reset must reach the consumer on exit"
);
let mut p = AnsiParser::new();
let mut alt = false;
let mut style = Style::default();
let evs = p.feed(b"\x1b[?1049h\x1b[31m\x1b[?1049lafter");
mirror(&evs, &mut alt, &mut style);
assert_ne!(
style,
Style::default(),
"a color set inside the alt screen styles post-exit text"
);
// No drift → no spurious resync event.
let mut p = AnsiParser::new();
let evs = p.feed(b"\x1b[?1049h\x1b[?1049l");
assert!(
!evs.iter().any(|e| matches!(e, AnsiEvent::SetStyle(_))),
"style untouched inside alt screen emits no resync"
);
}
#[test]
fn finish_emits_default_style_when_reset_was_suppressed() {
// The round-4 finding-2 finish() scenario: consumer shows
// red from before the alt-screen enter; an SGR reset inside
// makes the INTERNAL style default, so a current_style
// comparison sees nothing to balance — but the consumer is
// still red. finish() must compare against what was last
// EMITTED.
let mut p = AnsiParser::new();
let mut alt = false;
let mut style = Style::default();
let evs = p.feed(b"\x1b[31mred\x1b[?1049h\x1b[0m");
mirror(&evs, &mut alt, &mut style);
assert!(alt, "enter observed");
assert_ne!(style, Style::default(), "consumer is red pre-finish");
let evs = p.finish();
mirror(&evs, &mut alt, &mut style);
assert!(!alt, "finish balances the enter");
assert_eq!(
style,
Style::default(),
"finish must emit the default SetStyle the consumer needs \
even though the internal style is already default"
);
}
}

View File

@ -373,6 +373,19 @@ impl EditorState {
include_str!("../builtin/runtime/indent.lua"),
)
.expect("load indent 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
// duplicate bindings are rejected, so the takeover is
// unbind-then-bind against lsp.lua's diag bindings — they
// must exist first. (Loaded last in the runtime sequence;
// its after-tick pump is ordering-independent.)
lua_host
.eval(
Some("@pmacs/builtin/runtime/compile.lua"),
include_str!("../builtin/runtime/compile.lua"),
)
.expect("load compile builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it
@ -5765,6 +5778,68 @@ mod tests {
);
}
/// PR #113 round-6 finding 1: a same-buffer split copies
/// store-backed render overlays to the new pane (splits fire no
/// switch hook and started from an empty overlay list), and
/// per-window attachment is idempotent via the store identity.
#[test]
fn same_buffer_split_copies_style_overlays_and_attach_is_idempotent() {
use crate::overlay::{BufferStyleOverlay, SharedBufferStyleSpans};
use crate::window::Orientation;
use std::sync::{Arc, Mutex};
let s = fresh_with(b"hello\n");
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(Vec::new()));
{
let mut core = s.core.borrow_mut();
let win = core.active_window_mut();
win.ensure_overlay(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
// Second ensure over the SAME store: no duplicate.
win.ensure_overlay(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
assert_eq!(
win.overlay_kinds()
.iter()
.filter(|k| **k == "buffer_style_overlay")
.count(),
1,
"ensure_overlay must be idempotent per store"
);
}
// Same-buffer split: the new pane carries a copy.
let new_id = s
.core
.borrow_mut()
.split_active(Orientation::Horizontal, true);
{
let core = s.core.borrow();
let win = core.windows.get(&new_id).expect("split window");
assert_eq!(
win.overlay_kinds()
.iter()
.filter(|k| **k == "buffer_style_overlay")
.count(),
1,
"a same-buffer split must copy the render overlay"
);
}
// Fresh-buffer split: no copy (different buffer, different
// styling).
let scratch_id = s
.core
.borrow_mut()
.split_active(Orientation::Horizontal, false);
let core = s.core.borrow();
let win = core.windows.get(&scratch_id).expect("scratch window");
assert_eq!(
win.overlay_kinds()
.iter()
.filter(|k| **k == "buffer_style_overlay")
.count(),
0,
"a fresh-buffer split carries nothing"
);
}
// ---- T M2.12: mouse input ----------------------------------------------
fn mouse(kind: crossterm::event::MouseEventKind, row: u16, col: u16) -> MouseEvent {

View File

@ -2109,9 +2109,21 @@ impl EditorCore {
(new_id, TextView::new(buf))
};
let new_id = WindowId::next();
let new_window = Window::new(new_id, buffer_id, text_view);
self.windows.insert(new_id, new_window);
let mut new_window = Window::new(new_id, buffer_id, text_view);
let active = self.active_window_id();
// A same-buffer split starts from an empty overlay list and
// fires no switch hook, so store-backed render overlays
// (ANSI styling on a compile buffer) would silently vanish
// from the new pane (PR #113 round-6 finding 1). Views that
// carry across splits say so via `clone_for_split`.
if same_buffer && let Some(src) = self.windows.get(&active) {
for overlay in &src.overlays {
if let Some(copy) = overlay.clone_for_split() {
new_window.overlays.push(copy);
}
}
}
self.windows.insert(new_id, new_window);
self.active_layout_mut()
.split_window(active, orientation, new_id);
new_id

View File

@ -1101,6 +1101,16 @@ pub enum BindingError {
after the edit completes"
)]
Reentrant,
/// Style-overlay teardown was requested from a callback that is
/// still running under an editor-core or buffer-registry borrow.
/// Disposal touches both stores, so it must acquire both before
/// changing the shared disposed flag or removing either view.
#[error(
"style overlay disposal cannot run while editor state is borrowed; defer dispose() until \
after the current callback completes"
)]
StyleOverlayDisposeReentrant,
}
// ---------------------------------------------------------------------------
@ -1181,6 +1191,17 @@ fn add_query_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
})
});
// Edit revision: bumped by every edit, undo, and redo. The
// compile-mode external-edit guard (Q#CM2) records this after
// each of its own writes and resyncs on mismatch — byte length
// is not an edit-integrity token (a same-length replace changes
// content while preserving length).
methods.add_method("revision", |lua, this, ()| {
with_registry(lua, |r| {
i64::try_from(resolve(r, this.0)?.revision()).map_err(mlua::Error::external)
})
});
methods.add_method("is_modified", |lua, this, ()| {
with_registry(lua, |r| Ok(resolve(r, this.0)?.is_modified()))
});
@ -1732,9 +1753,30 @@ pub struct InterceptHandleLua {
#[derive(Clone)]
/// Lua handle for a shared buffer-byte style overlay.
///
/// Lifetime contract (PR #113 round-6 finding 3): the buffer-attached
/// translator lives until the buffer dies OR `dispose()` is called.
/// One handle per buffer incarnation (the compile-mode and REPL
/// discipline) needs no disposal — the buffer's death frees it;
/// repeated `add_style_overlay` calls on a LONG-LIVED buffer must
/// `dispose()` retired handles, or every edit keeps paying for every
/// abandoned translator.
pub struct StyleOverlayHandleLua {
/// Shared style spans rendered by every attached overlay view.
spans: crate::overlay::SharedBufferStyleSpans,
/// Buffer the translator was attached to. Attachment is
/// validated against this (round-7 finding 1): a render view on
/// any OTHER buffer would show coordinates translated only by
/// edits to this one.
buffer: BufferId,
/// The buffer-attached translator's view id — retained so
/// `dispose()` can detach it.
translator: crate::buffer::ViewId,
/// Shared across handle clones (`FromLua` clones): set by
/// `dispose()`, checked by attachment — re-attaching a disposed
/// handle would resurrect rendering without its translator
/// (round-7 finding 1).
disposed: Arc<std::sync::atomic::AtomicBool>,
}
impl FromLua for StyleOverlayHandleLua {
@ -1810,6 +1852,55 @@ impl UserData for StyleOverlayHandleLua {
}
Ok(out)
});
// Idempotent teardown (round-6 finding 3): detaches the
// buffer-attached translator (so later edits stop paying for
// it) and removes every window render view over this store.
// Without this, a retired handle's translator lived until
// the buffer died — permanent per-edit cost growth for
// repeated creation on a long-lived buffer. Safe to call
// twice; safe after the buffer is gone.
methods.add_method("dispose", |lua, this, ()| {
// Preflight every borrow before changing shared state.
// A callback may run while the editor core or registry is
// already borrowed; panicking (or removing the window
// views before discovering a registry conflict) would
// leave a partially disposed handle. Returning a pointed
// error keeps the operation retryable after the callback.
let core_handle = lua.app_data_ref::<SharedCore>();
let mut core = match core_handle.as_deref() {
Some(core) => Some(core.try_borrow_mut().map_err(|_| {
mlua::Error::external(BindingError::StyleOverlayDisposeReentrant)
})?),
None => None,
};
let registry_handle = lua
.app_data_ref::<SharedRegistry>()
.ok_or_else(|| mlua::Error::external(BindingError::NoRegistry))?;
let mut registry = registry_handle
.try_borrow_mut()
.map_err(|_| mlua::Error::external(BindingError::StyleOverlayDisposeReentrant))?;
this.disposed
.store(true, std::sync::atomic::Ordering::Relaxed);
let id = crate::overlay::style_store_identity(&this.spans);
// Window cleanup needs the editor core, which is
// optional app data...
if let Some(core) = core.as_mut() {
for win in core.windows.values_mut() {
win.overlays.retain(|v| v.overlay_identity() != Some(id));
}
}
// ...but the translator detach must not go through it:
// an install-only/headless host registers the registry
// WITHOUT a core, and returning success while the
// translator stays attached would leak per-edit work for
// the buffer's lifetime (round-7 finding 2).
if let Ok(buf) = registry.get_mut(this.buffer) {
buf.detach_view(this.translator);
}
Ok(())
});
}
}
@ -2905,13 +2996,32 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
}
{
let reg = registry.clone();
buffer.set(
"add_style_overlay",
lua.create_function(
move |lua, id: BufferIdLua| -> mlua::Result<StyleOverlayHandleLua> {
let spans = Arc::new(Mutex::new(Vec::new()));
// Coordinate translation lives on the BUFFER
// (PR #113 round-5 finding 1): buffer-attached
// views see every edit exactly once — Lua bypass
// writes, undo/redo, remote CRDT ops — whether or
// not any window shows the buffer. The window
// attachments below are render-only; per-window
// translation ran once per split and zero times
// hidden.
let translator = {
let mut r = reg.borrow_mut();
let buf = resolve_mut(&mut r, id.0)?;
buf.attach_view(Box::new(crate::overlay::BufferStyleSpanTranslator::new(
Arc::clone(&spans),
)))
};
let handle = StyleOverlayHandleLua {
spans: Arc::clone(&spans),
buffer: id.0,
translator,
disposed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
attach_style_overlay_to_visible_windows(lua, id.0, &spans);
Ok(handle)
@ -2925,6 +3035,36 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
"attach_style_overlay",
lua.create_function(
move |lua, (id, handle): (BufferIdLua, StyleOverlayHandleLua)| {
// Round-7 finding 1: a disposed handle's
// translator is gone — re-attaching would
// resurrect rendering with frozen coordinates —
// and a handle's translator follows edits to ITS
// buffer only, so attaching to any other buffer
// shows unmaintained spans.
if handle.disposed.load(std::sync::atomic::Ordering::Relaxed) {
return Err(mlua::Error::external(
"this style overlay handle was disposed; create a fresh \
one with pmacs.buffer.add_style_overlay",
));
}
if id.0 != handle.buffer {
return Err(mlua::Error::external(format!(
"this style overlay handle belongs to buffer {:?}; its \
spans are not translated by edits to {:?} create an \
overlay for that buffer with pmacs.buffer.add_style_overlay",
handle.buffer, id.0
)));
}
// The recorded owner may have been removed since
// the handle was created. Buffer IDs are
// generational, so resolving it is the only way
// to distinguish a live owner from a stale handle;
// silently scanning the windows would otherwise
// report a successful no-op.
with_registry(lua, |r| {
resolve(r, id.0)?;
Ok(())
})?;
attach_style_overlay_to_visible_windows(lua, id.0, &handle.spans);
Ok(())
},
@ -2960,7 +3100,12 @@ fn attach_style_overlay_to_visible_windows(
let mut core = core.borrow_mut();
for win in core.windows.values_mut() {
if win.buffer_id == buffer_id {
win.push_overlay(Box::new(crate::overlay::BufferStyleOverlay::new(
// ensure_overlay: idempotent per window via the store's
// identity (round-6 finding 1) — repeated switches into
// the buffer stacked duplicate render views on passive
// panes, each cloning every span and rescanning the
// buffer per frame.
win.ensure_overlay(Box::new(crate::overlay::BufferStyleOverlay::new(
Arc::clone(spans),
)));
}
@ -2973,9 +3118,11 @@ fn attach_style_overlay_to_visible_windows(
/// Lua-facing wrapper around [`crate::ansi::AnsiParser`].
///
/// Constructed via `pmacs.ansi.parser()`; methods `feed(bytes)` and
/// `reset()` mirror the Rust API. `feed` returns an array of event
/// tables --- see [`event_to_lua_table`] for the schema. The wrapper
/// Constructed via `pmacs.ansi.parser()`; methods `feed(bytes)`,
/// `reset()`, and `finish()` mirror the Rust API. `feed` and
/// `finish` return an array of event tables --- see
/// [`event_to_lua_table`] for the schema; `finish` drains stream-end
/// state and resets the parser for a fresh stream. The wrapper
/// is `RefCell`-internal so multiple Lua-side methods can borrow
/// safely; the Lua VM is single-threaded so the borrow can never
/// race.
@ -2998,6 +3145,19 @@ impl UserData for AnsiParserLua {
Ok(())
});
// Stream-end finalization: flushes cross-feed state (an
// incomplete UTF-8 sequence becomes the replacement
// character). Compile-mode calls this once at the process's
// terminal event (Q#CM4; PR #113 round-1 finding 9).
methods.add_method("finish", |lua, this, ()| {
let events = this.0.borrow_mut().finish();
let out = lua.create_table_with_capacity(events.len(), 0)?;
for (i, ev) in events.iter().enumerate() {
out.set(i + 1, event_to_lua_table(lua, ev)?)?;
}
Ok(out)
});
methods.add_meta_method(mlua::MetaMethod::ToString, |_, _this, ()| {
Ok("AnsiParser".to_string())
});
@ -6974,6 +7134,47 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
.ok()
.flatten()
.unwrap_or(false);
// Compile-mode process shape (Q#CM3). Both options are
// pipe-mode-only; the supervisor rejects them at spawn under PTY
// so misconfiguration surfaces as a spawn error, not silence.
// Type errors are HARD errors, not silent coercions: `stdin =
// true` would quietly keep a piped stdin (hang), and a mistyped
// `group` would coerce through Lua truthiness to whichever
// boolean its truthiness happens to be — either way the caller's
// intent is unverifiable, and these fields carry process-hygiene
// guarantees (PR #113 round-1 finding 6; wording corrected in
// round 2 finding 5). RAW reads (round-3 finding 3): a spec
// table is plain data — metatable-provided fields are
// deliberately not honored (the compile.lua rawget posture), and
// a raising __index must not be silently absorbed into "false"
// and quietly disable process-group isolation.
let stdin_raw: Option<String> = table.raw_get("stdin").map_err(|_| {
mlua::Error::external("stdin must be the string \"piped\" or \"null\"".to_owned())
})?;
let stdin = match stdin_raw.as_deref() {
None | Some("piped") => crate::process::StdinMode::Piped,
Some("null") => crate::process::StdinMode::Null,
Some(other) => {
return Err(mlua::Error::external(format!(
"stdin must be \"piped\" or \"null\"; got {other:?}"
)));
}
};
// Read as a raw Value: mlua's `bool` conversion applies Lua
// truthiness, so `group = "true"` would silently coerce instead
// of erroring. A raw Value read cannot raise; any residual error
// is still a hard error, never a silent default.
let group = match table.raw_get::<mlua::Value>("group") {
Ok(mlua::Value::Nil) => false,
Ok(mlua::Value::Boolean(b)) => b,
Ok(other) => {
return Err(mlua::Error::external(format!(
"group must be a boolean; got {}",
other.type_name()
)));
}
Err(e) => return Err(e),
};
Ok(ProcessSpec {
label,
command,
@ -6983,6 +7184,8 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
mode,
restart,
ansi_events,
stdin,
group,
})
}
@ -10796,7 +10999,25 @@ fn install_motion(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<
let cc = core.clone();
editor.set(
"jump_back",
lua.create_function(move |_, ()| Ok(cc.borrow_mut().jump_back()))?,
lua.create_function(move |lua, ()| {
let (jumped, buffer_changed) = {
let mut core = cc.borrow_mut();
let before = core.active_buffer_id();
let jumped = core.jump_back();
(jumped, core.active_buffer_id() != before)
};
// Parity with `pmacs.window.switch_buffer` (compile-mode
// additions #3): a jump that lands in another buffer
// clears the destination window's overlays exactly like
// any other switch, so overlay subscribers need the same
// re-attach signal. Without this, RET → M-, permanently
// stripped a generated buffer's styling. Same-buffer
// jumps stay hook-silent.
if buffer_changed {
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
}
Ok(jumped)
})?,
)?;
}
Ok(())
@ -12584,6 +12805,37 @@ mod tests {
assert!(called);
}
#[test]
fn buffer_revision_bumps_on_edit_undo_and_redo() {
// Compile-mode's external-edit guard (Q#CM2) leans on all
// three bump sources: a same-length replace changes content
// without changing length, and undo/redo are exactly the
// mutations the guard exists to catch.
let (lua, _reg, _cmds, _kms, _hks) = fresh();
let ok: bool = lua
.load(
r#"
local b = pmacs.buffer.from_bytes("rev", "abcd")
local r0 = b:revision()
b:insert(4, "e")
local r1 = b:revision()
b:replace(0, 1, "X") -- same-length replace still bumps
local r2 = b:revision()
assert(b:undo(), "undo applies")
local r3 = b:revision()
assert(b:redo(), "redo applies")
local r4 = b:revision()
return r1 > r0 and r2 > r1 and r3 > r2 and r4 > r3
"#,
)
.eval()
.unwrap();
assert!(
ok,
"revision must be strictly monotonic across edit/undo/redo"
);
}
#[test]
fn buffer_remove_prunes_buffer_local_keymaps() {
let (lua, _reg, _cmds, kms, _hks) = fresh();
@ -14671,4 +14923,42 @@ mod tests {
id.uptime_secs
);
}
#[test]
fn style_overlay_dispose_detaches_translator_without_a_core() {
// PR #113 round-7 finding 2: `fresh()` is the install-only /
// headless host shape — the registry is registered as app
// data, SharedCore is NOT. dispose() must detach the
// buffer-attached translator through the registry alone;
// pre-fix it returned success having done nothing, leaving
// the translator attached (and paying per edit) for the
// buffer's lifetime.
let (lua, reg, _cmds, _kms, _hks) = fresh();
lua.load(r#"_G.hbuf = pmacs.buffer.create("headless")"#)
.exec()
.unwrap();
let id = reg
.borrow()
.find_by_name("headless")
.expect("buffer exists");
let baseline = reg.borrow().get(id).unwrap().view_count();
lua.load(r"_G.hov = pmacs.buffer.add_style_overlay(_G.hbuf)")
.exec()
.unwrap();
assert_eq!(
reg.borrow().get(id).unwrap().view_count(),
baseline + 1,
"add_style_overlay attaches the translator"
);
lua.load("_G.hov:dispose()").exec().unwrap();
assert_eq!(
reg.borrow().get(id).unwrap().view_count(),
baseline,
"dispose must detach the translator with no core registered"
);
// Idempotent: a second dispose neither errors nor
// over-detaches.
lua.load("_G.hov:dispose()").exec().unwrap();
assert_eq!(reg.borrow().get(id).unwrap().view_count(), baseline);
}
}

View File

@ -180,12 +180,30 @@ pub struct BufferStyleSpan {
/// Shared span store used by Lua handles and render overlays.
pub type SharedBufferStyleSpans = Arc<Mutex<Vec<BufferStyleSpan>>>;
/// Identity of a span store: the allocation address, stable for the
/// `Arc`'s lifetime. Every overlay/translator over the same store
/// reports this via [`View::overlay_identity`], which is what makes
/// per-window attachment idempotent and disposal able to find every
/// window copy (PR #113 round-6 findings 1 and 3).
#[must_use]
pub fn style_store_identity(spans: &SharedBufferStyleSpans) -> usize {
Arc::as_ptr(spans) as usize
}
/// View that renders buffer-byte style annotations.
///
/// Unlike [`StyleSpanOverlay`], this overlay stores byte ranges rather
/// than viewport cell ranges. That is the right shape for stream
/// consumers such as the REPL: ANSI SGR applies to bytes as they land
/// in the rope, and render maps the surviving ranges into visible cells.
///
/// RENDER-ONLY (PR #113 round-5 finding 1): this view deliberately
/// does not implement `on_edit`. Every window showing the buffer
/// holds its own copy over the SAME shared store, so a per-view
/// translation runs once per attached window — twice under a split,
/// zero times while the buffer is hidden. Coordinate translation
/// belongs to [`BufferStyleSpanTranslator`], attached to the buffer
/// itself.
#[derive(Clone, Debug)]
pub struct BufferStyleOverlay {
spans: SharedBufferStyleSpans,
@ -199,30 +217,102 @@ impl BufferStyleOverlay {
}
}
impl View for BufferStyleOverlay {
/// Buffer-attached edit translator for a shared span store.
///
/// Keeps the byte coordinates in a [`SharedBufferStyleSpans`] store
/// in sync with buffer edits, EXACTLY ONCE per edit, independent of
/// how many windows currently render the buffer (PR #113 round-5
/// finding 1). Buffer-attached views receive `on_edit` on every
/// mutation path — intercept-skipping Lua writes, undo/redo, and
/// remote CRDT ops — whether or not the buffer is displayed
/// anywhere; window-attached [`BufferStyleOverlay`] copies are
/// render-only.
///
/// Translation preserves the untouched fragments of a span that
/// partially overlaps the edit (round-5 finding 2): bytes before the
/// replaced range keep their styling, bytes at/after it keep theirs
/// shifted by the edit's length delta, and only the bytes actually
/// replaced lose styling — the writer styles what it writes.
pub struct BufferStyleSpanTranslator {
spans: SharedBufferStyleSpans,
}
impl BufferStyleSpanTranslator {
/// Construct a translator over `spans`.
#[must_use]
pub fn new(spans: SharedBufferStyleSpans) -> Self {
Self { spans }
}
}
impl View for BufferStyleSpanTranslator {
fn on_edit(&mut self, _buf: &Buffer, edit: &Edit) -> Result<(), crate::buffer::BufferError> {
let old_start = edit.range.start;
let old_end = edit.range.end;
let old_len = old_end - old_start;
let new_len = edit.inserted_len;
// Buffers deliberately broadcast no-op edits (empty insert /
// empty-range delete — buffer.rs's "callers that count the
// call" contract). Nothing moved, so there is nothing to
// translate; falling through would split any span containing
// the position into two adjacent fragments per call —
// unbounded growth for repeated no-ops, and a fragment
// boundary mid-codepoint for a no-op at a continuation byte
// (round-6 finding 2).
if old_len == 0 && new_len == 0 {
return Ok(());
}
let mut spans = self.spans.lock().expect("style spans mutex poisoned");
let mut adjusted = Vec::with_capacity(spans.len());
for mut span in spans.drain(..) {
if span.end <= old_start {
adjusted.push(span);
} else if span.start >= old_end {
span.start = shift_pos(span.start, old_end, old_len, new_len);
span.end = shift_pos(span.end, old_end, old_len, new_len);
adjusted.push(span);
for span in spans.drain(..) {
// Left fragment: bytes strictly before the replaced
// range are untouched by the edit.
if span.start < old_start {
adjusted.push(BufferStyleSpan {
start: span.start,
end: span.end.min(old_start),
style: span.style,
});
}
// Overlapping spans are dropped. REPL style spans are append-only
// and scrollback truncation deletes whole old blocks, so a
// conservative drop is simpler and avoids half-styled fragments.
// Right fragment: bytes at/after the replaced range
// survive, shifted by the length delta. (`pos >= old_end
// >= old_len`, so the subtraction cannot underflow.)
if span.end > old_end {
adjusted.push(BufferStyleSpan {
start: span.start.max(old_end) - old_len + new_len,
end: span.end - old_len + new_len,
style: span.style,
});
}
// A span entirely inside the replaced range produces
// neither fragment and is dropped.
}
*spans = adjusted;
Ok(())
}
fn kind(&self) -> &'static str {
"buffer_style_span_translator"
}
fn overlay_identity(&self) -> Option<usize> {
Some(style_store_identity(&self.spans))
}
}
impl View for BufferStyleOverlay {
fn kind(&self) -> &'static str {
"buffer_style_overlay"
}
fn overlay_identity(&self) -> Option<usize> {
Some(style_store_identity(&self.spans))
}
fn clone_for_split(&self) -> Option<Box<dyn View>> {
Some(Box::new(self.clone()))
}
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
let spans = self
.spans
@ -243,15 +333,6 @@ impl View for BufferStyleOverlay {
}
}
fn shift_pos(pos: u64, old_end: u64, old_len: u64, new_len: u64) -> u64 {
if new_len >= old_len {
pos + (new_len - old_len)
} else {
pos.saturating_sub(old_end)
.saturating_add(old_end - (old_len - new_len))
}
}
fn compute_line_offsets(buf: &Buffer) -> Vec<u64> {
let mut offsets = vec![0];
let rope = buf.snapshot_rope();
@ -681,4 +762,188 @@ mod tests {
});
virt.render(&buf, viewport(1, 5), &mut grid);
}
fn red() -> Style {
Style {
fg: crate::cell::Color::Indexed(1),
..Default::default()
}
}
fn spans_of(store: &SharedBufferStyleSpans) -> Vec<(u64, u64)> {
store
.lock()
.unwrap()
.iter()
.map(|s| (s.start, s.end))
.collect()
}
#[test]
fn translator_shifts_spans_exactly_once_regardless_of_render_views() {
// PR #113 round-5 finding 1: N windows over the same store
// must not translate N times, and zero windows must not mean
// zero translations. The render-only overlays contribute
// nothing to on_edit; the single buffer-attached translator
// does it all.
use crate::buffer::EditOp;
use crate::rope::Range;
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abc");
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
start: 1,
end: 3,
style: red(),
}]));
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
// Two render copies attached to the same buffer — the
// split-window shape. Their on_edit is the default no-op.
buf.attach_view(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
buf.attach_view(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
// Replace byte 0 with two bytes: delta +1, span after the
// edit shifts by exactly one.
buf.apply_edit(EditOp::Replace {
range: Range::new(0, 1),
bytes: b"XY",
})
.expect("edit applies");
assert_eq!(
spans_of(&store),
vec![(2, 4)],
"one translator, one shift — attachment count is irrelevant"
);
}
#[test]
fn translator_preserves_untouched_span_fragments() {
// PR #113 round-5 finding 2: a partial overwrite must keep
// styling on the bytes it never wrote. Replacing byte 0 of a
// red [0,3) span (same length) leaves [1,3) red; the written
// byte's styling is the writer's business.
use crate::buffer::EditOp;
use crate::rope::Range;
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abc");
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
start: 0,
end: 3,
style: red(),
}]));
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
buf.apply_edit(EditOp::Replace {
range: Range::new(0, 1),
bytes: b"X",
})
.expect("edit applies");
assert_eq!(
spans_of(&store),
vec![(1, 3)],
"the untouched right fragment survives a same-length rewrite"
);
}
#[test]
fn translator_splits_a_span_around_an_interior_edit() {
// Both fragments survive an interior replacement; the
// replaced middle loses styling. Also pins the insertion
// case: bytes inserted INSIDE a span do not inherit style.
use crate::buffer::EditOp;
use crate::rope::Range;
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abcdef");
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
start: 0,
end: 6,
style: red(),
}]));
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
// Replace "cd" with "Z": left [0,2) intact, right [4,6)
// shifts to [3,5).
buf.apply_edit(EditOp::Replace {
range: Range::new(2, 4),
bytes: b"Z",
})
.expect("edit applies");
assert_eq!(
spans_of(&store),
vec![(0, 2), (3, 5)],
"left kept, right shifted by the length delta"
);
// A span wholly inside a replaced range is dropped.
{
let mut spans = store.lock().unwrap();
spans.clear();
spans.push(BufferStyleSpan {
start: 1,
end: 2,
style: red(),
});
}
buf.apply_edit(EditOp::Replace {
range: Range::new(0, 4),
bytes: b"....",
})
.expect("edit applies");
assert_eq!(
spans_of(&store),
Vec::<(u64, u64)>::new(),
"a fully-overwritten span produces no fragments"
);
}
#[test]
fn translator_splits_a_span_around_a_genuine_insertion() {
// A real EditOp::Insert (not a replacement) inside a span:
// the left fragment stays, the right fragment shifts by the
// inserted length, and the inserted bytes inherit nothing.
use crate::buffer::EditOp;
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abcdef");
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
start: 0,
end: 6,
style: red(),
}]));
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
buf.apply_edit(EditOp::Insert {
pos: 3,
bytes: b"XY",
})
.expect("insert applies");
assert_eq!(
spans_of(&store),
vec![(0, 3), (5, 8)],
"insertion splits the span; inserted bytes are unstyled"
);
}
#[test]
fn translator_ignores_pure_noop_edits() {
// Buffers deliberately broadcast no-op edits (empty insert /
// empty-range delete). PR #113 round-6 finding 2: falling
// through split a containing span into two adjacent
// fragments per call — unbounded growth for repeated no-ops
// at distinct positions, and a fragment boundary
// mid-codepoint for a no-op at a UTF-8 continuation byte.
use crate::buffer::EditOp;
use crate::rope::Range;
let mut buf = Buffer::from_bytes(BufferId::next(), "t", "ab\u{e9}def".as_bytes());
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
start: 0,
end: 7,
style: red(),
}]));
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
// Distinct interior positions, including 3 — the é's
// continuation byte.
for pos in [1, 2, 3, 4, 5] {
buf.apply_edit(EditOp::Insert { pos, bytes: b"" })
.expect("no-op insert applies");
buf.apply_edit(EditOp::Delete {
range: Range::new(pos, pos),
})
.expect("no-op delete applies");
}
assert_eq!(
spans_of(&store),
vec![(0, 7)],
"no-op edits must not fragment or move spans"
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -236,6 +236,27 @@ pub trait View {
fn kind(&self) -> &'static str {
"unknown"
}
/// Identity of the shared resource this overlay renders, if any
/// (PR #113 round-6 findings 1 and 3). Two overlay instances
/// backed by the same store report the same value, which lets a
/// window attach a resource-backed overlay AT MOST once
/// ([`crate::window::Window::ensure_overlay`]) and lets disposal
/// remove every window copy. The default `None` opts out: such
/// views are never deduplicated or bulk-removed.
fn overlay_identity(&self) -> Option<usize> {
None
}
/// A copy of this overlay for a freshly split window showing the
/// same buffer (round-6 finding 1: a same-buffer split starts
/// with an empty overlay list and fires no switch hook, so
/// without this the new pane rendered unstyled). `None` (the
/// default) means the view does not carry across splits;
/// store-backed render overlays return a clone.
fn clone_for_split(&self) -> Option<Box<dyn View>> {
None
}
}
// ---------------------------------------------------------------------------

View File

@ -232,6 +232,25 @@ impl Window {
self.overlays.push(view);
}
/// Push `view` unless an overlay with the same
/// [`View::overlay_identity`] is already attached — attachment
/// of store-backed overlays must be idempotent per window
/// (PR #113 round-6 finding 1: repeated switches into a buffer
/// stacked duplicate render views on passive panes, each cloning
/// every span and rescanning the buffer per frame). Views
/// without an identity always push.
pub fn ensure_overlay(&mut self, view: Box<dyn View>) {
if let Some(id) = view.overlay_identity()
&& self
.overlays
.iter()
.any(|v| v.overlay_identity() == Some(id))
{
return;
}
self.overlays.push(view);
}
/// Stable kind identifiers of every overlay on this window, in
/// push order. Test seam used by `pmacs.window._overlay_kinds()`
/// to verify that a specific overlay type actually attached

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,356 @@
// compile_mode_crdt_acceptance.rs --- compile-mode over the wire.
//! Compile-mode two-replica acceptance (docs/compile-mode-framing.md,
//! item 35): a full compile run's generated buffer converges
//! byte-identically on a mirror replica, and a synthetic accepted
//! replica edit to that buffer triggers the immediate recovery
//! marker (the `buffer.after-edit` path fires for accepted `CrdtOp`s)
//! and still converges on both replicas — even though the
//! hook-produced marker may queue before the source edit's
//! rebroadcast (the established causal-reordering seam).
//!
//! All compile-buffer writes are daemon-side Lua bypass edits —
//! ordinary daemon-peer CRDT ops with no optimistic involvement —
//! so convergence here pins the whole streaming pipeline (header,
//! parsed output, exit marker) as replicable state.
#![cfg(feature = "crdt")]
use std::time::Duration;
use pmacs::crdt::CrdtState;
use pmacs::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers};
use pmacs::rope::CrdtOp as RopeCrdtOp;
use pmacs::transport::write_message;
mod common;
use common::daemon::{TestDaemon, attach_multi};
fn read_initial_snapshot(
stream: &mut std::os::unix::net::UnixStream,
) -> (pmacs::buffer::BufferId, Vec<u8>) {
match pmacs::transport::read_message::<pmacs::protocol::InstanceMessage>(stream)
.expect("read initial BufferSnapshot")
{
pmacs::protocol::InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
} => (buffer_id, crdt_snapshot),
other => panic!("expected initial BufferSnapshot, got {other:?}"),
}
}
struct Replica {
stream: std::os::unix::net::UnixStream,
state: CrdtState,
fid: FrontendId,
buffer_id: pmacs::buffer::BufferId,
}
fn attach_replica(daemon: &TestDaemon) -> Replica {
let (hello, mut stream) = attach_multi(daemon);
let fid = hello.assigned_frontend_id;
let (buffer_id, snap) = read_initial_snapshot(&mut stream);
let state = CrdtState::new(fid.0).expect("CrdtState::new");
state.import_snapshot(&snap).expect("import_snapshot");
Replica {
stream,
state,
fid,
buffer_id,
}
}
fn send_key(replica: &mut Replica, key: Key, mods: Modifiers) {
write_message(
&mut replica.stream,
&FrontendEvent::Key(KeyEvent {
frontend_id: replica.fid,
key,
mods,
timestamp_ns: 0,
}),
)
.expect("send Key");
}
/// Mutate the local replica, export the delta, and ship it as an
/// optimistic `FrontendEvent::CrdtOp` (the `m10_11` idiom).
fn send_optimistic_op<F>(replica: &mut Replica, mutate: F)
where
F: FnOnce(&CrdtState),
{
let v = replica.state.version();
mutate(&replica.state);
let op_bytes = replica
.state
.export_updates_since(&v)
.expect("export updates after local mutation");
write_message(
&mut replica.stream,
&FrontendEvent::CrdtOp {
frontend_id: replica.fid,
buffer_id: replica.buffer_id,
op: RopeCrdtOp {
peer_id: replica.fid.0,
bytes: op_bytes,
},
},
)
.expect("write CrdtOp");
}
/// Read until a `BufferSnapshot` for a buffer other than the current
/// one arrives (the compile run creates *compilation* mid-session;
/// the daemon broadcasts a snapshot for the newly-CRDT-backed buffer
/// and via the active-buffer-follow path). Re-seats the replica's
/// mirror on that buffer.
fn adopt_next_buffer(replica: &mut Replica, what: &str) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
assert!(
std::time::Instant::now() < deadline,
"timeout adopting the new buffer snapshot for {what}"
);
replica
.stream
.set_read_timeout(Some(Duration::from_millis(100)))
.ok();
match pmacs::transport::read_message::<pmacs::protocol::InstanceMessage>(
&mut replica.stream,
) {
Ok(pmacs::protocol::InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
}) if buffer_id != replica.buffer_id => {
let state = CrdtState::new(replica.fid.0).expect("CrdtState::new");
state
.import_snapshot(&crdt_snapshot)
.expect("import new-buffer snapshot");
replica.state = state;
replica.buffer_id = buffer_id;
return;
}
Ok(_) | Err(_) => {}
}
}
}
/// Pump broadcast messages, importing every `CrdtOp` for the tracked
/// buffer, until `pred(text)` holds.
fn pump_until_text<P: Fn(&str) -> bool>(
replica: &mut Replica,
timeout: Duration,
what: &str,
pred: P,
) -> String {
let deadline = std::time::Instant::now() + timeout;
let mut text = replica.state.materialize_string();
loop {
if pred(&text) {
return text;
}
assert!(
std::time::Instant::now() < deadline,
"pump timeout waiting for {what}; text={text:?}"
);
replica
.stream
.set_read_timeout(Some(Duration::from_millis(100)))
.ok();
match pmacs::transport::read_message::<pmacs::protocol::InstanceMessage>(
&mut replica.stream,
) {
Ok(pmacs::protocol::InstanceMessage::CrdtOp { buffer_id: b, op })
if b == replica.buffer_id =>
{
let _ = replica.state.import_updates(&op.bytes);
text = replica.state.materialize_string();
}
Ok(_) | Err(_) => {}
}
}
}
const DESYNC: &str = "[output desynced by external edit]";
#[test]
fn compile_run_converges_and_replica_edit_triggers_recovery() {
// Fixture: the compile command lives in a shared tempdir; the
// init.lua binds a chord that runs it (typing an M-x prompt over
// the wire would test the minibuffer, not compile-mode).
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("fix.sh");
std::fs::write(&script, "printf 'x.c:1:1: error: boom\\ndone\\n'\n").unwrap();
let init = format!(
r#"
pmacs.command.define {{
name = "test.compile",
description = "compile-mode CRDT fixture trigger",
fn = function()
pmacs.compile.run("sh {script}", {{ cwd = "{dir}" }})
end,
}}
pmacs.keymap.bind {{ scope = "global", sequence = "C-c 9", command = "test.compile" }}
"#,
script = script.display(),
dir = dir.path().display(),
);
let daemon = TestDaemon::spawn_with_config(&init);
let mut source = attach_replica(&daemon);
let mut observer = attach_replica(&daemon);
let initial = source.buffer_id;
// Trigger the run from the source replica (round-tripped keys).
send_key(&mut source, Key::Char('c'), Modifiers::CTRL);
send_key(&mut source, Key::Char('9'), Modifiers::NONE);
// Both replicas adopt the freshly-created *compilation* buffer.
adopt_next_buffer(&mut source, "source");
adopt_next_buffer(&mut observer, "observer");
assert_ne!(source.buffer_id, initial, "a new buffer was created");
assert_eq!(
source.buffer_id, observer.buffer_id,
"both replicas mirror the same generated buffer"
);
// The full run — header, streamed output, exit marker — reaches
// both mirrors byte-identically.
let done = |t: &str| t.contains("[compile exited with code 0]");
let src_text = pump_until_text(&mut source, Duration::from_secs(15), "source run", done);
let obs_text = pump_until_text(&mut observer, Duration::from_secs(15), "observer run", done);
assert_eq!(src_text, obs_text, "byte-identical convergence");
assert!(
src_text.contains("x.c:1:1: error: boom"),
"output replicated"
);
assert!(src_text.starts_with("$ sh "), "header replicated");
// Synthetic accepted replica edit to the generated buffer: the
// daemon applies it, buffer.after-edit fires, and compile.lua's
// revision guard appends the recovery marker immediately. The
// marker (a daemon-peer op) may broadcast before the source
// edit's own rebroadcast — the causal-reordering seam — and both
// replicas must still converge.
send_optimistic_op(&mut source, |r| {
r.insert(0, "Z").expect("replica edit");
});
let recovered = |t: &str| t.contains(DESYNC) && t.starts_with('Z');
let src_text = pump_until_text(
&mut source,
Duration::from_secs(10),
"source recovery marker",
recovered,
);
let obs_text = pump_until_text(
&mut observer,
Duration::from_secs(10),
"observer recovery marker",
recovered,
);
assert_eq!(
src_text, obs_text,
"post-recovery convergence across the reorder seam"
);
}
#[test]
fn r3f1_unicode_cr_backspace_survive_crdt_replication() {
// PR #113 round-3 finding 1, CRDT twin: pre-fix the byte-counted
// overwrite split a 2-byte é mid-codepoint; the byte-native
// UTF-8 CRDT edit REJECTS that range, the pump callback aborts
// after events_take, the run never reaches its exit marker, and
// the process record leaks. Post-fix the whole-codepoint atomic
// replace applies cleanly and both replicas converge.
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("uni.sh");
std::fs::write(
&script,
"printf '\\303\\251\\rX\\n'\nprintf 'X\\r\\303\\251\\n'\nprintf '\\303\\251\\bX\\n'\n",
)
.unwrap();
let init = format!(
r#"
pmacs.command.define {{
name = "test.compile-unicode",
description = "round-3 unicode fixture trigger",
fn = function()
pmacs.compile.run("sh {script}", {{ cwd = "{dir}" }})
end,
}}
pmacs.keymap.bind {{ scope = "global", sequence = "C-c 8", command = "test.compile-unicode" }}
"#,
script = script.display(),
dir = dir.path().display(),
);
let daemon = TestDaemon::spawn_with_config(&init);
let mut source = attach_replica(&daemon);
let mut observer = attach_replica(&daemon);
send_key(&mut source, Key::Char('c'), Modifiers::CTRL);
send_key(&mut source, Key::Char('8'), Modifiers::NONE);
adopt_next_buffer(&mut source, "source");
adopt_next_buffer(&mut observer, "observer");
let done = |t: &str| t.contains("[compile exited with code 0]");
let src_text = pump_until_text(&mut source, Duration::from_secs(15), "source run", done);
let obs_text = pump_until_text(&mut observer, Duration::from_secs(15), "observer run", done);
assert_eq!(src_text, obs_text, "byte-identical convergence");
assert!(
src_text.contains("\nX\n\u{e9}\nX\n"),
"whole-codepoint overwrites replicate as valid UTF-8; got:\n{src_text:?}"
);
}
#[test]
fn r4f1_column_rewrites_replicate_and_converge() {
// PR #113 round-4 finding 1, CRDT twin: the column-based
// renderer makes SEVERAL byte-native edits per text event
// (segment overwrites plus appended newlines instead of one
// atomic replace); every edit must land on codepoint boundaries
// and the full run — including the shorter rewrite whose stale
// remainder survives in place and the multibyte-over-ASCII
// overwrite — must converge byte-identically. Pre-fix the
// byte-counted overwrite replicates the corrupted structure
// (ghost line, eaten column) to both replicas.
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("cols.sh");
std::fs::write(
&script,
"printf 'abcdef\\rX\\n'\nprintf 'abc\\r\\303\\251\\n'\n",
)
.unwrap();
let init = format!(
r#"
pmacs.command.define {{
name = "test.compile-columns",
description = "round-4 column-rewrite fixture trigger",
fn = function()
pmacs.compile.run("sh {script}", {{ cwd = "{dir}" }})
end,
}}
pmacs.keymap.bind {{ scope = "global", sequence = "C-c 7", command = "test.compile-columns" }}
"#,
script = script.display(),
dir = dir.path().display(),
);
let daemon = TestDaemon::spawn_with_config(&init);
let mut source = attach_replica(&daemon);
let mut observer = attach_replica(&daemon);
send_key(&mut source, Key::Char('c'), Modifiers::CTRL);
send_key(&mut source, Key::Char('7'), Modifiers::NONE);
adopt_next_buffer(&mut source, "source");
adopt_next_buffer(&mut observer, "observer");
let done = |t: &str| t.contains("[compile exited with code 0]");
let src_text = pump_until_text(&mut source, Duration::from_secs(15), "source run", done);
let obs_text = pump_until_text(&mut observer, Duration::from_secs(15), "observer run", done);
assert_eq!(src_text, obs_text, "byte-identical convergence");
assert!(
src_text.contains("\nXbcdef\n\u{e9}bc\n"),
"column-based rewrites replicate intact; got:\n{src_text:?}"
);
}

View File

@ -1892,13 +1892,18 @@ fn m4_6_diag_navigate_commands_and_bindings_are_registered() {
)
.eval()
.expect("keymap.list");
// Compile-mode (Q#CM5, docs/compile-mode-framing.md) took the
// M-g chords over for the unified dispatchers; the diag commands
// stay registered and remain the dispatchers' fallback when no
// compile/grep run has claimed the error source, so the
// no-LSP-attachment behavior asserted below is unchanged.
assert!(
bindings.iter().any(|b| b == "M-g n=>diag.next"),
"M-g n must bind to diag.next; got: {bindings:?}"
bindings.iter().any(|b| b == "M-g n=>error.next"),
"M-g n must bind to error.next; got: {bindings:?}"
);
assert!(
bindings.iter().any(|b| b == "M-g p=>diag.previous"),
"M-g p must bind to diag.previous; got: {bindings:?}"
bindings.iter().any(|b| b == "M-g p=>error.previous"),
"M-g p must bind to error.previous; got: {bindings:?}"
);
// Without an LSP attachment, the command should surface a status