From ac8c46f2d392923d2dcd98be26168c5ae5737c3d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 11 Jul 2026 16:11:51 +0100 Subject: [PATCH 1/6] docs: auto-pairing framing (Arc 2) + handoff snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-pairing framing at revision 3 (two pre-branch review rounds): pair chars leave both optimistic classifiers and round-trip through dispatch (adjacent daemon-peer undo units, real type-over, no transient skip paint); reaction hook pair.lua loads before lsp.lua so the synchronous sighelp didChange flush ships the closer; exact one-shot typed-edit provenance (pmacs.editor.take_typed_edit) gates pairing fail-closed against transformed, relocated, or context-switched source self-inserts and non-typed origins. Handoff: main @ 7e127ab (auto-indent #109 merged), auto-pairing in flight on this branch, Arc 2 deferrals consolidated into §6. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- docs/agent-handoff.md | 42 +-- docs/auto-pairing-framing.md | 490 +++++++++++++++++++++++++++++++++++ 2 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 docs/auto-pairing-framing.md diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 2bd5302..dd2421b 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-11, on the laptop, by the auto-pairing 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 @@ -8,28 +8,31 @@ taking on work, seed your persistent memory from it, and **update this file (and commit it) whenever project state changes materially** — the next machine reads it the way you just did. -## 1. Where the project stands (2026-07-10) +## 1. Where the project stands (2026-07-11) -- `main` @ `efa41cb`, protocol **v15** (`SUPPORTED=[6..15]`). -- **Auto-indent on newline (Arc 2) in flight on this branch** — - framing `docs/auto-indent-framing.md` is at revision 6 (five - pre-branch review rounds plus PR #109 round 1). RET now binds - `edit.newline-and-indent`; plain Enter is no longer GPU-optimistic - (round-trips like the TUI). Rode along: Q#AI8 search invalidation is - shared by dispatch, direct notification, undo, and redo (stale - step/summary fail closed; live origins translate through edits), and - Q#AI9 clears empty selections only after successful core inserts and - in the daemon's optimistic CRDT source arm for both frontends. The +- `main` @ `7e127ab` (auto-indent #109 merged), protocol **v15** + (`SUPPORTED=[6..15]`). +- **Auto-pairing (Arc 2, last item) in flight on this branch** — + framing `docs/auto-pairing-framing.md` at revision 3 (two + pre-branch review rounds). Shape: pair chars leave both frontends' + optimistic classifiers (dispatch-routed, daemon-peer undo units); + reaction hook in `builtin/runtime/pair.lua` loaded BEFORE lsp.lua; + exact one-shot typed-edit provenance via + `pmacs.editor.take_typed_edit()` (Q#AP9). When this merges, Arc 2 + closes. +- Auto-indent (#109) landed: RET binds `edit.newline-and-indent`; + plain Enter round-trips on both frontends. Rode along: shared + search invalidation across dispatch/notification/undo/redo (Q#AI8), + empty-selection clearing after successful inserts (Q#AI9). The TUI's missing nonempty-selection optimistic type-over gate and - generated-buffer search invalidation remain named deferrals. + generated-buffer search invalidation remain named deferrals (§6). - 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 hardening follow-ups (#102, #105, #106). - **Arc 2 (editing table stakes)** — query-replace (#97), kill ring - + `M-y` (#103/#105/#106), comment-toggle (#107), auto-indent (this - branch). **Remaining after this merges: auto-pairing**, as its own - small framing + PR. + + `M-y` (#103/#105/#106), comment-toggle (#107), auto-indent + (#109), auto-pairing (this branch — the last item). - **Arc 3 (persistence) COMPLETE** — saveplace/recentf (#98), desktop-save (#99), autosave/crash-recovery (#100), save-clobber fix (#101). @@ -172,7 +175,12 @@ comment padding. 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 -cursor-delta heuristic (migrate to `this_command`). +cursor-delta heuristic (migrate to `this_command`), the TUI's +nonempty-selection optimistic type-over gate, generated-buffer search +invalidation, cross-peer chronological undo arbitration (mixed +source/daemon history; pinned by auto-pairing acceptance), +origin-pinned `buffer.after-edit` fan-out (a context-switching +intercept changes what later callbacks — LSP, completion — observe). LSP/persistence: hidden-buffer LSP attach, daemon desktop-restore, the *warning* half of external-change detection (verify-visited-file- modtime), config registry (no unified config surface yet). diff --git a/docs/auto-pairing-framing.md b/docs/auto-pairing-framing.md new file mode 100644 index 0000000..0479144 --- /dev/null +++ b/docs/auto-pairing-framing.md @@ -0,0 +1,490 @@ +# Auto-pairing — framing (Arc 2, editing table stakes) + +Typing `(` should give `()` with the cursor between; typing `)` when +the next char is already `)` should step over it instead of doubling +it. Language-aware pair sets, conservative insertion predicate. Last +Arc 2 item; after it merges, Arc 2 closes and the compile-mode vs +themes decision discussion is due. + +Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-pairing"). +Revision 2: R1 findings — pair chars now route through dispatch +(peer-bound undo falsified the pure-reaction undo story), pair.lua +loads before lsp.lua (the sighelp path flushes didChange +synchronously mid-hook), rejected/transformed intercept outcomes +separated (the effective edit has already landed), the type-over +claim is path-qualified with a region guard, and the CRDT acceptance +gains a second replica plus undo cases for both routing models. + +Revision 3: R2 findings — the dispatch route no longer claims global +chronological undo (an older source-peer edit still wins the TUI's +local undo arbitration), source self-inserts gain an exact ephemeral +typed-edit record so a relocated opener can never be inferred from +unrelated text, the context-switch/LSP limit is stated rather than +hidden by the cursor guard, non-typed acceptance now drives callbacks +that actually fire, and redo is exercised rather than merely named. + +## Ground truth (as of `7e127ab`) + +- **Dispatch is keymap-first for printables** — `Char('(')` resolves + through the keymap stack before the self-insert fallback + (`src/editor.rs:713-717`, fallback `:748-761`); printable keys are + bindable (the buffer list binds `n`/`p` buffer-locally, + `builtin/commands/default.lua:407-423`). +- **But a key binding cannot carry pairing.** The GPU applies plain + printables optimistically **even mid-line** — classifier + `pmacs-gpu/src/main.rs:1525-1533`, eligibility `:2054-2079` — so a + `(`-binding would never run for GPU typing. The TUI applies + printables optimistically **at end of line** (the F19 paint + constraint: mid-line inserts round-trip, EOL appends do not — + `src/optimistic.rs:269-271`, contract `:168-200`). And a binding + that inserted the pair atomically would break classification + (below) even where it did run. +- **The typed-char substrate exists and has a working consumer.** The + daemon classifies single-codepoint optimistic inserts as + `buffer.self-insert` (exact byte decode, + `src/daemon.rs:1985-2002`, `:2085-2087`), explicitly "for + typed-char consumers (signature help; …)". `buffer.after-edit` + fires on BOTH paths — dispatch (`src/editor.rs:772-776`) and the + optimistic CRDT arm (`src/daemon.rs:2156-2169`) — with core + borrows released, so a hook may edit the buffer. Signature-help + auto-trigger (`builtin/runtime/lsp.lua:761-791`) is the in-tree + template: `this_command() == "buffer.self-insert"` plus + `char_before`; a paste can never trigger it. +- **An atomic 2-byte `"()"` insert breaks that classification** + (`is_single_codepoint_insert` decodes the actual bytes, + `src/daemon.rs:2079-2084` names signature help as the reason), and + **intercepts cannot rewrite `(` into `()`** (M6.4: kind and payload + immutable, `src/lua_bindings/mod.rs:1061-1073`, `:1657-1705`). Any + pairing design must keep the opener a genuine single-codepoint + self-insert. +- **Hook mechanics**: callbacks run in registration order + (`src/hook.rs:240`, snapshot `:255-259`; fan-out `:291-297`); + `buffer.after-edit` is **all-must-succeed** + (`builtin/hooks/default.lua:41-45`) — one callback's error or + return value cannot suppress the others. Hook-created edits do + **not** re-fire `buffer.after-edit` (fired once per dispatch cycle + / per optimistic op). +- **The LSP hook flushes synchronously mid-fan-out.** The after-edit + callback in lsp.lua marks the buffer dirty, and — when the typed + char is a signature trigger — calls `signature_help_quiet`, which + calls `flush_did_change_for(rec)` **synchronously** + (`builtin/runtime/lsp.lua:739-743`: "The server must see the + character we just typed") before requesting. `flush_did_change` + reads `buffer_text` at flush time and clears the pending entry + (`:268-279`). Consequence: a closer inserted by a hook registered + **after** lsp.lua's would miss that flush AND stay unsynchronized + (no re-fire) until the next edit. Coalescing does not save this + path; registration order does. +- **Undo is peer-bound, per frontend.** Loro's `UndoManager` binds to + one peer at construction; each frontend's `BufferMirror` undoes + only its own peer's ops (`src/buffer_mirror.rs:541-555`), and the + daemon's `buffer.undo` covers only daemon-peer edits + (`src/buffer.rs:1360-1380`). The TUI's undo key tries the mirror + first and **round-trips to the daemon when the mirror has nothing + to undo** (`src/optimistic.rs:238-247`). Consequence: a pair whose + opener is a source-peer optimistic op and whose closer is a + daemon-peer Lua edit is **not undoable coherently by either + frontend** — the TUI removes the opener first (leaving `)`), the + daemon removes the closer then unrelated daemon edits. Edits that + route through dispatch are all daemon-peer. +- **The TUI's mirror-empty fallback is not chronological + arbitration.** If the mirror has *any* older source-peer edit, its + optimistic undo succeeds and the key never reaches the daemon + (`src/optimistic.rs:230-247`). Thus `a` (optimistic) then `()` + (daemon-routed) followed by the TUI's single-key undo removes `a`, + not the closer. `C-x u`, which always dispatches, removes the + daemon closer instead. Routing the pair's two edits to one peer + makes their daemon-local order coherent; it does not merge that + order with older source-peer history. Global chronological undo is + existing cross-peer substrate work, not something pairing can + truthfully claim to solve. +- **Mutator effects land before the caller sees them.** The effective + edit is applied and only then reported as the returned triple + (`src/lua_bindings/mod.rs:1184`, `:1246-1259`) — a transformed + (relocated/expanded) edit has already happened; it can be + *repaired around*, never *withheld*. Lua mutators move no cursors + and reconcile no window state (PR #109 ground truth); the + context-guarded right-gravity repair in + `builtin/runtime/indent.lua:60-64`, `:100-121` is the established + pattern. +- **The producing self-insert has the same intercept problem, and + `after-edit` currently carries no payload.** `insert_char` advances + the cursor from the requested position after `apply_active_edit` + (`src/editor_core.rs:1738-1749`), even if an insert intercept moved + the effective insertion. `this_command` proves only the input + class; `char_before(cursor)` does not prove which character was + typed or where it landed. Pairing therefore needs exact ephemeral + source-edit provenance, not the signature-help heuristic alone. +- **A hook-inserted closer lands correctly by construction**: insert + at the cursor, cursor stays before it (mutators move no cursors); + the daemon's `CursorByte` re-grounds both frontends; the GPU + applies incoming ops and rebases unconfirmed edits without moving + `own_cursor` (`pmacs-gpu/src/main.rs:2463-2564`). +- **Broadcast ordering quirk on the optimistic path**: the after-edit + hook fires (`src/daemon.rs:2167`) **before** the source opener op + is queued for broadcast (`:2178`), so a hook-queued DaemonKey + closer reaches **non-source replicas before the opener it depends + on**. Loro is expected to buffer and converge; nothing pins that + today. +- **Region-active typing**: CUA type-over consumes the region on the + dispatch path. The GPU round-trips when a selection decoration + exists; the TUI's optimistic gate consults no selection state (PR + #109 named deferral) — a nonempty TUI selection ending at EOL can + optimistically insert without consuming the region, and the daemon + arm deliberately preserves nonempty anchors. +- **A callback may switch context, and later callbacks observe the + switch.** The hook runner snapshots callbacks, not editor context; + lsp.lua reads `pmacs.window.buffer()` when its callback runs. If a + pair reaction's intercept switches from A to B, a local cursor guard + can avoid touching B, but it cannot make the later LSP callback see + A. Origin-pinned hook fan-out is named substrate work below. +- **Direct Lua mutation and plain `pmacs.command.invoke` do not fire + `buffer.after-edit`.** A non-typed regression that merely calls + either API is vacuous; the test must explicitly run the hook or use + a production path such as paste which fires it. +- **No pair knowledge exists anywhere**; `pmacs.comment.strings` + (`builtin/runtime/comment.lua:26-42`) is the per-language table + precedent. No node-at-byte API on `pmacs.parse` (manual descent + only; async/stale trees) — syntax-aware inhibit is not v1-viable. +- Rust uses `'` for lifetimes — pairing `'` per-language is a + correctness matter, not taste. + +## Decisions + +### Q#AP1 — Carrier: after-edit reaction, with pair chars routed through dispatch + +Two coupled decisions, each grounded in the constraints above: + +**The reaction carrier** (unchanged from R1): on +`this_command() == "buffer.self-insert"`, the pairing hook reads the +exact ephemeral typed-edit record from Q#AP9 and reacts with a second +edit. This keeps the opener a genuine single-codepoint self-insert — +the classification signature help depends on — without guessing its +identity or position from surrounding buffer text. + +**The routing change** (new, R1 finding): the built-in pair charset +`( ) [ ] { } " ' `` ` is **removed from both optimistic +classifiers** — the GPU's `optimistic_insert_text` and the TUI's +`classify_key` — so those chars always round-trip through dispatch. +Without this, the opener is a source-peer op and the closer a +daemon-peer op, and peer-bound undo makes the pair uncleanly +undoable on every frontend (ground truth). With it, both edits are +adjacent daemon-peer undo units. A daemon-routed undo removes the +closer and then the opener; the TUI's single-key optimistic undo does +the same **only when its older source-peer stack is empty**. This is +pair-local coherence, not global chronological arbitration (Q#AP5). +The exclusion also restores CUA type-over for pair chars on the TUI +(a round-tripped `(` consumes the region the optimistic path would +have skipped) and removes transient pair/skip paint from both +frontends. + +Costs, named: one daemon round-trip per built-in-charset keystroke — +on the TUI only EOL appends change (mid-line already round-trips); on +the GPU all nine chars do. `'` and `` ` `` round-trip even in languages +whose sets don't pair them — uniform routing beats per-language +classifier state the frontends don't have. **User-extended pair +chars beyond the built-in nine still arrive optimistically**: they +pair correctly (the reaction fires either way) but their undo is +cross-peer-degraded — documented limitation, pinned in acceptance, +full fix deferred with the pre-existing mixed-history problem +(cross-peer chronological undo arbitration). + +`builtin/runtime/pair.lua`; a `pmacs.pair.*` namespace. + +### Q#AP2 — Pair sets: per-language table, conservative default + +`pmacs.pair.sets` — the `pmacs.comment.strings` shape: language → +array of 2-byte pair strings, plus a `default` entry used when the +language is unknown or has no entry (pairing is useful in scratch +buffers): + +- `default = { "()", "[]", "{}", '""' }` — no `'` (prose + apostrophes), no backtick. +- `python`, `lua`, `javascript`/`typescript` (+react), `sh`/`bash` + add `''`; javascript/typescript/markdown add `` `` `` pairs. +- `rust`, `c`, `cpp`, `go`, `zig` = default (lifetimes, char + literals). Users opt in from init.lua: `pmacs.pair.sets.rust = …`. + +### Q#AP3 — Insert-pair semantics (open char typed) + +React when ALL hold: + +- `this_command() == "buffer.self-insert"` **and** Q#AP9 returns a + live typed-edit record for this callback (pastes, manual hook runs, + and programmatic inserts have no record and never pair); +- the record's buffer/window match the current context, its source + edit is clean (effective triple equals the requested insert or + replace), and the current cursor equals its recorded post-edit + cursor. A relocated/expanded or context-switching source + self-insert stands as the intercept produced it and gets no pair + reaction. A non-clean triple reports *"auto-pair skipped: source + self-insert transformed"*; a context/cursor mismatch reports + *"auto-pair skipped: source context changed"*. This is the + fail-closed answer to R2 finding 2; +- **no active region survives the edit** (`ed.region() == nil`) — + on the dispatch path type-over has already consumed and cleared + it; a surviving nonempty region means the edit arrived through the + TUI's selection-blind optimistic gate (custom chars only), where + reacting would pile a closer onto an unconsumed region; +- the record's exact typed codepoint is an opener in the buffer's pair + set (language via `pmacs.lsp.active_buffer_language()`, resolved + **at callback time**, nil-guarded — pair.lua loads before lsp.lua, + Q#AP7). `char_before` is not input provenance; +- **conservative predicate**: the char at the cursor is EOL, + whitespace, or a closing bracket from the pair set — `foo|bar` + + `(` gives `(bar`, never `()bar`; +- for symmetric pairs (quotes), the skip check (Q#AP4) runs first. + +Reaction: one pcall'd `buf:insert(cursor, closer)`. Outcomes, +separated (R1 finding): + +- **Rejected** (intercept threw): nothing landed; the opener stands + alone; status *"auto-pair closer rejected by buffer intercept"*. +- **Transformed** (effective triple deviates): the edit has already + landed wherever the intercept put it — the positional result + stands. Report, then **context-guarded cursor repair**: with the + window+buffer snapshot taken before the mutator, right-gravity- + translate the pre-edit cursor through the effective edit and + `goto_byte` (clamps); skip all repair if the intercept switched + window or buffer. (The clean path needs no cursor motion at all — + the asymmetry is deliberate: repair only on deviation, because the + clean at-cursor insert must leave the cursor *before* the closer, + which translation would not.) + +### Q#AP4 — Skip-over-close, reactive + +When Q#AP9's exact typed char is a closer in the pair set AND the char +at the recorded post-insert cursor equals it: one pcall'd +`buf:delete(cursor, cursor+len)`. Net text and cursor are exactly +Emacs's skip: `(|)` + `)` → `()|`; nested closers skip likewise; +`"` at `"|"` exits the string. With pair chars on the dispatch route +there is no transient duplicate to paint — the frontends never +locally applied the typed closer. + +Same outcome separation as Q#AP3: rejected delete → the duplicate +stays (`())`), status, no retry; transformed delete → already +landed, report, context-guarded translate-and-clamp repair. + +### Q#AP5 — Undo grain + +Built-in pairs are two adjacent daemon-peer edits. On the GPU, and on +the TUI when the mirror has no older source-peer unit (or when the +user invokes the always-dispatched `C-x u`), daemon undo removes the +closer and then the opener. Still two steps, not one; a skip's daemon +undo restores the swallowed duplicate. + +This does **not** make undo globally chronological. With optimistic +`a` already in the TUI mirror, then daemon-routed `()`, the TUI's +single-key optimistic undo removes `a` first because the mirror has a +local unit and never falls back. The GPU can undo the two daemon pair +units but cannot subsequently reach the older source-peer `a` through +daemon undo. Custom optimistic pair chars additionally split the pair +itself across peers. All three behaviors are pinned explicitly; the +general fix is a cross-peer chronological undo arbiter, deferred as +existing collaboration substrate rather than charged to pair.lua. + +### Q#AP6 — Type-over composes on the dispatch path; wrap is deferred + +For built-in pair chars (always dispatch-routed), typing an opener +over an active region type-overs the region, then the reaction runs +under the Q#AP3 predicate — Emacs-with-delete-selection semantics, +guaranteed for a clean, context-preserving source edit. A transformed +source edit fails closed under Q#AP9. For custom optimistic chars the +TUI selection gap persists; the Q#AP3 region guard suppresses the +reaction there rather than compounding the gap. **Wrapping** the region +in the pair is deferred (needs a command carrier plus the TUI +selection-gate fix). + +### Q#AP7 — Load order: pair.lua BEFORE lsp.lua + +Registration order is execution order (ground truth), and lsp.lua's +after-edit callback synchronously flushes didChange on the signature +trigger path — the closer must already be in the buffer when that +callback runs, or the server receives `(`-only text and the closer +stays unsynchronized until the next edit (hook edits don't re-fire). +So pair.lua's loader entry in `src/editor.rs` goes **before** +lsp.lua's (`:288`), with a comment naming this ordering contract; +all `pmacs.lsp.*` lookups inside the callback are lazy and +nil-guarded (lsp.lua defines them later in the load sequence). +Acceptance asserts the ordering by its observable: the **first** +didChange after an ordinary `(` carries `()`; when the closer is +position-transformed without switching context, that first didChange +carries the complete effective post-reaction text rather than an +opener-only intermediate. + +Scope: if the reaction intercept itself switches active window or +buffer, later callbacks observe that new context. The pair callback's +cursor guard cannot repair hook-wide context, so the first didChange +guarantee does not extend to that legal-but-pathological case; A may +remain pending while lsp.lua observes B. This is recorded under +Deferred as origin-pinned after-edit fan-out and is no longer hidden +behind a cursor-only acceptance claim. + +### Q#AP8 — Interactions, verified + +- **Signature help**: the opener still classifies as self-insert on + both routes; the closer insert moves no cursor, so `char_before` + still reads `(`; the synchronous flush ships `()` (Q#AP7). Pinned + with the `sighelp` fake-LSP mode. Same context-switch exception as + Q#AP7. +- **Completion popup**: `(` never belonged to the popup's key set; + `completion_popup_validate` runs after the hook fan-out + (`src/editor.rs:778-783`) and judges the post-pair buffer when + context is preserved. The Q#AP7 context-switch exception applies to + completion as well. +- **Kill ring / boundaries**: the closer is a plain Lua mutator — + stamps nothing, rotates nothing. +- **No recursion**: hook edits don't re-fire after-edit; the + autosave-driven manual `pmacs.hook.run("buffer.after-edit")` + (`builtin/runtime/autosave.lua:233`) is inert because Q#AP9 exposes + no typed-edit record outside the Rust-owned hook boundary, even if + `this_command` is stale. +- **Auto-indent**: RET inside `{|}` yields `{\n␣␣|}`; the electric + closer-on-own-line split stays deferred with language-aware + indent. + +### Q#AP9 — Exact typed-edit provenance, ephemeral, one-shot, fail-closed + +`this_command` remains the coarse input-origin signal used by existing +consumers, but pairing additionally requires a new +`pmacs.editor.take_typed_edit()` record. The record is per frontend, +is armed only while Rust is running the one `buffer.after-edit` +fan-out for that input, and can be consumed exactly once: + +```text +{ buffer, window_id, codepoint, requested_start, requested_end, + effective_start, effective_end, inserted_len, post_cursor, clean } +``` + +The dispatch path arms the record from the self-insert codepoint and +the requested Insert/Replace, then completes it from the effective +`Edit` returned by `insert_char_over_region`. The optimistic CRDT arm +already has both the decoded single codepoint and effective `Edit`, so +it builds the same record before firing the hook. A small EditorCore +outcome/report refactor is required because `apply_active_edit` +currently discards the effective range on its way back to +`insert_char`; payload immutability means the codepoint itself remains +authoritative. + +The pair callback takes the record; later callbacks and a nested +manual re-run of `buffer.after-edit` see nil. The dispatcher/daemon +also clears any untaken slot immediately after the hook returns, +including error paths and the no-revision-change path. Plain +`pmacs.hook.run`, paste, programmatic mutation, and a stale +`this_command == "buffer.self-insert"` therefore see nil. Pairing +requires `clean == true`, matching buffer/window, and +`cursor == post_cursor`; otherwise it reports and does nothing. This +is deliberately narrower than teaching every command to expose its +edit: one producer class, one consumer contract, and no persistent +history. + +## Bets + +1. **Nine round-tripped chars are imperceptible.** The TUI already + round-trips every mid-line char; the GPU pays one local-socket hop + on pair chars. The concrete return is real dispatch type-over, no + optimistic duplicate paint during skip, and adjacent daemon-local + pair units — not a false promise of global chronological undo. +2. **The conservative predicate kills the hate-mail cases** — no + pairing before words, no apostrophe pairing in the default set. +3. **Uniform routing beats language-aware classifiers** — `'` + round-trips in Rust for nothing, and nobody notices. +4. **`default`-set pairing in language-less buffers is wanted**, not + surprising. + +## Deferred (named) + +- Wrap-region on opener with active selection (command carrier + the + TUI selection-gate fix). +- Pair-aware backspace (delete both of a fresh empty pair). +- RET inside a pair → closer on its own line (with language-aware / + electric indent). +- In-string/in-comment inhibit — needs a node-at-byte `pmacs.parse` + binding and freshness guarantees. +- Undo amalgamation (pair = one undo step); cross-peer undo grouping + and chronological arbitration (would un-degrade custom optimistic + pair chars and mixed source/daemon history generally). +- Origin-pinned `buffer.after-edit` context. Today a legal intercept + that switches window/buffer changes what all later callbacks see; + pair.lua can guard its own cursor repair but cannot keep LSP and + completion on the producing context without a hook-wide substrate. +- Balance-aware quote handling (odd/even counting). +- A per-buffer toggle (config-registry-blocked). + +## Acceptance + +`tests/auto_pair_acceptance.rs` (dispatch-driven): + +- `(` at EOL → `()`, cursor between; mid-line before whitespace and + before `)`; before a word char → no pair. +- Skip: `(|)` + `)` → `()`, cursor after; nested; `"` at `"|"` exits. +- Quotes pair under the predicate. +- Per-language: `'` pairs in `.py`, not in `.rs`; scratch pairs the + default set. +- Non-typed provenance, with the callback actually exercised: + production `FrontendEvent::Paste("(")` after a prior self-insert + leaves a lone pasted opener; `buf:insert("(")` followed by explicit + `pmacs.hook.run("buffer.after-edit")` also leaves it lone even when + `this_command` was deliberately left as `buffer.self-insert`; + `pmacs.command.invoke`d self-insert plus the same explicit hook run + also has no record and no reaction. +- Type-over: region + `(` (dispatch route) → region consumed, then + the predicate decides; selection cleared. +- Daemon undo grain pinned in the non-replica harness: `(` then undo → + `(` alone; undo → empty; redo → `(`; redo → `()`; skip undo restores + the duplicate. +- Intercepts: rejected closer → opener stands, status; **relocated + closer** → landed at the intercept's position, reported, cursor + translated not teleported; rejected skip-delete → duplicate stays; + **expanded/relocated skip-delete** → landed, reported, + translate-and-clamp. The **source self-insert** gets separate cases: + relocated opener and expanded/relocated type-over produce exactly + the intercept's positional result, Q#AP9 reports/skips, and no + unrelated closer is inserted; a source context switch likewise + fails closed. +- Context-switching **reaction** intercept → pair cursor repair + skipped, new context's text/cursor untouched by pair.lua. A probe + callback registered after pair.lua observes the switched context, + explicitly pinning (not concealing) the origin-context deferral; do + not combine this case with the Q#AP7 first-didChange guarantee. +- Signature help: fake-LSP `sighelp` mode — auto-trigger still fires + with pairing active, and the **first didChange after `(` contains + `()`** when the reaction preserves context (the Q#AP7 ordering + observable). A preserving-context relocated closer instead asserts + that the first didChange contains the complete effective text. +- Hook fan-out: one fire per keystroke; the closer edit does not + re-fire. +- Typed-edit lifecycle: `take_typed_edit()` yields the exact + codepoint/effective triple once during both dispatch and optimistic + self-insert hooks; a second take (including a nested manual + after-edit run) is nil. It is also nil before/after the fan-out, for + paste, for standalone manual hook runs, and after a rejecting edit. + Two frontends cannot see or consume each other's slot. + +Classifier flips (in-crate): GPU `optimistic_insert_text` returns +`None` for the nine pair chars (test updated alongside Enter's); +TUI `classify_key`/orchestrator equivalents round-trip them (test +updated). Exercise both `Modifiers::NONE` and the `SHIFT` shapes real +keyboards use for `(){}"` so the test cannot pass only for synthetic +unshifted punctuation. + +CRDT (`--features crdt`), **two replicas** (source + observer): + +- Dispatch route: round-tripped `(` → both replicas converge to + `()`; daemon cursor between; `)` skip converges; **undo/redo**: + two daemon undos restore empty on both replicas, then two daemon + redos restore `(` and `()` in order on both. +- Mixed history, dispatch route: source optimistically inserts `a`, + then round-tripped `(` produces `a()`. On the TUI routing model the + single-key optimistic undo removes `a` first (leaving `()`). From a + fresh identical state, an always-dispatched `C-x u` removes the + closer first (leaving `a(`). On the GPU routing model two daemon + undos remove the pair but a further daemon undo cannot reach + source-peer `a`. These are assertions of the named substrate limit, + not frontend-equivalence claims. +- Optimistic route (custom pair char added to the set): source ships + the opener op; the hook's DaemonKey closer is queued **before** + the opener's broadcast — the observer receives the causally + dependent closer first and must still converge (pairing and skip + both). **Undo**: pinned degraded behavior — the source mirror's + undo removes the opener, leaving the closer. From 223e26420baedc228cc276a54e9f8f55200e1ceb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 11 Jul 2026 17:11:56 +0100 Subject: [PATCH 2/6] feat(edit): auto-pairing (Arc 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing an opener inserts the closer with the cursor between; typing a closer over its twin steps over it. Q#AP1: the nine built-in pair chars leave both optimistic classifiers (shared charset in pmacs-protocol) and round-trip through dispatch, so the opener and the hook's closer are adjacent daemon-peer undo units, dispatch CUA type-over applies, and skip never paints a transient duplicate. Q#AP9: exact one-shot typed-edit provenance. EditorCore's apply_active_edit now returns the effective Edit; the dispatch fallback arms a per-frontend record (codepoint + requested vs effective ranges + post-cursor + clean verdict) that insert primitives complete and the daemon's optimistic CRDT arm builds directly. The record is takeable exactly once via pmacs.editor.take_typed_edit() during the one after-edit fan-out, then cleared — paste, programmatic edits, manual hook runs, nested re-runs, rejected edits, and stale this_command all observe nil, and transformed / relocated / context-switched source self-inserts fail closed with a status. pair.lua (loaded BEFORE lsp.lua — ordering contract in editor.rs): per-language pmacs.pair.sets with a conservative default (no ' or `), EOL/whitespace/closer insertion predicate, reactive skip-over-close, rejected/transformed intercept outcomes with context-guarded translate-and-clamp cursor repair. Acceptance: 32 dispatch-driven cases (predicate, skip, per-language sets, non-typed provenance incl. production-shaped paste, type-over, undo/redo grain, intercept outcomes on both the source and reaction edits, context-switch probe, record lifecycle, frontend isolation) + first-didChange ordering against the fake LSP's sighelp mode via a new PMACS_FAKE_LSP_CHANGE_SINK replay file. Six two-replica CRDT cases pin dispatch-route convergence with cursor-between, undo/redo walking the pair on both replicas, both mixed-history undo models as named substrate limits, and the optimistic custom-char route (closer-broadcast-before-opener convergence, degraded cross-peer undo). TestDaemon gains spawn_with_config for init.lua-extended pair sets. Framing: docs/auto-pairing-framing.md (revision 3). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- builtin/runtime/pair.lua | 251 +++++++ pmacs-gpu/src/main.rs | 36 +- pmacs-protocol/src/lib.rs | 13 +- pmacs-protocol/src/message.rs | 16 + src/bin/pmacs_fake_lsp.rs | 53 +- src/daemon.rs | 67 +- src/editor.rs | 38 ++ src/editor_core.rs | 229 ++++++- src/lua_bindings/mod.rs | 38 ++ src/optimistic.rs | 33 +- tests/auto_pair_acceptance.rs | 1019 ++++++++++++++++++++++++++++ tests/auto_pair_crdt_acceptance.rs | 470 +++++++++++++ tests/common/daemon.rs | 21 + 13 files changed, 2242 insertions(+), 42 deletions(-) create mode 100644 builtin/runtime/pair.lua create mode 100644 tests/auto_pair_acceptance.rs create mode 100644 tests/auto_pair_crdt_acceptance.rs diff --git a/builtin/runtime/pair.lua b/builtin/runtime/pair.lua new file mode 100644 index 0000000..03856d9 --- /dev/null +++ b/builtin/runtime/pair.lua @@ -0,0 +1,251 @@ +-- pair.lua --- auto-pairing (Arc 2). +-- +-- Typing `(` gives `()` with the cursor between; typing `)` when the +-- next char is already `)` steps over it instead of doubling it. The +-- carrier is a `buffer.after-edit` reaction (Q#AP1): the opener stays +-- a genuine single-codepoint self-insert — the classification +-- signature help depends on — and this hook inserts (or swallows) the +-- closer as a second edit. Provenance is the exact one-shot typed-edit +-- record (`pmacs.editor.take_typed_edit()`, Q#AP9), not buffer-text +-- inference: pastes, programmatic edits, manual hook runs, and a stale +-- `this_command` have no record and never pair, and a transformed, +-- relocated, or context-switching source self-insert fails closed. +-- +-- This chunk loads BEFORE lsp.lua (Q#AP7): registration order is hook +-- execution order, and lsp.lua's after-edit callback synchronously +-- flushes didChange on the signature-trigger path — the closer must +-- already be in the buffer when that callback runs. Everything under +-- `pmacs.lsp` is therefore looked up lazily at callback time. +-- +-- Framing: docs/auto-pairing-framing.md. + +pmacs.pair = pmacs.pair or {} + +local ed = pmacs.editor + +-- Language → array of pair strings (opener codepoint followed by +-- closer codepoint), plus the `default` entry used when the language +-- is unknown or has no entry — pairing is useful in scratch buffers +-- (Q#AP2). Public and user-extensible, like `pmacs.comment.strings`: +-- pmacs.pair.sets.rust = { "()", "[]", "{}", '""', "''" } +-- Conservative defaults: no `'` (prose apostrophes, Rust lifetimes, +-- char literals), no backtick, outside the languages that want them. +-- NOTE (Q#AP1): only the nine built-in chars `()[]{}"'` and backtick +-- are excluded from the frontends' optimistic classifiers. A +-- user-added pair char beyond those still pairs, but arrives +-- optimistically: its opener is a source-peer op and the closer a +-- daemon-peer op, so its undo is cross-peer-degraded (documented +-- limitation; the general fix is chronological cross-peer undo +-- arbitration, named substrate work). +pmacs.pair.sets = { + default = { "()", "[]", "{}", '""' }, + python = { "()", "[]", "{}", '""', "''" }, + lua = { "()", "[]", "{}", '""', "''" }, + javascript = { "()", "[]", "{}", '""', "''", "``" }, + typescript = { "()", "[]", "{}", '""', "''", "``" }, + javascriptreact = { "()", "[]", "{}", '""', "''", "``" }, + typescriptreact = { "()", "[]", "{}", '""', "''", "``" }, + markdown = { "()", "[]", "{}", '""', "``" }, + sh = { "()", "[]", "{}", '""', "''" }, + bash = { "()", "[]", "{}", '""', "''" }, +} + +-- The first full UTF-8 codepoint starting at byte `pos`, as a string, +-- or nil at end-of-buffer / on a non-boundary byte. Forward twin of +-- lsp.lua's `char_before`; reads at most 4 bytes. +local function char_at(buf, pos) + local len = buf:len() + if pos >= len then return nil end + local to = math.min(pos + 4, len) + local ok, s = pcall(function() return buf:slice(pos, to) end) + if not ok or type(s) ~= "string" or #s == 0 then return nil end + local b = s:byte(1) + local n + if b < 0x80 then + n = 1 + elseif b < 0xC0 then + return nil -- continuation byte: pos is not a codepoint boundary + elseif b < 0xE0 then + n = 2 + elseif b < 0xF0 then + n = 3 + else + n = 4 + end + if n > #s then return nil end + return s:sub(1, n) +end + +-- Split a pair entry into (opener, closer): the first codepoint and +-- the rest. nil for entries that aren't two-or-more bytes of +-- opener-then-closer (malformed user additions are skipped, not +-- errors — the hook must never throw over a config typo). +local function split_pair(s) + if type(s) ~= "string" or #s < 2 then return nil end + local b = s:byte(1) + local n + if b < 0x80 then + n = 1 + elseif b < 0xC0 then + return nil + elseif b < 0xE0 then + n = 2 + elseif b < 0xF0 then + n = 3 + else + n = 4 + end + if n >= #s then return nil end + return s:sub(1, n), s:sub(n + 1) +end + +-- The active buffer's pair set: language entry if the language is +-- known and configured, else `default`. `pmacs.lsp` is looked up +-- lazily and nil-guarded — this chunk loads before lsp.lua (Q#AP7), +-- and language detection is an LSP-runtime service. +local function active_set() + local lang + if pmacs.lsp and pmacs.lsp.active_buffer_language then + local ok, l = pcall(pmacs.lsp.active_buffer_language) + if ok then lang = l end + end + return (lang and pmacs.pair.sets[lang]) or pmacs.pair.sets.default +end + +-- opener → closer, and the set of closer codepoints. +local function maps_for(set) + local openers, closers = {}, {} + for _, entry in ipairs(set) do + local o, c = split_pair(entry) + if o then + openers[o] = c + closers[c] = true + end + end + return openers, closers +end + +-- Conservative insertion predicate (Q#AP3): pair only before +-- end-of-buffer, end-of-line, whitespace, or a closing char from the +-- active set — `foo|bar` + `(` gives `(bar`, never `()bar`. +local function should_pair(buf, cursor, closers) + local nxt = char_at(buf, cursor) + if nxt == nil then return true end + if nxt == "\n" or nxt == "\r" or nxt == " " or nxt == "\t" then return true end + return closers[nxt] == true +end + +-- Right-gravity translation of `pos` through the effective edit — +-- indent.lua's repair shape (Q#AP3/Q#AP4 transformed outcomes). +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 + +-- Context-guarded cursor repair after a TRANSFORMED reaction edit: +-- the intercept's positional result stands (kind and payload are +-- immutable; the edit has already landed), so translate the pre-edit +-- cursor through the effective edit and clamp via goto_byte — unless +-- the intercept switched window or buffer, in which case the new +-- context is not ours to touch. The clean path deliberately performs +-- NO cursor motion: a clean at-cursor closer insert must leave the +-- cursor *before* the closer, which translation would not. +local function repair_cursor(win0, buf0, cursor0, estart, estop, einserted) + if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf0 then + return + end + ed.goto_byte(translate(cursor0, estart, estop, einserted)) +end + +pmacs.hook.add("buffer.after-edit", function() + -- One-shot provenance (Q#AP9). Absence — paste, programmatic edit, + -- manual hook run, rejected insert, stale `this_command` — is a + -- silent non-event; only a live record that then fails a gate + -- reports. + local rec = ed.take_typed_edit and ed.take_typed_edit() + -- Test seam (leading underscore = not stable API, like + -- `pmacs.window._overlay_kinds`): the record this fan-out yielded, + -- or nil. This callback registers first and consumes the one-shot + -- record, so acceptance tests observe the exact codepoint / + -- effective triple here — and prove one-shot-ness by taking again. + pmacs.pair._last_record = rec + if not rec then return end + if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end + + local buf = pmacs.window.buffer() + if not buf then return end + + -- Fail closed on a transformed source self-insert (Q#AP3): the + -- intercept's positional result stands as produced; pairing on top + -- of a relocated or expanded opener would compound it. + if not rec.clean then + ed.set_status("auto-pair skipped: source self-insert transformed") + return + end + -- Fail closed when the source edit's context is no longer current: + -- an intercept switched window/buffer, or something moved the + -- cursor off the post-insert position. + if buf ~= rec.buffer + or pmacs.window.current() ~= rec.window + or ed.cursor() ~= rec.post_cursor then + ed.set_status("auto-pair skipped: source context changed") + return + end + -- Region guard (Q#AP3/Q#AP6): on the dispatch route type-over has + -- already consumed and cleared the region. A region surviving the + -- edit means the TUI's selection-blind optimistic gate let a custom + -- pair char through (named deferral) — reacting would pile a closer + -- onto an unconsumed region. + if ed.region() ~= nil then return end + + local ch = rec.char + local cursor = rec.post_cursor + local openers, closers = maps_for(active_set()) + + -- Skip-over-close (Q#AP4), checked before insertion so symmetric + -- pairs (quotes) step over their own closer: typing `)` at `(|)` + -- swallows the freshly typed duplicate, net `()` with the cursor + -- after — exactly Emacs's skip. The pair chars round-trip (Q#AP1), + -- so no frontend ever painted the transient duplicate. + if closers[ch] then + local dup_ok, dup = pcall(function() return buf:slice(cursor, cursor + #ch) end) + if dup_ok and dup == ch then + local win0 = pmacs.window.current() + local ok, estart, estop, einserted = pcall(function() + return buf:delete(cursor, cursor + #ch) + end) + if not ok then + -- The duplicate stays (e.g. `())`); report, no retry. + ed.set_status("auto-pair skip rejected by buffer intercept") + return + end + if estart ~= cursor or estop ~= cursor + #ch or einserted ~= 0 then + ed.set_status("auto-pair skip altered by buffer intercept") + repair_cursor(win0, buf, cursor, estart, estop, einserted) + end + return + end + end + + local closer = openers[ch] + if not closer then return end + if not should_pair(buf, cursor, closers) then return end + + local win0 = pmacs.window.current() + local ok, estart, estop, einserted = pcall(function() + return buf:insert(cursor, closer) + end) + if not ok then + -- Nothing landed; the opener stands alone. + ed.set_status("auto-pair closer rejected by buffer intercept") + return + end + if estart ~= cursor or estop ~= cursor or einserted ~= #closer then + ed.set_status("auto-pair closer altered by buffer intercept") + repair_cursor(win0, buf, cursor, estart, estop, einserted) + end + -- Clean path: no cursor motion — the insert landed at the cursor + -- and Lua mutators move no cursors, so it already sits between the + -- pair; the daemon's per-tick CursorByte re-grounds both frontends. +end) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 8be9030..d4b1956 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -39,6 +39,7 @@ use pmacs_protocol::{ InstanceSignal, Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StyleSegment, StyleSpan, cell::{Color as CellColor, Style as CellStyle}, + is_builtin_pair_char, }; use wgpu::MultisampleState; use winit::application::ApplicationHandler; @@ -1527,7 +1528,16 @@ fn optimistic_insert_text(key: ProtocolKey, mods: Modifiers, chbuf: &mut [u8; 4] return None; } match key { - ProtocolKey::Char(ch) if !ch.is_control() => Some(ch.encode_utf8(chbuf)), + // Auto-pairing Q#AP1: the built-in pair charset always + // round-trips so the typed opener and the pairing hook's + // closer land as adjacent daemon-peer undo units, and + // dispatch-path CUA type-over / skip-over-close apply. An + // optimistic pair char would put the opener on this + // frontend's peer with the closer on the daemon's — uncleanly + // undoable from either side. + ProtocolKey::Char(ch) if !ch.is_control() && !is_builtin_pair_char(ch) => { + Some(ch.encode_utf8(chbuf)) + } ProtocolKey::Tab if mods.is_empty() => Some("\t"), _ => None, } @@ -7112,6 +7122,30 @@ mod tests { ); } + #[test] + fn optimistic_insert_text_round_trips_builtin_pair_chars() { + // Auto-pairing Q#AP1: the nine built-in pair chars must reach + // daemon dispatch so the opener and the pairing hook's closer + // are adjacent daemon-peer undo units. Both modifier shapes + // real keyboards produce are pinned: `[`/`]`/`'`/`` ` `` + // arrive unshifted, `(`/`)`/`{`/`}`/`"` arrive with SHIFT — a + // gate that only caught `Modifiers::NONE` would leak every + // shifted pair char back onto the optimistic path. + let mut buf = [0u8; 4]; + for c in pmacs_protocol::BUILTIN_PAIR_CHARS { + assert_eq!( + optimistic_insert_text(ProtocolKey::Char(c), Modifiers::NONE, &mut buf), + None, + "unshifted {c:?} must round-trip" + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Char(c), Modifiers::SHIFT, &mut buf), + None, + "shifted {c:?} must round-trip" + ); + } + } + /// Q#R1 parity invariant: the per-line surgery's chunk source /// (`clipped_chunks_for_range` over one line's content range) /// must agree byte-for-byte — text AND color — with the full diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index bcb1000..01eb91c 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -46,12 +46,13 @@ pub use cell::{ pub use crdt::CrdtOp; pub use ids::{BufferId, ByteRange, FrontendId, Position}; pub use message::{ - AdornmentContent, AdornmentPlacement, AttachRequest, BlockAdornment, CompletionPopupRow, - CursorState, Decoration, DecorationKind, DecorationSegment, FrontendCapabilities, - FrontendEvent, GoodbyeReason, Hello, InlineAdornment, InstanceCapabilities, InstanceIdentity, - InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MenuPromptRow, Modifiers, - MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, - ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, + AdornmentContent, AdornmentPlacement, AttachRequest, BUILTIN_PAIR_CHARS, BlockAdornment, + CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment, + FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment, + InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent, + LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, + NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody, + SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, is_builtin_pair_char, is_supported_protocol_version, negotiate_capabilities, }; pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 29499ca..57e8d4f 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -89,6 +89,22 @@ pub enum Key { Unknown(u32), } +/// The nine built-in auto-pair characters (docs/auto-pairing-framing.md +/// Q#AP1). Both frontends' optimistic classifiers exclude these so they +/// always round-trip through daemon dispatch: the typed opener and the +/// pairing hook's closer then land as adjacent daemon-peer undo units, +/// dispatch-path CUA type-over applies, and skip-over-close never +/// paints a transient duplicate. Shared here — not duplicated per +/// frontend — because a frontend that drifts from this set silently +/// re-degrades pair undo to the cross-peer mixed-history case. +pub const BUILTIN_PAIR_CHARS: [char; 9] = ['(', ')', '[', ']', '{', '}', '"', '\'', '`']; + +/// True when `c` is one of [`BUILTIN_PAIR_CHARS`]. +#[must_use] +pub fn is_builtin_pair_char(c: char) -> bool { + BUILTIN_PAIR_CHARS.contains(&c) +} + /// Modifier-key set. Bit-flag encoding for compact wire shape. /// /// `META` corresponds to the "logo" / "super" key on most keyboards. diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index d5b916b..8c11df8 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -49,6 +49,11 @@ //! advertises `signatureHelpProvider` with `(` / `,` triggers, so a //! test can drive the Arc 1d auto-trigger. Every other mode omits the //! capability and therefore never auto-triggers. +//! * If `PMACS_FAKE_LSP_CHANGE_SINK` names a file (any mode): appends +//! one `{"method", "text"}` JSON line per received didOpen / +//! didChange, so a test can replay the exact document-sync sequence +//! the server saw — the auto-pairing Q#AP7 ordering observable +//! ("the first didChange after `(` carries `()`"). use std::collections::HashMap; use std::io::{self, Read, Write}; @@ -415,22 +420,38 @@ fn main() { .and_then(|t| t.get("uri")) .cloned() .unwrap_or(serde_json::Value::Null); - if let Some(uri_s) = uri.as_str() { - let text = if method == "textDocument/didOpen" { - params - .get("textDocument") - .and_then(|t| t.get("text")) - .and_then(serde_json::Value::as_str) - } else { - params - .get("contentChanges") - .and_then(serde_json::Value::as_array) - .and_then(|a| a.first()) - .and_then(|c| c.get("text")) - .and_then(serde_json::Value::as_str) - }; - if let Some(text) = text { - open_docs.insert(uri_s.to_owned(), text.to_owned()); + let text = if method == "textDocument/didOpen" { + params + .get("textDocument") + .and_then(|t| t.get("text")) + .and_then(serde_json::Value::as_str) + } else { + params + .get("contentChanges") + .and_then(serde_json::Value::as_array) + .and_then(|a| a.first()) + .and_then(|c| c.get("text")) + .and_then(serde_json::Value::as_str) + }; + if let (Some(uri_s), Some(text)) = (uri.as_str(), text) { + open_docs.insert(uri_s.to_owned(), text.to_owned()); + } + // Auto-pairing Q#AP7: the ordering observable is "the + // FIRST didChange after `(` carries `()`" — provable + // only from what the server actually received, in + // order. Mirror of `PMACS_FAKE_LSP_ROOT_SINK`: append + // one JSON line per didOpen/didChange to the sink + // file so a test can replay the exact sequence. + if let (Ok(sink), Some(text)) = (std::env::var("PMACS_FAKE_LSP_CHANGE_SINK"), text) + { + use std::io::Write as _; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&sink) + { + let line = serde_json::json!({ "method": method, "text": text }); + let _ = writeln!(f, "{line}"); } } let echo = serde_json::json!({ diff --git a/src/daemon.rs b/src/daemon.rs index dea3493..cc40cf9 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2000,6 +2000,25 @@ fn is_single_codepoint_insert(edit: &crate::rope::Edit) -> bool { expected == len } +/// The exact codepoint a single-codepoint insert landed (auto-pairing +/// Q#AP9). Preconditions are [`is_single_codepoint_insert`]'s; the +/// inserted bytes live in the post-edit rope at `range.start`. `None` +/// on malformed UTF-8 (a classification the byte-length check above +/// already rejects, kept fail-closed rather than panicking). +#[cfg(feature = "crdt")] +fn decoded_single_codepoint(edit: &crate::rope::Edit) -> Option { + let len = usize::try_from(edit.inserted_len) + .ok() + .filter(|l| *l <= 4)?; + let mut buf = [0u8; 4]; + edit.new_rope.slice( + edit.range.start, + edit.range.start + edit.inserted_len, + &mut buf[..len], + ); + std::str::from_utf8(&buf[..len]).ok()?.chars().next() +} + /// T M10.10 (post-audit) — apply a *pre-validated* /// `FrontendEvent::CrdtOp`. Identity, capability, and scope checks /// happen upstream in `validate_remote_crdt_op`; this function trusts @@ -2082,9 +2101,18 @@ fn handle_remote_crdt_op( // must NOT classify as typing (review round 4 — it would // spuriously auto-trigger signature help). Exact provenance on // the wire op is the named deferred general fix. - if edit.range.start == edit.range.end && is_single_codepoint_insert(edit) { - core.rotate_command(source, "buffer.self-insert"); - } + let typed_codepoint = + if edit.range.start == edit.range.end && is_single_codepoint_insert(edit) { + core.rotate_command(source, "buffer.self-insert"); + // Auto-pairing Q#AP9: the optimistic arm is the second + // typed self-insert producer. The decoded codepoint plus + // this Edit build the same exact provenance record the + // dispatch fallback arms — remote CRDT imports run no + // intercepts, so requested == effective and clean == true. + decoded_single_codepoint(edit) + } else { + None + }; // Transient status messages clear on user input. The Key path // gets this from `dispatch_key`'s entry clear; the optimistic // path routes plain typing here instead, and since v15 ships @@ -2144,6 +2172,36 @@ fn handle_remote_crdt_op( } core.notify_buffer_edit(buffer_id, edit); + // Auto-pairing Q#AP9: arm the typed-edit record for the one + // after-edit fan-out below — but only when the source's + // active window actually displays the edited buffer, so + // `post_cursor` (set to the optimistic post-edit position in + // the window loop above) is that window's real cursor. A + // synthetic replica editing a background buffer gets no + // record: absence fails closed, silently. + if let Some(ch) = typed_codepoint + && let Some(wid) = source_active_window_id + && core + .windows + .get(&wid) + .is_some_and(|w| w.buffer_id == buffer_id) + { + core.typed_edit_set_armed( + source, + crate::editor_core::TypedEditRecord { + buffer: buffer_id, + window: wid, + codepoint: ch, + requested_start: edit.range.start, + requested_end: edit.range.end, + effective_start: edit.range.start, + effective_end: edit.range.end, + inserted_len: edit.inserted_len, + post_cursor: post_edit_cursor, + clean: true, + }, + ); + } // T M11.9 — temporarily switch active_frontend to source so // the `buffer.after-edit` hook's Lua observers (notably the // LSP `did_change` glue in `builtin/runtime/lsp.lua`) read @@ -2167,6 +2225,9 @@ fn handle_remote_crdt_op( editor .lua_host .run_hook("buffer.after-edit", mlua::MultiValue::new()); + // Q#AP9: drop any untaken record the moment the fan-out + // returns — the slot must never leak into a later hook run. + editor.core.borrow_mut().typed_edit_clear_armed(); } // Effect 4: queue for broadcast. The source frontend's mirror diff --git a/src/editor.rs b/src/editor.rs index 0e07d4e..40dde61 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -283,6 +283,21 @@ impl EditorState { include_str!("../builtin/runtime/listview.lua"), ) .expect("load listview builtin chunk"); + // Auto-pairing (Arc 2, Q#AP7) — ORDERING CONTRACT: pair.lua + // must load BEFORE lsp.lua. Hook callbacks run in registration + // order, and lsp.lua's `buffer.after-edit` callback flushes + // didChange synchronously on the signature-trigger path — the + // pairing closer must already be in the buffer when that + // callback runs, or the server receives opener-only text and + // the closer stays unsynchronized until the next edit (hook + // edits don't re-fire the hook). pair.lua's `pmacs.lsp.*` + // lookups are lazy and nil-guarded for the same reason. + lua_host + .eval( + Some("@pmacs/builtin/runtime/pair.lua"), + include_str!("../builtin/runtime/pair.lua"), + ) + .expect("load pair builtin chunk"); lua_host .eval( Some("@pmacs/builtin/runtime/lsp.lua"), @@ -753,6 +768,14 @@ impl EditorState { self.core .borrow_mut() .rotate_command(frontend_id, "buffer.self-insert"); + // Auto-pairing Q#AP9: this dispatch is the typed + // self-insert producer — arm the exact typed-edit + // record so the after-edit fan-out below can + // expose it. The insert primitive completes the + // record with the effective (post-intercept) edit; + // `typed_edit_finish` takes it back on every path + // out of this dispatch. + self.core.borrow_mut().typed_edit_arm(frontend_id, ch); let mut args = mlua::MultiValue::new(); args.push_back(mlua::Value::Integer(ch as i64)); if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) { @@ -769,10 +792,25 @@ impl EditorState { } } + // Auto-pairing Q#AP9: take back the typed-edit arm on every + // path out of this dispatch — command error, rejected insert, + // and the no-revision-change case all land here with either a + // completed record or nothing. The record is armed for Lua + // only across the one after-edit fan-out below and cleared + // the moment it returns, so paste, later dispatches, and + // manual hook runs can never observe a stale record. + let typed_edit = self.core.borrow_mut().typed_edit_finish(frontend_id); + let post_revision = self.active_buffer_revision(); if pre_revision != post_revision { + if let Some(record) = typed_edit { + self.core + .borrow_mut() + .typed_edit_set_armed(frontend_id, record); + } self.lua_host .run_hook("buffer.after-edit", mlua::MultiValue::new()); + self.core.borrow_mut().typed_edit_clear_armed(); } // Q#C3 post-dispatch validation, deliberately AFTER the diff --git a/src/editor_core.rs b/src/editor_core.rs index 8a69c40..da6567c 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -142,6 +142,64 @@ pub struct CommandBoundary { pub last: Option, } +/// Exact provenance of one typed self-insert (auto-pairing Q#AP9). +/// +/// `this_command() == "buffer.self-insert"` proves only the *input +/// class*; it cannot say which character was typed, where the edit +/// actually landed after intercepts, or whether the command that ran +/// under that name performed the insert at all. This record carries +/// the exact facts for the one consumer contract that needs them (the +/// pairing hook): the decoded codepoint, the requested and effective +/// ranges, and the post-edit cursor, plus a `clean` verdict (effective +/// triple equals the request). It is ephemeral — armed by the two +/// self-insert producers (dispatch fallback, optimistic CRDT arm) for +/// exactly one `buffer.after-edit` fan-out, consumable once via +/// `pmacs.editor.take_typed_edit()`, and cleared when the fan-out +/// returns. Paste, programmatic mutation, manual hook runs, and a +/// stale `this_command` therefore observe nil, not a leftover record. +#[derive(Debug, Clone)] +pub struct TypedEditRecord { + /// Buffer the self-insert landed in. + pub buffer: BufferId, + /// Window that was active when the self-insert ran. + pub window: WindowId, + /// The exact typed codepoint (payload immutability makes this + /// authoritative even when an intercept relocated the edit). + pub codepoint: char, + /// Requested edit range: `start == end` for a plain insert; a CUA + /// type-over requests a `Replace` over the consumed region. + pub requested_start: u64, + /// End of the requested range (see `requested_start`). + pub requested_end: u64, + /// Effective (post-intercept) range start in the old rope. + pub effective_start: u64, + /// Effective (post-intercept) range end in the old rope. + pub effective_end: u64, + /// Bytes actually inserted at `effective_start`. + pub inserted_len: u64, + /// The window cursor immediately after the self-insert. + pub post_cursor: u64, + /// True iff the effective triple equals the request. + pub clean: bool, +} + +/// In-flight arm for a [`TypedEditRecord`] (auto-pairing Q#AP9): the +/// dispatch fallback declares "the next matching self-insert edit is +/// the typed one" before invoking `buffer.self-insert`; the insert +/// primitives complete the record when the edit lands. Private — +/// nothing outside the arm/complete/finish trio observes the pending +/// state. +#[derive(Debug)] +struct TypedEditPending { + /// Frontend whose dispatch armed this. + fid: FrontendId, + /// The codepoint the dispatcher decoded from the keystroke; a + /// completing edit must match it exactly. + codepoint: char, + /// Filled by the first matching insert primitive. + record: Option, +} + /// The world state mutated by editor commands. pub struct EditorCore { /// Shared buffer registry. The registry is the canonical owner @@ -270,6 +328,19 @@ pub struct EditorCore { /// query-replace twin of `search`; drives the fifth dispatcher /// shadow. query_replace: Option, + /// In-flight typed-edit arm (auto-pairing Q#AP9): set by the + /// dispatch fallback just before it invokes `buffer.self-insert`, + /// completed by the insert primitives, taken back by the + /// dispatcher via [`Self::typed_edit_finish`] in the same + /// dispatch. Never survives a dispatch cycle. + typed_edit_pending: Option, + /// The armed typed-edit record (auto-pairing Q#AP9), exposed to + /// Lua as `pmacs.editor.take_typed_edit()` for the duration of + /// exactly one `buffer.after-edit` fan-out. Keyed by frontend so + /// two attached frontends can never see or consume each other's + /// slot; the producer clears any untaken record when the fan-out + /// returns. + typed_edit_armed: Option<(FrontendId, TypedEditRecord)>, } impl EditorCore { @@ -313,6 +384,8 @@ impl EditorCore { completion_popup: crate::completion::make_shared_popup(), round_trip_buffers: std::collections::HashSet::new(), query_replace: None, + typed_edit_pending: None, + typed_edit_armed: None, } } @@ -1154,12 +1227,15 @@ impl EditorCore { // ---- editing primitives ------------------------------------------------ /// Apply `op` to the active buffer; notify every window - /// displaying that buffer. Returns the new buffer length. + /// displaying that buffer. Returns the effective [`Edit`] — the + /// post-intercept range and inserted length (auto-pairing Q#AP9 + /// needs the effective triple; every other caller reads + /// `new_rope.len()` or discards it). /// /// # Errors /// /// Returns a stringified error on buffer or view failure. - pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result { + pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result { let buffer_id = self.active_buffer_id(); // Scope the registry borrow: the origin translation below needs // `&mut self` after the views have been notified. @@ -1197,7 +1273,7 @@ impl EditorCore { // headline isearch bet — "stale-after-edit linger" — is // closed here. self.search_invalidate_for_edit(buffer_id, &edit); - Ok(edit.new_rope.len()) + Ok(edit) } /// Q#AI8 search invalidation for a landed edit: mark the buffer's @@ -1741,11 +1817,26 @@ impl EditorCore { let s = ch.encode_utf8(&mut buf); let bytes = s.as_bytes(); let pos = self.active_window().cursor; - if let Err(e) = self.apply_active_edit(EditOp::Insert { pos, bytes }) { - self.status = format!("insert failed: {e}"); - return false; - } + // Q#AP9: the buffer/window the request was made in, captured + // BEFORE the edit — a legal intercept may switch the active + // context mid-edit, and the record must name where the + // self-insert actually landed, not where the intercept went. + let (buffer_id, window_id) = (self.active_buffer_id(), self.active_window_id()); + let edit = match self.apply_active_edit(EditOp::Insert { pos, bytes }) { + Ok(edit) => edit, + Err(e) => { + self.status = format!("insert failed: {e}"); + return false; + } + }; self.active_window_mut().cursor += bytes.len() as u64; + self.typed_edit_complete( + ch, + (buffer_id, window_id), + Range::new(pos, pos), + bytes.len() as u64, + &edit, + ); true } @@ -1770,16 +1861,29 @@ impl EditorCore { self.active_window_mut().goal_col = None; let mut buf = [0u8; 4]; let bytes = ch.encode_utf8(&mut buf).as_bytes(); - if let Err(e) = self.apply_active_edit(EditOp::Replace { + // Q#AP9: capture the request's context before the edit (see + // the twin comment in [`Self::insert_char`]). + let (buffer_id, window_id) = (self.active_buffer_id(), self.active_window_id()); + let edit = match self.apply_active_edit(EditOp::Replace { range: Range { start: lo, end: hi }, bytes, }) { - self.status = format!("replace failed: {e}"); - return; - } + Ok(edit) => edit, + Err(e) => { + self.status = format!("replace failed: {e}"); + return; + } + }; let aw = self.active_window_mut(); aw.cursor = lo + bytes.len() as u64; aw.selection = None; + self.typed_edit_complete( + ch, + (buffer_id, window_id), + Range::new(lo, hi), + bytes.len() as u64, + &edit, + ); } /// Delete the codepoint immediately before the cursor. @@ -2095,9 +2199,12 @@ impl EditorCore { let Some((lo, hi)) = self.active_region() else { return Ok(self.active_buffer_len()); }; - let new_len = self.apply_active_edit(EditOp::Delete { - range: Range { start: lo, end: hi }, - })?; + let new_len = self + .apply_active_edit(EditOp::Delete { + range: Range { start: lo, end: hi }, + })? + .new_rope + .len(); let aw = self.active_window_mut(); aw.cursor = lo; aw.selection = None; @@ -2189,6 +2296,100 @@ impl EditorCore { .as_deref() } + // ---- typed-edit provenance (auto-pairing, Q#AP9) --------------------- + + /// Declare that `fid`'s dispatch is about to invoke + /// `buffer.self-insert` for `codepoint`: the next insert primitive + /// whose character matches completes the [`TypedEditRecord`]. + /// Called by the dispatch fallback only — programmatic + /// `pmacs.command.invoke("buffer.self-insert")` deliberately never + /// arms, so a hook run after it observes no record. + pub fn typed_edit_arm(&mut self, fid: FrontendId, codepoint: char) { + self.typed_edit_pending = Some(TypedEditPending { + fid, + codepoint, + record: None, + }); + } + + /// Complete the pending typed-edit record from the effective edit, + /// if one is armed for this character and hasn't completed yet. + /// First match wins: a command body that somehow self-inserts the + /// same character twice records the first landing (the one the + /// dispatcher's keystroke produced). `context` is the caller's + /// pre-edit `(buffer, window)` — the buffer the edit landed in + /// even when an intercept switched the active context mid-edit. + fn typed_edit_complete( + &mut self, + ch: char, + context: (BufferId, WindowId), + requested: Range, + requested_len: u64, + edit: &Edit, + ) { + let matches = self.typed_edit_pending.as_ref().is_some_and(|p| { + p.record.is_none() && p.codepoint == ch && p.fid == self.active_frontend + }); + if !matches { + return; + } + let clean = edit.range == requested && edit.inserted_len == requested_len; + let record = TypedEditRecord { + buffer: context.0, + window: context.1, + codepoint: ch, + requested_start: requested.start, + requested_end: requested.end, + effective_start: edit.range.start, + effective_end: edit.range.end, + inserted_len: edit.inserted_len, + post_cursor: self.active_window().cursor, + clean, + }; + if let Some(p) = self.typed_edit_pending.as_mut() { + p.record = Some(record); + } + } + + /// Take back the pending arm at the end of `fid`'s dispatch, + /// yielding the completed record (or `None` if the self-insert + /// never landed — rejected edit, command error). Always clears the + /// pending state: an arm never survives its dispatch cycle. + pub fn typed_edit_finish(&mut self, fid: FrontendId) -> Option { + let pending = self.typed_edit_pending.take()?; + if pending.fid != fid { + return None; + } + pending.record + } + + /// Arm `record` for consumption during the `buffer.after-edit` + /// fan-out the caller is about to run. The caller MUST clear the + /// slot when the fan-out returns ([`Self::typed_edit_clear_armed`]), + /// error paths included — the record must never outlive its hook. + pub fn typed_edit_set_armed(&mut self, fid: FrontendId, record: TypedEditRecord) { + self.typed_edit_armed = Some((fid, record)); + } + + /// Drop any untaken armed record. Producers call this immediately + /// after their `buffer.after-edit` fan-out returns. + pub fn typed_edit_clear_armed(&mut self) { + self.typed_edit_armed = None; + } + + /// One-shot consume of the armed typed-edit record, per frontend: + /// yields the record iff one is armed for the *active* frontend, + /// clearing the slot. Second and later takes — including from a + /// nested manual `pmacs.hook.run("buffer.after-edit")` — observe + /// `None`, as does any context where no producer armed a record + /// (paste, programmatic mutation, standalone manual hook runs). + pub fn take_typed_edit(&mut self) -> Option { + if self.typed_edit_armed.as_ref()?.0 != self.active_frontend { + return None; + } + self.typed_edit_armed.take().map(|(_, rec)| rec) + } + /// Copy the active region into the clipboard slot and queue an /// outbound OS-clipboard publish to the originating frontend. /// Returns `false` (a no-op) when there is no region. diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index dcdab4b..a72840f 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -11011,6 +11011,44 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result lua.create_function(move |_, ()| Ok(cc.borrow().this_command().map(str::to_owned)))?, )?; } + { + // take_typed_edit(): auto-pairing Q#AP9 — the one-shot exact + // provenance record of the self-insert that produced the + // current `buffer.after-edit` fan-out, or nil. Where + // `this_command()` names only the input class, this record + // carries the typed codepoint and the requested vs effective + // (post-intercept) edit, so a consumer can fail closed on a + // transformed, relocated, or context-switched source edit. + // Consuming clears the slot: later callbacks and nested manual + // hook runs see nil, and the producer clears any untaken + // record when the fan-out returns. Per-frontend — one + // frontend can never take another's record. `char` is the + // codepoint as a UTF-8 string (LuaJIT has no `utf8` library + // to convert `codepoint` Lua-side). + let cc = core.clone(); + editor.set( + "take_typed_edit", + lua.create_function(move |lua, ()| { + let Some(rec) = cc.borrow_mut().take_typed_edit() else { + return Ok(mlua::Value::Nil); + }; + let cvt = |v: u64| i64::try_from(v).map_err(mlua::Error::external); + let t = lua.create_table()?; + t.set("buffer", BufferIdLua(rec.buffer))?; + t.set("window", cvt(rec.window.raw())?)?; + t.set("codepoint", i64::from(u32::from(rec.codepoint)))?; + t.set("char", rec.codepoint.to_string())?; + t.set("requested_start", cvt(rec.requested_start)?)?; + t.set("requested_end", cvt(rec.requested_end)?)?; + t.set("effective_start", cvt(rec.effective_start)?)?; + t.set("effective_end", cvt(rec.effective_end)?)?; + t.set("inserted_len", cvt(rec.inserted_len)?)?; + t.set("post_cursor", cvt(rec.post_cursor)?)?; + t.set("clean", rec.clean)?; + Ok(mlua::Value::Table(t)) + })?, + )?; + } { // view_top(): the active window's first visible source line. // The saveplace getter (Arc 3) — pairs with set_view_top so a diff --git a/src/optimistic.rs b/src/optimistic.rs index 6b27d84..e8a30f7 100644 --- a/src/optimistic.rs +++ b/src/optimistic.rs @@ -34,7 +34,7 @@ use crate::buffer::BufferId; use crate::buffer_mirror::{BufferMirror, BufferMirrorError}; -use crate::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers}; +use crate::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers, is_builtin_pair_char}; use crate::rope::CrdtOp; use unicode_width::UnicodeWidthChar; @@ -135,7 +135,13 @@ pub fn classify_key(key: Key, mods: Modifiers) -> OptimisticAction { return OptimisticAction::RoundTrip; } match key { - Key::Char(c) if !c.is_control() => OptimisticAction::Insert(c), + // Auto-pairing Q#AP1: the built-in pair charset always + // round-trips so the opener and the pairing hook's closer are + // adjacent daemon-peer undo units (and dispatch-path CUA + // type-over applies). An optimistic pair char would be a + // source-peer op whose reaction closer lives on the daemon + // peer — uncleanly undoable from either frontend. + Key::Char(c) if !c.is_control() && !is_builtin_pair_char(c) => OptimisticAction::Insert(c), Key::Backspace => OptimisticAction::DeleteBack, Key::Delete => OptimisticAction::DeleteForward, _ => OptimisticAction::RoundTrip, @@ -460,6 +466,29 @@ mod tests { } } + #[test] + fn classify_builtin_pair_chars_round_trip() { + // Auto-pairing Q#AP1: the nine built-in pair chars must reach + // the daemon's dispatch so the opener and the hook's closer are + // adjacent daemon-peer undo units. Both modifier shapes real + // keyboards produce are pinned: `[`/`]`/`'`/`` ` `` arrive + // unshifted, `(`/`)`/`{`/`}`/`"` arrive with SHIFT set — a gate + // that only caught `Modifiers::NONE` would leak every shifted + // pair char back onto the optimistic path. + for c in crate::protocol::BUILTIN_PAIR_CHARS { + assert_eq!( + classify_key(Key::Char(c), Modifiers::NONE), + OptimisticAction::RoundTrip, + "unshifted {c:?} must round-trip" + ); + assert_eq!( + classify_key(Key::Char(c), Modifiers::SHIFT), + OptimisticAction::RoundTrip, + "shifted {c:?} must round-trip" + ); + } + } + #[test] fn classify_unicode_char_no_modifiers_is_insert() { // Non-ASCII printable — multi-byte UTF-8. diff --git a/tests/auto_pair_acceptance.rs b/tests/auto_pair_acceptance.rs new file mode 100644 index 0000000..5223947 --- /dev/null +++ b/tests/auto_pair_acceptance.rs @@ -0,0 +1,1019 @@ +//! Auto-pairing acceptance (Arc 2, docs/auto-pairing-framing.md). +//! +//! Dispatch-driven: pair chars round-trip through `dispatch_key` +//! (Q#AP1 removed them from both optimistic classifiers, so this IS +//! the production path for both frontends). Scratch-buffer tests cover +//! the default pair set; per-language tests visit file-backed buffers +//! with an emptied `pmacs.lsp.config` (language DETECTION must work, +//! server SPAWNING must not); the Q#AP7/Q#AP8 ordering tests drive the +//! real fake-LSP `sighelp` mode and replay the exact document-sync +//! sequence the server received via `PMACS_FAKE_LSP_CHANGE_SINK`. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; +use pmacs::protocol::FrontendId; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +fn fresh_state_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-autopair-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn editor(state_dir: &std::path::Path) -> EditorState { + let s = EditorState::new(); + s.lua_host.lua().remove_app_data::(); + s.lua_host + .lua() + .set_app_data(StateDir(state_dir.to_path_buf())); + // Language DETECTION must work (filetypes/grammars); server + // SPAWNING must not (rust/python have default configs). + exec(&s, "pmacs.lsp.config = {}"); + s +} + +fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p.display().to_string() +} + +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 press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, 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), + ); + } +} + +/// `C-x u` — the always-dispatched undo (daemon-peer history). +fn undo(s: &mut EditorState) { + ctrl(s, 'x'); + press(s, KeyCode::Char('u')); +} + +/// `C-x r` — redo. +fn redo(s: &mut EditorState) { + ctrl(s, 'x'); + press(s, KeyCode::Char('r')); +} + +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 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 status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +/// Fresh scratch-buffer editor whose buffer holds `body`, cursor at 0. +/// No state dir / no files: scratch pairing uses the `default` set. +fn editor_with(body: &str) -> EditorState { + let s = EditorState::new(); + if !body.is_empty() { + exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); + } + exec(&s, "pmacs.editor.goto_byte(0)"); + s +} + +/// Fresh editor visiting `name` (created in a private tempdir) with +/// `body` on disk, cursor at 0, `pmacs.lsp.config` emptied. +fn editor_visiting(name: &str, body: &str) -> EditorState { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, name, body); + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + s +} + +// --------------------------------------------------------------------------- +// Insert-pair semantics (Q#AP3): the conservative predicate +// --------------------------------------------------------------------------- + +#[test] +fn opener_at_end_of_buffer_pairs_with_cursor_between() { + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + assert_eq!(cursor(&s), 1, "cursor sits between the pair"); +} + +#[test] +fn shifted_opener_pairs_too() { + // Real keyboards produce `(` as Shift+9: the chord arrives as + // `Char('(')` with SHIFT set and must still self-insert + pair. + let mut s = editor_with(""); + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('('), KeyModifiers::SHIFT), + ); + assert_eq!(buffer_text(&s), "()"); + assert_eq!(cursor(&s), 1); +} + +#[test] +fn opener_at_end_of_line_pairs() { + let mut s = editor_with("x\ny"); + exec(&s, "pmacs.editor.goto_byte(1)"); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "x()\ny"); + assert_eq!(cursor(&s), 2); +} + +#[test] +fn opener_before_whitespace_pairs() { + let mut s = editor_with("foo bar"); + exec(&s, "pmacs.editor.goto_byte(3)"); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "foo() bar"); + assert_eq!(cursor(&s), 4); +} + +#[test] +fn opener_before_closing_bracket_pairs() { + let mut s = editor_with("()"); + exec(&s, "pmacs.editor.goto_byte(1)"); + type_str(&mut s, "["); + assert_eq!(buffer_text(&s), "([])"); + assert_eq!(cursor(&s), 2); +} + +#[test] +fn opener_before_word_char_does_not_pair() { + let mut s = editor_with("bar"); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "(bar", + "`foo|bar` + `(` gives `(bar`, never `()bar`" + ); + assert_eq!(cursor(&s), 1); +} + +// --------------------------------------------------------------------------- +// Skip-over-close (Q#AP4) +// --------------------------------------------------------------------------- + +#[test] +fn closer_skips_over_existing_closer() { + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + type_str(&mut s, ")"); + assert_eq!( + buffer_text(&s), + "()", + "the typed `)` steps over, not doubles" + ); + assert_eq!(cursor(&s), 2, "cursor lands after the closer"); +} + +#[test] +fn nested_closers_skip_outward() { + let mut s = editor_with(""); + type_str(&mut s, "(("); + assert_eq!(buffer_text(&s), "(())"); + assert_eq!(cursor(&s), 2); + type_str(&mut s, "))"); + assert_eq!(buffer_text(&s), "(())", "both closers skip"); + assert_eq!(cursor(&s), 4); +} + +#[test] +fn quote_pairs_then_second_quote_exits() { + let mut s = editor_with(""); + type_str(&mut s, "\""); + assert_eq!(buffer_text(&s), "\"\"", "symmetric pair inserts"); + assert_eq!(cursor(&s), 1); + type_str(&mut s, "\""); + assert_eq!( + buffer_text(&s), + "\"\"", + "second quote skips (exits the string)" + ); + assert_eq!(cursor(&s), 2); +} + +// --------------------------------------------------------------------------- +// Pair sets (Q#AP2): per-language, conservative default +// --------------------------------------------------------------------------- + +#[test] +fn single_quote_pairs_in_python_but_not_rust() { + let mut py = editor_visiting("a.py", ""); + type_str(&mut py, "'"); + assert_eq!(buffer_text(&py), "''", "python's set adds `''`"); + + let mut rs = editor_visiting("a.rs", ""); + type_str(&mut rs, "'"); + assert_eq!( + buffer_text(&rs), + "'", + "rust keeps the default set: `'` is a lifetime, not a pair" + ); +} + +#[test] +fn scratch_buffer_pairs_the_default_set() { + let mut s = editor_with(""); + type_str(&mut s, "{"); + assert_eq!( + buffer_text(&s), + "{}", + "language-less buffers pair the default set" + ); + let mut s2 = editor_with(""); + type_str(&mut s2, "'"); + assert_eq!( + buffer_text(&s2), + "'", + "no apostrophe pairing in the default set" + ); + let mut s3 = editor_with(""); + type_str(&mut s3, "`"); + assert_eq!( + buffer_text(&s3), + "`", + "no backtick pairing in the default set" + ); +} + +// --------------------------------------------------------------------------- +// Non-typed provenance (Q#AP9): no record, no reaction — with the +// after-edit callback actually exercised in every case. +// --------------------------------------------------------------------------- + +#[test] +fn paste_of_opener_does_not_pair() { + let mut s = editor_with(""); + // A prior self-insert, so a heuristic keyed only on buffer text or + // `char_before` would be primed to misfire. + type_str(&mut s, "a"); + // The daemon's unified inbound-paste route, faithfully: break the + // source's command chain, insert, fire the after-edit hook + // (`handle_inbound_paste` + `with_after_edit_check`). + s.core.borrow_mut().break_command_chain(FrontendId::LOCAL); + s.core.borrow_mut().paste_inbound(b"(").unwrap(); + s.lua_host + .run_hook("buffer.after-edit", mlua::MultiValue::new()); + assert_eq!(buffer_text(&s), "a(", "a pasted opener stays lone"); + let record_nil: bool = eval(&s, "return pmacs.pair._last_record == nil"); + assert!(record_nil, "paste must arm no typed-edit record"); +} + +#[test] +fn programmatic_insert_with_stale_this_command_does_not_pair() { + let mut s = editor_with(""); + // Type 'a' so `this_command` is (and stays) "buffer.self-insert" — + // the deliberately stale signal the provenance gate must ignore. + type_str(&mut s, "a"); + let stale: String = eval(&s, "return pmacs.editor.this_command()"); + assert_eq!(stale, "buffer.self-insert"); + exec(&s, "pmacs.window.buffer():insert(1, \"(\")"); + exec(&s, "pmacs.hook.run(\"buffer.after-edit\")"); + assert_eq!( + buffer_text(&s), + "a(", + "programmatic insert + manual hook run must not pair, even with \ + this_command still reading buffer.self-insert" + ); + let record_nil: bool = eval(&s, "return pmacs.pair._last_record == nil"); + assert!(record_nil, "manual hook run must observe no record"); +} + +#[test] +fn command_invoke_self_insert_does_not_pair() { + let s = editor_with(""); + // Plain `pmacs.command.invoke` is the programmatic API: it stamps + // no boundary and arms no record. + exec(&s, "pmacs.command.invoke(\"buffer.self-insert\", 40)"); // '(' + exec(&s, "pmacs.hook.run(\"buffer.after-edit\")"); + assert_eq!(buffer_text(&s), "(", "invoked self-insert stays lone"); + let record_nil: bool = eval(&s, "return pmacs.pair._last_record == nil"); + assert!(record_nil); +} + +// --------------------------------------------------------------------------- +// Type-over composition (Q#AP6, dispatch route) +// --------------------------------------------------------------------------- + +#[test] +fn opener_over_region_type_overs_then_pairs() { + let mut s = editor_with("abc"); + // Shift+Right x3: region [0, 3). + for _ in 0..3 { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT)); + } + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "()", + "the region is consumed by type-over, then the predicate pairs at EOB" + ); + assert_eq!(cursor(&s), 1); + let region_nil: bool = eval(&s, "return pmacs.editor.region() == nil"); + assert!(region_nil, "selection cleared"); +} + +// --------------------------------------------------------------------------- +// Undo grain (Q#AP5, daemon history in the non-replica harness) +// --------------------------------------------------------------------------- + +#[test] +fn pair_is_two_adjacent_undo_steps_and_two_redo_steps() { + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + undo(&mut s); + assert_eq!( + buffer_text(&s), + "(", + "first undo removes the reaction closer" + ); + undo(&mut s); + assert_eq!(buffer_text(&s), "", "second undo removes the typed opener"); + redo(&mut s); + assert_eq!(buffer_text(&s), "(", "first redo restores the opener"); + redo(&mut s); + assert_eq!(buffer_text(&s), "()", "second redo restores the closer"); +} + +#[test] +fn skip_undo_restores_the_swallowed_duplicate() { + let mut s = editor_with(""); + type_str(&mut s, "()"); + assert_eq!(buffer_text(&s), "()"); + undo(&mut s); + assert_eq!( + buffer_text(&s), + "())", + "undoing the skip's delete restores the typed duplicate" + ); +} + +// --------------------------------------------------------------------------- +// Reaction intercept outcomes (Q#AP3/Q#AP4): rejected vs transformed +// --------------------------------------------------------------------------- + +#[test] +fn rejected_closer_leaves_opener_alone_and_reports() { + let mut s = editor_with(""); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" and op.bytes == ")" then + error("rejected by test intercept") + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "(", + "nothing landed; the opener stands alone" + ); + assert_eq!(cursor(&s), 1); + assert!( + status(&s).contains("auto-pair closer rejected"), + "got: {:?}", + status(&s) + ); +} + +#[test] +fn relocated_closer_lands_where_the_intercept_put_it_cursor_translated() { + let mut s = editor_with("ab"); + exec(&s, "pmacs.editor.goto_byte(2)"); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" and op.bytes == ")" then + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + ")ab(", + "the intercept's positional result stands" + ); + assert!( + status(&s).contains("auto-pair closer altered"), + "got: {:?}", + status(&s) + ); + assert_eq!( + cursor(&s), + 4, + "pre-edit cursor 3 right-gravity-translated through the insert at 0 — \ + translated, not teleported to the relocated closer" + ); +} + +#[test] +fn rejected_skip_delete_keeps_the_duplicate() { + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "delete" then error("rejected by test intercept") end + return nil + end) + "#, + ); + type_str(&mut s, ")"); + assert_eq!(buffer_text(&s), "())", "the typed duplicate stays"); + assert_eq!(cursor(&s), 2); + assert!( + status(&s).contains("auto-pair skip rejected"), + "got: {:?}", + status(&s) + ); +} + +#[test] +fn expanded_skip_delete_lands_reported_and_cursor_clamped() { + let mut s = editor_with(""); + type_str(&mut s, "("); + exec(&s, "pmacs.window.buffer():insert(2, \"x\")"); // "()x" + exec(&s, "pmacs.editor.goto_byte(1)"); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "delete" then + return { kind = "delete", start = op.start, ["end"] = op["end"] + 1 } + end + return nil + end) + "#, + ); + type_str(&mut s, ")"); + assert_eq!( + buffer_text(&s), + "()", + "the expanded delete swallowed the duplicate AND the x — it stands" + ); + assert!( + status(&s).contains("auto-pair skip altered"), + "got: {:?}", + status(&s) + ); + assert_eq!(cursor(&s), 2, "translate-and-clamp repair"); +} + +// --------------------------------------------------------------------------- +// Source self-insert intercepts (Q#AP9): the reaction fails closed +// --------------------------------------------------------------------------- + +#[test] +fn relocated_opener_gets_no_pair_reaction() { + let mut s = editor_with("ab"); + exec(&s, "pmacs.editor.goto_byte(2)"); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" and op.bytes == "(" then + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "(ab", + "exactly the intercept's positional result — no closer anywhere" + ); + assert!( + status(&s).contains("auto-pair skipped: source self-insert transformed"), + "got: {:?}", + status(&s) + ); +} + +#[test] +fn transformed_type_over_gets_no_pair_reaction() { + let mut s = editor_with("abcd"); + for _ in 0..2 { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT)); + } + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "replace" then + return { kind = "replace", start = op.start, + ["end"] = op["end"] + 1, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "(d", + "the expanded type-over stands as the intercept produced it" + ); + assert!( + status(&s).contains("auto-pair skipped: source self-insert transformed"), + "got: {:?}", + status(&s) + ); +} + +#[test] +fn source_context_switch_fails_closed() { + // A context-switching INTERCEPT cannot exist on the dispatch + // self-insert path (the core borrow is held across it; the + // three-phase borrow-released discipline is the Lua-mutator + // path's). The legal producer of a context-switched source + // self-insert is a user-redefined `buffer.self-insert` command + // that switches after inserting — the record still completes for + // the exact typed codepoint, and the hook then runs in the new + // context, where pairing must fail closed. + let dir = fresh_state_dir(); + let mut s = editor(&dir); + let other = write_file(&dir, "other.txt", "z"); + exec(&s, "_G.scratch = pmacs.window.buffer()"); + exec(&s, &format!("pmacs.buffer.find_or_open({other:?})")); + exec(&s, "_G.other = pmacs.window.buffer()"); + exec(&s, "pmacs.window.switch_buffer(_G.scratch)"); + // Bump the scratch revision past other.txt's so the active-buffer + // revision compare still fires the hook after the switch (the + // buffer-aware edit epoch is a named substrate deferral). + type_str(&mut s, "ab"); + exec( + &s, + r#" + pmacs.command.unregister("buffer.self-insert") + pmacs.command.define { + name = "buffer.self-insert", + description = "test override: insert, then switch context", + fn = function(cp) + pmacs.editor.insert_char_over_region(cp) + pmacs.window.switch_buffer(_G.other) + end, + } + "#, + ); + type_str(&mut s, "("); + assert!( + status(&s).contains("auto-pair skipped: source context changed"), + "got: {:?}", + status(&s) + ); + let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())"); + assert_eq!( + scratch_text, "ab(", + "the opener landed in scratch, no closer" + ); + let other_text: String = eval(&s, "return _G.other:slice(0, _G.other:len())"); + assert_eq!(other_text, "z", "the switched-to buffer is untouched"); +} + +// --------------------------------------------------------------------------- +// Context-switching REACTION intercept: repair skipped, deferral pinned +// --------------------------------------------------------------------------- + +#[test] +fn context_switching_reaction_intercept_skips_repair_and_later_callbacks_observe_it() { + let dir = fresh_state_dir(); + let mut s = editor(&dir); + let other = write_file(&dir, "other.txt", "z"); + exec(&s, "_G.scratch = pmacs.window.buffer()"); + exec(&s, &format!("pmacs.buffer.find_or_open({other:?})")); + exec(&s, "_G.other = pmacs.window.buffer()"); + exec(&s, "pmacs.window.switch_buffer(_G.scratch)"); + type_str(&mut s, "ab"); + // A probe registered AFTER pair.lua (and every builtin): it + // observes whatever context the fan-out is in when it runs — + // explicitly pinning, not concealing, the origin-context deferral. + exec( + &s, + r#" + _G.probe_buf = nil + pmacs.hook.add("buffer.after-edit", function() + _G.probe_buf = tostring(pmacs.window.buffer()) + end) + pmacs.buffer.add_intercept(_G.scratch, function(op) + if op.kind == "insert" and op.bytes == ")" then + pmacs.window.switch_buffer(_G.other) + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert!( + status(&s).contains("auto-pair closer altered"), + "got: {:?}", + status(&s) + ); + let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())"); + assert_eq!( + scratch_text, ")ab(", + "the relocated closer landed in the scratch buffer as the intercept wrote it" + ); + let other_text: String = eval(&s, "return _G.other:slice(0, _G.other:len())"); + assert_eq!( + other_text, "z", + "pair.lua never touched the new context's text" + ); + assert_eq!( + cursor(&s), + 0, + "no cursor repair in the switched-to context (switch_buffer's own \ + cursor reset stands untouched)" + ); + let probe_saw_other: bool = eval(&s, "return _G.probe_buf == tostring(_G.other)"); + assert!( + probe_saw_other, + "a later callback observes the switched context — the origin-pinned \ + fan-out deferral, pinned" + ); +} + +// --------------------------------------------------------------------------- +// Hook fan-out: one fire per keystroke, the reaction edit doesn't re-fire +// --------------------------------------------------------------------------- + +#[test] +fn after_edit_fires_once_per_pairing_keystroke() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.fires = 0 + pmacs.hook.add("buffer.after-edit", function() _G.fires = _G.fires + 1 end) + "#, + ); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + let fires: i64 = eval(&s, "return _G.fires"); + assert_eq!( + fires, 1, + "the closer edit must not re-fire buffer.after-edit" + ); +} + +// --------------------------------------------------------------------------- +// Typed-edit record lifecycle (Q#AP9) +// --------------------------------------------------------------------------- + +#[test] +fn typed_edit_record_is_exact_and_one_shot() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.second_take = "unset" + pmacs.hook.add("buffer.after-edit", function() + _G.second_take = pmacs.editor.take_typed_edit() + end) + "#, + ); + type_str(&mut s, "("); + // pair.lua (first registrant) consumed the record and published it + // on the test seam: exact codepoint + effective triple. + let (cp, ch, clean, es, ee, il, pc): (i64, String, bool, i64, i64, i64, i64) = eval( + &s, + " + local r = pmacs.pair._last_record + return r.codepoint, r.char, r.clean, r.effective_start, + r.effective_end, r.inserted_len, r.post_cursor + ", + ); + assert_eq!(cp, 40, "exact codepoint for '('"); + assert_eq!(ch, "("); + assert!(clean); + assert_eq!( + (es, ee, il), + (0, 0, 1), + "effective triple of the opener insert" + ); + assert_eq!(pc, 1); + // A second take — from a later callback in the SAME fan-out — is nil. + let second_nil: bool = eval(&s, "return _G.second_take == nil"); + assert!(second_nil, "the record is consumable exactly once"); + // Outside any fan-out the slot is empty. + let outside_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil"); + assert!(outside_nil, "no record outside the after-edit fan-out"); +} + +#[test] +fn nested_manual_after_edit_run_sees_no_record() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.outer = nil + _G.ran_nested = false + pmacs.hook.add("buffer.after-edit", function() + if _G.ran_nested then return end -- the nested run reaches this callback too + _G.ran_nested = true + _G.outer = pmacs.pair._last_record + pmacs.hook.run("buffer.after-edit") + end) + "#, + ); + type_str(&mut s, "("); + let outer_seen: bool = eval(&s, "return _G.outer ~= nil"); + assert!(outer_seen, "the outer fan-out carried a record"); + // The nested run re-entered pair.lua, which took nil and published + // nil on the seam — proving the nested run observed no record. + let nested_nil: bool = eval(&s, "return pmacs.pair._last_record == nil"); + assert!(nested_nil, "a nested manual re-run must see nil"); + assert_eq!(buffer_text(&s), "()", "and must insert no second closer"); +} + +#[test] +fn rejected_self_insert_leaves_no_record() { + let mut s = editor_with(""); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op) + error("rejected by test intercept") + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "", "nothing landed"); + let take_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil"); + assert!(take_nil, "a rejecting edit must arm no record"); +} + +#[test] +fn frontends_cannot_consume_each_others_slot() { + let s = editor_with(""); + let (buffer, window) = { + let core = s.core.borrow(); + (core.active_buffer_id(), core.active_window_id()) + }; + let record = pmacs::editor_core::TypedEditRecord { + buffer, + window, + codepoint: '(', + requested_start: 0, + requested_end: 0, + effective_start: 0, + effective_end: 0, + inserted_len: 1, + post_cursor: 1, + clean: true, + }; + let a = FrontendId::LOCAL; + let b = FrontendId(a.0 + 1); + let mut core = s.core.borrow_mut(); + core.typed_edit_set_armed(a, record); + core.active_frontend = b; + assert!( + core.take_typed_edit().is_none(), + "frontend B must not see frontend A's record" + ); + core.active_frontend = a; + assert!( + core.take_typed_edit().is_some(), + "the slot survives a foreign take attempt for its owner" + ); + assert!( + core.take_typed_edit().is_none(), + "one-shot for the owner too" + ); +} + +// --------------------------------------------------------------------------- +// Signature help + first-didChange ordering (Q#AP7 / Q#AP8) +// --------------------------------------------------------------------------- + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool { + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + let done: bool = state + .lua_host + .lua() + .load(format!("return ({flag}) == true")) + .eval() + .unwrap_or(false); + if done { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Editor visiting a `.rs` file attached to the fake LSP in `sighelp` +/// mode, with the document-sync sink at `sink`. Returns after the +/// server initialized. +fn sighelp_editor(dir: &std::path::Path, sink: &std::path::Path, body: &str) -> EditorState { + let mut s = editor(dir); + let fake = fake_lsp_path(); + let sink_disp = sink.display().to_string(); + exec( + &s, + &format!( + "pmacs.lsp.config.rust = {{ + command = '{fake}', + env = {{ + PMACS_FAKE_LSP_MODE = 'sighelp', + PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}', + }}, + }}" + ), + ); + let f = write_file(dir, "a.rs", body); + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + let initialized = "(function() \ + for _,r in ipairs(pmacs.lsp.list()) do \ + if r.state and r.state.kind=='initialized' then return true end \ + end \ + return false \ + end)()"; + assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init"); + s +} + +/// The `text` of every `textDocument/didChange` line in the sink, in +/// arrival order. +fn did_change_texts(sink: &std::path::Path) -> Vec { + let Ok(raw) = std::fs::read_to_string(sink) else { + return Vec::new(); + }; + raw.lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange")) + .filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned)) + .collect() +} + +#[test] +fn first_did_change_after_opener_carries_the_pair() { + let dir = fresh_state_dir(); + let sink = dir.join("changes.jsonl"); + let mut s = sighelp_editor(&dir, &sink, "\n"); + + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "()\n", + "pairing is active in the attached buffer" + ); + + // The signature auto-trigger's synchronous flush sends the first + // didChange from inside the SAME fan-out; pump until the fake + // server has written it to the sink. + let deadline = Instant::now() + Duration::from_secs(5); + let changes = loop { + s.tick_processes(); + s.tick_lsp(); + s.tick_async(); + let c = did_change_texts(&sink); + if !c.is_empty() { + break c; + } + assert!( + Instant::now() < deadline, + "no didChange reached the fake server" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!( + changes[0], "()\n", + "the FIRST didChange after `(` carries the closer — pair.lua ran \ + before lsp.lua's synchronous flush (Q#AP7 ordering observable)" + ); + + // And the auto-trigger itself still fired with pairing active + // (Q#AP8): the fake's signature label reaches the status line. + let deadline = Instant::now() + Duration::from_secs(5); + let mut saw = false; + while Instant::now() < deadline { + s.tick_processes(); + s.tick_lsp(); + s.tick_async(); + if status(&s).contains("fn echo(") { + saw = true; + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + saw, + "signature help must still auto-trigger with pairing active" + ); +} + +#[test] +fn relocated_closer_first_did_change_carries_the_complete_effective_text() { + let dir = fresh_state_dir(); + let sink = dir.join("changes.jsonl"); + let mut s = sighelp_editor(&dir, &sink, "\n"); + // Context-preserving position transform: the closer lands at 0. + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" and op.bytes == ")" then + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), ")(\n"); + + let deadline = Instant::now() + Duration::from_secs(5); + let changes = loop { + s.tick_processes(); + s.tick_lsp(); + s.tick_async(); + let c = did_change_texts(&sink); + if !c.is_empty() { + break c; + } + assert!( + Instant::now() < deadline, + "no didChange reached the fake server" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!( + changes[0], ")(\n", + "a position-only closer transform still sends the complete effective \ + text in the first didChange — never an opener-only intermediate" + ); +} diff --git a/tests/auto_pair_crdt_acceptance.rs b/tests/auto_pair_crdt_acceptance.rs new file mode 100644 index 0000000..46761b3 --- /dev/null +++ b/tests/auto_pair_crdt_acceptance.rs @@ -0,0 +1,470 @@ +// auto_pair_crdt_acceptance.rs --- auto-pairing over the wire. + +//! Auto-pairing two-replica acceptance (docs/auto-pairing-framing.md): +//! a synthetic source replica plus a synthetic observer replica against +//! a real daemon subprocess. +//! +//! Dispatch route (built-in pair chars, Q#AP1): the source sends +//! round-tripped `Key` events; the daemon pairs/skips and broadcasts +//! `DaemonKey` ops to both replicas. Undo grain (Q#AP5) is pinned for +//! both routing models — the TUI's single-key optimistic undo is the +//! source replica's own peer-bound undo, `C-x u` is a round-tripped +//! daemon undo — as assertions of the named cross-peer substrate +//! limit, NOT frontend-equivalence claims. +//! +//! Optimistic route (custom pair char via user config, Q#AP1 cost +//! paragraph): the opener arrives as a `FrontendEvent::CrdtOp`; the +//! daemon's hook-queued closer is broadcast BEFORE the opener's +//! rebroadcast (the ordering quirk named in the framing), and the +//! observer must still converge. The source mirror's undo removes the +//! opener and leaves the closer — the pinned degraded undo. + +#![cfg(feature = "crdt")] + +use std::time::Duration; + +use pmacs::crdt::CrdtState; +use pmacs::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers}; +use pmacs::rope::CrdtOp as RopeCrdtOp; +use pmacs::transport::write_message; + +mod common; +use common::daemon::{TestDaemon, attach_multi}; + +/// Read the daemon's initial `BufferSnapshot` for a freshly-attached +/// replica stream (the daemon always emits it first). +fn read_initial_snapshot( + stream: &mut std::os::unix::net::UnixStream, +) -> (pmacs::buffer::BufferId, Vec) { + match pmacs::transport::read_message::(stream) + .expect("read initial BufferSnapshot") + { + pmacs::protocol::InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected initial BufferSnapshot, got {other:?}"), + } +} + +/// One attached synthetic replica: stream + mirror + identity. +struct Replica { + stream: std::os::unix::net::UnixStream, + state: CrdtState, + fid: FrontendId, + buffer_id: pmacs::buffer::BufferId, +} + +fn attach_replica(daemon: &TestDaemon) -> Replica { + let (hello, mut stream) = attach_multi(daemon); + let fid = hello.assigned_frontend_id; + let (buffer_id, snap) = read_initial_snapshot(&mut stream); + let state = CrdtState::new(fid.0).expect("CrdtState::new"); + state.import_snapshot(&snap).expect("import_snapshot"); + Replica { + stream, + state, + fid, + buffer_id, + } +} + +/// Mutate the local replica, export the delta, and ship it as an +/// optimistic `FrontendEvent::CrdtOp` (the `m10_11` idiom). +fn send_optimistic_op(replica: &mut Replica, mutate: F) +where + F: FnOnce(&CrdtState), +{ + let v = replica.state.version(); + mutate(&replica.state); + let op_bytes = replica + .state + .export_updates_since(&v) + .expect("export updates after local mutation"); + write_message( + &mut replica.stream, + &FrontendEvent::CrdtOp { + frontend_id: replica.fid, + buffer_id: replica.buffer_id, + op: RopeCrdtOp { + peer_id: replica.fid.0, + bytes: op_bytes, + }, + }, + ) + .expect("write CrdtOp"); +} + +fn send_key(replica: &mut Replica, key: Key, mods: Modifiers) { + write_message( + &mut replica.stream, + &FrontendEvent::Key(KeyEvent { + frontend_id: replica.fid, + key, + mods, + timestamp_ns: 0, + }), + ) + .expect("send Key"); +} + +/// `C-x u` — the always-dispatched daemon undo. +fn send_daemon_undo(replica: &mut Replica) { + send_key(replica, Key::Char('x'), Modifiers::CTRL); + send_key(replica, Key::Char('u'), Modifiers::NONE); +} + +/// `C-x r` — daemon redo. +fn send_daemon_redo(replica: &mut Replica) { + send_key(replica, Key::Char('x'), Modifiers::CTRL); + send_key(replica, Key::Char('r'), Modifiers::NONE); +} + +/// What a pump observed so far: materialized text, the daemon's last +/// `CursorByte` for the shared buffer, ops imported this call. +struct Observed { + text: String, + cursor: Option, + imported: usize, +} + +/// Pump broadcast messages into the replica until `pred` holds or the +/// deadline passes. Imports every `CrdtOp` for the shared buffer and +/// tracks the latest `CursorByte`. +fn pump_until bool>( + replica: &mut Replica, + timeout: Duration, + what: &str, + pred: P, +) -> Observed { + let deadline = std::time::Instant::now() + timeout; + let mut obs = Observed { + text: replica.state.materialize_string(), + cursor: None, + imported: 0, + }; + loop { + if pred(&obs) { + return obs; + } + assert!( + std::time::Instant::now() < deadline, + "pump timeout waiting for {what}; text={:?} cursor={:?} imported={}", + obs.text, + obs.cursor, + obs.imported + ); + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + replica + .stream + .set_read_timeout(Some(remaining.min(Duration::from_millis(100)))) + .ok(); + match pmacs::transport::read_message::( + &mut replica.stream, + ) { + Ok(pmacs::protocol::InstanceMessage::CrdtOp { buffer_id: b, op }) + if b == replica.buffer_id => + { + let _ = replica.state.import_updates(&op.bytes); + obs.imported += 1; + obs.text = replica.state.materialize_string(); + } + Ok(pmacs::protocol::InstanceMessage::CursorByte { + buffer_id: b, + byte_pos, + }) if b == replica.buffer_id => { + obs.cursor = Some(byte_pos); + } + Ok(_) | Err(_) => {} + } + } +} + +/// Pump for `window` expecting NO text change — the negative +/// assertion for "a further daemon undo cannot reach source-peer +/// history". Ops are still imported (there should be none that change +/// text); panics if the text leaves `expected`. +fn assert_text_stays(replica: &mut Replica, expected: &str, window: Duration) { + let deadline = std::time::Instant::now() + window; + while std::time::Instant::now() < deadline { + replica + .stream + .set_read_timeout(Some(Duration::from_millis(50))) + .ok(); + if let Ok(pmacs::protocol::InstanceMessage::CrdtOp { buffer_id: b, op }) = + pmacs::transport::read_message::(&mut replica.stream) + && b == replica.buffer_id + { + let _ = replica.state.import_updates(&op.bytes); + assert_eq!( + replica.state.materialize_string(), + expected, + "text must not change during the negative window" + ); + } + } + assert_eq!(replica.state.materialize_string(), expected); +} + +// --------------------------------------------------------------------------- +// Dispatch route (built-in chars) +// --------------------------------------------------------------------------- + +/// Round-tripped `(` pairs daemon-side and both replicas converge to +/// `()` with the daemon cursor between the pair; a round-tripped `)` +/// then skips (insert + swallow-delete, two more ops) and the daemon +/// cursor steps over the closer. +#[test] +fn dispatch_route_pair_and_skip_converge_on_both_replicas() { + let daemon = TestDaemon::spawn(); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_key(&mut source, Key::Char('('), Modifiers::NONE); + pump_until(&mut observer, Duration::from_secs(5), "observer ()", |o| { + o.text == "()" + }); + pump_until( + &mut source, + Duration::from_secs(5), + "source () with cursor between", + |o| o.text == "()" && o.cursor == Some(1), + ); + + // Skip: the typed `)` inserts then swallows the duplicate — two + // ops that leave the text identical, so convergence is detected + // by the op count plus the daemon cursor stepping to 2. + send_key(&mut source, Key::Char(')'), Modifiers::NONE); + pump_until( + &mut source, + Duration::from_secs(5), + "source skip (two ops, cursor after the closer)", + |o| o.text == "()" && o.imported >= 2 && o.cursor == Some(2), + ); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer skip (two ops, text still ())", + |o| o.text == "()" && o.imported >= 2, + ); +} + +/// Q#AP5 undo grain over the wire: the pair is two adjacent +/// daemon-peer units. Two `C-x u` restore `(` then empty on BOTH +/// replicas; two `C-x r` restore `(` then `()` in order. +#[test] +fn dispatch_route_daemon_undo_redo_walk_the_pair_on_both_replicas() { + let daemon = TestDaemon::spawn(); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_key(&mut source, Key::Char('('), Modifiers::NONE); + pump_until(&mut source, Duration::from_secs(5), "source ()", |o| { + o.text == "()" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer ()", |o| { + o.text == "()" + }); + + send_daemon_undo(&mut source); + pump_until(&mut source, Duration::from_secs(5), "source (", |o| { + o.text == "(" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer (", |o| { + o.text == "(" + }); + + send_daemon_undo(&mut source); + pump_until(&mut source, Duration::from_secs(5), "source empty", |o| { + o.text.is_empty() + }); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer empty", + |o| o.text.is_empty(), + ); + + send_daemon_redo(&mut source); + pump_until( + &mut source, + Duration::from_secs(5), + "source ( redone", + |o| o.text == "(", + ); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer ( redone", + |o| o.text == "(", + ); + + send_daemon_redo(&mut source); + pump_until( + &mut source, + Duration::from_secs(5), + "source () redone", + |o| o.text == "()", + ); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer () redone", + |o| o.text == "()", + ); +} + +// --------------------------------------------------------------------------- +// Mixed source/daemon history (the named substrate limit, pinned) +// --------------------------------------------------------------------------- + +/// TUI routing model: with optimistic `a` already in the source +/// mirror, the single-key optimistic undo (the mirror's own peer-bound +/// undo) removes `a` — NOT the daemon-peer closer — leaving `()`. +#[test] +fn mixed_history_source_mirror_undo_removes_the_optimistic_char_first() { + let daemon = TestDaemon::spawn(); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_optimistic_op(&mut source, |r| { + r.insert(0, "a").expect("insert a"); + }); + send_key(&mut source, Key::Char('('), Modifiers::NONE); + pump_until(&mut source, Duration::from_secs(5), "source a()", |o| { + o.text == "a()" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer a()", |o| { + o.text == "a()" + }); + + // The TUI's single-key undo: mirror-local, peer-bound. + send_optimistic_op(&mut source, |r| { + r.undo().expect("mirror undo"); + }); + assert_eq!( + source.state.materialize_string(), + "()", + "the mirror undo removed source-peer `a`, not the adjacent daemon closer" + ); + pump_until(&mut observer, Duration::from_secs(5), "observer ()", |o| { + o.text == "()" + }); +} + +/// `C-x u` routing model (and the GPU model, which reaches the daemon +/// the same way): daemon undos peel the pair — closer, then opener — +/// and a FURTHER daemon undo cannot reach the source-peer `a`. +#[test] +fn mixed_history_daemon_undo_peels_the_pair_but_cannot_reach_source_history() { + let daemon = TestDaemon::spawn(); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_optimistic_op(&mut source, |r| { + r.insert(0, "a").expect("insert a"); + }); + send_key(&mut source, Key::Char('('), Modifiers::NONE); + pump_until(&mut source, Duration::from_secs(5), "source a()", |o| { + o.text == "a()" + }); + + send_daemon_undo(&mut source); + pump_until(&mut source, Duration::from_secs(5), "source a(", |o| { + o.text == "a(" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer a(", |o| { + o.text == "a(" + }); + + send_daemon_undo(&mut source); + pump_until(&mut source, Duration::from_secs(5), "source a", |o| { + o.text == "a" + }); + + // The named limit: daemon undo is peer-bound too — source-peer + // `a` is beyond its reach. (Cross-peer chronological arbitration + // is deferred substrate work, not pair.lua's claim.) + send_daemon_undo(&mut source); + assert_text_stays(&mut source, "a", Duration::from_millis(800)); +} + +// --------------------------------------------------------------------------- +// Optimistic route (custom pair char from user config) +// --------------------------------------------------------------------------- + +const CUSTOM_PAIR_CONFIG: &str = "table.insert(pmacs.pair.sets.default, \"<>\")\n"; + +/// A user-extended pair char still arrives optimistically: the opener +/// is a source-peer op, the daemon's hook-queued `>` closer is +/// broadcast BEFORE the opener's rebroadcast (the framing's ordering +/// quirk — the observer receives the causally dependent closer +/// first), and both replicas must still converge. The skip route +/// converges likewise. +#[test] +fn optimistic_route_custom_char_pairs_and_skips_despite_closer_first_broadcast() { + let daemon = TestDaemon::spawn_with_config(CUSTOM_PAIR_CONFIG); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_optimistic_op(&mut source, |r| { + r.insert(0, "<").expect("insert <"); + }); + pump_until(&mut observer, Duration::from_secs(5), "observer <>", |o| { + o.text == "<>" + }); + pump_until(&mut source, Duration::from_secs(5), "source <>", |o| { + o.text == "<>" + }); + + // Skip: the source optimistically types the closer before the + // existing `>`; the daemon swallows the duplicate. Text returns + // to `<>`; the extra daemon delete op must reach both replicas. + send_optimistic_op(&mut source, |r| { + r.insert(1, ">").expect("insert >"); + }); + pump_until( + &mut source, + Duration::from_secs(5), + "source skip converged", + |o| o.text == "<>" && o.imported >= 1, + ); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer skip converged", + |o| o.text == "<>" && o.imported >= 2, + ); +} + +/// The pinned degraded undo for optimistic pair chars: the opener and +/// closer live on DIFFERENT peers, so the source mirror's undo removes +/// its own opener and leaves the daemon's closer behind. +#[test] +fn optimistic_route_mirror_undo_removes_the_opener_leaving_the_closer() { + let daemon = TestDaemon::spawn_with_config(CUSTOM_PAIR_CONFIG); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_optimistic_op(&mut source, |r| { + r.insert(0, "<").expect("insert <"); + }); + pump_until(&mut source, Duration::from_secs(5), "source <>", |o| { + o.text == "<>" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer <>", |o| { + o.text == "<>" + }); + + send_optimistic_op(&mut source, |r| { + r.undo().expect("mirror undo"); + }); + assert_eq!( + source.state.materialize_string(), + ">", + "peer-bound mirror undo removes the opener; the daemon-peer closer stays" + ); + pump_until(&mut observer, Duration::from_secs(5), "observer >", |o| { + o.text == ">" + }); +} diff --git a/tests/common/daemon.rs b/tests/common/daemon.rs index a0879cf..8a1aed2 100644 --- a/tests/common/daemon.rs +++ b/tests/common/daemon.rs @@ -48,12 +48,33 @@ impl TestDaemon { /// T M10.8 Day 4 — spawn with extra env-var overrides for /// instance-capability tests. pub fn spawn_with_env(env_vars: &[(&str, &str)]) -> Self { + Self::spawn_with_env_and_config(env_vars, None) + } + + /// Spawn with a user `init.lua` pre-written into the daemon's + /// isolated config home (the tempdir doubles as `HOME` / + /// `XDG_CONFIG_HOME`, so the chunk lands at + /// `/pmacs/init.lua` and loads through the real + /// `load_user_config` path). First consumer: the auto-pairing + /// CRDT suite, which extends `pmacs.pair.sets` from config to + /// exercise the optimistic (non-built-in) pair-char route. + #[allow(dead_code)] // consumed per-suite; not every test crate uses it + pub fn spawn_with_config(init_lua: &str) -> Self { + Self::spawn_with_env_and_config(&[], Some(init_lua)) + } + + fn spawn_with_env_and_config(env_vars: &[(&str, &str)], init_lua: Option<&str>) -> Self { let tempdir = TempDir::new().expect("tempdir"); // tempfile::TempDir creates 0755-mode directories; the daemon // requires a 0700-or-stricter parent for the socket. Tighten // the tempdir before spawning. fs::set_permissions(tempdir.path(), fs::Permissions::from_mode(0o700)) .expect("chmod tempdir 0700"); + if let Some(chunk) = init_lua { + let config_dir = tempdir.path().join("pmacs"); + fs::create_dir_all(&config_dir).expect("create pmacs config dir"); + fs::write(config_dir.join("init.lua"), chunk).expect("write init.lua"); + } let socket_path = tempdir.path().join("pmacs.sock"); let mut process = spawn_daemon_process_with_env(&socket_path, env_vars); wait_for_socket_or_exit(&socket_path, &mut process, Duration::from_secs(10)) From b0bbc867927f5089d8e2762068f1f1d59afbc159 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 12 Jul 2026 15:17:33 +0100 Subject: [PATCH 3/6] =?UTF-8?q?fix(edit):=20PR=20#110=20round=201=20?= =?UTF-8?q?=E2=80=94=20revision=20postcondition,=20relevance=20gate,=20str?= =?UTF-8?q?ict=20pair=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (medium): the typed-edit record now pins the edited buffer's revision after the completing edit; typed_edit_finish re-reads it at dispatch end and drops the record if the command edited again — a redefined buffer.self-insert that replaces the typed char (cursor unmoved) no longer leaves a stale-but-clean record, so `(`-then-replace-with-`[` yields `[`, not `[)`. Bite: post_insert_mutation_by_the_command_kills_the_record. Finding 2 (medium): pair-set relevance is established before the clean/context gates, so a transformed or relocated character outside the active set stays silent instead of drawing an auto-pair report. Bite: transformed_non_pair_char_stays_silent. Finding 3 (medium): split_pair parses EXACTLY two codepoints and rejects trailing bytes — a "()x" (or "«»x") entry is skipped entirely, never honored as `(` → `)x`; valid multibyte pairs ("«»") pair and skip at byte-correct cursors. Bites: malformed_pair_entries_are_skipped_not_partially_honored, multibyte_pair_entries_pair_and_skip. Finding 4 (low): the record-capture seam is gated behind the opt-in pmacs.pair._capture_records test facility, off by default — no consumed record is retained in production, restoring the Q#AP9 ephemerality the seam had defeated. Seam-reading tests opt in; record_capture_is_off_by_default pins the default. Finding 5 (low): the equal-revision source-context-switch twin is covered — the fan-out is skipped by the active-buffer revision compare, pairing fails closed silently, and no report is possible; the framing scopes the context-change report as best-effort until the buffer-aware edit epoch lands. Framing synced to revision 4 (Q#AP2 entry rule, Q#AP3 relevance-first + best-effort report scope, Q#AP9 revision postcondition + capture facility + the dispatch-path intercept borrow note, acceptance list). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- builtin/runtime/pair.lua | 94 ++++++++++--------- docs/auto-pairing-framing.md | 75 +++++++++++++--- src/daemon.rs | 11 +++ src/editor_core.rs | 41 ++++++++- tests/auto_pair_acceptance.rs | 164 +++++++++++++++++++++++++++++++++- 5 files changed, 328 insertions(+), 57 deletions(-) diff --git a/builtin/runtime/pair.lua b/builtin/runtime/pair.lua index 03856d9..231e23d 100644 --- a/builtin/runtime/pair.lua +++ b/builtin/runtime/pair.lua @@ -50,6 +50,16 @@ pmacs.pair.sets = { bash = { "()", "[]", "{}", '""', "''" }, } +-- UTF-8 sequence length from a leading byte; nil on a continuation +-- byte (not a codepoint boundary). +local function cp_len(b) + if b < 0x80 then return 1 end + if b < 0xC0 then return nil end + if b < 0xE0 then return 2 end + if b < 0xF0 then return 3 end + return 4 +end + -- The first full UTF-8 codepoint starting at byte `pos`, as a string, -- or nil at end-of-buffer / on a non-boundary byte. Forward twin of -- lsp.lua's `char_before`; reads at most 4 bytes. @@ -59,44 +69,23 @@ local function char_at(buf, pos) local to = math.min(pos + 4, len) local ok, s = pcall(function() return buf:slice(pos, to) end) if not ok or type(s) ~= "string" or #s == 0 then return nil end - local b = s:byte(1) - local n - if b < 0x80 then - n = 1 - elseif b < 0xC0 then - return nil -- continuation byte: pos is not a codepoint boundary - elseif b < 0xE0 then - n = 2 - elseif b < 0xF0 then - n = 3 - else - n = 4 - end - if n > #s then return nil end + local n = cp_len(s:byte(1)) + if not n or n > #s then return nil end return s:sub(1, n) end --- Split a pair entry into (opener, closer): the first codepoint and --- the rest. nil for entries that aren't two-or-more bytes of --- opener-then-closer (malformed user additions are skipped, not --- errors — the hook must never throw over a config typo). +-- Split a pair entry into (opener, closer): EXACTLY two codepoints, +-- no trailing bytes (PR #110 round 1, finding 3 — "()x" must be +-- skipped entirely, never honored as `(` → `)x`). nil for malformed +-- user additions: skipped, not errors — the hook must never throw +-- over a config typo. local function split_pair(s) if type(s) ~= "string" or #s < 2 then return nil end - local b = s:byte(1) - local n - if b < 0x80 then - n = 1 - elseif b < 0xC0 then - return nil - elseif b < 0xE0 then - n = 2 - elseif b < 0xF0 then - n = 3 - else - n = 4 - end - if n >= #s then return nil end - return s:sub(1, n), s:sub(n + 1) + local n1 = cp_len(s:byte(1)) + if not n1 or n1 >= #s then return nil end + local n2 = cp_len(s:byte(n1 + 1)) + if not n2 or n1 + n2 ~= #s then return nil end + return s:sub(1, n1), s:sub(n1 + 1) end -- The active buffer's pair set: language entry if the language is @@ -158,24 +147,38 @@ local function repair_cursor(win0, buf0, cursor0, estart, estop, einserted) ed.goto_byte(translate(cursor0, estart, estop, einserted)) end +-- Test facility (leading underscore = not stable API), OFF by +-- default: the one-shot record must stay ephemeral in production — +-- retaining every consumed record in a public field would defeat the +-- Q#AP9 contract the take API enforces (PR #110 round 1, finding 4). +-- Acceptance tests flip `_capture_records` on; each fan-out then +-- publishes the record it observed (or nil) to `_last_record`, which +-- is how tests read the exact codepoint / effective triple and prove +-- one-shot-ness (this callback registers first and consumes it). +pmacs.pair._capture_records = false + pmacs.hook.add("buffer.after-edit", function() -- One-shot provenance (Q#AP9). Absence — paste, programmatic edit, - -- manual hook run, rejected insert, stale `this_command` — is a - -- silent non-event; only a live record that then fails a gate + -- manual hook run, rejected insert, a post-insert mutation by the + -- command, stale `this_command` — is a silent non-event; only a + -- live record for a pair-set character that then fails a gate -- reports. local rec = ed.take_typed_edit and ed.take_typed_edit() - -- Test seam (leading underscore = not stable API, like - -- `pmacs.window._overlay_kinds`): the record this fan-out yielded, - -- or nil. This callback registers first and consumes the one-shot - -- record, so acceptance tests observe the exact codepoint / - -- effective triple here — and prove one-shot-ness by taking again. - pmacs.pair._last_record = rec + if pmacs.pair._capture_records then pmacs.pair._last_record = rec end if not rec then return end if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end local buf = pmacs.window.buffer() if not buf then return end + -- Relevance first (PR #110 round 1, finding 2): pairing has no + -- interest in characters outside the active set, so a transformed + -- or relocated ordinary `a` must stay silent — the reports below + -- are for pair characters only. + local ch = rec.char + local openers, closers = maps_for(active_set()) + if not (openers[ch] or closers[ch]) then return end + -- Fail closed on a transformed source self-insert (Q#AP3): the -- intercept's positional result stands as produced; pairing on top -- of a relocated or expanded opener would compound it. @@ -185,7 +188,12 @@ pmacs.hook.add("buffer.after-edit", function() end -- Fail closed when the source edit's context is no longer current: -- an intercept switched window/buffer, or something moved the - -- cursor off the post-insert position. + -- cursor off the post-insert position. Best-effort by construction: + -- the report needs this fan-out to run at all, and dispatch's + -- active-buffer revision compare (the named buffer-aware edit-epoch + -- deferral) skips the fan-out when a context-switching command + -- lands on a buffer with a coincidentally equal revision — pairing + -- still fails closed there, silently (the record dies un-armed). if buf ~= rec.buffer or pmacs.window.current() ~= rec.window or ed.cursor() ~= rec.post_cursor then @@ -199,9 +207,7 @@ pmacs.hook.add("buffer.after-edit", function() -- onto an unconsumed region. if ed.region() ~= nil then return end - local ch = rec.char local cursor = rec.post_cursor - local openers, closers = maps_for(active_set()) -- Skip-over-close (Q#AP4), checked before insertion so symmetric -- pairs (quotes) step over their own closer: typing `)` at `(|)` diff --git a/docs/auto-pairing-framing.md b/docs/auto-pairing-framing.md index 0479144..3e4a840 100644 --- a/docs/auto-pairing-framing.md +++ b/docs/auto-pairing-framing.md @@ -23,6 +23,19 @@ unrelated text, the context-switch/LSP limit is stated rather than hidden by the cursor guard, non-typed acceptance now drives callbacks that actually fire, and redo is exercised rather than merely named. +Revision 4: PR #110 round 1 — the typed-edit record pins the edited +buffer's revision after the completing edit and dies at dispatch end +if the command edited again (a redefined self-insert that replaces +the typed char can no longer leave a stale-but-clean record); +pair-set relevance is established before any provenance report +(transformed non-pair characters stay silent); pair entries parse as +exactly two codepoints (malformed entries are skipped entirely, never +partially honored); the record-capture seam is an opt-in test +facility, off in production; and the source-context-change *report* +is scoped as best-effort under the active-buffer edit-epoch limit — +an equal-revision context switch skips the fan-out and fails closed +silently. + ## Ground truth (as of `7e127ab`) - **Dispatch is keymap-first for printables** — `Char('(')` resolves @@ -194,9 +207,12 @@ full fix deferred with the pre-existing mixed-history problem ### Q#AP2 — Pair sets: per-language table, conservative default `pmacs.pair.sets` — the `pmacs.comment.strings` shape: language → -array of 2-byte pair strings, plus a `default` entry used when the -language is unknown or has no entry (pairing is useful in scratch -buffers): +array of pair strings, plus a `default` entry used when the language +is unknown or has no entry (pairing is useful in scratch buffers). +An entry is EXACTLY two codepoints — opener then closer, multibyte +allowed (`"«»"`); malformed entries (trailing bytes, non-boundary +first byte) are skipped entirely, never partially honored (R4: a +`"()x"` typo must not turn `(` into `()x`): - `default = { "()", "[]", "{}", '""' }` — no `'` (prose apostrophes), no backtick. @@ -212,6 +228,10 @@ React when ALL hold: - `this_command() == "buffer.self-insert"` **and** Q#AP9 returns a live typed-edit record for this callback (pastes, manual hook runs, and programmatic inserts have no record and never pair); +- **relevance first (R4)**: the record's exact typed codepoint is in + the active pair set at all (opener or closer). Characters outside + the set exit silently BEFORE any gate below can report — a + transformed ordinary `a` is not auto-pairing's business; - the record's buffer/window match the current context, its source edit is clean (effective triple equals the requested insert or replace), and the current cursor equals its recorded post-edit @@ -220,7 +240,13 @@ React when ALL hold: reaction. A non-clean triple reports *"auto-pair skipped: source self-insert transformed"*; a context/cursor mismatch reports *"auto-pair skipped: source context changed"*. This is the - fail-closed answer to R2 finding 2; + fail-closed answer to R2 finding 2. The context-change *report* is + best-effort (R4): it requires the after-edit fan-out to run, and + the dispatcher's active-buffer revision compare (the named + buffer-aware edit-epoch deferral) skips the fan-out when a + context-switching command lands on a buffer whose revision + coincidentally equals the origin's — the record dies un-armed and + pairing fails closed silently; - **no active region survives the edit** (`ed.region() == nil`) — on the dispatch path type-over has already consumed and cleared it; a surviving nonempty region means the edit arrived through the @@ -366,6 +392,19 @@ currently discards the effective range on its way back to `insert_char`; payload immutability means the codepoint itself remains authoritative. +The record additionally pins the edited buffer's revision immediately +after the completing edit — a producer-side postcondition, not +consumer surface (R4). At dispatch end, before arming, the revision +is re-read: if the command edited again after the self-insert (a +redefined `buffer.self-insert` that replaces or removes the typed +character while leaving the cursor in place), the record no longer +describes the buffer and dies un-armed. Note the switch-context case +is reachable only through such a redefined command: a +context-switching *intercept* cannot exist on the dispatch +self-insert path (the core borrow is held across it; the +borrow-released three-phase discipline belongs to the Lua-mutator +path the reaction uses). + The pair callback takes the record; later callbacks and a nested manual re-run of `buffer.after-edit` see nil. The dispatcher/daemon also clears any untaken slot immediately after the hook returns, @@ -373,10 +412,12 @@ including error paths and the no-revision-change path. Plain `pmacs.hook.run`, paste, programmatic mutation, and a stale `this_command == "buffer.self-insert"` therefore see nil. Pairing requires `clean == true`, matching buffer/window, and -`cursor == post_cursor`; otherwise it reports and does nothing. This -is deliberately narrower than teaching every command to expose its -edit: one producer class, one consumer contract, and no persistent -history. +`cursor == post_cursor`; otherwise it reports (pair-set characters +only, R4) and does nothing. This is deliberately narrower than +teaching every command to expose its edit: one producer class, one +consumer contract, and no persistent history. Record retention is +zero in production: the opt-in `pmacs.pair._capture_records` test +facility is the only way a consumed record outlives its fan-out (R4). ## Bets @@ -421,6 +462,9 @@ history. - Quotes pair under the predicate. - Per-language: `'` pairs in `.py`, not in `.rs`; scratch pairs the default set. +- Set-entry parsing (R4): a malformed `"()x"` (and an overlong + multibyte `"«»x"`) pairs nothing; a valid multibyte `"«»"` pairs + and skips at byte-correct cursors. - Non-typed provenance, with the callback actually exercised: production `FrontendEvent::Paste("(")` after a prior self-insert leaves a lone pasted opener; `buf:insert("(")` followed by explicit @@ -440,8 +484,14 @@ history. translate-and-clamp. The **source self-insert** gets separate cases: relocated opener and expanded/relocated type-over produce exactly the intercept's positional result, Q#AP9 reports/skips, and no - unrelated closer is inserted; a source context switch likewise - fails closed. + unrelated closer is inserted; a source context switch (via a + redefined `buffer.self-insert` — the only legal producer, see + Q#AP9) likewise fails closed, in BOTH revision shapes (R4): skewed + revisions report "source context changed"; equal revisions skip the + fan-out entirely and fail closed silently. A relocated **non-pair** + character draws no auto-pair report at all (R4), and a redefined + self-insert that edits again after the insert (replacing the typed + char, cursor unmoved) kills the record — no `[)` (R4). - Context-switching **reaction** intercept → pair cursor repair skipped, new context's text/cursor untouched by pair.lua. A probe callback registered after pair.lua observes the switched context, @@ -459,7 +509,10 @@ history. self-insert hooks; a second take (including a nested manual after-edit run) is nil. It is also nil before/after the fan-out, for paste, for standalone manual hook runs, and after a rejecting edit. - Two frontends cannot see or consume each other's slot. + Two frontends cannot see or consume each other's slot. Exact-record + observation goes through the opt-in `_capture_records` facility; + with it off (production), no consumed record is retained anywhere + (R4). Classifier flips (in-crate): GPU `optimistic_insert_text` returns `None` for the nine pair chars (test updated alongside Enter's); diff --git a/src/daemon.rs b/src/daemon.rs index cc40cf9..973188c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2186,6 +2186,16 @@ fn handle_remote_crdt_op( .get(&wid) .is_some_and(|w| w.buffer_id == buffer_id) { + // Revision postcondition anchor: this arm consumes the + // record in the same fan-out (no command body runs after + // the import), so the current revision is trivially the + // post-edit one. + let revision = core + .registry + .borrow() + .get(buffer_id) + .ok() + .map_or(0, crate::buffer::Buffer::revision); core.typed_edit_set_armed( source, crate::editor_core::TypedEditRecord { @@ -2199,6 +2209,7 @@ fn handle_remote_crdt_op( inserted_len: edit.inserted_len, post_cursor: post_edit_cursor, clean: true, + revision, }, ); } diff --git a/src/editor_core.rs b/src/editor_core.rs index da6567c..64b3211 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -181,6 +181,15 @@ pub struct TypedEditRecord { pub post_cursor: u64, /// True iff the effective triple equals the request. pub clean: bool, + /// The edited buffer's revision immediately after the completing + /// edit — a producer-side postcondition, not consumer surface (it + /// is not exposed on the Lua record). `typed_edit_finish` drops + /// the record when the buffer's revision has moved past this: a + /// redefined `buffer.self-insert` that edits again after the + /// insert (removing or replacing the typed character) must not + /// leave a stale-but-"clean" record for the pairing hook (PR #110 + /// round 1, finding 1). + pub revision: u64, } /// In-flight arm for a [`TypedEditRecord`] (auto-pairing Q#AP9): the @@ -2333,6 +2342,17 @@ impl EditorCore { if !matches { return; } + // The revision postcondition anchor: if the buffer vanished + // (killed mid-command), no record — absence fails closed. + let Some(revision) = self + .registry + .borrow() + .get(context.0) + .ok() + .map(Buffer::revision) + else { + return; + }; let clean = edit.range == requested && edit.inserted_len == requested_len; let record = TypedEditRecord { buffer: context.0, @@ -2345,6 +2365,7 @@ impl EditorCore { inserted_len: edit.inserted_len, post_cursor: self.active_window().cursor, clean, + revision, }; if let Some(p) = self.typed_edit_pending.as_mut() { p.record = Some(record); @@ -2355,12 +2376,30 @@ impl EditorCore { /// yielding the completed record (or `None` if the self-insert /// never landed — rejected edit, command error). Always clears the /// pending state: an arm never survives its dispatch cycle. + /// + /// Postcondition (PR #110 round 1, finding 1): the record is + /// yielded only if the edited buffer's revision still equals the + /// one captured at completion. A command body that edited again + /// after the self-insert — replacing or removing the typed + /// character while leaving the cursor in place — produced state + /// the record no longer describes; the record dies here, before + /// it can be armed for the hook. pub fn typed_edit_finish(&mut self, fid: FrontendId) -> Option { let pending = self.typed_edit_pending.take()?; if pending.fid != fid { return None; } - pending.record + let record = pending.record?; + let current = self + .registry + .borrow() + .get(record.buffer) + .ok() + .map(Buffer::revision); + if current != Some(record.revision) { + return None; + } + Some(record) } /// Arm `record` for consumption during the `buffer.after-edit` diff --git a/tests/auto_pair_acceptance.rs b/tests/auto_pair_acceptance.rs index 5223947..1998b04 100644 --- a/tests/auto_pair_acceptance.rs +++ b/tests/auto_pair_acceptance.rs @@ -260,6 +260,40 @@ fn single_quote_pairs_in_python_but_not_rust() { ); } +#[test] +fn malformed_pair_entries_are_skipped_not_partially_honored() { + // PR #110 round 1, finding 3: an entry is exactly two codepoints. + // "()x" must be ignored entirely — never "type `(`, get `)x`". + let mut s = editor_with(""); + exec(&s, "pmacs.pair.sets.default = { \"()x\" }"); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "(", "a malformed entry pairs nothing"); + assert_eq!(cursor(&s), 1); + + // Overlong multibyte entry: same rule after a 2-byte opener. + let mut s2 = editor_with(""); + exec(&s2, "pmacs.pair.sets.default = { \"\u{ab}\u{bb}x\" }"); // "«»x" + type_str(&mut s2, "\u{ab}"); + assert_eq!( + buffer_text(&s2), + "\u{ab}", + "an overlong entry pairs nothing" + ); +} + +#[test] +fn multibyte_pair_entries_pair_and_skip() { + // Two-codepoint entries with multibyte members are valid: guillemets. + let mut s = editor_with(""); + exec(&s, "pmacs.pair.sets.default = { \"\u{ab}\u{bb}\" }"); // "«»" + type_str(&mut s, "\u{ab}"); + assert_eq!(buffer_text(&s), "\u{ab}\u{bb}"); + assert_eq!(cursor(&s), 2, "cursor between the pair (byte offset)"); + type_str(&mut s, "\u{bb}"); + assert_eq!(buffer_text(&s), "\u{ab}\u{bb}", "the closer skips"); + assert_eq!(cursor(&s), 4); +} + #[test] fn scratch_buffer_pairs_the_default_set() { let mut s = editor_with(""); @@ -293,9 +327,14 @@ fn scratch_buffer_pairs_the_default_set() { #[test] fn paste_of_opener_does_not_pair() { let mut s = editor_with(""); + exec(&s, "pmacs.pair._capture_records = true"); // A prior self-insert, so a heuristic keyed only on buffer text or - // `char_before` would be primed to misfire. + // `char_before` would be primed to misfire. (It also proves the + // capture seam live: the nil assertion below is a transition from + // this keystroke's captured record, not an unset field.) type_str(&mut s, "a"); + let primed: bool = eval(&s, "return pmacs.pair._last_record ~= nil"); + assert!(primed, "the typed `a` captured a record"); // The daemon's unified inbound-paste route, faithfully: break the // source's command chain, insert, fire the after-edit hook // (`handle_inbound_paste` + `with_after_edit_check`). @@ -311,6 +350,7 @@ fn paste_of_opener_does_not_pair() { #[test] fn programmatic_insert_with_stale_this_command_does_not_pair() { let mut s = editor_with(""); + exec(&s, "pmacs.pair._capture_records = true"); // Type 'a' so `this_command` is (and stays) "buffer.self-insert" — // the deliberately stale signal the provenance gate must ignore. type_str(&mut s, "a"); @@ -331,6 +371,7 @@ fn programmatic_insert_with_stale_this_command_does_not_pair() { #[test] fn command_invoke_self_insert_does_not_pair() { let s = editor_with(""); + exec(&s, "pmacs.pair._capture_records = true"); // Plain `pmacs.command.invoke` is the programmatic API: it stamps // no boundary and arms no record. exec(&s, "pmacs.command.invoke(\"buffer.self-insert\", 40)"); // '(' @@ -523,6 +564,68 @@ fn expanded_skip_delete_lands_reported_and_cursor_clamped() { // Source self-insert intercepts (Q#AP9): the reaction fails closed // --------------------------------------------------------------------------- +#[test] +fn post_insert_mutation_by_the_command_kills_the_record() { + // PR #110 round 1, finding 1: a redefined `buffer.self-insert` + // that inserts the char and then REPLACES it — leaving the cursor + // untouched — must not pair off the stale record. The record pins + // the buffer revision after the completing edit; any further edit + // by the same command kills it before the fan-out. + let mut s = editor_with(""); + exec( + &s, + r#" + pmacs.command.unregister("buffer.self-insert") + pmacs.command.define { + name = "buffer.self-insert", + description = "test override: insert, then replace the typed char", + fn = function(cp) + pmacs.editor.insert_char_over_region(cp) + pmacs.window.buffer():replace(0, 1, "[") + end, + } + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "[", + "the typed `(` no longer exists; a `)` reaction would produce `[)`" + ); + assert_eq!(cursor(&s), 1); + assert!( + !status(&s).contains("auto-pair"), + "a dead record is a silent non-event; got: {:?}", + status(&s) + ); +} + +#[test] +fn transformed_non_pair_char_stays_silent() { + // PR #110 round 1, finding 2: pairing has no interest in `a`; an + // intercept relocating it must not draw an auto-pair report. + let mut s = editor_with("xy"); + exec(&s, "pmacs.editor.goto_byte(2)"); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" and op.bytes == "a" then + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "a"); + assert_eq!(buffer_text(&s), "axy"); + assert!( + !status(&s).contains("auto-pair"), + "chars outside the active pair set must stay silent; got: {:?}", + status(&s) + ); +} + #[test] fn relocated_opener_gets_no_pair_reaction() { let mut s = editor_with("ab"); @@ -632,6 +735,50 @@ fn source_context_switch_fails_closed() { assert_eq!(other_text, "z", "the switched-to buffer is untouched"); } +#[test] +fn source_context_switch_with_equal_revisions_fails_closed_silently() { + // PR #110 round 1, finding 5: the twin of the test above WITHOUT + // the revision bump. Dispatch's active-buffer revision compare + // (pre = scratch@0, post = other@0) sees no delta, so the + // after-edit fan-out never runs: no reaction anywhere, and no + // context-change report either — the record dies un-armed. The + // report is best-effort until the buffer-aware edit epoch lands + // (named substrate deferral); failing closed is unconditional. + let dir = fresh_state_dir(); + let mut s = editor(&dir); + let other = write_file(&dir, "other.txt", "z"); + exec(&s, "_G.scratch = pmacs.window.buffer()"); + exec(&s, &format!("pmacs.buffer.find_or_open({other:?})")); + exec(&s, "_G.other = pmacs.window.buffer()"); + exec(&s, "pmacs.window.switch_buffer(_G.scratch)"); + exec( + &s, + r#" + pmacs.command.unregister("buffer.self-insert") + pmacs.command.define { + name = "buffer.self-insert", + description = "test override: insert, then switch context", + fn = function(cp) + pmacs.editor.insert_char_over_region(cp) + pmacs.window.switch_buffer(_G.other) + end, + } + "#, + ); + type_str(&mut s, "("); + let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())"); + assert_eq!(scratch_text, "(", "the opener landed in scratch, no closer"); + let other_text: String = eval(&s, "return _G.other:slice(0, _G.other:len())"); + assert_eq!(other_text, "z", "the switched-to buffer is untouched"); + assert!( + !status(&s).contains("auto-pair"), + "no fan-out ran, so no report is possible; got: {:?}", + status(&s) + ); + let take_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil"); + assert!(take_nil, "the record was never armed"); +} + // --------------------------------------------------------------------------- // Context-switching REACTION intercept: repair skipped, deferral pinned // --------------------------------------------------------------------------- @@ -728,6 +875,7 @@ fn typed_edit_record_is_exact_and_one_shot() { exec( &s, r#" + pmacs.pair._capture_records = true _G.second_take = "unset" pmacs.hook.add("buffer.after-edit", function() _G.second_take = pmacs.editor.take_typed_edit() @@ -768,6 +916,7 @@ fn nested_manual_after_edit_run_sees_no_record() { exec( &s, r#" + pmacs.pair._capture_records = true _G.outer = nil _G.ran_nested = false pmacs.hook.add("buffer.after-edit", function() @@ -788,6 +937,18 @@ fn nested_manual_after_edit_run_sees_no_record() { assert_eq!(buffer_text(&s), "()", "and must insert no second closer"); } +#[test] +fn record_capture_is_off_by_default() { + // PR #110 round 1, finding 4: without the explicit test facility, + // no consumed record is retained anywhere — the one-shot take API + // is the only access, and it is empty after the fan-out. + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + let leaked: bool = eval(&s, "return pmacs.pair._last_record ~= nil"); + assert!(!leaked, "production keystrokes must retain no record"); +} + #[test] fn rejected_self_insert_leaves_no_record() { let mut s = editor_with(""); @@ -823,6 +984,7 @@ fn frontends_cannot_consume_each_others_slot() { inserted_len: 1, post_cursor: 1, clean: true, + revision: 0, }; let a = FrontendId::LOCAL; let b = FrontendId(a.0 + 1); From ceaeb81386b4994cb08b05d54f59a306ee03f21c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 12 Jul 2026 15:54:16 +0100 Subject: [PATCH 4/6] =?UTF-8?q?fix(edit):=20PR=20#110=20round=202=20?= =?UTF-8?q?=E2=80=94=20UTF-8=20well-formedness,=20source-buffer=20relevanc?= =?UTF-8?q?e,=20non-table=20sets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (medium): pair entries validate full UTF-8 well-formedness (Unicode Table 3-7), not just lead-byte length — continuation-byte shape on every trailing byte, overlong encodings (C0/C1, E0 80-9F, F0 80-8F), UTF-16 surrogates (ED A0-BF), and beyond-U+10FFFF (F5+, F4 90+) all disqualify, so "(\xC2x" can no longer inject invalid bytes as a closer. char_at shares the validator and returns the raw byte for malformed buffer content: the predicate treats junk as word-like (no pairing before it), never as EOL. Bite: malformed_utf8_pair_entries_are_rejected (four ill-formed shapes). Finding 2 (low): relevance and reporting resolve against the SOURCE buffer the record names, not whatever buffer a context-switching command left active. New pmacs.lsp.buffer_language(buf) is the parameterized primitive (active_buffer_language delegates), backed by a new buf:path() query on buffer handles. Bites: rust→python `'` now stays silent; python→rust `'` now reports "source context changed". Finding 3 (low): non-table set containers degrade language→default→empty instead of throwing from the after-edit callback on every keystroke. Bites: a string default pairs nothing with a clean *errors* buffer; a junk language entry falls back to the default set. Framing synced to revision 5 (Q#AP2 well-formedness + container degradation + source-buffer resolution, Q#AP3 predicate junk-byte posture, acceptance list). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- builtin/runtime/lsp.lua | 16 +++- builtin/runtime/pair.lua | 96 ++++++++++++++------- docs/auto-pairing-framing.md | 49 ++++++++--- src/lua_bindings/mod.rs | 14 ++++ tests/auto_pair_acceptance.rs | 153 ++++++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 44 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index b4f8474..928ee37 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -333,9 +333,9 @@ local function active_buffer_path() return pmacs.editor.file_path() end -local function active_buffer_language() - local path = active_buffer_path() - if not path then return nil end +local function buffer_language(buf) + local ok, path = pcall(function() return buf and buf:path() end) + if not ok or not path then return nil end -- Grammar-backed detection first (keeps rust/.rs etc. exactly as -- before); fall back to the LSP-only filetype map so languages -- with a server but no tree-sitter grammar (Python) still attach. @@ -344,6 +344,16 @@ local function active_buffer_language() local ext = path:match("%.([%w_]+)$") return ext and pmacs.lsp.filetypes[ext] or nil end +-- Public: the per-buffer language chain. Auto-pairing resolves +-- relevance against the buffer its typed-edit record names — which a +-- context-switching command may have left inactive by callback time — +-- so the parameterized form is the primitive and the active-buffer +-- form delegates. +pmacs.lsp.buffer_language = buffer_language + +local function active_buffer_language() + return buffer_language(pmacs.window.buffer()) +end -- Public: the comment-toggle module (and future language-aware Lua) -- reuses this grammar+filetypes chain instead of replicating it. pmacs.lsp.active_buffer_language = active_buffer_language diff --git a/builtin/runtime/pair.lua b/builtin/runtime/pair.lua index 231e23d..1fe0e4e 100644 --- a/builtin/runtime/pair.lua +++ b/builtin/runtime/pair.lua @@ -50,55 +50,85 @@ pmacs.pair.sets = { bash = { "()", "[]", "{}", '""', "''" }, } --- UTF-8 sequence length from a leading byte; nil on a continuation --- byte (not a codepoint boundary). -local function cp_len(b) - if b < 0x80 then return 1 end - if b < 0xC0 then return nil end - if b < 0xE0 then return 2 end - if b < 0xF0 then return 3 end +-- Length of the well-formed UTF-8 sequence starting at `s[i]`, or nil +-- for anything ill-formed (Unicode 15, Table 3-7): continuation-byte +-- shapes are checked on EVERY trailing byte, and the narrowed +-- second-byte ranges exclude overlong encodings (C0/C1 leads, +-- E0 80–9F, F0 80–8F), UTF-16 surrogates (ED A0–BF), and codepoints +-- beyond U+10FFFF (F5+ leads, F4 90+). Length-from-lead-byte alone +-- accepted "(\xC2x" as two "codepoints" (PR #110 round 2, finding 1). +local function utf8_seq_len(s, i) + local b1 = s:byte(i) + if not b1 then return nil end + if b1 < 0x80 then return 1 end + if b1 < 0xC2 or b1 > 0xF4 then return nil end + local b2 = s:byte(i + 1) + if not b2 or b2 < 0x80 or b2 > 0xBF then return nil end + if b1 < 0xE0 then return 2 end + if b1 == 0xE0 and b2 < 0xA0 then return nil end + if b1 == 0xED and b2 > 0x9F then return nil end + 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 b3 < 0x80 or b3 > 0xBF then return nil end + if b1 < 0xF0 then return 3 end + local b4 = s:byte(i + 3) + if not b4 or b4 < 0x80 or b4 > 0xBF then return nil end return 4 end --- The first full UTF-8 codepoint starting at byte `pos`, as a string, --- or nil at end-of-buffer / on a non-boundary byte. Forward twin of --- lsp.lua's `char_before`; reads at most 4 bytes. +-- The first full UTF-8 codepoint starting at byte `pos`, as a string; +-- nil at end-of-buffer. Bytes that do not begin a well-formed +-- sequence (malformed file content, a truncated sequence at EOF) +-- yield the single raw byte instead: it matches neither whitespace +-- nor any validated closer, so the predicate conservatively treats +-- junk like a word character — never like EOL, which nil would mean. local function char_at(buf, pos) local len = buf:len() if pos >= len then return nil end local to = math.min(pos + 4, len) local ok, s = pcall(function() return buf:slice(pos, to) end) if not ok or type(s) ~= "string" or #s == 0 then return nil end - local n = cp_len(s:byte(1)) - if not n or n > #s then return nil end + local n = utf8_seq_len(s, 1) + if not n or n > #s then return s:sub(1, 1) end return s:sub(1, n) end --- Split a pair entry into (opener, closer): EXACTLY two codepoints, --- no trailing bytes (PR #110 round 1, finding 3 — "()x" must be --- skipped entirely, never honored as `(` → `)x`). nil for malformed --- user additions: skipped, not errors — the hook must never throw --- over a config typo. +-- Split a pair entry into (opener, closer): EXACTLY two well-formed +-- UTF-8 codepoints, no trailing bytes (PR #110 round 1 finding 3 + +-- round 2 finding 1 — "()x" and "(\xC2x" must be skipped entirely, +-- never partially honored). nil for malformed user additions: +-- skipped, not errors — the hook must never throw over a config typo. local function split_pair(s) if type(s) ~= "string" or #s < 2 then return nil end - local n1 = cp_len(s:byte(1)) + local n1 = utf8_seq_len(s, 1) if not n1 or n1 >= #s then return nil end - local n2 = cp_len(s:byte(n1 + 1)) + local n2 = utf8_seq_len(s, n1 + 1) if not n2 or n1 + n2 ~= #s then return nil end return s:sub(1, n1), s:sub(n1 + 1) end --- The active buffer's pair set: language entry if the language is --- known and configured, else `default`. `pmacs.lsp` is looked up --- lazily and nil-guarded — this chunk loads before lsp.lua (Q#AP7), --- and language detection is an LSP-runtime service. -local function active_set() +-- The pair set for `buf`: its language's entry if configured, else +-- `default`. Language resolves against the buffer the typed-edit +-- record names — NOT the currently active buffer, which a +-- context-switching command may have replaced by callback time +-- (PR #110 round 2, finding 2). `pmacs.lsp` is looked up lazily and +-- nil-guarded — this chunk loads before lsp.lua (Q#AP7). Non-table +-- values anywhere (a config typo like `pmacs.pair.sets.default = +-- "()"`) degrade to the default set, then to empty — never a throw +-- from the after-edit callback (round 2, finding 3). +local function set_for(buf) local lang - if pmacs.lsp and pmacs.lsp.active_buffer_language then - local ok, l = pcall(pmacs.lsp.active_buffer_language) + if pmacs.lsp and pmacs.lsp.buffer_language then + local ok, l = pcall(pmacs.lsp.buffer_language, buf) if ok then lang = l end end - return (lang and pmacs.pair.sets[lang]) or pmacs.pair.sets.default + local sets = pmacs.pair.sets + if type(sets) ~= "table" then return {} end + local set = lang and sets[lang] + if type(set) ~= "table" then set = sets.default end + if type(set) ~= "table" then return {} end + return set end -- opener → closer, and the set of closer codepoints. @@ -172,11 +202,15 @@ pmacs.hook.add("buffer.after-edit", function() if not buf then return end -- Relevance first (PR #110 round 1, finding 2): pairing has no - -- interest in characters outside the active set, so a transformed - -- or relocated ordinary `a` must stay silent — the reports below - -- are for pair characters only. + -- interest in characters outside the set, so a transformed or + -- relocated ordinary `a` must stay silent — the reports below are + -- for pair characters only. The set is the SOURCE buffer's (round + -- 2, finding 2): `'` typed in Rust stays silent even when a + -- context-switching command lands in Python, and `'` typed in + -- Python still draws the context-change report when it lands in + -- Rust. local ch = rec.char - local openers, closers = maps_for(active_set()) + local openers, closers = maps_for(set_for(rec.buffer)) if not (openers[ch] or closers[ch]) then return end -- Fail closed on a transformed source self-insert (Q#AP3): the diff --git a/docs/auto-pairing-framing.md b/docs/auto-pairing-framing.md index 3e4a840..78375e2 100644 --- a/docs/auto-pairing-framing.md +++ b/docs/auto-pairing-framing.md @@ -36,6 +36,17 @@ is scoped as best-effort under the active-buffer edit-epoch limit — an equal-revision context switch skips the fan-out and fails closed silently. +Revision 5: PR #110 round 2 — set entries validate full UTF-8 +well-formedness (Table 3-7: continuation bytes, overlong encodings, +surrogates, beyond-U+10FFFF all disqualify; lead-byte length alone +had accepted `"(\xC2x"`), and the predicate treats malformed buffer +bytes as word-like (no pairing before junk), never as EOL; relevance +and reporting resolve against the SOURCE buffer's language via the +new `pmacs.lsp.buffer_language(buf)` / `buf:path()` (a +context-switching command no longer attributes them to the +destination buffer); and non-table set containers degrade +language→default→empty instead of throwing from the callback. + ## Ground truth (as of `7e127ab`) - **Dispatch is keymap-first for printables** — `Char('(')` resolves @@ -209,10 +220,17 @@ full fix deferred with the pre-existing mixed-history problem `pmacs.pair.sets` — the `pmacs.comment.strings` shape: language → array of pair strings, plus a `default` entry used when the language is unknown or has no entry (pairing is useful in scratch buffers). -An entry is EXACTLY two codepoints — opener then closer, multibyte -allowed (`"«»"`); malformed entries (trailing bytes, non-boundary -first byte) are skipped entirely, never partially honored (R4: a -`"()x"` typo must not turn `(` into `()x`): +An entry is EXACTLY two **well-formed** UTF-8 codepoints — opener +then closer, multibyte allowed (`"«»"`); malformed entries are +skipped entirely, never partially honored (R4: a `"()x"` typo must +not turn `(` into `()x`; R5: well-formedness per Unicode Table 3-7, +so truncated sequences, overlong encodings, surrogates, and +beyond-U+10FFFF closers like `"(\xC2x"` also disqualify). A +non-table container anywhere — a typo like `pmacs.pair.sets.default += "()"` — degrades language→default→empty rather than throwing from +the after-edit callback (R5). The set (and the language behind it) +always resolves against the buffer the typed-edit record names, via +`pmacs.lsp.buffer_language(buf)` (R5): - `default = { "()", "[]", "{}", '""' }` — no `'` (prose apostrophes), no backtick. @@ -252,13 +270,16 @@ React when ALL hold: it; a surviving nonempty region means the edit arrived through the TUI's selection-blind optimistic gate (custom chars only), where reacting would pile a closer onto an unconsumed region; -- the record's exact typed codepoint is an opener in the buffer's pair - set (language via `pmacs.lsp.active_buffer_language()`, resolved - **at callback time**, nil-guarded — pair.lua loads before lsp.lua, - Q#AP7). `char_before` is not input provenance; +- the record's exact typed codepoint is an opener in the SOURCE + buffer's pair set (language via `pmacs.lsp.buffer_language(buf)` on + the record's buffer, resolved **at callback time**, nil-guarded — + pair.lua loads before lsp.lua, Q#AP7; R5: the active buffer is the + wrong buffer whenever a context-switching command ran). + `char_before` is not input provenance; - **conservative predicate**: the char at the cursor is EOL, whitespace, or a closing bracket from the pair set — `foo|bar` + - `(` gives `(bar`, never `()bar`; + `(` gives `(bar`, never `()bar`. Malformed bytes at the cursor are + word-like (no pairing before junk), not EOL-like (R5); - for symmetric pairs (quotes), the skip check (Q#AP4) runs first. Reaction: one pcall'd `buf:insert(cursor, closer)`. Outcomes, @@ -464,7 +485,15 @@ facility is the only way a consumed record outlives its fan-out (R4). default set. - Set-entry parsing (R4): a malformed `"()x"` (and an overlong multibyte `"«»x"`) pairs nothing; a valid multibyte `"«»"` pairs - and skips at byte-correct cursors. + and skips at byte-correct cursors. Ill-formed UTF-8 closers (R5) — + truncated `"(\xC2x"`, overlong `"(\xC0\xAF"`, surrogate + `"(\xED\xA0\x80"`, beyond-U+10FFFF `"(\xF5\x80\x80\x80"` — all + pair nothing. Non-table containers (R5): a string `default` pairs + nothing without erroring; a junk language entry falls back to the + default set. +- Source-buffer relevance (R5): `'` typed in Rust with a + context-switching command landing in Python stays silent; the + inverse Python→Rust route still reports "source context changed". - Non-typed provenance, with the callback actually exercised: production `FrontendEvent::Paste("(")` after a prior self-insert leaves a lone pasted opener; `buf:insert("(")` followed by explicit diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index a72840f..01bf0d2 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1167,6 +1167,20 @@ fn add_query_methods>(methods: &mut M) { with_registry(lua, |r| Ok(resolve(r, this.0)?.name().to_owned())) }); + // Backing file path, or nil for pathless buffers (scratch, + // generated). The per-buffer twin of `pmacs.editor.file_path()`: + // consumers that hold a buffer handle from earlier in a hook + // fan-out (auto-pairing's typed-edit record) must resolve + // language/URIs against THAT buffer, not whatever is active by + // the time their callback runs. + methods.add_method("path", |lua, this, ()| { + with_registry(lua, |r| { + Ok(resolve(r, this.0)? + .file_path() + .map(|p| p.display().to_string())) + }) + }); + methods.add_method("is_modified", |lua, this, ()| { with_registry(lua, |r| Ok(resolve(r, this.0)?.is_modified())) }); diff --git a/tests/auto_pair_acceptance.rs b/tests/auto_pair_acceptance.rs index 1998b04..09fee39 100644 --- a/tests/auto_pair_acceptance.rs +++ b/tests/auto_pair_acceptance.rs @@ -281,6 +281,34 @@ fn malformed_pair_entries_are_skipped_not_partially_honored() { ); } +#[test] +fn malformed_utf8_pair_entries_are_rejected() { + // PR #110 round 2, finding 1: byte-length-from-lead-byte alone is + // not validation. Every ill-formed closer shape from Unicode + // Table 3-7 must disqualify the entry — never land in the buffer. + let cases = [ + // Truncated 2-byte sequence with a trailing ASCII byte: the + // lead byte "promises" 2 bytes, so lead-length parsing counts + // "\xC2x" as one codepoint. + ("string.char(0xC2) .. \"x\"", "truncated sequence"), + // Overlong encoding of `/` (C0 AF). + ("string.char(0xC0, 0xAF)", "overlong encoding"), + // UTF-16 surrogate D800 (ED A0 80). + ("string.char(0xED, 0xA0, 0x80)", "surrogate encoding"), + // Beyond U+10FFFF (F5 80 80 80). + ("string.char(0xF5, 0x80, 0x80, 0x80)", "beyond U+10FFFF"), + ]; + for (closer, what) in cases { + let mut s = editor_with(""); + exec( + &s, + &format!("pmacs.pair.sets.default = {{ \"(\" .. {closer} }}"), + ); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "(", "a {what} closer must pair nothing"); + } +} + #[test] fn multibyte_pair_entries_pair_and_skip() { // Two-codepoint entries with multibyte members are valid: guillemets. @@ -294,6 +322,41 @@ fn multibyte_pair_entries_pair_and_skip() { assert_eq!(cursor(&s), 4); } +#[test] +fn non_table_default_set_fails_closed_without_erroring() { + // PR #110 round 2, finding 3: a config typo assigning a STRING + // where the set table belongs must behave as an empty set — not + // throw from the after-edit callback on every keystroke. + let mut s = editor_with(""); + exec(&s, "pmacs.pair.sets.default = \"()\""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "(", "a non-table set pairs nothing"); + let log = s.lua_host.errors_buffer_text(); + assert!( + !log.contains("pair"), + "the pairing callback must not error over a config typo; *errors*:\n{log}" + ); +} + +#[test] +fn non_table_language_set_falls_back_to_default() { + // The language entry being junk falls back to `default` (the + // buffer still deserves pairing), and nothing throws. + let mut s = editor_visiting("a.rs", ""); + exec(&s, "pmacs.pair.sets.rust = 42"); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "()", + "a junk language entry falls back to the default set" + ); + let log = s.lua_host.errors_buffer_text(); + assert!( + !log.contains("pair"), + "the pairing callback must not error over a config typo; *errors*:\n{log}" + ); +} + #[test] fn scratch_buffer_pairs_the_default_set() { let mut s = editor_with(""); @@ -735,6 +798,96 @@ fn source_context_switch_fails_closed() { assert_eq!(other_text, "z", "the switched-to buffer is untouched"); } +#[test] +fn context_switch_relevance_is_the_source_buffers_rust_to_python_is_silent() { + // PR #110 round 2, finding 2: `'` typed in Rust is not a pair + // char THERE — that the context-switching command lands in a + // Python buffer (where `''` pairs) must not conjure an irrelevant + // "source context changed" report. Relevance and reporting are + // attributed to the buffer the record names. + let dir = fresh_state_dir(); + let mut s = editor(&dir); + let py = write_file(&dir, "b.py", ""); + let rs = write_file(&dir, "a.rs", ""); + exec(&s, &format!("pmacs.buffer.find_or_open({py:?})")); + exec(&s, "_G.py = pmacs.window.buffer()"); + exec(&s, &format!("pmacs.buffer.find_or_open({rs:?})")); + exec(&s, "_G.rs = pmacs.window.buffer()"); + // Revision skew so the fan-out runs after the switch (the + // buffer-aware edit epoch is a named substrate deferral). + type_str(&mut s, "xy"); + exec( + &s, + r#" + pmacs.command.unregister("buffer.self-insert") + pmacs.command.define { + name = "buffer.self-insert", + description = "test override: insert, then switch context", + fn = function(cp) + pmacs.editor.insert_char_over_region(cp) + pmacs.window.switch_buffer(_G.py) + end, + } + "#, + ); + type_str(&mut s, "'"); + assert!( + !status(&s).contains("auto-pair"), + "`'` is outside the SOURCE (rust) set; got: {:?}", + status(&s) + ); + let rs_text: String = eval(&s, "return _G.rs:slice(0, _G.rs:len())"); + assert_eq!( + rs_text, "xy'", + "the quote landed in the rust buffer, no pair" + ); + let py_text: String = eval(&s, "return _G.py:slice(0, _G.py:len())"); + assert_eq!(py_text, "", "the python buffer is untouched"); +} + +#[test] +fn context_switch_relevance_is_the_source_buffers_python_to_rust_reports() { + // The inverse route: `'` typed in Python IS a pair char there, so + // the context-change report must fire even though the destination + // (rust) set would have suppressed it under active-buffer lookup. + let dir = fresh_state_dir(); + let mut s = editor(&dir); + let rs = write_file(&dir, "a.rs", ""); + let py = write_file(&dir, "b.py", ""); + exec(&s, &format!("pmacs.buffer.find_or_open({rs:?})")); + exec(&s, "_G.rs = pmacs.window.buffer()"); + exec(&s, &format!("pmacs.buffer.find_or_open({py:?})")); + exec(&s, "_G.py = pmacs.window.buffer()"); + type_str(&mut s, "xy"); + exec( + &s, + r#" + pmacs.command.unregister("buffer.self-insert") + pmacs.command.define { + name = "buffer.self-insert", + description = "test override: insert, then switch context", + fn = function(cp) + pmacs.editor.insert_char_over_region(cp) + pmacs.window.switch_buffer(_G.rs) + end, + } + "#, + ); + type_str(&mut s, "'"); + assert!( + status(&s).contains("auto-pair skipped: source context changed"), + "`'` is in the SOURCE (python) set, so the report fires; got: {:?}", + status(&s) + ); + let py_text: String = eval(&s, "return _G.py:slice(0, _G.py:len())"); + assert_eq!( + py_text, "xy'", + "the quote landed in the python buffer, no pair" + ); + let rs_text: String = eval(&s, "return _G.rs:slice(0, _G.rs:len())"); + assert_eq!(rs_text, "", "the rust buffer is untouched"); +} + #[test] fn source_context_switch_with_equal_revisions_fails_closed_silently() { // PR #110 round 1, finding 5: the twin of the test above WITHOUT From 3085195794704f000f4f24a62791c1da5f5b8d75 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 12 Jul 2026 16:20:01 +0100 Subject: [PATCH 5/6] =?UTF-8?q?test(edit):=20PR=20#110=20round=203=20?= =?UTF-8?q?=E2=80=94=20pin=20raw-byte=20predicate=20posture=20and=20top-le?= =?UTF-8?q?vel=20sets=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage pins only, no code changes. Finding 1 (low): the predicate's raw-byte posture is now pinned from the buffer side — `(` typed immediately before a lone 0xFF inserts no closer. Verified non-vacuous: reverting char_at to nil-on-malformed makes the test fail (nil reads as end-of-buffer and pairs before the junk). Finding 2 (low): the top-level container guard is pinned alongside the per-entry cases — `pmacs.pair.sets = 42` pairs nothing and leaves *errors* clean. Framing synced to revision 6. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- docs/auto-pairing-framing.md | 15 +++++++++--- tests/auto_pair_acceptance.rs | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/auto-pairing-framing.md b/docs/auto-pairing-framing.md index 78375e2..4c89fe2 100644 --- a/docs/auto-pairing-framing.md +++ b/docs/auto-pairing-framing.md @@ -47,6 +47,13 @@ context-switching command no longer attributes them to the destination buffer); and non-table set containers degrade language→default→empty instead of throwing from the callback. +Revision 6: PR #110 round 3 — coverage pins only, no code changes: +the predicate's raw-byte posture is pinned from the buffer side +(`(` typed before a lone `0xFF` inserts no closer; a regression to +nil-on-malformed would read junk as end-of-buffer), and the +top-level non-table `pmacs.pair.sets` container is pinned alongside +the per-entry cases (pairs nothing, clean `*errors*`). + ## Ground truth (as of `7e127ab`) - **Dispatch is keymap-first for printables** — `Char('(')` resolves @@ -488,9 +495,11 @@ facility is the only way a consumed record outlives its fan-out (R4). and skips at byte-correct cursors. Ill-formed UTF-8 closers (R5) — truncated `"(\xC2x"`, overlong `"(\xC0\xAF"`, surrogate `"(\xED\xA0\x80"`, beyond-U+10FFFF `"(\xF5\x80\x80\x80"` — all - pair nothing. Non-table containers (R5): a string `default` pairs - nothing without erroring; a junk language entry falls back to the - default set. + pair nothing. Non-table containers (R5/R6): a string `default` and + a non-table `pmacs.pair.sets` itself pair nothing without erroring; + a junk language entry falls back to the default set. Malformed + BUFFER bytes (R6): `(` typed immediately before a lone `0xFF` + stays unpaired — junk is word-like, not EOL-like. - Source-buffer relevance (R5): `'` typed in Rust with a context-switching command landing in Python stays silent; the inverse Python→Rust route still reports "source context changed". diff --git a/tests/auto_pair_acceptance.rs b/tests/auto_pair_acceptance.rs index 09fee39..29291b3 100644 --- a/tests/auto_pair_acceptance.rs +++ b/tests/auto_pair_acceptance.rs @@ -322,6 +322,52 @@ fn multibyte_pair_entries_pair_and_skip() { assert_eq!(cursor(&s), 4); } +#[test] +fn opener_before_malformed_buffer_bytes_does_not_pair() { + // PR #110 round 3, finding 1: the predicate's raw-byte posture, + // pinned from the BUFFER side. A byte that begins no well-formed + // sequence (0xFF) sits after the cursor; `char_at` must surface + // it as a raw byte the predicate treats as word-like — a + // regression back to nil would read it as end-of-buffer and pair + // before the junk. + let mut s = editor_with(""); + exec(&s, "pmacs.window.buffer():insert(0, string.char(0xFF))"); + exec(&s, "pmacs.editor.goto_byte(0)"); + type_str(&mut s, "("); + let len: i64 = eval(&s, "return pmacs.window.buffer():len()"); + assert_eq!(len, 2, "opener + junk byte only — a closer would make 3"); + let shape_ok: bool = eval( + &s, + "local b = pmacs.window.buffer() \ + return b:slice(0, 1) == \"(\" and b:slice(1, 2):byte(1) == 0xFF", + ); + assert!( + shape_ok, + "the opener stands alone before the malformed byte" + ); + assert!( + !status(&s).contains("auto-pair"), + "declining the predicate is silent; got: {:?}", + status(&s) + ); +} + +#[test] +fn non_table_sets_container_fails_closed_without_erroring() { + // PR #110 round 3, finding 2: the TOP-LEVEL container guard, + // pinned. `set_for` degrades a non-table `pmacs.pair.sets` to the + // empty set; the callback must not throw. + let mut s = editor_with(""); + exec(&s, "pmacs.pair.sets = 42"); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "(", "a non-table container pairs nothing"); + let log = s.lua_host.errors_buffer_text(); + assert!( + !log.contains("pair"), + "the pairing callback must not error over a config typo; *errors*:\n{log}" + ); +} + #[test] fn non_table_default_set_fails_closed_without_erroring() { // PR #110 round 2, finding 3: a config typo assigning a STRING From 5a49d0de5d5329fdd7c6b66fad44de6c83e4bd05 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 12 Jul 2026 16:28:50 +0100 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20sync=20handoff=20=E2=80=94=20auto-p?= =?UTF-8?q?airing=20#110=20merged,=20Arc=202=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot: main @ 4174f3e, Arc 2 closed with #110 (framing revision 6, two pre-branch + three PR rounds). Next action is the compile-mode vs themes decision discussion — frame the tradeoff, don't pick. Pairing deferrals consolidated into §6; new substrate noted (buf:path, pmacs.lsp.buffer_language, PMACS_FAKE_LSP_CHANGE_SINK, TestDaemon::spawn_with_config). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- docs/agent-handoff.md | 68 ++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index dd2421b..12be69d 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,44 +1,48 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-11, on the laptop, by the auto-pairing -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 -taking on work, seed your persistent memory from it, and **update this -file (and commit it) whenever project state changes materially** — the -next machine reads it the way you just did. +**Last updated: 2026-07-12, on the laptop, by the auto-pairing +session (post-merge sync).** 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 taking on work, seed your persistent +memory from it, and **update this file (and commit it) whenever +project state changes materially** — the next machine reads it the +way you just did. -## 1. Where the project stands (2026-07-11) +## 1. Where the project stands (2026-07-12) -- `main` @ `7e127ab` (auto-indent #109 merged), protocol **v15** +- `main` @ `4174f3e` (auto-pairing #110 merged), protocol **v15** (`SUPPORTED=[6..15]`). -- **Auto-pairing (Arc 2, last item) in flight on this branch** — - framing `docs/auto-pairing-framing.md` at revision 3 (two - pre-branch review rounds). Shape: pair chars leave both frontends' - optimistic classifiers (dispatch-routed, daemon-peer undo units); - reaction hook in `builtin/runtime/pair.lua` loaded BEFORE lsp.lua; - exact one-shot typed-edit provenance via - `pmacs.editor.take_typed_edit()` (Q#AP9). When this merges, Arc 2 - closes. -- Auto-indent (#109) landed: RET binds `edit.newline-and-indent`; - plain Enter round-trips on both frontends. Rode along: shared - search invalidation across dispatch/notification/undo/redo (Q#AI8), - empty-selection clearing after successful inserts (Q#AI9). The - TUI's missing nonempty-selection optimistic type-over gate and - generated-buffer search invalidation remain named deferrals (§6). +- **Auto-pairing (#110) landed — Arc 2 is COMPLETE.** Framing + `docs/auto-pairing-framing.md` at revision 6 (two pre-branch rounds + + three PR rounds). Shape that shipped: the nine built-in pair + chars leave both frontends' optimistic classifiers + (`BUILTIN_PAIR_CHARS` in pmacs-protocol; dispatch-routed → + adjacent daemon-peer undo units); reaction hook + `builtin/runtime/pair.lua` loads BEFORE lsp.lua (first-didChange + ordering contract); exact one-shot typed-edit provenance via + `pmacs.editor.take_typed_edit()` with a buffer-revision + postcondition (Q#AP9). New substrate other code can use: + `buf:path()`, `pmacs.lsp.buffer_language(buf)`, + `PMACS_FAKE_LSP_CHANGE_SINK` (fake-LSP doc-sync replay), + `TestDaemon::spawn_with_config` (init.lua-carrying daemon fixture). +- **NEXT: the user wants a decision discussion — compile-mode (Arc 5 + stage 1) vs themes (Arc 4). Do not pick unilaterally; frame the + tradeoff and ask.** +- Auto-indent (#109) landed earlier: RET binds + `edit.newline-and-indent`; plain Enter round-trips on both + frontends; shared search invalidation (Q#AI8), empty-selection + clearing (Q#AI9). - 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 hardening follow-ups (#102, #105, #106). - - **Arc 2 (editing table stakes)** — query-replace (#97), kill ring - + `M-y` (#103/#105/#106), comment-toggle (#107), auto-indent - (#109), auto-pairing (this branch — the last item). + - **Arc 2 (editing table stakes) COMPLETE** — query-replace (#97), + kill ring + `M-y` (#103/#105/#106), comment-toggle (#107), + auto-indent (#109), auto-pairing (#110). - **Arc 3 (persistence) COMPLETE** — saveplace/recentf (#98), desktop-save (#99), autosave/crash-recovery (#100), save-clobber fix (#101). - - **After Arc 2 closes**: the user wants a decision discussion — - compile-mode (Arc 5 stage 1) vs themes (Arc 4). Do not pick - unilaterally; frame the tradeoff and ask. ## 2. How we work (the part that must not drift) @@ -171,7 +175,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. Pairing (framing "Deferred"): wrap-region on opener, +pair-aware backspace, RET-inside-pair closer-on-own-line, +in-string/in-comment inhibit (needs node-at-byte `pmacs.parse`), +undo amalgamation (pair = one step), balance-aware quotes, +per-buffer toggle (config-registry-blocked). 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