From 9879b4637f9eff7c3d52d98f2c4c37b5f2c2d4b0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 12 Jul 2026 15:15:57 +0100 Subject: [PATCH 1/4] docs: editing-conveniences framing (Lua parallel lane) Framing for the editops pack: goto-line, case ops, transpose, zap-to-char (kill-chain member with origin guard + pending-prompt marker), line move/duplicate/join, region sort/reverse/dedupe, and delete-trailing-whitespace with opt-in trim-on-save. Five review revisions recorded in the doc; approved to branch at revision 4, revision 5 adopts the reviewer's optional commit_kill_prompt hardening. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018vF4gQVozBWi38y1SJiGfQ --- docs/editing-conveniences-framing.md | 759 +++++++++++++++++++++++++++ 1 file changed, 759 insertions(+) create mode 100644 docs/editing-conveniences-framing.md 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). From 781cd95fe27fbe66c2640a1e433921ce08c64886 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 12 Jul 2026 15:33:04 +0100 Subject: [PATCH 2/4] feat(edit): editing-conveniences pack (editops) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit builtin/runtime/editops.lua: goto-line (M-g g / M-g M-g), case ops (M-u/M-l/M-c), transpose chars/words (C-t/M-t), zap-to-char (M-z) + zap-up-to-char, line move/duplicate/join (M-up/M-down/M-^), region sort/reverse/dedupe, delete-trailing-whitespace + opt-in trim_on_save. All edits ride the Q#EC2 guarded single-replace discipline (snapshot, exact effective-triple check, context guard, right-gravity transformed-cursor repair, unconditional selection clear); word/case ops are explicit-byte-range ASCII (locale-proof); transpose-words matches the empirical Emacs 30.2 boundary table. killring.lua: zap commands join KILL_CHAIN; new exports kill_range (validated, chain-aware, typed failure returns), break_chain([fid]), and the Q#EC6 pending-prompt marker (arm/commit; arm-time abandoned-marker break; kill_push force-fresh on an uncommitted marker; detach cleanup) closing the silent-session-replacement hole. Zap guards its origin frontend and re-verifies this_command at accept time; commit_kill_prompt() reports armament so a consumed marker fails closed. editor.rs: editops.lua loader entry before saveplace.lua (the Q#EC9 before-save registration-order contract). tests/editops_acceptance.rs: 68 dispatch-driven cases — RET/C-g completed minibuffer sessions, the boundary-state pin, origin-guard and silent-replacement matrices, the nine-position transpose table, intercept discipline (reject/transform/context-switch/zero-length anchor), trim sweep semantics, and trim-on-save veto interactions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018vF4gQVozBWi38y1SJiGfQ --- builtin/runtime/editops.lua | 852 ++++++++++++++++++++++++ builtin/runtime/killring.lua | 134 +++- src/editor.rs | 13 + tests/editops_acceptance.rs | 1206 ++++++++++++++++++++++++++++++++++ 4 files changed, 2200 insertions(+), 5 deletions(-) create mode 100644 builtin/runtime/editops.lua create mode 100644 tests/editops_acceptance.rs diff --git a/builtin/runtime/editops.lua b/builtin/runtime/editops.lua new file mode 100644 index 0000000..7d1f061 --- /dev/null +++ b/builtin/runtime/editops.lua @@ -0,0 +1,852 @@ +-- editops.lua --- the editing-conveniences pack. +-- +-- goto-line, case ops, transpose, zap-to-char (a kill-chain member; +-- killring owns the ring surface), line move/duplicate/join, region +-- sort/reverse/dedupe, and delete-trailing-whitespace with an opt-in +-- trim-on-save hook. Framing: docs/editing-conveniences-framing.md +-- (Q#EC1..Q#EC10). +-- +-- House disciplines this file leans on: +-- * Q#EC2: every text change is ONE pcall'd mutator (one undo +-- step), snapshot-guarded (intercepts may switch window/buffer), +-- exact-effective-triple checked, with right-gravity cursor +-- translation on transformed edits and an UNCONDITIONAL +-- selection clear after any landed edit (a dormant zero-length +-- anchor re-activates on cursor motion). +-- * Locale hygiene: explicit byte ranges and byte comparators +-- only — string.upper/lower, the default string `<`, and the +-- %l/%u/%w/%s/%d pattern classes are ctype/strcoll-backed on the +-- Lua 5.4 backend and shift under os.setlocale. +-- * Word class: ASCII [A-Za-z0-9_] (the word_at_cursor precedent; +-- narrower than the Unicode motion class — named limitation). + +pmacs.editops = pmacs.editops or {} + +local ed = pmacs.editor + +local CHUNK = 4096 + +-- ---- byte classes (explicit ranges only) --------------------------- + +local function is_word_byte(b) + return (b >= 48 and b <= 57) -- 0-9 + or (b >= 65 and b <= 90) -- A-Z + or (b >= 97 and b <= 122) -- a-z + or b == 95 -- _ +end + +local function not_word_byte(b) + return not is_word_byte(b) +end + +local function is_nl_byte(b) + return b == 10 +end + +local function is_ws_byte(b) + return b == 32 or b == 9 +end + +-- ---- chunked scans (giant-line safety; the kill_line precedent) ---- + +-- First index >= from (< len) whose byte satisfies pred, or nil. +local function scan_forward(buf, from, len, pred) + local p = from + while p < len do + local stop = math.min(p + CHUNK, len) + local chunk = buf:slice(p, stop) + for i = 1, #chunk do + if pred(chunk:byte(i)) then return p + i - 1 end + end + p = stop + end + return nil +end + +-- First index < from (>= 0) whose byte satisfies pred, scanning +-- toward 0, or nil. +local function scan_back(buf, from, pred) + local p = from + while p > 0 do + local start = math.max(p - CHUNK, 0) + local chunk = buf:slice(start, p) + for i = #chunk, 1, -1 do + if pred(chunk:byte(i)) then return start + i - 1 end + end + p = start + end + return nil +end + +-- First occurrence of `needle` (plain bytes) at index >= from, or +-- nil. Windows overlap by #needle - 1 so a match spanning a chunk +-- boundary is seen whole by the next window. +local function find_forward(buf, from, len, needle) + local n = #needle + local p = from + while p < len do + local stop = math.min(p + CHUNK, len) + local wstop = math.min(stop + n - 1, len) + local chunk = buf:slice(p, wstop) + local i = chunk:find(needle, 1, true) + if i then return p + i - 1 end + p = stop + end + return nil +end + +-- bol/eol of the line containing `pos` (eol = index of its newline, +-- or len for a final bare line; a cursor sitting ON the newline +-- belongs to the line it terminates). +local function line_bounds(buf, pos, len) + local bol = (scan_back(buf, pos, is_nl_byte) or -1) + 1 + local eol = scan_forward(buf, pos, len, is_nl_byte) or len + return bol, eol +end + +-- ---- UTF-8 (codepoint-exact commands fail closed) ------------------ + +local function is_cont_byte(b) + return b >= 0x80 and b <= 0xBF +end + +local function cp_len(lead) + if lead < 0x80 then return 1 end + if lead >= 0xC2 and lead <= 0xDF then return 2 end + if lead >= 0xE0 and lead <= 0xEF then return 3 end + if lead >= 0xF0 and lead <= 0xF4 then return 4 end + return nil +end + +local function single_codepoint(s) + if #s == 0 then return false end + local n = cp_len(s:byte(1)) + if not n or n ~= #s then return false end + for i = 2, #s do + if not is_cont_byte(s:byte(i)) then return false end + end + return true +end + +-- Start of the codepoint ending just before `pos`. Returns nil at +-- BOB; nil, "malformed" when the preceding bytes are not one valid +-- UTF-8 codepoint (fail closed — goto_byte guarantees no boundary +-- alignment). +local function prev_cp_start(buf, pos) + if pos <= 0 then return nil end + local q = pos - 1 + local steps = 0 + while q > 0 and steps < 3 do + local b = buf:slice(q, q + 1):byte(1) + if not is_cont_byte(b) then break end + q = q - 1 + steps = steps + 1 + end + local lead = buf:slice(q, q + 1):byte(1) + local n = cp_len(lead) + if not n or q + n ~= pos then return nil, "malformed" end + return q +end + +-- ---- ASCII case maps (Q#EC4; never string.upper/lower) ------------- + +local function ascii_upper(s) + return (s:gsub("[a-z]", function(c) + return string.char(c:byte() - 32) + end)) +end + +local function ascii_lower(s) + return (s:gsub("[A-Z]", function(c) + return string.char(c:byte() + 32) + end)) +end + +-- First word byte upcased when it is a letter; every other letter +-- downcased (single-span semantics, Q#EC4). +local function ascii_capitalize(s) + local first = nil + for i = 1, #s do + if is_word_byte(s:byte(i)) then + first = i + break + end + end + local lowered = ascii_lower(s) + if not first then return lowered end + local b = lowered:byte(first) + if b >= 97 and b <= 122 then + return lowered:sub(1, first - 1) + .. string.char(b - 32) + .. lowered:sub(first + 1) + end + return lowered +end + +-- ---- Q#EC2 shared discipline --------------------------------------- + +-- Right-gravity translation of `pos` through an effective edit (the +-- indent.lua formula; estop is the PRE-edit end of the range). +local function translate(pos, estart, estop, einserted) + if pos < estart then return pos end + if pos > estop then return pos - (estop - estart) + einserted end + return estart + einserted +end + +-- One guarded replace: snapshot -> mutate -> exact verify -> context +-- guard -> cursor (command target on clean, translate+clamp on +-- transformed) -> unconditional selection clear. Returns "clean" | +-- "rejected" | "transformed" | "context". +local function guarded_replace(name, buf, rstart, rstop, text, clean_target) + local win0 = pmacs.window.current() + local cursor0 = ed.cursor() + local ok, estart, estop, einserted = pcall(function() + return buf:replace(rstart, rstop, text) + end) + if not ok then + ed.set_status(name .. " rejected by buffer intercept") + return "rejected" + end + if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf then + ed.set_status(name .. ": context changed during edit") + return "context" + end + if estart == rstart and estop == rstop and einserted == #text then + ed.goto_byte(clean_target) + ed.clear_selection() + return "clean" + end + ed.set_status(name .. " altered by buffer intercept") + ed.goto_byte(translate(cursor0, estart, estop, einserted)) + ed.clear_selection() + return "transformed" +end + +-- ---- goto-line (Q#EC3) --------------------------------------------- + +pmacs.command.define { + name = "cursor.goto-line", + description = "Go to a line by number (1-based; clamps to the buffer).", + fn = function() + local origin_fid = pmacs.frontend.id() + pmacs.minibuffer.read { + prompt = "Goto line: ", + history = "goto-line", + on_accept = function(input) + -- Milder origin guard (Q#EC6 tail): a prompt completed by + -- another frontend must not move THAT frontend's cursor. + if pmacs.frontend.id() ~= origin_fid then + ed.set_status("goto-line: prompt origin changed; ignored") + return + end + local cap = tostring(input or ""):match("^[ \t]*([0-9]+)[ \t]*$") + if not cap then + ed.set_status("goto-line: enter a line number") + return + end + -- Validate and bound BEFORE any state change: "0" clamps to + -- line 1 (Emacs); the cap keeps huge decimals inside what the + -- binding's integer conversion accepts (tonumber overflows to + -- float; math.min returns the integer cap). + local n = math.max(1, math.min(tonumber(cap), 0x80000000)) + ed.push_jump() + ed.move_to_line(n - 1) + end, + } + end, +} + +-- ---- case ops (Q#EC4) ---------------------------------------------- + +local function case_command(name, label, xform) + pmacs.command.define { + name = name, + description = label .. " the region, or the word from the cursor.", + fn = function() + local buf = pmacs.window.buffer() + if not buf then + ed.set_status(name .. ": no buffer") + return + end + local len = buf:len() + local region = ed.region() + local rstart, rstop + if region and region["end"] > region.start then + rstart, rstop = region.start, region["end"] + else + -- First word byte at-or-after the cursor, through that + -- word's end (Emacs mid-word remainder semantics). + local ws = scan_forward(buf, ed.cursor(), len, is_word_byte) + if not ws then + ed.set_status(name .. ": no word after the cursor") + return + end + rstart = ws + rstop = scan_forward(buf, ws + 1, len, not_word_byte) or len + end + local text = buf:slice(rstart, rstop) + local new = xform(text) + if new == text then + -- Identity: no edit (no undo step, no CRDT op) — but the + -- cursor still travels and the anchor rule still applies. + ed.goto_byte(rstop) + ed.clear_selection() + return + end + guarded_replace(name, buf, rstart, rstop, new, rstop) + end, + } +end + +case_command("edit.upcase", "Upcase", ascii_upper) +case_command("edit.downcase", "Downcase", ascii_lower) +case_command("edit.capitalize", "Capitalize", ascii_capitalize) + +-- ---- transpose (Q#EC5) --------------------------------------------- + +pmacs.command.define { + name = "edit.transpose-chars", + description = "Swap the characters around the cursor (C-t).", + fn = function() + local buf = pmacs.window.buffer() + if not buf then + ed.set_status("transpose-chars: no buffer") + return + end + local len = buf:len() + local cursor = ed.cursor() + local at_b = nil + if cursor < len then + at_b = buf:slice(cursor, cursor + 1):byte(1) + if is_cont_byte(at_b) then + ed.set_status("transpose-chars: cursor is inside a multi-byte character") + return + end + end + if cursor >= len or at_b == 10 then + -- EOL/EOF: swap the two codepoints BEFORE the cursor (Emacs + -- special case); cursor stays put. + local s2, why2 = prev_cp_start(buf, cursor) + if not s2 then + ed.set_status(why2 == "malformed" + and "transpose-chars: malformed UTF-8 before the cursor" + or "transpose-chars: not enough characters") + return + end + local s1, why1 = prev_cp_start(buf, s2) + if not s1 then + ed.set_status(why1 == "malformed" + and "transpose-chars: malformed UTF-8 before the cursor" + or "transpose-chars: not enough characters") + return + end + local cp1 = buf:slice(s1, s2) + local cp2 = buf:slice(s2, cursor) + guarded_replace("transpose-chars", buf, s1, cursor, cp2 .. cp1, cursor) + else + -- Swap the codepoints before and at the cursor; cursor ends + -- after both (Emacs drag-forward). + local s1, why = prev_cp_start(buf, cursor) + if not s1 then + ed.set_status(why == "malformed" + and "transpose-chars: malformed UTF-8 before the cursor" + or "transpose-chars: not enough characters") + return + end + local n2 = cp_len(at_b) + if not n2 or cursor + n2 > len then + ed.set_status("transpose-chars: malformed UTF-8 at the cursor") + return + end + local cp1 = buf:slice(s1, cursor) + local cp2 = buf:slice(cursor, cursor + n2) + guarded_replace("transpose-chars", buf, s1, cursor + n2, cp2 .. cp1, + cursor + n2) + end + end, +} + +pmacs.command.define { + name = "edit.transpose-words", + description = "Swap the words around the cursor (M-t).", + fn = function() + local buf = pmacs.window.buffer() + if not buf then + ed.set_status("transpose-words: no buffer") + return + end + local len = buf:len() + local cursor = ed.cursor() + -- W1 = the word containing the last word byte at-or-before + -- cursor-1 (covers strictly-inside, at-word-start, and separator + -- positions — the Emacs 30.2 table); fallback: the first word + -- at-or-after the cursor (BOB / leading separators). + local s1, e1 + local j = cursor > 0 and scan_back(buf, cursor, is_word_byte) or nil + if j then + s1 = (scan_back(buf, j, not_word_byte) or -1) + 1 + e1 = scan_forward(buf, j + 1, len, not_word_byte) or len + else + local ws = scan_forward(buf, cursor, len, is_word_byte) + if not ws then + ed.set_status("transpose-words: no words to transpose") + return + end + s1 = ws + e1 = scan_forward(buf, ws + 1, len, not_word_byte) or len + end + -- W2 = the first word strictly after W1. Missing -> status, no + -- edit, NO cursor motion (Emacs errors and moves point; the + -- point motion is a wart we don't copy). + local s2 = scan_forward(buf, e1, len, is_word_byte) + if not s2 then + ed.set_status("transpose-words: no following word to transpose") + return + end + local e2 = scan_forward(buf, s2 + 1, len, not_word_byte) or len + local w1 = buf:slice(s1, e1) + local sep = buf:slice(e1, s2) + local w2 = buf:slice(s2, e2) + -- Cursor: end of the replaced span — after W1 in its NEW position. + guarded_replace("transpose-words", buf, s1, e2, w2 .. sep .. w1, e2) + end, +} + +-- ---- zap (Q#EC6; ring surface owned by killring) ------------------- + +local function zap_command(cmd_name, label, prompt, up_to) + pmacs.command.define { + name = cmd_name, + description = label, + fn = function() + local origin_fid = pmacs.frontend.id() + pmacs.killring.arm_kill_prompt() + pmacs.minibuffer.read { + prompt = prompt, + history = "zap-char", + on_accept = function(input) + -- Origin guard: the session is global, boundaries are + -- per-frontend, and pointer input breaks the boundary + -- without closing the prompt. Both checks or no kill. + if pmacs.frontend.id() ~= origin_fid + or ed.this_command() ~= cmd_name then + pmacs.killring.break_chain(origin_fid) + ed.set_status("zap: prompt origin changed; aborted") + return + end + input = tostring(input or "") + if not single_codepoint(input) then + pmacs.killring.break_chain(origin_fid) + ed.set_status("zap: type a single character") + return + end + local buf = pmacs.window.buffer() + if not buf then + pmacs.killring.break_chain(origin_fid) + ed.set_status("zap: no buffer") + return + end + local len = buf:len() + local cursor = ed.cursor() + local p = find_forward(buf, cursor, len, input) + if not p then + pmacs.killring.break_chain(origin_fid) + ed.set_status("zap: no '" .. input .. "' after the cursor") + return + end + local stop = up_to and p or (p + #input) + if stop <= cursor then + pmacs.killring.break_chain(origin_fid) + ed.set_status("zap: already at '" .. input .. "'") + return + end + -- Q#EC2 snapshot around the killring-owned delete. + local win0 = pmacs.window.current() + local cursor0 = cursor + -- A consumed marker means the armed state is no longer + -- trustworthy (public Lua touched it mid-prompt): fail + -- closed, no kill. + if not pmacs.killring.commit_kill_prompt() then + pmacs.killring.break_chain(origin_fid) + ed.set_status("zap: prompt state consumed; aborted") + return + end + local ok, kind, estart, estop, eins = + pmacs.killring.kill_range(cursor, stop) + if pmacs.window.current() ~= win0 + or pmacs.window.buffer() ~= buf then + ed.set_status("zap: context changed during edit") + return + end + if ok then + -- Clean: the delete starts at the cursor; re-seat + -- explicitly and apply the unconditional anchor clear. + ed.goto_byte(cursor0) + ed.clear_selection() + elseif kind == "transformed" then + ed.goto_byte(translate(cursor0, estart, estop, eins)) + ed.clear_selection() + end + -- rejected: nothing landed; kill_range reported and broke + -- the chain; no fix-up. + end, + on_cancel = function() + -- Targeted: whichever frontend cancels, the ORIGIN's chain + -- must break (its this_command is still the zap). + pmacs.killring.break_chain(origin_fid) + end, + } + end, + } +end + +zap_command("edit.zap-to-char", "Kill through the next occurrence of a character.", + "Zap to char: ", false) +zap_command("edit.zap-up-to-char", "Kill up to (excluding) the next occurrence of a character.", + "Zap up to char: ", true) + +-- ---- line ops (Q#EC7; plain byte moves, never indentation) --------- + +pmacs.command.define { + name = "edit.move-line-down", + description = "Swap the cursor line with the line below.", + fn = function() + local buf = pmacs.window.buffer() + if not buf then + ed.set_status("move-line-down: no buffer") + return + end + local len = buf:len() + local cursor = ed.cursor() + local bol, eol = line_bounds(buf, cursor, len) + if eol >= len then + ed.set_status("move-line-down: already at the last line") + return + end + local nbol = eol + 1 + local neol = scan_forward(buf, nbol, len, is_nl_byte) or len + local cur = buf:slice(bol, eol) + local nxt = buf:slice(nbol, neol) + local col = math.min(cursor - bol, #cur) + guarded_replace("move-line-down", buf, bol, neol, nxt .. "\n" .. cur, + bol + #nxt + 1 + col) + end, +} + +pmacs.command.define { + name = "edit.move-line-up", + description = "Swap the cursor line with the line above.", + fn = function() + local buf = pmacs.window.buffer() + if not buf then + ed.set_status("move-line-up: no buffer") + return + end + local len = buf:len() + local cursor = ed.cursor() + local bol, eol = line_bounds(buf, cursor, len) + if bol == 0 then + ed.set_status("move-line-up: already at the first line") + return + end + local peol = bol - 1 + local pbol = (scan_back(buf, peol, is_nl_byte) or -1) + 1 + local cur = buf:slice(bol, eol) + local prev = buf:slice(pbol, peol) + local col = math.min(cursor - bol, #cur) + guarded_replace("move-line-up", buf, pbol, eol, cur .. "\n" .. prev, + pbol + col) + end, +} + +pmacs.command.define { + name = "edit.duplicate-line", + description = "Insert a copy of the cursor line below it.", + fn = function() + local buf = pmacs.window.buffer() + if not buf then + ed.set_status("duplicate-line: no buffer") + return + end + local len = buf:len() + local cursor = ed.cursor() + local bol, eol = line_bounds(buf, cursor, len) + local line = buf:slice(bol, eol) + local col = math.min(cursor - bol, #line) + -- Insert "\n"..line AT the line's end: works uniformly for a + -- middle line and a final line without a trailing newline. + guarded_replace("duplicate-line", buf, eol, eol, "\n" .. line, + eol + 1 + col) + end, +} + +pmacs.command.define { + name = "edit.join-line", + description = "Join the cursor line onto the previous line (M-^).", + fn = function() + local buf = pmacs.window.buffer() + if not buf then + ed.set_status("join-line: no buffer") + return + end + local len = buf:len() + local cursor = ed.cursor() + local bol, eol = line_bounds(buf, cursor, len) + if bol == 0 then + ed.set_status("join-line: already at the first line") + return + end + local peol = bol - 1 + local pbol = (scan_back(buf, peol, is_nl_byte) or -1) + 1 + -- Junction: prev line's trailing whitespace + the newline + the + -- current line's leading whitespace, replaced by one space — or + -- nothing when either side of the junction is empty. + local tws = peol + while tws > pbol do + local b = buf:slice(tws - 1, tws):byte(1) + if is_ws_byte(b) then tws = tws - 1 else break end + end + local lwe = bol + while lwe < eol do + local b = buf:slice(lwe, lwe + 1):byte(1) + if is_ws_byte(b) then lwe = lwe + 1 else break end + end + local prev_empty = tws == pbol + local cur_empty = lwe == eol + local sep = (prev_empty or cur_empty) and "" or " " + guarded_replace("join-line", buf, tws, lwe, sep, tws) + end, +} + +-- ---- region line ops (Q#EC8) --------------------------------------- + +-- Byte-wise line order: never the default string `<` (strcoll). +local function byte_lt(a, b) + local la, lb = #a, #b + local n = la < lb and la or lb + for i = 1, n do + local ba, bb = a:byte(i), b:byte(i) + if ba ~= bb then return ba < bb end + end + return la < lb +end + +local function region_lines_command(name, label, transform) + pmacs.command.define { + name = name, + description = label, + fn = function() + local buf = pmacs.window.buffer() + if not buf then + ed.set_status(name .. ": no buffer") + return + end + local region = ed.region() + if not region or region["end"] <= region.start then + ed.set_status(name .. ": no active region (select the lines first)") + return + end + local len = buf:len() + -- Whole-line expansion: BOL of the start line through EOL of + -- the line containing region.end - 1 (a region ending exactly + -- at a BOL excludes that line), newline included when present. + local lstart = (scan_back(buf, region.start, is_nl_byte) or -1) + 1 + local eol = scan_forward(buf, region["end"] - 1, len, is_nl_byte) + local lend = eol and (eol + 1) or len + local text = buf:slice(lstart, lend) + local had_nl = text:sub(-1) == "\n" + local body = had_nl and text:sub(1, -2) or text + local lines = {} + local pos = 1 + while true do + local nl = body:find("\n", pos, true) + if nl then + lines[#lines + 1] = body:sub(pos, nl - 1) + pos = nl + 1 + else + lines[#lines + 1] = body:sub(pos) + break + end + end + local newlines, info = transform(lines) + if info then ed.set_status(info) end + local newbody = table.concat(newlines, "\n") .. (had_nl and "\n" or "") + if newbody == text then + -- Identity: no edit, no undo step; anchor rule still applies. + ed.goto_byte(lstart) + ed.clear_selection() + return + end + guarded_replace(name, buf, lstart, lend, newbody, lstart) + end, + } +end + +region_lines_command("edit.sort-lines", "Sort the selected lines (byte order).", + function(lines) + local out = {} + for i, l in ipairs(lines) do out[i] = l end + table.sort(out, byte_lt) + return out + end) + +region_lines_command("edit.reverse-lines", "Reverse the order of the selected lines.", + function(lines) + local out = {} + for i = #lines, 1, -1 do out[#out + 1] = lines[i] end + return out + end) + +region_lines_command("edit.delete-duplicate-lines", + "Delete duplicate lines in the region, keeping first occurrences.", + function(lines) + local seen, out = {}, {} + for _, l in ipairs(lines) do + if not seen[l] then + seen[l] = true + out[#out + 1] = l + end + end + return out, string.format("delete-duplicate-lines: %d removed", + #lines - #out) + end) + +-- ---- delete-trailing-whitespace (Q#EC9) ---------------------------- + +-- Trailing ' '/'\t' runs, one {start, stop, line} per affected line, +-- ascending. Applied bottom-up so earlier deletes never shift later +-- targets. +local function collect_trailing(buf, len) + local runs = {} + local line_no = 1 + local p = 0 + while true do + local eol = scan_forward(buf, p, len, is_nl_byte) or len + local s = eol + while s > p do + local b = buf:slice(s - 1, s):byte(1) + if is_ws_byte(b) then s = s - 1 else break end + end + if s < eol then + runs[#runs + 1] = { start = s, stop = eol, line = line_no } + end + if eol >= len then break end + p = eol + 1 + line_no = line_no + 1 + end + return runs +end + +local function trim_active(name) + local buf = pmacs.window.buffer() + if not buf then + ed.set_status(name .. ": no buffer") + return + end + local len = buf:len() + local runs = collect_trailing(buf, len) + if #runs == 0 then + ed.set_status(name .. ": nothing to trim") + return + end + local win0 = pmacs.window.current() + local cursor0 = ed.cursor() + local applied = {} + local failed = nil + local context_tripped = false + for i = #runs, 1, -1 do + local r = runs[i] + local ok, estart, estop, eins = pcall(function() + return buf:delete(r.start, r.stop) + end) + if not ok then + -- Rejected: nothing landed for this line; stop the sweep. + failed = { line = r.line, what = "rejected" } + break + end + applied[#applied + 1] = { estart, estop, eins } + -- Context guard after EVERY delete: a clean delete's intercept + -- can switch window/buffer, and the sweep must not keep deleting + -- through the saved handle behind the new context's back. + if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf then + context_tripped = true + break + end + if estart ~= r.start or estop ~= r.stop or eins ~= 0 then + -- Transformed: the intercept's edit stands (it is in `applied` + -- for translation); stop the sweep. + failed = { line = r.line, what = "altered" } + break + end + end + if context_tripped then + ed.set_status(name .. ": context changed during edit") + return + end + if failed then + ed.set_status(string.format("%s: line %d %s by buffer intercept", + name, failed.line, failed.what)) + else + ed.set_status(string.format("%s: trimmed %d line%s", name, #applied, + #applied == 1 and "" or "s")) + end + if #applied > 0 then + -- Translate through every landed effective edit, in application + -- order; goto_byte clamps. Unconditional anchor clear (step 7). + local c = cursor0 + for _, t in ipairs(applied) do + c = translate(c, t[1], t[2], t[3]) + end + ed.goto_byte(c) + ed.clear_selection() + end +end + +pmacs.command.define { + name = "edit.delete-trailing-whitespace", + description = "Delete trailing spaces and tabs from every line.", + fn = function() + trim_active("delete-trailing-whitespace") + end, +} + +-- Opt-in trim-on-save. Getter when nil (the killring.max shape); +-- default OFF — rewriting bytes on save is a policy, not a default. +local trim_enabled = false +function pmacs.editops.trim_on_save(on) + if on == nil then return trim_enabled end + trim_enabled = (on ~= false) + return trim_enabled +end + +-- Registered at load time (gated inside) so it runs BEFORE +-- saveplace's cursor-record in the before-save fan-out: editops.lua +-- loads before saveplace.lua (the loader ordering contract, Q#EC9). +pmacs.hook.add("buffer.before-save", function() + -- Outer pcall: a raised error in a short-circuit hook vetoes the + -- save; trim failure must report via status, never block a save. + pcall(function() + if trim_enabled then + trim_active("delete-trailing-whitespace (on save)") + end + end) + -- nil return: never a veto. +end) + +-- ---- bindings (Q#EC1: all verified free across builtin bind sites) -- + +local function bind(seq, command) + pmacs.keymap.bind { scope = "global", sequence = seq, command = command } +end + +bind("M-g g", "cursor.goto-line") +bind("M-g M-g", "cursor.goto-line") +bind("M-u", "edit.upcase") +bind("M-l", "edit.downcase") +bind("M-c", "edit.capitalize") +bind("C-t", "edit.transpose-chars") +bind("M-t", "edit.transpose-words") +bind("M-z", "edit.zap-to-char") +bind("M-", "edit.move-line-up") +bind("M-", "edit.move-line-down") +bind("M-^", "edit.join-line") diff --git a/builtin/runtime/killring.lua b/builtin/runtime/killring.lua index 8e2fad9..0a33686 100644 --- a/builtin/runtime/killring.lua +++ b/builtin/runtime/killring.lua @@ -34,8 +34,24 @@ local max_entries = DEFAULT_MAX local last_kill_id = {} -- fid -> ring-entry id of that frontend's last kill local sessions = {} -- fid -> { buffer, start, stop, entry_id, text } --- Commands whose success may extend a kill chain (Q#KR4). -local KILL_CHAIN = { ["edit.kill-line"] = true, ["edit.cut"] = true } +-- Commands whose success may extend a kill chain (Q#KR4; the zap +-- pair joined via the editing-conveniences framing Q#EC6 — their +-- kills run inside a minibuffer accept, where the boundary state is +-- the invoking dispatch's, preserved by the minibuffer key shadow). +local KILL_CHAIN = { + ["edit.kill-line"] = true, + ["edit.cut"] = true, + ["edit.zap-to-char"] = true, + ["edit.zap-up-to-char"] = true, +} + +-- Q#EC6 pending-prompt marker: fid -> true while a kill-producing +-- minibuffer prompt is armed but not yet committed. Minibuffer::begin +-- replaces a live session WITHOUT running its on_cancel, so a +-- silently-discarded zap prompt leaves no callback to break the +-- chain; the marker lives here, where every kill can see it, and an +-- ordinary kill that meets it uncommitted refuses to append. +local pending_kill_prompt = {} -- fid -> true local function trim() while #ring > max_entries do @@ -63,9 +79,13 @@ function pmacs.killring.list() return out end --- Test/debug seam (Q#KR11 lifecycle assertions). +-- Test/debug seam (Q#KR11 lifecycle assertions; Q#EC6 marker). function pmacs.killring._debug_state(fid) - return { session = sessions[fid], last_kill_id = last_kill_id[fid] } + return { + session = sessions[fid], + last_kill_id = last_kill_id[fid], + pending_kill_prompt = pending_kill_prompt[fid], + } end -- Push `text` as a fresh entry (duplicate-of-head collapses, keeping @@ -79,9 +99,13 @@ local function push_entry(text) end -- A kill-family command failed or was a no-op: it must not leave a --- live chain for the next kill to append to (Q#KR4). +-- live chain for the next kill to append to (Q#KR4). Also drops any +-- armed-but-uncommitted prompt marker (Q#EC6): every failure path +-- routes through here, and clearing both state components together +-- is what makes break_chain sufficient. local function fail_kill(fid) last_kill_id[fid] = nil + pending_kill_prompt[fid] = nil end -- Chain-aware kill (Q#KR4): append to the head iff the previous @@ -90,6 +114,17 @@ end -- not ours — append would corrupt their entry). Mirrors the head to -- the acting frontend's OS clipboard either way. local function kill_push(fid, text) + -- Q#EC6 fail-safe: an uncommitted prompt marker means an armed + -- kill prompt never resolved (silent session replacement bypasses + -- on_cancel). Refuse to append no matter what last_command and the + -- id say — force a fresh entry and clear the marker. + if pending_kill_prompt[fid] then + pending_kill_prompt[fid] = nil + local head = push_entry(text) + last_kill_id[fid] = head.id + ed.clipboard_set(head.text) + return head + end local chained = KILL_CHAIN[ed.last_command() or ""] and last_kill_id[fid] ~= nil and ring[1] ~= nil @@ -335,11 +370,100 @@ function pmacs.killring.yank_pop() return true end +-- ---- editing-conveniences exports (Q#EC6) -------------------------- +-- The chain-aware surface zap needs from its minibuffer accept: a +-- range kill with killring's exact-effective-edit discipline, a +-- targeted chain break (the frontend whose chain must break is the +-- INVOKING one, which need not be the acting one), and the +-- pending-prompt marker lifecycle. + +-- Kill [start, stop) of the ACTIVE buffer into the ring, chain-aware. +-- Returns true on a clean kill; false, "rejected" when an intercept +-- threw (nothing landed); false, "transformed", estart, estop, +-- einserted when the effective edit deviated (the intercept's result +-- stands — the caller owns any cursor repair). Both failure paths +-- break the acting frontend's chain and report status. Invalid +-- arguments error BEFORE any ring or buffer mutation: misuse of a +-- programmatic API, not a user outcome. +function pmacs.killring.kill_range(start, stop) + local buf = pmacs.window.buffer() + if not buf then + error("pmacs.killring.kill_range: no active buffer") + end + local function ok_int(n) + return type(n) == "number" and n == n and n ~= math.huge + and n == math.floor(n) and n >= 0 + end + if not (ok_int(start) and ok_int(stop)) + or start >= stop or stop > buf:len() then + error("pmacs.killring.kill_range: expected integers " + .. "0 <= start < stop <= buffer length") + end + local fid = pmacs.frontend.id() + local text = buf:slice(start, stop) + local ok, estart, estop, einserted = pcall(function() + return buf:delete(start, stop) + end) + if not ok then + fail_kill(fid) + ed.set_status("kill rejected by buffer intercept") + return false, "rejected" + end + if estart ~= start or estop ~= stop or einserted ~= 0 then + fail_kill(fid) + ed.set_status("kill altered by buffer intercept; ring not updated") + return false, "transformed", estart, estop, einserted + end + kill_push(fid, text) + return true +end + +-- Public chain break. Targets `fid` when given (the origin guard +-- passes the INVOKING frontend, which may differ from the acting +-- one), else the acting frontend. +function pmacs.killring.break_chain(fid) + if fid == nil then + fid = pmacs.frontend.id() + elseif type(fid) ~= "number" or fid ~= fid or fid == math.huge + or fid ~= math.floor(fid) or fid < 0 then + error("pmacs.killring.break_chain: fid must be a nonnegative integer") + end + fail_kill(fid) +end + +-- Arm the acting frontend's pending-prompt marker (zap, at invoke +-- time, before minibuffer.read). Does NOT touch last_kill_id — +-- backward chaining (C-k then a completed zap appends) needs the id +-- alive. An ALREADY-set marker means the previous armed prompt was +-- silently discarded without resolution (replaced session, no +-- on_cancel): break that chain first, or a second zap would commit +-- the stale marker away and falsely append to the pre-abandonment +-- kill. +function pmacs.killring.arm_kill_prompt() + local fid = pmacs.frontend.id() + if pending_kill_prompt[fid] then + last_kill_id[fid] = nil + end + pending_kill_prompt[fid] = true +end + +-- Clear the acting frontend's marker, reporting whether one was +-- still armed. Callers kill only on true: a false return means some +-- other Lua consumed the marker while the prompt was open, and the +-- armed state is no longer trustworthy (fail closed). +function pmacs.killring.commit_kill_prompt() + local fid = pmacs.frontend.id() + local was_armed = pending_kill_prompt[fid] ~= nil + pending_kill_prompt[fid] = nil + return was_armed +end + -- Q#KR11: a detached frontend's chain/session state must not outlive -- it (ids are monotonic; these tables would grow forever). pmacs.hook.add("frontend.detached", function(fid) sessions[fid] = nil last_kill_id[fid] = nil + pending_kill_prompt[fid] = nil end) pmacs.command.define { diff --git a/src/editor.rs b/src/editor.rs index 0e07d4e..f0f6a78 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -299,6 +299,19 @@ impl EditorState { include_str!("../builtin/runtime/completion.lua"), ) .expect("load completion builtin chunk"); + // Editing-conveniences pack (Q#EC9 ordering contract): MUST + // load before saveplace.lua — editops registers its (gated, + // default-off) trim-on-save callback at load time, and hook + // callbacks run in registration order, so saveplace's + // before-save cursor-record must observe post-trim text. Its + // pmacs.killring.* references resolve at invoke time, so + // loading before killring.lua is fine. + lua_host + .eval( + Some("@pmacs/builtin/runtime/editops.lua"), + include_str!("../builtin/runtime/editops.lua"), + ) + .expect("load editops builtin chunk"); // Arc 3: persistence builtins (saveplace + recentf). Load after // the LSP/completion runtimes; they subscribe to buffer hooks // and drive `pmacs.state` (inert until the state dir is diff --git a/tests/editops_acceptance.rs b/tests/editops_acceptance.rs new file mode 100644 index 0000000..5452495 --- /dev/null +++ b/tests/editops_acceptance.rs @@ -0,0 +1,1206 @@ +//! Editing-conveniences acceptance (docs/editing-conveniences-framing.md). +//! +//! Dispatch-driven where a binding exists (`pmacs.command.invoke` +//! bypasses dispatch — a dead binding would pass vacuously), with +//! minibuffer sessions completed by DISPATCHING RET / C-g: the Lua +//! lifecycle `accept()` bypasses `with_after_edit_check`, a path +//! interactive key input never takes. M-x-only commands go through +//! the real M-x minibuffer. The origin/silent-replacement matrices +//! ride the same unregistered-second-`FrontendId` shape as the +//! kill-ring suite. + +use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; + +const B: FrontendId = FrontendId(9); + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn ctrl(s: &mut EditorState, c: char) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(c), KeyModifiers::CONTROL), + ); +} + +fn alt(s: &mut EditorState, c: char) { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT)); +} + +fn alt_code(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::ALT)); +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn press_as(s: &mut EditorState, fid: FrontendId, code: KeyCode) { + s.dispatch_key(fid, key(code, KeyModifiers::NONE)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +fn lua_str(text: &str) -> String { + let mut out = String::from("\""); + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +fn buffer_text(s: &EditorState) -> String { + let b: mlua::String = eval( + s, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + String::from_utf8_lossy(&b.as_bytes()).into_owned() +} + +fn cursor(s: &EditorState) -> i64 { + eval(s, "return pmacs.editor.cursor()") +} + +fn cursor_line(s: &EditorState) -> i64 { + eval(s, "return pmacs.editor.cursor_line()") +} + +fn region_is_nil(s: &EditorState) -> bool { + eval(s, "return pmacs.editor.region() == nil") +} + +fn ring(s: &EditorState) -> Vec { + eval(s, "return pmacs.killring.list()") +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +/// Fresh editor whose scratch buffer holds `text` (seeded directly — +/// typing RET would route through edit.newline-and-indent and clone +/// leading whitespace), cursor at 0. +fn editor_with(text: &str) -> EditorState { + let s = EditorState::new(); + exec( + &s, + &format!( + "local b = pmacs.window.buffer(); b:replace(0, b:len(), {})", + lua_str(text) + ), + ); + exec(&s, "pmacs.editor.goto_byte(0)"); + s +} + +/// Complete the open minibuffer session: seed contents directly, then +/// DISPATCH RET (the framing's accept-path requirement). +fn accept_minibuffer(s: &mut EditorState, contents: &str) { + exec( + s, + &format!("pmacs.minibuffer.set_contents({})", lua_str(contents)), + ); + press(s, KeyCode::Enter); +} + +fn m_x(s: &mut EditorState, name: &str) { + alt(s, 'x'); + type_str(s, name); + press(s, KeyCode::Enter); +} + +fn zap(s: &mut EditorState, ch: &str) { + alt(s, 'z'); + accept_minibuffer(s, ch); +} + +fn undo(s: &mut EditorState) { + ctrl(s, '/'); +} + +fn click(s: &mut EditorState) { + let term = pmacs::cell::CellSize::new(24, 80); + s.dispatch_mouse( + FrontendId::LOCAL, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 1, + modifiers: KeyModifiers::NONE, + }, + term, + ); +} + +// --------------------------------------------------------------------------- +// Boundary-state pin (the Q#EC6 substrate observation, asserted directly) +// --------------------------------------------------------------------------- + +#[test] +fn minibuffer_accept_preserves_the_invoking_commands_boundary() { + let mut s = editor_with("one\ntwo\n"); + exec( + &s, + r#" + pmacs.command.define { + name = "test.boundary-probe", + description = "capture boundary state inside on_accept", + fn = function() + pmacs.minibuffer.read { + prompt = "p: ", + on_accept = function() + PROBE_THIS = tostring(pmacs.editor.this_command()) + PROBE_LAST = tostring(pmacs.editor.last_command()) + end, + } + end, + } + pmacs.keymap.bind { scope = "global", sequence = "C-c q", command = "test.boundary-probe" } + "#, + ); + ctrl(&mut s, 'k'); // predecessor: edit.kill-line + ctrl(&mut s, 'c'); + press(&mut s, KeyCode::Char('q')); + press(&mut s, KeyCode::Enter); + let this: String = eval(&s, "return PROBE_THIS"); + let last: String = eval(&s, "return PROBE_LAST"); + assert_eq!( + this, "test.boundary-probe", + "this_command survives the prompt" + ); + assert_eq!( + last, "edit.kill-line", + "last_command is the pre-prompt predecessor" + ); +} + +// --------------------------------------------------------------------------- +// goto-line (Q#EC3) +// --------------------------------------------------------------------------- + +#[test] +fn goto_line_moves_and_pushes_a_jump() { + let mut s = editor_with("a\nb\nc\nd\ne"); + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('g')); + accept_minibuffer(&mut s, "4"); + assert_eq!(cursor_line(&s), 3, "1-based input, 0-based line"); + let back: bool = eval(&s, "return pmacs.editor.jump_back()"); + assert!(back, "goto-line pushed a jump"); + assert_eq!(cursor_line(&s), 0); +} + +#[test] +fn goto_line_binds_the_double_alt_form_too() { + let mut s = editor_with("a\nb\nc"); + alt(&mut s, 'g'); + alt(&mut s, 'g'); + accept_minibuffer(&mut s, "2"); + assert_eq!(cursor_line(&s), 1); +} + +#[test] +fn goto_line_zero_clamps_to_the_first_line() { + let mut s = editor_with("a\nb\nc"); + exec(&s, "pmacs.editor.move_to_line(2)"); + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('g')); + accept_minibuffer(&mut s, "0"); + assert_eq!(cursor_line(&s), 0, "Emacs clamps line 0 to the first line"); +} + +#[test] +fn goto_line_huge_decimal_clamps_to_the_last_line() { + let mut s = editor_with("a\nb\nc\nd\ne"); + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('g')); + accept_minibuffer(&mut s, "9999999999999999999999999"); + assert_eq!(cursor_line(&s), 4, "clamps, never errors"); +} + +#[test] +fn goto_line_rejects_non_numeric_without_touching_the_jump_stack() { + let mut s = editor_with("a\nb\nc"); + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('g')); + accept_minibuffer(&mut s, "abc"); + assert!(status(&s).contains("enter a line number")); + assert_eq!(cursor_line(&s), 0, "no motion"); + let back: bool = eval(&s, "return pmacs.editor.jump_back()"); + assert!(!back, "nothing pushed before validation"); +} + +// --------------------------------------------------------------------------- +// Case ops (Q#EC4) +// --------------------------------------------------------------------------- + +#[test] +fn upcase_region_clears_the_selection() { + let mut s = editor_with("foo bar"); + exec(&s, "pmacs.editor.begin_selection(0)"); + exec(&s, "pmacs.editor.goto_byte(3)"); + alt(&mut s, 'u'); + assert_eq!(buffer_text(&s), "FOO bar"); + assert!(region_is_nil(&s), "selection cleared after the edit"); +} + +#[test] +fn upcase_mid_word_transforms_the_remainder_and_moves_the_cursor() { + let mut s = editor_with("foo bar"); + exec(&s, "pmacs.editor.goto_byte(1)"); + alt(&mut s, 'u'); + assert_eq!(buffer_text(&s), "fOO bar", "Emacs mid-word remainder"); + assert_eq!(cursor(&s), 3, "cursor at the span end"); +} + +#[test] +fn downcase_from_a_separator_takes_the_next_word() { + let mut s = editor_with("foo BAR baz"); + exec(&s, "pmacs.editor.goto_byte(3)"); + alt(&mut s, 'l'); + assert_eq!(buffer_text(&s), "foo bar baz"); + assert_eq!(cursor(&s), 7); +} + +#[test] +fn capitalize_word_and_region() { + let mut s = editor_with("hELLO"); + alt(&mut s, 'c'); + assert_eq!(buffer_text(&s), "Hello"); + let mut s = editor_with("hello WORLD"); + exec(&s, "pmacs.editor.begin_selection(0)"); + exec(&s, "pmacs.editor.goto_byte(11)"); + alt(&mut s, 'c'); + assert_eq!( + buffer_text(&s), + "Hello world", + "single-span capitalize: first word char up, rest down" + ); +} + +#[test] +fn case_ops_report_when_no_word_follows() { + let mut s = editor_with("foo "); + exec(&s, "pmacs.editor.goto_byte(4)"); + alt(&mut s, 'u'); + assert_eq!(buffer_text(&s), "foo "); + assert!(status(&s).contains("no word after the cursor")); +} + +#[test] +fn case_ops_leave_non_ascii_bytes_identical() { + // é is not in the ASCII conversion ranges: byte-identical in any + // process locale, while its ASCII neighbors flip. + let mut s = editor_with("aéb"); + exec(&s, "pmacs.editor.begin_selection(0)"); + exec(&s, &format!("pmacs.editor.goto_byte({})", "aéb".len())); + alt(&mut s, 'u'); + assert_eq!(buffer_text(&s), "AéB"); +} + +// --------------------------------------------------------------------------- +// Transpose-chars (Q#EC5) +// --------------------------------------------------------------------------- + +#[test] +fn transpose_chars_swaps_and_advances() { + let mut s = editor_with("abc"); + exec(&s, "pmacs.editor.goto_byte(1)"); + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "bac"); + assert_eq!(cursor(&s), 2, "Emacs drag-forward"); + undo(&mut s); + assert_eq!(buffer_text(&s), "abc", "one undo step"); +} + +#[test] +fn transpose_chars_at_eol_swaps_the_two_before() { + let mut s = editor_with("ab\ncd"); + exec(&s, "pmacs.editor.goto_byte(2)"); + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "ba\ncd"); + assert_eq!(cursor(&s), 2, "cursor stays at EOL"); +} + +#[test] +fn transpose_chars_at_eof_swaps_the_two_before() { + let mut s = editor_with("ab"); + exec(&s, "pmacs.editor.goto_byte(2)"); + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "ba"); +} + +#[test] +fn transpose_chars_no_ops_at_bob_and_on_single_chars() { + let mut s = editor_with("ab"); + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "ab"); + assert!(status(&s).contains("not enough characters")); + let mut s = editor_with("a"); + exec(&s, "pmacs.editor.goto_byte(1)"); + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "a"); +} + +#[test] +fn transpose_chars_swaps_whole_codepoints() { + let mut s = editor_with("éx"); + exec(&s, "pmacs.editor.goto_byte(2)"); // between é and x + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "xé", "intact UTF-8, é dragged forward"); + assert_eq!(cursor(&s), 3); + let mut s = editor_with("xé"); + exec(&s, "pmacs.editor.goto_byte(1)"); + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "éx"); + assert_eq!(cursor(&s), 3); +} + +#[test] +fn transpose_chars_swaps_across_a_newline() { + let mut s = editor_with("a\nb"); + exec(&s, "pmacs.editor.goto_byte(2)"); + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "ab\n"); +} + +#[test] +fn transpose_chars_fails_closed_on_a_continuation_byte() { + let mut s = editor_with("éx"); + exec(&s, "pmacs.editor.goto_byte(1)"); // inside é + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "éx", "no edit"); + assert!(status(&s).contains("multi-byte")); +} + +// --------------------------------------------------------------------------- +// Transpose-words: the nine-position Emacs 30.2 table (Q#EC5) +// --------------------------------------------------------------------------- + +/// (0-based byte cursor, expected text, expected cursor) — mutating +/// rows of the framing's table ("one two three"; Emacs points are +/// these offsets + 1). +#[test] +fn transpose_words_matches_the_emacs_table() { + let mutating: [(i64, &str, i64); 7] = [ + (0, "two one three", 7), + (1, "two one three", 7), + (3, "two one three", 7), + (4, "two one three", 7), + (5, "one three two", 13), + (7, "one three two", 13), + (8, "one three two", 13), + ]; + for (pos, want, want_cursor) in mutating { + let mut s = editor_with("one two three"); + exec(&s, &format!("pmacs.editor.goto_byte({pos})")); + alt(&mut s, 't'); + assert_eq!(buffer_text(&s), want, "cursor byte {pos}"); + assert_eq!(cursor(&s), want_cursor, "cursor byte {pos}"); + } + // No-successor rows: no edit, and — the named deviation from + // Emacs — NO cursor motion. + for pos in [10i64, 13] { + let mut s = editor_with("one two three"); + exec(&s, &format!("pmacs.editor.goto_byte({pos})")); + alt(&mut s, 't'); + assert_eq!(buffer_text(&s), "one two three", "cursor byte {pos}"); + assert_eq!(cursor(&s), pos, "no cursor motion on the no-op"); + assert!(status(&s).contains("no following word")); + } +} + +#[test] +fn transpose_words_preserves_separator_bytes_and_undoes_in_one_step() { + let mut s = editor_with("aa,, bb"); + exec(&s, "pmacs.editor.goto_byte(3)"); + alt(&mut s, 't'); + assert_eq!(buffer_text(&s), "bb,, aa", "separators verbatim"); + undo(&mut s); + assert_eq!(buffer_text(&s), "aa,, bb", "one undo step"); +} + +// --------------------------------------------------------------------------- +// Zap (Q#EC6) +// --------------------------------------------------------------------------- + +#[test] +fn zap_to_char_kills_through_the_target_onto_the_ring() { + let mut s = editor_with("foo(bar"); + zap(&mut s, "("); + assert_eq!(buffer_text(&s), "bar"); + assert_eq!(cursor(&s), 0); + assert_eq!(ring(&s), vec!["foo("]); + let slot: String = eval(&s, "return pmacs.editor.clipboard_get()"); + assert_eq!(slot, "foo(", "clipboard mirrors the kill"); + // The boundary after a completed zap still names the zap. + let this: String = eval(&s, "return tostring(pmacs.editor.this_command())"); + assert_eq!(this, "edit.zap-to-char"); +} + +#[test] +fn zap_up_to_char_leaves_the_target() { + let mut s = editor_with("abc"); + m_x(&mut s, "edit.zap-up-to-char"); + accept_minibuffer(&mut s, "c"); + assert_eq!(buffer_text(&s), "c"); + assert_eq!(ring(&s), vec!["ab"]); +} + +#[test] +fn zap_up_to_char_at_the_target_is_a_zero_length_no_op() { + let mut s = editor_with("abc"); + m_x(&mut s, "edit.zap-up-to-char"); + accept_minibuffer(&mut s, "a"); + assert_eq!(buffer_text(&s), "abc"); + assert!(status(&s).contains("already at")); +} + +#[test] +fn zap_rejects_multi_char_input_and_missing_targets() { + let mut s = editor_with("abc"); + zap(&mut s, "xy"); + assert_eq!(buffer_text(&s), "abc"); + assert!(status(&s).contains("single character")); + let mut s = editor_with("abc"); + zap(&mut s, "Q"); + assert_eq!(buffer_text(&s), "abc"); + assert!(status(&s).contains("no 'Q' after the cursor")); +} + +#[test] +fn zap_fires_after_edit_exactly_once() { + let mut s = editor_with("foo(bar"); + exec( + &s, + "EDITS = 0; pmacs.hook.add('buffer.after-edit', function() EDITS = EDITS + 1 end)", + ); + zap(&mut s, "("); + let edits: i64 = eval(&s, "return EDITS"); + assert_eq!(edits, 1, "the RET-dispatch wrapper fires once"); +} + +// ---- chain matrix ---------------------------------------------------------- + +#[test] +fn zap_after_a_kill_appends() { + let mut s = editor_with("abc\nd(e"); + ctrl(&mut s, 'k'); // "abc" + zap(&mut s, "("); + assert_eq!(ring(&s), vec!["abc\nd("], "one appended entry"); +} + +#[test] +fn a_kill_after_a_zap_appends() { + let mut s = editor_with("a(bc\nd"); + zap(&mut s, "("); + ctrl(&mut s, 'k'); // "bc" + assert_eq!(ring(&s), vec!["a(bc"], "one appended entry"); +} + +#[test] +fn consecutive_zaps_append() { + let mut s = editor_with("a(b(c"); + zap(&mut s, "("); + zap(&mut s, "("); + assert_eq!(ring(&s), vec!["a(b("]); +} + +#[test] +fn cancelled_zap_breaks_the_chain() { + let mut s = editor_with("abc\nx(y"); + ctrl(&mut s, 'k'); + alt(&mut s, 'z'); + ctrl(&mut s, 'g'); // cancel the prompt + ctrl(&mut s, 'k'); // kills "\n" + assert_eq!(ring(&s), vec!["\n", "abc"], "two entries, no append"); +} + +#[test] +fn invalid_zap_input_breaks_the_chain() { + let mut s = editor_with("abc\nx(y"); + ctrl(&mut s, 'k'); + zap(&mut s, "xy"); + ctrl(&mut s, 'k'); + assert_eq!(ring(&s), vec!["\n", "abc"]); +} + +#[test] +fn no_match_zap_breaks_the_chain() { + let mut s = editor_with("abc\nxyz"); + ctrl(&mut s, 'k'); + zap(&mut s, "Q"); + ctrl(&mut s, 'k'); + assert_eq!(ring(&s), vec!["\n", "abc"]); +} + +#[test] +fn zero_length_up_to_zap_breaks_the_chain() { + let mut s = editor_with("ab\n(cd"); + ctrl(&mut s, 'k'); // "ab"; buffer "\n(cd", cursor 0 + exec(&s, "pmacs.editor.goto_byte(1)"); // programmatic: chain intact, cursor ON '(' + m_x(&mut s, "edit.zap-up-to-char"); + accept_minibuffer(&mut s, "("); + assert!(status(&s).contains("already at")); + ctrl(&mut s, 'k'); // kills "(cd" + assert_eq!( + ring(&s), + vec!["(cd", "ab"], + "no append across the zero-length no-op" + ); +} + +// ---- origin-guard matrix (multi-frontend) ---------------------------------- + +#[test] +fn accept_from_another_frontend_aborts_and_breaks_the_origin_chain() { + let mut s = editor_with("xx\na(b"); + ctrl(&mut s, 'k'); // A kills "xx" + alt(&mut s, 'z'); // A opens the zap prompt + exec(&s, "pmacs.minibuffer.set_contents('(')"); + press_as(&mut s, B, KeyCode::Enter); // B completes it + assert_eq!(buffer_text(&s), "\na(b", "no edit on either frontend"); + assert!(status(&s).contains("origin changed")); + ctrl(&mut s, 'k'); // A's next kill: fresh + assert_eq!(ring(&s), vec!["\n", "xx"], "A's chain was broken"); +} + +#[test] +fn cancel_from_another_frontend_breaks_the_origin_chain() { + let mut s = editor_with("xx\na(b"); + ctrl(&mut s, 'k'); + alt(&mut s, 'z'); + s.dispatch_key(B, key(KeyCode::Char('g'), KeyModifiers::CONTROL)); // B cancels + ctrl(&mut s, 'k'); + assert_eq!(ring(&s), vec!["\n", "xx"], "two entries, no append"); +} + +#[test] +fn pointer_click_mid_prompt_prevents_a_false_append() { + let mut s = editor_with("xx\na(b"); + ctrl(&mut s, 'k'); // pre-zap kill: last_kill_id armed + alt(&mut s, 'z'); + click(&mut s); // breaks the boundary; the prompt stays open + accept_minibuffer(&mut s, "("); + assert_eq!(buffer_text(&s), "\na(b", "no kill ran"); + assert!(status(&s).contains("origin changed")); + // The click legitimately moved the cursor, so the next C-k's text + // depends on the click position; the pin is that it is a FRESH + // entry — never an append onto the pre-zap "xx". + ctrl(&mut s, 'k'); + let r = ring(&s); + assert_eq!(r.len(), 2, "fresh entry, no append"); + assert_eq!(r[1], "xx", "the pre-zap kill is intact"); +} + +#[test] +fn goto_line_ignores_an_accept_from_another_frontend() { + let mut s = editor_with("a\nb\nc\nd"); + alt(&mut s, 'g'); + press(&mut s, KeyCode::Char('g')); + exec(&s, "pmacs.minibuffer.set_contents('3')"); + press_as(&mut s, B, KeyCode::Enter); + assert_eq!(cursor_line(&s), 0, "no motion"); + let back: bool = eval(&s, "return pmacs.editor.jump_back()"); + assert!(!back, "nothing on the jump stack"); +} + +// ---- silent-replacement matrix (the R3 blocker) ----------------------------- + +#[test] +fn silent_session_replacement_forces_the_next_kill_fresh() { + let mut s = editor_with("abc\nx(y"); + ctrl(&mut s, 'k'); // "abc" + alt(&mut s, 'z'); // zap prompt armed + // A package replaces the session; zap's on_cancel never runs. + exec( + &s, + "pmacs.minibuffer.read { prompt = 'r: ', on_accept = function() end }", + ); + press(&mut s, KeyCode::Enter); // close the replacement + ctrl(&mut s, 'k'); // would falsely append without the marker + assert_eq!( + ring(&s), + vec!["\n", "abc"], + "the uncommitted marker forces a fresh entry" + ); + let cleared: bool = eval( + &s, + "return pmacs.killring._debug_state(pmacs.frontend.id()).pending_kill_prompt == nil", + ); + assert!(cleared, "the marker was consumed by the fail-safe"); +} + +#[test] +fn second_zap_after_a_silent_replacement_does_not_append() { + let mut s = editor_with("abc\nx(y"); + ctrl(&mut s, 'k'); // "abc" + alt(&mut s, 'z'); + exec( + &s, + "pmacs.minibuffer.read { prompt = 'r: ', on_accept = function() end }", + ); + press(&mut s, KeyCode::Enter); + zap(&mut s, "("); // completes cleanly — kills "\nx(" + assert_eq!( + ring(&s), + vec!["\nx(", "abc"], + "arm-time abandoned-marker break: fresh, not appended" + ); +} + +#[test] +fn consumed_marker_aborts_the_zap_fail_closed() { + let mut s = editor_with("a(b"); + alt(&mut s, 'z'); + let was: bool = eval(&s, "return pmacs.killring.commit_kill_prompt()"); + assert!(was, "public Lua consumed the armed marker"); + accept_minibuffer(&mut s, "("); + assert_eq!(buffer_text(&s), "a(b", "no kill"); + assert!(status(&s).contains("prompt state consumed")); + assert_eq!(ring(&s).len(), 0); +} + +// ---- killring API validation ------------------------------------------------ + +#[test] +fn kill_range_validates_before_mutating() { + let s = editor_with("abcdef"); + for bad in [ + "pmacs.killring.kill_range(-1, 2)", + "pmacs.killring.kill_range(0, 0)", + "pmacs.killring.kill_range(1.5, 3)", + "pmacs.killring.kill_range(0, 99)", + "pmacs.killring.kill_range('a', 2)", + ] { + let ok: bool = eval(&s, &format!("return (pcall(function() {bad} end))")); + assert!(!ok, "{bad} must error"); + } + assert_eq!(buffer_text(&s), "abcdef", "no mutation"); + assert_eq!(ring(&s).len(), 0, "no ring entry"); +} + +#[test] +fn break_chain_validates_its_frontend_argument() { + let s = editor_with("abc"); + for bad in [ + "pmacs.killring.break_chain(-1)", + "pmacs.killring.break_chain(1.5)", + "pmacs.killring.break_chain('x')", + ] { + let ok: bool = eval(&s, &format!("return (pcall(function() {bad} end))")); + assert!(!ok, "{bad} must error"); + } + exec(&s, "pmacs.killring.break_chain()"); // acting frontend: fine + exec(&s, "pmacs.killring.break_chain(0)"); // explicit: fine +} + +#[test] +fn kill_range_rejected_and_transformed_leave_the_ring_alone() { + let s = editor_with("abcdef"); + exec( + &s, + r#" + REJECT = true + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if REJECT then error("nope") end + return { kind = "delete", start = 0, ["end"] = 4 } + end) + "#, + ); + let (ok, why): (bool, String) = eval(&s, "return pmacs.killring.kill_range(0, 2)"); + assert!(!ok); + assert_eq!(why, "rejected"); + assert_eq!(buffer_text(&s), "abcdef"); + exec(&s, "REJECT = false"); + let (ok, why): (bool, String) = eval(&s, "return pmacs.killring.kill_range(0, 2)"); + assert!(!ok); + assert_eq!(why, "transformed"); + assert_eq!(buffer_text(&s), "ef", "the intercept's delete stands"); + assert_eq!(ring(&s).len(), 0, "nothing pushed for a deviating kill"); +} + +// --------------------------------------------------------------------------- +// Line ops (Q#EC7) +// --------------------------------------------------------------------------- + +#[test] +fn move_line_down_and_up_round_trip() { + let mut s = editor_with("aa\nbb\ncc"); + exec(&s, "pmacs.editor.goto_byte(1)"); + alt_code(&mut s, KeyCode::Down); + assert_eq!(buffer_text(&s), "bb\naa\ncc"); + assert_eq!(cursor(&s), 4, "cursor rides the moved line, same column"); + alt_code(&mut s, KeyCode::Up); + assert_eq!(buffer_text(&s), "aa\nbb\ncc"); + assert_eq!(cursor(&s), 1); +} + +#[test] +fn move_line_no_ops_at_the_edges() { + let mut s = editor_with("aa\nbb"); + alt_code(&mut s, KeyCode::Up); + assert_eq!(buffer_text(&s), "aa\nbb"); + assert!(status(&s).contains("first line")); + exec(&s, "pmacs.editor.goto_byte(4)"); + alt_code(&mut s, KeyCode::Down); + assert_eq!(buffer_text(&s), "aa\nbb"); + assert!(status(&s).contains("last line")); +} + +#[test] +fn move_line_preserves_a_missing_trailing_newline() { + let mut s = editor_with("aa\nbb"); + alt_code(&mut s, KeyCode::Down); + assert_eq!(buffer_text(&s), "bb\naa", "no trailing newline appears"); + undo(&mut s); + assert_eq!(buffer_text(&s), "aa\nbb", "one undo step"); + exec(&s, "pmacs.editor.goto_byte(4)"); + alt_code(&mut s, KeyCode::Up); + assert_eq!(buffer_text(&s), "bb\naa"); +} + +#[test] +fn duplicate_line_copies_below_with_the_cursor_on_the_copy() { + let mut s = editor_with("aa\nbb"); + exec(&s, "pmacs.editor.goto_byte(1)"); + m_x(&mut s, "edit.duplicate-line"); + assert_eq!(buffer_text(&s), "aa\naa\nbb"); + assert_eq!(cursor(&s), 4, "same column on the copy"); + // Final line without a trailing newline. + let mut s = editor_with("aa\nbb"); + exec(&s, "pmacs.editor.goto_byte(4)"); + m_x(&mut s, "edit.duplicate-line"); + assert_eq!(buffer_text(&s), "aa\nbb\nbb", "invariant preserved"); + assert_eq!(cursor(&s), 7); +} + +#[test] +fn join_line_collapses_the_junction_to_one_space() { + let mut s = editor_with("foo \n bar"); + exec(&s, "pmacs.editor.goto_byte(9)"); + alt(&mut s, '^'); + assert_eq!(buffer_text(&s), "foo bar"); + assert_eq!(cursor(&s), 3, "cursor at the junction"); + undo(&mut s); + assert_eq!(buffer_text(&s), "foo \n bar", "one undo step"); +} + +#[test] +fn join_line_uses_no_space_when_a_side_is_empty() { + let mut s = editor_with("\nbar"); + exec(&s, "pmacs.editor.goto_byte(2)"); + alt(&mut s, '^'); + assert_eq!(buffer_text(&s), "bar", "blank previous line: no space"); + let mut s = editor_with("foo\n "); + exec(&s, "pmacs.editor.goto_byte(5)"); + alt(&mut s, '^'); + assert_eq!( + buffer_text(&s), + "foo", + "whitespace-only current line: no space" + ); +} + +#[test] +fn join_line_no_ops_on_the_first_line() { + let mut s = editor_with("foo\nbar"); + alt(&mut s, '^'); + assert_eq!(buffer_text(&s), "foo\nbar"); + assert!(status(&s).contains("first line")); +} + +// --------------------------------------------------------------------------- +// Region line ops (Q#EC8) +// --------------------------------------------------------------------------- + +fn select_range(s: &EditorState, start: i64, end: i64) { + exec(s, &format!("pmacs.editor.begin_selection({start})")); + exec(s, &format!("pmacs.editor.goto_byte({end})")); +} + +#[test] +fn sort_lines_uses_byte_order_in_any_locale() { + let mut s = editor_with("b\nA\na\nB\n"); + select_range(&s, 0, 8); + m_x(&mut s, "edit.sort-lines"); + assert_eq!( + buffer_text(&s), + "A\nB\na\nb\n", + "explicit byte comparator: uppercase before lowercase" + ); + assert!(region_is_nil(&s)); + assert_eq!(cursor(&s), 0); + undo(&mut s); + assert_eq!(buffer_text(&s), "b\nA\na\nB\n", "one undo step"); +} + +#[test] +fn sort_lines_excludes_a_line_when_the_region_ends_at_its_bol() { + let mut s = editor_with("c\nb\na\n"); + select_range(&s, 0, 4); // ends exactly at the BOL of "a" + m_x(&mut s, "edit.sort-lines"); + assert_eq!(buffer_text(&s), "b\nc\na\n", "the third line is untouched"); +} + +#[test] +fn sort_lines_preserves_a_missing_final_newline() { + let mut s = editor_with("b\na"); + select_range(&s, 0, 3); + m_x(&mut s, "edit.sort-lines"); + assert_eq!(buffer_text(&s), "a\nb"); +} + +#[test] +fn reverse_and_dedupe_lines() { + let mut s = editor_with("a\nb\nc\n"); + select_range(&s, 0, 6); + m_x(&mut s, "edit.reverse-lines"); + assert_eq!(buffer_text(&s), "c\nb\na\n"); + let mut s = editor_with("x\ny\nx\nz\n"); + select_range(&s, 0, 8); + m_x(&mut s, "edit.delete-duplicate-lines"); + assert_eq!(buffer_text(&s), "x\ny\nz\n", "first occurrence kept"); + assert!(status(&s).contains("1 removed")); +} + +#[test] +fn region_ops_require_a_region() { + let mut s = editor_with("b\na\n"); + m_x(&mut s, "edit.sort-lines"); + assert_eq!(buffer_text(&s), "b\na\n"); + assert!(status(&s).contains("no active region")); +} + +// --------------------------------------------------------------------------- +// Intercept discipline (Q#EC2) +// --------------------------------------------------------------------------- + +#[test] +fn rejecting_intercept_leaves_everything_alone() { + let mut s = editor_with("abc"); + exec( + &s, + "pmacs.buffer.add_intercept(pmacs.window.buffer(), function() error('ro') end)", + ); + exec(&s, "pmacs.editor.goto_byte(1)"); + ctrl(&mut s, 't'); + assert_eq!(buffer_text(&s), "abc"); + assert_eq!(cursor(&s), 1, "no cursor motion"); + assert!(status(&s).contains("rejected by buffer intercept")); +} + +#[test] +fn transforming_intercept_translates_and_clamps_the_cursor() { + // Expanding replace that shrinks the buffer below the old cursor: + // dedupe of three identical lines, with the intercept widening the + // replace to the whole buffer. Unrepaired, the cursor (9) would + // strand past the new length (3). + let mut s = editor_with("aa\naa\naa\nbb"); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "replace" then + local b = pmacs.window.buffer() + return { kind = "replace", start = 0, ["end"] = b:len() } + end + end) + "#, + ); + select_range(&s, 0, 9); + m_x(&mut s, "edit.delete-duplicate-lines"); + assert!(status(&s).contains("altered by buffer intercept")); + assert_eq!(buffer_text(&s), "aa\n", "the intercept's replace stands"); + assert_eq!(cursor(&s), 3, "translated and clamped, never past len"); + assert!(region_is_nil(&s), "stale selection cleared"); +} + +#[test] +fn context_switching_intercept_skips_all_fixup() { + let mut s = editor_with("abc"); + exec( + &s, + r#" + OTHER = pmacs.buffer.create("*editops-other*") + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + pmacs.window.switch_buffer(OTHER) + end) + "#, + ); + exec(&s, "pmacs.editor.goto_byte(1)"); + ctrl(&mut s, 't'); + assert!(status(&s).contains("context changed")); + let name: String = eval( + &s, + "return pmacs.describe.buffer(pmacs.window.buffer()).name", + ); + assert_eq!(name, "*editops-other*", "the switch stands"); + assert_eq!(cursor(&s), 0, "the switched-to context is untouched"); + let other_len: i64 = eval(&s, "return OTHER:len()"); + assert_eq!(other_len, 0); +} + +#[test] +fn zero_length_anchor_does_not_reactivate_as_a_selection() { + // The command must MOVE the cursor for this pin to be non-vacuous: + // mid-word upcase travels to the word end. + let mut s = editor_with("foo bar"); + exec(&s, "pmacs.editor.begin_selection(0)"); + alt(&mut s, 'u'); + assert_eq!(buffer_text(&s), "FOO bar"); + assert_eq!(cursor(&s), 3); + assert!( + region_is_nil(&s), + "the dormant anchor must not span the cursor's travel" + ); +} + +// --------------------------------------------------------------------------- +// delete-trailing-whitespace (Q#EC9) +// --------------------------------------------------------------------------- + +#[test] +fn trim_command_trims_and_translates_the_cursor() { + let mut s = editor_with("ab \ncd\t\t\nef"); + exec(&s, "pmacs.editor.goto_byte(3)"); // inside the first run + m_x(&mut s, "edit.delete-trailing-whitespace"); + assert_eq!(buffer_text(&s), "ab\ncd\nef"); + assert_eq!(cursor(&s), 2, "inside a trimmed run: its start"); + assert!(status(&s).contains("trimmed 2 lines")); +} + +#[test] +fn trim_shifts_a_cursor_after_the_runs() { + let mut s = editor_with("ab \ncd"); + exec(&s, "pmacs.editor.goto_byte(6)"); // 'c' + m_x(&mut s, "edit.delete-trailing-whitespace"); + assert_eq!(buffer_text(&s), "ab\ncd"); + assert_eq!(cursor(&s), 3, "shifted left through the deleted run"); +} + +#[test] +fn trim_undo_grain_is_one_step_per_line() { + let mut s = editor_with("a \nb \n"); + m_x(&mut s, "edit.delete-trailing-whitespace"); + assert_eq!(buffer_text(&s), "a\nb\n"); + // Applied bottom-up, so line 1's run was deleted LAST and is + // restored by the FIRST undo: one step per trimmed line. + undo(&mut s); + assert_eq!(buffer_text(&s), "a \nb\n"); + undo(&mut s); + assert_eq!(buffer_text(&s), "a \nb \n"); +} + +#[test] +fn trim_partial_sweep_stops_and_still_translates() { + let mut s = editor_with("a \nb \nc"); + exec(&s, "pmacs.editor.goto_byte(6)"); // 'c' + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "delete" and op.start < 3 then error("no") end + end) + "#, + ); + m_x(&mut s, "edit.delete-trailing-whitespace"); + assert_eq!( + buffer_text(&s), + "a \nb\nc", + "line 2 trimmed, line 1 rejected" + ); + assert!(status(&s).contains("line 1 rejected")); + assert_eq!(cursor(&s), 5, "translated through the one landed delete"); +} + +#[test] +fn trim_mid_sweep_context_switch_stops_the_sweep() { + let mut s = editor_with("a \nb \nc"); + exec( + &s, + r#" + OTHER = pmacs.buffer.create("*trim-other*") + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "delete" then pmacs.window.switch_buffer(OTHER) end + end) + "#, + ); + m_x(&mut s, "edit.delete-trailing-whitespace"); + assert!(status(&s).contains("context changed")); + let name: String = eval( + &s, + "return pmacs.describe.buffer(pmacs.window.buffer()).name", + ); + assert_eq!(name, "*trim-other*"); + assert_eq!(cursor(&s), 0, "no fix-up in the switched-to context"); + // The bottom-most run was deleted (cleanly) before the guard + // tripped; the earlier line's run must be untouched. + let orig: String = eval( + &s, + "return (function() local b for _, id in ipairs(pmacs.buffer.list()) do local d = pmacs.describe.buffer(id) if d.name ~= '*trim-other*' then b = id end end return b:slice(0, b:len()) end)()", + ); + assert_eq!(orig, "a \nb\nc", "sweep stopped at the switch"); +} + +// ---- trim-on-save ----------------------------------------------------------- + +fn temp_path(name: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!("pmacs-editops-{}-{}", std::process::id(), name)); + p +} + +#[test] +fn trim_on_save_defaults_off_and_writes_untouched_bytes() { + let path = temp_path("off.txt"); + std::fs::write(&path, "x \ny\t\n").unwrap(); + let mut s = EditorState::new(); + let on: bool = eval(&s, "return pmacs.editops.trim_on_save()"); + assert!(!on, "default off"); + exec( + &s, + &format!( + "pmacs.buffer.from_file({})", + lua_str(path.to_str().unwrap()) + ), + ); + ctrl(&mut s, 'x'); + ctrl(&mut s, 's'); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "x \ny\t\n"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn trim_on_save_trims_the_written_bytes_before_later_callbacks() { + let path = temp_path("on.txt"); + std::fs::write(&path, "x \ny\t\n").unwrap(); + let mut s = EditorState::new(); + exec(&s, "pmacs.editops.trim_on_save(true)"); + // A callback registered AFTER editops' (load-time) hook observes + // the fan-out order saveplace sees: post-trim text. + exec( + &s, + r#" + SEEN = nil + pmacs.hook.add("buffer.before-save", function() + local b = pmacs.window.buffer() + SEEN = b:slice(0, b:len()) + end) + "#, + ); + exec( + &s, + &format!( + "pmacs.buffer.from_file({})", + lua_str(path.to_str().unwrap()) + ), + ); + ctrl(&mut s, 'x'); + ctrl(&mut s, 's'); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "x\ny\n", + "disk trimmed" + ); + let seen: String = eval(&s, "return SEEN"); + assert_eq!( + seen, "x\ny\n", + "later before-save callbacks see post-trim text" + ); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn trim_on_save_failure_never_vetoes_the_save() { + let path = temp_path("veto-immune.txt"); + std::fs::write(&path, "x \n").unwrap(); + let mut s = EditorState::new(); + exec(&s, "pmacs.editops.trim_on_save(true)"); + exec( + &s, + &format!( + "pmacs.buffer.from_file({})", + lua_str(path.to_str().unwrap()) + ), + ); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "delete" then error("ro") end + end) + "#, + ); + ctrl(&mut s, 'x'); + ctrl(&mut s, 's'); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "x \n", + "the save proceeded (with the untrimmed bytes)" + ); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn another_callbacks_veto_is_not_masked_by_trim() { + let path = temp_path("veto.txt"); + std::fs::write(&path, "x \n").unwrap(); + let mut s = EditorState::new(); + exec(&s, "pmacs.editops.trim_on_save(true)"); + exec( + &s, + &format!( + "pmacs.buffer.from_file({})", + lua_str(path.to_str().unwrap()) + ), + ); + exec( + &s, + "pmacs.hook.add('buffer.before-save', function() return false end)", + ); + ctrl(&mut s, 'x'); + ctrl(&mut s, 's'); + assert!(status(&s).contains("vetoed")); + // A wrongly-unvetoed save would have written the TRIMMED bytes + // (trim ran before the veto and edited the buffer). + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "x \n", + "the veto still blocked the write" + ); + let _ = std::fs::remove_file(&path); +} From f7b5005e3d106ecbc4b926d4247780b0d2664fc5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 12 Jul 2026 15:37:59 +0100 Subject: [PATCH 3/4] docs: handoff snapshot (editops lane) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018vF4gQVozBWi38y1SJiGfQ --- docs/agent-handoff.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 2bd5302..7b1e26b 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,6 +1,6 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-10, on the laptop, by the auto-indent +**Last updated: 2026-07-12, on the laptop, by the editops session.** This file is the bridge between development machines. If you are an agent reading this on a fresh clone: this document plus the `docs/*-framing.md` files ARE your memory. Read this fully before @@ -22,6 +22,19 @@ next machine reads it the way you just did. in the daemon's optimistic CRDT source arm for both frontends. The TUI's missing nonempty-selection optimistic type-over gate and generated-buffer search invalidation remain named deferrals. +- **Editing-conveniences pack (editops) in flight on branch + `editops`** — a Lua parallel lane beside the auto-pairing + close-out. Framing `docs/editing-conveniences-framing.md` at + revision 5 (three review rounds + one adopted post-approval + hardening; approved to branch 2026-07-12). goto-line, case ops, + transpose, zap-to-char (kill-chain member with an origin guard + and killring's new pending-prompt marker), line + move/duplicate/join, region sort/reverse/dedupe, + delete-trailing-whitespace + opt-in trim-on-save. killring.lua + gains `kill_range` / `break_chain([fid])` / the marker lifecycle. + On the laptop this branch lives in a git worktree at + `../pmacs-editops` (the main checkout was mid-flight on + auto-pair); fold the worktree back after merge. - Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position: - **Arc 1 (LSP utility surface) COMPLETE** — completion popup (#92/#93), panels/references/outline/hover (#94–#96), plus @@ -168,7 +181,11 @@ Editing: word kills (`M-d`/`M-BS` — need bytes-returning deleters + prepend-on-backward append), `C-SPC` set-mark, `C-u C-y` / `C-M-w`, kill-ring browser + persistence, clipboard watching, block comments + mid-line comment spans, comment-dwim append-at-EOL, per-language -comment padding. +comment padding. Editops deferrals (full list in its framing): +recenter (blocked on viewport facts — the GPU never consumes daemon +`view_top`), Unicode case/word classes, region-spanning +move/duplicate, locale collation for sort-lines, zap chain semantics +through boundary-disturbing input, ensure-final-newline on save. Substrate: buffer-aware edit epoch (after-edit currently compares the ACTIVE buffer only), wire provenance for CRDT self-insert classification, Lua intercept probe, completion.lua still on the old From 87e88da02411c43970222b61268f3f003a69b7f1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 12 Jul 2026 16:34:18 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(edit):=20PR=20#111=20round=201=20?= =?UTF-8?q?=E2=80=94=20scalar-valid=20UTF-8,=20per-word=20capitalize,=20tr?= =?UTF-8?q?im=20error=20reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1: codepoint recognition is now full UTF-8 scalar validation (shared second-byte constraint table: overlongs, surrogates, and beyond-U+10FFFF all fail), and transpose validates the scalar AT the cursor trailing-bytes-included — a valid lead with non-continuation trailing bytes fails closed, as does a length-consistent overlong or out-of-range span behind the cursor. Zap's single-codepoint check uses the same validator as defense-in-depth (minibuffer contents arrive as Rust-side UTF-8; the buffer-facing checks are the load-bearing ones). Finding 2: capitalize is per-word across the span — Emacs capitalize-region parity, verified against Emacs 30.2 ("hello WORLD" -> "Hello World", "9abc a9bc" -> "9abc A9bc"); the one remaining deviation is named and pinned: `_` is a word constituent in this pack's ASCII class, so "foo_bar" -> "Foo_bar" versus Emacs's "Foo_Bar". Finding 3: an unexpected error caught by the trim-on-save outer pcall is no longer discarded — it reports on the status line AND the *errors* buffer via pmacs.error (the autosave sweep convention), both pcall'd, still never vetoing the save. All three fixes bite-verified: the five new/updated acceptance cases fail against the pre-fix editops.lua (72 total now). Framing at revision 6. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018vF4gQVozBWi38y1SJiGfQ --- builtin/runtime/editops.lua | 117 +++++++++++++++++--------- docs/editing-conveniences-framing.md | 78 ++++++++++++++---- tests/editops_acceptance.rs | 118 ++++++++++++++++++++++++++- 3 files changed, 259 insertions(+), 54 deletions(-) diff --git a/builtin/runtime/editops.lua b/builtin/runtime/editops.lua index 7d1f061..13fc1a6 100644 --- a/builtin/runtime/editops.lua +++ b/builtin/runtime/editops.lua @@ -110,28 +110,49 @@ local function is_cont_byte(b) return b >= 0x80 and b <= 0xBF end -local function cp_len(lead) - if lead < 0x80 then return 1 end - if lead >= 0xC2 and lead <= 0xDF then return 2 end - if lead >= 0xE0 and lead <= 0xEF then return 3 end - if lead >= 0xF0 and lead <= 0xF4 then return 4 end +-- Byte-length of the VALID UTF-8 scalar starting at index `i` of +-- `s`, or nil. Enforces the full second-byte constraint table — +-- overlong encodings (C0/C1, E0 80..9F, F0 80..8F), surrogates +-- (ED A0..BF), and > U+10FFFF (F4 90..BF, F5..FF) all fail, not +-- just bad lead/continuation ranges. Every consumer of "one +-- codepoint" routes through here so malformed bytes fail closed. +local function scalar_len(s, i) + local b1 = s:byte(i) + if not b1 then return nil end + if b1 < 0x80 then return 1 end + local b2 = s:byte(i + 1) + if not b2 or not is_cont_byte(b2) then return nil end + if b1 >= 0xC2 and b1 <= 0xDF then + return 2 + end + if b1 >= 0xE0 and b1 <= 0xEF then + if b1 == 0xE0 and b2 < 0xA0 then return nil end + if b1 == 0xED and b2 > 0x9F then return nil end + local b3 = s:byte(i + 2) + if not b3 or not is_cont_byte(b3) then return nil end + return 3 + end + if b1 >= 0xF0 and b1 <= 0xF4 then + if b1 == 0xF0 and b2 < 0x90 then return nil end + if b1 == 0xF4 and b2 > 0x8F then return nil end + local b3 = s:byte(i + 2) + if not b3 or not is_cont_byte(b3) then return nil end + local b4 = s:byte(i + 3) + if not b4 or not is_cont_byte(b4) then return nil end + return 4 + end return nil end local function single_codepoint(s) - if #s == 0 then return false end - local n = cp_len(s:byte(1)) - if not n or n ~= #s then return false end - for i = 2, #s do - if not is_cont_byte(s:byte(i)) then return false end - end - return true + return #s > 0 and scalar_len(s, 1) == #s end -- Start of the codepoint ending just before `pos`. Returns nil at -- BOB; nil, "malformed" when the preceding bytes are not one valid --- UTF-8 codepoint (fail closed — goto_byte guarantees no boundary --- alignment). +-- UTF-8 scalar (fail closed — goto_byte guarantees no boundary +-- alignment, and a length-consistent overlong/surrogate span must +-- not be treated as a character). local function prev_cp_start(buf, pos) if pos <= 0 then return nil end local q = pos - 1 @@ -142,9 +163,8 @@ local function prev_cp_start(buf, pos) q = q - 1 steps = steps + 1 end - local lead = buf:slice(q, q + 1):byte(1) - local n = cp_len(lead) - if not n or q + n ~= pos then return nil, "malformed" end + local span = buf:slice(q, pos) + if scalar_len(span, 1) ~= pos - q then return nil, "malformed" end return q end @@ -162,25 +182,29 @@ local function ascii_lower(s) end)) end --- First word byte upcased when it is a letter; every other letter --- downcased (single-span semantics, Q#EC4). +-- Per-word capitalize across the span (Emacs capitalize-region +-- parity, verified against Emacs 30.2: "hello WORLD" -> "Hello +-- World", "9abc" -> "9abc", "a9bc" -> "A9bc"): each word's first +-- byte is upcased when it is a letter, every other letter is +-- downcased. Words per the pack's ASCII class — `_` and digits are +-- word constituents here, so "foo_bar" gives "Foo_bar" where +-- Emacs's symbol-syntax `_` gives "Foo_Bar" (named deviation, +-- Q#EC4). local function ascii_capitalize(s) - local first = nil + local out = {} + local in_word = false for i = 1, #s do - if is_word_byte(s:byte(i)) then - first = i - break + local b = s:byte(i) + local w = is_word_byte(b) + if w and not in_word then + if b >= 97 and b <= 122 then b = b - 32 end + elseif w then + if b >= 65 and b <= 90 then b = b + 32 end end + in_word = w + out[#out + 1] = string.char(b) end - local lowered = ascii_lower(s) - if not first then return lowered end - local b = lowered:byte(first) - if b >= 97 and b <= 122 then - return lowered:sub(1, first - 1) - .. string.char(b - 32) - .. lowered:sub(first + 1) - end - return lowered + return table.concat(out) end -- ---- Q#EC2 shared discipline --------------------------------------- @@ -353,13 +377,17 @@ pmacs.command.define { or "transpose-chars: not enough characters") return end - local n2 = cp_len(at_b) - if not n2 or cursor + n2 > len then + -- Validate the WHOLE scalar at the cursor, trailing bytes + -- included — a valid lead followed by non-continuation bytes + -- must fail closed, not ride along as "one character". + local head = buf:slice(cursor, math.min(cursor + 4, len)) + local n2 = scalar_len(head, 1) + if not n2 then ed.set_status("transpose-chars: malformed UTF-8 at the cursor") return end local cp1 = buf:slice(s1, cursor) - local cp2 = buf:slice(cursor, cursor + n2) + local cp2 = head:sub(1, n2) guarded_replace("transpose-chars", buf, s1, cursor + n2, cp2 .. cp1, cursor + n2) end @@ -824,13 +852,26 @@ end -- loads before saveplace.lua (the loader ordering contract, Q#EC9). pmacs.hook.add("buffer.before-save", function() -- Outer pcall: a raised error in a short-circuit hook vetoes the - -- save; trim failure must report via status, never block a save. - pcall(function() + -- save; trim failure must never block a save. But it must not be + -- silently discarded either — an unexpected error is a bug, and + -- swallowing it hides the bug. Report on both channels the + -- autosave sweep uses: the status line (visible when the save + -- itself fails or is vetoed; a successful save overwrites it with + -- "saved ...") and the *errors* buffer via pmacs.error (durable + -- either way). Both reports are pcall'd — a broken reporting + -- channel must not resurrect the veto. + local ok, err = pcall(function() if trim_enabled then trim_active("delete-trailing-whitespace (on save)") end end) - -- nil return: never a veto. + if not ok then + local msg = "delete-trailing-whitespace (on save) failed: " + .. tostring(err) + pcall(ed.set_status, msg) + if pmacs.error then pcall(pmacs.error, msg) end + end + -- nil return either way: never a veto. end) -- ---- bindings (Q#EC1: all verified free across builtin bind sites) -- diff --git a/docs/editing-conveniences-framing.md b/docs/editing-conveniences-framing.md index cc91aa9..a39e2d1 100644 --- a/docs/editing-conveniences-framing.md +++ b/docs/editing-conveniences-framing.md @@ -58,6 +58,17 @@ Revision 5 (post-approval, R3's optional hardening adopted): zap fails closed — no kill, chain broken — when public Lua consumed the marker while the prompt was open. +Revision 6 (PR #111 round 1): codepoint recognition is full UTF-8 +scalar validation (second-byte constraint table; transpose +validates the cursor scalar trailing-bytes-included, and a +length-consistent overlong/surrogate span behind the cursor fails +closed); capitalize is per-word across the span (Emacs +capitalize-region parity, empirically verified, with the pack's +`_`-is-a-word-constituent deviation named); trim-on-save reports +unexpected errors on the status line AND the `*errors*` buffer via +`pmacs.error` instead of silently discarding them, still never +vetoing. + ## Ground truth (as of `7e127ab`) - **The taken-chord registry is wider than `builtin/keymaps/ @@ -337,7 +348,16 @@ range gsub with a byte map — NOT `string.upper/lower` and NOT 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. + +Capitalize is PER-WORD across the span — Emacs capitalize-region +parity (PR #111 R1 finding 2; empirical, Emacs 30.2 `-Q --batch`: +`"hello WORLD"` → `"Hello World"`, `"9abc a9bc"` → `"9abc A9bc"`): +each word's first byte is upcased when it is a letter, every other +letter downcased; a digit-led word keeps its letters lowercase. One +named deviation remains: `_` is a word constituent in this pack's +class (the `word_at_cursor` precedent) but symbol-syntax in Emacs, +so `foo_bar` capitalizes as `Foo_bar` here versus Emacs's +`Foo_Bar`. ### Q#EC5 — Transpose: codepoint-aware chars, Emacs-verified word boundaries @@ -345,13 +365,24 @@ Capitalize = first word char upcased, rest of span downcased. 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. +codepoints → status, no edit. Codepoint recognition is FULL scalar +validation, not lead/continuation range checks (PR #111 R1 finding +1): a shared validator enforces the UTF-8 second-byte constraint +table — overlongs (`C0`/`C1`, `E0 80..9F`, `F0 80..8F`), surrogates +(`ED A0..BF`), and beyond-`U+10FFFF` (`F4 90..BF`, `F5..FF`) all +fail — and the scalar AT the cursor is validated trailing bytes +included (a valid lead followed by non-continuation bytes must not +ride along as "one character"). Failures fail closed: a +continuation byte at the cursor, a malformed scalar at the cursor, +and a length-consistent-but-invalid span behind it each report and +leave the buffer untouched — `goto_byte` does not guarantee +boundary alignment, and buffers are byte-clean, so malformed input +is reachable. (Zap's single-codepoint input check uses the same +validator as defense-in-depth; minibuffer contents arrive as +Rust-side UTF-8 — `set_contents` is `String`-typed — so the +buffer-facing checks are the load-bearing ones.) 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: @@ -581,9 +612,15 @@ 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. +short-circuit hook vetoes immediately (`src/hook.rs:299`). An +unexpected error caught by that pcall is NOT silently discarded +(PR #111 R1 finding 3) — it reports on both channels the autosave +sweep uses: the status line (visible when the save fails or is +vetoed; a successful save overwrites it with "saved ...") and the +`*errors*` buffer via `pmacs.error` (durable either way; the +async/mcp/syntax/autosave convention). Both reports are +themselves pcall'd so a broken reporting channel cannot resurrect +the veto. ### Q#EC10 — Cut from the pack: recenter @@ -658,12 +695,21 @@ harness the kill-ring suite already uses. 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). + (explicit-range pin); capitalize: region `"hello WORLD"` → + `"Hello World"` (per-word, the Emacs parity row), `"9abc a9bc"` → + `"9abc A9bc"` (digit-led word keeps letters lowercase), and + `"foo_bar baz"` → `"Foo_bar Baz"` (the named `_` deviation, + pinned). - 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). + pin); **malformed-scalar pins**: a valid lead with a + non-continuation trailing byte at the cursor (`a\xC3xb`), an + overlong span behind the cursor (`\xE0\x80\x80b`), and a + beyond-`U+10FFFF` span behind it (`\xF4\x90\x80\x80b`) each → + status, buffer byte-identical. 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 @@ -751,7 +797,11 @@ harness the kill-ring suite already uses. (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). + veto still vetoes (trim's `nil` return masks nothing); + **unexpected-error pin**: an error raised inside the on-save trim + (beyond the per-edit pcalls) → the save still proceeds AND the + failure lands in the `pmacs.error` log (stubbed, the m9_6 + pattern) — never silently discarded. No CRDT-specific suite: every editops edit is a daemon-peer edit on the dispatch or minibuffer-accept path with no optimistic-classifier diff --git a/tests/editops_acceptance.rs b/tests/editops_acceptance.rs index 5452495..0a30f95 100644 --- a/tests/editops_acceptance.rs +++ b/tests/editops_acceptance.rs @@ -306,11 +306,29 @@ fn capitalize_word_and_region() { alt(&mut s, 'c'); assert_eq!( buffer_text(&s), - "Hello world", - "single-span capitalize: first word char up, rest down" + "Hello World", + "per-word capitalize across the region (Emacs capitalize-region)" ); } +#[test] +fn capitalize_is_per_word_with_the_packs_word_class() { + // Emacs 30.2 parity rows: a digit-led word keeps its letters + // lowercase; a letter after a digit is not a word start. + let mut s = editor_with("9abc a9bc"); + exec(&s, "pmacs.editor.begin_selection(0)"); + exec(&s, "pmacs.editor.goto_byte(9)"); + alt(&mut s, 'c'); + assert_eq!(buffer_text(&s), "9abc A9bc"); + // The named deviation: `_` is a word constituent in this pack's + // ASCII class (Emacs's symbol-syntax `_` would give "Foo_Bar"). + let mut s = editor_with("foo_bar baz"); + exec(&s, "pmacs.editor.begin_selection(0)"); + exec(&s, "pmacs.editor.goto_byte(11)"); + alt(&mut s, 'c'); + assert_eq!(buffer_text(&s), "Foo_bar Baz"); +} + #[test] fn case_ops_report_when_no_word_follows() { let mut s = editor_with("foo "); @@ -406,6 +424,55 @@ fn transpose_chars_fails_closed_on_a_continuation_byte() { assert!(status(&s).contains("multi-byte")); } +/// Seed raw (possibly malformed) bytes via Lua \x escapes and compare +/// byte-identically Lua-side (`from_utf8_lossy` would mask differences). +fn seed_raw(s: &EditorState, lua_bytes: &str) { + exec( + s, + &format!("local b = pmacs.window.buffer(); b:replace(0, b:len(), \"{lua_bytes}\")"), + ); + exec(s, "pmacs.editor.goto_byte(0)"); +} + +fn raw_equals(s: &EditorState, lua_bytes: &str) -> bool { + eval( + s, + &format!("local b = pmacs.window.buffer(); return b:slice(0, b:len()) == \"{lua_bytes}\""), + ) +} + +#[test] +fn transpose_chars_fails_closed_on_invalid_trailing_bytes() { + // A valid lead (C3) followed by a non-continuation byte: the + // scalar at the cursor must be validated whole, not length-only. + let mut s = editor_with(""); + seed_raw(&s, "a\\xC3xb"); + exec(&s, "pmacs.editor.goto_byte(1)"); + ctrl(&mut s, 't'); + assert!(raw_equals(&s, "a\\xC3xb"), "no edit on a malformed scalar"); + assert!(status(&s).contains("malformed UTF-8 at the cursor")); +} + +#[test] +fn transpose_chars_fails_closed_on_invalid_scalars_behind_the_cursor() { + // Length-consistent but scalar-invalid spans behind the cursor: + // an overlong three-byte encoding and a beyond-U+10FFFF four-byte + // encoding. Both scan back cleanly (lead + continuations) and + // must still fail closed. + let cases = [("\\xE0\\x80\\x80b", 3i64), ("\\xF4\\x90\\x80\\x80b", 4)]; + for (bytes, cursor_pos) in cases { + let mut s = editor_with(""); + seed_raw(&s, bytes); + exec(&s, &format!("pmacs.editor.goto_byte({cursor_pos})")); + ctrl(&mut s, 't'); + assert!(raw_equals(&s, bytes), "no edit for {bytes}"); + assert!( + status(&s).contains("malformed UTF-8 before the cursor"), + "fail-closed status for {bytes}" + ); + } +} + // --------------------------------------------------------------------------- // Transpose-words: the nine-position Emacs 30.2 table (Q#EC5) // --------------------------------------------------------------------------- @@ -1175,6 +1242,53 @@ fn trim_on_save_failure_never_vetoes_the_save() { let _ = std::fs::remove_file(&path); } +#[test] +fn trim_on_save_unexpected_error_reports_and_still_saves() { + let path = temp_path("unexpected.txt"); + std::fs::write(&path, "x \n").unwrap(); + let mut s = EditorState::new(); + exec(&s, "pmacs.editops.trim_on_save(true)"); + // Capture the pmacs.error log (the m9_6 stub pattern — the + // `if pmacs.error` branch is a no-op without it). + exec( + &s, + r" + PMACS_ERROR_LOG = {} + pmacs.error = function(msg) + PMACS_ERROR_LOG[#PMACS_ERROR_LOG + 1] = msg + end + ", + ); + exec( + &s, + &format!( + "pmacs.buffer.from_file({})", + lua_str(path.to_str().unwrap()) + ), + ); + // Force an error INSIDE trim, past the per-edit pcalls: trim's + // context snapshot reads pmacs.window.current(), which nothing + // else on the save path touches. + exec( + &s, + "TRIM_ORIG_WC = pmacs.window.current; pmacs.window.current = function() error('boom') end", + ); + ctrl(&mut s, 'x'); + ctrl(&mut s, 's'); + exec(&s, "pmacs.window.current = TRIM_ORIG_WC"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "x \n", + "the save proceeded (trim aborted before any edit)" + ); + let logged: String = eval(&s, "return PMACS_ERROR_LOG[1] or ''"); + assert!( + logged.contains("delete-trailing-whitespace (on save) failed:"), + "the unexpected error reached the pmacs.error log, got: {logged}" + ); + let _ = std::fs::remove_file(&path); +} + #[test] fn another_callbacks_veto_is_not_masked_by_trim() { let path = temp_path("veto.txt");