pmacs/builtin/runtime/killring.lua

483 lines
17 KiB
Lua

-- killring.lua --- the Emacs kill ring (Arc 2, kill-ring framing).
--
-- Kills accumulate here instead of overwriting the one clipboard slot:
-- consecutive kills append into one entry, `C-y` yanks the head, `M-y`
-- right after a yank cycles older entries. The ring is daemon-global
-- (shared across attached frontends, like the Emacs daemon); kill
-- chains and yank sessions are per-frontend, keyed by
-- `pmacs.frontend.id()` and checked against stable ring-entry ids so
-- one frontend's activity can never corrupt another's (Q#KR4/6/7).
--
-- The Rust substrate this rides on (Q#KR2): every input path either
-- rotates the per-frontend command boundary (commands) or breaks it
-- (optimistic CRDT edits, pointer gestures, pastes, unbound keys), so
-- `pmacs.editor.last_command()` is trustworthy on both frontends.
--
-- OS clipboard: the ring head is mirrored to the *acting frontend's*
-- OS clipboard on every kill/append (`ed.clipboard_set`); external
-- content joins the ring at yank time via the slot check (an OS copy
-- only reaches the daemon when pasted). `M-y` never touches the slot.
--
-- Framing: docs/kill-ring-framing.md.
pmacs.killring = pmacs.killring or {}
local ed = pmacs.editor
local DEFAULT_MAX = 60
local ring = {} -- array of { id, text }, most-recent first (shared)
local next_id = 1
local max_entries = DEFAULT_MAX
-- Per-frontend state (Q#KR4/KR6). Keyed by pmacs.frontend.id().
local last_kill_id = {} -- fid -> ring-entry id of that frontend's last kill
local sessions = {} -- fid -> { buffer, start, stop, entry_id, text }
-- Commands whose success may extend a kill chain (Q#KR4; the zap
-- pair joined via the editing-conveniences framing Q#EC6 — their
-- kills run inside a minibuffer accept, where the boundary state is
-- the invoking dispatch's, preserved by the minibuffer key shadow).
local KILL_CHAIN = {
["edit.kill-line"] = true,
["edit.cut"] = true,
["edit.zap-to-char"] = true,
["edit.zap-up-to-char"] = true,
}
-- Q#EC6 pending-prompt marker: fid -> true while a kill-producing
-- minibuffer prompt is armed but not yet committed. Minibuffer::begin
-- replaces a live session WITHOUT running its on_cancel, so a
-- silently-discarded zap prompt leaves no callback to break the
-- chain; the marker lives here, where every kill can see it, and an
-- ordinary kill that meets it uncommitted refuses to append.
local pending_kill_prompt = {} -- fid -> true
local function trim()
while #ring > max_entries do
table.remove(ring)
end
end
-- max([n]) --- getter when nil; validated setter otherwise. Rejects
-- non-numbers, NaN, and non-finite values (math.huge would defeat the
-- cap); floors; lowering the cap trims existing entries immediately.
function pmacs.killring.max(n)
if n == nil then return max_entries end
if type(n) ~= "number" or n ~= n or n == math.huge or n < 1 then
error("pmacs.killring.max: expected a finite number >= 1")
end
max_entries = math.floor(n)
trim()
return max_entries
end
-- The ring's texts, most-recent first (introspection / tests).
function pmacs.killring.list()
local out = {}
for i, e in ipairs(ring) do out[i] = e.text end
return out
end
-- Test/debug seam (Q#KR11 lifecycle assertions; Q#EC6 marker).
function pmacs.killring._debug_state(fid)
return {
session = sessions[fid],
last_kill_id = last_kill_id[fid],
pending_kill_prompt = pending_kill_prompt[fid],
}
end
-- Push `text` as a fresh entry (duplicate-of-head collapses, keeping
-- the existing id). Returns the head entry.
local function push_entry(text)
if ring[1] and ring[1].text == text then return ring[1] end
table.insert(ring, 1, { id = next_id, text = text })
next_id = next_id + 1
trim()
return ring[1]
end
-- A kill-family command failed or was a no-op: it must not leave a
-- live chain for the next kill to append to (Q#KR4). Also drops any
-- armed-but-uncommitted prompt marker (Q#EC6): every failure path
-- routes through here, and clearing both state components together
-- is what makes break_chain sufficient.
local function fail_kill(fid)
last_kill_id[fid] = nil
pending_kill_prompt[fid] = nil
end
-- Chain-aware kill (Q#KR4): append to the head iff the previous
-- command was a chain kill AND this frontend's last kill IS the
-- current head (another frontend's push in between means the head is
-- not ours — append would corrupt their entry). Mirrors the head to
-- the acting frontend's OS clipboard either way.
local function kill_push(fid, text)
-- Q#EC6 fail-safe: an uncommitted prompt marker means an armed
-- kill prompt never resolved (silent session replacement bypasses
-- on_cancel). Refuse to append no matter what last_command and the
-- id say — force a fresh entry and clear the marker.
if pending_kill_prompt[fid] then
pending_kill_prompt[fid] = nil
local head = push_entry(text)
last_kill_id[fid] = head.id
ed.clipboard_set(head.text)
return head
end
local chained = KILL_CHAIN[ed.last_command() or ""]
and last_kill_id[fid] ~= nil
and ring[1] ~= nil
and ring[1].id == last_kill_id[fid]
local head
if chained then
ring[1].text = ring[1].text .. text
head = ring[1]
else
head = push_entry(text)
end
last_kill_id[fid] = head.id
ed.clipboard_set(head.text)
return head
end
-- edit.cut body (C-w): kill the active region into the ring.
function pmacs.killring.cut()
local fid = pmacs.frontend.id()
local region = ed.region()
local buf = pmacs.window.buffer()
if not region or not buf then
fail_kill(fid)
ed.set_status("no region")
return false
end
local rstart, rstop = region.start, region["end"]
local text = buf:slice(rstart, rstop)
-- Same intercept discipline as kill_line, via the mutator so the
-- EFFECTIVE edit is checkable exactly. The selection is cleared
-- explicitly (ed.delete_region did that as a side effect).
local dok, estart, estop, einserted = pcall(function()
return buf:delete(rstart, rstop)
end)
ed.clear_selection()
if not dok then
fail_kill(fid)
ed.set_status("kill rejected by buffer intercept")
return false
end
if estart ~= rstart or estop ~= rstop or einserted ~= 0 then
fail_kill(fid)
ed.set_status("kill altered by buffer intercept; ring not updated")
return false
end
ed.goto_byte(rstart)
kill_push(fid, text)
return true
end
-- edit.copy body (M-w): save the region to the ring without deleting.
-- Not a chain command (Q#KR4's family is kill-line + cut): a copy
-- pushes fresh (duplicate-of-head collapses) and neither extends nor
-- starts an append chain.
function pmacs.killring.copy()
local fid = pmacs.frontend.id()
local region = ed.region()
local buf = pmacs.window.buffer()
if not region or not buf then
fail_kill(fid)
ed.set_status("no region")
return false
end
local text = buf:slice(region.start, region["end"])
push_entry(text)
ed.clipboard_set(text)
fail_kill(fid) -- a copy is not an appendable kill
return true
end
-- edit.kill-line body (C-k): kill from the cursor to end of line; at
-- the newline itself, kill the newline (Emacs kill-line with
-- kill-whole-line nil). Consecutive C-k's append (Q#KR4), so
-- C-k C-k C-k builds one multi-line entry.
function pmacs.killring.kill_line()
local fid = pmacs.frontend.id()
local buf = pmacs.window.buffer()
if not buf then
fail_kill(fid)
return false
end
local cursor = ed.cursor()
local len = buf:len()
if cursor >= len then
fail_kill(fid)
ed.set_status("end of buffer")
return false
end
-- Find the next newline by chunked scan (lines are almost always
-- shorter than one chunk; a chunk loop keeps giant lines safe).
local eol = nil
local p = cursor
while p < len do
local chunk_to = math.min(p + 4096, len)
local chunk = buf:slice(p, chunk_to)
local nl = chunk:find("\n", 1, true)
if nl then
eol = p + nl - 1
break
end
p = chunk_to
end
local kill_to
if eol == cursor then
kill_to = cursor + 1 -- at the newline: kill the newline itself
else
kill_to = eol or len -- rest of the line (or of a final bare line)
end
local text = buf:slice(cursor, kill_to)
-- The delete runs the buffer's edit intercepts, which may REJECT
-- (error) or TRANSFORM the operation. A rejection must clear the
-- kill chain (or the next C-k would append to a kill that never
-- happened); a transformation means the bytes actually removed are
-- not `text`, so pushing `text` would put never-killed bytes on the
-- ring and the OS clipboard. The mutators return the EFFECTIVE edit
-- (post-intercept start/end/inserted), so this is an exact check —
-- a length delta would be defeated by an equal-length rewrite to a
-- different range.
local ok, estart, estop, einserted = pcall(function()
return buf:delete(cursor, kill_to)
end)
if not ok then
fail_kill(fid)
ed.set_status("kill rejected by buffer intercept")
return false
end
if estart ~= cursor or estop ~= kill_to or einserted ~= 0 then
fail_kill(fid)
ed.set_status("kill altered by buffer intercept; ring not updated")
return false
end
kill_push(fid, text)
return true
end
-- Drop a frontend's yank session (invalid M-y must not leave state a
-- second M-y could ride, Q#KR7).
local function drop_session(fid, msg)
sessions[fid] = nil
if msg then ed.set_status(msg) end
end
-- edit.paste body (C-y): yank the ring head (Q#KR6).
function pmacs.killring.yank()
local fid = pmacs.frontend.id()
-- Slot check: content that arrived via an OS paste (paste_inbound
-- refreshes the slot) joins the ring the first time it is yanked.
local slot = ed.clipboard_get()
if slot and slot ~= "" and (not ring[1] or ring[1].text ~= slot) then
push_entry(slot)
end
local head = ring[1]
if not head then
drop_session(fid, "kill ring empty")
return false
end
local region = ed.region()
local start = region and region.start or ed.cursor()
if not slot or slot ~= head.text then
ed.clipboard_set(head.text)
end
local ok = ed.clipboard_paste()
if not ok then
drop_session(fid) -- failed paste creates no session (Q#KR6)
return false
end
local buf = pmacs.window.buffer()
sessions[fid] = {
buffer = buf and tostring(buf) or "",
start = start,
stop = ed.cursor(),
entry_id = head.id,
text = head.text,
}
return true
end
-- edit.yank-pop body (M-y): replace the just-yanked text with the
-- next-older ring entry (Q#KR7). Valid only immediately after a yank
-- or another pop, with a live, still-verifiable session.
function pmacs.killring.yank_pop()
local fid = pmacs.frontend.id()
local lc = ed.last_command()
local s = sessions[fid]
if not (lc == "edit.paste" or lc == "edit.yank-pop") or not s then
drop_session(fid, "previous command was not a yank")
return false
end
local buf = pmacs.window.buffer()
if not buf or tostring(buf) ~= s.buffer then
drop_session(fid, "yank was in another buffer")
return false
end
-- Invalidation guard: the remembered range must still hold exactly
-- the text this session yanked. A concurrent edit (another
-- frontend, a hook) that moved or altered it fails here — refuse
-- rather than splice garbage. The slice is pcall'd: an upstream
-- deletion can shrink the buffer below `stop`, and an out-of-bounds
-- range must read as "changed", not throw.
local ok, current = pcall(function() return buf:slice(s.start, s.stop) end)
if not ok or current ~= s.text then
drop_session(fid, "buffer changed since the yank")
return false
end
-- Stable-id rotation: find where this session's entry sits NOW
-- (other frontends' pushes shift positions, not ids) and step to
-- the next older, wrapping. An evicted id invalidates.
local pos = nil
for i, e in ipairs(ring) do
if e.id == s.entry_id then
pos = i
break
end
end
if not pos then
drop_session(fid, "kill ring entry expired")
return false
end
local entry = ring[pos % #ring + 1]
-- The replace runs buffer intercepts, which may REJECT (error) or
-- TRANSFORM. A rejection must still end the session — letting the
-- error propagate would leave `sessions[fid]` live for a second M-y
-- to reuse. And the verification must be EXACT: the mutator returns
-- the effective edit, and any deviation from the requested
-- (start, stop, #text) — e.g. an intercept enlarging `stop` by one
-- byte, silently deleting extra content — ends the session (the
-- interceptor's result stands; accepted post-hoc semantics, Q#KR7).
local rok, estart, estop, einserted = pcall(function()
return buf:replace(s.start, s.stop, entry.text)
end)
if not rok then
drop_session(fid, "yank-pop rejected by buffer intercept")
return false
end
if estart ~= s.start or estop ~= s.stop or einserted ~= #entry.text then
drop_session(fid, "yank-pop altered by buffer intercept; stopped")
return false
end
ed.goto_byte(s.start + #entry.text)
s.stop = s.start + #entry.text
s.entry_id = entry.id
s.text = entry.text
return true
end
-- ---- editing-conveniences exports (Q#EC6) --------------------------
-- The chain-aware surface zap needs from its minibuffer accept: a
-- range kill with killring's exact-effective-edit discipline, a
-- targeted chain break (the frontend whose chain must break is the
-- INVOKING one, which need not be the acting one), and the
-- pending-prompt marker lifecycle.
-- Kill [start, stop) of the ACTIVE buffer into the ring, chain-aware.
-- Returns true on a clean kill; false, "rejected" when an intercept
-- threw (nothing landed); false, "transformed", estart, estop,
-- einserted when the effective edit deviated (the intercept's result
-- stands — the caller owns any cursor repair). Both failure paths
-- break the acting frontend's chain and report status. Invalid
-- arguments error BEFORE any ring or buffer mutation: misuse of a
-- programmatic API, not a user outcome.
function pmacs.killring.kill_range(start, stop)
local buf = pmacs.window.buffer()
if not buf then
error("pmacs.killring.kill_range: no active buffer")
end
local function ok_int(n)
return type(n) == "number" and n == n and n ~= math.huge
and n == math.floor(n) and n >= 0
end
if not (ok_int(start) and ok_int(stop))
or start >= stop or stop > buf:len() then
error("pmacs.killring.kill_range: expected integers "
.. "0 <= start < stop <= buffer length")
end
local fid = pmacs.frontend.id()
local text = buf:slice(start, stop)
local ok, estart, estop, einserted = pcall(function()
return buf:delete(start, stop)
end)
if not ok then
fail_kill(fid)
ed.set_status("kill rejected by buffer intercept")
return false, "rejected"
end
if estart ~= start or estop ~= stop or einserted ~= 0 then
fail_kill(fid)
ed.set_status("kill altered by buffer intercept; ring not updated")
return false, "transformed", estart, estop, einserted
end
kill_push(fid, text)
return true
end
-- Public chain break. Targets `fid` when given (the origin guard
-- passes the INVOKING frontend, which may differ from the acting
-- one), else the acting frontend.
function pmacs.killring.break_chain(fid)
if fid == nil then
fid = pmacs.frontend.id()
elseif type(fid) ~= "number" or fid ~= fid or fid == math.huge
or fid ~= math.floor(fid) or fid < 0 then
error("pmacs.killring.break_chain: fid must be a nonnegative integer")
end
fail_kill(fid)
end
-- Arm the acting frontend's pending-prompt marker (zap, at invoke
-- time, before minibuffer.read). Does NOT touch last_kill_id —
-- backward chaining (C-k then a completed zap appends) needs the id
-- alive. An ALREADY-set marker means the previous armed prompt was
-- silently discarded without resolution (replaced session, no
-- on_cancel): break that chain first, or a second zap would commit
-- the stale marker away and falsely append to the pre-abandonment
-- kill.
function pmacs.killring.arm_kill_prompt()
local fid = pmacs.frontend.id()
if pending_kill_prompt[fid] then
last_kill_id[fid] = nil
end
pending_kill_prompt[fid] = true
end
-- Clear the acting frontend's marker, reporting whether one was
-- still armed. Callers kill only on true: a false return means some
-- other Lua consumed the marker while the prompt was open, and the
-- armed state is no longer trustworthy (fail closed).
function pmacs.killring.commit_kill_prompt()
local fid = pmacs.frontend.id()
local was_armed = pending_kill_prompt[fid] ~= nil
pending_kill_prompt[fid] = nil
return was_armed
end
-- Q#KR11: a detached frontend's chain/session state must not outlive
-- it (ids are monotonic; these tables would grow forever).
pmacs.hook.add("frontend.detached", function(fid)
sessions[fid] = nil
last_kill_id[fid] = nil
pending_kill_prompt[fid] = nil
end)
pmacs.command.define {
name = "edit.kill-line",
description = "Kill from the cursor to the end of the line (into the kill ring).",
fn = function() pmacs.killring.kill_line() end,
}
pmacs.command.define {
name = "edit.yank-pop",
description = "Replace the just-yanked text with the previous kill (after C-y).",
fn = function() pmacs.killring.yank_pop() end,
}
pmacs.keymap.bind { scope = "global", sequence = "C-k", command = "edit.kill-line" }
pmacs.keymap.bind { scope = "global", sequence = "M-y", command = "edit.yank-pop" }