Merge remote-tracking branch 'githubsucks/main' into editops

# Conflicts:
#	docs/agent-handoff.md
This commit is contained in:
Levi Neuwirth 2026-07-12 16:33:51 +01:00
commit f0a07f41c5
16 changed files with 3364 additions and 91 deletions

View File

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

291
builtin/runtime/pair.lua Normal file
View File

@ -0,0 +1,291 @@
-- 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 = { "()", "[]", "{}", '""', "''" },
}
-- 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 809F, F0 808F), UTF-16 surrogates (ED A0BF), 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;
-- 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 = 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 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 = utf8_seq_len(s, 1)
if not n1 or n1 >= #s then return nil end
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 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.buffer_language then
local ok, l = pcall(pmacs.lsp.buffer_language, buf)
if ok then lang = l end
end
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.
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
-- 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, 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()
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 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(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
-- 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. 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
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 cursor = rec.post_cursor
-- 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)

View File

@ -1,54 +1,62 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-12, on the laptop, by the editops
session.** This file is the bridge between development machines. If you
are an agent reading this on a fresh clone: this document plus the
`docs/*-framing.md` files ARE your memory. Read this fully before
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 editops session
(merging the auto-pairing #110 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-10)
## 1. Where the project stands (2026-07-12)
- `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
TUI's missing nonempty-selection optimistic type-over gate and
generated-buffer search invalidation remain named deferrals.
- **Editing-conveniences pack (editops) in flight on branch
`editops`** — a Lua parallel lane beside the auto-pairing
close-out. Framing `docs/editing-conveniences-framing.md` at
revision 5 (three review rounds + one adopted post-approval
hardening; approved to branch 2026-07-12). goto-line, case ops,
transpose, zap-to-char (kill-chain member with an origin guard
and killring's new pending-prompt marker), line
move/duplicate/join, region sort/reverse/dedupe,
delete-trailing-whitespace + opt-in trim-on-save. killring.lua
gains `kill_range` / `break_chain([fid])` / the marker lifecycle.
On the laptop this branch lives in a git worktree at
`../pmacs-editops` (the main checkout was mid-flight on
auto-pair); fold the worktree back after merge.
- `main` @ `4174f3e` (auto-pairing #110 merged), protocol **v15**
(`SUPPORTED=[6..15]`).
- **Editing-conveniences pack (editops) in flight: PR #111** — the
Lua parallel lane, branch `editops`. Framing
`docs/editing-conveniences-framing.md` at revision 6 (three
pre-branch rounds, one adopted post-approval hardening, and PR
round 1: full UTF-8 scalar validation, per-word capitalize with
the `_`-constituent deviation named, trim-on-save dual-channel
error reporting). goto-line, case ops, transpose, zap-to-char
(kill-chain member with an origin guard and killring's new
pending-prompt marker), line move/duplicate/join, region
sort/reverse/dedupe, delete-trailing-whitespace + opt-in
trim-on-save. killring.lua gains `kill_range` /
`break_chain([fid])` / the marker lifecycle. On the laptop this
branch lives in a git worktree at `../pmacs-editops`; fold it
back after merge.
- **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 (this
branch). **Remaining after this merges: auto-pairing**, as its own
small framing + PR.
- **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)
@ -181,15 +189,24 @@ 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. Editops deferrals (full list in its framing):
recenter (blocked on viewport facts — the GPU never consumes daemon
`view_top`), Unicode case/word classes, region-spanning
move/duplicate, locale collation for sort-lines, zap chain semantics
through boundary-disturbing input, ensure-final-newline on save.
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). Editops deferrals (full
list in its framing): recenter (blocked on viewport facts — the GPU
never consumes daemon `view_top`), Unicode case/word classes,
region-spanning move/duplicate, locale collation for sort-lines,
ensure-final-newline on save.
Substrate: buffer-aware edit epoch (after-edit currently compares the
ACTIVE buffer only), wire provenance for CRDT self-insert
classification, Lua intercept probe, completion.lua still on the old
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).

View File

@ -0,0 +1,581 @@
# 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.
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.
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.
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
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 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 **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.
- `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);
- **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
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. 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
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 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`. 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,
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 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,
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 (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
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.
- Set-entry parsing (R4): a malformed `"()x"` (and an overlong
multibyte `"«»x"`) pairs nothing; a valid multibyte `"«»"` pairs
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/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".
- 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 (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,
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. 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);
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.

View File

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

View File

@ -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};

View File

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

View File

@ -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!({

View File

@ -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<char> {
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,47 @@ 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)
{
// 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 {
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,
revision,
},
);
}
// 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 +2236,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

View File

@ -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"),
@ -766,6 +781,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) {
@ -782,10 +805,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

View File

@ -142,6 +142,73 @@ pub struct CommandBoundary {
pub last: Option<String>,
}
/// 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,
/// 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
/// 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<TypedEditRecord>,
}
/// The world state mutated by editor commands.
pub struct EditorCore {
/// Shared buffer registry. The registry is the canonical owner
@ -270,6 +337,19 @@ pub struct EditorCore {
/// query-replace twin of `search`; drives the fifth dispatcher
/// shadow.
query_replace: Option<QueryReplaceSession>,
/// 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<TypedEditPending>,
/// 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 +393,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 +1236,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<u64, String> {
pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<Edit, String> {
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 +1282,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 +1826,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 +1870,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 +2208,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 +2305,130 @@ 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;
}
// 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,
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,
revision,
};
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.
///
/// 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<TypedEditRecord> {
let pending = self.typed_edit_pending.take()?;
if pending.fid != fid {
return None;
}
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`
/// 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<TypedEditRecord> {
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.

View File

@ -1167,6 +1167,20 @@ fn add_query_methods<M: UserDataMethods<BufferIdLua>>(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()))
});
@ -11011,6 +11025,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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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<u8>) {
match pmacs::transport::read_message::<pmacs::protocol::InstanceMessage>(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<F>(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<u64>,
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<P: Fn(&Observed) -> 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::<pmacs::protocol::InstanceMessage>(
&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::<pmacs::protocol::InstanceMessage>(&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 == ">"
});
}

View File

@ -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
/// `<tempdir>/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))