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:
commit
98323df140
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
237
src/ansi.rs
237
src/ansi.rs
|
|
@ -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 1–2)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
305
src/overlay.rs
305
src/overlay.rs
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
948
src/process.rs
948
src/process.rs
File diff suppressed because it is too large
Load Diff
21
src/view.rs
21
src/view.rs
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue