diff --git a/docs/editing-conveniences-framing.md b/docs/editing-conveniences-framing.md new file mode 100644 index 0000000..cc91aa9 --- /dev/null +++ b/docs/editing-conveniences-framing.md @@ -0,0 +1,759 @@ +# Editing conveniences pack — framing (Lua-side, parallel lane) + +The doom/Emacs muscle-memory commands that are pure Lua on settled +substrate: goto-line, case ops, transpose, zap-to-char, line +move/duplicate/join, region line ops (sort/reverse/dedupe), and +delete-trailing-whitespace with an opt-in on-save hook. One runtime +chunk, one command family, zero contact with the in-flight lanes +(auto-pairing: `pmacs.pair.*` / `pair.lua` / its acceptance file; +indent: `indent.lua` / `pmacs.indent.*`; RET). + +Roadmap: `docs/roadmap-2026-07.md` — Arc 2 spirit ("editing table +stakes") but deliberately outside Arc 2's remaining scope; runs in +parallel to the auto-pairing close-out without sharing files beyond +the two named coordination points (Q#EC1). + +Revision 2: R1 findings — zap is now a real kill-chain member (the +no-chain premise was false: minibuffer keys never rotate the command +boundary, so `on_accept` observes exactly the state chaining needs); +the chain-unsafe `killring.push` is replaced by a chain-aware +`kill_range` + `break_chain`; Q#EC2 adopts the settled auto-indent +context-guard/translate discipline for all fix-up including +transformed-edit cursor repair; goto-line validates and bounds +before `push_jump`; transpose-words is specified against an +empirical Emacs 30.2 boundary table; ASCII conversion and sorting +are explicit-byte-range/-comparator (the Lua 5.4 backend is +locale-sensitive in `string.upper/lower`, string `<`, and pattern +classes); the trim callback gains an outer pcall (a raised error in +a short-circuit hook vetoes) and defined partial-sweep semantics. + +Revision 3: R2 findings — the zap chain gains an origin-frontend +guard (the minibuffer session is GLOBAL while command boundaries and +`last_kill_id` are per-frontend: another frontend can accept or +cancel the prompt, and pointer input breaks the boundary while +leaving the prompt open — either would misattribute or falsely +extend a chain); `break_chain` takes a target frontend; selection +clearing after a landed edit is unconditional (a dormant zero-length +anchor re-activates on cursor motion — the auto-indent rule); +transpose-words' cursor endpoint is named correctly (after W1 in its +new position); acceptance drives the minibuffer by dispatching +RET/C-g (the Lua lifecycle `accept()` bypasses the after-edit +wrapper); the trim sweep checks the context guard after every +delete; goto-line's parser uses `tonumber` + `[ \t]*` explicitly. + +Revision 4: R3 finding — `Minibuffer::begin` replaces a live +session without running its `on_cancel`, so zap's armed chain state +cannot live only in callbacks: killring gains a per-frontend +pending-prompt marker (arm at invoke, commit immediately before the +clean kill, cleared by `break_chain`/detach, and force-fresh + +clear when an ordinary kill meets it uncommitted), plus an arm-time +abandoned-marker break so a second zap after a silent replacement +cannot append to the pre-abandonment kill. `break_chain(fid)` +validates its argument; the zero-length-anchor acceptance case uses +a cursor-moving command so it cannot pass vacuously; the `accept()` +bypass is described as a path interactive key input never takes. + +Revision 5 (post-approval, R3's optional hardening adopted): +`commit_kill_prompt()` reports whether a marker was still armed; +zap fails closed — no kill, chain broken — when public Lua consumed +the marker while the prompt was open. + +## Ground truth (as of `7e127ab`) + +- **The taken-chord registry is wider than `builtin/keymaps/ + default.lua`.** Runtime chunks bind globally too: killring + (`C-k`, `M-y`; `builtin/runtime/killring.lua:357-358`), recentf + (`C-x C-r`, `builtin/runtime/recentf.lua:85`), comment (`M-;`, + `builtin/runtime/comment.lua:208`), lsp (`C-c` family, `M-.`, + `M-,`, `M-?`, `M-g n`, `M-g p`), completion (`C-M-i`). Every + chord this pack binds was verified free across ALL builtin bind + sites. `M-g` is already a live prefix (`M-g n`/`M-g p` + diagnostics), so `M-g g` extends an existing prefix map; + multi-chord and shifted-punctuation sequences are established + (`C-x C-s`, `M-%`, `M-{`). +- **Mutator discipline (substrate invariant).** `buf:insert/delete/ + replace` return the post-intercept effective `(start, end, + inserted_len)`; callers pcall and compare EXACTLY against the + request (killring documents why length-delta checks are defeated + patterns, `builtin/runtime/killring.lua:202-223`). Lua mutators + move no cursors. **The settled fix-up pattern is auto-indent's** + (`builtin/runtime/indent.lua:57-124`): snapshot window + buffer + + cursor BEFORE the edit (intercepts run with borrows released and + may switch context); after the edit, a context guard stops ALL + fix-up if the active window or buffer changed; cursor repair + right-gravity-translates the pre-edit cursor through the + effective triple and `goto_byte` clamps. Skipping transformed-edit + repair is not an option: an intercept that expands a replace can + shrink the buffer below the old cursor byte. Auto-indent also + clears the selection UNCONDITIONALLY after a landed edit + (`indent.lua:121-122`) — `ed.region()` hides an anchor equal to + the cursor, and the moment a command moves the cursor that + dormant zero-length anchor becomes an active selection. +- **`buffer.after-edit` coverage.** The dispatch cycle fires it + once, post-command, gated on an active-buffer revision change + (`src/editor.rs:772-776`). Edits performed inside a minibuffer + accept callback are covered separately by + `with_after_edit_check` (`src/editor.rs:840-864`) — the dedicated + revision wrapper for the accept/menu/paste paths. Zap's edit is + observed through that wrapper, not the `M-z` dispatch cycle — + but only on the KEY path: the RET dispatch wraps the accept + (`src/editor.rs:1170`), while the Lua lifecycle + `pmacs.minibuffer.accept()` invokes the callback directly and + BYPASSES `with_after_edit_check` + (`src/lua_bindings/mod.rs:11464`). Tests that call `accept()` + instead of dispatching RET therefore exercise a path interactive + key input never takes (public Lua code CAN take it — which is a + reason for tests to avoid it, not a claim it is unreachable). Direct Lua mutation outside these paths does + not fire the hook; editops has no such path. +- **Minibuffer sessions preserve command-boundary state — but the + session is global and boundaries are per-frontend.** While a + prompt is active every key routes through the minibuffer's + hardcoded handler and returns before normal dispatch + (`src/editor.rs:693-700`) — no `rotate_command` happens. + `rotate_command` runs once per interactive command dispatch + (`src/editor_core.rs:2254-2262`), and `last_command` names the + predecessor as observed from inside the currently-running command + (`src/editor_core.rs:2273-2280`). Consequence, for a command that + reads input via `pmacs.minibuffer.read`: inside `on_accept`, + `this_command()` is still the invoking command and + `last_command()` is still its predecessor; the NEXT command + rotates the invoking command into `last_command`. This is exactly + the state kill-chaining needs — in both directions. Three + hazards, though: the minibuffer session lives on the shared core, + not a frontend (`src/minibuffer.rs:60`); every input event + updates `active_frontend` BEFORE minibuffer interception + (`src/editor.rs:635`), so a different frontend can accept or + cancel the prompt and the callback then observes THAT frontend's + command history, buffer, and id; and pointer input is not + minibuffer-intercepted — a click breaks the boundary + (`this_command = nil`, `src/editor.rs:1288`) while leaving + `last_command` AND the open prompt intact, so a later accept + would still see the pre-invocation kill as `last_command` and + falsely append. And a fourth: `Minibuffer::begin` REPLACES an + existing session without invoking its `on_cancel` + (`src/minibuffer.rs:103`) — any Lua code, async callback, or + package calling `pmacs.minibuffer.read` mid-prompt silently + discards the session, so no cancel-path cleanup can be relied on + to run. Chain-sensitive minibuffer commands must pin their origin + frontend, re-verify `this_command` at accept time, and carry + their armed state somewhere a later kill can see it even when no + callback ever fired (Q#EC6). +- **Kill-ring internals.** Appending requires + `last_command ∈ KILL_CHAIN` AND the per-frontend `last_kill_id` + matching the current head's id (`builtin/runtime/ + killring.lua:38`, `:92-107`); `fail_kill` clears the id so both + conditions can never hold across a failed kill (`:83-85`). + `push_entry` collapses a duplicate-of-head while KEEPING the + existing id (`:71-79`) — so a naive "fresh push" API that leaves + `last_kill_id` untouched is chain-unsafe: kill "x", push "x" + (collapses to the same id), and the next `C-k` sees a matching id + and appends. There is no public push today; any export must keep + the id discipline intact. +- **The save pipeline is Lua; short-circuit hooks veto on raised + errors.** `buffer.save` runs + `pmacs.hook.run("buffer.before-save")` and only then `ed.save()` + (`builtin/commands/default.lua:222-234`) — a before-save mutation + lands in the written bytes. A callback returning `nil` never + vetoes (`builtin/runtime/saveplace.lua:80`), but a RAISED error + in a short-circuit hook vetoes immediately (`src/hook.rs:299`) — + which is why saveplace pcall-wraps its whole callback + (`saveplace.lua:81-91`). Callbacks run in registration order + (`src/hook.rs:240`). +- **The Lua 5.4 backend is locale-sensitive where rev 1 assumed + ASCII.** `string.upper/lower` call C `toupper`/`tolower` bytewise + (vendored lua-5.4.7, `lstrlib.c:124`) — after `os.setlocale`, + non-ASCII bytes can change and UTF-8 can be corrupted. String + `<`/`table.sort`'s default comparator use `strcoll` + (`lvm.c:370`) — not guaranteed byte-lexicographic. Pattern + classes (`%l`, `%u`, `%w`) are ctype-backed and equally + locale-sensitive. Only explicit byte ranges (`[a-z]`, + `[A-Za-z0-9_]`, `[0-9]`) and explicit comparators are portable + across locales and backends. +- **Word classes already diverge in-core.** Word *motion* is + Unicode alphanumeric + `_` (`src/editor_core.rs:2721-2724`); + `word_at_cursor` is deliberately ASCII alnum + `_` + (`src/editor_core.rs:2110-2132`). This pack's word/case ops use + the ASCII class — the `word_at_cursor` precedent, narrower than + motion (named limitation, Q#EC4). +- **Cursor-byte bindings clamp to length, not to codepoint + boundaries.** `goto_byte` clamps to `buf:len()` only; nothing + guarantees the cursor sits on a UTF-8 boundary when a command + starts. Codepoint-exact commands must fail closed on a + continuation byte at the cursor (Q#EC5). `move_to_line` is + 0-based and clamps out-of-range (`src/lua_bindings/mod.rs:10761`) + — but the Lua→integer conversion at the binding boundary errors + on huge or negative numbers before any clamping runs, so inputs + must be bounded Lua-side (Q#EC3). +- **Emacs transpose-words boundary behavior (empirical).** GNU + Emacs 30.2, `-Q --batch`, buffer `"one two three"`, 2026-07-11: + + | point (1-based) | position | result | final point | + |---|---|---|---| + | 1 | BOB, start of "one" | `two one three` | 8 | + | 2 | inside "one" | `two one three` | 8 | + | 4 | separator after "one" | `two one three` | 8 | + | 5 | exactly at start of "two" | `two one three` | 8 | + | 6 | inside "two" | `one three two` | 14 | + | 8 | separator after "two" | `one three two` | 14 | + | 9 | exactly at start of "three" | `one three two` | 14 | + | 11 | inside "three" (final word) | ERROR, buffer unchanged, point moved to 9 | 9 | + | 14 | EOB | ERROR, buffer unchanged, point moved to 9 | 9 | + + Cursor exactly AT a word's start pairs the PREVIOUS word with + that word; strictly inside a word pairs that word with the NEXT; + a final word with no successor is an error (with point motion — + a wart we do not copy, Q#EC5). +- **Recenter is not honestly buildable today.** `view_top` / + `set_view_top` are daemon-window line indices + (`src/lua_bindings/mod.rs:11015-11031`) which the TUI renders, + but the GPU's scroll is frontend-local and caret-driven (scroll + framing Q#S1/S2) and never consumes daemon `view_top`; no Lua API + exposes viewport height, so "center" is not computable. Recenter + is cut, not shipped TUI-only (Q#EC10). +- **`push_jump`/`jump_back` exist** (`src/lua_bindings/ + mod.rs:10770-10786`) and `M-,` already unwinds the jump stack. +- **Chunked scanning is the giant-line-safe idiom** — kill_line's + 4096-byte newline scan (`builtin/runtime/killring.lua:183-194`). +- **Runtime chunks load from an ordered `include_str!` list** in + `src/editor.rs` (async → fs → syntax → mcp → listview → lsp → + completion → saveplace → recentf → …). Command-body references + resolve at invoke time, so load position matters only for + load-time registrations — here, exactly one: the trim before-save + callback (Q#EC9). +- **Handoff §6 owns adjacent deferrals this pack must not claim.** + Word kills into the ring (`M-d`/`M-BS` rework), `C-SPC` set-mark, + and undo amalgamation stay in their lanes; editops touches none + of the delete-word commands. + +## Decisions + +### Q#EC1 — Shape: one chunk, one namespace, two coordination points + +`builtin/runtime/editops.lua`, namespace `pmacs.editops.*` (config + +implementation), commands in the existing `edit.*` family plus +`cursor.goto-line`. All bindings are made inside editops.lua (the +killring/recentf pattern) — `builtin/keymaps/default.lua` is not +touched, keeping the "default keymap is stable" contract with the +auto-pairing lane. + +File touch set: new `builtin/runtime/editops.lua`, new +`tests/editops_acceptance.rs`, a loader entry in `src/editor.rs` +(between completion.lua and saveplace.lua — the only load-order +requirement, Q#EC9), and ~40 lines in `builtin/runtime/killring.lua` +(Q#EC6: `kill_range`, `break_chain`, the pending-prompt marker). The two coordination points with the auto-pairing branch: +both add an editor.rs loader entry (different positions — pair.lua +goes before lsp.lua; trivial merge), and neither touches the other's +files otherwise. + +Ten commands bound via eleven sequences (each verified free across +every builtin bind site): + +| Chord | Command | +|---|---| +| `M-g g`, `M-g M-g` | `cursor.goto-line` | +| `M-u` / `M-l` / `M-c` | `edit.upcase` / `edit.downcase` / `edit.capitalize` | +| `C-t` / `M-t` | `edit.transpose-chars` / `edit.transpose-words` | +| `M-z` | `edit.zap-to-char` | +| `M-` / `M-` | `edit.move-line-up` / `edit.move-line-down` | +| `M-^` | `edit.join-line` | + +M-x-only (no chords): `edit.zap-up-to-char`, `edit.duplicate-line`, +`edit.sort-lines`, `edit.reverse-lines`, +`edit.delete-duplicate-lines`, `edit.delete-trailing-whitespace`. + +### Q#EC2 — Mutator discipline: the auto-indent guard, one replace per command + +Every text-changing command is expressed as a SINGLE `buf:replace` +(or `buf:delete`) spanning the affected region wherever possible — +transpose, case, line move, join, sort/reverse/dedupe are each one +edit, hence one undo unit. The one coarser-grained command is named: +trim (one delete per trimmed line, Q#EC9). + +The shared fix-up discipline is auto-indent's +(`builtin/runtime/indent.lua:57-124`), applied uniformly: + +1. **Snapshot** `pmacs.window.current()`, the buffer handle, and + `ed.cursor()` before the mutator. +2. **Edit** via one pcall'd mutator; capture the effective triple. +3. **Rejected** (intercept threw): nothing landed; status names the + command + "rejected by buffer intercept"; no fix-up, no state + updates (ring untouched, selection left alone). +4. **Context guard**: if the active window or buffer changed, stop + ALL fix-up — no `goto_byte`, no `clear_selection` against the + switched context; report "context changed during edit". +5. **Clean** (triple equals request): `goto_byte` to the + command-defined cursor target. +6. **Transformed** (triple deviates): the intercept's result stands + (accepted post-hoc semantics); status reports "altered by buffer + intercept"; the ORIGINAL cursor is right-gravity-translated + through the effective triple and `goto_byte` clamps (the + command-defined target is meaningless against a relocated edit, + but leaving the cursor unrepaired can strand it past + `buf:len()`). Follow-up state updates that assert the requested + edit happened (ring push) are skipped, matching killring. +7. **After ANY landed edit** (clean or transformed), under the same + guard: `clear_selection()` UNCONDITIONALLY — not just when a + nonempty region existed. `ed.region()` hides a zero-length + anchor at the cursor, and the command's own cursor motion would + re-activate it as a visible selection (the auto-indent rule, + `indent.lua:121-122`). + +### Q#EC3 — goto-line: validate and bound BEFORE any state changes + +`cursor.goto-line` reads via `pmacs.minibuffer.read` (prompt +"Goto line: ", history bucket `goto-line`, `source = "none"`). +`on_accept`, in order: + +1. Parse `^[ \t]*([0-9]+)[ \t]*$` — explicit ranges throughout + (not `%d`, not `%s`; both are ctype-backed and the parsing + contract is locale-independent). No match → status *"goto-line: + enter a line number"*; nothing mutated — `push_jump` has NOT + run. +2. `n = tonumber(capture)`, explicitly, then bound: + `n = math.max(1, math.min(n, 2^31))`. `"0"` clamps to line 1 + (Emacs behavior); the upper bound keeps the value inside what + the binding's integer conversion accepts — huge decimal input + must clamp to the last line, not error. +3. Only now `push_jump()`, then `move_to_line(n - 1)` (0-based; + clamps out-of-range to the last line). + +`M-,` returns to the origin via the existing jump stack. All state +is read at accept time — nothing captured at invoke time. + +### Q#EC4 — Case ops: DWIM span, explicit-byte-range conversion + +`edit.upcase` / `edit.downcase` / `edit.capitalize` (Emacs +`*-dwim`): with an active region, transform the region and clear the +selection (stale byte range; CUA/killring precedent — deviation from +Emacs's kept region, named). Without one, transform from the first +word character at-or-after the cursor through that word's end +(Emacs's mid-word remainder semantics), cursor to the span end. +No word forward → status, no edit. + +Word class: ASCII `[A-Za-z0-9_]` via explicit byte ranges (the +`word_at_cursor` precedent). Conversion: explicit `[a-z]`/`[A-Z]` +range gsub with a byte map — NOT `string.upper/lower` and NOT +`%l`/`%u` classes, all of which are locale-backed on the Lua 5.4 +backend (ground truth); this also keeps Lua 5.4 and LuaJIT +identical. Non-ASCII bytes pass through untouched — pinned in +acceptance (an `é` in the span is byte-identical after the op). +Capitalize = first word char upcased, rest of span downcased. + +### Q#EC5 — Transpose: codepoint-aware chars, Emacs-verified word boundaries + +`edit.transpose-chars` (C-t): swap the codepoints before and at the +cursor, cursor ends after both (Emacs drag-forward). At EOL (next +char is `\n` or EOF) with ≥2 preceding codepoints: swap the two +before the cursor (Emacs special case). Fewer than two reachable +codepoints → status, no edit. Codepoint boundaries by UTF-8 +lead-byte scan on a small slice around the cursor; **a continuation +byte at the cursor position fails closed** (status, no edit) — +`goto_byte` does not guarantee boundary alignment, and "never split +a codepoint" must hold against a misaligned start, not assume it +away. Newlines participate (transpose across lines works). One +replace spanning exactly the two codepoints. + +`edit.transpose-words` (M-t), specified against the Emacs 30.2 +table in Ground truth: + +- **W1** = the word containing the cursor, if the cursor lies + STRICTLY after that word's start; otherwise the nearest word + entirely before the cursor; if none exists (BOB / leading + separators), the first word at-or-after the cursor. A cursor + exactly at a word's start therefore pairs the PREVIOUS word with + it — the point-5/point-9 rows. +- **W2** = the first word strictly after W1's end. No W2 → status, + no edit, **no cursor motion** (Emacs errors AND moves point; the + point motion is a wart we don't copy — named deviation). +- Swap W1 and W2's spans in one replace, separator bytes between + them preserved verbatim; cursor ends at the replaced span's end — + immediately after W1 in its NEW position (post-swap, W1 sits + last; matches the observed final points 8 and 14). + +Word class ASCII (Q#EC4). Named simplification: W1/W2 are always +exact word spans — Emacs's `transpose-subr` can drag leading +separators into the region at BOB edges; we never transpose +separator bytes. + +### Q#EC6 — Zap: a real kill-chain member via a chain-aware killring export + +Rev 1's no-chain design rested on a false premise. Ground truth: +minibuffer keys never rotate the boundary, so inside `on_accept` +`this_command()` is `edit.zap-to-char` and `last_command()` is +M-z's predecessor — and the next command rotates zap into +`last_command`. That is exactly the state real chaining needs, in +both directions. So zap chains like Emacs: + +- `KILL_CHAIN` gains `edit.zap-to-char` and `edit.zap-up-to-char`: + a zap right after `C-k` appends to that kill's entry; a `C-k` + right after a zap appends to zap's entry; consecutive zaps + append. +- New killring exports (replacing rev 1's chain-unsafe `push`, + whose duplicate-of-head collapse plus untouched `last_kill_id` + would let a later `C-k` append across a foreign push): + - `pmacs.killring.kill_range(start, stop)` — operates on the + active buffer (the `cut` shape). Validates before ANY mutation: + integers, `0 <= start < stop <= buf:len()`, else it errors (a + programmer-facing API misuse, not a status). Slices the text + first, then one pcall'd exact-checked `buf:delete`. Clean → + `kill_push` (chain-aware append-or-push; updates + `last_kill_id`, mirrors the acting frontend's clipboard), + returns `true`. Rejected → killring-standard status + + `fail_kill`, returns `false, "rejected"`. Transformed → the + edit stands, status + `fail_kill`, returns + `false, "transformed", estart, estop, einserted` so the caller + can run its Q#EC2 guarded cursor repair. + - `pmacs.killring.break_chain([fid])` — public `fail_kill`, + targeting `fid` when given (validated as a nonnegative integer + before indexing per-frontend state), else the acting frontend. + The target parameter is required by the origin guard below: the + frontend whose chain must break is the INVOKING one, which need + not be the frontend whose input triggered the callback. + Clearing BOTH the chain id and the pending-prompt marker + (below) is sufficient to break a chain: appending requires the + id match AND the `KILL_CHAIN` predecessor together, and the + marker fail-safes the path where no callback ever ran. + - `pmacs.killring.arm_kill_prompt()` / + `pmacs.killring.commit_kill_prompt()` — the pending-prompt + marker (below). + +**Pending-prompt marker (the R3 blocker).** `Minibuffer::begin` +replaces a live session WITHOUT running its `on_cancel` (ground +truth) — so zap's cancel-path `break_chain` cannot be relied on to +run: C-k, M-z, a package's `pmacs.minibuffer.read` silently +replacing the prompt, the replacement closing, then C-k would +rotate `edit.zap-to-char` into `last_command` with the old id still +matching, and append as though the zap had happened. The armed +state must therefore live where every kill can see it, not in a +callback that may never fire. Killring gains per-frontend +`pending_kill_prompt[fid]`: + +- **Arm** (`arm_kill_prompt()`, called by zap at invoke time, + before `minibuffer.read`): sets the marker for the acting + frontend. It does NOT touch `last_kill_id` — backward chaining + (`C-k` then a completed zap appends) needs the id alive. If the + marker is ALREADY set, the previous armed prompt was silently + discarded without resolution: `fail_kill` first, then arm — + otherwise a second `M-z` after a silent replacement would commit + the stale marker away and falsely append to the pre-abandonment + kill (a residue the marker scheme alone would mask). +- **Commit** (`commit_kill_prompt()`): clears the marker and + RETURNS whether one was armed (post-approval hardening, adopted + from R3's optional note). Zap calls it immediately BEFORE + `kill_range` on the clean-input path — before, not after, or + `kill_push` would see the marker and force-fresh, killing + backward chaining — and treats a `false` return as fail-closed: + some public Lua consumed the marker while the prompt was open, + so the armed state is no longer trustworthy — status + + `break_chain(origin_fid)`, no kill. +- **`break_chain([fid])`** clears the marker along with + `last_kill_id` — every failure path already routes through it. +- **Ordinary `kill_push` encountering an uncommitted marker** for + the acting frontend forces a FRESH entry and clears the marker — + this is the fail-safe that catches the silent-replacement case: + the abandoned zap left its marker, and the next `C-k` refuses to + append no matter what `last_command` and the id say. +- **`frontend.detached`** clears the marker with the existing + per-frontend state (`killring.lua:340-343`). + +**Origin guard (the R2 blocker).** The minibuffer session is global +while command boundaries and `last_kill_id` are per-frontend, and +pointer input breaks the boundary without closing the prompt +(ground truth). So zap captures `origin_fid = pmacs.frontend.id()` +when it OPENS the prompt, and `on_accept` proceeds only when BOTH +hold: + +- `pmacs.frontend.id() == origin_fid` — the completing frontend is + the invoking one (a different frontend's accept would run the + kill against ITS buffer, history, and chain state); and +- `ed.this_command()` is still the invoking zap command — pointer + input (or any boundary-breaking event) on the origin frontend + sets `this_command = nil` while leaving `last_command` as the + pre-zap kill, so without this check a later accept would falsely + append the zap to that old kill. + +On either failure: abort — no scan, no edit — with status, and +`break_chain(origin_fid)` (breaking the ACTING frontend's chain +would leave the origin's pre-zap chain alive). `on_cancel` does the +same targeted `break_chain(origin_fid)` and clears the captured +`origin_fid`, regardless of which frontend cancelled. + +`edit.zap-to-char` (M-z): at invoke time, capture +`origin_fid = pmacs.frontend.id()` and `arm_kill_prompt()`, then +open the prompt ("Zap to char: "); all buffer state is read at +accept time. After the origin guard: input must be exactly one +UTF-8 codepoint, else status + `break_chain(origin_fid)`. Chunked +forward scan from the cursor; found at `p` → Q#EC2 snapshot, +`commit_kill_prompt()` (a `false` return aborts fail-closed: +status + `break_chain(origin_fid)`, no kill), then +`kill_range(cursor, p + #char)`; on the transformed return, +guarded translate-and-clamp repair. Not +found → status *"zap: no 'c' after the cursor"* + +`break_chain(origin_fid)`. `edit.zap-up-to-char` kills +`[cursor, p)`; a match AT the cursor is a zero-length no-op with +status + `break_chain(origin_fid)` (Emacs parity on the text, chain +broken on the no-op). + +Every non-kill outcome breaks the origin frontend's chain: origin +mismatch, disturbed boundary, cancel, invalid input, no match, +zero-length, rejection, transformation — and when a silent session +replacement lets NONE of those paths run, the uncommitted marker +makes the next kill fail safe to a fresh entry. Only a clean kill +by the origin frontend, through the commit, extends or starts a +chain. + +`cursor.goto-line` adopts the same origin guard for consistency +(abort with status on mismatch — no `push_jump`, no motion): it has +no chain stakes, but a prompt completed by a different frontend +moving THAT frontend's cursor is the same wrong-actor bug in milder +form. + +### Q#EC7 — Line ops: plain byte moves, explicitly not indentation + +All single-cursor-line in v1 (region-spanning variants deferred); +none of them inserts computed whitespace, calls `pmacs.indent.*`, or +reindents after moving — stated to keep this pack out of the indent +lane permanently, not just while #109's follow-ups settle. + +- `edit.move-line-up/down`: swap the cursor line with its neighbor + via one replace spanning both lines (newline placement handled + when the last line lacks a trailing `\n`); cursor keeps its byte + column, clamped to the moved line's length, on the line's new + location. At the first/last line → status, no edit. +- `edit.duplicate-line`: insert a copy of the cursor line below + (last line without `\n` → insert `"\n" .. line` at EOL); cursor + to the same byte column in the copy. +- `edit.join-line` (M-^, Emacs delete-indentation): join the cursor + line onto the previous one — one replace of + [prev line's trailing-whitespace start, current line's + leading-whitespace end) with a single space, or with nothing when + either side of the junction is empty (prev line blank or current + content empty — avoids `" bar"`). Cursor at the junction. On the + first line → status, no edit. + +### Q#EC8 — Region line ops: whole-line expansion, explicit byte comparator + +`edit.sort-lines` / `edit.reverse-lines` / +`edit.delete-duplicate-lines` require an active region (else status +*"…: no active region (select the lines first)"*). Expansion rule: +start → beginning of the line containing `region.start`; end → end +of the line containing `region.end - 1`, including its newline when +present (a region ending exactly at a BOL excludes that line — +Emacs sort-lines). Lines split/rejoined preserving the presence or +absence of a final newline. + +Sort uses `table.sort` with an EXPLICIT byte-wise comparator — +never the default string `<`, which is `strcoll`-backed and +locale-dependent (ground truth). Equal lines are identical, so +sort instability is moot. Dedupe keeps the first occurrence, status +reports the count removed. One replace; fix-up per Q#EC2 (selection +cleared, cursor to the region start, transformed edits translated). + +### Q#EC9 — Trailing whitespace: command always, hook opt-in, veto-proof + +`edit.delete-trailing-whitespace`: chunked line scan; one +`buf:delete` per line that has a trailing ` `/`\t` run, applied +bottom-up so earlier deletes never shift later targets. Undo grain +is one step per trimmed line — named (undo amalgamation is an +existing deferral, not this pack's). + +Partial-sweep semantics: the Q#EC2 context guard is checked after +EVERY delete, not only at final fix-up — a clean delete's intercept +can switch the active window or buffer, and the sweep must stop at +that point rather than keep deleting through the saved buffer +handle behind the switched-to context's back. The sweep also stops +at the first non-clean edit (rejected or transformed), reporting +which line failed. Fix-up then reflects EVERY edit that actually +landed — the cursor is right-gravity-translated through each +applied effective triple (including a transformed one, as returned) +and clamped, and the selection is cleared (unconditionally, Q#EC2 +step 7) if any delete landed — all skipped when the context guard +tripped. A clean full sweep translates the cursor the same way +(inside a trimmed run → its start). + +On-save: `pmacs.editops.trim_on_save([on])` — getter/setter (the +`killring.max` shape), **default off** (silently rewriting bytes on +save is a policy, not a default). The before-save callback is +registered unconditionally at chunk load and gates on the flag +inside, so its registration position is fixed by loader order: +editops.lua loads BEFORE saveplace.lua, making trim run before +saveplace's cursor-record within the before-save fan-out (recorded +places see post-trim text). The ENTIRE callback body is wrapped in +pcall with a `nil` return on both paths (the saveplace pattern): +returning `nil` never vetoes, but a raised error in a +short-circuit hook vetoes immediately (`src/hook.rs:299`) — trim +failure of any kind must report via status and never block the +save. + +### Q#EC10 — Cut from the pack: recenter + +`C-l` recenter is not shipped: the GPU never consumes daemon +`view_top` (its scroll is caret-driven and frontend-local) and no +API exposes viewport height, so "center/top/bottom" is either a lie +on one frontend or unimplementable. Deferred behind a +viewport-facts / frontend-scroll-control substrate (Arc 8 adjacent), +not worked around. + +## Bets + +1. **Free-chord verification against ALL bind sites is sufficient.** + The registry-of-taken-chords contract with the auto-pairing lane + is about not colliding and not rebinding — new bindings on + verified-free chords are in-bounds. +2. **ASCII word/case semantics are acceptable v1** — they match + `word_at_cursor`'s existing posture, and with explicit byte + ranges non-ASCII text is passed through untouched in every + locale, never corrupted. +3. **One-replace-per-command undo grain is what users expect** from + transpose/move/sort — and it falls out of the mutator discipline + rather than needing grouping substrate. +4. **Minibuffer boundary preservation is stable substrate, not + accident** — the shadow's early return and `rotate_command`'s + contract are documented behavior with the M-x path already + depending on them. What is NOT assumed is who completes the + prompt or that the boundary survives until accept: the origin + guard re-verifies both instead of trusting them, and the + acceptance suite pins the preserved-state observation and the + guard's failure modes directly. + +## Deferred (named) + +- Recenter + any frontend scroll control (needs viewport facts on + the wire; Arc 8 adjacent). +- Unicode-aware case conversion and word classes (would also + reconcile the in-core motion vs `word_at_cursor` split). +- Locale-aware collation modes for sort-lines (byte order is the + contract until then), and numeric sort. +- Region-spanning move/duplicate (drag-stuff parity). +- Emacs's separator-dragging `transpose-subr` edge at BOB (we + always transpose exact word spans). +- Ensure-final-newline on save (separate policy from trim). +- fixup-whitespace refinements for join (punctuation-aware spacing). +- Chords for the M-x-only commands if usage earns them. + +## Acceptance + +`tests/editops_acceptance.rs`, dispatch-driven where a binding +exists (per the established discipline: `pmacs.command.invoke` +bypasses dispatch, so bound-key cases must go through key dispatch +or a dead binding passes vacuously). Minibuffer-driven commands may +seed input with `set_contents()`, but MUST complete the session by +DISPATCHING RET (and C-g for cancel cases) — the Lua lifecycle +`accept()` invokes the callback directly and bypasses +`with_after_edit_check` (ground truth), a path interactive key +input never takes. Cross-frontend cases ride the same multi-frontend +harness the kill-ring suite already uses. + +- **Boundary-state pin** (the Q#EC6 substrate observation, asserted + directly): inside zap's `on_accept`, `this_command()` is + `edit.zap-to-char` and `last_command()` is the pre-M-z command; + after accept, the next command observes `last_command() == + "edit.zap-to-char"`. +- goto-line: dispatch `M-g g`, accept "5" → line 5 (1-based), jump + pushed (`M-,` returns); `"0"` → line 1, no error; a 25-digit + input → last line, no error; `"abc"` → status, no motion, and + the jump stack is untouched (nothing pushed before validation). +- Case ops: region upcase + selection cleared; mid-word `M-u` + transforms cursor→word-end and moves the cursor there; cursor on + separators skips forward to the next word; no word forward → no + edit; `é` in the span is byte-identical while ASCII neighbors + flip — and stays byte-identical regardless of process locale + (explicit-range pin). +- Transpose-chars: mid-line swap + cursor advance; EOL two-before + swap; BOB/single-char no-op; multi-byte: swapping `é` and `x` + yields intact UTF-8 both orders; across-newline swap; **cursor + parked on a continuation byte → status, no edit** (fail-closed + pin). Undo restores the original in ONE step (grain pin). +- Transpose-words: the full nine-position Emacs table from Ground + truth, byte-for-byte including final cursor positions for the + seven mutating rows; the two no-successor rows assert NO edit and + NO cursor motion (the named deviation); separator bytes between + the words preserved verbatim; one-step undo. +- Zap chain matrix: `C-k` then `M-z` → one appended entry; `M-z` + then `C-k` → one appended entry; `M-z M-z` → one appended entry; + each of cancel, invalid (multi-char) input, no-match, and + zero-length up-to BREAKS the chain (shape: `C-k`, failed/aborted + zap, `C-k` → the two `C-k`s are separate ring entries); killed + bytes land on the ring head and the clipboard slot; up-to-char + leaves the target; match-at-cursor up-to is a zero-length no-op; + **after-edit pin**: a completed zap fires `buffer.after-edit` + exactly once (the RET-dispatch wrapper — this is why the suite + dispatches RET rather than calling `accept()`). +- **Origin-guard matrix** (multi-frontend harness; every case ends + with frontend A's next `C-k` producing a FRESH ring entry): + frontend A invokes `M-z`, frontend B dispatches the accept → no + edit on either frontend, status, A's chain broken; A invokes, B + dispatches C-g → no edit, A's chain broken, `origin_fid` + cleared; A does `C-k`, `M-z`, then a pointer click on A, then + accept → NO append to the pre-zap `C-k` entry (the + `this_command` re-check), no edit, A's chain broken. Goto-line's + milder origin guard: A invokes `M-g g`, B accepts "5" → no + motion on either frontend, nothing on the jump stack. +- **Silent-replacement matrix** (the R3 blocker; `on_cancel` never + runs in either case): `C-k`, `M-z`, a programmatic + `pmacs.minibuffer.read` replacing the zap session, the + replacement closed by dispatched RET, then `C-k` → TWO separate + ring entries (the uncommitted marker forces the second kill + fresh); `C-k`, `M-z`, silent replacement, replacement closed, + then a SECOND `M-z` completed cleanly → the zap's kill is a + FRESH entry, not an append to the pre-abandonment `C-k` (the + arm-time abandoned-marker break). A committed normal zap right + after `C-k` still appends (the marker must not tax the healthy + path). **Consumed-marker pin** (the adopted hardening): public + Lua calls `commit_kill_prompt()` while zap's prompt is open → + the accept aborts with status, no edit, chain broken. +- `break_chain(fid)`: a non-integer or negative `fid` errors + before any per-frontend state is touched. +- `kill_range` API: invalid arguments (non-integer, negative, + `start >= stop`, `stop > len`) error BEFORE any ring or buffer + mutation; a rejected delete → `false, "rejected"`, ring + untouched, chain broken; a transformed delete → the transformed + edit stands, `false, "transformed", triple`, ring untouched, + chain broken, and zap's guarded repair leaves the cursor + translated and clamped (never past `buf:len()`). +- Line ops: move down/up round-trips; first/last line no-ops; + last-line-without-newline move and duplicate both preserve the + no-trailing-newline invariant; duplicate places the cursor at the + same column in the copy; join collapses the junction to one + space, to zero when the previous line is blank; each is one undo + step. +- Region ops: sort/reverse/dedupe on a region including a + region-ends-at-BOL exclusion case and a final-line-without- + newline case; **byte-order pin**: `{"b", "A", "a", "B"}` sorts to + `{"A", "B", "a", "b"}` regardless of process locale; dedupe count + in status; no-region → status, no edit; one undo step each; + selection cleared. +- Intercept discipline, per Q#EC2: a rejecting intercept on each + command class → status, no state change; a transforming intercept + → the intercept's result stands, cursor right-gravity-translated + and clamped (pinned with an expanding replace that shrinks the + buffer below the old cursor), selection cleared, no ring push; a + context-switching intercept → ALL fix-up skipped, the switched-to + window/buffer's cursor and selection untouched; **zero-length + anchor pin** (Q#EC2 step 7): `begin_selection` at the cursor with + no motion, then a clean mid-word `M-u` — a command whose clean + target MOVES the cursor, so the case cannot pass vacuously — → + no active region afterward (the dormant anchor must not + re-activate as a selection spanning the cursor's move to the + word end). +- Trim: command trims multiple lines; cursor inside a trimmed run + lands at the run start; cursor after a trimmed run shifts left + correctly; undo grain = one step per trimmed line (pinned, + named); partial sweep: a rejecting intercept on one line stops + the sweep, reports the line, and cursor translation reflects + every landed delete; **mid-sweep context switch**: a CLEAN delete + whose intercept switches the active buffer stops the sweep at + that delete — later (earlier-line) targets in the original + buffer are untouched, and no fix-up lands in the switched-to + context; `trim_on_save(true)` + `buffer.save` → file + bytes on disk are trimmed, and the saveplace-recorded cursor + reflects post-trim offsets (ordering pin); trim disabled + (default) → save writes bytes untouched; **veto-immunity pin**: a + rejecting intercept during on-save trim → the save still + proceeds with a status report; another before-save callback's + veto still vetoes (trim's `nil` return masks nothing). + +No CRDT-specific suite: every editops edit is a daemon-peer edit on +the dispatch or minibuffer-accept path with no optimistic-classifier +contact — the same posture as comment-toggle (which ships without +one).