feat(edit): kill ring + yank-pop on a per-frontend command-boundary substrate

Arc 2 (docs/kill-ring-framing.md, rev 3 — three review rounds). Kills
accumulate in a ring; consecutive kills append; C-y yanks the head; M-y
right after a yank cycles older entries; C-k (kill-line) exists at last.
The ring is daemon-global (Emacs-daemon model); chains and yank sessions
are per-frontend.

The substrate (Q#KR2): EditorCore.command_history maps FrontendId ->
{this, last} command. Every input path updates it --- the rev-1 design
treated dispatch_key as the only input path and review falsified that
twice:

  keybound command            dispatch_key Run arm          rotate
  typed char (round-trip)     self-insert fallback          rotate
  unbound key                 dispatch_key unbound arm      break
  GPU optimistic edit         handle_remote_crdt_op         break
  pointer gesture             dispatch_mouse + dispatch_pointer  break
  inbound OS paste            unified paste route           break
  menu item                   menu_invoke_active            rotate
  M-x accept                  pmacs.command.invoke_interactive   rotate

invoke_interactive gives Emacs's execute-extended-command semantics
(M-x kill-line then C-k appends; C-k then M-x kill-line does not); the
public pmacs.command.invoke stamps nothing. Wheel scroll deliberately
does NOT break (mwheel-scroll vs mouse-set-point, as in Emacs).

Three shipped bugs fixed en route (Q#KR10):
- Semantic-path Paste was dropped ("no grid-less effect yet"), and the
  GPU always negotiates semantic render --- GPU Ctrl-V was a no-op. Paste
  is now a dispatcher-level arm serving both attachment kinds.
- That arm keys off the dispatcher's AUTHENTICATED source; the old grid
  arm trusted the client-supplied payload frontend_id, letting a forged
  id paste into another frontend's active window (unit-tested).
- Paste, M-x-invoked commands, and menu-invoked commands never fired
  buffer.after-edit (each runs outside dispatch_key's revision check),
  so LSP/syntax/autosave missed those edits. A shared
  with_after_edit_check helper now wraps all three sites; scope is
  honest --- active-buffer compare, sound for these paths, not a general
  any-buffer guarantee (buffer-aware edit epoch deferred).

The ring (killring.lua, Q#KR4-7): entries carry stable monotonic ids.
Append requires last_command in the kill family AND this frontend's
last_kill_id == the head's id --- A-kill/B-kill/A-kill pushes fresh
instead of corrupting B's entry. Yank sessions store {buffer, start,
stop, entry_id, text}: M-y validates last_command + live session + same
buffer + slice(start,stop) == text (out-of-bounds reads as changed ---
pcall'd; an early test caught the guard throwing on an upstream
deletion instead of refusing), rotates by locating the entry_id's
CURRENT position (positions shift under other frontends' pushes; ids
don't), verifies the applied replace (intercepts may alter it; accepted
post-hoc semantics), then goto_byte. Failed kills clear last_kill_id;
failed/refused yanks create no session, so a second invalid M-y cannot
ride the first's name-stamp.

OS clipboard: ring head mirrors to the ACTING frontend's OS clipboard
only (pending_clipboard's existing shape; frontends may be different
machines). External content joins the ring at yank time via the
clipboard_get slot check (an OS copy reaches the daemon only when
pasted). New core seams: clipboard_set(bytes) / clipboard_get.

Lifecycle (Q#KR11): SessionDetached prunes command_history and fires the
new frontend.detached hook (raw id); killring.lua drops that frontend's
tables.

pmacs.killring.max([n]) validated (non-finite rejected --- math.huge
would defeat the cap; shrink trims immediately), default 60.

Deferred, named: word kills (M-d/M-BS/C-BS/C-h/C-DEL discard bytes ---
needs bytes-returning deleters), C-SPC/set-mark, clipboard watching,
ring browser/persistence, C-u C-y / C-M-w, buffer-aware edit epoch,
Lua-visible intercept probe.

Tests: tests/kill_ring_acceptance.rs (24) --- chain mechanics incl. all
break rows, the M-x three-direction matrix, per-frontend interleaving
(A-kill/B-kill/A-kill; stable-id rotation under B's pushes; eviction
mid-session; upstream-edit invalidation), menu Cut via real right-click
+ menu pointer (feeds ring, fires after-edit once, chains with C-k),
external-paste integration, cap validation + shrink-trim, detach
cleanup. Plus daemon unit tests: forged-id paste lands in the
authenticated source's window and leaves the claimed frontend's chain
untouched; optimistic CRDT op breaks only the source's chain.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 24;
cua 5; query-replace 16; completion 9; autosave 29; desktop 11;
persistence 5; clobber 6; m4 90; m8 10+15; m10/m11 crdt; GPU 58;
git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-09 18:26:52 -04:00
parent 2383b2e3ba
commit 04314ce132
8 changed files with 1527 additions and 52 deletions

View File

@ -200,21 +200,19 @@ cmd { name = "region.cancel",
-- bracketed paste also refreshes. The default bindings are the Emacs
-- kill/yank set (M-w / C-w / C-y, C-x h), which were all free.
-- The cut/copy/paste trio delegates to the kill ring (Arc 2): kills
-- accumulate, C-y yanks the head, M-y cycles older entries. Resolution
-- happens at invoke time, so chunk load order doesn't matter; the
-- context menu invokes these by name and inherits the ring for free.
cmd { name = "edit.copy",
description = "Copy the active region to the clipboard.",
fn = function()
if not ed.clipboard_copy() then ed.set_status("no region") end
end }
description = "Save the active region to the kill ring (and OS clipboard).",
fn = function() pmacs.killring.copy() end }
cmd { name = "edit.cut",
description = "Cut the active region to the clipboard.",
fn = function()
if not ed.clipboard_cut() then ed.set_status("no region") end
end }
description = "Kill the active region into the kill ring (and OS clipboard).",
fn = function() pmacs.killring.cut() end }
cmd { name = "edit.paste",
description = "Paste the clipboard at the cursor, replacing any region.",
fn = function()
if not ed.clipboard_paste() then ed.set_status("clipboard empty") end
end }
description = "Yank the most recent kill at the cursor, replacing any region.",
fn = function() pmacs.killring.yank() end }
cmd { name = "edit.select-all",
description = "Select the whole buffer.",
fn = function() ed.select_all() end }
@ -628,7 +626,11 @@ cmd { name = "editor.execute-command",
history = "command",
on_accept = function(name)
if name == nil or name == "" then return end
local ok, err = pcall(pmacs.command.invoke, name)
-- invoke_interactive records the command boundary (kill
-- ring Q#KR2), so chain-sensitive commands behave as under
-- Emacs's execute-extended-command: M-x kill-line then C-k
-- appends; C-k then M-x kill-line does not.
local ok, err = pcall(pmacs.command.invoke_interactive, name)
if not ok then
-- mlua's `tostring(err)` includes a Lua stack traceback
-- separated by newlines. The status line is one row;

View File

@ -67,6 +67,15 @@ define {
kind = "short-circuit",
}
define {
name = "frontend.detached",
description = "Fired when an attached frontend's session ends. " ..
"Receives the raw frontend id (integer). Modules keying " ..
"state by pmacs.frontend.id() (kill-ring sessions) drop " ..
"that id's entries here (Q#KR11).",
kind = "all-must-succeed",
}
define {
name = "process.after-tick",
description = "Fired once per editor frame, immediately after the process " ..

View File

@ -0,0 +1,317 @@
-- 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"])
if not ed.delete_region() then
fail_kill(fid)
ed.set_status("no region")
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)
buf:delete(cursor, kill_to)
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]
buf:replace(s.start, s.stop, entry.text)
-- 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" }

View File

@ -1540,6 +1540,30 @@ fn handle_dispatcher_event(
editor.dispatch_menu_pointer(source, index, invoke);
}
}
FrontendEvent::Paste {
frontend_id: claimed_fid,
data,
} => {
// Kill ring Q#KR10a — the unified paste route, for
// BOTH attachment kinds. Handled here (not in
// `apply_event`) for two reasons:
//
// 1. The semantic input dispatcher used to drop
// `Paste` entirely, so GPU Ctrl-V was a no-op
// (pmacs-gpu always negotiates semantic render).
// 2. The authenticated `source` is in scope. The
// event's `claimed_fid` is client-supplied and
// not trusted (the CrdtOp / Viewport / Pointer
// source-trust rule); the old grid arm set
// `active_frontend` from it, letting a forged id
// paste into another frontend's active window.
//
// The paste is a non-command edit, so it breaks the
// source's command chain (Q#KR2), and it fires
// `buffer.after-edit` like any other edit (Q#KR10b)
// — previously it never did, so LSP missed pastes.
handle_inbound_paste(editor, source, claimed_fid, &data);
}
_ => {
let term_size = *term_sizes
.get(&source)
@ -1579,10 +1603,21 @@ fn handle_dispatcher_event(
last_dispatch_idle_sent.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
{
let mut core = editor.core.borrow_mut();
core.unregister_frontend_view(frontend_id);
// Kill ring Q#KR11: frontend ids are monotonic, so
// per-frontend state must not outlive the session.
core.command_history.remove(&frontend_id);
}
// Q#KR11: let Lua modules holding per-frontend tables
// (killring sessions / kill flags) drop this id's entries.
// The first frontend-lifecycle hook; carries the raw id.
let mut args = mlua::MultiValue::new();
args.push_back(mlua::Value::Integer(
i64::try_from(frontend_id.0).unwrap_or(i64::MAX),
));
editor.lua_host.run_hook("frontend.detached", args);
}
}
}
@ -1914,6 +1949,35 @@ fn validate_remote_crdt_op(
Ok(())
}
/// The unified inbound-paste route (kill ring Q#KR10a) — one handler
/// for grid *and* semantic sessions, keyed by the dispatcher's
/// authenticated `source`. `claimed` is the event payload's
/// client-supplied id: never trusted (a forged id must not paste into
/// another frontend's active window), only logged on mismatch. The
/// paste breaks the source's command chain (a non-command edit, Q#KR2)
/// and fires `buffer.after-edit` when the buffer changed (Q#KR10b).
fn handle_inbound_paste(
editor: &mut EditorState,
source: FrontendId,
claimed: FrontendId,
data: &[u8],
) {
if claimed != source {
eprintln!(
"pmacs daemon: Paste claimed {claimed:?} but came \
from {source:?}; using the authenticated source"
);
}
editor.core.borrow_mut().active_frontend = source;
editor.with_after_edit_check(|state| {
let mut core = state.core.borrow_mut();
core.break_command_chain(source);
if let Err(e) = core.paste_inbound(data) {
eprintln!("pmacs: inbound paste failed: {e}");
}
});
}
/// T M10.10 (post-audit) — apply a *pre-validated*
/// `FrontendEvent::CrdtOp`. Identity, capability, and scope checks
/// happen upstream in `validate_remote_crdt_op`; this function trusts
@ -1944,6 +2008,12 @@ fn handle_remote_crdt_op(
buffer_id: crate::buffer::BufferId,
op: crate::rope::CrdtOp,
) {
// Kill ring Q#KR2: an optimistic edit is non-command input — GPU
// typing, Enter/Tab, Backspace/Delete all arrive here without ever
// touching dispatch_key. It must break the source frontend's
// command chain, or `C-k x C-k` on the GPU would append across the
// typed character.
editor.core.borrow_mut().break_command_chain(source);
// Effect 1: apply to buffer's CRDT + rope. Capture the Edit
// (or `None` for an op that imported cleanly but produced no
// text delta — F17).
@ -2193,9 +2263,10 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre
/// arms of [`apply_event`] but takes no `RenderState` — a semantic
/// frontend lays out locally, so the only state these events touch is
/// the editor core (cursor, buffer, commands), which `dispatch_key` /
/// `dispatch_mouse` operate on directly. `Resize` / `Paste` / `Focus`
/// have no grid-less effect yet and are dropped; `Viewport` / `CrdtOp`
/// are handled in their own dispatcher arms and never reach here.
/// `dispatch_mouse` operate on directly. `Resize` / `Focus` have no
/// grid-less effect yet and are dropped; `Viewport` / `CrdtOp` /
/// `Paste` (Q#KR10a) are handled in their own dispatcher arms and
/// never reach here.
#[allow(clippy::needless_pass_by_value)] // consumes the event, mirroring `apply_event`.
fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_size: CellSize) {
match ev {
@ -2238,20 +2309,13 @@ fn apply_event(
render_state.resize(size);
*term_size = size;
}
FrontendEvent::Paste { frontend_id, data } => {
// Q#CM6 — inbound OS paste (terminal bracketed paste, or
// GPU Ctrl-V reading `arboard`). Insert at the cursor of the
// originating frontend's active window, replacing any region,
// and refresh the clipboard slot so a later in-app paste
// repeats the same text. (Previously dropped: pmacs had
// never honored a paste.)
let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id;
if let Err(e) = core.paste_inbound(&data) {
eprintln!("pmacs: inbound paste failed: {e}");
}
}
FrontendEvent::FocusGained(_)
// Q#KR10a — Paste is handled in the dispatcher's own
// `FrontendEvent::Paste` arm (unified for grid and semantic
// sessions, keyed by the authenticated source), and never
// reaches here. Listed explicitly so a future reshuffle can't
// silently re-route it through this payload-trusting path.
FrontendEvent::Paste { .. }
| FrontendEvent::FocusGained(_)
| FrontendEvent::FocusLost(_)
// T M11.1: the semantic-frontend viewport declaration. Its
// consumer is the instance-side projection seam
@ -2447,6 +2511,188 @@ mod tests {
);
}
/// Kill ring Q#KR2 — an optimistic edit is non-command input: GPU
/// typing arrives here without touching dispatch_key, so it must
/// break the source frontend's command chain or `C-k x C-k` on the
/// GPU would append across the typed character.
#[cfg(feature = "crdt")]
#[test]
fn handle_remote_crdt_op_breaks_the_source_command_chain() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let mut editor = EditorState::new();
let source = FrontendId(7);
// A live kill chain for the source frontend...
editor
.core
.borrow_mut()
.rotate_command(source, "edit.kill-line");
// ...and one for a bystander that must survive.
editor
.core
.borrow_mut()
.rotate_command(FrontendId::LOCAL, "edit.kill-line");
let buffer_id = editor.core.borrow().active_window().buffer_id;
{
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
}
let snapshot_bytes = {
let core = editor.core.borrow();
let reg = core.registry.borrow();
reg.get(buffer_id)
.expect("buffer")
.crdt_state()
.expect("crdt-backed")
.export_snapshot()
.expect("export snapshot")
};
let peer = loro::LoroDoc::new();
peer.set_peer_id(7).expect("set peer id");
peer.import(&snapshot_bytes).expect("import snapshot");
let v_before = peer.oplog_vv();
peer.get_text("body").insert(0, "x").expect("peer insert");
let op_bytes = peer
.export(loro::ExportMode::updates(&v_before))
.expect("export op");
handle_remote_crdt_op(
&mut editor,
source,
buffer_id,
crate::rope::CrdtOp {
peer_id: 7,
bytes: op_bytes,
},
);
let core = editor.core.borrow();
assert!(
core.command_history
.get(&source)
.is_none_or(|b| b.this.is_none()),
"the optimistic edit must break the source's chain"
);
assert_eq!(
core.command_history
.get(&FrontendId::LOCAL)
.and_then(|b| b.this.as_deref()),
Some("edit.kill-line"),
"a bystander frontend's chain is untouched"
);
}
/// Kill ring Q#KR10a — the unified paste route trusts only the
/// dispatcher's authenticated source. A forged payload id must not
/// paste into another frontend's active window, and the paste
/// breaks the SOURCE's chain (not the claimed frontend's) and
/// fires `buffer.after-edit` exactly once.
#[test]
fn inbound_paste_uses_authenticated_source_not_the_claimed_id() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use crate::text_view::TextView;
use crate::window::{FrontendView, Layout, Window, WindowId};
let mut editor = EditorState::new();
editor
.lua_host
.eval(
Some("test"),
r#"
_G.PASTE_AFTER_EDIT = 0
pmacs.hook.add("buffer.after-edit", function()
_G.PASTE_AFTER_EDIT = _G.PASTE_AFTER_EDIT + 1
end)
"#,
)
.expect("install after-edit hook");
// Give the attacker frontend its OWN view onto its own buffer,
// so "which window did the text land in" is observable.
let source = FrontendId(7);
let victim = FrontendId::LOCAL;
let attacker_buf = {
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.create("attacker-buffer")
};
{
let mut core = editor.core.borrow_mut();
let tv = {
let reg = core.registry.borrow();
TextView::new(reg.get(attacker_buf).expect("attacker buffer"))
};
let wid = WindowId::next();
core.windows.insert(wid, Window::new(wid, attacker_buf, tv));
core.register_frontend_view(
source,
FrontendView {
layout: Layout::single(wid),
active: wid,
},
);
}
let victim_buf = editor.core.borrow().active_window().buffer_id;
// Seed a live chain on the victim: the forged paste must not
// break it (only the authenticated source's chain breaks).
editor
.core
.borrow_mut()
.rotate_command(victim, "edit.kill-line");
// The payload CLAIMS to be the victim.
handle_inbound_paste(&mut editor, source, victim, b"FORGED");
let core = editor.core.borrow();
let text_of = |id| {
let reg = core.registry.borrow();
let buf = reg.get(id).expect("buffer");
let len = buf.len();
let mut out = vec![0u8; usize::try_from(len).unwrap_or(0)];
if len > 0 {
buf.snapshot_rope().slice(0, len, &mut out);
}
String::from_utf8_lossy(&out).into_owned()
};
assert!(
text_of(attacker_buf).contains("FORGED"),
"the paste lands in the AUTHENTICATED source's active window"
);
assert!(
!text_of(victim_buf).contains("FORGED"),
"a forged payload id must not paste into the claimed frontend's window"
);
assert!(
core.command_history
.get(&source)
.is_none_or(|b| b.this.is_none()),
"the paste breaks the source's chain"
);
assert_eq!(
core.command_history
.get(&victim)
.and_then(|b| b.this.as_deref()),
Some("edit.kill-line"),
"the claimed frontend's chain is untouched"
);
drop(core);
let count = editor
.lua_host
.eval(Some("test-readback"), "return _G.PASTE_AFTER_EDIT")
.expect("read counter");
assert!(
matches!(count, mlua::Value::Integer(1)),
"paste fires buffer.after-edit exactly once, got {count:?}"
);
}
/// v15 regression: an optimistic-path edit (the bulk of plain-char
/// typing from a semantic frontend) must clear the transient
/// status message, exactly as `dispatch_key`'s entry clear does

