1037 lines
37 KiB
Lua
1037 lines
37 KiB
Lua
-- 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". `[^:]+` (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()
|
|
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
|
|
|
|
-- Byte offset where the line containing `out_pos` starts. Scanned
|
|
-- from the buffer (the REPL's `_current_line_start` discipline) —
|
|
-- 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 to the batch's first line
|
|
-- (using the stale value let a progress line overwrite everything
|
|
-- emitted earlier in the batch).
|
|
local function current_line_start(slot)
|
|
local pos = math.min(slot.out_pos, slot.buf:len())
|
|
local prefix = slot.buf:slice(0, pos)
|
|
local start = 0
|
|
local search = 1
|
|
while true do
|
|
local idx = prefix:find("\n", search, true)
|
|
if not idx then return start end
|
|
start = idx
|
|
search = idx + 1
|
|
end
|
|
end
|
|
|
|
-- The current unterminated line runs from its scanned start 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 = current_line_start(slot)
|
|
elseif kind == "backspace" then
|
|
if slot.out_pos > current_line_start(slot) 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 ls = current_line_start(slot)
|
|
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
|
|
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
|
|
|
|
-- 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()
|
|
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). 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" }
|