-- 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()
  -- The marker ends with \n, so the fresh epoch starts a new line.
  slot.line_start = slot.out_pos
  slot.parse_line_start = slot.out_pos
  slot.next_row = count_newlines(buf:slice(0, slot.parse_line_start))
  slot.expected_rev = buf:revision()
end

-- The guard's single checkpoint: nil buffer → false; revision drift
-- → resync (returns true: callers may continue, state is coherent
-- again). Called before every producer write and byte-anchor use,
-- and immediately from the buffer.after-edit subscription.
local function check_rev(slot)
  if not slot_alive(slot) then return false end
  if slot.expected_rev == nil then return true end
  if slot.buf:revision() ~= slot.expected_rev then
    resync(slot)
  end
  return true
end

local function style_is_default(style)
  if not style then return true end
  return style.fg == "default"
    and style.bg == "default"
    and not style.bold
    and not style.italic
    and style.underline == "none"
    and not style.reverse
end

local function add_style_span(slot, from, to)
  if not slot.overlay then return end
  if from >= to then return end
  if style_is_default(slot.cur_style) then return end
  slot.overlay:add(from, to, slot.cur_style)
end

-- True when byte `b` is a UTF-8 continuation byte (0x80–0xBF).
local function is_utf8_continuation(b)
  return b >= 0x80 and b < 0xC0
end

-- Codepoint count of `s` (lead bytes only; `s` always holds complete
-- scalars — parser text events never carry a partial sequence).
local function count_codepoints(s)
  local n = 0
  for i = 1, #s do
    if not is_utf8_continuation(s:byte(i)) then n = n + 1 end
  end
  return n
end

-- Byte length of the first `n` codepoints of `s`, clamped to #s.
local function codepoint_prefix_bytes(s, n)
  local len = #s
  local i = 0
  local seen = 0
  while i < len and seen < n do
    i = i + 1
    while i < len and is_utf8_continuation(s:byte(i + 1)) do
      i = i + 1
    end
    seen = seen + 1
  end
  return i
end

-- Track the current line's start as bytes land (round-5 finding 3):
-- `slot.line_start` is the byte offset where the line containing
-- `out_pos` begins. CR, backspace, and erase-line rewinds read it in
-- O(1); the old per-event scan materialized and walked the ENTIRE
-- preceding buffer (buf:slice(0, pos)) on every CR — quadratic for a
-- progress-heavy command behind megabytes of output. The value
-- advances wherever a \n lands (this helper for appended text; the
-- mid-line newline branch inline) and resets on the recovery paths
-- (run start, resync, raw marker appends). Rewinds never cross it,
-- so it is always ≤ out_pos and always a line start.
local function note_appended(slot, base, text)
  local last = nil
  local search = 1
  while true do
    local idx = text:find("\n", search, true)
    if not idx then break end
    last = idx
    search = idx + 1
  end
  if last then slot.line_start = base + last end
end

