fix(edit): semantic right-click breaks the chain; intercept-safe kill/yank-pop

Addresses the PR #103 review.

- BLOCKING semantic right-click: the dispatcher routes
  PointerKind::Context directly to open_menu_at_byte, bypassing
  dispatch_pointer's break — so GPU C-k, right-click, dismiss, C-k still
  appended, and M-y survived the click. open_menu_at_byte now breaks the
  chain like the grid right-click path.

- HIGH C-k under intercepts: kill_line captured text then called
  buf:delete un-pcall'd. A REJECTING intercept threw before fail_kill,
  leaving the old chain live (the next C-k appended to a kill that never
  happened); a TRANSFORMING intercept could delete different bytes while
  the ring and OS clipboard kept the original text. The delete is now
  pcall'd and verified by length delta: rejection clears the chain with a
  status; a transformed delete feeds nothing (the interceptor's result
  stands — accepted post-hoc semantics), also clearing the chain. Same
  discipline applied to cut's delete_region.

- HIGH rejected M-y: buf:replace ran outside pcall, so a rejecting
  intercept threw through command dispatch and left sessions[fid] live —
  a second M-y could reuse the supposedly-invalid session. The replace is
  pcall'd; rejection drops the session with a status.

Tests (kill_ring_acceptance now 28): semantic_context_right_click_breaks
_the_chain (drives open_menu_at_byte directly — the GPU route);
rejecting_intercept_clears_the_kill_chain (reject-once intercept: the
kill after the rejection pushes fresh, not append);
transforming_intercept_does_not_feed_the_ring (delete shrunk to one
byte: ring untouched, interceptor's result stands);
rejecting_intercept_ends_the_yank_session (second M-y refuses on
no-session, no splice).

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 28;
cua 5; m6_4 repl (intercept suite) 15; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-09 20:08:17 -04:00
parent 04314ce132
commit c6038a790d
3 changed files with 170 additions and 4 deletions

View File

@ -117,9 +117,18 @@ function pmacs.killring.cut()
return false
end
local text = buf:slice(region.start, region["end"])
if not ed.delete_region() then
-- Same intercept discipline as kill_line: a rejected or transformed
-- delete must not feed the ring or leave a live chain.
local len_before = buf:len()
local dok, deleted = pcall(ed.delete_region)
if not dok or not deleted then
fail_kill(fid)
ed.set_status("no region")
ed.set_status(dok and "no region" or "kill rejected by buffer intercept")
return false
end
if len_before - buf:len() ~= #text then
fail_kill(fid)
ed.set_status("kill altered by buffer intercept; ring not updated")
return false
end
kill_push(fid, text)
@ -185,7 +194,25 @@ function pmacs.killring.kill_line()
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)
-- The delete runs the buffer's edit intercepts, which may REJECT
-- (error) or TRANSFORM the operation. A rejection must clear the
-- kill chain (or the next C-k would append to a kill that never
-- happened); a transformation means the bytes actually removed are
-- not `text`, so pushing `text` would put never-killed bytes on the
-- ring and the OS clipboard. Verify by length delta: only a clean,
-- untransformed delete feeds the ring.
local len_before = buf:len()
local ok = pcall(function() buf:delete(cursor, kill_to) end)
if not ok then
fail_kill(fid)
ed.set_status("kill rejected by buffer intercept")
return false
end
if len_before - buf:len() ~= #text then
fail_kill(fid)
ed.set_status("kill altered by buffer intercept; ring not updated")
return false
end
kill_push(fid, text)
return true
end
@ -274,7 +301,15 @@ function pmacs.killring.yank_pop()
return false
end
local entry = ring[pos % #ring + 1]
buf:replace(s.start, s.stop, entry.text)
-- The replace runs buffer intercepts, which may REJECT by erroring.
-- A rejection must still end the session — letting the error
-- propagate would leave `sessions[fid]` live, and a second M-y
-- could reuse the supposedly-invalid session.
local rok = pcall(function() buf:replace(s.start, s.stop, entry.text) end)
if not rok then
drop_session(fid, "yank-pop rejected by buffer intercept")
return false
end
-- Verify the applied edit: buffer intercepts may alter or reject a
-- replace. Accepted post-hoc semantics (Q#KR7): on mismatch the
-- interceptor's result stands, the session ends, and we say so.

View File

@ -1429,6 +1429,12 @@ impl EditorState {
return;
}
core.set_active_window_id(win_id);
// A context right-click is a pointer gesture (kill ring
// Q#KR2): it must break the chain like the grid path's
// right-click does. The semantic dispatcher routes
// PointerKind::Context here directly, bypassing
// dispatch_pointer's break.
core.break_command_chain(frontend_id);
if core.active_region().is_none() {
let snapped = {
let registry = core.registry.clone();

View File

@ -639,6 +639,131 @@ fn cap_is_validated_and_shrink_trims() {
assert_eq!(ring(&s2).len(), 3, "shrinking the cap trims immediately");
}
#[test]
fn semantic_context_right_click_breaks_the_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one" — chain live
// The semantic dispatcher routes PointerKind::Context straight to
// open_menu_at_byte, bypassing dispatch_pointer — the GPU
// right-click path.
let buf_id = s.core.borrow().active_window().buffer_id;
s.open_menu_at_byte(FrontendId::LOCAL, buf_id, 3);
// Dismiss the menu without invoking anything.
s.core.borrow_mut().menu_close();
ctrl(&mut s, 'k');
assert_eq!(
ring(&s).len(),
2,
"a semantic right-click must break the kill chain: {:?}",
ring(&s)
);
}
#[test]
fn rejecting_intercept_clears_the_kill_chain() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one" — chain live, ring ["one"]
// An intercept that rejects exactly the NEXT edit, then allows.
exec(
&s,
r#"
_G.reject_once = true
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
if _G.reject_once then
_G.reject_once = false
error("rejected by test intercept")
end
return nil
end)
"#,
);
ctrl(&mut s, 'k'); // rejected — must clear the chain, push nothing
assert!(
status(&s).contains("rejected"),
"rejection is reported: {:?}",
status(&s)
);
assert_eq!(ring(&s), vec!["one"], "a rejected kill feeds nothing");
ctrl(&mut s, 'k'); // allowed again — must push FRESH, not append
assert_eq!(
ring(&s),
vec!["\n", "one"],
"the chain did not survive the rejection"
);
}
#[test]
fn transforming_intercept_does_not_feed_the_ring() {
let mut s = editor_with("alpha\nbeta\n");
// An intercept that shrinks every delete to its first byte: the
// bytes actually removed are not what C-k sliced, so pushing the
// sliced text would put never-killed bytes on the ring.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then
return { kind = "delete", start = op.start, ["end"] = op.start + 1 }
end
return nil
end)
"#,
);
ctrl(&mut s, 'k');
assert!(
status(&s).contains("altered"),
"transformation is reported: {:?}",
status(&s)
);
assert!(ring(&s).is_empty(), "a transformed kill feeds nothing");
// The interceptor's result stands (accepted post-hoc semantics).
assert_eq!(buffer_text(&s), "lpha\nbeta\n");
}
#[test]
fn rejecting_intercept_ends_the_yank_session() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one"
press(&mut s, KeyCode::Down);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k'); // "two"
assert_eq!(ring(&s).len(), 2);
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 live
// Reject the next edit (the M-y replace).
exec(
&s,
r#"
_G.reject_once = true
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
if _G.reject_once then
_G.reject_once = false
error("rejected by test intercept")
end
return nil
end)
"#,
);
alt(&mut s, 'y'); // rejected — must END the session, not throw through
assert!(
status(&s).contains("rejected"),
"rejection reported: {:?}",
status(&s)
);
// A second M-y must refuse on "no session", not reuse the dead one.
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(
status(&s).contains("not a yank"),
"the rejected session is gone: {:?}",
status(&s)
);
assert_eq!(buffer_text(&s), before, "no splice from a dead session");
}
#[test]
fn frontend_detached_drops_per_frontend_state() {
let mut s = editor_with("one\ntwo\n");