fix(fold): key the managed Lua widening on the EFFECTIVE edit site
PR #149 review round 5, finding 1 — correct, and both round-4 bugs did survive through the intercept path. Round 4 moved the widening off the point and onto `edit_start_of(&op)`, but ran it in `run_buffer_edit` BEFORE `run_managed_edit`. A managed buffer intercept may legally rewrite `pos` / `start` / `end` (`LuaInterceptView::intercept_edit`), so the requested op is not where the edit lands: - requested outside -> intercept relocates inside: the edit stayed hidden; - requested inside -> intercept relocates outside: an unrelated fold opened. The seam still covers BOTH paths — hooking only `run_managed_edit` would let an interactive `bypass_intercept` edit escape, which is why the framing put it on the common entry — but each path now keys on its own effective site: - `run_bypass_edit` applies its op verbatim, so `run_buffer_edit` hooks it there; - `run_managed_edit` hooks after the intercept chain settles and before the apply, on the op the chain returned. A chain that raises applies nothing, so it unfolds nothing. The registry borrow is released at that point, and the helper reads only `SharedCore` + the fold registry, so no borrow conflicts with phase 3. Three new tests, all through real `M-x` with a real `pmacs.buffer.add_intercept`: relocate-into-a-fold must unfold, relocate-out-of-a-fold must not, and a rejected chain must not. Each asserts the buffer text first, so the test fails loudly if the intercept stops relocating rather than silently passing. Suite 45 -> 48. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
This commit is contained in:
parent
90bb86d355
commit
b750000d06
|
|
@ -1309,12 +1309,16 @@ fn run_buffer_edit(
|
|||
bypass_intercept: bool,
|
||||
) -> mlua::Result<crate::rope::Edit> {
|
||||
// Arc 6 Stage 2 (Q#FD19): the interactive-Lua-command unfold seam.
|
||||
// Hooked HERE — above both `run_managed_edit` and `run_bypass_edit` —
|
||||
// so an interactive command that passes `bypass_intercept` does not
|
||||
// escape the widening. Keyed on where the edit LANDS, read before
|
||||
// `op` is consumed below.
|
||||
unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op));
|
||||
// BOTH paths are covered — hooking only `run_managed_edit` would let
|
||||
// an interactive `bypass_intercept` edit escape — but each keys on
|
||||
// *its own* effective edit site (round-5 F1):
|
||||
//
|
||||
// - bypass applies `op` verbatim, so the site is known right here;
|
||||
// - managed runs the intercept chain first, and an intercept may
|
||||
// legally relocate `pos` / `start` / `end`, so only the op the
|
||||
// chain settles on names where the edit will land.
|
||||
if bypass_intercept {
|
||||
unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op));
|
||||
run_bypass_edit(lua, id, op)
|
||||
} else {
|
||||
run_managed_edit(lua, id, op)
|
||||
|
|
@ -1359,6 +1363,12 @@ fn edit_start_of(op: &EditOp<'_>) -> u64 {
|
|||
/// [`apply_active_edit`](crate::editor_core::EditorCore::apply_active_edit)'s
|
||||
/// funnel keys on the point; only this Lua path can diverge.
|
||||
///
|
||||
/// "Edit site" means the **effective** one (round-5 F1). On the managed
|
||||
/// path a buffer intercept may legally rewrite `pos` / `start` / `end`
|
||||
/// before the op is applied, so [`run_managed_edit`] calls this *after*
|
||||
/// the intercept chain settles; only [`run_bypass_edit`], which applies
|
||||
/// its op verbatim, can name the site up front.
|
||||
///
|
||||
/// Matches Stage 1's data-API exemption: `pmacs.buffer.insert` called
|
||||
/// from a plugin, a hook, or a bare Lua chunk unfolds nothing.
|
||||
fn unfold_before_interactive_lua_edit(lua: &Lua, id: BufferId, edit_start: u64) {
|
||||
|
|
@ -1435,6 +1445,19 @@ fn run_managed_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result<cra
|
|||
Ok(current)
|
||||
})();
|
||||
|
||||
// Arc 6 Stage 2 (Q#FD19, round-5 F1): the interactive unfold keys on
|
||||
// the EFFECTIVE edit site, so it must run here — after the chain has
|
||||
// settled, before the apply. An intercept may legally relocate `pos`
|
||||
// / `start` / `end`, so widening on the *requested* op would leave a
|
||||
// relocated-into-a-fold edit invisible, and would open an unrelated
|
||||
// fold when the intercept moved the edit out of one. A rejected
|
||||
// chain (`Err`) applies nothing, so it unfolds nothing. The registry
|
||||
// borrow is released at this point; this reads `SharedCore` and the
|
||||
// fold registry only.
|
||||
if let Ok(final_op) = intercept_result.as_ref() {
|
||||
unfold_before_interactive_lua_edit(lua, id, edit_start_of(final_op));
|
||||
}
|
||||
|
||||
// Phase 3: re-borrow, restore views, clear mid-edit flag, apply.
|
||||
// We restore views and clear the flag even on intercept error,
|
||||
// so the buffer is left in a usable state.
|
||||
|
|
|
|||
|
|
@ -1180,6 +1180,118 @@ fn an_interactive_edit_outside_the_fold_leaves_it_closed() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Attach a buffer intercept that relocates every `insert` to `to`.
|
||||
/// This is a supported, documented rewrite (`pos` / `start` / `end` may
|
||||
/// be overridden), and it is what makes the *requested* op's position an
|
||||
/// unreliable answer to "where will this edit land?".
|
||||
fn relocate_inserts_to(s: &EditorState, to: u64) {
|
||||
exec(
|
||||
s,
|
||||
&format!(
|
||||
r#"
|
||||
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
|
||||
if op.kind == "insert" then
|
||||
return {{ kind = "insert", pos = {to} }}
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
"#
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_intercept_relocating_the_edit_into_a_fold_unfolds_it() {
|
||||
// Round-5 F1. The command requests an edit OUTSIDE the fold; a
|
||||
// managed intercept moves it INSIDE. Keying on the requested op
|
||||
// would leave what the user just wrote invisible.
|
||||
let (s, _id) = seeded();
|
||||
let mut s = s;
|
||||
fold_active(&s, 2, 5); // head 2, hidden 3..=5 — fold range (11, 23]
|
||||
relocate_inserts_to(&s, end_of(4)); // 19, inside the fold
|
||||
set_cursor(&s, 0);
|
||||
define(
|
||||
&s,
|
||||
"test.relocate_in",
|
||||
"pmacs.window.buffer():insert(0, 'y')",
|
||||
);
|
||||
m_x(&mut s, "test.relocate_in");
|
||||
|
||||
let text: String = eval(
|
||||
&s,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
assert!(
|
||||
text.starts_with("L00\nL01\nL02\nL03\nL04y"),
|
||||
"the intercept really relocated the insert: {text:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fold_count(&s),
|
||||
0,
|
||||
"the EFFECTIVE edit site was inside the fold, so it must be revealed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_intercept_relocating_the_edit_out_of_a_fold_leaves_it_closed() {
|
||||
// The other direction: requested INSIDE, intercept moves it OUTSIDE.
|
||||
// Keying on the requested op would open a fold nothing was written to.
|
||||
let (s, _id) = seeded();
|
||||
let mut s = s;
|
||||
fold_active(&s, 2, 5);
|
||||
relocate_inserts_to(&s, 0); // outside the fold
|
||||
set_cursor(&s, 0);
|
||||
define(
|
||||
&s,
|
||||
"test.relocate_out",
|
||||
&format!("pmacs.window.buffer():insert({}, 'y')", end_of(4)),
|
||||
);
|
||||
m_x(&mut s, "test.relocate_out");
|
||||
|
||||
let text: String = eval(
|
||||
&s,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
assert!(
|
||||
text.starts_with("yL00"),
|
||||
"the intercept really relocated the insert: {text:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fold_count(&s),
|
||||
1,
|
||||
"nothing landed inside the fold, so it must stay closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_intercept_chain_unfolds_nothing() {
|
||||
// A chain that raises applies no edit at all, so there is nothing to
|
||||
// reveal — the widening must not fire on a request that never lands.
|
||||
let (s, _id) = seeded();
|
||||
let mut s = s;
|
||||
fold_active(&s, 2, 5);
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
|
||||
if op.kind == "insert" then error("nope") end
|
||||
return nil
|
||||
end)
|
||||
"#,
|
||||
);
|
||||
set_cursor(&s, 0);
|
||||
define(
|
||||
&s,
|
||||
"test.rejected",
|
||||
&format!(
|
||||
"pcall(function() pmacs.window.buffer():insert({}, 'y') end)",
|
||||
end_of(4)
|
||||
),
|
||||
);
|
||||
m_x(&mut s, "test.rejected");
|
||||
assert_eq!(fold_count(&s), 1, "a rejected edit reveals nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bypass_intercept_interactive_edit_also_unfolds() {
|
||||
// Pins the seam at `run_buffer_edit`, above the managed/bypass split:
|
||||
|
|
|
|||
Loading…
Reference in New Issue