feat(edit): auto-pairing (Arc 2)
Typing an opener inserts the closer with the cursor between; typing a closer over its twin steps over it. Q#AP1: the nine built-in pair chars leave both optimistic classifiers (shared charset in pmacs-protocol) and round-trip through dispatch, so the opener and the hook's closer are adjacent daemon-peer undo units, dispatch CUA type-over applies, and skip never paints a transient duplicate. Q#AP9: exact one-shot typed-edit provenance. EditorCore's apply_active_edit now returns the effective Edit; the dispatch fallback arms a per-frontend record (codepoint + requested vs effective ranges + post-cursor + clean verdict) that insert primitives complete and the daemon's optimistic CRDT arm builds directly. The record is takeable exactly once via pmacs.editor.take_typed_edit() during the one after-edit fan-out, then cleared — paste, programmatic edits, manual hook runs, nested re-runs, rejected edits, and stale this_command all observe nil, and transformed / relocated / context-switched source self-inserts fail closed with a status. pair.lua (loaded BEFORE lsp.lua — ordering contract in editor.rs): per-language pmacs.pair.sets with a conservative default (no ' or `), EOL/whitespace/closer insertion predicate, reactive skip-over-close, rejected/transformed intercept outcomes with context-guarded translate-and-clamp cursor repair. Acceptance: 32 dispatch-driven cases (predicate, skip, per-language sets, non-typed provenance incl. production-shaped paste, type-over, undo/redo grain, intercept outcomes on both the source and reaction edits, context-switch probe, record lifecycle, frontend isolation) + first-didChange ordering against the fake LSP's sighelp mode via a new PMACS_FAKE_LSP_CHANGE_SINK replay file. Six two-replica CRDT cases pin dispatch-route convergence with cursor-between, undo/redo walking the pair on both replicas, both mixed-history undo models as named substrate limits, and the optimistic custom-char route (closer-broadcast-before-opener convergence, degraded cross-peer undo). TestDaemon gains spawn_with_config for init.lua-extended pair sets. Framing: docs/auto-pairing-framing.md (revision 3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
parent
ac8c46f2d3
commit
223e26420b
|
|
@ -0,0 +1,251 @@
|
|||
-- pair.lua --- auto-pairing (Arc 2).
|
||||
--
|
||||
-- Typing `(` gives `()` with the cursor between; typing `)` when the
|
||||
-- next char is already `)` steps over it instead of doubling it. The
|
||||
-- carrier is a `buffer.after-edit` reaction (Q#AP1): the opener stays
|
||||
-- a genuine single-codepoint self-insert — the classification
|
||||
-- signature help depends on — and this hook inserts (or swallows) the
|
||||
-- closer as a second edit. Provenance is the exact one-shot typed-edit
|
||||
-- record (`pmacs.editor.take_typed_edit()`, Q#AP9), not buffer-text
|
||||
-- inference: pastes, programmatic edits, manual hook runs, and a stale
|
||||
-- `this_command` have no record and never pair, and a transformed,
|
||||
-- relocated, or context-switching source self-insert fails closed.
|
||||
--
|
||||
-- This chunk loads BEFORE lsp.lua (Q#AP7): registration order is hook
|
||||
-- execution order, and lsp.lua's after-edit callback synchronously
|
||||
-- flushes didChange on the signature-trigger path — the closer must
|
||||
-- already be in the buffer when that callback runs. Everything under
|
||||
-- `pmacs.lsp` is therefore looked up lazily at callback time.
|
||||
--
|
||||
-- Framing: docs/auto-pairing-framing.md.
|
||||
|
||||
pmacs.pair = pmacs.pair or {}
|
||||
|
||||
local ed = pmacs.editor
|
||||
|
||||
-- Language → array of pair strings (opener codepoint followed by
|
||||
-- closer codepoint), plus the `default` entry used when the language
|
||||
-- is unknown or has no entry — pairing is useful in scratch buffers
|
||||
-- (Q#AP2). Public and user-extensible, like `pmacs.comment.strings`:
|
||||
-- pmacs.pair.sets.rust = { "()", "[]", "{}", '""', "''" }
|
||||
-- Conservative defaults: no `'` (prose apostrophes, Rust lifetimes,
|
||||
-- char literals), no backtick, outside the languages that want them.
|
||||
-- NOTE (Q#AP1): only the nine built-in chars `()[]{}"'` and backtick
|
||||
-- are excluded from the frontends' optimistic classifiers. A
|
||||
-- user-added pair char beyond those still pairs, but arrives
|
||||
-- optimistically: its opener is a source-peer op and the closer a
|
||||
-- daemon-peer op, so its undo is cross-peer-degraded (documented
|
||||
-- limitation; the general fix is chronological cross-peer undo
|
||||
-- arbitration, named substrate work).
|
||||
pmacs.pair.sets = {
|
||||
default = { "()", "[]", "{}", '""' },
|
||||
python = { "()", "[]", "{}", '""', "''" },
|
||||
lua = { "()", "[]", "{}", '""', "''" },
|
||||
javascript = { "()", "[]", "{}", '""', "''", "``" },
|
||||
typescript = { "()", "[]", "{}", '""', "''", "``" },
|
||||
javascriptreact = { "()", "[]", "{}", '""', "''", "``" },
|
||||
typescriptreact = { "()", "[]", "{}", '""', "''", "``" },
|
||||
markdown = { "()", "[]", "{}", '""', "``" },
|
||||
sh = { "()", "[]", "{}", '""', "''" },
|
||||
bash = { "()", "[]", "{}", '""', "''" },
|
||||
}
|
||||
|
||||
-- The first full UTF-8 codepoint starting at byte `pos`, as a string,
|
||||
-- or nil at end-of-buffer / on a non-boundary byte. Forward twin of
|
||||
-- lsp.lua's `char_before`; reads at most 4 bytes.
|
||||
local function char_at(buf, pos)
|
||||
local len = buf:len()
|
||||
if pos >= len then return nil end
|
||||
local to = math.min(pos + 4, len)
|
||||
local ok, s = pcall(function() return buf:slice(pos, to) end)
|
||||
if not ok or type(s) ~= "string" or #s == 0 then return nil end
|
||||
local b = s:byte(1)
|
||||
local n
|
||||
if b < 0x80 then
|
||||
n = 1
|
||||
elseif b < 0xC0 then
|
||||
return nil -- continuation byte: pos is not a codepoint boundary
|
||||
elseif b < 0xE0 then
|
||||
n = 2
|
||||
elseif b < 0xF0 then
|
||||
n = 3
|
||||
else
|
||||
n = 4
|
||||
end
|
||||
if n > #s then return nil end
|
||||
return s:sub(1, n)
|
||||
end
|
||||
|
||||
-- Split a pair entry into (opener, closer): the first codepoint and
|
||||
-- the rest. nil for entries that aren't two-or-more bytes of
|
||||
-- opener-then-closer (malformed user additions are skipped, not
|
||||
-- errors — the hook must never throw over a config typo).
|
||||
local function split_pair(s)
|
||||
if type(s) ~= "string" or #s < 2 then return nil end
|
||||
local b = s:byte(1)
|
||||
local n
|
||||
if b < 0x80 then
|
||||
n = 1
|
||||
elseif b < 0xC0 then
|
||||
return nil
|
||||
elseif b < 0xE0 then
|
||||
n = 2
|
||||
elseif b < 0xF0 then
|
||||
n = 3
|
||||
else
|
||||
n = 4
|
||||
end
|
||||
if n >= #s then return nil end
|
||||
return s:sub(1, n), s:sub(n + 1)
|
||||
end
|
||||
|
||||
-- The active buffer's pair set: language entry if the language is
|
||||
-- known and configured, else `default`. `pmacs.lsp` is looked up
|
||||
-- lazily and nil-guarded — this chunk loads before lsp.lua (Q#AP7),
|
||||
-- and language detection is an LSP-runtime service.
|
||||
local function active_set()
|
||||
local lang
|
||||
if pmacs.lsp and pmacs.lsp.active_buffer_language then
|
||||
local ok, l = pcall(pmacs.lsp.active_buffer_language)
|
||||
if ok then lang = l end
|
||||
end
|
||||
return (lang and pmacs.pair.sets[lang]) or pmacs.pair.sets.default
|
||||
end
|
||||
|
||||
-- opener → closer, and the set of closer codepoints.
|
||||
local function maps_for(set)
|
||||
local openers, closers = {}, {}
|
||||
for _, entry in ipairs(set) do
|
||||
local o, c = split_pair(entry)
|
||||
if o then
|
||||
openers[o] = c
|
||||
closers[c] = true
|
||||
end
|
||||
end
|
||||
return openers, closers
|
||||
end
|
||||
|
||||
-- Conservative insertion predicate (Q#AP3): pair only before
|
||||
-- end-of-buffer, end-of-line, whitespace, or a closing char from the
|
||||
-- active set — `foo|bar` + `(` gives `(bar`, never `()bar`.
|
||||
local function should_pair(buf, cursor, closers)
|
||||
local nxt = char_at(buf, cursor)
|
||||
if nxt == nil then return true end
|
||||
if nxt == "\n" or nxt == "\r" or nxt == " " or nxt == "\t" then return true end
|
||||
return closers[nxt] == true
|
||||
end
|
||||
|
||||
-- Right-gravity translation of `pos` through the effective edit —
|
||||
-- indent.lua's repair shape (Q#AP3/Q#AP4 transformed outcomes).
|
||||
local function translate(pos, estart, estop, einserted)
|
||||
if pos < estart then return pos end
|
||||
if pos > estop then return pos - (estop - estart) + einserted end
|
||||
return estart + einserted
|
||||
end
|
||||
|
||||
-- Context-guarded cursor repair after a TRANSFORMED reaction edit:
|
||||
-- the intercept's positional result stands (kind and payload are
|
||||
-- immutable; the edit has already landed), so translate the pre-edit
|
||||
-- cursor through the effective edit and clamp via goto_byte — unless
|
||||
-- the intercept switched window or buffer, in which case the new
|
||||
-- context is not ours to touch. The clean path deliberately performs
|
||||
-- NO cursor motion: a clean at-cursor closer insert must leave the
|
||||
-- cursor *before* the closer, which translation would not.
|
||||
local function repair_cursor(win0, buf0, cursor0, estart, estop, einserted)
|
||||
if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf0 then
|
||||
return
|
||||
end
|
||||
ed.goto_byte(translate(cursor0, estart, estop, einserted))
|
||||
end
|
||||
|
||||
pmacs.hook.add("buffer.after-edit", function()
|
||||
-- One-shot provenance (Q#AP9). Absence — paste, programmatic edit,
|
||||
-- manual hook run, rejected insert, stale `this_command` — is a
|
||||
-- silent non-event; only a live record that then fails a gate
|
||||
-- reports.
|
||||
local rec = ed.take_typed_edit and ed.take_typed_edit()
|
||||
-- Test seam (leading underscore = not stable API, like
|
||||
-- `pmacs.window._overlay_kinds`): the record this fan-out yielded,
|
||||
-- or nil. This callback registers first and consumes the one-shot
|
||||
-- record, so acceptance tests observe the exact codepoint /
|
||||
-- effective triple here — and prove one-shot-ness by taking again.
|
||||
pmacs.pair._last_record = rec
|
||||
if not rec then return end
|
||||
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end
|
||||
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
|
||||
-- Fail closed on a transformed source self-insert (Q#AP3): the
|
||||
-- intercept's positional result stands as produced; pairing on top
|
||||
-- of a relocated or expanded opener would compound it.
|
||||
if not rec.clean then
|
||||
ed.set_status("auto-pair skipped: source self-insert transformed")
|
||||
return
|
||||
end
|
||||
-- Fail closed when the source edit's context is no longer current:
|
||||
-- an intercept switched window/buffer, or something moved the
|
||||
-- cursor off the post-insert position.
|
||||
if buf ~= rec.buffer
|
||||
or pmacs.window.current() ~= rec.window
|
||||
or ed.cursor() ~= rec.post_cursor then
|
||||
ed.set_status("auto-pair skipped: source context changed")
|
||||
return
|
||||
end
|
||||
-- Region guard (Q#AP3/Q#AP6): on the dispatch route type-over has
|
||||
-- already consumed and cleared the region. A region surviving the
|
||||
-- edit means the TUI's selection-blind optimistic gate let a custom
|
||||
-- pair char through (named deferral) — reacting would pile a closer
|
||||
-- onto an unconsumed region.
|
||||
if ed.region() ~= nil then return end
|
||||
|
||||
local ch = rec.char
|
||||
local cursor = rec.post_cursor
|
||||
local openers, closers = maps_for(active_set())
|
||||
|
||||
-- Skip-over-close (Q#AP4), checked before insertion so symmetric
|
||||
-- pairs (quotes) step over their own closer: typing `)` at `(|)`
|
||||
-- swallows the freshly typed duplicate, net `()` with the cursor
|
||||
-- after — exactly Emacs's skip. The pair chars round-trip (Q#AP1),
|
||||
-- so no frontend ever painted the transient duplicate.
|
||||
if closers[ch] then
|
||||
local dup_ok, dup = pcall(function() return buf:slice(cursor, cursor + #ch) end)
|
||||
if dup_ok and dup == ch then
|
||||
local win0 = pmacs.window.current()
|
||||
local ok, estart, estop, einserted = pcall(function()
|
||||
return buf:delete(cursor, cursor + #ch)
|
||||
end)
|
||||
if not ok then
|
||||
-- The duplicate stays (e.g. `())`); report, no retry.
|
||||
ed.set_status("auto-pair skip rejected by buffer intercept")
|
||||
return
|
||||
end
|
||||
if estart ~= cursor or estop ~= cursor + #ch or einserted ~= 0 then
|
||||
ed.set_status("auto-pair skip altered by buffer intercept")
|
||||
repair_cursor(win0, buf, cursor, estart, estop, einserted)
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local closer = openers[ch]
|
||||
if not closer then return end
|
||||
if not should_pair(buf, cursor, closers) then return end
|
||||
|
||||
local win0 = pmacs.window.current()
|
||||
local ok, estart, estop, einserted = pcall(function()
|
||||
return buf:insert(cursor, closer)
|
||||
end)
|
||||
if not ok then
|
||||
-- Nothing landed; the opener stands alone.
|
||||
ed.set_status("auto-pair closer rejected by buffer intercept")
|
||||
return
|
||||
end
|
||||
if estart ~= cursor or estop ~= cursor or einserted ~= #closer then
|
||||
ed.set_status("auto-pair closer altered by buffer intercept")
|
||||
repair_cursor(win0, buf, cursor, estart, estop, einserted)
|
||||
end
|
||||
-- Clean path: no cursor motion — the insert landed at the cursor
|
||||
-- and Lua mutators move no cursors, so it already sits between the
|
||||
-- pair; the daemon's per-tick CursorByte re-grounds both frontends.
|
||||
end)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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,36 @@ fn handle_remote_crdt_op(
|
|||
}
|
||||
|
||||
core.notify_buffer_edit(buffer_id, edit);
|
||||
// Auto-pairing Q#AP9: arm the typed-edit record for the one
|
||||
// after-edit fan-out below — but only when the source's
|
||||
// active window actually displays the edited buffer, so
|
||||
// `post_cursor` (set to the optimistic post-edit position in
|
||||
// the window loop above) is that window's real cursor. A
|
||||
// synthetic replica editing a background buffer gets no
|
||||
// record: absence fails closed, silently.
|
||||
if let Some(ch) = typed_codepoint
|
||||
&& let Some(wid) = source_active_window_id
|
||||
&& core
|
||||
.windows
|
||||
.get(&wid)
|
||||
.is_some_and(|w| w.buffer_id == buffer_id)
|
||||
{
|
||||
core.typed_edit_set_armed(
|
||||
source,
|
||||
crate::editor_core::TypedEditRecord {
|
||||
buffer: buffer_id,
|
||||
window: wid,
|
||||
codepoint: ch,
|
||||
requested_start: edit.range.start,
|
||||
requested_end: edit.range.end,
|
||||
effective_start: edit.range.start,
|
||||
effective_end: edit.range.end,
|
||||
inserted_len: edit.inserted_len,
|
||||
post_cursor: post_edit_cursor,
|
||||
clean: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
// T M11.9 — temporarily switch active_frontend to source so
|
||||
// the `buffer.after-edit` hook's Lua observers (notably the
|
||||
// LSP `did_change` glue in `builtin/runtime/lsp.lua`) read
|
||||
|
|
@ -2167,6 +2225,9 @@ fn handle_remote_crdt_op(
|
|||
editor
|
||||
.lua_host
|
||||
.run_hook("buffer.after-edit", mlua::MultiValue::new());
|
||||
// Q#AP9: drop any untaken record the moment the fan-out
|
||||
// returns — the slot must never leak into a later hook run.
|
||||
editor.core.borrow_mut().typed_edit_clear_armed();
|
||||
}
|
||||
|
||||
// Effect 4: queue for broadcast. The source frontend's mirror
|
||||
|
|
|
|||
|
|
@ -283,6 +283,21 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/listview.lua"),
|
||||
)
|
||||
.expect("load listview builtin chunk");
|
||||
// Auto-pairing (Arc 2, Q#AP7) — ORDERING CONTRACT: pair.lua
|
||||
// must load BEFORE lsp.lua. Hook callbacks run in registration
|
||||
// order, and lsp.lua's `buffer.after-edit` callback flushes
|
||||
// didChange synchronously on the signature-trigger path — the
|
||||
// pairing closer must already be in the buffer when that
|
||||
// callback runs, or the server receives opener-only text and
|
||||
// the closer stays unsynchronized until the next edit (hook
|
||||
// edits don't re-fire the hook). pair.lua's `pmacs.lsp.*`
|
||||
// lookups are lazy and nil-guarded for the same reason.
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/pair.lua"),
|
||||
include_str!("../builtin/runtime/pair.lua"),
|
||||
)
|
||||
.expect("load pair builtin chunk");
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/lsp.lua"),
|
||||
|
|
@ -753,6 +768,14 @@ impl EditorState {
|
|||
self.core
|
||||
.borrow_mut()
|
||||
.rotate_command(frontend_id, "buffer.self-insert");
|
||||
// Auto-pairing Q#AP9: this dispatch is the typed
|
||||
// self-insert producer — arm the exact typed-edit
|
||||
// record so the after-edit fan-out below can
|
||||
// expose it. The insert primitive completes the
|
||||
// record with the effective (post-intercept) edit;
|
||||
// `typed_edit_finish` takes it back on every path
|
||||
// out of this dispatch.
|
||||
self.core.borrow_mut().typed_edit_arm(frontend_id, ch);
|
||||
let mut args = mlua::MultiValue::new();
|
||||
args.push_back(mlua::Value::Integer(ch as i64));
|
||||
if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) {
|
||||
|
|
@ -769,10 +792,25 @@ impl EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
// Auto-pairing Q#AP9: take back the typed-edit arm on every
|
||||
// path out of this dispatch — command error, rejected insert,
|
||||
// and the no-revision-change case all land here with either a
|
||||
// completed record or nothing. The record is armed for Lua
|
||||
// only across the one after-edit fan-out below and cleared
|
||||
// the moment it returns, so paste, later dispatches, and
|
||||
// manual hook runs can never observe a stale record.
|
||||
let typed_edit = self.core.borrow_mut().typed_edit_finish(frontend_id);
|
||||
|
||||
let post_revision = self.active_buffer_revision();
|
||||
if pre_revision != post_revision {
|
||||
if let Some(record) = typed_edit {
|
||||
self.core
|
||||
.borrow_mut()
|
||||
.typed_edit_set_armed(frontend_id, record);
|
||||
}
|
||||
self.lua_host
|
||||
.run_hook("buffer.after-edit", mlua::MultiValue::new());
|
||||
self.core.borrow_mut().typed_edit_clear_armed();
|
||||
}
|
||||
|
||||
// Q#C3 post-dispatch validation, deliberately AFTER the
|
||||
|
|
|
|||
|
|
@ -142,6 +142,64 @@ 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,
|
||||
}
|
||||
|
||||
/// 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 +328,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 +384,8 @@ impl EditorCore {
|
|||
completion_popup: crate::completion::make_shared_popup(),
|
||||
round_trip_buffers: std::collections::HashSet::new(),
|
||||
query_replace: None,
|
||||
typed_edit_pending: None,
|
||||
typed_edit_armed: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1154,12 +1227,15 @@ impl EditorCore {
|
|||
// ---- editing primitives ------------------------------------------------
|
||||
|
||||
/// Apply `op` to the active buffer; notify every window
|
||||
/// displaying that buffer. Returns the new buffer length.
|
||||
/// displaying that buffer. Returns the effective [`Edit`] — the
|
||||
/// post-intercept range and inserted length (auto-pairing Q#AP9
|
||||
/// needs the effective triple; every other caller reads
|
||||
/// `new_rope.len()` or discards it).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stringified error on buffer or view failure.
|
||||
pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<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 +1273,7 @@ impl EditorCore {
|
|||
// headline isearch bet — "stale-after-edit linger" — is
|
||||
// closed here.
|
||||
self.search_invalidate_for_edit(buffer_id, &edit);
|
||||
Ok(edit.new_rope.len())
|
||||
Ok(edit)
|
||||
}
|
||||
|
||||
/// Q#AI8 search invalidation for a landed edit: mark the buffer's
|
||||
|
|
@ -1741,11 +1817,26 @@ impl EditorCore {
|
|||
let s = ch.encode_utf8(&mut buf);
|
||||
let bytes = s.as_bytes();
|
||||
let pos = self.active_window().cursor;
|
||||
if let Err(e) = self.apply_active_edit(EditOp::Insert { pos, bytes }) {
|
||||
self.status = format!("insert failed: {e}");
|
||||
return false;
|
||||
}
|
||||
// Q#AP9: the buffer/window the request was made in, captured
|
||||
// BEFORE the edit — a legal intercept may switch the active
|
||||
// context mid-edit, and the record must name where the
|
||||
// self-insert actually landed, not where the intercept went.
|
||||
let (buffer_id, window_id) = (self.active_buffer_id(), self.active_window_id());
|
||||
let edit = match self.apply_active_edit(EditOp::Insert { pos, bytes }) {
|
||||
Ok(edit) => edit,
|
||||
Err(e) => {
|
||||
self.status = format!("insert failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
self.active_window_mut().cursor += bytes.len() as u64;
|
||||
self.typed_edit_complete(
|
||||
ch,
|
||||
(buffer_id, window_id),
|
||||
Range::new(pos, pos),
|
||||
bytes.len() as u64,
|
||||
&edit,
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -1770,16 +1861,29 @@ impl EditorCore {
|
|||
self.active_window_mut().goal_col = None;
|
||||
let mut buf = [0u8; 4];
|
||||
let bytes = ch.encode_utf8(&mut buf).as_bytes();
|
||||
if let Err(e) = self.apply_active_edit(EditOp::Replace {
|
||||
// Q#AP9: capture the request's context before the edit (see
|
||||
// the twin comment in [`Self::insert_char`]).
|
||||
let (buffer_id, window_id) = (self.active_buffer_id(), self.active_window_id());
|
||||
let edit = match self.apply_active_edit(EditOp::Replace {
|
||||
range: Range { start: lo, end: hi },
|
||||
bytes,
|
||||
}) {
|
||||
self.status = format!("replace failed: {e}");
|
||||
return;
|
||||
}
|
||||
Ok(edit) => edit,
|
||||
Err(e) => {
|
||||
self.status = format!("replace failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let aw = self.active_window_mut();
|
||||
aw.cursor = lo + bytes.len() as u64;
|
||||
aw.selection = None;
|
||||
self.typed_edit_complete(
|
||||
ch,
|
||||
(buffer_id, window_id),
|
||||
Range::new(lo, hi),
|
||||
bytes.len() as u64,
|
||||
&edit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete the codepoint immediately before the cursor.
|
||||
|
|
@ -2095,9 +2199,12 @@ impl EditorCore {
|
|||
let Some((lo, hi)) = self.active_region() else {
|
||||
return Ok(self.active_buffer_len());
|
||||
};
|
||||
let new_len = self.apply_active_edit(EditOp::Delete {
|
||||
range: Range { start: lo, end: hi },
|
||||
})?;
|
||||
let new_len = self
|
||||
.apply_active_edit(EditOp::Delete {
|
||||
range: Range { start: lo, end: hi },
|
||||
})?
|
||||
.new_rope
|
||||
.len();
|
||||
let aw = self.active_window_mut();
|
||||
aw.cursor = lo;
|
||||
aw.selection = None;
|
||||
|
|
@ -2189,6 +2296,100 @@ impl EditorCore {
|
|||
.as_deref()
|
||||
}
|
||||
|
||||
// ---- typed-edit provenance (auto-pairing, Q#AP9) ---------------------
|
||||
|
||||
/// Declare that `fid`'s dispatch is about to invoke
|
||||
/// `buffer.self-insert` for `codepoint`: the next insert primitive
|
||||
/// whose character matches completes the [`TypedEditRecord`].
|
||||
/// Called by the dispatch fallback only — programmatic
|
||||
/// `pmacs.command.invoke("buffer.self-insert")` deliberately never
|
||||
/// arms, so a hook run after it observes no record.
|
||||
pub fn typed_edit_arm(&mut self, fid: FrontendId, codepoint: char) {
|
||||
self.typed_edit_pending = Some(TypedEditPending {
|
||||
fid,
|
||||
codepoint,
|
||||
record: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Complete the pending typed-edit record from the effective edit,
|
||||
/// if one is armed for this character and hasn't completed yet.
|
||||
/// First match wins: a command body that somehow self-inserts the
|
||||
/// same character twice records the first landing (the one the
|
||||
/// dispatcher's keystroke produced). `context` is the caller's
|
||||
/// pre-edit `(buffer, window)` — the buffer the edit landed in
|
||||
/// even when an intercept switched the active context mid-edit.
|
||||
fn typed_edit_complete(
|
||||
&mut self,
|
||||
ch: char,
|
||||
context: (BufferId, WindowId),
|
||||
requested: Range,
|
||||
requested_len: u64,
|
||||
edit: &Edit,
|
||||
) {
|
||||
let matches = self.typed_edit_pending.as_ref().is_some_and(|p| {
|
||||
p.record.is_none() && p.codepoint == ch && p.fid == self.active_frontend
|
||||
});
|
||||
if !matches {
|
||||
return;
|
||||
}
|
||||
let clean = edit.range == requested && edit.inserted_len == requested_len;
|
||||
let record = TypedEditRecord {
|
||||
buffer: context.0,
|
||||
window: context.1,
|
||||
codepoint: ch,
|
||||
requested_start: requested.start,
|
||||
requested_end: requested.end,
|
||||
effective_start: edit.range.start,
|
||||
effective_end: edit.range.end,
|
||||
inserted_len: edit.inserted_len,
|
||||
post_cursor: self.active_window().cursor,
|
||||
clean,
|
||||
};
|
||||
if let Some(p) = self.typed_edit_pending.as_mut() {
|
||||
p.record = Some(record);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take back the pending arm at the end of `fid`'s dispatch,
|
||||
/// yielding the completed record (or `None` if the self-insert
|
||||
/// never landed — rejected edit, command error). Always clears the
|
||||
/// pending state: an arm never survives its dispatch cycle.
|
||||
pub fn typed_edit_finish(&mut self, fid: FrontendId) -> Option<TypedEditRecord> {
|
||||
let pending = self.typed_edit_pending.take()?;
|
||||
if pending.fid != fid {
|
||||
return None;
|
||||
}
|
||||
pending.record
|
||||
}
|
||||
|
||||
/// Arm `record` for consumption during the `buffer.after-edit`
|
||||
/// fan-out the caller is about to run. The caller MUST clear the
|
||||
/// slot when the fan-out returns ([`Self::typed_edit_clear_armed`]),
|
||||
/// error paths included — the record must never outlive its hook.
|
||||
pub fn typed_edit_set_armed(&mut self, fid: FrontendId, record: TypedEditRecord) {
|
||||
self.typed_edit_armed = Some((fid, record));
|
||||
}
|
||||
|
||||
/// Drop any untaken armed record. Producers call this immediately
|
||||
/// after their `buffer.after-edit` fan-out returns.
|
||||
pub fn typed_edit_clear_armed(&mut self) {
|
||||
self.typed_edit_armed = None;
|
||||
}
|
||||
|
||||
/// One-shot consume of the armed typed-edit record, per frontend:
|
||||
/// yields the record iff one is armed for the *active* frontend,
|
||||
/// clearing the slot. Second and later takes — including from a
|
||||
/// nested manual `pmacs.hook.run("buffer.after-edit")` — observe
|
||||
/// `None`, as does any context where no producer armed a record
|
||||
/// (paste, programmatic mutation, standalone manual hook runs).
|
||||
pub fn take_typed_edit(&mut self) -> Option<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.
|
||||
|
|
|
|||
|
|
@ -11011,6 +11011,44 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
|
|||
lua.create_function(move |_, ()| Ok(cc.borrow().this_command().map(str::to_owned)))?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
// take_typed_edit(): auto-pairing Q#AP9 — the one-shot exact
|
||||
// provenance record of the self-insert that produced the
|
||||
// current `buffer.after-edit` fan-out, or nil. Where
|
||||
// `this_command()` names only the input class, this record
|
||||
// carries the typed codepoint and the requested vs effective
|
||||
// (post-intercept) edit, so a consumer can fail closed on a
|
||||
// transformed, relocated, or context-switched source edit.
|
||||
// Consuming clears the slot: later callbacks and nested manual
|
||||
// hook runs see nil, and the producer clears any untaken
|
||||
// record when the fan-out returns. Per-frontend — one
|
||||
// frontend can never take another's record. `char` is the
|
||||
// codepoint as a UTF-8 string (LuaJIT has no `utf8` library
|
||||
// to convert `codepoint` Lua-side).
|
||||
let cc = core.clone();
|
||||
editor.set(
|
||||
"take_typed_edit",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let Some(rec) = cc.borrow_mut().take_typed_edit() else {
|
||||
return Ok(mlua::Value::Nil);
|
||||
};
|
||||
let cvt = |v: u64| i64::try_from(v).map_err(mlua::Error::external);
|
||||
let t = lua.create_table()?;
|
||||
t.set("buffer", BufferIdLua(rec.buffer))?;
|
||||
t.set("window", cvt(rec.window.raw())?)?;
|
||||
t.set("codepoint", i64::from(u32::from(rec.codepoint)))?;
|
||||
t.set("char", rec.codepoint.to_string())?;
|
||||
t.set("requested_start", cvt(rec.requested_start)?)?;
|
||||
t.set("requested_end", cvt(rec.requested_end)?)?;
|
||||
t.set("effective_start", cvt(rec.effective_start)?)?;
|
||||
t.set("effective_end", cvt(rec.effective_end)?)?;
|
||||
t.set("inserted_len", cvt(rec.inserted_len)?)?;
|
||||
t.set("post_cursor", cvt(rec.post_cursor)?)?;
|
||||
t.set("clean", rec.clean)?;
|
||||
Ok(mlua::Value::Table(t))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
// view_top(): the active window's first visible source line.
|
||||
// The saveplace getter (Arc 3) — pairs with set_view_top so a
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 == ">"
|
||||
});
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in New Issue