-- Append `text` at the tracked output position with overwrite
-- semantics (CR progress bars rewrite the current line in place).
--
-- Overwrites are COLUMN-counted and newline-segmented (PR #113
-- round-4 finding 1; codepoints approximate columns — double-width
-- and combining characters count as one, the framing's documented
-- stance). Each newline-free segment consumes one existing codepoint
-- per incoming codepoint — `abc\ré` yields `ébc`, never the
-- byte-counted `éc` — and LF is NOT an overwrite column: a newline
-- arriving mid-line drops the cursor to a fresh line and the stale
-- remainder of the current line survives in place (terminal
-- semantics), where the byte-counted overwrite wrote `X\n` INTO the
-- line, splitting it and leaving the remainder as a ghost line the
-- parser saw again at EOF.
--
-- UTF-8 safety (round-3 finding 1) is per-edit: every segment holds
-- complete scalars (the parser never splits one, and \n is ASCII)
-- and every consumed range covers whole existing codepoints, so the
-- rope is valid UTF-8 after each step and the byte-native CRDT edit
-- never rejects a range. `out_pos` stays on codepoint boundaries by
-- induction: it moves to end-of-segment, line starts (after \n), or
-- a boundary-aligned backspace target.
local function emit_text(slot, text)
  if #text == 0 then return end
  local buf = slot.buf
  local idx = 1
  while idx <= #text do
    local len = buf:len()
    local pos = math.min(slot.out_pos, len)
    if pos >= len then
      -- Append fast path: nothing ahead to overwrite, so the whole
      -- remainder (newlines included) lands as one edit.
      local rest = text:sub(idx)
      buf:insert(len, rest, { bypass_intercept = true })
      slot.out_pos = len + #rest
      note_appended(slot, len, rest)
      add_style_span(slot, len, len + #rest)
      return
    end
    local nl = text:find("\n", idx, true)
    if nl == idx then
      -- Newline while mid-line: cursor to a fresh line; the stale
      -- remainder stays. The \n is appended past it, never written
      -- over it.
      buf:insert(len, "\n", { bypass_intercept = true })
      slot.out_pos = len + 1
      slot.line_start = len + 1
      idx = idx + 1
    else
      local seg = text:sub(idx, (nl or #text + 1) - 1)
      -- The current line's remainder — the only bytes an overwrite
      -- may touch. No \n exists at or past out_pos: rewinds stay
      -- within the final line and \n is only ever appended.
      local tail = buf:slice(pos, len)
      local ow = codepoint_prefix_bytes(tail, count_codepoints(seg))
      buf:replace(pos, pos + ow, seg, { bypass_intercept = true })
      slot.out_pos = pos + #seg
      add_style_span(slot, pos, pos + #seg)
      idx = idx + #seg
    end
  end
end

-- The current unterminated line runs from the tracked
-- `slot.line_start` (per-slot, updated at every \n — NOT
-- `parse_line_start`, which only advances once per batch: a CR
-- arriving in the same batch as earlier completed lines must rewind
-- to the start of the CURRENT line, not the batch's first line) to
-- buf:len() — no newline ever exists past out_pos (output is
-- append-only except CR/BS rewinds within the current line).
local function apply_events(slot, events)
  local buf = slot.buf
  for _, ev in ipairs(events) do
    local kind = ev.kind
    if kind == "text" then
      emit_text(slot, ev.text)
    elseif kind == "set_style" then
      slot.cur_style = ev.style
    elseif kind == "carriage_return" then
      slot.out_pos = slot.line_start
    elseif kind == "backspace" then
      -- Step back over one whole CODEPOINT, not one byte — a
      -- mid-codepoint out_pos would make the next overwrite split
      -- the character (round-3 finding 1).
      local ls = slot.line_start
      if slot.out_pos > ls then
        local prefix = slot.buf:slice(ls, slot.out_pos)
        local i = #prefix
        while i > 1 and is_utf8_continuation(prefix:byte(i)) do
          i = i - 1
        end
        slot.out_pos = ls + i - 1
      end
    elseif kind == "erase_to_eol" then
      local len = buf:len()
      if slot.out_pos < len then
        buf:delete(slot.out_pos, len, { bypass_intercept = true })
      end
    elseif kind == "erase_line" then
      local ls = slot.line_start
      local len = buf:len()
      if ls < len then
        buf:delete(ls, len, { bypass_intercept = true })
      end
      slot.out_pos = ls
    end
    -- alt-screen suppression happens inside the parser; titles and
    -- shell-integration markers are irrelevant to a compile buffer.
  end
end

-- ---------------------------------------------------------------------
-- Line parsing (Q#CM4)
-- ---------------------------------------------------------------------

local SEVERITY_STYLE = {
  error = { fg = 1 }, -- indexed red
  warning = { fg = 3 }, -- indexed yellow
}

-- A stored coordinate must be a finite integer ≥ 1: `%d+` happily
-- captures digit runs whose tonumber is astronomically large or
-- math.huge, and an unbounded value would drive the cursor walk
-- loops effectively forever (round-1 finding 1). The cursor walk
-- also clamps independently — belt and braces.
local function valid_coordinate(n)
  return n ~= nil and n >= 1 and n < math.huge and n == math.floor(n)
end

local function parse_line(slot, line, abs_start)
  if not slot.parse_errors then return end
  for _, rule in ipairs(slot.rules) do
    -- Capture EVERYTHING the pattern produced: validation accepts
    -- any positive integer index, so truncating at three silently
    -- misread four-capture rules (round-1 finding 4).
    local caps = { pcall(string.match, line, rule.pattern) }
    local ok = table.remove(caps, 1)
    if ok and caps[1] then
      local file = caps[rule.file]
      local lnum = tonumber(caps[rule.line])
      local cnum = rule.col and tonumber(caps[rule.col]) or nil
      -- 1-based contract; below-1, non-integral, and non-finite
      -- captures fail closed. A rule that NAMES a column capture the
      -- match didn't produce also fails closed (silently storing
      -- column 0 would misreport the location).
      local col_ok = (rule.col == nil and cnum == nil) or valid_coordinate(cnum)
      if type(file) == "string" and valid_coordinate(lnum) and col_ok then
        local severity = rule.severity or sniff_severity(line)
        slot.errors[#slot.errors + 1] = {
          file = file,
          line = lnum - 1,
          col = cnum and (cnum - 1) or 0,
          severity = severity,
          line_start_byte = abs_start,
          row = slot.next_row,
        }
        local style = severity and SEVERITY_STYLE[severity]
        if style and slot.overlay then
          slot.overlay:add(abs_start, abs_start + #line, style)
        end
        return
      end
      -- Fail-closed match: fall through to later rules.
    end
  end
end

-- Parse every newly completed line exactly once (Q#CM4). Rows are
-- counted per completed line so RET/n/p can map cursor rows to
-- entries without rescanning the buffer.
local function parse_new_lines(slot)
  local buf = slot.buf
  local len = buf:len()
  if slot.parse_line_start >= len then return end
  local chunk = buf:slice(slot.parse_line_start, len)
  local search = 1
  while true do
    local nl = chunk:find("\n", search, true)
    if not nl then break end
    parse_line(slot, chunk:sub(search, nl - 1), slot.parse_line_start + search - 1)
    slot.next_row = slot.next_row + 1
    search = nl + 1
  end
  slot.parse_line_start = slot.parse_line_start + search - 1
end

-- ---------------------------------------------------------------------
-- Run lifecycle (Q#CM3/Q#CM9/Q#CM11)
-- ---------------------------------------------------------------------

local function project_root_of_active()
  local buf = pmacs.window.buffer()
  if not buf then return nil end
  local ok, path = pcall(function() return buf:path() end)
  if not (ok and path) then return nil end
  local ok2, proj = pcall(pmacs.project.detect, path)
  if ok2 and proj and proj.root then return proj.root end
  return nil
end

-- The daemon's actual working directory: the last-resort cwd when
-- there is no explicit opt and no detectable project. Resolving it
-- (rather than leaving nil and printing "(inherited)") gives the
-- header a real path and relative error files an explicit base
-- (round-1 finding 8).
local function daemon_working_directory()
  local ok, id = pcall(pmacs.instance.identity)
  if ok and type(id) == "table" and type(id.working_directory) == "string" then
    return id.working_directory
  end
  return nil
end

-- Where a run started right now would execute. Extracted from the
-- inline expression that used to live in `pmacs.compile.run`, because
-- the interactive command needs the SAME answer to build its prompt:
-- a suggestion computed from a different rule than the run obeys can
-- name a toolchain that is not at the directory the command executes
-- in (journey Stage 1b-1 framing §3.1).
local function resolve_cwd(explicit)
  return explicit or project_root_of_active() or daemon_working_directory()
end

--- Default compile command per detected project kind, keyed by the tag
--- `pmacs.project.detect` returns. Assign into this table from
--- `init.lua` to add or override one:
---
---     pmacs.compile.defaults.go = "go build ./..."
---
--- Only `rust` ships seeded, and that is a decision rather than an
--- omission: Rust has one answer, while npm/yarn/pnpm, make/cmake, and
--- `go build` versus `go test` do not. A wrong prefill costs more than
--- an empty one — the user must delete it before typing.
pmacs.compile.defaults = { rust = "cargo build" }

-- Read `defaults[kind]` defensively. The table is public and
-- assignable, so a metatable with a throwing `__index`, a non-string
-- entry, or a non-table replacement all have to be survivable — the
-- same discipline `validated_rules` keeps for a hostile rule container
-- (round-2 finding 3). A broken `defaults` degrades to the pre-stage
-- empty prompt; it never prevents compiling.
local function default_command_for(kind)
  if type(kind) ~= "string" then return nil end
  local ok, value = pcall(function() return pmacs.compile.defaults[kind] end)
  if ok and type(value) == "string" and #value > 0 then return value end
  return nil
end

--- Where the next compile would run, and what kind of project is
--- detected *from that directory*. Public getter (per API
--- conventions): `{ cwd = string|nil, kind = string|nil }`.
---
--- `kind` is always `detect(cwd)`, one rule regardless of which branch
--- produced `cwd`. Note that is not the same as "`cwd` is a project
--- root": a Cargo workspace subdirectory contains no `Cargo.toml`, is
--- detected as `rust` by the ancestor walk, and is a perfectly good
--- place to run `cargo build`.
function pmacs.compile.context(explicit_cwd)
  local cwd = resolve_cwd(explicit_cwd)
  local kind = nil
  if cwd then
    local ok, proj = pcall(pmacs.project.detect, cwd)
    if ok and proj and type(proj.kind) == "string" then kind = proj.kind end
  end
  return { cwd = cwd, kind = kind }
end

local function format_exit_marker(label, ev)
  if ev.kind == "exited" then
    return string.format("\n[%s exited with code %d]\n", label, ev.code or 0)
  elseif ev.kind == "signaled" then
    return string.format("\n[%s killed by %s]\n", label, ev.signal or "signal")
  elseif ev.kind == "crashed" then
    return string.format("\n[%s crashed: %s]\n", label, ev.error or "unknown")
  end
  return string.format("\n[%s exited]\n", label)
end

-- Plain append at end, no overwrite/style tracking — markers and
-- headers. LOCAL by design: a global here would let user config
-- shadow a helper the terminal-event path depends on, and an error
-- thrown from that shadow would consume the terminal event before
-- pump cleanup/forget ran (round-1 finding 5).
local function emit_text_raw(slot, text)
  local buf = slot.buf
  local base = buf:len()
  buf:insert(base, text, { bypass_intercept = true })
  slot.out_pos = buf:len()
  note_appended(slot, base, text)
  if slot.parse_line_start > slot.out_pos then
    slot.parse_line_start = slot.out_pos
  end
end

-- Terminal event: drain the parser's cross-feed state (an
-- incomplete UTF-8 sequence at process EOF can never complete — the
-- parser's finish() emits its replacement character, round-1
-- finding 9), finalize the pending unterminated line (a final
-- diagnostic emitted without a trailing newline is complete at EOF
-- and must not be dropped — Q#CM4), then the exit marker.
local function finish_run(slot, ev)
  if not check_rev(slot) then return end
  local buf = slot.buf
  if slot.parser then
    apply_events(slot, slot.parser:finish())
  end
  local len = buf:len()
  if slot.parse_errors and slot.parse_line_start < len then
    parse_line(slot, buf:slice(slot.parse_line_start, len), slot.parse_line_start)
    slot.next_row = slot.next_row + 1
    slot.parse_line_start = len
  end
  slot.out_pos = buf:len()
  emit_text_raw(slot, format_exit_marker(slot.label, ev))
  slot.expected_rev = buf:revision()
  if ev.kind == "exited" and (ev.code or 0) == 0 then
    pmacs.editor.set_status(slot.label .. ": finished")
  elseif ev.kind == "exited" then
    pmacs.editor.set_status(string.format("%s: exited abnormally with code %d", slot.label, ev.code))
  else
    pmacs.editor.set_status(slot.label .. ": " .. ev.kind)
  end
end

local function feed_bytes(slot, bytes)
  if not check_rev(slot) then return end
  apply_events(slot, slot.parser:feed(bytes))
  parse_new_lines(slot)
  slot.expected_rev = slot.buf:revision()
end

pmacs.hook.add("process.after-tick", function()
  for raw, entry in pairs(pump) do
    local events = pmacs.process.events_take(entry.procid)
    for _, ev in ipairs(events) do
      local kind = ev.kind
      if kind == "stdout" or kind == "stderr" then
        -- stderr cannot arrive (fd2 = fd1 at the child boundary),
        -- but if it somehow does, route it through the same parser
        -- rather than dropping user output (the REPL's posture).
        if not entry.tomb and slot_alive(entry.slot) then
          feed_bytes(entry.slot, ev.bytes)
        end
      elseif kind == "exited" or kind == "signaled" or kind == "crashed" then
        if not entry.tomb and slot_alive(entry.slot) then
          finish_run(entry.slot, ev)
        end
        if entry.slot.proc and entry.slot.proc:raw() == raw then
          entry.slot.proc = nil
        end
        pump[raw] = nil
        pcall(pmacs.process.forget, entry.procid)
      end
    end
  end
end)

-- Immediate command-path recovery (Q#CM2 trigger a): M-x/menu edits
-- fire buffer.after-edit; hook edits don't re-fire the hook, so the
-- resync marker can be appended from here safely. Covers the
-- undo-after-completed-run case where no pump event will ever come.
pmacs.hook.add("buffer.after-edit", function()
  local slot = slot_for_buffer(pmacs.window.buffer())
  if slot then check_rev(slot) end
end)

-- Overlay re-attach on ANY switch path landing on a slot buffer
-- (window overlay attachment is cleared by buffer switches; the
-- jump_back binding now fires this hook too, so RET → M-, keeps its
-- styling).
pmacs.hook.add("buffer.after-switch", function()
  local slot = slot_for_buffer(pmacs.window.buffer())
  if slot and slot.overlay and slot_alive(slot) then
    pcall(pmacs.buffer.attach_style_overlay, slot.buf, slot.overlay)
  end
end)

-- Start a run in `slot`. Shared by compile and shell-command; grep
-- has its own worker path.
-- Whether `buf` is currently the acting frontend's side-window buffer
-- (bottom-panel arc). Used so a recompile re-displays into the panel it
-- is already in rather than duplicating itself into the document window.
local function already_in_panel(buf)
  if not buf then return false end
  local panel = pmacs.window.panel()
  if not panel then return false end
  local ok, shown = pcall(pmacs.window.buffer, panel)
  return ok and shown == buf
end

local function start_run(slot, cmdline, opts)
  opts = opts or {}
  -- Bottom-panel arc (Q#BP11b): validate placement BEFORE the run
  -- supersedes anything, rewrites the buffer, or spawns a process, so
  -- an unknown value leaves no half-started run behind. In Stages 1-2
  -- omission means "current"; Stage 3 flips the default.
  -- Q#S3-1: one shared rule for vocabulary, error text and default.
  --
  -- `display_omitted` is captured SEPARATELY and deliberately. The
  -- resolver collapses omission into its default, but the recompile gate
  -- below distinguishes them: it fires on OMISSION only, never on an
  -- explicit `display = "current"`, which is the documented opt-out and
  -- must reach the raw switch even when the previous run was
  -- panel-placed. Resolving first and testing `== "current"` afterwards
  -- would silently merge the two and break that opt-out.
  local display_omitted = opts.display == nil
  -- Stage 3 (Q#BP12): omission resolves to the PANEL, with
  -- `select = false` at the display call below — compile output is
  -- passive and must not steal focus from the buffer being compiled.
  local display = pmacs.window._resolve_display("compile.run", opts.display, "panel")
  -- 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 = resolve_cwd(opts.cwd)

  -- Supersede (Q#CM9): terminate the old group and tombstone its
  -- pump entry; its terminal event still drives forget.
  if slot.proc then
    local entry = pump[slot.proc:raw()]
    if entry then entry.tomb = true end
    pcall(pmacs.process.terminate, slot.proc)
    slot.proc = nil
    pmacs.editor.set_status(slot.label .. ": superseded previous run")
  end

  -- Fresh run state. Only error-parsing slots touch the rule table
  -- at all: shell-command performs no parsing, so it must neither
  -- surface compile-rule warnings nor fail on a hostile rule
  -- container (round-2 finding 3).
  if slot.parse then
    slot.rules, slot.skipped_rules = validated_rules()
  else
    slot.rules, slot.skipped_rules = {}, 0
  end
  slot.parse_errors = slot.parse
  slot.errors = {}
  slot.err_index = 0
  slot.cur_style = nil
  slot.parser = pmacs.ansi.parser()
  slot.overlay:clear()
  local buf = slot.buf
  local len = buf:len()
  if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end
  -- The identity fallback should always resolve; "(unknown)" only
  -- survives if the instance API itself failed.
  local header = string.format("$ %s\nDirectory: %s\n\n", cmdline, cwd or "(unknown)")
  buf:insert(0, header, { bypass_intercept = true })
  slot.out_pos = buf:len()
  -- The header ends with \n, so output starts on a fresh line.
  slot.line_start = slot.out_pos
  slot.parse_line_start = slot.out_pos
  slot.next_row = count_newlines(header)
  slot.expected_rev = buf:revision()
  slot.cwd = cwd
  if slot.parse and slot.skipped_rules > 0 then
    pmacs.editor.set_status(
      string.format("compile: skipped %d malformed rule entr%s",
        slot.skipped_rules, slot.skipped_rules == 1 and "y" or "ies"))
  end

  -- Spawn (Q#CM3): pipes, merged stderr at the child boundary, null
  -- stdin, own process group, TERM=dumb.
  local spec = {
    label = slot.label,
    command = "/bin/sh",
    args = { "-c", "exec 2>&1; " .. cmdline },
    env = { TERM = "dumb" },
    stdin = "null",
    group = true,
  }
  if cwd then spec.cwd = cwd end
  local ok, proc = pcall(pmacs.process.spawn, spec)
  -- switch_buffer synchronously fires buffer.after-switch, whose
  -- subscription above attaches the overlay — a second explicit
  -- attach here stacked a duplicate render view per run (round-5
  -- finding 1; translation itself is buffer-level and unaffected by
  -- attachment count).
  -- The FIRST display of this run is the side-affine one (Q#BP3): a
  -- persistent *compilation* already visible in a document window must
  -- not preempt the requested panel. Compile output is passive, so it
  -- takes `select = false` explicitly.
  --
  -- A recompile REPLAYS `_last`, which since Stage 3 carries `display`
  -- alongside cmdline/cwd — an opt-out that did not survive replay
  -- would silently revert to the panel on the next `g`. So an explicit
  -- `display = "current"` reaches here again on a recompile, and must
  -- still take the raw switch below.
  --
  -- The `display_omitted` arm remains for the genuinely omitted case:
  -- it keeps a buffer that already owns the panel slot in the panel
  -- rather than duplicating it into the selected DOCUMENT window while
  -- the panel still shows it.
  --
  -- Gated on OMISSION, never on an explicit value: `display = "current"`
  -- is the documented user-facing opt-out from the Stage 3 default flip,
  -- so it must reach the raw switch even when the previous run was
  -- panel-placed. The duplicate presentation that produces is the
  -- escape hatch's documented cost (R3-rp2).
  if display == "panel" or (display_omitted and already_in_panel(slot.buf)) then
    pmacs.window.display(slot.buf, { side = "bottom", select = false })
  else
    pmacs.window.switch_buffer(slot.buf)
  end
  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()
  -- Bottom-panel arc (Q#BP11b): RET from a compilation PANEL opens the
  -- source in the document target, leaving the panel where it is.
  local ok, err = pcall(pmacs.window.display_file, path, { select = true })
  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
    -- Bottom-panel arc (Q#BP11b): in a side window, `q` deletes or
    -- restores the PRESENTATION rather than leaving a source buffer
    -- stranded in the panel slot. Capability fallback and pre-arc
    -- placement keep today's previous-buffer restore below.
    local params = pmacs.window.params()
    if params and params.side and params.quit_action then
      pmacs.window.quit()
      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
    -- Stage 3: `display` is stored too. It is the documented opt-out
    -- from the panel default, and `g` (recompile) reaches `start_run`
    -- with whatever `_last` holds — so without this, a user who ran
    -- `compile.run{display="current"}` would be moved into a panel the
    -- moment they recompiled. An opt-out that reverts on the next `g`
    -- is not an opt-out. `nil` is stored as `nil`, so an omitted
    -- `display` keeps resolving to the default rather than being
    -- frozen at the first run's resolution.
    pmacs.compile._last = { cmdline = cmdline, cwd = slot.cwd, display = opts and opts.display }
    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
    -- Captured ONCE, here. `pmacs.minibuffer.read` is asynchronous and
    -- nothing freezes the active window while a prompt is open, so
    -- re-resolving inside `on_accept` would let the prompt offer
    -- `cargo build` for A and execute in B. Sharing the resolver is
    -- necessary and not sufficient; the resolution has to be captured.
    -- This is Journey Stage 1a's `commit_to` discipline on a smaller
    -- seam — the mechanism differs, the failure prevented is the same.
    local ctx = pmacs.compile.context()
    pmacs.minibuffer.read {
      prompt = "Compile command: ",
      history = "compile",
      -- `_last` still wins: a user who ran `cargo test` once gets it
      -- back rather than being reset to the project default.
      initial = last and last.cmdline or default_command_for(ctx.kind) or "",
      on_accept = function(cmdline)
        if cmdline == nil or cmdline == "" then return end
        -- `ctx.cwd` passes through verbatim, INCLUDING nil: a nil cwd
        -- means every resolution step failed, and the header renders
        -- "(unknown)" exactly as before. Re-resolving here would
        -- reintroduce the drift for the case least able to tolerate it.
        pmacs.compile.run(cmdline, { cwd = ctx.cwd })
      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, display = last.display })
  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" }
-- Journey step 9 (COHERENCE §2): running a build had no binding at all,
-- while `C-c @ C-M-s` opened all folds. `C-c c` is free, sits under the
-- established `C-c` prefix, and does not collide with CUA copy (that is
-- `M-w`). Bound here rather than in `default.lua` because a runtime
-- module owns its own global keys — `terminal.lua` binds `C-c t`,
-- `lsp.lua` binds `C-c o`.
--
-- Two inherited reachability limits, both pre-existing: inside a
-- terminal window `C-c` is consumed as the escape key, and the repl
-- package binds `C-c` at buffer scope. `M-x compile.run` still works in
-- both. `compile.recompile` gets no global chord — `g` in
-- `*compilation*` already covers rerun.
pmacs.keymap.bind { scope = "global", sequence = "C-c c", command = "compile.run" }
