diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index deaf6ef..7baa098 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,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() diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua new file mode 100644 index 0000000..e59d179 --- /dev/null +++ b/builtin/runtime/compile.lua @@ -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; "`, 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" } diff --git a/src/editor.rs b/src/editor.rs index ec22d4c..0d03af2 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