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:
Levi Neuwirth 2026-07-23 21:45:33 -04:00
parent 90bb86d355
commit b750000d06
2 changed files with 140 additions and 5 deletions

View File

@ -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.

View File

@ -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: