fix(edit): exact effective-edit verification for kill/yank-pop

Addresses the PR #103 round-3 review: length-delta verification is
defeated by an intercept that rewrites an op to a DIFFERENT
equal-length range, and "replacement text appears at start" is defeated
by one that enlarges `end` by a byte.

The buffer mutators (buf:insert/delete/replace) now RETURN the
effective edit — `(start, end, inserted_len)` of the post-intercept
operation actually applied (they returned nothing before, so no caller
breaks). killring compares those against what it requested:

- C-k / cut: any deviation (shifted range, resized range, nonzero
  insertion) means the bytes removed are not the bytes sliced — the
  ring and OS clipboard receive nothing, the chain clears, and the
  interceptor's result stands. cut now goes through buf:delete (for
  the effective edit) with explicit clear_selection + goto_byte.
- M-y: any deviation from (s.start, s.stop, #entry.text) drops the
  session — including the end+1 enlargement that silently deleted an
  extra byte while passing the old text-at-start check. The redundant
  post-replace slice verify is gone; the exact contract replaces it.

Tests (kill_ring_acceptance now 30):
equal_length_shifted_delete_does_not_feed_the_ring (delete shifted +2,
same length — the case a length delta cannot see),
stop_enlarging_replace_ends_the_yank_session (mid-buffer yank so the
enlarged range is valid and the transform path — not range validation —
is what fires; at buffer end the same intercept fails validation and
takes the rejection path, which also drops the session).

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 30;
cua 5; m6_4/m6_5 repl (mutator-heavy) 15/11; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-09 20:40:48 -04:00
parent c6038a790d
commit 8da143b402
3 changed files with 124 additions and 30 deletions

View File

@ -116,21 +116,26 @@ function pmacs.killring.cut()
ed.set_status("no region") ed.set_status("no region")
return false return false
end end
local text = buf:slice(region.start, region["end"]) local rstart, rstop = region.start, region["end"]
-- Same intercept discipline as kill_line: a rejected or transformed local text = buf:slice(rstart, rstop)
-- delete must not feed the ring or leave a live chain. -- Same intercept discipline as kill_line, via the mutator so the
local len_before = buf:len() -- EFFECTIVE edit is checkable exactly. The selection is cleared
local dok, deleted = pcall(ed.delete_region) -- explicitly (ed.delete_region did that as a side effect).
if not dok or not deleted then local dok, estart, estop, einserted = pcall(function()
return buf:delete(rstart, rstop)
end)
ed.clear_selection()
if not dok then
fail_kill(fid) fail_kill(fid)
ed.set_status(dok and "no region" or "kill rejected by buffer intercept") ed.set_status("kill rejected by buffer intercept")
return false return false
end end
if len_before - buf:len() ~= #text then if estart ~= rstart or estop ~= rstop or einserted ~= 0 then
fail_kill(fid) fail_kill(fid)
ed.set_status("kill altered by buffer intercept; ring not updated") ed.set_status("kill altered by buffer intercept; ring not updated")
return false return false
end end
ed.goto_byte(rstart)
kill_push(fid, text) kill_push(fid, text)
return true return true
end end
@ -199,16 +204,19 @@ function pmacs.killring.kill_line()
-- kill chain (or the next C-k would append to a kill that never -- kill chain (or the next C-k would append to a kill that never
-- happened); a transformation means the bytes actually removed are -- happened); a transformation means the bytes actually removed are
-- not `text`, so pushing `text` would put never-killed bytes on the -- not `text`, so pushing `text` would put never-killed bytes on the
-- ring and the OS clipboard. Verify by length delta: only a clean, -- ring and the OS clipboard. The mutators return the EFFECTIVE edit
-- untransformed delete feeds the ring. -- (post-intercept start/end/inserted), so this is an exact check —
local len_before = buf:len() -- a length delta would be defeated by an equal-length rewrite to a
local ok = pcall(function() buf:delete(cursor, kill_to) end) -- different range.
local ok, estart, estop, einserted = pcall(function()
return buf:delete(cursor, kill_to)
end)
if not ok then if not ok then
fail_kill(fid) fail_kill(fid)
ed.set_status("kill rejected by buffer intercept") ed.set_status("kill rejected by buffer intercept")
return false return false
end end
if len_before - buf:len() ~= #text then if estart ~= cursor or estop ~= kill_to or einserted ~= 0 then
fail_kill(fid) fail_kill(fid)
ed.set_status("kill altered by buffer intercept; ring not updated") ed.set_status("kill altered by buffer intercept; ring not updated")
return false return false
@ -301,24 +309,22 @@ function pmacs.killring.yank_pop()
return false return false
end end
local entry = ring[pos % #ring + 1] local entry = ring[pos % #ring + 1]
-- The replace runs buffer intercepts, which may REJECT by erroring. -- The replace runs buffer intercepts, which may REJECT (error) or
-- A rejection must still end the session — letting the error -- TRANSFORM. A rejection must still end the session — letting the
-- propagate would leave `sessions[fid]` live, and a second M-y -- error propagate would leave `sessions[fid]` live for a second M-y
-- could reuse the supposedly-invalid session. -- to reuse. And the verification must be EXACT: the mutator returns
local rok = pcall(function() buf:replace(s.start, s.stop, entry.text) end) -- the effective edit, and any deviation from the requested
-- (start, stop, #text) — e.g. an intercept enlarging `stop` by one
-- byte, silently deleting extra content — ends the session (the
-- interceptor's result stands; accepted post-hoc semantics, Q#KR7).
local rok, estart, estop, einserted = pcall(function()
return buf:replace(s.start, s.stop, entry.text)
end)
if not rok then if not rok then
drop_session(fid, "yank-pop rejected by buffer intercept") drop_session(fid, "yank-pop rejected by buffer intercept")
return false return false
end end
-- Verify the applied edit: buffer intercepts may alter or reject a if estart ~= s.start or estop ~= s.stop or einserted ~= #entry.text then
-- 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") drop_session(fid, "yank-pop altered by buffer intercept; stopped")
return false return false
end end

View File

@ -1198,7 +1198,7 @@ fn add_mutation_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
bypass_intercept, bypass_intercept,
)?; )?;
notify_buffer_edit_to_windows(lua, this.0, &edit); notify_buffer_edit_to_windows(lua, this.0, &edit);
Ok(()) effective_edit_triple(&edit)
}, },
); );
@ -1209,7 +1209,7 @@ fn add_mutation_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
let bypass_intercept = parse_bypass_intercept(opts.as_ref())?; let bypass_intercept = parse_bypass_intercept(opts.as_ref())?;
let edit = run_buffer_edit(lua, this.0, EditOp::Delete { range }, bypass_intercept)?; let edit = run_buffer_edit(lua, this.0, EditOp::Delete { range }, bypass_intercept)?;
notify_buffer_edit_to_windows(lua, this.0, &edit); notify_buffer_edit_to_windows(lua, this.0, &edit);
Ok(()) effective_edit_triple(&edit)
}, },
); );
@ -1229,7 +1229,7 @@ fn add_mutation_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
bypass_intercept, bypass_intercept,
)?; )?;
notify_buffer_edit_to_windows(lua, this.0, &edit); notify_buffer_edit_to_windows(lua, this.0, &edit);
Ok(()) effective_edit_triple(&edit)
}, },
); );
} }
@ -1243,6 +1243,22 @@ fn parse_bypass_intercept(opts: Option<&Table>) -> mlua::Result<bool> {
}) })
} }
/// The mutators' Lua return value: the **effective** edit after buffer
/// intercepts ran — `(start, end, inserted_len)` of the operation that
/// was actually applied (kill ring review round 4). An intercept may
/// legally rewrite an op's range; callers that must know exactly what
/// happened (killring's C-k / M-y) compare these against what they
/// requested instead of inferring from length deltas, which an
/// equal-length rewrite defeats.
fn effective_edit_triple(edit: &crate::rope::Edit) -> mlua::Result<(i64, i64, i64)> {
let cvt = |v: u64| i64::try_from(v).map_err(mlua::Error::external);
Ok((
cvt(edit.range.start)?,
cvt(edit.range.end)?,
cvt(edit.inserted_len)?,
))
}
fn run_buffer_edit( fn run_buffer_edit(
lua: &Lua, lua: &Lua,
id: BufferId, id: BufferId,

View File

@ -720,6 +720,78 @@ fn transforming_intercept_does_not_feed_the_ring() {
assert_eq!(buffer_text(&s), "lpha\nbeta\n"); assert_eq!(buffer_text(&s), "lpha\nbeta\n");
} }
#[test]
fn equal_length_shifted_delete_does_not_feed_the_ring() {
let mut s = editor_with("abcdef\nghijkl\n");
// An intercept that SHIFTS every delete right by 2 bytes while
// keeping its length — a length-delta check cannot see this, and
// the ring would receive text that was never killed.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then
return { kind = "delete", start = op.start + 2, ["end"] = op["end"] + 2 }
end
return nil
end)
"#,
);
ctrl(&mut s, 'k'); // wanted [0,6) "abcdef"; intercept deletes [2,8)
assert!(
status(&s).contains("altered"),
"an equal-length shifted delete is detected: {:?}",
status(&s)
);
assert!(
ring(&s).is_empty(),
"never-killed text must not reach the ring: {:?}",
ring(&s)
);
// The interceptor's result stands.
assert_eq!(buffer_text(&s), "abhijkl\n");
}
#[test]
fn stop_enlarging_replace_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"
// Yank at the START so the buffer extends past the yanked range —
// an end+1 range at buffer end would fail validation ("rejected")
// instead of exercising the transform path.
exec(&s, "pmacs.editor.goto_byte(0)");
ctrl(&mut s, 'y'); // session live
// An intercept that enlarges every replace's end by ONE byte: the
// replacement text still lands at s.start, so a "text appears at
// start" verify passes — but one extra byte was silently deleted.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "replace" then
return { kind = "replace", start = op.start, ["end"] = op["end"] + 1 }
end
return nil
end)
"#,
);
alt(&mut s, 'y');
assert!(
status(&s).contains("altered"),
"an end-enlarged replace is detected: {:?}",
status(&s)
);
// The session is dead: a further M-y refuses without editing.
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(status(&s).contains("not a yank"));
assert_eq!(buffer_text(&s), before);
}
#[test] #[test]
fn rejecting_intercept_ends_the_yank_session() { fn rejecting_intercept_ends_the_yank_session() {
let mut s = editor_with("one\ntwo\n"); let mut s = editor_with("one\ntwo\n");