diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 865068c..deaf6ef 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -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; diff --git a/builtin/hooks/default.lua b/builtin/hooks/default.lua index 98d89e4..7fe3a0a 100644 --- a/builtin/hooks/default.lua +++ b/builtin/hooks/default.lua @@ -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 " .. diff --git a/builtin/runtime/killring.lua b/builtin/runtime/killring.lua new file mode 100644 index 0000000..730ec6c --- /dev/null +++ b/builtin/runtime/killring.lua @@ -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" } diff --git a/src/daemon.rs b/src/daemon.rs index 4c2f06f..15342de 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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 diff --git a/src/editor.rs b/src/editor.rs index 5fd9f61..67cdc59 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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::(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::(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(); diff --git a/src/editor_core.rs b/src/editor_core.rs index 1b14c44..6eb50d0 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -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, + /// The command before `this`. + pub last: Option, +} + /// 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)>, + /// 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, /// Open context menu (Q#CM1), or `None` when closed. Shared /// `Arc` 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) { + 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. diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 2830bed..49e1b79 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -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)| { + if let Some(core) = lua.app_data_ref::() { + 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::>(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( diff --git a/tests/kill_ring_acceptance.rs b/tests/kill_ring_acceptance.rs new file mode 100644 index 0000000..c154f59 --- /dev/null +++ b/tests/kill_ring_acceptance.rs @@ -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(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +fn ring(s: &EditorState) -> Vec { + 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"); +}