-- 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" }