diff --git a/Cargo.toml b/Cargo.toml index 51b8b56..6a1b21e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"` diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index deaf6ef..7b761b9 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -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() diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua new file mode 100644 index 0000000..e364de2 --- /dev/null +++ b/builtin/runtime/compile.lua @@ -0,0 +1,1136 @@ +-- compile.lua --- compile-mode / shell-command + unified next-error +-- (Arc 5 stage 1). Framing: docs/compile-mode-framing.md. +-- +-- ORDERING CONTRACT: this chunk must load AFTER lsp.lua. It takes +-- over `M-g n` / `M-g p` for the unified error dispatchers, and +-- duplicate bindings are rejected — the takeover is unbind-then- +-- bind, which requires lsp.lua's diag bindings to exist first +-- (Q#CM1/Q#CM5). Its `process.after-tick` subscription is +-- ordering-independent: it pumps only its own proc-id-keyed +-- registry, disjoint from the REPL package's. +-- +-- Shape (Q#CM2/Q#CM3): a generated buffer per "slot" (*compilation*, +-- *shell-command*) streams one merged output pipe (`/bin/sh -c +-- "exec 2>&1; "`, TERM=dumb, stdin="null", group=true) +-- through a Lua-side ANSI parser — text appends at a tracked output +-- position, SGR becomes style-overlay spans, CR/BS/erase collapse +-- progress bars. Lines are parsed for error locations exactly once, +-- when their newline lands (Q#CM4). The buffer is read-only via an +-- erroring intercept; module writes bypass it. External edits are +-- survived by a buffer-revision guard with a desync marker and +-- anchor epochs (Q#CM2). + +pmacs.compile = pmacs.compile or {} +pmacs.shell = pmacs.shell or {} +pmacs.errors = pmacs.errors or {} + +local COMPILATION = "*compilation*" +local SHELL_OUT = "*shell-command*" +local SEARCH_RESULTS = "*search-results*" + +local DESYNC_MARKER = "\n[output desynced by external edit]\n" + +-- --------------------------------------------------------------------- +-- Unified next-error dispatcher (Q#CM5) +-- --------------------------------------------------------------------- + +-- Last claim wins (a deliberate simplification of Emacs's +-- next-error-last-buffer). A compile run claims on spawn; the grep +-- upgrade in commands/default.lua claims on search start. With no +-- claim, the dispatchers fall through to the diagnostic commands — +-- a user who never compiles or greps sees exactly the pre-compile +-- behavior (including diag's wrap). +local claimed_source = nil + +function pmacs.errors.claim(source) + claimed_source = source +end + +pmacs.command.define { + name = "error.next", + description = "Jump to the next error (compile/grep when claimed; diagnostics otherwise).", + fn = function() + if claimed_source then + claimed_source.next() + else + pmacs.command.invoke("diag.next") + end + end, +} + +pmacs.command.define { + name = "error.previous", + description = "Jump to the previous error (compile/grep when claimed; diagnostics otherwise).", + fn = function() + if claimed_source then + claimed_source.previous() + else + pmacs.command.invoke("diag.previous") + end + end, +} + +-- --------------------------------------------------------------------- +-- Error rules (Q#CM4) +-- --------------------------------------------------------------------- + +-- Ordered; first match per line wins. Captures follow compiler +-- convention: 1-based line/column (values below 1 fail closed). +-- `severity` is an override; nil falls back to keyword sniffing on +-- the matched line. User-extensible from init.lua. +pmacs.compile.rules = { + -- rustc/cargo arrow lines: " --> src/foo.rs:12:4". `[^:]+` (the + -- framing's spelling), NOT `[^:%s]+` — paths may contain spaces + -- (PR #113 round-1 finding 3). + { pattern = "%-%->%s+([^:]+):(%d+):(%d+)", file = 1, line = 2, col = 3 }, + -- gcc/clang/grep-format: "file:line:col:" (also matches most Unix tools) + { pattern = "([^%s:][^:]*):(%d+):(%d+):", file = 1, line = 2, col = 3 }, + -- Python tracebacks: 'File "foo.py", line 12' + { pattern = 'File "([^"]+)", line (%d+)', file = 1, line = 2 }, + -- generic two-part: "file:line:" + { pattern = "([^%s:][^:]*):(%d+):", file = 1, line = 2 }, +} + +-- A capture index must be a positive, FINITE integer. Fractional +-- indexes read a distinct (absent) table key, not a capture; +-- math.floor(math.huge) == math.huge, so integrality alone does not +-- imply finiteness (round-1 finding 4; round-2 finding 2). +local function is_capture_index(v) + return type(v) == "number" and v >= 1 and v < math.huge and v == math.floor(v) +end + +-- Validate one rule via RAW reads (rawget): a metatable-backed entry +-- whose __index raises must be a skipped malformed entry, not an +-- error thrown through the per-frame pump mid-batch (round-2 +-- finding 1). Fail-closed posture: metatable-provided fields are +-- deliberately not honored. Returns a plain-table copy of the +-- validated scalar fields, or nil — the copy is the run's snapshot, +-- immune to post-validation mutation of the user's rule object. +local function validated_rule_copy(rule) + if type(rule) ~= "table" then return nil end + local pattern = rawget(rule, "pattern") + if type(pattern) ~= "string" then return nil end + -- Probe the pattern against the empty string so a malformed Lua + -- pattern is caught (and counted in the status note) here at + -- validation time, not silently at match time. + if not pcall(string.match, "", pattern) then return nil end + local file = rawget(rule, "file") + local line = rawget(rule, "line") + local col = rawget(rule, "col") + local severity = rawget(rule, "severity") + if not is_capture_index(file) then return nil end + if not is_capture_index(line) then return nil end + if col ~= nil and not is_capture_index(col) then return nil end + if severity ~= nil and severity ~= "error" and severity ~= "warning" then + return nil + end + return { pattern = pattern, file = file, line = line, col = col, severity = severity } +end + +-- The defaults are a private deep copy taken at load time: an alias +-- of the public table would keep in-place user mutations live after +-- the "using built-in defaults" degradation (round-1 finding 10). +local BUILTIN_RULES = {} +for i, rule in ipairs(pmacs.compile.rules) do + BUILTIN_RULES[i] = validated_rule_copy(rule) +end + +-- Validate the (user-mutable) rule table once per run, fail-closed +-- per entry (Q#CM4): a non-table container degrades to the built-in +-- defaults; malformed entries are skipped; one status note per run +-- counts the skips. Never raises — this feeds the per-frame pump — +-- so the container traversal itself is protected too (a hostile +-- __index on the OUTER table can raise from inside ipairs; round-2 +-- finding 1). The returned list holds per-run plain-table copies: +-- validation is a stable, total snapshot, and mutating the user's +-- rule objects after compile.run() cannot alter an in-flight run. +local function validated_rules() + local rules = pmacs.compile.rules + if type(rules) ~= "table" then + pmacs.editor.set_status("compile: pmacs.compile.rules is not a table; using built-in defaults") + return BUILTIN_RULES, 0 + end + local valid, skipped = {}, 0 + local ok = pcall(function() + for _, rule in ipairs(rules) do + local copy = validated_rule_copy(rule) + if copy then + valid[#valid + 1] = copy + else + skipped = skipped + 1 + end + end + end) + if not ok then + pmacs.editor.set_status( + "compile: pmacs.compile.rules raised during traversal; using built-in defaults") + return BUILTIN_RULES, 0 + end + return valid, skipped +end + +local function sniff_severity(line) + local lower = line:lower() + if lower:find("error", 1, true) then return "error" end + if lower:find("warning", 1, true) then return "warning" end + return nil +end + +-- --------------------------------------------------------------------- +-- Slots: one streaming generated buffer per name (Q#CM2) +-- --------------------------------------------------------------------- + +-- name -> slot. A slot owns its buffer incarnation, overlay handle, +-- streaming state, error list, and the live process (if any). +local slots = {} +-- proc raw id -> { procid, slot, tomb }. Tombstoned entries drop +-- output on arrival but stay registered until their terminal event +-- drains, then forget (Q#CM9 — forget is legal only on terminated +-- processes; removing earlier leaks the supervisor record). +local pump = {} + +local function buffer_named(name) + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == name then return id end + end + return nil +end + +local function slot_for_buffer(buf) + if not buf then return nil end + for _, slot in pairs(slots) do + if slot.buf and slot.buf == buf then return slot end + end + return nil +end + +--- True when `buf` is one of the module's generated buffers (or the +--- grep panel, which shares the q-target discipline). Used for the +--- never-capture-a-generated-buffer guard (Q#CM11) — also consumed +--- by the project.search upgrade in commands/default.lua. +function pmacs.compile.is_generated_buffer(buf) + if not buf then return false end + local ok, d = pcall(pmacs.describe.buffer, buf) + if not (ok and d) then return false end + return d.name == COMPILATION or d.name == SHELL_OUT or d.name == SEARCH_RESULTS +end + +local UNDO_CHORDS = { "C-/", "C-_", "C-4", "C-x u", "C-?", "C-S-_", "C-x r" } + +local function bind_slot_keys(slot) + local function bind(seq, command) + pmacs.keymap.bind { + scope = "buffer", buffer = slot.buf, sequence = seq, command = command, + } + end + bind("RET", "compile.visit-error") + bind("n", "compile.next-error-line") + bind("p", "compile.previous-error-line") + bind("q", "compile.quit") + bind("C-c C-k", "compile.kill") + if slot.name == COMPILATION then + bind("g", "compile.recompile") + end + -- All seven shipped undo/redo chords become status no-ops (Q#CM2 + -- layer 1); command/menu undo stays dispatchable and is + -- guard-recovered by the revision guard (layer 2). + for _, seq in ipairs(UNDO_CHORDS) do + bind(seq, "compile.undo-noop") + end +end + +local function slot_buffer_removed(slot) + -- Killed buffer (Q#CM9): terminate promptly, tombstone the pump + -- entry (its terminal event still drives forget), drop the handle + -- so the next run recreates the buffer. + if slot.proc then + local entry = pump[slot.proc:raw()] + if entry then entry.tomb = true end + pcall(pmacs.process.terminate, slot.proc) + slot.proc = nil + pmacs.editor.set_status(slot.label .. ": buffer killed; run terminated") + end + slot.buf = nil + slot.overlay = nil +end + +local function ensure_slot(name, label) + local slot = slots[name] + if slot and slot.buf and slot.buf:is_valid() then return slot end + slot = slot or { name = name, label = label } + slots[name] = slot + slot.buf = buffer_named(name) or pmacs.buffer.create(name) + -- Read-only via erroring intercept (the listview idiom); module + -- writes pass bypass_intercept. Lives as long as the buffer. + pmacs.buffer.add_intercept(slot.buf, function() + error(name .. " is read-only") + end) + -- Q#P6: semantic frontends round-trip keys here (RET must visit, + -- not optimistically insert a newline; undo chords must reach the + -- local no-ops). + pmacs.buffer.set_round_trip_input(slot.buf, true) + -- One overlay handle per buffer incarnation, retained; cleared per + -- run; re-attached after every switch into the buffer (window + -- overlay attachment is cleared by buffer switches). + slot.overlay = pmacs.buffer.add_style_overlay(slot.buf) + pcall(pmacs.buffer.on_removed, slot.buf, function() + slot_buffer_removed(slot) + end) + bind_slot_keys(slot) + return slot +end + +-- --------------------------------------------------------------------- +-- Revision guard + streaming writes (Q#CM2) +-- --------------------------------------------------------------------- + +local function 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 slot_alive(slot) + return slot.buf ~= nil and slot.buf:is_valid() +end + +-- Resync after an external edit (Q#CM2): clamp to the end, reset +-- pending-line state, drop ALL pre-marker in-buffer anchors (a +-- revision carries no edit range; a same-length replace can move +-- newlines with every anchor in bounds), append exactly one +-- newline-delimited marker, and open a fresh anchor epoch — lines +-- completed by subsequent output get trustworthy rows again. The +-- file-location list (M-g n) is preserved across epochs. +local function resync(slot) + local buf = slot.buf + for _, e in ipairs(slot.errors) do + -- Both in-buffer anchors: the display row AND the public byte + -- anchor — pre-marker byte offsets are exactly as untrustworthy + -- as rows after an unknown edit (round-1 finding 7). + e.row = nil + e.line_start_byte = nil + end + local len = buf:len() + buf:insert(len, DESYNC_MARKER, { bypass_intercept = true }) + slot.out_pos = buf:len() + -- The marker ends with \n, so the fresh epoch starts a new line. + slot.line_start = slot.out_pos + slot.parse_line_start = slot.out_pos + slot.next_row = count_newlines(buf:slice(0, slot.parse_line_start)) + slot.expected_rev = buf:revision() +end + +-- The guard's single checkpoint: nil buffer → false; revision drift +-- → resync (returns true: callers may continue, state is coherent +-- again). Called before every producer write and byte-anchor use, +-- and immediately from the buffer.after-edit subscription. +local function check_rev(slot) + if not slot_alive(slot) then return false end + if slot.expected_rev == nil then return true end + if slot.buf:revision() ~= slot.expected_rev then + resync(slot) + end + return true +end + +local function style_is_default(style) + if not style then return true end + return style.fg == "default" + and style.bg == "default" + and not style.bold + and not style.italic + and style.underline == "none" + and not style.reverse +end + +local function add_style_span(slot, from, to) + if not slot.overlay then return end + if from >= to then return end + if style_is_default(slot.cur_style) then return end + slot.overlay:add(from, to, slot.cur_style) +end + +-- True when byte `b` is a UTF-8 continuation byte (0x80–0xBF). +local function is_utf8_continuation(b) + return b >= 0x80 and b < 0xC0 +end + +-- Codepoint count of `s` (lead bytes only; `s` always holds complete +-- scalars — parser text events never carry a partial sequence). +local function count_codepoints(s) + local n = 0 + for i = 1, #s do + if not is_utf8_continuation(s:byte(i)) then n = n + 1 end + end + return n +end + +-- Byte length of the first `n` codepoints of `s`, clamped to #s. +local function codepoint_prefix_bytes(s, n) + local len = #s + local i = 0 + local seen = 0 + while i < len and seen < n do + i = i + 1 + while i < len and is_utf8_continuation(s:byte(i + 1)) do + i = i + 1 + end + seen = seen + 1 + end + return i +end + +-- Track the current line's start as bytes land (round-5 finding 3): +-- `slot.line_start` is the byte offset where the line containing +-- `out_pos` begins. CR, backspace, and erase-line rewinds read it in +-- O(1); the old per-event scan materialized and walked the ENTIRE +-- preceding buffer (buf:slice(0, pos)) on every CR — quadratic for a +-- progress-heavy command behind megabytes of output. The value +-- advances wherever a \n lands (this helper for appended text; the +-- mid-line newline branch inline) and resets on the recovery paths +-- (run start, resync, raw marker appends). Rewinds never cross it, +-- so it is always ≤ out_pos and always a line start. +local function note_appended(slot, base, text) + local last = nil + local search = 1 + while true do + local idx = text:find("\n", search, true) + if not idx then break end + last = idx + search = idx + 1 + end + if last then slot.line_start = base + last end +end + +-- Append `text` at the tracked output position with overwrite +-- semantics (CR progress bars rewrite the current line in place). +-- +-- Overwrites are COLUMN-counted and newline-segmented (PR #113 +-- round-4 finding 1; codepoints approximate columns — double-width +-- and combining characters count as one, the framing's documented +-- stance). Each newline-free segment consumes one existing codepoint +-- per incoming codepoint — `abc\ré` yields `ébc`, never the +-- byte-counted `éc` — and LF is NOT an overwrite column: a newline +-- arriving mid-line drops the cursor to a fresh line and the stale +-- remainder of the current line survives in place (terminal +-- semantics), where the byte-counted overwrite wrote `X\n` INTO the +-- line, splitting it and leaving the remainder as a ghost line the +-- parser saw again at EOF. +-- +-- UTF-8 safety (round-3 finding 1) is per-edit: every segment holds +-- complete scalars (the parser never splits one, and \n is ASCII) +-- and every consumed range covers whole existing codepoints, so the +-- rope is valid UTF-8 after each step and the byte-native CRDT edit +-- never rejects a range. `out_pos` stays on codepoint boundaries by +-- induction: it moves to end-of-segment, line starts (after \n), or +-- a boundary-aligned backspace target. +local function emit_text(slot, text) + if #text == 0 then return end + local buf = slot.buf + local idx = 1 + while idx <= #text do + local len = buf:len() + local pos = math.min(slot.out_pos, len) + if pos >= len then + -- Append fast path: nothing ahead to overwrite, so the whole + -- remainder (newlines included) lands as one edit. + local rest = text:sub(idx) + buf:insert(len, rest, { bypass_intercept = true }) + slot.out_pos = len + #rest + note_appended(slot, len, rest) + add_style_span(slot, len, len + #rest) + return + end + local nl = text:find("\n", idx, true) + if nl == idx then + -- Newline while mid-line: cursor to a fresh line; the stale + -- remainder stays. The \n is appended past it, never written + -- over it. + buf:insert(len, "\n", { bypass_intercept = true }) + slot.out_pos = len + 1 + slot.line_start = len + 1 + idx = idx + 1 + else + local seg = text:sub(idx, (nl or #text + 1) - 1) + -- The current line's remainder — the only bytes an overwrite + -- may touch. No \n exists at or past out_pos: rewinds stay + -- within the final line and \n is only ever appended. + local tail = buf:slice(pos, len) + local ow = codepoint_prefix_bytes(tail, count_codepoints(seg)) + buf:replace(pos, pos + ow, seg, { bypass_intercept = true }) + slot.out_pos = pos + #seg + add_style_span(slot, pos, pos + #seg) + idx = idx + #seg + end + end +end + +-- The current unterminated line runs from the tracked +-- `slot.line_start` (per-slot, updated at every \n — NOT +-- `parse_line_start`, which only advances once per batch: a CR +-- arriving in the same batch as earlier completed lines must rewind +-- to the start of the CURRENT line, not the batch's first line) to +-- buf:len() — no newline ever exists past out_pos (output is +-- append-only except CR/BS rewinds within the current line). +local function apply_events(slot, events) + local buf = slot.buf + for _, ev in ipairs(events) do + local kind = ev.kind + if kind == "text" then + emit_text(slot, ev.text) + elseif kind == "set_style" then + slot.cur_style = ev.style + elseif kind == "carriage_return" then + slot.out_pos = slot.line_start + elseif kind == "backspace" then + -- Step back over one whole CODEPOINT, not one byte — a + -- mid-codepoint out_pos would make the next overwrite split + -- the character (round-3 finding 1). + local ls = slot.line_start + if slot.out_pos > ls then + local prefix = slot.buf:slice(ls, slot.out_pos) + local i = #prefix + while i > 1 and is_utf8_continuation(prefix:byte(i)) do + i = i - 1 + end + slot.out_pos = ls + i - 1 + end + elseif kind == "erase_to_eol" then + local len = buf:len() + if slot.out_pos < len then + buf:delete(slot.out_pos, len, { bypass_intercept = true }) + end + elseif kind == "erase_line" then + local ls = slot.line_start + local len = buf:len() + if ls < len then + buf:delete(ls, len, { bypass_intercept = true }) + end + slot.out_pos = ls + end + -- alt-screen suppression happens inside the parser; titles and + -- shell-integration markers are irrelevant to a compile buffer. + end +end + +-- --------------------------------------------------------------------- +-- Line parsing (Q#CM4) +-- --------------------------------------------------------------------- + +local SEVERITY_STYLE = { + error = { fg = 1 }, -- indexed red + warning = { fg = 3 }, -- indexed yellow +} + +-- A stored coordinate must be a finite integer ≥ 1: `%d+` happily +-- captures digit runs whose tonumber is astronomically large or +-- math.huge, and an unbounded value would drive the cursor walk +-- loops effectively forever (round-1 finding 1). The cursor walk +-- also clamps independently — belt and braces. +local function valid_coordinate(n) + return n ~= nil and n >= 1 and n < math.huge and n == math.floor(n) +end + +local function parse_line(slot, line, abs_start) + if not slot.parse_errors then return end + for _, rule in ipairs(slot.rules) do + -- Capture EVERYTHING the pattern produced: validation accepts + -- any positive integer index, so truncating at three silently + -- misread four-capture rules (round-1 finding 4). + local caps = { pcall(string.match, line, rule.pattern) } + local ok = table.remove(caps, 1) + if ok and caps[1] then + local file = caps[rule.file] + local lnum = tonumber(caps[rule.line]) + local cnum = rule.col and tonumber(caps[rule.col]) or nil + -- 1-based contract; below-1, non-integral, and non-finite + -- captures fail closed. A rule that NAMES a column capture the + -- match didn't produce also fails closed (silently storing + -- column 0 would misreport the location). + local col_ok = (rule.col == nil and cnum == nil) or valid_coordinate(cnum) + if type(file) == "string" and valid_coordinate(lnum) and col_ok then + local severity = rule.severity or sniff_severity(line) + slot.errors[#slot.errors + 1] = { + file = file, + line = lnum - 1, + col = cnum and (cnum - 1) or 0, + severity = severity, + line_start_byte = abs_start, + row = slot.next_row, + } + local style = severity and SEVERITY_STYLE[severity] + if style and slot.overlay then + slot.overlay:add(abs_start, abs_start + #line, style) + end + return + end + -- Fail-closed match: fall through to later rules. + end + end +end + +-- Parse every newly completed line exactly once (Q#CM4). Rows are +-- counted per completed line so RET/n/p can map cursor rows to +-- entries without rescanning the buffer. +local function parse_new_lines(slot) + local buf = slot.buf + local len = buf:len() + if slot.parse_line_start >= len then return end + local chunk = buf:slice(slot.parse_line_start, len) + local search = 1 + while true do + local nl = chunk:find("\n", search, true) + if not nl then break end + parse_line(slot, chunk:sub(search, nl - 1), slot.parse_line_start + search - 1) + slot.next_row = slot.next_row + 1 + search = nl + 1 + end + slot.parse_line_start = slot.parse_line_start + search - 1 +end + +-- --------------------------------------------------------------------- +-- Run lifecycle (Q#CM3/Q#CM9/Q#CM11) +-- --------------------------------------------------------------------- + +local function project_root_of_active() + local buf = pmacs.window.buffer() + if not buf then return nil end + local ok, path = pcall(function() return buf:path() end) + if not (ok and path) then return nil end + local ok2, proj = pcall(pmacs.project.detect, path) + if ok2 and proj and proj.root then return proj.root end + return nil +end + +-- The daemon's actual working directory: the last-resort cwd when +-- there is no explicit opt and no detectable project. Resolving it +-- (rather than leaving nil and printing "(inherited)") gives the +-- header a real path and relative error files an explicit base +-- (round-1 finding 8). +local function daemon_working_directory() + local ok, id = pcall(pmacs.instance.identity) + if ok and type(id) == "table" and type(id.working_directory) == "string" then + return id.working_directory + end + return nil +end + +local function format_exit_marker(label, ev) + if ev.kind == "exited" then + return string.format("\n[%s exited with code %d]\n", label, ev.code or 0) + elseif ev.kind == "signaled" then + return string.format("\n[%s killed by %s]\n", label, ev.signal or "signal") + elseif ev.kind == "crashed" then + return string.format("\n[%s crashed: %s]\n", label, ev.error or "unknown") + end + return string.format("\n[%s exited]\n", label) +end + +-- Plain append at end, no overwrite/style tracking — markers and +-- headers. LOCAL by design: a global here would let user config +-- shadow a helper the terminal-event path depends on, and an error +-- thrown from that shadow would consume the terminal event before +-- pump cleanup/forget ran (round-1 finding 5). +local function emit_text_raw(slot, text) + local buf = slot.buf + local base = buf:len() + buf:insert(base, text, { bypass_intercept = true }) + slot.out_pos = buf:len() + note_appended(slot, base, text) + if slot.parse_line_start > slot.out_pos then + slot.parse_line_start = slot.out_pos + end +end + +-- Terminal event: drain the parser's cross-feed state (an +-- incomplete UTF-8 sequence at process EOF can never complete — the +-- parser's finish() emits its replacement character, round-1 +-- finding 9), finalize the pending unterminated line (a final +-- diagnostic emitted without a trailing newline is complete at EOF +-- and must not be dropped — Q#CM4), then the exit marker. +local function finish_run(slot, ev) + if not check_rev(slot) then return end + local buf = slot.buf + if slot.parser then + apply_events(slot, slot.parser:finish()) + end + local len = buf:len() + if slot.parse_errors and slot.parse_line_start < len then + parse_line(slot, buf:slice(slot.parse_line_start, len), slot.parse_line_start) + slot.next_row = slot.next_row + 1 + slot.parse_line_start = len + end + slot.out_pos = buf:len() + emit_text_raw(slot, format_exit_marker(slot.label, ev)) + slot.expected_rev = buf:revision() + if ev.kind == "exited" and (ev.code or 0) == 0 then + pmacs.editor.set_status(slot.label .. ": finished") + elseif ev.kind == "exited" then + pmacs.editor.set_status(string.format("%s: exited abnormally with code %d", slot.label, ev.code)) + else + pmacs.editor.set_status(slot.label .. ": " .. ev.kind) + end +end + +local function feed_bytes(slot, bytes) + if not check_rev(slot) then return end + apply_events(slot, slot.parser:feed(bytes)) + parse_new_lines(slot) + slot.expected_rev = slot.buf:revision() +end + +pmacs.hook.add("process.after-tick", function() + for raw, entry in pairs(pump) do + local events = pmacs.process.events_take(entry.procid) + for _, ev in ipairs(events) do + local kind = ev.kind + if kind == "stdout" or kind == "stderr" then + -- stderr cannot arrive (fd2 = fd1 at the child boundary), + -- but if it somehow does, route it through the same parser + -- rather than dropping user output (the REPL's posture). + if not entry.tomb and slot_alive(entry.slot) then + feed_bytes(entry.slot, ev.bytes) + end + elseif kind == "exited" or kind == "signaled" or kind == "crashed" then + if not entry.tomb and slot_alive(entry.slot) then + finish_run(entry.slot, ev) + end + if entry.slot.proc and entry.slot.proc:raw() == raw then + entry.slot.proc = nil + end + pump[raw] = nil + pcall(pmacs.process.forget, entry.procid) + end + end + end +end) + +-- Immediate command-path recovery (Q#CM2 trigger a): M-x/menu edits +-- fire buffer.after-edit; hook edits don't re-fire the hook, so the +-- resync marker can be appended from here safely. Covers the +-- undo-after-completed-run case where no pump event will ever come. +pmacs.hook.add("buffer.after-edit", function() + local slot = slot_for_buffer(pmacs.window.buffer()) + if slot then check_rev(slot) end +end) + +-- Overlay re-attach on ANY switch path landing on a slot buffer +-- (window overlay attachment is cleared by buffer switches; the +-- jump_back binding now fires this hook too, so RET → M-, keeps its +-- styling). +pmacs.hook.add("buffer.after-switch", function() + local slot = slot_for_buffer(pmacs.window.buffer()) + if slot and slot.overlay and slot_alive(slot) then + pcall(pmacs.buffer.attach_style_overlay, slot.buf, slot.overlay) + end +end) + +-- Start a run in `slot`. Shared by compile and shell-command; grep +-- has its own worker path. +local function start_run(slot, cmdline, opts) + opts = opts or {} + -- q-target discipline (Q#CM11): capture only when coming from a + -- non-generated buffer, so `g` reruns don't re-capture and + -- compile → g → q restores the original buffer. + local cur = pmacs.window.buffer() + if cur and not pmacs.compile.is_generated_buffer(cur) then + slot.prev = cur + end + local cwd = opts.cwd or project_root_of_active() or daemon_working_directory() + + -- Supersede (Q#CM9): terminate the old group and tombstone its + -- pump entry; its terminal event still drives forget. + if slot.proc then + local entry = pump[slot.proc:raw()] + if entry then entry.tomb = true end + pcall(pmacs.process.terminate, slot.proc) + slot.proc = nil + pmacs.editor.set_status(slot.label .. ": superseded previous run") + end + + -- Fresh run state. Only error-parsing slots touch the rule table + -- at all: shell-command performs no parsing, so it must neither + -- surface compile-rule warnings nor fail on a hostile rule + -- container (round-2 finding 3). + if slot.parse then + slot.rules, slot.skipped_rules = validated_rules() + else + slot.rules, slot.skipped_rules = {}, 0 + end + slot.parse_errors = slot.parse + slot.errors = {} + slot.err_index = 0 + slot.cur_style = nil + slot.parser = pmacs.ansi.parser() + slot.overlay:clear() + local buf = slot.buf + local len = buf:len() + if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end + -- The identity fallback should always resolve; "(unknown)" only + -- survives if the instance API itself failed. + local header = string.format("$ %s\nDirectory: %s\n\n", cmdline, cwd or "(unknown)") + buf:insert(0, header, { bypass_intercept = true }) + slot.out_pos = buf:len() + -- The header ends with \n, so output starts on a fresh line. + slot.line_start = slot.out_pos + slot.parse_line_start = slot.out_pos + slot.next_row = count_newlines(header) + slot.expected_rev = buf:revision() + slot.cwd = cwd + if slot.parse and slot.skipped_rules > 0 then + pmacs.editor.set_status( + string.format("compile: skipped %d malformed rule entr%s", + slot.skipped_rules, slot.skipped_rules == 1 and "y" or "ies")) + end + + -- Spawn (Q#CM3): pipes, merged stderr at the child boundary, null + -- stdin, own process group, TERM=dumb. + local spec = { + label = slot.label, + command = "/bin/sh", + args = { "-c", "exec 2>&1; " .. cmdline }, + env = { TERM = "dumb" }, + stdin = "null", + group = true, + } + if cwd then spec.cwd = cwd end + local ok, proc = pcall(pmacs.process.spawn, spec) + -- switch_buffer synchronously fires buffer.after-switch, whose + -- subscription above attaches the overlay — a second explicit + -- attach here stacked a duplicate render view per run (round-5 + -- finding 1; translation itself is buffer-level and unaffected by + -- attachment count). + pmacs.window.switch_buffer(slot.buf) + if not ok then + emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc))) + slot.expected_rev = buf:revision() + pmacs.editor.set_status(slot.label .. ": spawn failed") + return nil + end + slot.proc = proc + pump[proc:raw()] = { procid = proc, slot = slot, tomb = false } + return proc +end + +-- --------------------------------------------------------------------- +-- Navigation (Q#CM5/Q#CM6) +-- --------------------------------------------------------------------- + +-- Cursor walk via primitives so overlay observers see the motion +-- (the lsp.lua visit idiom; 0-based line/col; the col walk shares +-- lsp.lua's inherited per-codepoint residual). Both walks stop when +-- movement stops moving — a diagnostic pointing past EOF/EOL clamps +-- there instead of looping to its nominal coordinate (round-1 +-- finding 1; parse-time validation bounds the values, the clamp +-- bounds the walk regardless). +local function move_active_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 -- EOF + 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 -- buffer end + if pmacs.editor.cursor_line() ~= row then + -- Ran off the line's end onto the next row: step back to EOL. + pmacs.editor.move_left() + break + end + end +end + +local function resolve_error_path(slot, file) + if file:sub(1, 1) == "/" then return file end + if slot.cwd then return slot.cwd .. "/" .. file end + -- No explicit cwd: the child inherited the editor's, and so does + -- find_or_open's relative resolution — pass through unchanged. + return file +end + +-- Visit `slot.errors[idx]` (the visit_location discipline: jump +-- ring, pcall'd open, status on failure). Re-seats the walk index. +local function visit_error(slot, idx) + local e = slot.errors[idx] + if not e then return end + local path = resolve_error_path(slot, e.file) + 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(slot.label .. ": failed to open " .. path .. ": " .. tostring(err)) + return + end + move_active_cursor_to(e.line, e.col) + slot.err_index = idx +end + +local function compile_slot() + local slot = slots[COMPILATION] + if slot and slot_alive(slot) then return slot end + return nil +end + +local function claim_compile_source(slot) + pmacs.errors.claim { + name = "compile", + next = function() + if #slot.errors == 0 then + pmacs.editor.set_status("compile: no errors parsed") + return + end + if slot.err_index >= #slot.errors then + pmacs.editor.set_status("no more errors") + return + end + visit_error(slot, slot.err_index + 1) + end, + previous = function() + if slot.err_index <= 1 then + pmacs.editor.set_status("no more errors") + return + end + visit_error(slot, slot.err_index - 1) + end, + } +end + +-- Row → anchored entry index for RET (dropped anchors excluded). +local function entry_on_row(slot, row) + for i, e in ipairs(slot.errors) do + if e.row == row then return i end + end + return nil +end + +pmacs.command.define { + name = "compile.visit-error", + description = "Visit the error location on the current line of a compile/shell buffer.", + fn = function() + local slot = slot_for_buffer(pmacs.window.buffer()) + if not slot then return end + if not check_rev(slot) then return end + local idx = entry_on_row(slot, pmacs.editor.cursor_line()) + if not idx then + pmacs.editor.set_status("no error on this line") + return + end + visit_error(slot, idx) + end, +} + +local function move_to_row(row) + local cur = pmacs.editor.cursor_line() + while cur < row do + pmacs.editor.move_down() + cur = cur + 1 + end + while cur > row do + pmacs.editor.move_up() + cur = cur - 1 + end + pmacs.editor.move_line_start() +end + +local function nearest_anchored(slot, from_row, direction) + local best = nil + for _, e in ipairs(slot.errors) do + if e.row then + if direction > 0 and e.row > from_row and (not best or e.row < best) then + best = e.row + elseif direction < 0 and e.row < from_row and (not best or e.row > best) then + best = e.row + end + end + end + return best +end + +pmacs.command.define { + name = "compile.next-error-line", + description = "Move to the next error line within the compile buffer (no visit).", + fn = function() + local slot = slot_for_buffer(pmacs.window.buffer()) + if not slot then return end + if not check_rev(slot) then return end + local row = nearest_anchored(slot, pmacs.editor.cursor_line(), 1) + if not row then + pmacs.editor.set_status("no more errors") + return + end + move_to_row(row) + end, +} + +pmacs.command.define { + name = "compile.previous-error-line", + description = "Move to the previous error line within the compile buffer (no visit).", + fn = function() + local slot = slot_for_buffer(pmacs.window.buffer()) + if not slot then return end + if not check_rev(slot) then return end + local row = nearest_anchored(slot, pmacs.editor.cursor_line(), -1) + if not row then + pmacs.editor.set_status("no more errors") + return + end + move_to_row(row) + end, +} + +pmacs.command.define { + name = "compile.quit", + description = "Leave the compile/shell buffer, restoring the previous buffer.", + fn = function() + local slot = slot_for_buffer(pmacs.window.buffer()) + if not slot then return end + local target = slot.prev + if not (target and target:is_valid()) then + target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*") + end + pmacs.window.switch_buffer(target) + end, +} + +pmacs.command.define { + name = "compile.kill", + description = "Terminate the running compilation (SIGTERM to its process group).", + fn = function() + local slot = slot_for_buffer(pmacs.window.buffer()) or compile_slot() + if not (slot and slot.proc) then + pmacs.editor.set_status("compile: no compilation running") + return + end + pcall(pmacs.process.terminate, slot.proc) + pmacs.editor.set_status(slot.label .. ": killed") + end, +} + +pmacs.command.define { + name = "compile.undo-noop", + description = "Undo is disabled in generated compile/shell buffers.", + fn = function() + pmacs.editor.set_status("generated buffer: undo disabled") + end, +} + +-- --------------------------------------------------------------------- +-- Entry points (Q#CM11) +-- --------------------------------------------------------------------- + +--- Programmatic compile entry. `opts.cwd` overrides the resolved +--- working directory. Stores the recompile state on success. +function pmacs.compile.run(cmdline, opts) + if type(cmdline) ~= "string" or #cmdline == 0 then + error("pmacs.compile.run: cmdline must be a non-empty string") + end + local slot = ensure_slot(COMPILATION, "compile") + slot.parse = true + local proc = start_run(slot, cmdline, opts) + if proc then + pmacs.compile._last = { cmdline = cmdline, cwd = slot.cwd } + claim_compile_source(slot) + end + return proc +end + +--- The run's parsed error locations, oldest first. Public getter +--- (per API conventions): `{ file, line, col, severity, +--- line_start_byte }` with 0-based line/col. +function pmacs.compile.errors() + local slot = slots[COMPILATION] + local out = {} + if not slot then return out end + for _, e in ipairs(slot.errors) do + out[#out + 1] = { + file = e.file, + line = e.line, + col = e.col, + severity = e.severity, + line_start_byte = e.line_start_byte, + } + end + return out +end + +--- Programmatic shell-command entry (Q#CM8): same machinery, no +--- error parsing, no error-source claim. +function pmacs.shell.command(cmdline, opts) + if type(cmdline) ~= "string" or #cmdline == 0 then + error("pmacs.shell.command: cmdline must be a non-empty string") + end + local slot = ensure_slot(SHELL_OUT, "shell") + slot.parse = false + return start_run(slot, cmdline, opts) +end + +pmacs.command.define { + name = "compile.run", + description = "Compile: run a command in a streaming *compilation* buffer (M-x compile).", + fn = function() + local last = pmacs.compile._last + pmacs.minibuffer.read { + prompt = "Compile command: ", + history = "compile", + initial = last and last.cmdline or "", + on_accept = function(cmdline) + if cmdline == nil or cmdline == "" then return end + pmacs.compile.run(cmdline) + end, + } + end, +} + +pmacs.command.define { + name = "compile.recompile", + description = "Re-run the last compilation with its stored command and directory.", + fn = function() + local last = pmacs.compile._last + if not last then + pmacs.editor.set_status("compile: nothing to recompile yet (run compile.run first)") + return + end + pmacs.compile.run(last.cmdline, { cwd = last.cwd }) + end, +} + +pmacs.command.define { + name = "shell.command", + description = "Run a shell command asynchronously into *shell-command* (M-!).", + fn = function() + pmacs.minibuffer.read { + prompt = "Shell command: ", + history = "shell", + on_accept = function(cmdline) + if cmdline == nil or cmdline == "" then return end + pmacs.shell.command(cmdline) + end, + } + end, +} + +-- --------------------------------------------------------------------- +-- Global keys (Q#CM5): take over Emacs's next-error chords +-- --------------------------------------------------------------------- + +-- lsp.lua bound M-g n/p to the diag commands; duplicate bindings are +-- rejected, so unbind first (this is the Q#CM1 load-order contract). +-- The dispatchers fall back to those same diag commands when nothing +-- has claimed, preserving today's behavior exactly. +pmacs.keymap.unbind { scope = "global", sequence = "M-g n" } +pmacs.keymap.unbind { scope = "global", sequence = "M-g p" } +pmacs.keymap.bind { scope = "global", sequence = "M-g n", command = "error.next" } +pmacs.keymap.bind { scope = "global", sequence = "M-g p", command = "error.previous" } +pmacs.keymap.bind { scope = "global", sequence = "C-x `", command = "error.next" } +pmacs.keymap.bind { scope = "global", sequence = "M-!", command = "shell.command" } diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 327c2dd..bcddf1e 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -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; "` + (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 diff --git a/docs/compile-mode-framing.md b/docs/compile-mode-framing.md new file mode 100644 index 0000000..5d2addc --- /dev/null +++ b/docs/compile-mode-framing.md @@ -0,0 +1,1183 @@ +# Compile-mode — framing (Arc 5 stage 1, terminal) + +**Revision 14 — 2026-07-14. Status: implemented on branch +`compile-mode` (PR #113); revisions 7–14 fold in PR rounds 1–8.** + +Revision 14 (PR #113 round 8, direct review fixes): overlay disposal +now preflights both the optional editor-core borrow and the required +registry borrow before changing the shared disposed flag or removing +either view. A re-entrant callback therefore receives a pointed, +retryable error instead of a `RefCell` panic or partial teardown; the +acceptance bite holds each borrow in turn, proves the handle remains +fully live, and retries successfully. `attach_style_overlay` also +resolves the recorded owner in the registry after its identity checks, +so a handle whose buffer (and translator) has died is rejected as +stale rather than reporting a successful no-op. + +Revision 13 (PR #113 round 7, findings 1–2): overlay handle +attachment is validated — `attach_style_overlay` rejects a handle +whose recorded buffer differs from the target (its translator +follows edits to ITS buffer only; a cross-buffer render view showed +spans nobody maintains) and rejects a disposed handle (re-attachment +resurrected rendering without the translator); the disposed state is +shared across handle clones and both error messages point at +`add_style_overlay` as the fix. And `dispose()` no longer performs +the translator detach inside the optional `SharedCore` branch: the +window cleanup uses the core when present, while the detach goes +through the always-registered `SharedRegistry` — an +install-only/headless host previously got success with the +translator left attached (and paying per edit) for the buffer's +lifetime. Bites: cross-buffer + dispose-then-attach acceptance, and +a headless-host twin in the acceptance crate (the in-crate +registry-only unit vanishes under a mod.rs swap; the twin bites). + +Revision 12 (PR #113 round 6, findings 1–3): render-view attachment +is idempotent and split-complete. Overlays expose an +`overlay_identity` (the span store's allocation address); +`Window::ensure_overlay` attaches a store-backed render view AT MOST +once per window — pre-fix every switch into the buffer blindly +pushed another copy onto EVERY matching window, so passive panes +accumulated duplicates, each cloning all spans and rescanning the +buffer per frame. A same-buffer split copies clonable overlays to +the new pane via `clone_for_split` (splits fire no switch hook and +started with an empty overlay list — the new compilation pane +rendered unstyled). The translator ignores pure no-op edits +(buffers deliberately broadcast them): pre-fix each interior no-op +split the containing span into adjacent fragments — unbounded list +growth, and a no-op at a UTF-8 continuation byte minted a +mid-codepoint span boundary. And the overlay handle has a teardown +path: `handle:dispose()` idempotently detaches the buffer-attached +translator and removes every window render view over its store — +the documented lifetime contract is one handle per buffer +incarnation (compile/REPL need no disposal; repeated creation on a +long-lived buffer must dispose retired handles or every edit keeps +paying for abandoned translators). Bites: split+bounce per-cell +acceptance (immediate post-split assert, before any switch could +heal the pane), no-op fragmentation Lua twin, dispose Lua twin; +units for genuine insertion, no-op ignore, split copy, and +ensure-once. + +Revision 11 (PR #113 round 5, findings 1–3): style-span coordinate +translation belongs to the BUFFER, not to views — a new +`BufferStyleSpanTranslator` is attached to the buffer by +`pmacs.buffer.add_style_overlay`, sees every edit exactly once +(bypass writes, undo/redo, remote CRDT ops) regardless of window +count or visibility, and the window-attached `BufferStyleOverlay` +copies are render-only. Pre-fix each attached view translated the +shared store from its own `on_edit`: the duplicate attachment in +`start_run` (switch_buffer's after-switch hook already attaches) +made byte-delta rewrites shift later spans TWICE on the normal path, +splits multiplied further, and a hidden buffer shifted ZERO times. +Translation also preserves the untouched fragments of a partially +overlapped span (finding 2): left of the replaced range keeps its +styling, right of it shifts by the length delta, and only the bytes +actually rewritten lose theirs — `red abc, reset, CR, X` renders a +default X followed by red `bc`, where the old translation dropped +any overlapping span whole. Per-cell rendered assertions +(glyph, fg) pin both — `any_styled_cell` cannot see a wrong color on +the right cell. And the per-event whole-prefix line-start scan is +gone (finding 3): `slot.line_start` is tracked — advanced at every +\n, reset on recovery paths — making CR/BS/erase-line O(1); measured +2.52s → 0.67s on 2 MB + 3000 CRs (the pin is behavioral, not timed — +timing bounds flake on slow CI). + +Revision 10 (PR #113 round 4, findings 1–2): CR rewrites are +COLUMN-counted and newline-segmented, not byte-counted — each +newline-free segment of a text event consumes one existing codepoint +per incoming codepoint (codepoints approximate columns; double-width +and combining characters count as one, the documented stance), and +LF is not an overwrite column: a newline arriving mid-line drops the +cursor to a fresh line and the stale remainder of the current line +survives in place (terminal semantics). Pre-fix, `abcdef\rX\n` wrote +`X\n` over `ab` — splitting the line and leaving `cdef` as a ghost +line the parser saw again at EOF — and `abc\ré` ate two ASCII +columns because é is two bytes (bites: single-batch, split-feed with +a split codepoint, and a CRDT twin — the segmented renderer makes +several byte-native edits per event, each on codepoint boundaries). +The ANSI parser now tracks the style the consumer LAST RECEIVED +(`emitted_style`): SGR changes inside the alternate screen advance +the internal style while their events are suppressed, so an ordinary +`?1049l` exit resynchronizes the effective style, and `finish()` +balances against the emitted style rather than the internal one — a +suppressed SGR reset inside the alt screen no longer strands the +consumer on stale pre-enter color (consumer-mirror units + a Lua +twin that bites). + +Revision 9 (PR #113 round 3, findings 1–3): the CR/backspace +renderer is UTF-8-safe — overwrites consume WHOLE existing +codepoints (range end aligned forward past continuation bytes) in +ONE atomic replace of the complete text event, and backspace steps +to the previous codepoint boundary; pre-fix, byte-counted splits +left malformed bytes on the plain rope and made the byte-native CRDT +edit reject mid-codepoint ranges, aborting the pump after its events +were consumed (default + CRDT bites: é\rX, X\ré, é\bX). +`parser:finish()`'s reset is now OBSERVABLE: balancing events — +`AlternateScreenExit` for an unclosed enter, a default `SetStyle` +for a non-default running style — let consumers unwind mirrored +state from the event stream alone (unit applies events to consumer +state; Lua twin). The spawn-spec `stdin`/`group` fields are RAW +reads: metatable-provided fields are deliberately not honored (the +compile.lua posture) and a raising `__index` can no longer be +silently absorbed as `group = false`, disabling isolation. + +Revision 8 (PR #113 round 2, findings 1–5): rule validation is a +stable, total snapshot — validated scalar fields are copied into +per-run plain tables via raw reads (rawget; metatable-provided +fields are deliberately not honored), and the container traversal is +itself protected, so neither post-run mutation of the user's rule +objects nor a hostile `__index` can alter an in-flight run or raise +through the pump mid-batch (traversal-raise semantics are +Lua-flavor-dependent: 5.2+ `ipairs` consults `__index`, LuaJIT reads +raw — both pinned); capture indexes must also be FINITE +(`math.floor(math.huge) == math.huge`); shell-command never touches +the rule table (no spurious warnings, no rule-container failure can +block a run that parses nothing); `parser:finish()` now RESETS the +parser — in-flight CSI/OSC/escape state and alt-screen suppression +included — so a post-finish feed parses a fresh stream (direct unit +tests plus a Lua-driven twin); three stale comments corrected. +Bites: four acceptance tests against pre-fix compile.lua, one +against pre-fix ansi.rs. + +Revision 7 (PR #113 round 1, findings 1–10): stored coordinates must +be finite integers and both cursor walks are movement-bounded +(clamping at EOF/EOL) — an astronomical `%d+` capture can no longer +hang the editor; the grep panel gains the same immediate +`buffer.after-edit` recovery trigger as the compile slots; the rustc +rule uses the framing's `([^:]+)` spelling (paths with spaces); +rule capture indexes must be positive integers, all pattern captures +are honored (not just the first three), and a rule that names a +column its match didn't produce rejects the match; the marker/header +append helper is module-local (a user global could shadow it and its +error consumed the terminal event before forget); `stdin`/`group` +spec fields reject wrong Lua types instead of silently defaulting +(strict boolean check — mlua truthiness would coerce `"true"`); +resync also nils the public `line_start_byte` (total pre-marker +anchor invalidation includes the byte anchor); the inherited cwd +resolves through `pmacs.instance.identity().working_directory` so +the header always names a real path; a new `parser:finish()` +(additions #5) flushes a truncated UTF-8 sequence at process EOF as +U+FFFD before the exit marker; the built-in default rules are a +private deep copy, immune to in-place mutation of the public table. +Eleven bite tests, each verified failing against the pre-fix tree +via `scripts/bite`. + +Revision 6 (responding to review round 5, findings 1–4, plus the +follow-up blocker audit): group-aware final drain now has an absolute +cancel deadline of `GROUP_TERM_GRACE` + one reader-poll interval even +when an escaped writer has already left the tracked group — queued +output gets one quiescent drain interval, then the reader is cancelled +and its retained join completes within one further interval, so the +setsid path cannot fall back to the old two-second synchronous stall +(supervisor additions #2); the external-edit ground truth now reflects +that round-trip buffers disable honest +frontend optimistic undo and accepted CRDT ops fire `buffer.after-edit` +— pump/anchor guards own only no-hook programmatic bypasses and defense +in depth (Q#CM2); acceptance dispatches all seven shipped undo/redo +chords, including raw-terminal `C-4` and both redo forms; after a +revision mismatch all pre-marker anchors are invalidated, while +diagnostics completed after the recovery marker may create a fresh, +reliable anchor epoch; every asynchronous producer, including grep +worker batches, checks the revision before writing so it cannot mask an +external edit (Q#CM2/Q#CM7). Acceptance remains 35 items. + +Revision 5 (responding to review round 4, findings 1–6): the reap +ledger integrated into supervisor shutdown — outstanding groups are +force-killed at teardown, and restart is gated off once shut down +(supervisor additions #2); the ledger deadline enforced *inside* +`final_drain_runtime`'s loop so SIGKILL lands at the grace bound +instead of the drain timeout, with the residual synchronous stall +bounded and named (additions #2); command/menu undo recovered +*immediately* via a `buffer.after-edit` subscription rather than +waiting for a pump event that may never come (Q#CM2); on any +revision mismatch ALL in-buffer anchors are dropped — a revision +carries no edit range, and a same-length replace can move newlines +while every anchor stays in bounds (Q#CM2); ledger arming made +idempotent, earliest-deadline-wins (additions #2); the nix `poll` +feature added to the manifest touches (additions #2). Acceptance +grew to 35 items. + +Revision 4 (round 3, findings 1–6): group-liveness reap ledger +decoupled from leader/reader state; reader detach replaced with +poll-based cancellable reads; the external-edit guard upgraded from +byte length to buffer revision (`buf:revision()`, additions #4); +all seven shipped undo/redo chords neutralized; sub-1 rule captures +fail closed; rule `severity` defined as an override. + +Revision 3 (round 2, findings 1–7): leader-exit group reap before +drain/join; bounded TERM→KILL escalation (shutdown-time-fallback +deferral withdrawn); external-edit resilience for the streaming +buffer; grep kill-mid-search safety + root retention across +interactive supersedes; jump-back after-switch parity + +after-switch overlay re-attach; rustc/Python severity honesty. + +Revision 2 (round 1, findings 1–11): pipe+ansi spawn rejection fixed +by moving ANSI parsing to Lua; stderr merged at the child boundary; +null-stdin spec option; process-group spawn + group-directed +SIGTERM; the `M-g` ordering contract corrected; tombstoned teardown; +coordinate normalization; unterminated-final-line parse; rule-table +validation; overlay handle discipline; interactive command contract. + +Scope: `M-x`-style `compile.run` with a streaming `*compilation*` +buffer and error-regex navigation; grep-mode as an upgrade of the +existing `project.search` surface; `M-!` shell-command. One branch, +one PR — the three commands are thin entry points over one shared +machinery (streaming read-only output buffer + error-locations +source), and splitting them would ship the machinery twice. + +This is roadmap Arc 5 stage 1 ("cheap, transformative"). Stage 2 +(vterm / 2D grid) is explicitly out of scope. There is no +`spec/pmacs-tasks.tex` task for this feature — the spec predates the +July roadmap; the roadmap entry is the source of scope. + +## Ground truth (as of `f0a05c5`) + +Everything below was verified by reading the code, not the roadmap. + +- **Process supervisor** (`src/process.rs`, T M4.4 done): + `ProcessSpec { label, command, args, cwd, env, mode, restart, + ansi_events }`; pipes or PTY; streaming 8 KiB chunks with a bounded + channel; `Termination::{Exited{code}, Signaled{signal}, + Crashed{error}}` surfaced as events; signal/terminate/write_stdin/ + resize. Lua surface `pmacs.process.{spawn, signal, terminate, + write_stdin, resize_pty, status, events_take, list, forget}` + (`src/lua_bindings/mod.rs:7077-7232`). `EditorCore::tick_processes` + drains the supervisor and fires the `process.after-tick` hook every + frame (`src/editor.rs:450-458`). +- **Pipe-mode constraints** (rounds 1–2, all verified): + - `ansi = true` + pipes is **rejected at spawn** + (`src/process.rs:1200-1203`); structured ANSI events are + PTY-only. Pipe consumers get raw byte events. + - `drain_raw_output` coalesces **all stdout first, then all + stderr** per tick (`src/process.rs:1501-1518`) — cross-stream + arrival order is destroyed before Lua sees it. + - Pipe children always get `Stdio::piped()` stdin whose writer is + retained (`src/process.rs:1213-1229`); a child that reads stdin + to EOF hangs forever. + - `signal_target` sends group-directed signals (negative pid) for + PTY children via the master's `process_group_leader`, but plain + positive-pid signals for pipe children + (`src/process.rs:607-620`). + - **Leader-exit drain/join hazard:** on a terminal event, + `poll_one` calls `final_drain_runtime`, which polls until the + reader threads finish or `EXIT_OUTPUT_DRAIN_TIMEOUT` elapses + (`src/process.rs:966-999`, `:1539-1554`); dropping the runtime + then **joins** the readers (`src/process.rs:544-555`). The + cancel flag only unwedges a reader stuck on a *full channel*; a + reader blocked in `read()` on a pipe still held open by a + surviving descendant (`sh -c "sleep 60 &"` exits immediately, + the backgrounded sleep inherits fd1) burns the full drain + timeout and then blocks the join — **the editor tick freezes + until the descendant exits.** Group ownership must reap before + drain/join, not just on explicit kill. + - `forget` errors unless the process is already `Terminated` + (`src/process.rs:1109-1123`); removing a pump entry before the + terminal event is observed leaks the supervisor record. +- **ANSI** (`src/ansi.rs`): `Text` events never contain escape bytes + (`:58-60`) — child output cannot corrupt a buffer. SGR → + `SetStyle`; intra-line `CarriageReturn`/`Backspace`; + `EraseToEol`/`EraseLine`; alt-screen contents suppressed; 2D cursor + addressing parsed-and-discarded. `pmacs.ansi.parser()` gives Lua a + stateful parser for raw bytes — the REPL's `append_output` feeds it + exactly this way (`builtin/packages/repl/init.lua:382-384`). +- **The REPL package is the proven consumer** + (`builtin/packages/repl/init.lua`): single `process.after-tick` + subscription walking a proc-id-keyed pump registry (`:765-773`); + `append_events` applies text/set_style/carriage_return/backspace/ + erase events to the buffer (`:386-427`); style overlay spans via + `pmacs.buffer.add_style_overlay` + `overlay:add(start, end, style)` + (`:190`, `:536-541`); exit markers with `_on_exit` as the single + teardown point calling `pmacs.process.forget` only after the + terminal event (`:782-822`, the M6.9 no-leak discipline). +- **Style overlay window semantics** (`src/lua_bindings/mod.rs: + 2907-2933`, `:1756-1799`): `add_style_overlay` attaches a + buffer-level `BufferStyleSpanTranslator` (coordinate translation, + exactly once per edit — Revision 11) plus render-only window + overlays on windows *currently showing* the buffer; buffer + switches clear window overlays; `attach_style_overlay(buf, + handle)` re-attaches the render view, idempotently per window via + the store identity, and same-buffer splits copy the render view to + the new pane (Revision 12). Attachment validates the handle: + wrong-buffer and disposed handles are rejected with messages + pointing at `add_style_overlay`; a handle whose recorded owner has + died is rejected as stale (Revisions 13–14). The handle has + `add`, `clear`, `clear_before`, `spans`, and idempotent `dispose` + (teardown of the translator + every window render view; the + translator detach rides the always-registered registry, not the + optional editor core; teardown preflights both borrows so re-entrant + calls fail atomically and can be retried; one handle per buffer + incarnation needs no disposal). +- **Buffer-switch hooks**: `buffer.after-switch` exists and fires on + the ordinary switch paths (recentf subscribes, + `builtin/runtime/recentf.lua:54`). **`pmacs.editor.jump_back` does + not fire it**: the binding calls straight into + `EditorCore::jump_back`, which switches via + `switch_active_buffer` with no hook (`src/editor_core.rs:736-751`, + `src/lua_bindings/mod.rs:10795-10801`) — so the RET → `M-,` + round trip sheds any per-window overlay attachment. +- **Read-only + navigable panels** (`builtin/runtime/listview.lua`, + Q#P1/P3/P6): intercept that `error()`s makes a buffer read-only; + the module's own writes pass `{ bypass_intercept = true }` + (`:60-61`, `:101-103`); buffer-local keymap; previous-buffer + capture + `q` restore (with a never-capture-a-panel guard, + `:118-124`); `pmacs.buffer.set_round_trip_input(buf, true)` + (`:106`). **Caveat:** listview re-renders wholesale (`:50-62`) — + wrong for a streaming compile. Display convention is + switch-in-place (Q#P2: the GPU cannot show splits). +- **Keymap constraints**: duplicate bindings are **rejected**, not + replaced (`KeymapError::DuplicateBinding`, + `src/keymap_tree.rs:238-245`); `pmacs.keymap.unbind` exists + (`src/lua_bindings/mod.rs:5511`). `M-g n`/`M-g p` = + `diag.next`/`diag.previous` (`builtin/runtime/lsp.lua:2148-2149`; + `diag.next` **wraps**); `M-g g`/`M-g M-g` = goto-line (editops, + #111). Free: `M-g M-n`, `M-g M-p`, `` C-x ` `` (the parser accepts + a single-character chord), `M-!`, `M-&`. Runtime chunks load in a + fixed sequence in `src/editor.rs` (`:184-377`); lsp.lua at `:301`. +- **Buffer lifecycle hook**: `pmacs.buffer.on_removed(buf, cb)` + exists with once-only semantics (`src/lua_bindings/mod.rs:2818`, + autosave consumer `builtin/runtime/autosave.lua:147`). +- **Undo / external-edit routing (Revision 6 correction):** undo does + bypass Lua intercepts, but these generated buffers are marked with + `pmacs.buffer.set_round_trip_input(buf, true)`. While one is active, + `EditorState::dispatch_idle()` returns false (`src/editor.rs:622-639`), + and both semantic frontends gate their entire optimistic edit path + on that signal (`src/attach.rs:835-845`, + `pmacs-gpu/src/main.rs:2064-2067`). An honest frontend's undo key + therefore round-trips and reaches the buffer-local binding; it is + not a production no-dispatch escape. Even an accepted replica + `CrdtOp` fires `buffer.after-edit` (`src/daemon.rs:2225-2238`). The + remaining no-hook exposure is programmatic mutation (including a + caller deliberately using `bypass_intercept`) plus defense against + stale/malformed clients; pump/anchor revision checks make those + paths total. The shipped undo surface is **seven chords** + (`builtin/keymaps/default.lua:126-137`): `C-/`, `C-_`, `C-4`, + `C-x u` → `buffer.undo`; `C-?`, `C-S-_`, `C-x r` → + `buffer.redo` (terminal translation aliases), plus `M-x + buffer.undo` and menu invocation as non-key dispatch paths. +- **Buffer revision**: `Buffer::revision()` exists core-side + (`src/buffer.rs:462`), bumped by the shared edit path + (`src/lua_bindings/mod.rs:1317` names "the revision bump") — + edits, undo, and redo all increment it. It is **not** exposed to + Lua yet; the auto-pair arc's `buf:path()` method is the precedent + for adding a query method to `BufferIdLua`. +- **Jump idiom** (`builtin/runtime/lsp.lua`): `visit_location` + (`:1451-1467`) = `push_jump` → pcall `find_or_open` (dropping the + pushed jump on failure) → `move_active_cursor_to(line, col)`, + which consumes **0-based** line/col (`:879-888`). Known residual + (`:867-877`): the col walk steps per codepoint while col is a byte + offset — multi-byte lines land the cursor short. Inherited. +- **Grep**: `pmacs.workers.grep{root, pattern}` (T M3.6, + `builtin/runtime/async.lua:292-324`) streams structured matches: + `file` is **relative to the search root**, `line` is **1-based**, + `match_start`/`match_end` are **0-based byte offsets within the + line** (`src/async_runtime.rs:117-135`). Streams expose + `:cancel()` (cooperative, `builtin/runtime/async.lua:155`). + Existing consumer `M-x project.search` + (`builtin/commands/default.lua:647-718`): formats lines into + `*search-results*`, supersedes via `supersede = "search"` + + stream-id late-batch rejection, root defaults to `"."`; its + `on_batch`/`on_close` callbacks **write through a retained buffer + handle with no `is_valid()` guard** (`:693-703`) — killing the + buffer mid-search makes every subsequent batch raise. +- **Prompting**: `pmacs.minibuffer.read` accepts `prompt`, `initial`, + `history`, `source`, `source_root`, `on_accept`, `on_cancel` + (`src/lua_bindings/mod.rs:11297-11330`). +- **Project root**: `pmacs.project.detect(path)` → + `{root, kind, language_id}` or nil (`src/lua_bindings/mod.rs: + 9644-9674`). +- **Supervisor shutdown** (`src/process.rs:1129-1165`): SIGTERM to + every managed process → poll `tick()` while `any_running()` within + the grace period → SIGKILL leftovers → bounded reap loop. It knows + nothing beyond managed records — a group survivor tracked only by + the reap ledger would be discarded at `Drop` — and the `tick()` + calls inside it perform restart accounting, so a + `restart = always` process can respawn mid-teardown unless gated. +- **Command-path edits fire `buffer.after-edit`**: + `with_after_edit_check` (`src/editor.rs:845-864`) wraps the + minibuffer-accept (`M-x`), menu-invoke, and unified-paste routes, + firing the hook when the *active* buffer's revision changed — + exactly the paths key rebinding cannot reach. Hook edits don't + re-fire the hook (established in the auto-pairing arc), so a hook + callback may safely append to the buffer it was notified about. +- **Process-group std API**: + `std::os::unix::process::CommandExt::process_group(0)` is safe + (stable since Rust 1.64) — a new process group at spawn with **no + `unsafe`, no trampoline**, preserving the `forbid(unsafe_code)` + posture. `nix`'s `kill(Pid::from_raw(-pgid), sig)` and a + signal-0 liveness probe are likewise safe. **Manifest caveat:** + the workspace enables nix features `signal, user, fs, term, + socket` only (`Cargo.toml:159`) — `nix::poll` is feature-gated, + so the cancellable readers require adding `poll`. +- **Test harness precedent**: `run_with_pump` + (`tests/m6_5_repl_acceptance.rs:87-115`) drives + `editor.tick_processes()` until a Lua predicate holds. + +## Supervisor / binding additions (the arc's only Rust changes) + +No protocol change, no frontend change. + +1. **`stdin = "null"`** (`ProcessSpec`, pipes-only) — spawn with + `Stdio::null()`: no writer thread, the child reads EOF + immediately (zero-race; strictly better here than exposing + `close_stdin` post-spawn). `write_stdin` on such a process errors + with the existing stdin-not-piped message. Rejected under PTY. + The Lua spec parsing for `stdin` and `group` treats wrong types + as HARD errors (Revision 7) — a silently-defaulted `stdin = true` + or `group = "true"` would undo exactly the guarantees the fields + carry; `group` is matched as a raw Value because mlua's bool + conversion applies Lua truthiness. Both fields are RAW reads + (Revision 9): a spec table is plain data, metatable-provided + fields are deliberately not honored (the compile.lua rawget + posture), and a raising `__index` cannot be silently absorbed + into `group = false`, quietly disabling isolation. +2. **`group = true`** (`ProcessSpec`, pipes-only) — a full lifecycle + policy, not just a spawn flag: + - **Spawn**: `process_group(0)` — the child leads a fresh group. + - **Signal**: `signal_target` returns `-pid` for flagged pipe + processes, mirroring the existing PTY branch + (`src/process.rs:607-615`). + - **Group-liveness reap ledger (Revision 4 — replaces the + Revision-3 conditions)**: escalation is driven by **group + liveness itself**, not by leader or reader state. Whenever a + group process is TERMed (kill, supersede) *or* its leader's + terminal event is observed (normal exit included), the + supervisor SIGTERMs the group and records `{pgid, deadline}` in + a reap ledger that is independent of the process record. Every + tick probes each ledger entry with `kill(-pgid, 0)` (safe nix): + ESRCH → group gone, entry dropped; still alive past the + deadline (`GROUP_TERM_GRACE`, 500 ms) → SIGKILL the group. The + Revision-3 formulation had a verified hole: a leader that exits + while a TERM-ignoring descendant redirects or closes its + stdout/stderr produces a terminal event AND finished readers, + so conditions keyed on either would never escalate — the + ledger's liveness probe catches exactly that survivor. The + ledger outlives `forget`, so `process.list()` returning to + baseline and group death are independently guaranteed. Residual + (named): between TERM and the KILL deadline a fully-recycled + pgid could theoretically absorb the KILL; the window is one + grace period and pids allocate forward — accepted. + - **Idempotent arming (Revision 5)**: arming inserts a ledger + entry only if the pgid is absent — earliest deadline wins. + Repeated `terminate` calls (or kill-then-supersede races) must + not push the SIGKILL bound out; a plain `insert` would reset + the 500 ms clock on every TERM. Pinned by a unit test issuing + repeated TERMs and asserting the original bound. + - **Shutdown integration (Revision 5)**: `shutdown()` currently + polls only `any_running()` and would discard a pre-deadline + ledger at `Drop` (`src/process.rs:1129-1165`) — a leader that + exits promptly while its TERM-ignoring group member survives + would leak that member at editor exit. Two changes: (a) + shutdown resolves the ledger — outstanding groups are SIGKILLed + immediately (editor exit owes no grace) and probed to ESRCH + within the existing bounded reap loop; (b) `maybe_restart` is + gated off once `shut_down` is set, so `restart = always` + processes cannot respawn mid-teardown. Both pinned by + supervisor unit tests (a drop-twin of the ledger acceptance). + - **Leader-exit ordering + bounded in-drain enforcement (Revision + 5/6)**: + the group TERM (and ledger arming) happens **before** + `final_drain_runtime`, closing the verified freeze: a shell + that exits leaving `sleep 60 &` holding the merged pipe would + otherwise burn the full drain timeout and then block the reader + join (`src/process.rs:544-555`, `:1539-1554`). Arming alone is + not enough: `final_drain_runtime` blocks the tick for up to two + seconds, and no other tick runs to probe the ledger — a + TERM-ignoring descendant holding fd1 would get its SIGKILL ~2 s + late. The drain loop therefore **enforces ledger deadlines from + inside each iteration**. At the grace bound it SIGKILLs a + surviving group. If the original pgrp probes ESRCH earlier, the + drain gives readers one quiescent `READER_SEND_POLL_INTERVAL` to + flush already-read and kernel-buffered bytes; new data resets that + quiescence window. Independently, **no group reader drain may pass + the absolute cancel deadline** of the original ledger deadline plus + one poll interval — reaching that deadline cancels the readers even + if the liveness ledger has not yet observed ESRCH. The ledger remains + alive and keeps probing the group after the process record/runtime + are gone. The drain sets the shared cancel flag, drains the channel + once more, and lets the retained join complete within one further + poll interval. + This closes the Revision-5 residual: a setsid'd fd holder must not + fall through to `EXIT_OUTPUT_DRAIN_TIMEOUT` merely because its old + group is already gone. Honest trailing output gets a bounded + flush; escaped output may be truncated. Residual (named): the + synchronous stall is bounded by approximately + `GROUP_TERM_GRACE + 2 * READER_SEND_POLL_INTERVAL` (~600 ms with + the proposed constants), plus ordinary scheduler tolerance, not + eliminated; a fully tick-driven drain remains deferred. + - **Cancellable readers (Revision 4 — replaces the Revision-3 + detach)**: for `group = true` processes, reader threads use + poll-based reads (`nix::poll`, safe; FD set nonblocking via + `nix::fcntl` — no `unsafe`) with a cancellation check each + poll interval and again immediately after poll, before any + read/send. `RuntimeHandles::Drop` keeps its join, which + now completes within one interval regardless of who still + holds the write end. Dropping a `JoinHandle` merely detaches — + the Revision-3 "join skipped past a hard cap" traded the freeze + for an unbounded thread+FD leak across repeated runs, and is + retracted. Existing non-group consumers (REPL, LSP) keep the + blocking readers they were tuned on (the M6.6 ingest gate); + unifying is a named deferral. Manifest touch: add `poll` to + the nix feature list (`Cargo.toml:159` — currently absent, + Revision 5). + - **Escape hatch (documented behavior, not a bug)**: a descendant + that calls `setsid` leaves the process group and is deliberately + not reaped — the standard daemonization path still works. If it + holds the merged fd1 after the original group is gone, the + Revision-6 quiescence/cap rule cancels the reader rather than + waiting the two-second drain timeout. Trailing escaped output is + truncated; threads join, FDs close, and nothing accumulates. + Pinned by a supervisor unit test that asserts both **bounded + latency and resource reclamation**: repeated spawn/reap cycles + return through the retained joins within the bound, with a + per-runtime `cfg(test)` active-reader counter back to zero before + fixture cleanup. Join return plus that counter is + the deterministic proof that the reader threads ended and their + owned read FDs dropped; a process-global thread/FD count would be + racy under Rust's parallel test runner. The fixture + records and explicitly kills the escaped pid afterward — the + supervisor deliberately does not own it. +3. **`jump_back` after-switch parity (Revision 3)** — the + `pmacs.editor.jump_back` binding fires `buffer.after-switch` when + the jump actually changed the active buffer, matching the + ordinary switch paths. This is a parity fix with observable + side benefits (recentf now records `M-,` re-visits); it is what + lets overlay re-attachment ride one hook instead of special + cases. Behavior change called out in the PR body. +4. **`buf:revision()` query method (Revision 4)** — exposes + `Buffer::revision()` (`src/buffer.rs:462`) on `BufferIdLua`, + the `buf:path()` precedent. Needed because byte length is not an + edit-integrity token: the CR-overwrite rendering path performs + same-length replaces, so undoing one changes content without + changing length. Revision increments on every edit, undo, and + redo. +5. **`AnsiParser::finish()` + `parser:finish()` (Revisions 7–8)** — + stream-end finalization: the feed-boundary contract deliberately + buffers an incomplete UTF-8 sequence for the next feed, but at + process EOF there is no next feed — `finish()` emits U+FFFD for + the pending prefix (the same posture an interrupting control byte + gets) and flushes the text run, **then fully resets the parser + (in-flight CSI/OSC/escape state, alt-screen suppression, and the + running SGR style): a feed after finish parses a NEW stream** + rather than continuing a pre-EOF escape, staying suppressed, or + inheriting stale color. The reset is OBSERVABLE (Revision 9): + balancing events — `AlternateScreenExit` for an unclosed enter, a + default `SetStyle` — let consumers that mirror parser state + unwind from the event stream alone. The balance point is the + style the consumer LAST RECEIVED, not the internal style + (Revision 10, `emitted_style`): SGR changes inside the alternate + screen advance `current_style` while their events are suppressed, + so a suppressed reset would otherwise strand the consumer on + pre-enter color with nothing to compare unequal. The same field + drives an ordinary `?1049l` exit, which resynchronizes the + effective style whenever suppressed SGR changes drifted it. + Compile-mode calls `finish()` once at the terminal event, before + finalizing the pending line. + +## Decisions + +### Q#CM1 — Placement: one runtime module, one PR, ordered after lsp.lua + +New `builtin/runtime/compile.lua` owning: the streaming output-buffer +machinery, the error-rule table, the error-source dispatcher +(`pmacs.errors`), and the `compile.*` / `shell.*` commands. + +**Ordering contract:** compile.lua MUST load after lsp.lua in the +`src/editor.rs` chunk sequence, with a comment naming the contract. +Reason: it takes over `M-g n`/`M-g p`, and duplicate bindings are +rejected (`keymap_tree.rs:238`) — the takeover is `unbind` × 2 then +`bind` × 2, which requires lsp.lua's bindings to exist first. +(Placed last in the runtime sequence, after indent.lua.) Its +`process.after-tick` subscription is ordering-independent — it pumps +only its own proc-id-keyed registry, disjoint from the REPL's. + +The grep upgrade edits `project.search` in +`builtin/commands/default.lua` in place; it reaches the shared +machinery through the `pmacs.errors`/`pmacs.compile` globals at +invoke time, so commands/runtime load order stays irrelevant for it. + +### Q#CM2 — The `*compilation*` buffer: streaming append, intercept read-only + +- Named `*compilation*`, reused across runs. Each run resets it to a + header (command + resolved cwd), then streams output, then an exit + marker (REPL's `format_exit_marker` shape). +- **Read-only via an erroring intercept; module writes pass + `{ bypass_intercept = true }`** (the listview idiom — compile + buffers have no user-editable region, so the REPL's `_self_write` + machinery is unnecessary). +- **Streaming append via a Lua-side ANSI parser.** Spawn with + `ansi = false` (structured events are PTY-only); feed raw byte + events through a per-run `pmacs.ansi.parser()` — the REPL's + `append_output` path — applying events the way its `append_events` + does: `text` appends at the tracked output position, `set_style` + updates the running style (a style-overlay span per non-default + emission), `carriage_return`/`backspace`/`erase_*` get the + intra-line treatment so progress bars collapse. Alt-screen + suppression comes free. One parser, one running style, one output + position — coherent because the child delivers **one merged + stream** (Q#CM3). **Overwrite semantics (Revision 9→10):** + overwrites are COLUMN-counted and newline-segmented — each + newline-free segment of a text event consumes one existing + codepoint per incoming codepoint (codepoints approximate columns; + double-width and combining characters count as one, the documented + stance), LF is never an overwrite column (a mid-line newline + appends past the surviving stale remainder — terminal semantics — + instead of being written INTO the line, which split it and left + the remainder as a ghost line), and backspace steps to the + previous codepoint boundary. Every buffer edit keeps both range + ends on codepoint boundaries — Revision 9's UTF-8 invariant, held + per-segment now rather than by one atomic replace: segments carry + complete scalars, so the rope is valid UTF-8 after every step and + the byte-native CRDT edit never rejects a range. `out_pos` stays + on codepoint boundaries by induction. History: pre-Revision-9 + splits left malformed bytes and aborted the CRDT pump after + events_take had already consumed the batch; pre-Revision-10 the + byte count split lines (`abcdef\rX\n` → ghost `cdef` line) and ate + columns under multibyte overwrites (`abc\ré` → `éc`). +- **External-edit resilience (Revision 4–6).** Generated-buffer keys + round-trip, so honest frontend optimistic undo is not an escape from + the local bindings; accepted replica ops also fire + `buffer.after-edit`. "Only module writes move the buffer" is still + not a hard invariant, however: programmatic Lua may deliberately + bypass the intercept, and the revision guard is cheap defense in + depth against stale/malformed clients. Two layers: + 1. *Shipped key aliases neutralized* (the Revision-3 "dispatch + vector closed" claim is retracted — it covered two of seven + chords): **all seven** shipped undo/redo bindings are rebound + buffer-locally in every generated buffer to a status no-op + ("generated buffer: undo disabled") — `C-/`, `C-_`, `C-4`, + `C-x u`, `C-?`, `C-S-_`, `C-x r` + (`builtin/keymaps/default.lua:126-137`). `M-x buffer.undo` and + menu invocation remain dispatchable by design (rebinding + cannot reach them) and are **guard-recovered** by layer 2. + 2. *Everything else survived by the revision guard*: the module + records `buf:revision()` (additions #4) after each of its own + writes and checks it at **three trigger points** — + (a) *immediately*, via a `buffer.after-edit` subscription: + `with_after_edit_check` (`src/editor.rs:845`) fires the hook + for the command/menu edit routes that rebinding cannot reach; + accepted replica `CrdtOp`s reach the same hook. Hook edits don't + re-fire the hook, so the callback can append the marker safely. + Without this, an `M-x + buffer.undo` after a *completed* run — no pump event, no + byte-anchor use ever coming — would leave the buffer corrupted + indefinitely with no marker (round-4 finding 3); + (b) before every asynchronous producer write — process-pump + appends and terminal markers for compile/shell, plus grep + `on_batch`/`on_close` writes (catches a no-hook programmatic bypass + while streaming and prevents the producer's own next write from + masking it by advancing the expected revision; also defense in + depth for remote input); + (c) before any byte-anchor use (RET, `n`/`p`). + A mismatch triggers a resync: output position clamps to + `buf:len()`, pending-line state resets, exactly one + `\n[output desynced by external edit]\n` marker is appended, the + expected revision advances to that marker write, and streaming + (if live) continues at the end. Both newlines are load-bearing: + post-recovery output must not share a line with damaged pre-marker + content or with the marker itself. Revision, not byte + length: the CR-overwrite path emits same-length replaces, so an + undone overwrite changes content while preserving length — a + length guard provably misses it (round-3 finding 3). **Anchor + invalidation is total (Revisions 5/7):** a revision mismatch + carries no edit range, and a same-length replace can remove or + move newlines while every anchor stays in bounds — so ALL + **pre-marker** in-buffer anchors are dropped on any mismatch, + the public `line_start_byte` included (a pre-marker byte offset + is exactly as untrustworthy as a row). + Immediately after recovery, `n`/`p` report "no more errors" and + RET reports "no error on this line" for the damaged pre-marker + content. The marker establishes a fresh anchor epoch: diagnostic + lines completed by subsequent process output have trustworthy + positions and may add new in-buffer anchors, so navigation resumes + for those rows without waiting for the next run. The file-location + list (`M-g n`) is preserved across epochs; + stale overlay spans may mis-style until the next run's + `clear()` (accepted degraded state, named). The pump never + raises. +- **Style overlay discipline:** one overlay handle per generated + buffer, created once and retained; `overlay:clear()` on each run + reset; render re-attach rides **one** `buffer.after-switch` + subscription that fires whenever any switch path lands on one of + its buffers (Revision 3) — `start_run`'s own switch included, so + the former explicit attach after it stacked a duplicate render + view per run and is removed (Revision 11) — combined with the + jump-back parity fix (additions #3), this covers `C-x b` returns + and the primary RET → `M-,` workflow. The former "user-initiated + switch loses styling" deferral is withdrawn as covered. + Coordinate translation never depends on any of this: it lives on + the buffer (Revision 11), exactly once per edit, hidden or split + or not attached at all. +- No marks needed: one append point, tracked as plain integers, with + the revision guard above as the honesty check. + +### Q#CM3 — Process shape: pipes, merged stderr, null stdin, own group + +- `mode = pipes`, `ansi = false` (parsing in Lua, Q#CM2), + `stdin = "null"`, `group = true`, `env.TERM = "dumb"`, + `restart = never`, `label = "compile"`. +- **The command line runs as `/bin/sh -c "exec 2>&1; "`.** + The `exec 2>&1;` prefix merges stderr into stdout **at the child + boundary**: every descendant inherits fd2 = fd1, so the buffer + receives one pipe in true kernel arrival order — sidestepping + `drain_raw_output`'s stdout-then-stderr coalescing entirely (a + defensive stderr-event arm still routes through the same parser + but cannot fire when fd2 is fd1). Per-command redirections inside + the user's cmdline (`2>/dev/null`, `> log`) still behave normally. +- `TERM=dumb` + a non-tty is Emacs's compile posture: tools emit + plain line-oriented output; tools *forced* to color still parse + cleanly (SGR → styled spans). +- `stdin = "null"`: noninteractive children that read stdin (`cat`, + interactive-probe tools) see immediate EOF instead of hanging. +- `group = true`: the full lifecycle policy of additions #2 — + group-directed TERM with bounded KILL escalation on kill and + supersede, plus leader-exit reap so a normally exiting shell with + surviving descendants cannot impose the old two-second/unbounded + teardown freeze or leak. The accepted synchronous upper bound is + approximately + `GROUP_TERM_GRACE + 2 * READER_SEND_POLL_INTERVAL`; eliminating it + requires the deferred tick-driven drain. +- `cwd`: explicit opt > `pmacs.project.detect(active buffer + path).root` > `pmacs.instance.identity().working_directory` + (Revision 7 — actually *resolved*, so the header always names a + real path and relative error files get an explicit base rather + than an implicit pass-through). + +### Q#CM4 — Error parsing: ordered Lua-pattern rules, parsed at newline time + +- `pmacs.compile.rules`: an ordered array of + `{ pattern, file = , line = , col = , + severity = "error"|"warning"|nil }`; first match per line wins. + User-extensible from init.lua. Starter rules (Lua pattern syntax): + 1. rustc arrows: `%-%->%s+([^:]+):(%d+):(%d+)`, + 2. generic `([^%s:][^:]*):(%d+):(%d+):` and `file:line:` (gcc, + clang, most Unix tools; also matches grep-format lines), + 3. Python: `File "([^"]+)", line (%d+)`. +- **Severity and color (Revision 4, contract pinned):** a rule's + `severity` field is an **override** — when present ("error" or + "warning"; anything else makes the entry malformed), every match + of that rule stores it verbatim. When the field is nil, severity + is sniffed from `error`/`warning` keywords **on the matched line + only**. Severity drives overlay color (error = indexed red, + warning = indexed yellow), never navigation. The gcc-style rule + usually colocates `error:` on the location line and gets color; + **rustc-arrow and Python frame lines carry no severity token, so + those built-in entries store `severity = nil` and render in the + default style — navigable but uncolored.** A context-carrying + classifier (rustc `error[E…]:` header lines, traceback tails) is + a named deferral. +- **Coordinate normalization:** rule captures follow compiler + convention — **1-based line and column**; that is the public rule + contract. Captured values **below 1, non-integral, or non-finite + fail closed** (Revisions 4/7): the match is discarded — `%d+` + accepts `0` (an unvalidated `0 - 1 = -1` would walk the cursor + loops to a silent (0,0) landing) and also accepts digit runs whose + `tonumber` is `math.huge` (an unbounded loop bound). The cursor + walks are independently **movement-bounded** (Revision 7): they + stop when motion stops moving, clamping at EOF, and the column + walk clamps at the target row's EOL instead of marching onto later + rows. Rule capture **indexes** must be positive integers; all + pattern captures are collected (an index above three reads the + real capture, not nil-as-column-0); a rule that names a column + capture its match didn't produce rejects the match. Valid stored + entries are **0-based** (what `move_active_cursor_to` consumes): + `line - 1`; `col - 1` when captured, else `0`. The Python rule has + no column: `line - 1`, col `0`. (Grep normalization is in Q#CM7.) +- **Parse each line exactly once, when its `\n` lands** — CR/erase + rewrites happen within the current *unterminated* line, so the + content parsed at newline time is the line's final form. **At the + terminal event: first `parser:finish()` drains the cross-feed + state (a truncated multibyte sequence at process EOF surfaces as + U+FFFD instead of vanishing — Revision 7, additions #5), then the + pending unterminated line (if any) is finalized and parsed once + before the exit marker is appended.** +- **Rule-table robustness (fail-closed per entry):** on each run's + first use the table is validated — non-table `pmacs.compile.rules` + degrades to **a private deep copy of the built-in defaults** + (Revision 7: an alias of the public table would keep in-place user + mutations live after the degradation); malformed entries are + skipped, invalid Lua patterns are caught (and counted) at + validation time via a probe match, and match-time pattern calls + stay pcall'd as belt-and-braces. **Validation is a stable, total + snapshot (Revision 8):** validated scalar fields are copied into + per-run plain tables via raw reads — mutating the user's rule + objects after `compile.run()` cannot alter the in-flight run, a + hostile `__index` is a counted skip rather than an error through + the pump, and the container traversal itself is protected + (5.2+ `ipairs` consults `__index`; LuaJIT reads raw — both + degrade cleanly). Capture indexes must be positive, FINITE + integers. One status note per run counts the skipped entries; a + later valid rule still matches; the per-frame pump never raises. + Shell-command slots never touch the rule table at all. +- Each match appends `{ file, line, col, severity, + line_start_byte }` to the run's ordered error list (reset per + run). Relative paths resolve against the run's cwd. + `pmacs.compile.errors()` exposes the current list (a getter, per + API conventions — public surface, not a test seam). +- Visiting pcalls `find_or_open` and reports failures as status (the + `visit_location` discipline). + +### Q#CM5 — Unified next-error: dispatcher with diagnostics fallback + +- New module-owned slot `pmacs.errors` with `claim(source)` where + `source = { name, next(), previous() }`. A compile run claims it on + spawn; a grep run claims it on search start. **Last claim wins** + (a deliberate simplification of Emacs's `next-error-last-buffer`). +- New commands `error.next` / `error.previous`: dispatch to the + claimed source; **when nothing has claimed, invoke + `diag.next`/`diag.previous`** — a user who never compiles or greps + sees exactly today's behavior. +- **Rebind mechanics:** duplicate bindings are rejected, so + compile.lua explicitly `pmacs.keymap.unbind`s `M-g n` and `M-g p`, + then binds the dispatchers (hence the Q#CM1 load-order contract). + `diag.next`/`diag.previous` remain as named commands (and keep + their wrap). `` C-x ` `` → `error.next`, unconditional. Acceptance + pins the final dispatch-level lookup of all three chords. +- Compile-source walk semantics: a per-run current index; stepping + past either end reports "no more errors" and stays (**no wrap**, + Emacs compile parity; the diag fallback keeps its documented + wrap). RET-visiting an error re-seats the index there. + +The alternative — leaving `M-g n/p` on diagnostics and giving compile +`M-g M-n`/`M-g M-p` — avoids touching a shipped binding but +permanently forks the Emacs muscle memory the lsp.lua comment itself +acknowledges. The dispatcher keeps one chord pair with a +behavior-preserving fallback. + +### Q#CM6 — Compilation buffer keys (buffer-local) + +`RET` visit the error on the cursor's line (status "no error on this +line" otherwise; jump ring included so `M-,` returns); `n`/`p` move +to the next/previous error *line* within the buffer (cursor motion +only, no visit); `g` recompile; `q` restore the previous buffer; +`C-c C-k` kill the running compile; all seven undo/redo chords +status no-ops (Q#CM2 resilience). `set_round_trip_input(buf, true)` +per Q#P6. No +auto-scroll: the cursor starts on the header and stays where the +user puts it (an auto-scroll option is deferred). + +### Q#CM7 — Grep-mode: upgrade `project.search` in place + +`*search-results*` becomes a first-class locations buffer: + +- Read-only intercept + bypass writes; RET/`n`/`p`/`q` buffer-local + keys, undo no-ops, and round-trip input, mirroring Q#CM6. +- Every streamed match appends both the formatted line and a + location entry — **no regex parsing**. Normalization: `line` is + 1-based → store `line - 1`; `match_start` is already a 0-based + byte offset within the line → store as col unchanged; `file` is + **relative to the search root** → resolve against the root used + for this search (not the cwd). +- **Kill-mid-search safety (Revision 3):** `pmacs.buffer.on_removed` + on the results buffer calls `stream:cancel()` (exists, + `async.lua:155`) and invalidates `active_search_id`; the + `on_batch`/`on_close` callbacks additionally guard + `buf:is_valid()` before writing (today they write through a stale + handle unguarded, `default.lua:693-703`). The next search + recreates the buffer. +- Every `on_batch`/`on_close` write runs the Q#CM2 revision check + **before** mutating the buffer, then records the post-write revision. + Checking only process-pump appends is insufficient: a no-hook edit to + `*search-results*` followed by a worker batch would otherwise advance + the expected revision and permanently mask the external edit. +- **The panel has the same immediate `buffer.after-edit` recovery + trigger as the compile slots (Revision 7):** after a COMPLETED + search no producer write or navigation may ever come, so an `M-x + buffer.undo` in the panel must be marked synchronously, not on the + next guarded operation. +- **Root retention across interactive supersedes (Revision 3):** the + panel stores the root each search ran with. Resolution order: + explicit `opts.root` > *if the active buffer is the results panel, + the panel's stored root* > `pmacs.project.detect(active buffer + path).root` > `"."`. Without the panel clause, the natural UI path + — search, land in the pathless panel, search again to supersede — + silently degrades the root to `"."`. +- Claims the error source on search start, so `M-g n` walks matches. +- Supersede semantics (`supersede = "search"`, late-batch dropping by + stream id) unchanged. + +### Q#CM8 — Shell-command: `M-!`, same machinery, no error claim + +`M-x shell.command` bound to `M-!`: prompt (history bucket +`"shell"`), run through the Q#CM3 shape (merged stderr, null stdin, +own group) into `*shell-command*` via the same streaming machinery +(read-only, exit marker, `q` restore, undo no-ops). Always async — a +separate `M-&` adds nothing (named deferral). Does **not** parse +errors or claim the error source. + +### Q#CM9 — Lifecycle: one live run per buffer slot, tombstoned teardown + +- One compilation at a time: `compile.run` while a run is live + group-SIGTERMs the old process (with the additions-#2 KILL + escalation backing it) and **tombstones** its pump entry: output + events for a tombstoned entry are dropped on arrival, but the + entry stays registered until its terminal event drains, at which + point it calls `pmacs.process.forget` and removes itself — + `forget` is only legal on terminated processes + (`process.rs:1109-1123`). The buffer resets and the new run starts + immediately; status notes the supersede. Same rule for + `*shell-command*`; grep supersedes at the worker layer. +- **Killed buffer:** `pmacs.buffer.on_removed` on each generated + buffer initiates prompt group termination of a live run; the pump + entry is tombstoned the same way. The pump also guards + `buf:is_valid()` defensively. The next run recreates the buffer. +- Post-condition either way: `pmacs.process.list()` returns to its + pre-run baseline once the terminal event has drained — now + **guaranteed** by the bounded TERM→KILL escalation (a + TERM-ignoring group can no longer stall the tombstone forever) — + pinned by acceptance. + +### Q#CM10 — Display and replication + +- Switch-in-place (Q#P2): `compile.run` switches the active window + to `*compilation*`; `q` restores. +- All writes are daemon-side Lua bypass edits — ordinary daemon-peer + CRDT ops; no optimistic path, no typed-edit provenance + involvement. One CRDT acceptance test pins mirror-replica + convergence of a full run. +- Undo in generated buffers: the shipped undo/redo chords are + neutralized buffer-locally; command/menu undo, accepted replica ops, + and no-hook programmatic edits are survived by the revision guard + (Q#CM2). The + *content* damage of a guard-recovered undo is accepted degraded + state (desync marker); making generated buffers truly immutable is + owned by the deferred real `read_only` flag (lsp-panels framing). + +### Q#CM11 — Interactive command contract + +- **`compile.run`** (the `M-x compile` equivalent; description says + so for discoverability): prompts via `pmacs.minibuffer.read` with + history bucket `"compile"` and `initial` = the stored previous + command. +- **`pmacs.compile.run(cmdline, opts)`** is the programmatic API + (`opts.cwd` override); the interactive command calls it. Each + successful start stores `{ cmdline, cwd }` as the recompile state + (session-scoped; persistence is a named deferral). +- **`compile.recompile`** (`g` in the buffer, also `M-x`-able): + reruns the stored state; errors with a pointed status if nothing + has been compiled yet. +- **`q`-target discipline:** the previous-buffer capture happens + only when the module switches in *from a buffer that is not one of + its own generated buffers* (listview's never-capture-a-panel + guard, extended). `g` therefore reruns without re-capturing — + compile → `g` → `q` restores the original buffer, pinned by + acceptance. +- **`shell.command`** (`M-!`): prompt with history bucket `"shell"`; + programmatic `pmacs.shell.command(cmdline, opts)`. + +## Bets + +- Pipes + `TERM=dumb` + child-boundary stderr merge + Lua-side ANSI + parsing cover real compiler output (colors when forced, progress + bars collapsed, no corruption, true arrival order) without PTY + complexity. +- The `group = true` lifecycle policy (spawn group, `-pid` signal, + liveness-probed TERM→KILL ledger, cancellable readers) reaps + `sh -c` trees without the old two-second/unbounded tick freeze or + thread/FD leaks; `setsid` remains the deliberate process-ownership + escape hatch, while its inherited output reader is still cancelled + within the same bounded drain cap. +- `exec 2>&1;` prefixed to the user's cmdline preserves per-command + redirection semantics while merging by default. +- Three starter rules cover cargo/rustc, gcc/clang, Python, and + grep-format lines; everything else is a user-added rule. +- The claim-based dispatcher preserves today's `M-g n/p` exactly for + non-compile users while giving compile/grep the Emacs chords. +- The revision-guard resync makes the pump total: no external edit — + undo included, same-length replaces included — can make it raise + or write out of bounds. +- Per-newline parsing + per-tick pump stay far inside the frame + budget (the REPL's 100 MB/s ingest discipline, minus its per-byte + hot path). + +## Deferred (named) + +PTY-mode compile variant (COLUMNS/forced-color env); auto-scroll +option (`compilation-scroll-output` analog); context-carrying +severity classifier (rustc header lines, traceback tails — starter +rules are navigable but uncolored there); severity threshold for +navigation (skip warnings); per-language/project default compile +commands; echo-area display of short shell-command output and a +distinct `M-&`; error parsing in `*shell-command*`; occur-mode; +split-window display of the compilation buffer; real `read_only` +buffer flag (owns full immutability of generated buffers; already +deferred in lsp-panels framing); persistence of the last compile +command across sessions; byte-accurate col cursor walk (inherited +lsp.lua residual); next-error across multiple historical result +buffers (only the claimed source is walkable); configurable +`GROUP_TERM_GRACE`; unifying poll-based cancellable readers across +non-group pipe consumers (REPL, LSP — tuned on the M6.6 ingest + gate, migrated separately if ever); fully tick-driven final drain + (the in-loop ledger enforcement plus escaped-writer cancellation + bounds the synchronous stall at approximately + `GROUP_TERM_GRACE + 2 * READER_SEND_POLL_INTERVAL`; eliminating it + entirely is a supervisor state-machine change). + +## Acceptance + +Suites: `tests/compile_mode_acceptance.rs` (dispatch-driven, pump via +the `run_with_pump` pattern) + `tests/compile_mode_crdt_acceptance.rs` +(small). Fixtures are `/bin/sh` scripts in a tempdir emitting +scripted output. Keybinding tests dispatch keys (never +`pmacs.command.invoke`), per the standing discipline. + +1. `compile.run` **spawns successfully** under the exact production + spec (pipes, `ansi = false`, `stdin = "null"`, `group = true`) + and streams a script's output into `*compilation*` (header with + command + cwd; exit marker with code; status). +2. Read-only: dispatched typing is rejected; buffer text unchanged. +3. stderr/stdout interleaving: a script alternating + `echo out; echo err >&2` lines yields the buffer in **emission + order** (would fail under per-tick stdout-then-stderr + coalescing). +4. EOF: a command that exits only at stdin EOF (`cat`) terminates + promptly with exit code 0 (would hang under piped stdin). +5. Group kill: a script that backgrounds a descendant + (`sleep 60 & echo $! > pidfile; wait`) — `C-c C-k` produces a + signaled exit marker AND the recorded descendant pid is dead + within the timeout (would survive under positive-pid SIGTERM). +6. **Leader-exit reap (Revision 3):** a script that backgrounds a + descendant and exits WITHOUT waiting + (`sleep 60 & echo $! > pidfile`) — the run completes promptly + (exit marker within a wall-clock bound well under the drain + timeout; the tick does not freeze), the descendant pid is dead, + and `pmacs.process.list()` returns to baseline. +7. **TERM→KILL escalation (Revision 3):** a script that traps + SIGTERM (`trap '' TERM; sleep 60`) — `C-c C-k` still yields a + terminal event within the escalation bound; baseline restored. +8. **Group-liveness ledger (Revision 4, bite):** a leader that + exits normally while a TERM-ignoring descendant redirects its + output away (`( trap '' TERM; exec >/dev/null 2>&1; sleep 60 ) & + echo $! > pidfile`) — the terminal event arrives and the readers + finish, yet the descendant pid is dead within the escalation + bound (fails under leader- or reader-conditioned escalation; + only the `kill(-pgid, 0)` probe catches it). +9. **In-drain enforcement, non-redirected twin (Revision 5):** the + same TERM-ignoring descendant but **keeping fd1 open** (no + redirect) — the group dies near the 500 ms grace bound, not the + ~2 s drain timeout, and the blocking tick's latency is bounded by + `GROUP_TERM_GRACE + 2 * READER_SEND_POLL_INTERVAL`, with a modest + wall-clock tolerance for test scheduling (fails without ledger + enforcement inside the drain loop). The supervisor-unit setsid + twin in item 34 pins the same bound when the original pgrp is + already ESRCH but an escaped writer still holds fd1. +10. Error parsing per starter rule: rustc-arrow, gcc-style + `file:line:col:`, Python `File "...", line N` fixtures produce + the expected `pmacs.compile.errors()` lists — including 0-based + normalization (a `foo.rs:3:5` diagnostic lands the cursor on + line index 2, col index 4), cwd-relative resolution, and + severity: gcc-style entries carry `error`/`warning`, + rustc-arrow and Python entries carry `nil`. +11. **Sub-1 coordinates fail closed (Revision 4):** a `foo.rs:0:0:` + line produces no error entry (would otherwise store -1 and land + the cursor silently at (0,0)). +12. **Custom-rule severity override (Revision 4):** a user rule + with `severity = "warning"` stamps every match "warning" even + when the line says `error:`; a rule with `severity = "fatal"` + is rejected as malformed (skipped, counted in the status note). +13. Unterminated final line: a fixture whose last diagnostic is + emitted via `printf` with no trailing `\n` still parses (bite: + fails without the terminal-event finalization). +14. Malformed rules: a non-table `pmacs.compile.rules` and a table + containing an invalid-pattern entry + a valid entry — no error + spam (clean `*errors*`), the valid entry still matches, one + status note. +15. RET on an error line visits the tempdir file at line/col; `M-,` + returns; RET on a non-error line reports status and stays. +16. `n`/`p` walk error lines in-buffer; no wrap, status at the ends. +17. Chord pins (dispatch-level): `M-g n`/`M-g p` reach the + dispatchers; `` C-x ` `` reaches `error.next`; after a compile + they walk compile errors in order across files; past the last: + "no more errors", no wrap. +18. Dispatcher fallback: `M-g n` with no claim falls through to + diagnostics (pathless scratch: the diag "no LSP server" status). +19. `g` recompiles: fresh buffer content, command actually + re-executed (script increments a counter file); old + style-overlay spans cleared. +20. compile → `g` → `q` restores the buffer that was active before + the *first* compile (q-target not re-captured). +21. `C-c C-k` kills a long-running script → signaled exit marker. +22. Supersede: `compile.run` during a live run terminates the old + group; no old-run output or exit marker lands after the reset; + `pmacs.process.list()` returns to baseline once drained. +23. **Undo/redo key aliases (Revision 4/6):** table-driven dispatch of + all seven shipped forms in `*compilation*` — `C-/`, `C-_`, `C-4`, + `C-x u`, `C-?`, `C-S-_`, and `C-x r` — produces the status no-op + and leaves text unchanged. `C-4` pins the raw-terminal-deliverable + single-key undo shape; the final three pin redo as well. +24. **Command-path undo after a completed run (Revision 5):** + `M-x buffer.undo` in `*shell-command*` AFTER the process has + exited — no pump event will ever arrive — yet the desync marker + appears immediately via the `buffer.after-edit` subscription + (bite: fails when recovery only runs at pump/anchor time). +25. **No-hook programmatic external edit (Revision 3–6):** the harness + evaluates Lua directly, outside dispatch and + `with_after_edit_check`, to perform a mid-stream bypass-intercept + shrink and a **same-length bypass replace that moves a newline**. + These are the actual no-hook producer the pump guard owns — + generated-buffer keystrokes round-trip, and accepted remote CRDT ops fire + `buffer.after-edit`. Both mutations trigger resync (the replace + bite fails under a length-only guard); the pump survives, appends + the desync marker, and continues streaming. All pre-marker anchors + drop, so `n`/`p` and RET initially report empty-state statuses + instead of using stale rows. The fixture then emits one new + diagnostic line after asserting the recovery marker is newline- + delimited: it receives a fresh post-marker anchor, and + `n`/`p` plus RET navigate that row correctly. A grep twin edits + `*search-results*` between batches and proves the next `on_batch` + detects the mismatch before its own append rather than masking it. +26. ANSI: a script emitting SGR color + CR progress yields final + text free of escape bytes, the progress line collapsed, AND — + attachment proven, not just span existence — a rendered TUI + cell in the active window carries the span's style; RET to a + file then `M-,` back retains styling (rides the jump-back + parity fix + after-switch re-attach). +27. Buffer killed mid-run (`on_removed` path): process terminated, + no error spam, `pmacs.process.list()` back to baseline; next + run recreates the buffer. +28. Grep: seeded tempdir; `project.search` results are read-only, + RET visits the match (root-relative path resolved, 0-based + landing), `M-g n` walks matches (source claimed); a second + search still supersedes the first. +29. **Grep kill-mid-search (Revision 3):** killing + `*search-results*` during an active stream cancels it — no + stale-handle writes, clean `*errors*` — and a subsequent search + recreates the buffer and works. +30. **Grep root retention (Revision 3):** an interactive search from + a project file, then a second interactive search issued from + inside the results panel (no `opts.root`) — both run with the + same root (would degrade to `"."` without the panel clause). +31. Shell-command: `M-!` output lands in `*shell-command*` with exit + marker; does not claim the error source. +32. `q` restores the previous buffer from all three buffers. +33. Rust-level: round-trip input set on the generated buffers. +34. Supervisor unit tests (in `src/process.rs`): `stdin = "null"` + yields immediate EOF; `group = true` spawns a distinct process + group; `terminate` signals group-wide with ledger-driven KILL + escalation on a TERM-trapping child; the liveness probe reaps a + TERM-ignoring survivor after a normal leader exit (unit twin of + acceptance 8); **repeated `terminate` calls do not extend the + ledger deadline** (earliest-deadline-wins, Revision 5); + **`shutdown()` force-kills outstanding ledger groups and probes + them to ESRCH** (drop-twin of acceptance 8, Revision 5); + **`maybe_restart` is inert once `shut_down`** — a + `restart = always` process does not respawn during teardown + (Revision 5); leader-exit reap enforces the original deadline for + a TERM-ignoring pipe-holding descendant (unit twin of acceptance + 9); a setsid'd descendant is not reaped, but teardown is bounded + well below `EXIT_OUTPUT_DRAIN_TIMEOUT` and **reclaims resources** — + repeated spawns with a still-open escaped writer all return through + the retained reader joins within the bound, and a per-runtime + `cfg(test)` active-reader counter returns to zero before cleanup + (deterministic proof that the joined threads ended and their owned + read FDs dropped, without racy process-global resource counts); each + escaped fixture pid is explicitly killed during test cleanup; both + options are rejected under PTY mode; `jump_back` binding fires + `buffer.after-switch` exactly + when the buffer changed. +35. CRDT: a full compile run converges byte-identically on a mirror + replica. A synthetic accepted replica edit to the generated buffer + also triggers the immediate recovery marker and converges on two + replicas even though the hook-produced marker may queue before the + source edit's rebroadcast (the established causal-reordering seam). + +Every reviewer finding in PR rounds gets a bite-verified fix (a test +observed failing without the fix), per the standing method. diff --git a/src/ansi.rs b/src/ansi.rs index 37f19fe..4455fe7 100644 --- a/src/ansi.rs +++ b/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 { + 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) { // 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" + ); + } } diff --git a/src/editor.rs b/src/editor.rs index ec22d4c..0f23c07 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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 { diff --git a/src/editor_core.rs b/src/editor_core.rs index 64b3211..0ef913b 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -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 diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 01bf0d2..0ad2752 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -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>(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, } 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::(); + 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::() + .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 mlua::Result { 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 mlua::Result { .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 = 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::("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 { 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); + } } diff --git a/src/overlay.rs b/src/overlay.rs index c079bb2..edc3b73 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -180,12 +180,30 @@ pub struct BufferStyleSpan { /// Shared span store used by Lua handles and render overlays. pub type SharedBufferStyleSpans = Arc>>; +/// 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 { + Some(style_store_identity(&self.spans)) + } +} + +impl View for BufferStyleOverlay { + fn kind(&self) -> &'static str { + "buffer_style_overlay" + } + + fn overlay_identity(&self) -> Option { + Some(style_store_identity(&self.spans)) + } + + fn clone_for_split(&self) -> Option> { + 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 { 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" + ); + } } diff --git a/src/process.rs b/src/process.rs index 307d56c..e7d04aa 100644 --- a/src/process.rs +++ b/src/process.rs @@ -160,6 +160,22 @@ impl ProcessMode { } } +/// Stdin disposition for a pipe-mode child. +/// +/// Compile-mode (Q#CM3) runs noninteractive commands that may probe +/// or read stdin (`cat`, tools that block on a tty check); `Null` +/// gives them immediate EOF from `/dev/null` with no writer thread +/// and no close-after-spawn race. PTY children have no separable +/// stdin, so `Null` is rejected at spawn under PTY mode. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StdinMode { + /// Piped writer thread (the default; see [`StdinWriter`]). + Piped, + /// `/dev/null`: immediate EOF; `write_stdin` errors with the + /// stdin-not-piped message. + Null, +} + /// What to do when a managed process terminates. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RestartPolicy { @@ -200,6 +216,19 @@ pub struct ProcessSpec { /// instead of raw stdout bytes. Opt-in so LSP and other byte-stream /// consumers keep their existing stdout/stderr contract. pub ansi_events: bool, + /// Stdin disposition (pipe-mode only; rejected under PTY). + pub stdin: StdinMode, + /// Compile-mode group lifecycle (Q#CM3; pipe-mode only, rejected + /// under PTY — PTY children already lead their own session). + /// When set: the child is spawned as the leader of a fresh + /// process group (`process_group(0)`), fatal signals are + /// group-directed (negative pid, mirroring the PTY branch of + /// [`signal_target`]), the group receives SIGTERM and enters the + /// liveness-probed reap ledger on the leader's terminal event, + /// and the generation's readers are poll-based and cancellable + /// so teardown is bounded even when an escaped descendant holds + /// the output pipe. + pub group: bool, } impl ProcessSpec { @@ -216,6 +245,8 @@ impl ProcessSpec { mode: ProcessMode::Pipes, restart: RestartPolicy::Never, ansi_events: false, + stdin: StdinMode::Piped, + group: false, } } } @@ -391,6 +422,16 @@ const READER_SEND_POLL_INTERVAL: Duration = Duration::from_millis(50); /// before the runtime handles are dropped. const EXIT_OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); +/// TERM→KILL escalation window for `group = true` process groups +/// (Q#CM3). Armed into the reap ledger when the group receives +/// SIGTERM — on explicit kill/supersede and on the leader's terminal +/// event — +/// and enforced both by the per-tick ledger probe and from inside +/// the group-aware final drain loop. Deliberately short: this is +/// child-tree cleanup, not polite application shutdown (the polite +/// TERM already went out when the window starts). +pub const GROUP_TERM_GRACE: Duration = Duration::from_millis(500); + // --------------------------------------------------------------------------- // Supervisor // --------------------------------------------------------------------------- @@ -409,8 +450,31 @@ pub struct ProcessSupervisor { /// exponential). restart_backoff: Duration, /// True once `shutdown()` has run; subsequent `spawn` calls - /// fail. + /// fail and `maybe_restart` is inert (a `restart = always` + /// process must not respawn mid-teardown). shut_down: bool, + /// Liveness-probed TERM→KILL reap ledger for `group = true` + /// process groups (Q#CM3). Keyed by pgid; independent of the + /// managed-process records so it survives `forget` and leader + /// exit. Armed insert-if-absent (earliest deadline wins — a + /// repeated TERM must not push the SIGKILL bound out). Probed + /// every tick with `kill(-pgid, 0)`: ESRCH drops the entry; + /// alive past the deadline SIGKILLs the group. `shutdown()` + /// force-kills outstanding entries and probes them to ESRCH + /// inside its bounded reap loop. + reap_ledger: HashMap, + /// TERM→KILL window used when arming the ledger. Constant + /// [`GROUP_TERM_GRACE`] in production; overridable in tests. + group_term_grace: Duration, +} + +/// One armed group in the reap ledger. +struct GroupReap { + /// When to SIGKILL the group if it still probes alive. + deadline: Instant, + /// SIGKILL already sent — keep probing to ESRCH but don't + /// re-kill every tick. + killed: bool, } struct ManagedProcess { @@ -442,6 +506,18 @@ struct RuntimeHandles { /// reader stuck in `send` (consumer fell behind) wakes promptly /// instead of leaking until the kernel ends the producer. cancel: Arc, + /// Live reader-thread count for this generation, maintained by + /// [`spawn_group_reader`] via a drop guard. Unit tests hold a + /// clone across teardown as the deterministic proof that the + /// joined threads ended and their owned read FDs dropped + /// (join-return alone cannot distinguish "never started", and a + /// process-global thread/FD count is racy under the parallel + /// test runner). Always present — one Arc and two atomics per + /// reader lifetime — because cfg-gating the field would spread + /// cfg attributes through every construction site; only the + /// probe accessor is test-gated, hence the not(test) allow. + #[cfg_attr(not(test), allow(dead_code))] + active_readers: Arc, } /// Byte budget for stdin data queued but not yet written, per @@ -614,6 +690,14 @@ fn signal_target(proc: &ManagedProcess, pid: u32) -> Result { { return Ok(Pid::from_raw(-pgrp)); } + // `group = true` pipe children lead a fresh process group + // (`process_group(0)` at spawn ⇒ pgid == pid), so fatal signals + // reach the whole `sh -c` tree — mirroring the PTY branch above + // (Q#CM3). + if proc.spec.group { + let pgid = i32::try_from(pid).map_err(|e| e.to_string())?; + return Ok(Pid::from_raw(-pgid)); + } Ok(Pid::from_raw( i32::try_from(pid).map_err(|e| e.to_string())?, )) @@ -710,6 +794,8 @@ impl ProcessSupervisor { grace_period: Duration::from_secs(2), restart_backoff: Duration::from_millis(250), shut_down: false, + reap_ledger: HashMap::new(), + group_term_grace: GROUP_TERM_GRACE, } } @@ -718,6 +804,11 @@ impl ProcessSupervisor { self.grace_period = d; } + /// Override the group TERM→KILL escalation window. Test helper. + pub fn set_group_term_grace(&mut self, d: Duration) { + self.group_term_grace = d; + } + /// Override the restart back-off. Test helper. pub fn set_restart_backoff(&mut self, d: Duration) { self.restart_backoff = d; @@ -808,6 +899,18 @@ impl ProcessSupervisor { pid, signaled_at: Instant::now(), }; + // Arm the group reap ledger on the first fatal signal + // (Q#CM3). Insert-if-absent: a repeated `terminate` must + // not push the SIGKILL bound out. + if proc.spec.group + && let Ok(pgid) = i32::try_from(pid) + { + let deadline = Instant::now() + self.group_term_grace; + self.reap_ledger.entry(pgid).or_insert(GroupReap { + deadline, + killed: false, + }); + } } Ok(()) } @@ -921,6 +1024,35 @@ impl ProcessSupervisor { self.poll_one(id); self.maybe_restart(id); } + // Probe the group reap ledger last so groups TERMed by this + // tick's poll_one get their liveness checked from the very + // next tick onward (Q#CM3). + self.tick_reap_ledger(); + } + + /// Probe every armed group: ESRCH → group gone, drop the entry; + /// alive past its deadline → SIGKILL the group (once), then keep + /// probing to ESRCH. Independent of managed-process records by + /// design — this is what catches a TERM-ignoring descendant that + /// survived its leader's clean exit with its output redirected + /// (round-3 finding 1: neither leader state nor reader state can + /// see that survivor; only group liveness can). + fn tick_reap_ledger(&mut self) { + let now = Instant::now(); + self.reap_ledger.retain(|pgid, entry| { + // ESRCH: no such group — done. Any other probe error is + // also treated as "nothing left we can reach" (EPERM + // cannot happen for our own children) so the ledger + // cannot grow without bound. + if nix::sys::signal::kill(Pid::from_raw(-*pgid), None).is_err() { + return false; + } + if now >= entry.deadline && !entry.killed { + let _ = nix::sys::signal::kill(Pid::from_raw(-*pgid), Some(Signal::SIGKILL)); + entry.killed = true; + } + true + }); } /// Drain the per-generation byte channel for `id` and emit at @@ -963,61 +1095,81 @@ impl ProcessSupervisor { let Some(runtime) = proc.runtime.as_mut() else { return; }; - match runtime.child.try_wait() { - Ok(None) => {} - Ok(Some(TermStatus::Exited(code))) => { - let now = Instant::now(); - let final_output = final_drain_runtime(runtime); - proc.state = ProcessState::Terminated(Termination::Exited { + let status = runtime.child.try_wait(); + if matches!(status, Ok(None)) { + return; + } + // Terminal from here on. Group leader-exit reap (Q#CM3): + // TERM the remaining group and arm the reap ledger BEFORE + // the final drain — a leader that exits leaving `sleep 60 &` + // holding the merged pipe would otherwise burn the full + // drain timeout and then block the reader join. Arming is + // insert-if-absent, so a deadline already armed by an + // explicit kill is not extended. + let group_ctx = if proc.spec.group { + i32::try_from(runtime.pid).ok().map(|pgid| { + let _ = nix::sys::signal::kill(Pid::from_raw(-pgid), Some(Signal::SIGTERM)); + let deadline = Instant::now() + self.group_term_grace; + let entry = self.reap_ledger.entry(pgid).or_insert(GroupReap { + deadline, + killed: false, + }); + GroupDrainCtx { + pgid, + deadline: entry.deadline, + } + }) + } else { + None + }; + let now = Instant::now(); + let final_output = final_drain_runtime(runtime, group_ctx); + let (termination, event) = match status { + Ok(Some(TermStatus::Exited(code))) => ( + Termination::Exited { code, started, ended: now, - }); - proc.runtime = None; - append_process_events(&mut self.pending, id, final_output, now); - self.pending.entry(id).or_default().push(ProcessEvent { - id, - kind: ProcessEventKind::Exited { code }, - at: now, - }); - } - Ok(Some(TermStatus::Signaled(signal))) => { - let now = Instant::now(); - let final_output = final_drain_runtime(runtime); - proc.state = ProcessState::Terminated(Termination::Signaled { + }, + ProcessEventKind::Exited { code }, + ), + Ok(Some(TermStatus::Signaled(signal))) => ( + Termination::Signaled { signal: signal.clone(), started, ended: now, - }); - proc.runtime = None; - append_process_events(&mut self.pending, id, final_output, now); - self.pending.entry(id).or_default().push(ProcessEvent { - id, - kind: ProcessEventKind::Signaled { signal }, - at: now, - }); - } - Err(e) => { - let now = Instant::now(); - let final_output = final_drain_runtime(runtime); - proc.state = ProcessState::Terminated(Termination::Crashed { + }, + ProcessEventKind::Signaled { signal }, + ), + Err(e) => ( + Termination::Crashed { error: e.clone(), ended: now, - }); - proc.runtime = None; - append_process_events(&mut self.pending, id, final_output, now); - self.pending.entry(id).or_default().push(ProcessEvent { - id, - kind: ProcessEventKind::Crashed { error: e }, - at: now, - }); - } - } + }, + ProcessEventKind::Crashed { error: e }, + ), + // Guarded above; kept explicit so the match stays total. + Ok(None) => return, + }; + proc.state = ProcessState::Terminated(termination); + proc.runtime = None; + append_process_events(&mut self.pending, id, final_output, now); + self.pending.entry(id).or_default().push(ProcessEvent { + id, + kind: event, + at: now, + }); } /// Apply restart policy after `poll_one` may have transitioned /// the process to `Terminated`. fn maybe_restart(&mut self, id: ProcessId) { + // Inert during and after shutdown: shutdown's own tick() + // calls must not respawn a `restart = always` process + // mid-teardown (round-4 finding 1). + if self.shut_down { + return; + } let now = Instant::now(); let restart_now = { let Some(proc) = self.processes.get(&id) else { @@ -1155,12 +1307,25 @@ impl ProcessSupervisor { let _ = self.signal(*id, Signal::SIGKILL); } } + // Editor exit owes group survivors no grace: force-kill every + // outstanding reap-ledger entry now, then probe it to ESRCH in + // the bounded loop below. Without this, a pre-deadline ledger + // (leader exited promptly, TERM-ignoring group member alive) + // would be silently discarded at Drop and leak the member + // (Q#CM3, round-4 finding 1). + for (pgid, entry) in &mut self.reap_ledger { + let _ = nix::sys::signal::kill(Pid::from_raw(-*pgid), Some(Signal::SIGKILL)); + entry.killed = true; + } // Final reap loop. SIGKILL is delivered immediately by the // kernel; the child becomes a zombie until we reap. Bound // the wait so a pathological case can't hang the editor - // exit forever. + // exit forever. The tick also probes the reap ledger, so the + // loop holds until force-killed groups observe ESRCH. let final_deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < final_deadline && self.any_running() { + while Instant::now() < final_deadline + && (self.any_running() || !self.reap_ledger.is_empty()) + { self.tick(); std::thread::sleep(Duration::from_millis(20)); } @@ -1176,6 +1341,23 @@ impl ProcessSupervisor { ) }) } + + /// Clone of a live generation's active-reader counter (see + /// [`RuntimeHandles::active_readers`]). Unit tests grab it while + /// the generation runs and assert zero after teardown. + #[cfg(test)] + fn active_reader_probe(&self, id: ProcessId) -> Option> { + Some(Arc::clone( + &self.processes.get(&id)?.runtime.as_ref()?.active_readers, + )) + } + + /// Number of armed reap-ledger entries. Test observability for + /// the shutdown/drop-twin pins. + #[cfg(test)] + fn reap_ledger_len(&self) -> usize { + self.reap_ledger.len() + } } impl Drop for ProcessSupervisor { @@ -1201,6 +1383,17 @@ fn build_runtime(spec: &ProcessSpec, id: ProcessId) -> Result build_pipes_runtime(spec, id), ProcessMode::Pty { rows, cols, mode } => build_pty_runtime(spec, id, rows, cols, mode), @@ -1212,9 +1405,20 @@ fn build_pipes_runtime(spec: &ProcessSpec, _id: ProcessId) -> Result Stdio::piped(), + // Immediate EOF, no writer thread, zero close-after-spawn + // race (Q#CM3). + StdinMode::Null => Stdio::null(), + }) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if spec.group { + // Fresh process group with the child as leader (pgid == pid). + // Safe std API — no `unsafe`, no trampoline (stable 1.64). + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } if let Some(ref cwd) = spec.cwd { cmd.current_dir(cwd); } @@ -1231,22 +1435,38 @@ fn build_pipes_runtime(spec: &ProcessSpec, _id: ProcessId) -> Result(BYTE_CHUNK_CHANNEL_CAP); let cancel = Arc::new(AtomicBool::new(false)); + let active_readers = Arc::new(AtomicUsize::new(0)); let mut readers = Vec::new(); if let Some(out) = stdout { - readers.push(spawn_reader( - byte_tx.clone(), - Arc::clone(&cancel), - out, - ReaderKind::Stdout, - )); + readers.push(if spec.group { + spawn_group_reader( + byte_tx.clone(), + Arc::clone(&cancel), + out, + ReaderKind::Stdout, + Arc::clone(&active_readers), + ) + } else { + spawn_reader( + byte_tx.clone(), + Arc::clone(&cancel), + out, + ReaderKind::Stdout, + ) + }); } if let Some(err) = stderr { - readers.push(spawn_reader( - byte_tx, - Arc::clone(&cancel), - err, - ReaderKind::Stderr, - )); + readers.push(if spec.group { + spawn_group_reader( + byte_tx, + Arc::clone(&cancel), + err, + ReaderKind::Stderr, + Arc::clone(&active_readers), + ) + } else { + spawn_reader(byte_tx, Arc::clone(&cancel), err, ReaderKind::Stderr) + }); } Ok(RuntimeHandles { child: ChildHandle::Pipes(child), @@ -1255,6 +1475,7 @@ fn build_pipes_runtime(spec: &ProcessSpec, _id: ProcessId) -> Result( }) } +/// RAII live-count for group reader threads: increments on +/// construction, decrements on every exit path (panic included), so +/// [`RuntimeHandles::active_readers`] reaching zero is a +/// deterministic "thread ended, its read FD dropped" signal. +struct ActiveReaderGuard(Arc); + +impl ActiveReaderGuard { + fn new(counter: Arc) -> Self { + counter.fetch_add(1, Ordering::Relaxed); + Self(counter) + } +} + +impl Drop for ActiveReaderGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Poll-based cancellable reader for `group = true` generations +/// (Q#CM3). Unlike [`spawn_reader`], the fd is set nonblocking and +/// every wait — for readability or for channel space — re-checks +/// `cancel` each [`READER_SEND_POLL_INTERVAL`], with an extra check +/// between poll and read/send, so `RuntimeHandles::Drop`'s retained +/// join completes within one interval regardless of who still holds +/// the pipe's write end (a setsid'd descendant, notably). Non-group +/// consumers (REPL, LSP) keep the blocking [`spawn_reader`] they +/// were tuned on — the M6.6 ingest gate; unifying is a named +/// deferral in the compile-mode framing. +fn spawn_group_reader( + byte_tx: Sender, + cancel: Arc, + read: R, + kind: ReaderKind, + active: Arc, +) -> JoinHandle<()> +where + R: Read + std::os::fd::AsFd + Send + 'static, +{ + std::thread::spawn(move || { + let _guard = ActiveReaderGuard::new(active); + let mut read = read; + // nix 0.29's fcntl still takes a RawFd (poll takes BorrowedFd). + let raw_fd = std::os::fd::AsRawFd::as_raw_fd(&read.as_fd()); + if nix::fcntl::fcntl( + raw_fd, + nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_NONBLOCK), + ) + .is_err() + { + // Cannot go nonblocking (does not happen for pipe fds in + // practice): exit rather than risk an uncancellable + // blocking read. + return; + } + let poll_timeout = nix::poll::PollTimeout::try_from(READER_SEND_POLL_INTERVAL) + .unwrap_or(nix::poll::PollTimeout::MAX); + let mut buf = [0u8; BYTE_CHUNK_SIZE]; + loop { + if cancel.load(Ordering::Relaxed) { + return; + } + let ready = { + let mut fds = [nix::poll::PollFd::new( + read.as_fd(), + nix::poll::PollFlags::POLLIN, + )]; + nix::poll::poll(&mut fds, poll_timeout) + }; + match ready { + // Timeout or interrupt: loop around and re-check the + // cancel flag. + Ok(0) | Err(nix::errno::Errno::EINTR) => continue, + Ok(_) => {} + Err(_) => return, + } + if cancel.load(Ordering::Relaxed) { + return; + } + match read.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + let mut payload: ByteChunk = (kind, buf[..n].to_vec()); + loop { + match byte_tx.send_timeout(payload, READER_SEND_POLL_INTERVAL) { + Ok(()) => break, + Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => { + if cancel.load(Ordering::Relaxed) { + return; + } + payload = rejected; + } + Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => { + return; + } + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return, + } + } + }) +} + fn drain_raw_output(byte_rx: &Receiver) -> Vec { let mut stdout_buf: Vec = Vec::new(); let mut stderr_buf: Vec = Vec::new(); @@ -1536,16 +1866,61 @@ fn drain_runtime_output(rt: &RuntimeHandles) -> Vec { } } -fn final_drain_runtime(rt: &RuntimeHandles) -> Vec { +/// Context for a group-aware final drain (Q#CM3). Carries the reap +/// ledger's deadline for this group: the drain enforces it from +/// inside its loop because no other tick runs while the drain +/// blocks the frame. +#[derive(Clone, Copy)] +struct GroupDrainCtx { + pgid: i32, + deadline: Instant, +} + +fn final_drain_runtime(rt: &RuntimeHandles, group: Option) -> Vec { let deadline = Instant::now() + EXIT_OUTPUT_DRAIN_TIMEOUT; let mut out = Vec::new(); + // Group drains get tighter bounds than the plain byte-flush + // timeout (Q#CM3, round-4 finding 2 / round-5 revision): + // - the ledger deadline is enforced in-loop — SIGKILL the group + // at the grace bound; + // - once the group probes ESRCH, readers get one quiescent + // READER_SEND_POLL_INTERVAL to flush already-read and + // kernel-buffered bytes; new data resets the window; + // - independently, no group drain may pass the absolute cancel + // deadline of ledger deadline + one poll interval — reaching + // it cancels the readers even when an escaped (setsid'd) + // writer still holds the pipe past its group's death. Honest + // trailing output gets a bounded flush; escaped output may be + // truncated. The retained join in RuntimeHandles::Drop then + // completes within one further poll interval because group + // readers are poll-based and observe the cancel flag. + let mut group_killed = false; + let mut last_data = Instant::now(); loop { let drained = drain_runtime_output(rt); let drained_any = !drained.is_empty(); out.extend(drained); + if drained_any { + last_data = Instant::now(); + } if rt.readers.iter().all(std::thread::JoinHandle::is_finished) && !drained_any { return out; } + if let Some(ctx) = &group { + let now = Instant::now(); + let group_alive = nix::sys::signal::kill(Pid::from_raw(-ctx.pgid), None).is_ok(); + if group_alive && now >= ctx.deadline && !group_killed { + let _ = nix::sys::signal::kill(Pid::from_raw(-ctx.pgid), Some(Signal::SIGKILL)); + group_killed = true; + } + let quiesced = + !group_alive && now.duration_since(last_data) >= READER_SEND_POLL_INTERVAL; + if quiesced || now >= ctx.deadline + READER_SEND_POLL_INTERVAL { + rt.cancel.store(true, Ordering::Relaxed); + out.extend(drain_runtime_output(rt)); + return out; + } + } if Instant::now() >= deadline { return out; } @@ -2314,4 +2689,459 @@ mod tests { ); handle.join().expect("test thread should exit cleanly"); } + + // ----------------------------------------------------------------- + // Compile-mode group lifecycle (Q#CM3; framing acceptance 34) + // ----------------------------------------------------------------- + + fn sh_group_spec(label: &str, script: &str) -> ProcessSpec { + let mut spec = ProcessSpec::new(label, "/bin/sh"); + spec.args = vec!["-c".into(), script.to_owned()]; + spec.stdin = StdinMode::Null; + spec.group = true; + spec + } + + fn started_pid(events: &[ProcessEvent]) -> Option { + events.iter().find_map(|e| match e.kind { + ProcessEventKind::Started { pid } => Some(pid), + _ => None, + }) + } + + fn stdout_contains(events: &[ProcessEvent], needle: &[u8]) -> bool { + let mut all = Vec::new(); + for e in events { + if let ProcessEventKind::Stdout(b) = &e.kind { + all.extend_from_slice(b); + } + } + all.windows(needle.len()).any(|w| w == needle) + } + + fn pid_alive(pid: i32) -> bool { + nix::sys::signal::kill(Pid::from_raw(pid), None).is_ok() + } + + /// Process group of `pid` via `ps` (portable across Linux and + /// macOS CI — the previous /proc//stat read has no macOS + /// equivalent; `ps -o pgid=` avoids widening the nix feature set + /// with `process` for `getpgid`). + fn pgid_of(pid: u32) -> i32 { + let out = std::process::Command::new("ps") + .args(["-o", "pgid=", "-p", &pid.to_string()]) + .output() + .expect("run ps"); + String::from_utf8_lossy(&out.stdout) + .trim() + .parse() + .expect("pgid parses") + } + + /// True when `name` resolves on PATH. Fixture-dependency gate: + /// the setsid escape-hatch test needs util-linux's setsid(1), + /// absent on macOS — skip per-test rather than fail (the + /// `m6_5_repl_acceptance` selective-skip precedent). + fn binary_available(name: &str) -> bool { + std::process::Command::new("which") + .arg(name) + .output() + .is_ok_and(|o| o.status.success()) + } + + /// Fixture: background a TERM-ignoring survivor and let the + /// leader exit only after the survivor's trap is INSTALLED + /// (readiness file). Without the gate, a slow scheduler (macOS + /// CI, observed) can deliver the leader-exit group-TERM before + /// the subshell's `trap` runs — killing the "survivor": flaky + /// red for tests that need it alive, vacuous green for tests + /// that assert its death. `redirect` sheds the survivor's + /// stdout/stderr (the acceptance-8 shape); without it the + /// survivor keeps fd1 (the acceptance-9 shape). Returns + /// (script, pidfile). + fn survivor_script(dir: &std::path::Path, redirect: bool) -> (String, std::path::PathBuf) { + let pidfile = dir.join("pid"); + let ready = dir.join("ready"); + let redirect_part = if redirect { + "exec >/dev/null 2>&1; " + } else { + "" + }; + let script = format!( + "( trap '' TERM; : > {ready}; {redirect_part}sleep 30 ) & echo $! > {pid}; \ + while [ ! -e {ready} ]; do sleep 0.01; done", + ready = ready.display(), + pid = pidfile.display(), + ); + (script, pidfile) + } + + /// Poll `path` until it holds a parseable pid. Fixture scripts + /// write descendant pids there. + fn wait_pidfile(path: &std::path::Path) -> i32 { + let stop = Instant::now() + Duration::from_secs(5); + while Instant::now() < stop { + if let Ok(s) = std::fs::read_to_string(path) + && let Ok(pid) = s.trim().parse::() + { + return pid; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("pidfile {} never appeared", path.display()); + } + + #[test] + fn stdin_null_yields_immediate_eof() { + let mut sup = ProcessSupervisor::new(); + // `cat` exits only at stdin EOF; under piped stdin this test + // would hang until the drain deadline killed it. (Framing + // acceptance 34 / round-1 finding 3.) + let spec = sh_group_spec("eof-test", "cat; echo done"); + let id = sup.spawn(spec).expect("spawn"); + let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + assert!( + events + .iter() + .any(|e| matches!(e.kind, ProcessEventKind::Exited { code: 0 })), + "cat must see EOF and exit 0; events: {events:?}" + ); + assert!( + stdout_contains(&events, b"done"), + "post-cat echo must run; events: {events:?}" + ); + } + + #[test] + fn group_true_spawns_distinct_process_group() { + let mut sup = ProcessSupervisor::new(); + let id = sup + .spawn(sh_group_spec("group-test", "sleep 30")) + .expect("spawn"); + let events = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { + started_pid(evs).is_some() + }); + let pid = started_pid(&events).expect("Started event"); + assert_eq!( + pgid_of(pid), + i32::try_from(pid).unwrap(), + "group child must lead its own process group (pgid == pid)" + ); + // Control: a non-group child inherits the test process's + // group instead of leading its own. + let mut plain = ProcessSpec::new("plain", "/bin/sh"); + plain.args = vec!["-c".into(), "sleep 30".into()]; + let plain_id = sup.spawn(plain).expect("spawn plain"); + let plain_events = drain_until(&mut sup, plain_id, Duration::from_secs(2), |evs| { + started_pid(evs).is_some() + }); + let plain_pid = started_pid(&plain_events).expect("Started event"); + assert_ne!( + pgid_of(plain_pid), + i32::try_from(plain_pid).unwrap(), + "non-group child must not lead its own group" + ); + sup.terminate(id).ok(); + sup.terminate(plain_id).ok(); + let _ = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + let _ = drain_until(&mut sup, plain_id, Duration::from_secs(5), has_exited); + } + + #[test] + fn terminate_group_escalates_to_sigkill_on_term_trapping_child() { + let mut sup = ProcessSupervisor::new(); + sup.set_group_term_grace(Duration::from_millis(200)); + // Readiness echo: terminating before the trap is installed + // would let plain SIGTERM win and vacuously pass. + let id = sup + .spawn(sh_group_spec( + "trap-test", + "trap '' TERM; echo ready; sleep 30", + )) + .expect("spawn"); + let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { + stdout_contains(evs, b"ready") + }); + let t0 = Instant::now(); + sup.terminate(id).expect("terminate"); + let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + let elapsed = t0.elapsed(); + assert!( + events.iter().any(|e| matches!( + &e.kind, + ProcessEventKind::Signaled { signal } if signal == "SIGKILL" + )), + "TERM-trapping child must fall to the ledger's SIGKILL; events: {events:?}" + ); + assert!( + elapsed < Duration::from_millis(1500), + "escalation must land near the 200ms grace, not the 2s drain timeout; took {elapsed:?}" + ); + } + + #[test] + fn liveness_probe_reaps_term_ignoring_survivor_after_leader_exit() { + // Unit twin of framing acceptance 8: the survivor ignores + // TERM *and* sheds its stdout/stderr, so the leader's + // terminal event arrives and the readers finish — only the + // ledger's kill(-pgid, 0) probe can catch it. + let dir = tempfile::tempdir().expect("tempdir"); + let mut sup = ProcessSupervisor::new(); + sup.set_group_term_grace(Duration::from_millis(200)); + let (script, pidfile) = survivor_script(dir.path(), true); + let id = sup + .spawn(sh_group_spec("survivor", &script)) + .expect("spawn"); + let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + assert!( + events + .iter() + .any(|e| matches!(e.kind, ProcessEventKind::Exited { code: 0 })), + "leader must exit cleanly; events: {events:?}" + ); + let survivor = wait_pidfile(&pidfile); + // The ledger fires on subsequent ticks — keep ticking. + let stop = Instant::now() + Duration::from_secs(3); + while Instant::now() < stop && pid_alive(survivor) { + sup.tick(); + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + !pid_alive(survivor), + "TERM-ignoring redirected survivor must be SIGKILLed by the ledger probe" + ); + // Ledger converges to empty once the group probes ESRCH. + let stop = Instant::now() + Duration::from_secs(2); + while Instant::now() < stop && sup.reap_ledger_len() > 0 { + sup.tick(); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(sup.reap_ledger_len(), 0, "ledger must drain to empty"); + } + + #[test] + fn repeated_terminate_does_not_extend_ledger_deadline() { + let mut sup = ProcessSupervisor::new(); + sup.set_group_term_grace(Duration::from_millis(500)); + let id = sup + .spawn(sh_group_spec( + "re-term", + "trap '' TERM; echo ready; sleep 30", + )) + .expect("spawn"); + let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { + stdout_contains(evs, b"ready") + }); + let t0 = Instant::now(); + sup.terminate(id).expect("terminate"); + // Re-terminate at half the grace window: with plain + // HashMap::insert arming, this would reset the 500ms clock + // and push SIGKILL past 800ms. + std::thread::sleep(Duration::from_millis(300)); + sup.tick(); + sup.terminate(id).expect("re-terminate"); + let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + let elapsed = t0.elapsed(); + assert!( + events.iter().any(|e| matches!( + &e.kind, + ProcessEventKind::Signaled { signal } if signal == "SIGKILL" + )), + "must escalate; events: {events:?}" + ); + assert!( + elapsed < Duration::from_millis(750), + "earliest deadline must win: SIGKILL by ~500ms, not 800ms; took {elapsed:?}" + ); + } + + #[test] + fn shutdown_force_kills_outstanding_ledger_groups() { + // Drop-twin of framing acceptance 8 (round-4 finding 1): the + // grace is long enough that the ledger cannot fire on its + // own — only shutdown's force-kill can reap the survivor. + let dir = tempfile::tempdir().expect("tempdir"); + let mut sup = ProcessSupervisor::new(); + sup.set_group_term_grace(Duration::from_secs(30)); + let (script, pidfile) = survivor_script(dir.path(), true); + let id = sup + .spawn(sh_group_spec("survivor", &script)) + .expect("spawn"); + let _ = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + let survivor = wait_pidfile(&pidfile); + assert!(pid_alive(survivor), "survivor alive pre-shutdown"); + assert!(sup.reap_ledger_len() > 0, "ledger armed pre-shutdown"); + sup.shutdown(); + assert!( + !pid_alive(survivor), + "shutdown must force-kill outstanding ledger groups" + ); + assert_eq!( + sup.reap_ledger_len(), + 0, + "shutdown must probe forced kills to ESRCH" + ); + } + + #[test] + fn maybe_restart_inert_once_shut_down() { + let mut sup = ProcessSupervisor::new(); + sup.set_restart_backoff(Duration::from_millis(30)); + let mut spec = ProcessSpec::new("restarter", "/bin/sh"); + spec.args = vec!["-c".into(), "echo x".into()]; + spec.restart = RestartPolicy::Always; + let id = sup.spawn(spec).expect("spawn"); + // Prove the policy is live: observe at least one restart. + let events = drain_until(&mut sup, id, Duration::from_secs(5), |evs| { + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Restarting { .. })) + }); + assert!( + events + .iter() + .any(|e| matches!(e.kind, ProcessEventKind::Restarting { .. })), + "restart=always must restart pre-shutdown; events: {events:?}" + ); + sup.shutdown(); + let _ = sup.take_events(id); + // Give a reset restart-backoff window plenty of room, then + // confirm no respawn happened during or after teardown. + for _ in 0..8 { + sup.tick(); + std::thread::sleep(Duration::from_millis(20)); + } + let after = sup.take_events(id); + assert!( + !after.iter().any(|e| matches!( + e.kind, + ProcessEventKind::Restarting { .. } | ProcessEventKind::Started { .. } + )), + "restart accounting must be inert once shut down; events: {after:?}" + ); + } + + #[test] + fn leader_exit_reap_bounds_drain_with_pipe_holding_descendant() { + // Unit twin of framing acceptance 9: the descendant ignores + // TERM and KEEPS fd1, so the readers stay alive and the old + // drain would block ~2s per EXIT_OUTPUT_DRAIN_TIMEOUT (and + // then the join would hang). In-drain ledger enforcement + // SIGKILLs at the grace bound instead. Readiness-gated so an + // early leader-exit TERM can't reap the holder and let the + // bound hold vacuously. + let dir = tempfile::tempdir().expect("tempdir"); + let mut sup = ProcessSupervisor::new(); + sup.set_group_term_grace(Duration::from_millis(300)); + let (script, _pidfile) = survivor_script(dir.path(), false); + let id = sup.spawn(sh_group_spec("holder", &script)).expect("spawn"); + let stop = Instant::now() + Duration::from_secs(5); + let mut max_tick = Duration::ZERO; + let mut events = Vec::new(); + while Instant::now() < stop && !has_exited(&events) { + let t = Instant::now(); + sup.tick(); + max_tick = max_tick.max(t.elapsed()); + events.append(&mut sup.take_events(id)); + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + has_exited(&events), + "leader exit must be observed; events: {events:?}" + ); + assert!( + max_tick < Duration::from_millis(1200), + "the blocking tick must be bounded by ~grace + 2 poll intervals, \ + not the 2s drain timeout; max tick {max_tick:?}" + ); + } + + #[test] + fn setsid_escapee_is_not_reaped_and_teardown_reclaims_readers() { + // The setsid'd descendant leaves the group (the deliberate + // daemonization escape hatch) while inheriting fd1, so it + // holds the pipe after its old group is ESRCH. The + // quiescence/cancel cap must bound the drain, the retained + // joins must complete, and the per-runtime active-reader + // count must return to zero — across repeated cycles, so + // nothing accumulates. + if !binary_available("setsid") { + // util-linux's setsid(1) is absent on macOS CI; the + // escape hatch is a Linux-production behavior. Skip + // rather than fail — the other group-lifecycle tests + // still run everywhere. + eprintln!("skipping: setsid(1) not on PATH"); + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + let mut escapees = Vec::new(); + for round in 0..3 { + let pidfile = dir.path().join(format!("pid{round}")); + let mut sup = ProcessSupervisor::new(); + sup.set_group_term_grace(Duration::from_millis(300)); + let script = format!( + "setsid /bin/sh -c 'echo $$ > {}; exec sleep 30' & echo started", + pidfile.display() + ); + let id = sup.spawn(sh_group_spec("escapee", &script)).expect("spawn"); + let ready = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { + started_pid(evs).is_some() + }); + assert!(started_pid(&ready).is_some(), "Started must arrive"); + let probe = sup.active_reader_probe(id).expect("live runtime probe"); + let t0 = Instant::now(); + let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + let elapsed = t0.elapsed(); + assert!( + has_exited(&events), + "leader exit must be observed; events: {events:?}" + ); + assert!( + elapsed < Duration::from_millis(1500), + "escaped-writer drain must be cancelled at the cap, \ + not ride the 2s timeout; took {elapsed:?}" + ); + assert_eq!( + probe.load(Ordering::Relaxed), + 0, + "reader threads must have ended and dropped their FDs" + ); + let escapee = wait_pidfile(&pidfile); + assert!( + pid_alive(escapee), + "setsid escapee must NOT be reaped (deliberate escape hatch)" + ); + escapees.push(escapee); + } + // Fixture owns the escapees the supervisor deliberately + // does not: kill them explicitly. + for pid in escapees { + let _ = nix::sys::signal::kill(Pid::from_raw(pid), Some(Signal::SIGKILL)); + } + } + + #[test] + fn group_and_null_stdin_rejected_under_pty() { + let mut sup = ProcessSupervisor::new(); + let mut spec = ProcessSpec::new("pty-null", "/bin/sh"); + spec.mode = ProcessMode::default_pty(); + spec.stdin = StdinMode::Null; + let err = sup + .spawn(spec) + .expect_err("stdin=null must be rejected under pty"); + assert!( + err.contains("pipe mode"), + "error points at pipe mode: {err}" + ); + + let mut spec = ProcessSpec::new("pty-group", "/bin/sh"); + spec.mode = ProcessMode::default_pty(); + spec.group = true; + let err = sup + .spawn(spec) + .expect_err("group=true must be rejected under pty"); + assert!( + err.contains("pipe mode"), + "error points at pipe mode: {err}" + ); + } } diff --git a/src/view.rs b/src/view.rs index 139c599..313f411 100644 --- a/src/view.rs +++ b/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 { + 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> { + None + } } // --------------------------------------------------------------------------- diff --git a/src/window.rs b/src/window.rs index 316ecc4..22add47 100644 --- a/src/window.rs +++ b/src/window.rs @@ -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) { + 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 diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs new file mode 100644 index 0000000..56be46c --- /dev/null +++ b/tests/compile_mode_acceptance.rs @@ -0,0 +1,2751 @@ +//! Compile-mode acceptance (Arc 5 stage 1, +//! docs/compile-mode-framing.md, items 1–33; item 34 lives as unit +//! tests in src/process.rs, item 35 in +//! `tests/compile_mode_crdt_acceptance.rs`). +//! +//! Dispatch-driven: every keybinding claim is exercised through +//! `dispatch_key` (never `pmacs.command.invoke`), per the standing +//! discipline — a dead binding must fail these tests. Process output +//! is pumped through `tick_processes` (the production +//! `process.after-tick` path); grep streams through `tick_async`. +//! Fixtures are `/bin/sh` scripts materialized in tempdirs. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use std::path::Path; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Harness (auto_pair_acceptance conventions + m6_5 pump pattern) +// --------------------------------------------------------------------------- + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn ctrl(s: &mut EditorState, c: char) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(c), KeyModifiers::CONTROL), + ); +} + +fn ctrl_shift(s: &mut EditorState, c: char) { + s.dispatch_key( + FrontendId::LOCAL, + key( + KeyCode::Char(c), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + ); +} + +fn alt(s: &mut EditorState, c: char) { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT)); +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +fn errors_buffer(s: &EditorState) -> String { + s.lua_host.errors_buffer_text() +} + +/// Fresh editor with LSP spawning disabled (language detection still +/// works; the after-load hook must not exec real servers). +fn editor() -> EditorState { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + s +} + +fn write_script(dir: &Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p.display().to_string() +} + +/// Text of the buffer named `name`, or empty when absent. +fn named_text(s: &EditorState, name: &str) -> String { + let b: mlua::String = eval( + s, + &format!( + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == {name:?} then + return id:slice(0, id:len()) + end + end + return "" + "# + ), + ); + String::from_utf8_lossy(&b.as_bytes()).into_owned() +} + +fn active_buffer_name(s: &EditorState) -> String { + eval( + s, + "return pmacs.describe.buffer(pmacs.window.buffer()).name", + ) +} + +fn compilation_text(s: &EditorState) -> String { + named_text(s, "*compilation*") +} + +fn process_count(s: &EditorState) -> i64 { + eval(s, "return #pmacs.process.list()") +} + +/// Drive frames until `pred` holds. Pumps both the process +/// supervisor (compile/shell) and the async runtime (grep workers). +fn pump_until( + s: &mut EditorState, + timeout_ms: u64, + mut pred: impl FnMut(&EditorState) -> bool, +) -> bool { + let stop = Instant::now() + Duration::from_millis(timeout_ms); + loop { + if pred(s) { + return true; + } + if Instant::now() >= stop { + return false; + } + s.tick_processes(); + s.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + } +} + +/// Start a compile run programmatically with an explicit cwd. +fn compile_run(s: &EditorState, cmdline: &str, cwd: &Path) { + exec( + s, + &format!( + "pmacs.compile.run({cmdline:?}, {{ cwd = {:?} }})", + cwd.display().to_string() + ), + ); +} + +/// Run `cmdline` and pump to its exit marker. Panics on timeout. +fn compile_and_finish(s: &mut EditorState, cmdline: &str, cwd: &Path) { + compile_run(s, cmdline, cwd); + assert!( + pump_until(s, 10_000, |s| compilation_text(s).contains("[compile ")), + "compile run must reach its exit marker; buffer:\n{}", + compilation_text(s) + ); +} + +/// Poll `path` for a pid the fixture script wrote there. +fn wait_pidfile(s: &mut EditorState, path: &Path) -> i32 { + let mut pid = None; + pump_until(s, 5_000, |_| { + if let Ok(body) = std::fs::read_to_string(path) + && let Ok(p) = body.trim().parse::() + { + pid = Some(p); + return true; + } + false + }); + pid.expect("fixture pidfile must appear") +} + +fn pid_alive(pid: i32) -> bool { + // `kill -0` probe via /bin/kill: portable across Linux and macOS + // (a /proc existence check has no macOS equivalent and would + // make every "descendant is dead" assertion vacuously true + // there). + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .output() + .is_ok_and(|o| o.status.success()) +} + +/// Errors getter marshalled to a comparable Rust shape (encoded as +/// one line per entry — mlua tuples don't implement `FromLua`). +fn compile_errors(s: &EditorState) -> Vec<(String, i64, i64, Option)> { + let encoded: String = eval( + s, + r#" + local out = {} + for _, e in ipairs(pmacs.compile.errors()) do + out[#out + 1] = string.format("%s|%d|%d|%s", + e.file, e.line, e.col, e.severity or "-") + end + return table.concat(out, "\n") + "#, + ); + encoded + .lines() + .map(|l| { + let mut parts = l.split('|'); + let file = parts.next().unwrap().to_owned(); + let line = parts.next().unwrap().parse().unwrap(); + let col = parts.next().unwrap().parse().unwrap(); + let sev = match parts.next().unwrap() { + "-" => None, + s => Some(s.to_owned()), + }; + (file, line, col, sev) + }) + .collect() +} + +/// Rendered cells of the active window (copied from the +/// `m4_acceptance` grid helper — cross-crate test code can't import). +fn render_active_window_to_grid( + state: &mut EditorState, + rows: u32, + cols: u32, +) -> Vec { + use pmacs::cell::{Cell, CellGrid, CellSize}; + use pmacs::view::{View, Viewport}; + use pmacs::window::Rect; + + let mut core = state.core.borrow_mut(); + let active = core.active_window_id(); + let registry = core.registry.clone(); + let win = core.windows.get_mut(&active).expect("active window"); + let rect = Rect::new(0, 0, rows, cols); + let cell_count = (rect.size.rows * rect.size.cols) as usize; + let mut backing = vec![Cell::default(); cell_count]; + let reg = registry.borrow(); + let buf = reg.get(win.buffer_id).expect("buffer in registry"); + let viewport = Viewport { + buffer_start: 0, + buffer_end: buf.len(), + cell_origin: rect.origin, + cell_size: CellSize::new(rect.size.rows, rect.size.cols), + gutter_w: 0, + }; + let mut grid = CellGrid { + cells: &mut backing, + stride: rect.size.cols, + size: CellSize::new(rect.size.rows, rect.size.cols), + }; + win.text_view.render(buf, viewport, &mut grid); + for overlay in &mut win.overlays { + overlay.render(buf, viewport, &mut grid); + } + backing +} + +fn any_styled_cell(cells: &[pmacs::cell::Cell]) -> bool { + cells + .iter() + .any(|c| c.style != pmacs::cell::Style::default()) +} + +/// `(glyph, fg)` for the first `n` cells of the grid row whose glyphs +/// spell `prefix`; panics if no row matches. Round-5 tests pin exact +/// per-cell colors — `any_styled_cell` cannot see a wrong color on +/// the right glyph. +fn styled_row( + cells: &[pmacs::cell::Cell], + rows: u32, + cols: u32, + prefix: &str, + n: usize, +) -> Vec<(char, pmacs::cell::Color)> { + let glyph_at = |r: u32, c: u32| match cells[(r * cols + c) as usize].glyph { + pmacs::cell::Glyph::Char(ch) => ch, + _ => ' ', + }; + for r in 0..rows { + let line: String = (0..cols).map(|c| glyph_at(r, c)).collect(); + if line.starts_with(prefix) { + return (0..n) + .map(|i| { + let cell = &cells[(r * cols) as usize + i]; + (glyph_at(r, i as u32), cell.style.fg) + }) + .collect(); + } + } + panic!("no rendered row starts with {prefix:?}"); +} + +const DESYNC: &str = "[output desynced by external edit]"; + +// --------------------------------------------------------------------------- +// 1–4: spawn shape, read-only, merged interleaving, EOF +// --------------------------------------------------------------------------- + +#[test] +fn acc01_spawn_streams_header_output_and_exit_marker() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + compile_and_finish(&mut s, "printf 'hello\\nworld\\n'", dir.path()); + let text = compilation_text(&s); + assert!( + text.starts_with("$ printf"), + "header leads with the command; got:\n{text}" + ); + assert!( + text.contains(&format!("Directory: {}", dir.path().display())), + "header names the resolved cwd; got:\n{text}" + ); + assert!(text.contains("hello\nworld\n"), "output streamed:\n{text}"); + assert!( + text.contains("[compile exited with code 0]"), + "exit marker with code:\n{text}" + ); + assert!( + status(&s).contains("finished"), + "completion status; got: {}", + status(&s) + ); + assert_eq!(active_buffer_name(&s), "*compilation*", "switch-in-place"); +} + +#[test] +fn acc02_buffer_is_read_only_under_dispatch() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + compile_and_finish(&mut s, "echo out", dir.path()); + let before = compilation_text(&s); + type_str(&mut s, "x"); + assert_eq!( + compilation_text(&s), + before, + "dispatched typing must be rejected by the read-only intercept" + ); +} + +#[test] +fn acc03_stderr_interleaves_in_emission_order() { + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "mix.sh", + "echo out1\necho err1 >&2\necho out2\necho err2 >&2\n", + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let text = compilation_text(&s); + assert!( + text.contains("out1\nerr1\nout2\nerr2\n"), + "child-boundary merge preserves emission order (per-tick \ + stdout-then-stderr coalescing would reorder); got:\n{text}" + ); +} + +#[test] +fn acc04_stdin_eof_lets_cat_terminate() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + let t0 = Instant::now(); + compile_and_finish(&mut s, "cat; echo done", dir.path()); + assert!( + compilation_text(&s).contains("\ndone\n"), + "cat must see EOF and fall through (line-start match — the \ + header echoes the command and would match bare 'done')" + ); + assert!( + compilation_text(&s).contains("exited with code 0"), + "clean exit" + ); + assert!( + t0.elapsed() < Duration::from_secs(5), + "must not hang on piped stdin" + ); +} + +// --------------------------------------------------------------------------- +// 5–9: group lifecycle through the editor surface +// --------------------------------------------------------------------------- + +#[test] +fn acc05_kill_reaps_backgrounded_descendant() { + let dir = tempfile::tempdir().unwrap(); + let pidfile = dir.path().join("pid"); + let mut s = editor(); + compile_run( + &s, + &format!("sleep 30 & echo $! > {}; wait", pidfile.display()), + dir.path(), + ); + let pid = wait_pidfile(&mut s, &pidfile); + ctrl(&mut s, 'c'); + ctrl(&mut s, 'k'); + assert!( + pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile killed by")), + "kill must produce a signaled exit marker; buffer:\n{}", + compilation_text(&s) + ); + assert!( + pump_until(&mut s, 3_000, |_| !pid_alive(pid)), + "group-directed kill must reap the backgrounded descendant \ + (positive-pid SIGTERM would strand it)" + ); + assert!( + pump_until(&mut s, 3_000, |s| process_count(s) == 0), + "process list returns to baseline" + ); +} + +#[test] +fn acc06_leader_exit_without_wait_completes_promptly_and_reaps() { + let dir = tempfile::tempdir().unwrap(); + let pidfile = dir.path().join("pid"); + let mut s = editor(); + let t0 = Instant::now(); + compile_run( + &s, + &format!("sleep 30 & echo $! > {}", pidfile.display()), + dir.path(), + ); + assert!( + pump_until(&mut s, 5_000, |s| compilation_text(s) + .contains("[compile exited")), + "leader exit must be observed without waiting on the descendant" + ); + assert!( + t0.elapsed() < Duration::from_millis(2500), + "the run must not ride the 2s drain timeout; took {:?}", + t0.elapsed() + ); + let pid = wait_pidfile(&mut s, &pidfile); + assert!( + pump_until(&mut s, 3_000, |_| !pid_alive(pid)), + "leader-exit reap must kill the pipe-holding descendant" + ); + assert!( + pump_until(&mut s, 3_000, |s| process_count(s) == 0), + "process list returns to baseline" + ); +} + +#[test] +fn acc07_term_trapping_child_falls_to_sigkill() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + compile_run(&s, "trap '' TERM; echo ready; sleep 30", dir.path()); + // "\nready\n" — the OUTPUT line, not the header's echo of the + // command (matching the header raced the kill ahead of the trap + // installation and let plain SIGTERM win). + assert!( + pump_until(&mut s, 5_000, |s| compilation_text(s).contains("\nready\n")), + "trap must be installed before we kill" + ); + let t0 = Instant::now(); + ctrl(&mut s, 'c'); + ctrl(&mut s, 'k'); + assert!( + pump_until(&mut s, 5_000, |s| compilation_text(s) + .contains("killed by SIGKILL")), + "TERM-trapping child must fall to the ledger's SIGKILL; buffer:\n{}", + compilation_text(&s) + ); + assert!( + t0.elapsed() < Duration::from_secs(2), + "escalation lands near the 500ms grace, not the drain timeout; took {:?}", + t0.elapsed() + ); + assert!( + pump_until(&mut s, 3_000, |s| process_count(s) == 0), + "baseline restored" + ); +} + +/// Fixture: background a TERM-ignoring survivor; the leader exits +/// only after the survivor's trap is INSTALLED (readiness file). +/// Without the gate, a slow scheduler (macOS CI, observed) delivers +/// the leader-exit group-TERM before the subshell's `trap` runs and +/// kills the "survivor" — making these assertions vacuous. Mirrors +/// the process.rs unit fixture. +fn survivor_cmdline(dir: &Path, redirect: bool) -> (String, std::path::PathBuf) { + let pidfile = dir.join("pid"); + let ready = dir.join("ready"); + let redirect_part = if redirect { + "exec >/dev/null 2>&1; " + } else { + "" + }; + let cmdline = format!( + "( trap '' TERM; : > {ready}; {redirect_part}sleep 30 ) & echo $! > {pid}; \ + while [ ! -e {ready} ]; do sleep 0.01; done", + ready = ready.display(), + pid = pidfile.display(), + ); + (cmdline, pidfile) +} + +#[test] +fn acc08_ledger_reaps_term_ignoring_redirected_survivor() { + // The bite: the survivor ignores TERM and sheds its output, so + // the terminal event arrives AND the readers finish — only the + // liveness probe can catch it. + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + let (cmdline, pidfile) = survivor_cmdline(dir.path(), true); + compile_run(&s, &cmdline, dir.path()); + assert!( + pump_until(&mut s, 5_000, |s| compilation_text(s) + .contains("exited with code 0")), + "leader exits cleanly" + ); + let pid = wait_pidfile(&mut s, &pidfile); + assert!( + pump_until(&mut s, 3_000, |_| !pid_alive(pid)), + "the kill(-pgid, 0) probe must reap the redirected survivor \ + (leader- or reader-conditioned escalation never fires here)" + ); +} + +#[test] +fn acc09_pipe_holding_survivor_bounded_tick_latency() { + // Non-redirected twin of acc08: the descendant KEEPS fd1, so the + // readers stay alive — in-drain ledger enforcement must SIGKILL + // at the grace bound instead of blocking ~2s. + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + let (cmdline, _pidfile) = survivor_cmdline(dir.path(), false); + compile_run(&s, &cmdline, dir.path()); + let stop = Instant::now() + Duration::from_secs(6); + let mut max_tick = Duration::ZERO; + let mut done = false; + while Instant::now() < stop { + let t = Instant::now(); + s.tick_processes(); + max_tick = max_tick.max(t.elapsed()); + if compilation_text(&s).contains("[compile exited") { + done = true; + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(done, "run must complete; buffer:\n{}", compilation_text(&s)); + assert!( + max_tick < Duration::from_millis(1200), + "blocking tick bounded by ~grace + 2 poll intervals, not the \ + 2s drain timeout; max tick {max_tick:?}" + ); +} + +// --------------------------------------------------------------------------- +// 10–14: error parsing +// --------------------------------------------------------------------------- + +#[test] +fn acc10_starter_rules_parse_and_normalize() { + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "diag.sh", + concat!( + "printf 'error[E0308]: mismatched types\\n'\n", + "printf ' --> src/foo.rs:3:5\\n'\n", + "printf 'foo.c:7:2: warning: unused variable\\n'\n", + "printf 'Traceback (most recent call last):\\n'\n", + "printf ' File \"bar.py\", line 9\\n'\n", + ), + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let errors = compile_errors(&s); + assert_eq!( + errors, + vec![ + // rustc arrow: 1-based 3:5 → 0-based (2,4); the arrow + // line carries no severity token → nil (navigable but + // uncolored, per Q#CM4). + ("src/foo.rs".to_owned(), 2, 4, None), + // gcc-style colocates the keyword → sniffed severity. + ("foo.c".to_owned(), 6, 1, Some("warning".to_owned())), + // Python frame: no column → col 0; no severity token. + ("bar.py".to_owned(), 8, 0, None), + ], + "starter-rule parse + 0-based normalization" + ); +} + +#[test] +fn acc11_sub_one_coordinates_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + compile_and_finish(&mut s, "printf 'foo.rs:0:0: error: boom\\n'", dir.path()); + assert!( + compile_errors(&s).is_empty(), + "a 0:0 capture must be discarded, not stored as -1; got {:?}", + compile_errors(&s) + ); +} + +#[test] +fn acc12_custom_rule_severity_override_and_malformed_rejection() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + exec( + &s, + r#" + pmacs.compile.rules = { + { pattern = "x", file = 1, line = 1, severity = "fatal" }, + { pattern = "(z%.txt):(%d+):", file = 1, line = 2, severity = "warning" }, + } + "#, + ); + // The skip note is a transient status set at run start; capture + // it before the completion status overwrites it. + compile_run(&s, "printf 'z.txt:5: error: boom\\n'", dir.path()); + assert!( + status(&s).contains("skipped 1 malformed"), + "the severity=\"fatal\" entry is rejected and counted; got: {}", + status(&s) + ); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); + let errors = compile_errors(&s); + assert_eq!( + errors, + vec![("z.txt".to_owned(), 4, 0, Some("warning".to_owned()))], + "the severity field overrides the sniffed 'error' keyword" + ); +} + +#[test] +fn acc13_unterminated_final_line_still_parses() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + // No trailing newline: complete at EOF, parsed at the terminal + // event (bite: fails without the finalization pass). + compile_and_finish(&mut s, "printf 'x.c:3:1: error: no newline'", dir.path()); + assert_eq!( + compile_errors(&s), + vec![("x.c".to_owned(), 2, 0, Some("error".to_owned()))], + "final unterminated diagnostic must not be dropped" + ); +} + +#[test] +fn acc14_malformed_rule_containers_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + // (a) top-level non-table degrades to the built-in defaults. + let mut s = editor(); + exec(&s, "pmacs.compile.rules = 42"); + compile_run(&s, "printf 'a.c:1:1: error: e\\n'", dir.path()); + assert!( + status(&s).contains("not a table"), + "one degradation note at run start; got: {}", + status(&s) + ); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); + assert_eq!( + compile_errors(&s).len(), + 1, + "built-in defaults still parse under a non-table container" + ); + assert!( + errors_buffer(&s).is_empty(), + "no error spam: {}", + errors_buffer(&s) + ); + + // (b) an invalid-pattern entry is skipped; a later valid entry + // still matches; one status note counts the skip. + let mut s = editor(); + exec( + &s, + r#" + pmacs.compile.rules = { + { pattern = "([", file = 1, line = 2 }, + { pattern = "(b%.c):(%d+):", file = 1, line = 2 }, + } + "#, + ); + compile_run(&s, "printf 'b.c:4: error: e\\n'", dir.path()); + assert!( + status(&s).contains("skipped 1 malformed"), + "one status note at run start; got: {}", + status(&s) + ); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); + assert_eq!( + compile_errors(&s), + vec![("b.c".to_owned(), 3, 0, Some("error".to_owned()))], + "the valid entry still matches after the malformed one" + ); + assert!( + errors_buffer(&s).is_empty(), + "no error spam: {}", + errors_buffer(&s) + ); +} + +// --------------------------------------------------------------------------- +// 15–18: navigation +// --------------------------------------------------------------------------- + +/// Fixture: a target file plus a compile run reporting one error at +/// target.c:3:2. Returns the editor, finished, in *compilation*. +fn error_fixture(dir: &Path) -> EditorState { + std::fs::write(dir.join("target.c"), "l1\nl2\nl3 body\nl4\n").unwrap(); + let mut s = editor(); + compile_and_finish(&mut s, "printf 'target.c:3:2: error: boom\\n'", dir); + s +} + +#[test] +fn acc15_ret_visits_error_and_jump_back_returns() { + let dir = tempfile::tempdir().unwrap(); + let mut s = error_fixture(dir.path()); + // Cursor starts on the header (row 0): RET there reports and + // stays. + press(&mut s, KeyCode::Enter); + assert_eq!(status(&s), "no error on this line"); + assert_eq!(active_buffer_name(&s), "*compilation*"); + // n lands on the diagnostic row; RET visits at 0-based (2,1). + press(&mut s, KeyCode::Char('n')); + press(&mut s, KeyCode::Enter); + assert!( + active_buffer_name(&s).ends_with("target.c"), + "RET visits the file; active: {}", + active_buffer_name(&s) + ); + let line: i64 = eval(&s, "return pmacs.editor.cursor_line()"); + let col: i64 = eval(&s, "return pmacs.editor.cursor_col()"); + assert_eq!((line, col), (2, 1), "0-based landing from 1-based 3:2"); + // M-, returns to the compilation buffer (jump ring). + alt(&mut s, ','); + assert_eq!(active_buffer_name(&s), "*compilation*"); +} + +#[test] +fn acc16_n_p_walk_error_lines_without_wrap() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + compile_and_finish( + &mut s, + "printf 'a.c:1:1: error: one\\nplain line\\nb.c:2:2: error: two\\n'", + dir.path(), + ); + let row_of = |s: &EditorState| -> i64 { eval(s, "return pmacs.editor.cursor_line()") }; + press(&mut s, KeyCode::Char('n')); + let first = row_of(&s); + press(&mut s, KeyCode::Char('n')); + let second = row_of(&s); + assert!(second > first, "n walks forward between error lines"); + press(&mut s, KeyCode::Char('n')); + assert_eq!(status(&s), "no more errors", "no wrap at the end"); + assert_eq!(row_of(&s), second, "cursor stays"); + press(&mut s, KeyCode::Char('p')); + assert_eq!(row_of(&s), first, "p walks back"); + press(&mut s, KeyCode::Char('p')); + assert_eq!(status(&s), "no more errors", "no wrap at the start"); +} + +#[test] +fn acc17_chords_walk_compile_errors_across_files() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("one.c"), "a\nb\n").unwrap(); + std::fs::write(dir.path().join("two.c"), "c\nd\ne\n").unwrap(); + let mut s = editor(); + compile_and_finish( + &mut s, + "printf 'one.c:1:1: error: e1\\ntwo.c:3:1: error: e2\\n'", + dir.path(), + ); + // M-g n visits the first error, then the second, then reports. + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('n')); + assert!(active_buffer_name(&s).ends_with("one.c"), "first error"); + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('n')); + assert!(active_buffer_name(&s).ends_with("two.c"), "second error"); + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('n')); + assert_eq!(status(&s), "no more errors", "no wrap past the last"); + assert!(active_buffer_name(&s).ends_with("two.c"), "stays put"); + // M-g p walks back. + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('p')); + assert!(active_buffer_name(&s).ends_with("one.c"), "previous error"); + // C-x ` is the classic chord for the same dispatcher. + ctrl(&mut s, 'x'); + press(&mut s, KeyCode::Char('`')); + assert!( + active_buffer_name(&s).ends_with("two.c"), + "C-x ` = error.next" + ); +} + +#[test] +fn acc18_dispatcher_falls_back_to_diagnostics_without_a_claim() { + let mut s = editor(); + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('n')); + assert_eq!( + status(&s), + "diag: no LSP server for active buffer", + "with no compile/grep claim, M-g n must reach diag.next \ + (today's behavior preserved exactly)" + ); +} + +// --------------------------------------------------------------------------- +// 19–22: recompile, q-target, kill, supersede +// --------------------------------------------------------------------------- + +#[test] +fn acc19_g_recompiles_and_clears_overlay_spans() { + let dir = tempfile::tempdir().unwrap(); + let counter = dir.path().join("count"); + let mut s = editor(); + // First run emits a severity-colored diagnostic (overlay span); + // each run appends to the counter file. + compile_and_finish( + &mut s, + &format!( + "echo run >> {}; printf 'a.c:1:1: error: colored\\n'", + counter.display() + ), + dir.path(), + ); + assert!(any_styled_cell(&render_active_window_to_grid( + &mut s, 8, 60 + ))); + assert_eq!( + std::fs::read_to_string(&counter).unwrap().lines().count(), + 1 + ); + // g re-runs the stored command. + press(&mut s, KeyCode::Char('g')); + assert!( + pump_until(&mut s, 10_000, |_| { + std::fs::read_to_string(&counter).is_ok_and(|c| c.lines().count() == 2) + }), + "recompile must actually re-execute the command" + ); + // The rerun executes the SAME stored command, so its output + // carries the same diagnostic; per-run reset is pinned by + // checking the fresh run reached its own marker with + // exactly one diagnostic parsed (not accumulated). + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); + assert_eq!( + compile_errors(&s).len(), + 1, + "error list resets per run (not accumulated)" + ); +} + +#[test] +fn acc20_compile_g_q_restores_the_original_buffer() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("origin.txt"), "home\n").unwrap(); + let mut s = editor(); + exec( + &s, + &format!( + "pmacs.buffer.find_or_open({:?})", + dir.path().join("origin.txt").display().to_string() + ), + ); + compile_and_finish(&mut s, "echo one", dir.path()); + press(&mut s, KeyCode::Char('g')); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); + press(&mut s, KeyCode::Char('q')); + assert!( + active_buffer_name(&s).ends_with("origin.txt"), + "q restores the pre-compile buffer even after g (q-target \ + not re-captured); active: {}", + active_buffer_name(&s) + ); +} + +#[test] +fn acc21_kill_produces_signaled_marker() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + compile_run(&s, "echo running; sleep 30", dir.path()); + assert!(pump_until(&mut s, 5_000, |s| compilation_text(s) + .contains("\nrunning\n"))); + ctrl(&mut s, 'c'); + ctrl(&mut s, 'k'); + assert!( + pump_until(&mut s, 5_000, |s| compilation_text(s) + .contains("[compile killed by SIGTERM]")), + "plain kill: SIGTERM marker; buffer:\n{}", + compilation_text(&s) + ); +} + +#[test] +fn acc22_supersede_resets_and_returns_to_baseline() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + assert_eq!(process_count(&s), 0); + compile_run(&s, "echo first-run-output; sleep 30", dir.path()); + assert!(pump_until(&mut s, 5_000, |s| compilation_text(s) + .contains("\nfirst-run-output\n"))); + compile_run(&s, "echo second-run-output", dir.path()); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited with code 0]"))); + let text = compilation_text(&s); + assert!( + !text.contains("\nfirst-run-output\n"), + "old-run output must not land after the reset:\n{text}" + ); + assert!( + !text.contains("killed by"), + "the superseded run's exit marker must not land either:\n{text}" + ); + assert!( + text.contains("\nsecond-run-output\n"), + "new run streams:\n{text}" + ); + assert!( + pump_until(&mut s, 5_000, |s| process_count(s) == 0), + "both generations forgotten once drained" + ); +} + +// --------------------------------------------------------------------------- +// 23–25: undo surfaces and the revision guard +// --------------------------------------------------------------------------- + +#[test] +fn acc23_all_seven_undo_redo_chords_are_status_noops() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + compile_and_finish(&mut s, "echo out", dir.path()); + let before = compilation_text(&s); + + let check = |s: &mut EditorState, label: &str| { + assert_eq!( + status(s), + "generated buffer: undo disabled", + "{label} must reach the buffer-local no-op" + ); + assert_eq!(compilation_text(s), before, "{label} must not edit"); + exec(s, "pmacs.editor.set_status('')"); + }; + + ctrl(&mut s, '/'); + check(&mut s, "C-/"); + ctrl(&mut s, '_'); + check(&mut s, "C-_"); + ctrl(&mut s, '4'); + check(&mut s, "C-4 (raw-terminal single-key undo)"); + ctrl(&mut s, 'x'); + press(&mut s, KeyCode::Char('u')); + check(&mut s, "C-x u"); + ctrl(&mut s, '?'); + check(&mut s, "C-? (redo)"); + ctrl_shift(&mut s, '_'); + check(&mut s, "C-S-_ (redo)"); + ctrl(&mut s, 'x'); + press(&mut s, KeyCode::Char('r')); + check(&mut s, "C-x r (redo)"); +} + +#[test] +fn acc24_command_path_undo_after_completed_run_recovers_immediately() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + exec( + &s, + &format!( + "pmacs.shell.command('echo shell-out', {{ cwd = {:?} }})", + dir.path().display().to_string() + ), + ); + assert!(pump_until(&mut s, 10_000, |s| named_text( + s, + "*shell-command*" + ) + .contains("[shell exited with code 0]"))); + // M-x buffer.undo: the command path rebinding cannot reach. No + // pump event will ever arrive — recovery must come from the + // buffer.after-edit subscription, synchronously. + alt(&mut s, 'x'); + type_str(&mut s, "buffer.undo"); + press(&mut s, KeyCode::Enter); + let text = named_text(&s, "*shell-command*"); + assert!( + text.contains(DESYNC), + "desync marker must appear immediately (bite: fails when \ + recovery only runs at pump/anchor time); buffer:\n{text}" + ); + assert!( + errors_buffer(&s).is_empty(), + "clean *errors*: {}", + errors_buffer(&s) + ); +} + +#[test] +fn acc25a_no_hook_shrink_mid_stream_recovers_and_reanchors() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("b.c"), "x\ny\nz\n").unwrap(); + let script = write_script( + dir.path(), + "slow.sh", + "printf 'a.c:1:1: error: one\\n'\nsleep 1\nprintf 'b.c:2:1: error: two\\n'\n", + ); + let mut s = editor(); + compile_run(&s, &format!("sh {script}"), dir.path()); + assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains(": one"))); + let first_row: i64 = { + // The first diagnostic's row, while its anchor is live. + press(&mut s, KeyCode::Char('n')); + eval(&s, "return pmacs.editor.cursor_line()") + }; + // Harness-eval mutation: outside dispatch, outside + // with_after_edit_check — the actual no-hook producer the pump + // guard owns. + exec( + &s, + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*compilation*" then + id:delete(id:len() - 3, id:len(), { bypass_intercept = true }) + end + end + "#, + ); + assert!( + pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited")), + "the pump must survive and finish; buffer:\n{}", + compilation_text(&s) + ); + let text = compilation_text(&s); + assert!( + text.contains(&format!("\n{DESYNC}\n")), + "newline-delimited marker; buffer:\n{text}" + ); + assert!( + text.find(DESYNC).unwrap() < text.find("two").unwrap(), + "streaming continued after the marker:\n{text}" + ); + assert!( + errors_buffer(&s).is_empty(), + "no spam: {}", + errors_buffer(&s) + ); + // Pre-marker anchor dropped: RET on the old row reports. + exec(&s, "pmacs.editor.goto_byte(0)"); + let target = first_row; + exec( + &s, + &format!("for _ = 1, {target} do pmacs.editor.move_down() end"), + ); + press(&mut s, KeyCode::Enter); + assert_eq!(status(&s), "no error on this line", "stale anchor dropped"); + // Fresh epoch: the post-marker diagnostic gets a working anchor. + exec(&s, "pmacs.editor.goto_byte(0)"); + press(&mut s, KeyCode::Char('n')); + press(&mut s, KeyCode::Enter); + assert!( + active_buffer_name(&s).ends_with("b.c"), + "post-marker diagnostic navigates; active: {}", + active_buffer_name(&s) + ); +} + +#[test] +fn acc25b_same_length_newline_moving_replace_is_caught() { + // The length-guard killer: content changes, length doesn't, and + // a newline moves so every anchor stays in bounds but rows lie. + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "slow.sh", + "printf 'a.c:1:1: error: one\\n'\nsleep 1\nprintf 'done\\n'\n", + ); + let mut s = editor(); + compile_run(&s, &format!("sh {script}"), dir.path()); + assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains(": one"))); + // Replace "one\n" with "one!" — same length, newline moved. + exec( + &s, + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*compilation*" then + local text = id:slice(0, id:len()) + local at = text:find("one\n", 1, true) + id:replace(at - 1, at + 3, "one!", { bypass_intercept = true }) + end + end + "#, + ); + assert!( + pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited")), + "pump survives; buffer:\n{}", + compilation_text(&s) + ); + assert!( + compilation_text(&s).contains(DESYNC), + "revision guard catches what a length guard provably misses:\n{}", + compilation_text(&s) + ); +} + +// --------------------------------------------------------------------------- +// 26: ANSI + rendered styling + M-, retention +// --------------------------------------------------------------------------- + +#[test] +fn acc26_ansi_renders_styled_cells_and_survives_jump_back() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("x.c"), "a\nb\n").unwrap(); + let script = write_script( + dir.path(), + "color.sh", + concat!( + "printf '\\033[31mredtext\\033[0m plain\\n'\n", + "printf 'progress 1\\rprogress 2\\n'\n", + "printf 'x.c:1:1: error: e\\n'\n", + ), + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let text = compilation_text(&s); + assert!(!text.contains('\u{1b}'), "no escape bytes:\n{text}"); + assert!(text.contains("redtext"), "SGR text survives:\n{text}"); + assert!( + text.contains("progress 2") && !text.contains("progress 1"), + "CR progress collapsed in place:\n{text}" + ); + // Attachment proven: a rendered cell in the ACTIVE WINDOW + // carries style (handle spans alone would pass even when + // attach_style_overlay was never called). + assert!( + any_styled_cell(&render_active_window_to_grid(&mut s, 10, 60)), + "rendered TUI cell must carry the SGR span's style" + ); + // RET to the file and M-, back must retain styling (rides the + // jump_back after-switch parity + re-attach subscription). + press(&mut s, KeyCode::Char('n')); + press(&mut s, KeyCode::Enter); + assert!(active_buffer_name(&s).ends_with("x.c")); + alt(&mut s, ','); + assert_eq!(active_buffer_name(&s), "*compilation*"); + assert!( + any_styled_cell(&render_active_window_to_grid(&mut s, 10, 60)), + "styling must survive the RET → M-, round trip" + ); +} + +// --------------------------------------------------------------------------- +// 27: killed buffer mid-run +// --------------------------------------------------------------------------- + +#[test] +fn acc27_killed_buffer_terminates_run_and_recreates() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + compile_run(&s, "echo alive; sleep 30", dir.path()); + assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains("\nalive\n"))); + exec( + &s, + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*compilation*" then + pmacs.buffer.remove(id) + end + end + "#, + ); + assert!( + pump_until(&mut s, 5_000, |s| process_count(s) == 0), + "run terminated and forgotten after buffer death" + ); + assert!( + errors_buffer(&s).is_empty(), + "no spam: {}", + errors_buffer(&s) + ); + // The next run recreates the buffer and completes. + compile_and_finish(&mut s, "echo reborn", dir.path()); + assert!(compilation_text(&s).contains("reborn")); +} + +// --------------------------------------------------------------------------- +// 28–30: grep-mode +// --------------------------------------------------------------------------- + +fn grep_fixture(dir: &Path) { + std::fs::create_dir_all(dir.join(".git")).unwrap(); + std::fs::write( + dir.join("f.txt"), + "top\nzqxvbn_needle_77 here\nzqxvbn_needle_77 again\n", + ) + .unwrap(); +} + +fn search(s: &EditorState, query: &str, root: &Path) { + exec( + s, + &format!( + "pmacs.project.search({query:?}, {{ root = {:?} }})", + root.display().to_string() + ), + ); +} + +fn search_done(s: &EditorState) -> bool { + named_text(s, "*search-results*").contains("-- search ") +} + +#[test] +fn acc28_grep_panel_is_a_locations_buffer() { + let dir = tempfile::tempdir().unwrap(); + grep_fixture(dir.path()); + let mut s = editor(); + search(&s, "zqxvbn_needle_77", dir.path()); + assert!(pump_until(&mut s, 10_000, search_done), "search completes"); + assert_eq!(active_buffer_name(&s), "*search-results*"); + let text = named_text(&s, "*search-results*"); + assert!( + text.contains("f.txt:2:0:"), + "structured match line:\n{text}" + ); + // Read-only under dispatch. + type_str(&mut s, "x"); + assert_eq!(named_text(&s, "*search-results*"), text, "read-only"); + // RET visits the first match (line 2 → 0-based 1; col 0). + press(&mut s, KeyCode::Char('n')); + press(&mut s, KeyCode::Enter); + assert!(active_buffer_name(&s).ends_with("f.txt")); + let line: i64 = eval(&s, "return pmacs.editor.cursor_line()"); + assert_eq!(line, 1, "grep 1-based line normalized"); + // The search claimed the error source: M-g n continues the walk + // (RET re-seated the index at match 1, so next = match 2). + alt(&mut s, ','); + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('n')); + assert!( + active_buffer_name(&s).ends_with("f.txt"), + "M-g n walks grep matches (source claimed)" + ); + let line: i64 = eval(&s, "return pmacs.editor.cursor_line()"); + assert_eq!(line, 2, "M-g n advanced to the second match"); + // A second search supersedes: fresh page. + alt(&mut s, ','); + search(&s, "top", dir.path()); + assert!(pump_until(&mut s, 10_000, search_done)); + let text2 = named_text(&s, "*search-results*"); + assert!( + text2.contains("Searching for: top") && !text2.contains("zqxvbn_needle_77 here"), + "supersede gives a fresh page:\n{text2}" + ); +} + +#[test] +fn acc29_grep_kill_mid_search_is_safe_and_masking_is_prevented() { + // A wide fixture keeps the stream alive across many ticks so a + // mid-stream window deterministically exists. + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); + for i in 0..2000 { + std::fs::write( + dir.path().join(format!("f{i:04}.txt")), + "zqxvbn_needle_77\n", + ) + .unwrap(); + } + // (a) masking prevention: a no-hook edit between producer writes + // is detected by the NEXT producer write (a later batch or the + // close trailer), not silently absorbed. + let mut s = editor(); + search(&s, "zqxvbn_needle_77", dir.path()); + assert!( + pump_until(&mut s, 10_000, |s| { + let t = named_text(s, "*search-results*"); + t.contains(":1:0:") && !t.contains("-- search ") + }), + "must observe a mid-stream state (matches landed, not closed)" + ); + exec( + &s, + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*search-results*" then + id:insert(id:len(), "INTRUDER", { bypass_intercept = true }) + end + end + "#, + ); + assert!(pump_until(&mut s, 15_000, search_done)); + let text = named_text(&s, "*search-results*"); + let marker_at = text.find(DESYNC); + let trailer_at = text.find("-- search ").unwrap(); + assert!( + marker_at.is_some() && marker_at.unwrap() < trailer_at, + "the next producer write must mark the mismatch before \ + appending (not mask it); tail:\n…{}", + &text[text.len().saturating_sub(400)..] + ); + assert!( + errors_buffer(&s).is_empty(), + "no spam: {}", + errors_buffer(&s) + ); + + // (b) killing the panel mid-search: no stale-handle writes, and + // the next search recreates the buffer. + let mut s = editor(); + search(&s, "zqxvbn_needle_77", dir.path()); + assert!(pump_until(&mut s, 10_000, |s| named_text( + s, + "*search-results*" + ) + .contains(":1:0:"))); + exec( + &s, + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*search-results*" then + pmacs.buffer.remove(id) + end + end + "#, + ); + // Drain whatever the worker still delivers. + let _ = pump_until(&mut s, 1_000, |_| false); + assert!( + errors_buffer(&s).is_empty(), + "no spam: {}", + errors_buffer(&s) + ); + search(&s, "zqxvbn_needle_77", dir.path()); + assert!( + pump_until(&mut s, 15_000, |s| named_text(s, "*search-results*") + .contains(":1:0:")), + "a subsequent search recreates the panel and works" + ); +} + +#[test] +fn acc30_grep_root_retained_across_interactive_supersede() { + let dir = tempfile::tempdir().unwrap(); + grep_fixture(dir.path()); + let mut s = editor(); + // First search from a project file: root comes from + // pmacs.project.detect (the .git marker). + exec( + &s, + &format!( + "pmacs.buffer.find_or_open({:?})", + dir.path().join("f.txt").display().to_string() + ), + ); + exec(&s, "pmacs.project.search('zqxvbn_needle_77')"); + assert!(pump_until(&mut s, 10_000, |s| named_text( + s, + "*search-results*" + ) + .contains("f.txt:2:0:"))); + // Second search issued from inside the pathless panel, no + // opts.root: the panel's stored root must be reused (the "." + // fallback would search the test process's cwd and find + // nothing). + exec(&s, "pmacs.project.search('zqxvbn_needle_77')"); + assert!( + pump_until(&mut s, 10_000, |s| { + let t = named_text(s, "*search-results*"); + t.contains("f.txt:2:0:") && t.contains("-- search ") + }), + "second interactive search must run with the first search's \ + root; panel:\n{}", + named_text(&s, "*search-results*") + ); + // And RET still resolves against that root. + press(&mut s, KeyCode::Char('n')); + press(&mut s, KeyCode::Enter); + assert!(active_buffer_name(&s).ends_with("f.txt")); +} + +// --------------------------------------------------------------------------- +// 31–33: shell-command, q, round-trip flag +// --------------------------------------------------------------------------- + +#[test] +fn acc31_shell_command_via_m_bang_does_not_claim_errors() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("one.c"), "a\n").unwrap(); + let mut s = editor(); + // Compile first so the error source is claimed by compile. + compile_and_finish(&mut s, "printf 'one.c:1:1: error: e\\n'", dir.path()); + // M-! prompts; type the command; RET runs it. + alt(&mut s, '!'); + type_str(&mut s, "echo shellout"); + press(&mut s, KeyCode::Enter); + assert!( + pump_until(&mut s, 10_000, |s| named_text(s, "*shell-command*") + .contains("[shell exited with code 0]")), + "M-! output + exit marker; buffer:\n{}", + named_text(&s, "*shell-command*") + ); + assert!(named_text(&s, "*shell-command*").contains("\nshellout\n")); + // The shell run must NOT have stolen the claim: M-g n still + // walks the compile errors. + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('n')); + assert!( + active_buffer_name(&s).ends_with("one.c"), + "M-g n after M-! still walks the prior compile; active: {}", + active_buffer_name(&s) + ); +} + +#[test] +fn acc32_q_restores_previous_buffer_from_all_three() { + let dir = tempfile::tempdir().unwrap(); + grep_fixture(dir.path()); + std::fs::write(dir.path().join("home.txt"), "hi\n").unwrap(); + let mut s = editor(); + let open_home = format!( + "pmacs.buffer.find_or_open({:?})", + dir.path().join("home.txt").display().to_string() + ); + exec(&s, &open_home); + // *compilation* + compile_and_finish(&mut s, "echo x", dir.path()); + press(&mut s, KeyCode::Char('q')); + assert!( + active_buffer_name(&s).ends_with("home.txt"), + "q from compile" + ); + // *shell-command* + exec( + &s, + &format!( + "pmacs.shell.command('echo y', {{ cwd = {:?} }})", + dir.path().display().to_string() + ), + ); + assert!(pump_until(&mut s, 10_000, |s| named_text( + s, + "*shell-command*" + ) + .contains("[shell exited"))); + press(&mut s, KeyCode::Char('q')); + assert!(active_buffer_name(&s).ends_with("home.txt"), "q from shell"); + // *search-results* + search(&s, "zqxvbn_needle_77", dir.path()); + assert!(pump_until(&mut s, 10_000, search_done)); + press(&mut s, KeyCode::Char('q')); + assert!( + active_buffer_name(&s).ends_with("home.txt"), + "q from search" + ); +} + +#[test] +fn acc33_round_trip_input_is_set_on_generated_buffers() { + let dir = tempfile::tempdir().unwrap(); + grep_fixture(dir.path()); + let mut s = editor(); + compile_and_finish(&mut s, "echo x", dir.path()); + assert!( + s.core.borrow().active_buffer_round_trips(), + "*compilation* must round-trip (semantic frontends gate their \ + optimistic path on this)" + ); + exec( + &s, + &format!( + "pmacs.shell.command('echo y', {{ cwd = {:?} }})", + dir.path().display().to_string() + ), + ); + assert!( + s.core.borrow().active_buffer_round_trips(), + "*shell-command*" + ); + search(&s, "zqxvbn_needle_77", dir.path()); + assert!( + s.core.borrow().active_buffer_round_trips(), + "*search-results*" + ); +} + +// --------------------------------------------------------------------------- +// PR #113 round 1 — bite tests (one per finding; each observed +// failing against the pre-fix tree via scripts/bite) +// --------------------------------------------------------------------------- + +#[test] +fn r1f1_non_finite_coordinates_fail_closed() { + // A 400-digit line capture tonumbers to math.huge; pre-fix it + // was stored and any visit walked forever. + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + let digits = "9".repeat(400); + compile_and_finish( + &mut s, + &format!("printf 'h.c:{digits}:1: error: e\\n'"), + dir.path(), + ); + assert!( + compile_errors(&s).is_empty(), + "a non-finite line coordinate must be discarded; got {:?}", + compile_errors(&s) + ); +} + +#[test] +fn r1f1_beyond_eol_column_clamps_to_the_target_row() { + // Column past EOL: pre-fix the walk marched move_right across + // newlines and landed rows away from the diagnostic's line (and + // an astronomical value walked effectively forever). + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("w.c"), "ab\ncd\nef\n").unwrap(); + let mut s = editor(); + compile_and_finish(&mut s, "printf 'w.c:1:500: error: e\\n'", dir.path()); + press(&mut s, KeyCode::Char('n')); + press(&mut s, KeyCode::Enter); + assert!(active_buffer_name(&s).ends_with("w.c")); + let line: i64 = eval(&s, "return pmacs.editor.cursor_line()"); + assert_eq!( + line, 0, + "the column walk must clamp at the target row's EOL, not run \ + onto later rows" + ); +} + +#[test] +fn r1f2_grep_command_path_undo_after_completed_search_recovers() { + let dir = tempfile::tempdir().unwrap(); + grep_fixture(dir.path()); + let mut s = editor(); + search(&s, "zqxvbn_needle_77", dir.path()); + assert!(pump_until(&mut s, 10_000, search_done), "search completes"); + // M-x buffer.undo in the completed panel: no producer write or + // navigation may ever come — recovery must be immediate via the + // buffer.after-edit subscription. + alt(&mut s, 'x'); + type_str(&mut s, "buffer.undo"); + press(&mut s, KeyCode::Enter); + let text = named_text(&s, "*search-results*"); + assert!( + text.contains(DESYNC), + "desync marker must appear immediately in the grep panel; \ + buffer:\n{text}" + ); + assert!( + errors_buffer(&s).is_empty(), + "clean *errors*: {}", + errors_buffer(&s) + ); +} + +#[test] +fn r1f3_rustc_arrow_paths_with_spaces_parse() { + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "space.sh", + "printf ' --> /tmp/my dir/foo.rs:12:4\\n'\n", + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + assert_eq!( + compile_errors(&s), + vec![("/tmp/my dir/foo.rs".to_owned(), 11, 3, None)], + "the rustc rule must capture space-containing paths whole \ + (pre-fix the two-part fallback recorded arrow junk at col 0)" + ); +} + +#[test] +fn r1f4_capture_indexes_above_three_and_absent_columns() { + let dir = tempfile::tempdir().unwrap(); + // (a) a valid four-capture rule with col = 4 must store the + // fourth capture, not silently column 0. + let mut s = editor(); + exec( + &s, + r#" + pmacs.compile.rules = { + { pattern = "(q%.c):(%d+):(x):(%d+)", file = 1, line = 2, col = 4 }, + } + "#, + ); + compile_and_finish(&mut s, "printf 'q.c:7:x:9 error\\n'", dir.path()); + assert_eq!( + compile_errors(&s), + vec![("q.c".to_owned(), 6, 8, Some("error".to_owned()))], + "capture index 4 must be honored" + ); + + // (b) fractional indexes are malformed; (c) a rule that names a + // column its match didn't produce rejects the match. + let mut s = editor(); + exec( + &s, + r#" + pmacs.compile.rules = { + { pattern = "(a%.c):(%d+):", file = 1, line = 2, col = 1.5 }, + { pattern = "(r%.c):(%d+):?(%d*)", file = 1, line = 2, col = 3 }, + } + "#, + ); + compile_run(&s, "printf 'a.c:3: e\\nr.c:5: e\\n'", dir.path()); + assert!( + status(&s).contains("skipped 1 malformed"), + "the fractional index is rejected at validation; got: {}", + status(&s) + ); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); + assert!( + compile_errors(&s).is_empty(), + "an empty column capture under a col-naming rule must reject \ + the match, not store column 0; got {:?}", + compile_errors(&s) + ); +} + +#[test] +fn r1f5_user_global_cannot_shadow_the_marker_helper() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + // A hostile (or merely colliding) user global with the helper's + // old name: pre-fix this replaced the module's function and its + // error consumed the terminal event before forget ran. + exec(&s, "_G.emit_text_raw = function() error('shadowed') end"); + compile_and_finish(&mut s, "echo fine", dir.path()); + assert!( + compilation_text(&s).contains("[compile exited with code 0]"), + "the exit marker must come from the module's local helper" + ); + assert!( + errors_buffer(&s).is_empty(), + "no error spam: {}", + errors_buffer(&s) + ); + assert!( + pump_until(&mut s, 3_000, |s| process_count(s) == 0), + "terminal-event cleanup (forget) must have run" + ); +} + +#[test] +fn r1f6_wrong_spec_types_error_instead_of_defaulting() { + let s = editor(); + let (ok, err): (bool, String) = eval( + &s, + r#" + local ok, err = pcall(pmacs.process.spawn, + { label = "t", command = "/bin/true", stdin = true }) + return ok, tostring(err) + "#, + ); + assert!(!ok, "stdin = true (boolean) must be a hard error"); + assert!(err.contains("stdin must be"), "pointed message; got: {err}"); + let (ok, err): (bool, String) = eval( + &s, + r#" + local ok, err = pcall(pmacs.process.spawn, + { label = "t", command = "/bin/true", group = "true" }) + return ok, tostring(err) + "#, + ); + assert!(!ok, "group = \"true\" (string) must be a hard error"); + assert!( + err.contains("group must be a boolean"), + "pointed message; got: {err}" + ); +} + +#[test] +fn r1f7_resync_invalidates_the_public_byte_anchor() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + compile_and_finish(&mut s, "printf 'a.c:1:1: error: e\\n'", dir.path()); + let has_anchor: bool = eval( + &s, + "return pmacs.compile.errors()[1].line_start_byte ~= nil", + ); + assert!(has_anchor, "pre-desync entries carry the byte anchor"); + // Trigger the guard through the command path. + alt(&mut s, 'x'); + type_str(&mut s, "buffer.undo"); + press(&mut s, KeyCode::Enter); + assert!(compilation_text(&s).contains(DESYNC), "marker appended"); + let has_anchor: bool = eval( + &s, + "return pmacs.compile.errors()[1].line_start_byte ~= nil", + ); + assert!( + !has_anchor, + "total pre-marker anchor invalidation includes the public \ + line_start_byte, not just the display row" + ); +} + +#[test] +fn r1f8_inherited_cwd_resolves_to_the_daemon_working_directory() { + // Pathless scratch buffer, no opts.cwd, no project: the header + // must print the real working directory, and relative error + // paths must resolve against it explicitly. + let mut s = editor(); + exec(&s, "pmacs.compile.run('echo hi')"); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); + let cwd = std::env::current_dir().unwrap().display().to_string(); + let text = compilation_text(&s); + assert!( + text.contains(&format!("Directory: {cwd}")), + "the header must name the daemon's actual working directory; \ + got:\n{text}" + ); + assert!( + !text.contains("(inherited)") && !text.contains("(unknown)"), + "no placeholder when the identity API is available:\n{text}" + ); +} + +#[test] +fn r1f9_truncated_utf8_at_eof_becomes_the_replacement_character() { + let dir = tempfile::tempdir().unwrap(); + // \303 (0xC3) opens a two-byte sequence that never completes: + // the parser's cross-feed buffer holds it, and only the new + // stream-end finish() can flush it as U+FFFD. + let script = write_script(dir.path(), "trunc.sh", "printf 'abc\\303'\n"); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let text = compilation_text(&s); + assert!( + text.contains("abc\u{FFFD}"), + "the truncated sequence must surface as U+FFFD, not vanish; \ + buffer:\n{text}" + ); +} + +// --------------------------------------------------------------------------- +// PR #113 round 2 — bite tests +// --------------------------------------------------------------------------- + +#[test] +fn r2f1_rule_validation_is_a_stable_snapshot() { + // Mutating the user's rule object AFTER compile.run() must not + // alter the in-flight run: validation copies scalar fields into + // per-run plain tables. + let dir = tempfile::tempdir().unwrap(); + let script = write_script(dir.path(), "slow.sh", "sleep 0.5\nprintf 'm.c:3: e\\n'\n"); + let mut s = editor(); + exec( + &s, + r#"pmacs.compile.rules = { { pattern = "(m%.c):(%d+):", file = 1, line = 2 } }"#, + ); + compile_run(&s, &format!("sh {script}"), dir.path()); + // The output hasn't arrived yet; sabotage the live rule object. + exec(&s, "pmacs.compile.rules[1].pattern = 'nevermatch'"); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); + assert_eq!( + compile_errors(&s), + vec![("m.c".to_owned(), 2, 0, None)], + "the run must parse with its validated snapshot, not the \ + mutated object" + ); +} + +#[test] +fn r2f1_metatable_backed_rules_cannot_raise_through_the_pump() { + // A rule whose field reads raise (hostile __index) and a + // container whose traversal raises: both must degrade cleanly — + // no error thrown through the per-frame pump, terminal cleanup + // intact. + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + exec( + &s, + r#" + local hostile = setmetatable({}, { __index = function() error("boom") end }) + pmacs.compile.rules = { hostile, + { pattern = "(k%.c):(%d+):", file = 1, line = 2 } } + "#, + ); + compile_run(&s, "printf 'k.c:4: e\\n'", dir.path()); + assert!( + status(&s).contains("skipped 1 malformed"), + "the hostile entry is a counted skip; got: {}", + status(&s) + ); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited with code 0]"))); + assert_eq!( + compile_errors(&s), + vec![("k.c".to_owned(), 3, 0, None)], + "the valid entry still parses" + ); + assert!( + errors_buffer(&s).is_empty(), + "nothing raised through the pump: {}", + errors_buffer(&s) + ); + assert!( + pump_until(&mut s, 3_000, |s| process_count(s) == 0), + "terminal cleanup ran" + ); + + // Hostile CONTAINER whose traversal raises. Flavor-dependent by + // Lua semantics: 5.2+ `ipairs` consults __index (the raise fires + // and the pcall degrades to defaults with a note); LuaJIT/5.1 + // reads raw (the container is simply empty — no rules, no note). + // Both flavors must complete cleanly with nothing thrown + // through the pump. + let mut s = editor(); + exec( + &s, + r#" + pmacs.compile.rules = setmetatable({}, { + __index = function() error("container boom") end, + }) + "#, + ); + compile_run(&s, "printf 'a.c:1:1: error: e\\n'", dir.path()); + let is_lua54: bool = eval(&s, "return _VERSION ~= 'Lua 5.1'"); + if is_lua54 { + assert!( + status(&s).contains("raised during traversal"), + "degradation note under 5.2+ ipairs semantics; got: {}", + status(&s) + ); + } + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); + if is_lua54 { + assert_eq!( + compile_errors(&s).len(), + 1, + "built-in defaults still parse after container degradation" + ); + } else { + assert!( + compile_errors(&s).is_empty(), + "under raw-ipairs flavors the hostile container reads as \ + an (empty) rule table" + ); + } + assert!( + errors_buffer(&s).is_empty(), + "no spam: {}", + errors_buffer(&s) + ); +} + +#[test] +fn r2f2_infinite_capture_index_is_counted_malformed() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + exec( + &s, + r#" + pmacs.compile.rules = { + { pattern = "(z%.c):(%d+):", file = 1, line = 2, col = math.huge }, + } + "#, + ); + compile_run(&s, "printf 'z.c:2: e\\n'", dir.path()); + assert!( + status(&s).contains("skipped 1 malformed"), + "math.huge is not a capture index (floor(huge) == huge, so \ + integrality alone passes it); got: {}", + status(&s) + ); + assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) + .contains("[compile exited"))); +} + +#[test] +fn r2f3_shell_command_ignores_the_compile_rule_table() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + // Both degradation shapes at once: a non-table container would + // warn, a raising container would abort — shell-command performs + // no parsing and must see neither. + exec(&s, "pmacs.compile.rules = 42"); + exec( + &s, + &format!( + "pmacs.shell.command('echo shellok', {{ cwd = {:?} }})", + dir.path().display().to_string() + ), + ); + assert!( + !status(&s).contains("not a table"), + "no compile-rule warning on a shell run; got: {}", + status(&s) + ); + assert!( + pump_until(&mut s, 10_000, |s| named_text(s, "*shell-command*") + .contains("[shell exited with code 0]")), + "shell-command runs regardless of rule-table state" + ); +} + +#[test] +fn r2f4_parser_finish_resets_for_a_fresh_stream() { + // Lua-driven twin of the ansi.rs units (which live inside the + // file a scripts/bite swap replaces): after finish(), a feed + // must parse a NEW stream — not continue a pre-EOF escape, not + // stay alt-screen-suppressed. + let s = editor(); + let (after_csi, after_alt): (String, String) = eval( + &s, + r#" + local function text_of(evs) + local out = {} + for _, ev in ipairs(evs) do + if ev.kind == "text" then out[#out + 1] = ev.text end + end + return table.concat(out) + end + local p = pmacs.ansi.parser() + p:feed("\27[3") -- incomplete CSI at stream end + p:finish() + local a = text_of(p:feed("plain")) + local q = pmacs.ansi.parser() + q:feed("\27[?1049hhidden") -- alt screen active at stream end + q:finish() + local b = text_of(q:feed("visible")) + return a, b + "#, + ); + assert_eq!( + after_csi, "plain", + "post-finish feed must not continue the pre-EOF CSI" + ); + assert_eq!( + after_alt, "visible", + "stream end must end alt-screen suppression" + ); +} + +#[test] +fn r1f10_builtin_default_rules_survive_in_place_mutation() { + let dir = tempfile::tempdir().unwrap(); + let mut s = editor(); + // Corrupt an entry IN PLACE, then degrade the container: the + // "built-in defaults" fallback must be a true copy, not an alias + // of the mutated table. + exec( + &s, + "pmacs.compile.rules[2] = 'junk'; pmacs.compile.rules = 42", + ); + compile_and_finish(&mut s, "printf 'foo.c:7:2: warning: w\\n'", dir.path()); + assert_eq!( + compile_errors(&s), + vec![("foo.c".to_owned(), 6, 1, Some("warning".to_owned()))], + "the gcc three-part rule from the TRUE defaults must match \ + (the aliased pre-fix table lost it and the two-part fallback \ + stored column 0)" + ); +} + +// --------------------------------------------------------------------------- +// PR #113 round 3 — bite tests +// --------------------------------------------------------------------------- + +#[test] +fn r3f1_cr_and_backspace_are_utf8_safe() { + // Overwrites must consume whole existing codepoints in one + // atomic replace, and backspace must step to the previous UTF-8 + // boundary. Pre-fix, é\rX left "X\xA9" (malformed) and é\bX left + // "\xC3X"; under CRDT the mid-codepoint edit rejects and aborts + // the pump (see the CRDT twin). + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "uni.sh", + concat!( + "printf '\\303\\251\\rX\\n'\n", // é\rX → X + "printf 'X\\r\\303\\251\\n'\n", // X\ré → é + "printf '\\303\\251\\bX\\n'\n", // é\bX → X + ), + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let text = compilation_text(&s); + assert!( + text.contains("\nX\n\u{e9}\nX\n"), + "each overwrite must yield exactly the replacing character, \ + valid UTF-8, no residue; buffer:\n{text:?}" + ); + assert!( + text.contains("[compile exited with code 0]"), + "terminal event must survive the unicode batch:\n{text}" + ); + assert!( + errors_buffer(&s).is_empty(), + "no pump aborts: {}", + errors_buffer(&s) + ); + assert!( + pump_until(&mut s, 3_000, |s| process_count(s) == 0), + "process record forgotten (cleanup ran)" + ); +} + +#[test] +fn r3f2_parser_finish_emits_balancing_state_events() { + // Consumers mirror parser state from the event stream alone: an + // unclosed alt-screen enter must be balanced by an exit, and a + // non-default running style by a default SetStyle — applied to + // consumer state, not just observed as later text. + let s = editor(); + let (alt_balanced, style_reset): (bool, bool) = eval( + &s, + r#" + local p = pmacs.ansi.parser() + p:feed("\27[31mred") + p:feed("\27[?1049h") -- enter alt screen, never exited + local alt = true -- consumer mirror of the enter + local style = { fg = 1 } + for _, ev in ipairs(p:finish()) do + if ev.kind == "alt_screen_exit" then alt = false end + if ev.kind == "set_style" then style = ev.style end + end + local style_is_default = style.fg == "default" + and style.bg == "default" and not style.bold + return alt == false, style_is_default + "#, + ); + assert!( + alt_balanced, + "finish must emit alt_screen_exit for an unclosed enter" + ); + assert!( + style_reset, + "finish must emit a default set_style when the running style \ + was non-default" + ); +} + +#[test] +fn r3f3_spec_fields_are_raw_reads_metatables_not_honored() { + let s = editor(); + // A metatable that provides group = true: honoring it would be + // silent spec-by-metatable; raw reads ignore it (the compile.lua + // rawget posture), so the child must NOT lead its own group. + let pid: i64 = eval( + &s, + r#" + local spec = setmetatable( + { label = "mt", command = "/bin/sh", args = { "-c", "sleep 30" } }, + { __index = function(_, k) + if k == "group" then return true end + return nil + end }) + local id = pmacs.process.spawn(spec) + for _, row in ipairs(pmacs.process.list()) do + if row.state and row.state.pid then return row.state.pid end + end + return -1 + "#, + ); + assert!(pid > 0, "metatable-backed spec must spawn"); + let out = std::process::Command::new("ps") + .args(["-o", "pgid=", "-p", &pid.to_string()]) + .output() + .expect("ps"); + let pgid: i64 = String::from_utf8_lossy(&out.stdout) + .trim() + .parse() + .expect("pgid"); + assert_ne!( + pgid, pid, + "metatable-provided group=true must not be honored (raw reads)" + ); + // A RAISING __index must not be silently absorbed either — with + // raw reads it simply never fires; the spawn succeeds cleanly. + let ok: bool = eval( + &s, + r#" + local spec = setmetatable( + { label = "mt2", command = "/bin/sh", args = { "-c", "exit 0" } }, + { __index = function() error("hostile spec metatable") end }) + local ok = pcall(pmacs.process.spawn, spec) + return ok + "#, + ); + assert!(ok, "raw reads must not trip a raising __index"); + exec( + &s, + r" + for _, row in ipairs(pmacs.process.list()) do + pcall(pmacs.process.terminate, row.id) + end + ", + ); +} + +// --------------------------------------------------------------------------- +// PR #113 round 4 — bite tests +// --------------------------------------------------------------------------- + +#[test] +fn r4f1_cr_rewrites_are_column_based_not_byte_based() { + // PR #113 round-4 finding 1: the round-3 renderer preserved + // UTF-8 validity but still counted the overwrite in BYTES. + // `abcdef\rX\n` wrote "X\n" over "ab", splitting the line and + // leaving "cdef" as a ghost line the parser saw again at EOF; + // `abc\ré` overwrote TWO ASCII characters because é is two + // bytes. Overwrites are column-counted (codepoints), and LF is + // not an overwrite column: the newline drops to a fresh line and + // the stale remainder survives in place (terminal semantics). + // CRLF (a CR event followed by a text event starting "\n") is + // the same rule and used to corrupt the same way. + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "cols.sh", + concat!( + "printf 'abcdef\\rX\\n'\n", // → Xbcdef + "printf 'abc\\r\\303\\251\\n'\n", // → ébc + "printf 'one\\r\\ntwo\\r\\n'\n", // CRLF → one / two + ), + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let text = compilation_text(&s); + assert!( + text.contains("\nXbcdef\n\u{e9}bc\none\ntwo\n"), + "shorter rewrites keep the line whole (no ghost line), a \ + multibyte overwrite consumes one COLUMN, and CRLF is a \ + plain line break; buffer:\n{text:?}" + ); + assert!( + text.contains("[compile exited with code 0]"), + "run completes: {text}" + ); + assert!( + errors_buffer(&s).is_empty(), + "no error spam: {}", + errors_buffer(&s) + ); +} + +#[test] +fn r4f1_split_feed_rewrites_and_split_codepoints() { + // The same rewrites with the CR, the overwrite text, and even + // the é's two bytes arriving in SEPARATE pump batches: the + // buffer-scanned line start and the parser's cross-feed UTF-8 + // buffer must compose with the column-based overwrite. + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "split.sh", + concat!( + "printf 'abcdef'\n", + "sleep 0.3\n", + "printf '\\rX\\n'\n", + "printf 'abc\\r\\303'\n", // é's lead byte ends the batch + "sleep 0.3\n", + "printf '\\251\\n'\n", + ), + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let text = compilation_text(&s); + assert!( + text.contains("\nXbcdef\n\u{e9}bc\n"), + "cross-batch rewrites must match the single-batch results; \ + buffer:\n{text:?}" + ); + assert!( + errors_buffer(&s).is_empty(), + "no error spam: {}", + errors_buffer(&s) + ); +} + +#[test] +fn r4f2_alt_screen_style_desync_resynced_on_exit_and_finish() { + // Round-4 finding 2, Lua twin (the in-crate Rust units vanish + // with an ansi.rs swap; this one bites): a consumer mirroring + // style from the event stream must be resynchronized when SGR + // changes were suppressed inside the alternate screen — on + // ordinary exit AND at finish(). The finish() half is the + // internal-comparison trap: the suppressed reset makes the + // PARSER's style default, so only the emitted-style comparison + // sees anything to balance. + let s = editor(); + let (exit_resynced, finish_resynced): (bool, bool) = eval( + &s, + r#" + local function last_style(evs, style) + for _, ev in ipairs(evs) do + if ev.kind == "set_style" then style = ev.style end + end + return style + end + local function is_default(style) + return style.fg == "default" and style.bg == "default" + and not style.bold + end + -- Ordinary exit: red before enter, SGR reset inside + -- (suppressed), explicit CSI ?1049l. + local p = pmacs.ansi.parser() + local evs = p:feed("\27[31mred\27[?1049h\27[0m\27[?1049l") + local a = is_default(last_style(evs, {})) + -- finish(): the same drift, closed by stream end instead. + local q = pmacs.ansi.parser() + local style = last_style(q:feed("\27[31mred\27[?1049h\27[0m"), {}) + local b = is_default(last_style(q:finish(), style)) + return a, b + "#, + ); + assert!( + exit_resynced, + "ordinary alt-screen exit must resync the suppressed SGR reset" + ); + assert!( + finish_resynced, + "finish must emit the default SetStyle the CONSUMER needs even \ + though the internal style is already default" + ); +} + +// --------------------------------------------------------------------------- +// PR #113 round 5 — bite tests +// --------------------------------------------------------------------------- + +#[test] +fn r5f1_span_translation_is_exactly_once_for_the_active_buffer() { + use pmacs::cell::Color; + // Red 'a', blue 'bc', then CR and a red 2-byte é overwriting the + // 'a' (length delta +1): the blue span must shift exactly ONCE + // (round-5 finding 1). Pre-fix every attached view translated + // the shared store — the duplicate attachment made it twice on + // the normal path, splits multiplied it further — leaving 'b' + // unstyled and the span end past the buffer. + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "spans.sh", + "printf '\\033[31ma\\033[34mbc\\r\\033[31m\\303\\251\\n'\n", + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let cells = render_active_window_to_grid(&mut s, 12, 60); + let row = styled_row(&cells, 12, 60, "\u{e9}bc", 3); + assert_eq!( + row, + vec![ + ('\u{e9}', Color::Indexed(1)), + ('b', Color::Indexed(4)), + ('c', Color::Indexed(4)), + ], + "the blue span shifts exactly once past the 1-to-2-byte rewrite" + ); +} + +#[test] +fn r5f1_hidden_buffer_spans_still_translate() { + use pmacs::cell::Color; + // The compile buffer sits in NO window while the rewrite + // arrives: pre-fix, no view received on_edit and the spans were + // never adjusted at all. Translation is buffer-level now, + // independent of visibility. + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "hidden.sh", + concat!( + "printf '\\033[31ma\\033[34mbc'\n", + "sleep 0.5\n", + "printf '\\r\\033[31m\\303\\251\\n'\n", + ), + ); + let mut s = editor(); + compile_run(&s, &format!("sh {script}"), dir.path()); + assert!( + pump_until(&mut s, 5_000, |s| compilation_text(s).contains("abc")), + "styled prefix lands first" + ); + exec( + &s, + r#"pmacs.window.switch_buffer(pmacs.buffer.create("*elsewhere*"))"#, + ); + assert!( + pump_until(&mut s, 10_000, |s| named_text(s, "*compilation*") + .contains("[compile ")), + "run finishes while hidden" + ); + // Switch back; the after-switch hook re-attaches the render view. + exec( + &s, + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*compilation*" then + pmacs.window.switch_buffer(id) + end + end + "#, + ); + let cells = render_active_window_to_grid(&mut s, 12, 60); + let row = styled_row(&cells, 12, 60, "\u{e9}bc", 3); + assert_eq!( + row, + vec![ + ('\u{e9}', Color::Indexed(1)), + ('b', Color::Indexed(4)), + ('c', Color::Indexed(4)), + ], + "spans shifted while hidden; the re-shown buffer renders true colors" + ); +} + +#[test] +fn r5f2_partial_rewrite_preserves_untouched_styling() { + use pmacs::cell::Color; + // SGR red; abc; SGR reset; CR; X — the default-styled X + // overwrites only 'a'; 'bc' keeps its red (round-5 finding 2). + // Pre-fix any overlap dropped the WHOLE span (bc lost its + // color); with no translation at all the stale span painted the + // default X red instead. Exact per-cell colors on both sides. + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "frag.sh", + "printf '\\033[31mabc\\033[0m\\rX\\n'\n", + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let cells = render_active_window_to_grid(&mut s, 12, 60); + let row = styled_row(&cells, 12, 60, "Xbc", 3); + assert_eq!( + row, + vec![ + ('X', Color::Default), + ('b', Color::Indexed(1)), + ('c', Color::Indexed(1)), + ], + "the rewritten cell is default-styled; the untouched suffix keeps red" + ); +} + +// --------------------------------------------------------------------------- +// PR #113 round 6 — bite tests +// --------------------------------------------------------------------------- + +/// Count of `buffer_style_overlay` render views on the ACTIVE window. +fn active_style_overlay_count(s: &EditorState) -> i64 { + eval( + s, + r#" + local n = 0 + for _, k in ipairs(pmacs.window._overlay_kinds()) do + if k == "buffer_style_overlay" then n = n + 1 end + end + return n + "#, + ) +} + +#[test] +fn r6f1_split_panes_stay_styled_with_one_attachment_each() { + use pmacs::cell::Color; + // A styled compilation split into two panes: the split copies + // the render view (splits fire no switch hook and pre-fix left + // the new pane unstyled), and repeated switches of one pane must + // not stack duplicates on the passive pane (pre-fix every + // re-attach blindly pushed to EVERY matching window — unbounded + // render cost). Round-6 finding 1. + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "red.sh", + "printf '\\033[31mhello\\033[0m world\\n'\n", + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + exec(&s, "pmacs.window.split_horizontal()"); + // IMMEDIATELY after the split — before any switch could heal it + // through the attach-to-all-matching-windows path — both panes + // must be styled with one attachment each. This is the split + // half of the finding: pre-fix the new pane had NO render view. + for pane in 0..2 { + assert_eq!( + active_style_overlay_count(&s), + 1, + "pane {pane} post-split: exactly one render attachment" + ); + let cells = render_active_window_to_grid(&mut s, 12, 60); + let row = styled_row(&cells, 12, 60, "hello world", 5); + assert!( + row.iter().all(|(_, fg)| *fg == Color::Indexed(1)), + "pane {pane} post-split: 'hello' renders red; got {row:?}" + ); + exec(&s, "pmacs.window.focus_next()"); + } + // Bounce the ACTIVE pane away and back three times; the passive + // pane keeps showing *compilation* through every re-attach. This + // is the accumulation half: pre-fix every re-attach blindly + // pushed another render view onto the passive pane. + exec(&s, r#"bounce_buf = pmacs.buffer.create("*bounce*")"#); + for _ in 0..3 { + exec(&s, "pmacs.window.switch_buffer(bounce_buf)"); + exec( + &s, + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*compilation*" then + pmacs.window.switch_buffer(id) + end + end + "#, + ); + } + for pane in 0..2 { + assert_eq!( + active_style_overlay_count(&s), + 1, + "pane {pane} post-bounce: exactly one render attachment" + ); + let cells = render_active_window_to_grid(&mut s, 12, 60); + let row = styled_row(&cells, 12, 60, "hello world", 5); + assert!( + row.iter().all(|(_, fg)| *fg == Color::Indexed(1)), + "pane {pane} post-bounce: 'hello' renders red; got {row:?}" + ); + exec(&s, "pmacs.window.focus_next()"); + } +} + +#[test] +fn r6f2_noop_edits_do_not_fragment_spans() { + // Buffers deliberately broadcast no-op edits; the translator + // must ignore them (round-6 finding 2). Pre-fix each interior + // no-op split the containing span into two adjacent fragments — + // unbounded growth, and position 3 (the é's continuation byte) + // minted a mid-codepoint span boundary. + let s = editor(); + let (count, start, end): (i64, i64, i64) = eval( + &s, + r#" + local buf = pmacs.buffer.create("*noop-spans*") + local ov = pmacs.buffer.add_style_overlay(buf) + buf:insert(0, "ab\195\169def") + ov:add(0, 7, { fg = 1 }) + for _, pos in ipairs({ 1, 2, 3, 4, 5 }) do + buf:insert(pos, "") + buf:delete(pos, pos) + end + local spans = ov:spans() + return #spans, spans[1].start, spans[1]["end"] + "#, + ); + assert_eq!( + (count, start, end), + (1, 0, 7), + "no-op edits must neither fragment nor move spans" + ); +} + +#[test] +fn r6f3_handle_dispose_detaches_translator_and_render_views() { + // Teardown path (round-6 finding 3): dispose() detaches the + // buffer-attached translator (later edits stop translating) and + // removes every window render view over the handle's store; + // calling it twice is safe. Pre-fix a dropped handle's + // translator lived until the buffer died. + let s = editor(); + let (live, stale, render_views): (i64, i64, i64) = eval( + &s, + r#" + local buf = pmacs.buffer.create("*disposable*") + pmacs.window.switch_buffer(buf) + local ov = pmacs.buffer.add_style_overlay(buf) + buf:insert(0, "abcdef") + ov:add(0, 6, { fg = 1 }) + buf:insert(0, "xx") -- live translator: span shifts + local live = ov:spans()[1].start + ov:dispose() + buf:insert(0, "yy") -- detached: span must NOT move + local stale = ov:spans()[1].start + ov:dispose() -- idempotent + local n = 0 + for _, k in ipairs(pmacs.window._overlay_kinds()) do + if k == "buffer_style_overlay" then n = n + 1 end + end + return live, stale, n + "#, + ); + assert_eq!(live, 2, "pre-dispose edits translate the span"); + assert_eq!(stale, 2, "post-dispose edits must not reach the store"); + assert_eq!(render_views, 0, "dispose removes the window render views"); +} + +// --------------------------------------------------------------------------- +// PR #113 round 7 — bite tests +// --------------------------------------------------------------------------- + +#[test] +fn r7f1_attach_validates_buffer_identity_and_disposed_state() { + // Round-7 finding 1: a handle's translator follows edits to ITS + // buffer only, so attaching the handle to another buffer showed + // spans nobody maintains; attaching after dispose() resurrected + // rendering without the translator. Both now fail clearly, with + // the message pointing at add_style_overlay. + let s = editor(); + let (ok_cross, err_cross, ok_same, ok_after, err_after): (bool, String, bool, bool, String) = + eval( + &s, + r#" + local a = pmacs.buffer.create("*ov-a*") + local b = pmacs.buffer.create("*ov-b*") + local ov = pmacs.buffer.add_style_overlay(a) + local ok_cross, err_cross = pcall(pmacs.buffer.attach_style_overlay, b, ov) + local ok_same = pcall(pmacs.buffer.attach_style_overlay, a, ov) + ov:dispose() + local ok_after, err_after = pcall(pmacs.buffer.attach_style_overlay, a, ov) + return ok_cross, tostring(err_cross), ok_same, ok_after, tostring(err_after) + "#, + ); + assert!(!ok_cross, "cross-buffer attachment must be rejected"); + assert!( + err_cross.contains("belongs to buffer") && err_cross.contains("add_style_overlay"), + "pointed message naming the fix; got: {err_cross}" + ); + assert!(ok_same, "same-buffer attachment stays valid"); + assert!(!ok_after, "attachment after dispose must be rejected"); + assert!( + err_after.contains("disposed") && err_after.contains("add_style_overlay"), + "pointed message naming the fix; got: {err_after}" + ); +} + +#[test] +fn r7f2_dispose_detaches_translator_in_a_headless_host() { + // Round-7 finding 2, acceptance twin (the in-crate unit vanishes + // with a mod.rs swap; this one bites): an install-only host + // registers the buffer registry as app data but NO editor core. + // Pre-fix, dispose() did all cleanup inside the optional + // SharedCore branch — returning success while the translator + // stayed attached for the buffer's lifetime. + use pmacs::lua_bindings::{ + SharedCommandRegistry, SharedHookRegistry, SharedKeymapStack, SharedMenuRegistry, + SharedRegistry, install, + }; + use std::cell::RefCell; + use std::rc::Rc; + let lua = mlua::Lua::new(); + let reg: SharedRegistry = Rc::new(RefCell::new(pmacs::buffer_registry::BufferRegistry::new())); + let cmds: SharedCommandRegistry = Rc::new(RefCell::new(pmacs::command::CommandRegistry::new())); + let kms: SharedKeymapStack = Rc::new(RefCell::new(pmacs::keymap_stack::KeymapStack::new())); + let mns: SharedMenuRegistry = Rc::new(RefCell::new(pmacs::menu::MenuRegistry::new())); + let hks: SharedHookRegistry = Rc::new(RefCell::new(pmacs::hook::HookRegistry::new())); + install(&lua, ®, &cmds, &kms, &mns, &hks).expect("install-only host"); + lua.load( + r#" + _G.hbuf = pmacs.buffer.create("headless") + _G.hov = pmacs.buffer.add_style_overlay(_G.hbuf) + "#, + ) + .exec() + .expect("create + overlay"); + let id = reg + .borrow() + .find_by_name("headless") + .expect("buffer exists"); + let with_translator = reg.borrow().get(id).unwrap().view_count(); + lua.load("_G.hov:dispose()").exec().expect("dispose"); + assert_eq!( + reg.borrow().get(id).unwrap().view_count(), + with_translator - 1, + "dispose must detach the translator without a core registered" + ); +} + +// --------------------------------------------------------------------------- +// PR #113 round 8 — direct review fixes +// --------------------------------------------------------------------------- + +#[test] +fn r8f1_dispose_is_atomic_and_retryable_under_reentrant_borrows() { + // dispose() is callable from Lua callbacks that may still be + // running under an EditorCore or BufferRegistry RefCell borrow. + // It must return a pointed error before changing the shared + // disposed state or removing either view, then succeed when + // retried after that callback completes. + let s = editor(); + exec( + &s, + r#" + _G.r8_buf = pmacs.buffer.create("*r8-dispose*") + pmacs.window.switch_buffer(_G.r8_buf) + _G.r8_ov = pmacs.buffer.add_style_overlay(_G.r8_buf) + "#, + ); + + let (ok_core, err_core): (bool, String) = { + let _core_borrow = s.core.borrow_mut(); + eval( + &s, + "local ok, err = pcall(_G.r8_ov.dispose, _G.r8_ov); \ + return ok, tostring(err)", + ) + }; + assert!(!ok_core, "dispose under a core borrow must fail cleanly"); + assert!( + err_core.contains("defer dispose()"), + "core-borrow error must name the recovery; got: {err_core}" + ); + let (still_attached, can_attach): (i64, bool) = eval( + &s, + r#" + local n = 0 + for _, kind in ipairs(pmacs.window._overlay_kinds()) do + if kind == "buffer_style_overlay" then n = n + 1 end + end + return n, pcall(pmacs.buffer.attach_style_overlay, _G.r8_buf, _G.r8_ov) + "#, + ); + assert_eq!( + still_attached, 1, + "failed disposal must not remove render views" + ); + assert!( + can_attach, + "failed disposal must not mark the handle disposed" + ); + + let registry = s.core.borrow().registry.clone(); + let (ok_registry, err_registry): (bool, String) = { + let _registry_borrow = registry.borrow_mut(); + eval( + &s, + "local ok, err = pcall(_G.r8_ov.dispose, _G.r8_ov); \ + return ok, tostring(err)", + ) + }; + assert!( + !ok_registry, + "dispose under a registry borrow must fail cleanly" + ); + assert!( + err_registry.contains("defer dispose()"), + "registry-borrow error must name the recovery; got: {err_registry}" + ); + let (still_attached, can_attach): (i64, bool) = eval( + &s, + r#" + local n = 0 + for _, kind in ipairs(pmacs.window._overlay_kinds()) do + if kind == "buffer_style_overlay" then n = n + 1 end + end + return n, pcall(pmacs.buffer.attach_style_overlay, _G.r8_buf, _G.r8_ov) + "#, + ); + assert_eq!( + still_attached, 1, + "registry conflict must not partially dispose" + ); + assert!(can_attach, "registry conflict must leave the handle live"); + + let (ok_final, remaining): (bool, i64) = eval( + &s, + r#" + local ok = pcall(_G.r8_ov.dispose, _G.r8_ov) + local n = 0 + for _, kind in ipairs(pmacs.window._overlay_kinds()) do + if kind == "buffer_style_overlay" then n = n + 1 end + end + return ok, n + "#, + ); + assert!(ok_final, "dispose must be retryable after the borrow ends"); + assert_eq!(remaining, 0, "successful retry removes the render view"); +} + +#[test] +fn r8f2_attach_rejects_a_handle_whose_owner_buffer_was_removed() { + // BufferId equality alone does not prove that the recorded owner + // is still live. Without a registry resolution this returned + // success after the buffer (and its translator) had been removed. + let s = editor(); + let (ok, err): (bool, String) = eval( + &s, + r#" + local buf = pmacs.buffer.create("*stale-overlay-owner*") + local ov = pmacs.buffer.add_style_overlay(buf) + pmacs.buffer.remove(buf) + local ok, err = pcall(pmacs.buffer.attach_style_overlay, buf, ov) + return ok, tostring(err) + "#, + ); + assert!(!ok, "attachment for a removed owner must be rejected"); + assert!( + err.contains("stale buffer handle"), + "stale-owner error must identify the invalid handle; got: {err}" + ); +} + +#[test] +fn r5f3_tracked_line_start_matches_the_scan_across_transitions() { + // Round-5 finding 3 is a performance fix — the per-CR/BS/erase + // whole-prefix scan became a tracked byte. These are the + // correctness pins for that tracked value across every + // transition: a multi-line append, CR after a batch boundary, + // repeated CR on one line, erase-line, and a fresh line after + // each. Behavior must be identical to the old scan (this test + // passes on both by design — the perf win is measured, not + // asserted; a timing bound here would flake on slow CI). + let dir = tempfile::tempdir().unwrap(); + let script = write_script( + dir.path(), + "track.sh", + concat!( + "printf 'l1\\nl2 partial'\n", + "sleep 0.3\n", + "printf '\\rL2 done!!!\\n'\n", + "printf 'p 1\\rp 2\\rp 22\\n'\n", + "printf 'erase me\\033[2K'\n", + "printf 'clean\\n'\n", + ), + ); + let mut s = editor(); + compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); + let text = compilation_text(&s); + assert!( + text.contains("\nl1\nL2 done!!!\np 22\nclean\n"), + "every rewind lands at the tracked line start; buffer:\n{text:?}" + ); +} diff --git a/tests/compile_mode_crdt_acceptance.rs b/tests/compile_mode_crdt_acceptance.rs new file mode 100644 index 0000000..ba79d49 --- /dev/null +++ b/tests/compile_mode_crdt_acceptance.rs @@ -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) { + match pmacs::transport::read_message::(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(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::( + &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 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::( + &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:?}" + ); +} diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 70d674a..eb05196 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -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