pmacs/builtin/runtime/killring.lua

353 lines
12 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).
local KILL_CHAIN = { ["edit.kill-line"] = true, ["edit.cut"] = 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).
function pmacs.killring._debug_state(fid)
return { session = sessions[fid], last_kill_id = last_kill_id[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).
local function fail_kill(fid)
last_kill_id[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)
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 text = buf:slice(region.start, region["end"])
-- Same intercept discipline as kill_line: a rejected or transformed
-- delete must not feed the ring or leave a live chain.
local len_before = buf:len()
local dok, deleted = pcall(ed.delete_region)
if not dok or not deleted then
fail_kill(fid)
ed.set_status(dok and "no region" or "kill rejected by buffer intercept")
return false
end
if len_before - buf:len() ~= #text 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
-- 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. Verify by length delta: only a clean,
-- untransformed delete feeds the ring.
local len_before = buf:len()
local ok = pcall(function() 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 len_before - buf:len() ~= #text 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 by erroring.
-- A rejection must still end the session — letting the error
-- propagate would leave `sessions[fid]` live, and a second M-y
-- could reuse the supposedly-invalid session.
local rok = pcall(function() 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
-- Verify the applied edit: buffer intercepts may alter or reject a
-- replace. Accepted post-hoc semantics (Q#KR7): on mismatch the
-- interceptor's result stands, the session ends, and we say so.
-- pcall'd for the same reason as the guard above: an intercept that
-- shrank the buffer must invalidate, not throw.
local vok, applied = pcall(function()
return buf:slice(s.start, s.start + #entry.text)
end)
if not vok or applied ~= 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
-- 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
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" }