View File

@ -327,6 +327,12 @@ impl EditorState {
include_str!("../builtin/runtime/autosave.lua"),
)
.expect("load autosave builtin chunk");
lua_host
.eval(
Some("@pmacs/builtin/runtime/killring.lua"),
include_str!("../builtin/runtime/killring.lua"),
)
.expect("load killring builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it
@ -706,6 +712,10 @@ impl EditorState {
match action {
Action::Run { command, .. } => {
// Kill ring Q#KR2: record the command boundary before the
// body runs, so the body's own `ed.last_command()` reads
// its *predecessor* (Emacs `last-command` semantics).
self.core.borrow_mut().rotate_command(frontend_id, &command);
if let Err(e) = self
.lua_host
.invoke_command(&command, mlua::MultiValue::new())
@ -723,20 +733,28 @@ impl EditorState {
// shadow instead of the dispatcher.
self.core.borrow_mut().completion_popup_close();
}
Action::Unbound { sequence } => match printable_char(&sequence) {
Some(ch) => {
Action::Unbound { sequence } => {
if let Some(ch) = printable_char(&sequence) {
// Typing a character is a command too (Q#KR2): it
// must break a kill chain — `C-k x C-k` is two ring
// entries, not an append.
self.core
.borrow_mut()
.rotate_command(frontend_id, "buffer.self-insert");
let mut args = mlua::MultiValue::new();
args.push_back(mlua::Value::Integer(ch as i64));
if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) {
self.core.borrow_mut().status =
format!("self-insert failed: {}", first_line(&e.to_string()));
}
}
None => {
} else {
// An unbound key still breaks the chain (Q#KR2) —
// Emacs's `undefined` runs as a command.
self.core.borrow_mut().break_command_chain(frontend_id);
self.core.borrow_mut().status =
format!("{}: not bound", display_sequence(&sequence));
}
},
}
}
let post_revision = self.active_buffer_revision();
@ -761,6 +779,32 @@ impl EditorState {
self.buffer_revision(id)
}
/// Run `f`, then fire `buffer.after-edit` if the active buffer's
/// revision changed — the same compare `dispatch_key` performs
/// after a keybound command (kill ring Q#KR10b).
///
/// For call sites that execute edits *outside* `dispatch_key`'s
/// post-command check: the minibuffer accept callback (`M-x`), the
/// menu invoke, and the unified paste route. Without it, those
/// edits are invisible to LSP `didChange`, the syntax reparse, and
/// autosave's observers.
///
/// Scope, honestly: the *active-buffer* before/after compare is
/// sound for these paths (all edit the active buffer and stay
/// there) but is not a general any-buffer guarantee — a callback
/// that edits buffer A then switches to B evades it. The general
/// fix is a buffer-aware edit epoch; deferred, named in the
/// kill-ring framing.
pub(crate) fn with_after_edit_check(&mut self, f: impl FnOnce(&mut Self)) {
let pre = self.active_buffer_revision();
f(self);
let post = self.active_buffer_revision();
if pre != post {
self.lua_host
.run_hook("buffer.after-edit", mlua::MultiValue::new());
}
}
/// Edit revision of a specific buffer, or `None` if the registry no
/// longer knows it. Used by the query-replace shadow to compare the
/// *edited* (origin) buffer, not whichever is active.
@ -946,13 +990,28 @@ impl EditorState {
fn menu_invoke_active(&mut self) {
let command = self.core.borrow().menu_active_command();
self.core.borrow_mut().menu_close();
if let Some(command) = command
&& let Err(e) = self
.lua_host
.invoke_command(&command, mlua::MultiValue::new())
{
self.core.borrow_mut().status =
format!("error in {command}: {}", first_line(&e.to_string()));
if let Some(command) = command {
// A menu item is an interactive command (kill ring Q#KR2):
// rotate the boundary so a menu Cut chains like a keybound
// one. The invoke below bypasses dispatch_key, which would
// otherwise leave the boundary stale.
{
let mut core = self.core.borrow_mut();
let fid = core.active_frontend;
core.rotate_command(fid, &command);
}
// Q#KR10b: menu invocation bypasses dispatch_key's
// revision check — a menu Cut's edit must still fire
// `buffer.after-edit`.
self.with_after_edit_check(|state| {
if let Err(e) = state
.lua_host
.invoke_command(&command, mlua::MultiValue::new())
{
state.core.borrow_mut().status =
format!("error in {command}: {}", first_line(&e.to_string()));
}
});
}
}
@ -1062,12 +1121,18 @@ impl EditorState {
.create_string(&contents)
.expect("Lua VM out of memory while building minibuffer callback args"),
));
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
self.core.borrow_mut().status = format!(
"minibuffer on_accept failed: {}",
first_line(&e.to_string())
);
}
// Q#KR10b: the accept callback runs outside dispatch_key's
// post-command revision check (the minibuffer interception
// returns before it), so an M-x'd editing command would never
// fire `buffer.after-edit` without this wrapper.
self.with_after_edit_check(|state| {
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
state.core.borrow_mut().status = format!(
"minibuffer on_accept failed: {}",
first_line(&e.to_string())
);
}
});
}
fn minibuffer_cancel(&mut self) {
@ -1159,6 +1224,12 @@ impl EditorState {
self.mouse_click = None;
return; // Mode-line click: reserved.
}
// Point moves: break the command chain (kill ring
// Q#KR2). Scroll arms below deliberately do NOT — a
// wheel that only moves the viewport preserves a kill
// chain, as in Emacs (`mwheel-scroll` vs
// `mouse-set-point`).
self.core.borrow_mut().break_command_chain(frontend_id);
let click_cell = CellCoord::new(cell_row, cell_col);
let is_double_click = self.is_double_click(frontend_id, win_id, click_cell);
self.activate_and_position(win_id, local_row, local_col);
@ -1181,10 +1252,12 @@ impl EditorState {
if local_row >= inner_rows {
return;
}
self.core.borrow_mut().break_command_chain(frontend_id);
self.activate_and_position(win_id, local_row, local_col);
}
MouseEventKind::Up(MouseButton::Left) => {
let mut core = self.core.borrow_mut();
core.break_command_chain(frontend_id);
if let Some(sel) = core.active_window().selection
&& sel.anchor == core.cursor()
{
@ -1197,6 +1270,8 @@ impl EditorState {
return; // Mode-line right-click: reserved.
}
self.mouse_click = None;
// Opening the menu is a pointer gesture too (Q#KR2).
self.core.borrow_mut().break_command_chain(frontend_id);
self.open_context_menu(win_id, local_row, local_col, (cell_row, cell_col));
}
MouseEventKind::ScrollUp => {
@ -1271,6 +1346,12 @@ impl EditorState {
return;
}
core.set_active_window_id(win_id);
// Every PointerKind moves point or changes the selection (the
// GPU scrolls locally via Viewport, which never reaches here),
// so any pointer gesture breaks the frontend's command chain
// (kill ring Q#KR2) — clicking away and killing again must not
// append, and M-y after a click must refuse.
core.break_command_chain(frontend_id);
let byte = {
let registry = core.registry.clone();
let reg = registry.borrow();

View File

@ -123,6 +123,25 @@ pub struct QueryReplaceSession {
found_any: bool,
}
/// One frontend's command boundary — Emacs's `this-command` /
/// `last-command` pair (kill ring, Q#KR2).
///
/// `this` is the command currently (or most recently) executing for the
/// frontend; `last` is the one before it. A chain-sensitive command
/// (kill append, `M-y`) reads `last` *while it runs* — its own rotation
/// already moved its predecessor there. `this = None` is a broken
/// chain: some non-command input (an optimistic CRDT edit, a pointer
/// gesture, a paste, an unbound key) intervened, so the next rotation
/// makes `last = None` and every chain check fails.
#[derive(Debug, Clone, Default)]
pub struct CommandBoundary {
/// The command executing now / most recently, or `None` after a
/// non-command input.
pub this: Option<String>,
/// The command before `this`.
pub last: Option<String>,
}
/// The world state mutated by editor commands.
pub struct EditorCore {
/// Shared buffer registry. The registry is the canonical owner
@ -217,6 +236,15 @@ pub struct EditorCore {
/// frontend, which writes the OS clipboard (OSC 52 in the TUI,
/// `arboard` in the GPU). Drained per-tick like `pending_crdt_ops`.
pending_clipboard: Option<(FrontendId, Vec<u8>)>,
/// Per-frontend command boundaries (kill ring, Q#KR2) — Emacs's
/// `this-command` / `last-command`, tracked **per frontend**: two
/// attached frontends interleave their own command streams, and a
/// kill chain or yank session on frontend A must not survive into
/// frontend B's checks. Every input path updates this — commands
/// rotate; non-command inputs (optimistic CRDT edits, pointer
/// gestures, pastes, unbound keys) break the chain. Entries are
/// pruned on `SessionDetached` (Q#KR11).
pub command_history: HashMap<FrontendId, CommandBoundary>,
/// Open context menu (Q#CM1), or `None` when closed. Shared
/// `Arc<Mutex>` so the TUI [`crate::menu::MenuView`] overlay renders
/// from the same state the dispatch path mutates — the menu twin of
@ -280,6 +308,7 @@ impl EditorCore {
search: None,
clipboard_slot: Vec::new(),
pending_clipboard: None,
command_history: HashMap::new(),
menu: crate::menu::make_shared_menu(),
completion_popup: crate::completion::make_shared_popup(),
round_trip_buffers: std::collections::HashSet::new(),
@ -2043,6 +2072,38 @@ impl EditorCore {
Some(out)
}
// ---- command boundaries (kill ring, Q#KR2) --------------------------
/// Record `name` as `fid`'s executing command: `last = this;
/// this = name`. Called once per interactive command dispatch —
/// keybound commands, the self-insert fallback, menu items, and
/// `pmacs.command.invoke_interactive` (`M-x`).
pub fn rotate_command(&mut self, fid: FrontendId, name: &str) {
let entry = self.command_history.entry(fid).or_default();
entry.last = entry.this.take();
entry.this = Some(name.to_owned());
}
/// Break `fid`'s command chain: a non-command input happened (an
/// optimistic CRDT edit, a point-moving pointer gesture, an inbound
/// paste, an unbound key). Sets `this = None`, so the next
/// rotation yields `last = None` and every chain-sensitive check
/// (kill append, `M-y`) fails.
pub fn break_command_chain(&mut self, fid: FrontendId) {
self.command_history.entry(fid).or_default().this = None;
}
/// The active frontend's previous command — Emacs's `last-command`
/// as observed *from inside* the currently-running command (its own
/// rotation already happened).
#[must_use]
pub fn last_command(&self) -> Option<&str> {
self.command_history
.get(&self.active_frontend)?
.last
.as_deref()
}
/// Copy the active region into the clipboard slot and queue an
/// outbound OS-clipboard publish to the originating frontend.
/// Returns `false` (a no-op) when there is no region.
@ -2069,6 +2130,28 @@ impl EditorCore {
Ok(true)
}
/// Set the clipboard slot to arbitrary bytes and queue the
/// OS-clipboard publish to the acting frontend (kill ring Q#KR1).
/// The ring's kills have no region for [`Self::clipboard_copy`] to
/// read (`C-k`'s killed line, an appended chain), so the Lua ring
/// pushes the exact bytes here.
pub fn clipboard_set(&mut self, bytes: Vec<u8>) {
self.clipboard_slot.clone_from(&bytes);
self.pending_clipboard = Some((self.active_frontend, bytes));
}
/// The clipboard slot's current bytes, or `None` when empty (kill
/// ring Q#KR6 — the yank-time "did external content arrive via a
/// paste since our last kill" check).
#[must_use]
pub fn clipboard_get(&self) -> Option<&[u8]> {
if self.clipboard_slot.is_empty() {
None
} else {
Some(&self.clipboard_slot)
}
}
/// Paste the clipboard slot at the cursor, replacing the active
/// region if one exists (one undo step, like CUA type-over).
/// Returns `false` when the slot is empty.

View File

@ -4652,6 +4652,42 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua::
)?;
}
{
// invoke_interactive(name, ...): like `invoke`, but records a
// command boundary first (kill ring Q#KR2) — `last = this;
// this = name` for the active frontend. Used by
// `editor.execute-command` (M-x) so the invoked command's
// chain semantics match Emacs's `execute-extended-command`
// (which sets `this-command`): `M-x edit.kill-line` then `C-k`
// appends, while `C-k` then `M-x edit.kill-line` does not.
//
// Plain `invoke` deliberately stamps NOTHING: it is a public
// programmatic API called from wrappers, hooks, and async
// callbacks, and must never pollute interactive command
// history.
let cmds = commands.clone();
command.set(
"invoke_interactive",
lua.create_function(move |lua, (name, args): (String, Variadic<Value>)| {
if let Some(core) = lua.app_data_ref::<SharedCore>() {
let mut core = core.borrow_mut();
let fid = core.active_frontend;
core.rotate_command(fid, &name);
}
let body = {
let r = cmds.borrow();
r.get(&name)
.ok_or_else(|| {
mlua::Error::external(CommandError::NotFound { name: name.clone() })
})?
.body
.clone()
};
body.call::<Variadic<Value>>(args)
})?,
)?;
}
{
let cmds = commands.clone();
command.set(
@ -10935,6 +10971,18 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
})?,
)?;
}
{
// last_command(): the active frontend's previous interactive
// command — Emacs's `last-command` as seen from inside the
// running command (kill ring Q#KR2). `nil` after a non-command
// input (optimistic edit, pointer gesture, paste, unbound key)
// broke the chain.
let cc = core.clone();
editor.set(
"last_command",
lua.create_function(move |_, ()| Ok(cc.borrow().last_command().map(str::to_owned)))?,
)?;
}
{
// view_top(): the active window's first visible source line.
// The saveplace getter (Arc 3) — pairs with set_view_top so a
@ -11107,6 +11155,35 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
})?,
)?;
}
{
// clipboard_set(bytes): set the slot + queue the OS publish to
// the acting frontend (kill ring Q#KR1). The ring's kills have
// no region for clipboard_copy to read.
let cc = core.clone();
editor.set(
"clipboard_set",
lua.create_function(move |_, bytes: mlua::String| {
cc.borrow_mut().clipboard_set(bytes.as_bytes().to_vec());
Ok(())
})?,
)?;
}
{
// clipboard_get() -> string?: the slot's bytes (kill ring
// Q#KR6 — yank's "did external content arrive via a paste
// since our last kill" check). nil when empty.
let cc = core.clone();
editor.set(
"clipboard_get",
lua.create_function(move |lua, ()| {
let cc = cc.borrow();
match cc.clipboard_get() {
Some(bytes) => Ok(Some(lua.create_string(bytes)?)),
None => Ok(None),
}
})?,
)?;
}
{
let cc = core.clone();
editor.set(

View File

@ -0,0 +1,660 @@
//! Kill-ring acceptance (Arc 2, docs/kill-ring-framing.md rev 3).
//!
//! Drives the real dispatch surfaces: `dispatch_key` for chords (both
//! "frontends" via distinct `FrontendId`s — an unregistered id falls
//! back to LOCAL's view, sharing the buffer while keeping its own
//! command boundary, which is exactly the shared-ring interleaving
//! shape), `dispatch_mouse` / `dispatch_pointer` for the pointer
//! boundary rows, and the real minibuffer for `M-x`.
//!
//! The daemon-side rows (optimistic CRDT edits, the unified
//! authenticated-source paste) are covered by unit tests next to
//! `handle_remote_crdt_op` / `handle_inbound_paste` in `src/daemon.rs`.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn ctrl(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(c), KeyModifiers::CONTROL),
);
}
fn alt(s: &mut EditorState, c: char) {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn ring(s: &EditorState) -> Vec<String> {
eval(s, "return pmacs.killring.list()")
}
fn buffer_text(s: &EditorState) -> String {
let b: mlua::String = eval(
s,
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// Fresh editor whose scratch buffer holds `text`, cursor at 0.
fn editor_with(text: &str) -> EditorState {
let mut s = EditorState::new();
type_str(&mut s, text);
exec(&s, "pmacs.editor.goto_byte(0)");
s
}
// ---------------------------------------------------------------------------
// Chain mechanics
// ---------------------------------------------------------------------------
#[test]
fn kill_line_kills_to_eol_and_the_newline_separately() {
let mut s = editor_with("hello\nworld");
ctrl(&mut s, 'k'); // kills "hello"
assert_eq!(buffer_text(&s), "\nworld");
assert_eq!(ring(&s), vec!["hello"]);
// Cursor now sits at the newline: C-k kills the newline itself —
// and, being consecutive, APPENDS.
ctrl(&mut s, 'k');
assert_eq!(buffer_text(&s), "world");
assert_eq!(ring(&s), vec!["hello\n"], "consecutive C-k appends");
}
#[test]
fn consecutive_kills_build_one_entry_and_sync_the_clipboard() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
ctrl(&mut s, 'k'); // "\n"
ctrl(&mut s, 'k'); // "two"
assert_eq!(ring(&s), vec!["one\ntwo"]);
// OS slot mirrors the appended head (Q#KR4).
let slot: String = eval(&s, "return pmacs.editor.clipboard_get()");
assert_eq!(slot, "one\ntwo");
}
#[test]
fn movement_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one"; buffer now "\ntwo\n"
press(&mut s, KeyCode::Down); // a cursor command: chain broken
exec(&s, "pmacs.editor.goto_byte(1)"); // start of "two"
ctrl(&mut s, 'k'); // "two"
assert_eq!(ring(&s), vec!["two", "one"], "two entries, no append");
}
#[test]
fn self_insert_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one" (line now "\ntwo\n", cursor 0)
type_str(&mut s, "x"); // buffer "x\ntwo\n"
ctrl(&mut s, 'k'); // kills "" ... wait: cursor after 'x' is 1, at "\n"
// cursor sits at the newline → kills it.
assert_eq!(ring(&s), vec!["\n", "one"], "self-insert broke the chain");
}
#[test]
fn an_unbound_key_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k');
press(&mut s, KeyCode::F(12)); // unbound, not printable
assert!(status(&s).contains("not bound"));
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k');
assert_eq!(ring(&s).len(), 2, "unbound key broke the chain");
}
#[test]
fn failed_kill_does_not_leave_an_appendable_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // ring: ["one"]
ctrl(&mut s, 'w'); // no region → fails, clears last_kill_id
assert!(status(&s).contains("no region"));
assert_eq!(ring(&s), vec!["one"], "failed kill pushed nothing");
ctrl(&mut s, 'k'); // kills "\n"
assert_eq!(
ring(&s),
vec!["\n", "one"],
"a kill after a FAILED kill pushes fresh (no stale append)"
);
}
// ---------------------------------------------------------------------------
// M-x semantics (the three-direction matrix, Q#KR2)
// ---------------------------------------------------------------------------
fn m_x(s: &mut EditorState, name: &str) {
alt(s, 'x');
type_str(s, name);
press(s, KeyCode::Enter);
}
#[test]
fn m_x_kill_after_keybound_kill_does_not_append() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
m_x(&mut s, "edit.kill-line"); // kills "\n" — but must NOT append
assert_eq!(
ring(&s),
vec!["\n", "one"],
"the minibuffer interaction breaks the chain (Emacs semantics)"
);
}
#[test]
fn keybound_kill_after_m_x_kill_appends() {
let mut s = editor_with("one\ntwo\nthree\n");
m_x(&mut s, "edit.kill-line"); // "one" — invoke_interactive stamps it
ctrl(&mut s, 'k'); // "\n" — last_command is edit.kill-line → append
assert_eq!(
ring(&s),
vec!["one\n"],
"execute-extended-command sets this-command (Emacs semantics)"
);
}
#[test]
fn m_x_kill_twice_does_not_append() {
let mut s = editor_with("one\ntwo\nthree\n");
m_x(&mut s, "edit.kill-line");
m_x(&mut s, "edit.kill-line");
assert_eq!(ring(&s).len(), 2, "each M-x interposes execute-command");
}
// ---------------------------------------------------------------------------
// Yank / yank-pop
// ---------------------------------------------------------------------------
#[test]
fn yank_inserts_the_head_and_yank_pop_cycles_and_wraps() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
press(&mut s, KeyCode::Down); // break chain
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k'); // rest of "two"-line from byte 1: "wo"... careful
// (buffer was "\ntwo\nthree\n"; byte 1 = 't'; kills "two"[1..] = "wo")
let r = ring(&s);
assert_eq!(r.len(), 2);
let newest = r[0].clone(); // "wo"
let oldest = r[1].clone(); // "one"
// Yank at the end of the buffer.
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y');
assert!(buffer_text(&s).ends_with(&newest), "C-y yanks the head");
let cursor_after_yank: i64 = eval(&s, "return pmacs.editor.cursor()");
assert_eq!(
cursor_after_yank,
len + i64::try_from(newest.len()).unwrap()
);
// M-y replaces with the older entry...
alt(&mut s, 'y');
assert!(buffer_text(&s).ends_with(&oldest), "M-y rotates to older");
let cursor: i64 = eval(&s, "return pmacs.editor.cursor()");
assert_eq!(
cursor,
len + i64::try_from(oldest.len()).unwrap(),
"cursor at end of rotation"
);
// ...and wraps back to the newest.
alt(&mut s, 'y');
assert!(buffer_text(&s).ends_with(&newest), "M-y wraps");
}
#[test]
fn yank_pop_without_a_yank_refuses_and_stays_refused() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k');
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(status(&s).contains("not a yank"));
assert_eq!(buffer_text(&s), before, "no edit on refusal");
// A second M-y must not ride the first one's name-stamp (Q#KR7):
// last_command IS edit.yank-pop now, but no session exists.
alt(&mut s, 'y');
assert!(status(&s).contains("not a yank"));
assert_eq!(buffer_text(&s), before);
}
#[test]
fn pointer_click_breaks_kill_chain_and_yank_session() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut s = editor_with("one\ntwo\nthree\n");
let term = pmacs::cell::CellSize::new(24, 80);
let click = |s: &mut EditorState, kind| {
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind,
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
},
term,
);
};
// Kill side: C-k, click, C-k → two entries.
ctrl(&mut s, 'k');
click(&mut s, MouseEventKind::Down(MouseButton::Left));
click(&mut s, MouseEventKind::Up(MouseButton::Left));
ctrl(&mut s, 'k');
assert_eq!(ring(&s).len(), 2, "a click must break the kill chain");
// Yank side: C-y, click, M-y → refused.
ctrl(&mut s, 'y');
click(&mut s, MouseEventKind::Down(MouseButton::Left));
click(&mut s, MouseEventKind::Up(MouseButton::Left));
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(
status(&s).contains("not a yank"),
"click invalidates the yank"
);
assert_eq!(buffer_text(&s), before);
}
#[test]
fn wheel_scroll_does_not_break_the_kill_chain() {
use crossterm::event::{MouseEvent, MouseEventKind};
let term = pmacs::cell::CellSize::new(24, 80);
let scroll = |s: &mut EditorState, kind| {
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind,
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
},
term,
);
};
// Case 1 — a boundary-clamped scroll (ScrollUp at the top) moves
// nothing at all: the chain holds and the next C-k appends in
// place.
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
scroll(&mut s, MouseEventKind::ScrollUp);
ctrl(&mut s, 'k'); // the newline
assert_eq!(ring(&s), vec!["one\n"], "no-op scroll preserves the chain");
// Case 2 — pmacs scrolling is cursor-follows-view, so a mid-buffer
// wheel moves point too. The chain STILL holds (Emacs: mwheel
// preserves last-command) and the next kill appends from the NEW
// point — one entry, not two.
let mut s = editor_with("one\ntwo\nthree\nfour\nfive\nsix\n");
ctrl(&mut s, 'k'); // "one"
scroll(&mut s, MouseEventKind::ScrollDown);
ctrl(&mut s, 'k');
assert_eq!(
ring(&s).len(),
1,
"a cursor-following scroll still preserves the chain: {:?}",
ring(&s)
);
}
#[test]
fn semantic_pointer_gesture_breaks_the_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k');
let buf_id = s.core.borrow().active_window().buffer_id;
s.dispatch_pointer(
FrontendId::LOCAL,
buf_id,
5,
pmacs::protocol::PointerKind::Down,
pmacs::protocol::Modifiers::NONE,
);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k');
assert_eq!(
ring(&s).len(),
2,
"a semantic pointer Down breaks the chain"
);
}
// ---------------------------------------------------------------------------
// Region kills, selection yank
// ---------------------------------------------------------------------------
#[test]
fn cut_and_copy_feed_the_ring_and_yank_replaces_a_selection() {
let mut s = editor_with("alpha beta\n");
// Select "alpha" (bytes 0..5).
exec(
&s,
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
);
alt(&mut s, 'w'); // copy
assert_eq!(ring(&s), vec!["alpha"]);
assert_eq!(buffer_text(&s), "alpha beta\n", "copy does not delete");
// Cut " beta" (bytes 5..10).
exec(
&s,
"pmacs.editor.goto_byte(10); pmacs.editor.begin_selection(5)",
);
ctrl(&mut s, 'w');
assert_eq!(buffer_text(&s), "alpha\n");
assert_eq!(ring(&s), vec![" beta", "alpha"]);
// Yank over a selection replaces it, and M-y rotates against the
// replaced range.
exec(
&s,
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
);
ctrl(&mut s, 'y'); // "alpha" → " beta"
assert_eq!(buffer_text(&s), " beta\n");
alt(&mut s, 'y'); // rotate to "alpha"
assert_eq!(buffer_text(&s), "alpha\n");
}
// ---------------------------------------------------------------------------
// Shared-ring interleaving (two frontends)
// ---------------------------------------------------------------------------
/// A second "frontend": an unregistered id shares LOCAL's view (the
/// `active_view` fallback) but has its own command boundary and killring
/// session — the exact shape of the Q#KR4/KR7 interleaving blockers.
const B: FrontendId = FrontendId(9);
fn ctrl_as(s: &mut EditorState, fid: FrontendId, c: char) {
s.dispatch_key(fid, key(KeyCode::Char(c), KeyModifiers::CONTROL));
}
#[test]
fn interleaved_kills_never_append_across_frontends() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // A kills "one"
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl_as(&mut s, B, 'k'); // B kills "wo" — B's own first kill
exec(&s, "pmacs.editor.goto_byte(2)");
ctrl(&mut s, 'k'); // A again — A's last_kill is NOT the head (B's is)
let r = ring(&s);
assert_eq!(r.len(), 3, "A-kill/B-kill/A-kill = three entries");
assert_eq!(r[1], "two", "B's entry intact — never appended onto");
}
#[test]
fn yank_pop_rotates_from_the_sessions_own_entry_despite_other_pushes() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // A: ring ["one"]
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // A yanks "one"; session → entry("one")
// B pushes a new head, shifting positions but not ids.
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl_as(&mut s, B, 'k'); // B kills "wo" → ring ["wo", "one"]
// A's M-y must rotate from A's OWN entry ("one" — now position 2),
// to the next-older-with-wrap = "wo". An index-based session would
// have mis-resolved after B's push.
// (B's kill edited the buffer, but before A's yanked range, so the
// session's slice-verify passes — the range shifted is upstream.)
// Note: B's kill removed bytes BEFORE the yank range, so the
// remembered {start,stop} no longer hold the yanked text → the
// invalidation guard fires instead. That IS the specified
// behavior: refuse rather than splice a shifted range.
alt(&mut s, 'y');
assert!(
status(&s).contains("changed since the yank"),
"a concurrent upstream edit invalidates rather than mis-splices: {:?}",
status(&s)
);
}
#[test]
fn yank_pop_uses_stable_ids_when_other_pushes_leave_the_range_intact() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // ring ["one"], A's chain live
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // A yanks "one" at end; session entry = "one"
// B COPIES bytes 1..3 ("tw" of the post-kill buffer "\ntwo\none"):
// ring becomes ["tw", "one"], buffer untouched, so A's yanked
// range is still intact.
exec(
&s,
"pmacs.editor.begin_selection(1); pmacs.editor.goto_byte(3)",
);
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
assert_eq!(ring(&s), vec!["tw", "one"]);
// A's M-y: the session's entry ("one") sits at position 2 now;
// next-older-with-wrap is B's copy. An integer index recorded at
// yank time (position 1) would have rotated from the wrong place.
alt(&mut s, 'y');
assert!(
buffer_text(&s).ends_with("tw"),
"rotation follows the stable id, not a shifted index: {:?}",
buffer_text(&s)
);
}
#[test]
fn eviction_mid_session_invalidates_the_yank_pop() {
let mut s = editor_with("one\ntwo\nthree\nfour\n");
exec(&s, "pmacs.killring.max(2)");
ctrl(&mut s, 'k'); // ring ["one"]
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // session → entry "one"
// Two copies (buffer untouched) evict "one" from a cap-2 ring.
exec(
&s,
"pmacs.editor.begin_selection(1); pmacs.editor.goto_byte(3)",
);
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
exec(
&s,
"pmacs.editor.begin_selection(5); pmacs.editor.goto_byte(8)",
);
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
let r = ring(&s);
assert_eq!(r.len(), 2);
assert!(!r.contains(&"one".to_string()), "'one' evicted");
alt(&mut s, 'y');
assert!(
status(&s).contains("expired"),
"an evicted session entry refuses cleanly: {:?}",
status(&s)
);
}
// ---------------------------------------------------------------------------
// External content, menu, hook delivery, cap
// ---------------------------------------------------------------------------
#[test]
fn externally_pasted_content_joins_the_ring_at_yank() {
let mut s = editor_with("");
ctrl(&mut s, 'k'); // fails (empty buffer) — ring stays empty
// An OS paste arrives (the daemon route sets the slot + inserts).
s.core.borrow_mut().paste_inbound(b"external").unwrap();
assert_eq!(buffer_text(&s), "external");
// The next yank notices slot ≠ head, pushes it, and yanks it.
ctrl(&mut s, 'y');
assert_eq!(ring(&s), vec!["external"]);
assert_eq!(buffer_text(&s), "externalexternal");
}
#[test]
fn menu_cut_feeds_the_ring_fires_after_edit_and_chains() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut s = editor_with("alpha beta\n");
exec(
&s,
r#"
_G.AE = 0
pmacs.hook.add("buffer.after-edit", function() _G.AE = _G.AE + 1 end)
"#,
);
// Select "alpha", open the context menu via right-click, invoke Cut.
exec(
&s,
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
);
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 2,
row: 0,
modifiers: KeyModifiers::NONE,
},
pmacs::cell::CellSize::new(24, 80),
);
assert!(
s.core.borrow().menu_is_open(),
"right-click opened the menu"
);
// Find the "edit.cut" row in the open menu's state.
let cut_index = {
let menu = s.core.borrow().menu.clone();
let guard = menu.lock().unwrap();
guard
.as_ref()
.and_then(|m| {
m.rows.iter().position(|r| {
matches!(r, pmacs::menu::MenuRow::Item { command, .. }
if command == "edit.cut")
})
})
.expect("menu has a Cut row")
};
s.dispatch_menu_pointer(
FrontendId::LOCAL,
Some(u32::try_from(cut_index).unwrap()),
true,
);
assert_eq!(ring(&s), vec!["alpha"], "menu Cut fed the ring");
assert_eq!(buffer_text(&s), " beta\n");
let fired: i64 = eval(&s, "return _G.AE");
assert_eq!(fired, 1, "menu Cut fires after-edit exactly once (Q#KR10b)");
// The menu rotation makes the cut chain like a keybound one:
// a following C-k appends.
ctrl(&mut s, 'k');
let r = ring(&s);
assert_eq!(r[0], "alpha beta", "menu Cut then C-k appends (rotate row)");
}
#[test]
fn m_x_kill_fires_after_edit_and_keybound_does_not_double_fire() {
let mut s = editor_with("one\ntwo\n");
exec(
&s,
r#"
_G.AE = 0
pmacs.hook.add("buffer.after-edit", function() _G.AE = _G.AE + 1 end)
"#,
);
ctrl(&mut s, 'k');
let after_keybound: i64 = eval(&s, "return _G.AE");
assert_eq!(after_keybound, 1, "keybound kill fires once, no double");
m_x(&mut s, "edit.kill-line");
let after_mx: i64 = eval(&s, "return _G.AE");
assert_eq!(after_mx, 2, "M-x kill fires after-edit too (Q#KR10b)");
}
#[test]
fn cap_is_validated_and_shrink_trims() {
let s = editor_with("");
let d: i64 = eval(&s, "return pmacs.killring.max()");
assert_eq!(d, 60);
for bad in ["0/0", "math.huge", "-math.huge", "0", "-3", "'ten'", "{}"] {
let ok: bool = eval(&s, &format!("return (pcall(pmacs.killring.max, {bad}))"));
assert!(!ok, "killring.max({bad}) must be rejected");
}
let set: i64 = eval(&s, "return pmacs.killring.max(2.9)");
assert_eq!(set, 2, "floored");
// Five distinct entries via COPY over a static buffer (copies never
// delete, so the byte offsets stay put; each text is distinct so
// duplicate-of-head collapse never fires).
let mut s2 = editor_with("aa bb cc dd ee\n");
exec(&s2, "pmacs.killring.max(10)");
for i in 0..5i64 {
let lo = i * 3;
exec(
&s2,
&format!(
"pmacs.editor.begin_selection({lo}); pmacs.editor.goto_byte({})",
lo + 2
),
);
alt(&mut s2, 'w');
}
assert_eq!(ring(&s2).len(), 5);
let trimmed: i64 = eval(&s2, "return pmacs.killring.max(3)");
assert_eq!(trimmed, 3);
assert_eq!(ring(&s2).len(), 3, "shrinking the cap trims immediately");
}
#[test]
fn frontend_detached_drops_per_frontend_state() {
let mut s = editor_with("one\ntwo\n");
ctrl_as(&mut s, B, 'k'); // B kills → B has last_kill_id
let has: bool = eval(
&s,
"return pmacs.killring._debug_state(9).last_kill_id ~= nil",
);
assert!(has, "B has kill state");
// The daemon fires this on SessionDetached (Q#KR11); fire it the
// same way to exercise the Lua-side cleanup.
exec(&s, "pmacs.hook.run('frontend.detached', 9)");
let gone: bool = eval(
&s,
"local st = pmacs.killring._debug_state(9); \
return st.last_kill_id == nil and st.session == nil",
);
assert!(gone, "detach dropped B's killring state");
}