feat(compile): compile-mode, shell-command, and the grep-mode upgrade

builtin/runtime/compile.lua (Q#CM1-CM6, CM8-CM11): streaming
intercept-read-only *compilation* / *shell-command* slots fed by a
Lua-side ANSI parser (SGR to overlay spans, CR/BS/erase progress
collapse); once-per-newline error parsing over a validated,
fail-closed rule table (rustc arrows, gcc/clang, Python, generic;
severity override + keyword sniff; sub-1 captures discarded);
buffer-revision external-edit guard with desync marker and anchor
epochs, checked before every producer write, before byte-anchor
use, and immediately via buffer.after-edit; unified error.next /
error.previous dispatcher with last-claim-wins sources and a
diagnostics fallback (M-g n/p unbind-then-rebind — hence the
loader's ordering contract after lsp.lua; C-x ` bound; M-! bound);
buffer-local RET/n/p/g/q/C-c C-k plus all seven undo/redo chords as
status no-ops; tombstoned pump teardown honoring forget's
terminated-only contract; q-target never captures a generated
buffer; overlay retained per incarnation, cleared per run,
re-attached from buffer.after-switch.

builtin/commands/default.lua (Q#CM7): project.search's
*search-results* becomes a first-class locations buffer — read-only
with bypass writes, RET/n/p/q + undo no-ops + round-trip input,
structured-match locations (line-1, match_start as col, paths
resolved against the search root), per-write revision checks so a
batch cannot mask an external edit, on_removed stream cancel +
guards for kill-mid-search, root retention across interactive
supersedes from inside the pathless panel, and an error-source
claim per search.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-13 14:45:51 +01:00
parent a7d5a6fedf
commit e20d5eaaad
3 changed files with 1219 additions and 10 deletions

View File

@ -644,7 +644,8 @@ cmd { name = "editor.execute-command",
}
end }
-- Workers and project search (T M3.6, T M3.7) -------------------------------
-- Workers and project search (T M3.6, T M3.7; grep-mode upgrade per
-- docs/compile-mode-framing.md Q#CM7) ---------------------------------------
--
-- `pmacs.workers.grep` is the runtime API; the user-facing entry
-- point is `M-x project.search`, which prompts for a query and
@ -653,19 +654,176 @@ 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
-- Cursor walk via primitives (the lsp.lua visit idiom; 0-based).
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 pmacs.editor.move_down() end
for _ = 1, col do pmacs.editor.move_right() 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 +831,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 +893,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()

901
builtin/runtime/compile.lua Normal file
View File

@ -0,0 +1,901 @@
-- 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; <cmdline>"`, 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"
{ pattern = "%-%->%s+([^:%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 },
}
local function rule_is_valid(rule)
if type(rule) ~= "table" then return false end
if type(rule.pattern) ~= "string" then return false end
if type(rule.file) ~= "number" or rule.file < 1 then return false end
if type(rule.line) ~= "number" or rule.line < 1 then return false end
if rule.col ~= nil and (type(rule.col) ~= "number" or rule.col < 1) then return false end
if rule.severity ~= nil and rule.severity ~= "error" and rule.severity ~= "warning" then
return false
end
return true
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.
local BUILTIN_RULES = pmacs.compile.rules
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
for _, rule in ipairs(rules) do
if rule_is_valid(rule) then
valid[#valid + 1] = rule
else
skipped = skipped + 1
end
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
e.row = nil
end
local len = buf:len()
buf:insert(len, DESYNC_MARKER, { bypass_intercept = true })
slot.out_pos = buf:len()
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
-- Append `text` at the tracked output position with overwrite
-- semantics (CR progress bars rewrite the current line in place).
local function emit_text(slot, text)
if #text == 0 then return end
local buf = slot.buf
local len = buf:len()
local pos = math.min(slot.out_pos, len)
local overwrite = math.min(#text, len - pos)
if overwrite > 0 then
buf:replace(pos, pos + overwrite, text:sub(1, overwrite), { bypass_intercept = true })
end
if #text > overwrite then
buf:insert(pos + overwrite, text:sub(overwrite + 1), { bypass_intercept = true })
end
slot.out_pos = pos + #text
add_style_span(slot, pos, pos + #text)
end
-- The current unterminated line spans [parse_line_start, buf:len());
-- CR/BS/erase are confined to it by construction (lines are parsed
-- and left behind the moment their newline lands).
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.parse_line_start
elseif kind == "backspace" then
if slot.out_pos > slot.parse_line_start then
slot.out_pos = slot.out_pos - 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 len = buf:len()
if slot.parse_line_start < len then
buf:delete(slot.parse_line_start, len, { bypass_intercept = true })
end
slot.out_pos = slot.parse_line_start
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
}
local function parse_line(slot, line, abs_start)
if not slot.parse_errors then return end
for _, rule in ipairs(slot.rules) do
local ok, c1, c2, c3 = pcall(string.match, line, rule.pattern)
if ok and c1 then
local caps = { c1, c2, c3 }
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 captures fail closed (a raw -1
-- would walk the cursor loops to a silent (0,0) landing).
if type(file) == "string" and lnum and lnum >= 1 and (cnum == nil or cnum >= 1) 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
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
-- Terminal event: 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
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
-- Plain append at end, no overwrite/style tracking — markers and
-- headers.
function emit_text_raw(slot, text)
local buf = slot.buf
buf:insert(buf:len(), text, { bypass_intercept = true })
slot.out_pos = buf:len()
if slot.parse_line_start > slot.out_pos then
slot.parse_line_start = slot.out_pos
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()
-- 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.
slot.rules, slot.skipped_rules = validated_rules()
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
local header = string.format("$ %s\nDirectory: %s\n\n", cmdline, cwd or "(inherited)")
buf:insert(0, header, { bypass_intercept = true })
slot.out_pos = buf:len()
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)
pmacs.window.switch_buffer(slot.buf)
pcall(pmacs.buffer.attach_style_overlay, slot.buf, slot.overlay)
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).
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 pmacs.editor.move_down() end
for _ = 1, col do pmacs.editor.move_right() 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" }

View File

@ -373,6 +373,19 @@ impl EditorState {
include_str!("../builtin/runtime/indent.lua"),
)
.expect("load indent builtin chunk");
// Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT:
// compile.lua must load AFTER lsp.lua. It takes over
// `M-g n` / `M-g p` for the unified error dispatchers, and
// duplicate bindings are rejected, so the takeover is
// unbind-then-bind against lsp.lua's diag bindings — they
// must exist first. (Loaded last in the runtime sequence;
// its after-tick pump is ordering-independent.)
lua_host
.eval(
Some("@pmacs/builtin/runtime/compile.lua"),
include_str!("../builtin/runtime/compile.lua"),
)
.expect("load compile builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it