pmacs/tests/auto_pair_acceptance.rs

1388 lines
46 KiB
Rust

//! Auto-pairing acceptance (Arc 2, docs/auto-pairing-framing.md).
//!
//! Dispatch-driven: pair chars round-trip through `dispatch_key`
//! (Q#AP1 removed them from both optimistic classifiers, so this IS
//! the production path for both frontends). Scratch-buffer tests cover
//! the default pair set; per-language tests visit file-backed buffers
//! with an emptied `pmacs.lsp.config` (language DETECTION must work,
//! server SPAWNING must not); the Q#AP7/Q#AP8 ordering tests drive the
//! real fake-LSP `sighelp` mode and replay the exact document-sync
//! sequence the server received via `PMACS_FAKE_LSP_CHANGE_SINK`.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::StateDir;
use pmacs::protocol::FrontendId;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
fn fresh_state_dir() -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"pmacs-autopair-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn editor(state_dir: &std::path::Path) -> EditorState {
let s = EditorState::new_with_roots(&crate::iso::roots());
s.lua_host.lua().remove_app_data::<StateDir>();
s.lua_host
.lua()
.set_app_data(StateDir(state_dir.to_path_buf()));
// Language DETECTION must work (filetypes/grammars); server
// SPAWNING must not (rust/python have default configs).
exec(&s, "pmacs.lsp.config = {}");
s
}
fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
p.display().to_string()
}
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn ctrl(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(c), KeyModifiers::CONTROL),
);
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
/// `C-x u` — the always-dispatched undo (daemon-peer history).
fn undo(s: &mut EditorState) {
ctrl(s, 'x');
press(s, KeyCode::Char('u'));
}
/// `C-x r` — redo.
fn redo(s: &mut EditorState) {
ctrl(s, 'x');
press(s, KeyCode::Char('r'));
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn buffer_text(s: &EditorState) -> String {
let b: mlua::String = eval(
s,
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
fn cursor(s: &EditorState) -> i64 {
eval(s, "return pmacs.editor.cursor()")
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// Fresh scratch-buffer editor whose buffer holds `body`, cursor at 0.
/// No state dir / no files: scratch pairing uses the `default` set.
fn editor_with(body: &str) -> EditorState {
let s = EditorState::new_with_roots(&crate::iso::roots());
if !body.is_empty() {
exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})"));
}
exec(&s, "pmacs.editor.goto_byte(0)");
s
}
/// Fresh editor visiting `name` (created in a private tempdir) with
/// `body` on disk, cursor at 0, `pmacs.lsp.config` emptied.
fn editor_visiting(name: &str, body: &str) -> EditorState {
let dir = fresh_state_dir();
let s = editor(&dir);
let f = write_file(&dir, name, body);
exec(&s, &format!("pmacs.buffer.find_or_open({f:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
s
}
// ---------------------------------------------------------------------------
// Insert-pair semantics (Q#AP3): the conservative predicate
// ---------------------------------------------------------------------------
#[test]
fn opener_at_end_of_buffer_pairs_with_cursor_between() {
let mut s = editor_with("");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "()");
assert_eq!(cursor(&s), 1, "cursor sits between the pair");
}
#[test]
fn shifted_opener_pairs_too() {
// Real keyboards produce `(` as Shift+9: the chord arrives as
// `Char('(')` with SHIFT set and must still self-insert + pair.
let mut s = editor_with("");
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('('), KeyModifiers::SHIFT),
);
assert_eq!(buffer_text(&s), "()");
assert_eq!(cursor(&s), 1);
}
#[test]
fn opener_at_end_of_line_pairs() {
let mut s = editor_with("x\ny");
exec(&s, "pmacs.editor.goto_byte(1)");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "x()\ny");
assert_eq!(cursor(&s), 2);
}
#[test]
fn opener_before_whitespace_pairs() {
let mut s = editor_with("foo bar");
exec(&s, "pmacs.editor.goto_byte(3)");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "foo() bar");
assert_eq!(cursor(&s), 4);
}
#[test]
fn opener_before_closing_bracket_pairs() {
let mut s = editor_with("()");
exec(&s, "pmacs.editor.goto_byte(1)");
type_str(&mut s, "[");
assert_eq!(buffer_text(&s), "([])");
assert_eq!(cursor(&s), 2);
}
#[test]
fn opener_before_word_char_does_not_pair() {
let mut s = editor_with("bar");
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"(bar",
"`foo|bar` + `(` gives `(bar`, never `()bar`"
);
assert_eq!(cursor(&s), 1);
}
// ---------------------------------------------------------------------------
// Skip-over-close (Q#AP4)
// ---------------------------------------------------------------------------
#[test]
fn closer_skips_over_existing_closer() {
let mut s = editor_with("");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "()");
type_str(&mut s, ")");
assert_eq!(
buffer_text(&s),
"()",
"the typed `)` steps over, not doubles"
);
assert_eq!(cursor(&s), 2, "cursor lands after the closer");
}
#[test]
fn nested_closers_skip_outward() {
let mut s = editor_with("");
type_str(&mut s, "((");
assert_eq!(buffer_text(&s), "(())");
assert_eq!(cursor(&s), 2);
type_str(&mut s, "))");
assert_eq!(buffer_text(&s), "(())", "both closers skip");
assert_eq!(cursor(&s), 4);
}
#[test]
fn quote_pairs_then_second_quote_exits() {
let mut s = editor_with("");
type_str(&mut s, "\"");
assert_eq!(buffer_text(&s), "\"\"", "symmetric pair inserts");
assert_eq!(cursor(&s), 1);
type_str(&mut s, "\"");
assert_eq!(
buffer_text(&s),
"\"\"",
"second quote skips (exits the string)"
);
assert_eq!(cursor(&s), 2);
}
// ---------------------------------------------------------------------------
// Pair sets (Q#AP2): per-language, conservative default
// ---------------------------------------------------------------------------
#[test]
fn single_quote_pairs_in_python_but_not_rust() {
let mut py = editor_visiting("a.py", "");
type_str(&mut py, "'");
assert_eq!(buffer_text(&py), "''", "python's set adds `''`");
let mut rs = editor_visiting("a.rs", "");
type_str(&mut rs, "'");
assert_eq!(
buffer_text(&rs),
"'",
"rust keeps the default set: `'` is a lifetime, not a pair"
);
}
#[test]
fn malformed_pair_entries_are_skipped_not_partially_honored() {
// PR #110 round 1, finding 3: an entry is exactly two codepoints.
// "()x" must be ignored entirely — never "type `(`, get `)x`".
let mut s = editor_with("");
exec(&s, "pmacs.pair.sets.default = { \"()x\" }");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "(", "a malformed entry pairs nothing");
assert_eq!(cursor(&s), 1);
// Overlong multibyte entry: same rule after a 2-byte opener.
let mut s2 = editor_with("");
exec(&s2, "pmacs.pair.sets.default = { \"\u{ab}\u{bb}x\" }"); // "«»x"
type_str(&mut s2, "\u{ab}");
assert_eq!(
buffer_text(&s2),
"\u{ab}",
"an overlong entry pairs nothing"
);
}
#[test]
fn malformed_utf8_pair_entries_are_rejected() {
// PR #110 round 2, finding 1: byte-length-from-lead-byte alone is
// not validation. Every ill-formed closer shape from Unicode
// Table 3-7 must disqualify the entry — never land in the buffer.
let cases = [
// Truncated 2-byte sequence with a trailing ASCII byte: the
// lead byte "promises" 2 bytes, so lead-length parsing counts
// "\xC2x" as one codepoint.
("string.char(0xC2) .. \"x\"", "truncated sequence"),
// Overlong encoding of `/` (C0 AF).
("string.char(0xC0, 0xAF)", "overlong encoding"),
// UTF-16 surrogate D800 (ED A0 80).
("string.char(0xED, 0xA0, 0x80)", "surrogate encoding"),
// Beyond U+10FFFF (F5 80 80 80).
("string.char(0xF5, 0x80, 0x80, 0x80)", "beyond U+10FFFF"),
];
for (closer, what) in cases {
let mut s = editor_with("");
exec(
&s,
&format!("pmacs.pair.sets.default = {{ \"(\" .. {closer} }}"),
);
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "(", "a {what} closer must pair nothing");
}
}
#[test]
fn multibyte_pair_entries_pair_and_skip() {
// Two-codepoint entries with multibyte members are valid: guillemets.
let mut s = editor_with("");
exec(&s, "pmacs.pair.sets.default = { \"\u{ab}\u{bb}\" }"); // "«»"
type_str(&mut s, "\u{ab}");
assert_eq!(buffer_text(&s), "\u{ab}\u{bb}");
assert_eq!(cursor(&s), 2, "cursor between the pair (byte offset)");
type_str(&mut s, "\u{bb}");
assert_eq!(buffer_text(&s), "\u{ab}\u{bb}", "the closer skips");
assert_eq!(cursor(&s), 4);
}
#[test]
fn opener_before_malformed_buffer_bytes_does_not_pair() {
// PR #110 round 3, finding 1: the predicate's raw-byte posture,
// pinned from the BUFFER side. A byte that begins no well-formed
// sequence (0xFF) sits after the cursor; `char_at` must surface
// it as a raw byte the predicate treats as word-like — a
// regression back to nil would read it as end-of-buffer and pair
// before the junk.
let mut s = editor_with("");
exec(&s, "pmacs.window.buffer():insert(0, string.char(0xFF))");
exec(&s, "pmacs.editor.goto_byte(0)");
type_str(&mut s, "(");
let len: i64 = eval(&s, "return pmacs.window.buffer():len()");
assert_eq!(len, 2, "opener + junk byte only — a closer would make 3");
let shape_ok: bool = eval(
&s,
"local b = pmacs.window.buffer() \
return b:slice(0, 1) == \"(\" and b:slice(1, 2):byte(1) == 0xFF",
);
assert!(
shape_ok,
"the opener stands alone before the malformed byte"
);
assert!(
!status(&s).contains("auto-pair"),
"declining the predicate is silent; got: {:?}",
status(&s)
);
}
#[test]
fn non_table_sets_container_fails_closed_without_erroring() {
// PR #110 round 3, finding 2: the TOP-LEVEL container guard,
// pinned. `set_for` degrades a non-table `pmacs.pair.sets` to the
// empty set; the callback must not throw.
let mut s = editor_with("");
exec(&s, "pmacs.pair.sets = 42");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "(", "a non-table container pairs nothing");
let log = s.lua_host.errors_buffer_text();
assert!(
!log.contains("pair"),
"the pairing callback must not error over a config typo; *errors*:\n{log}"
);
}
#[test]
fn non_table_default_set_fails_closed_without_erroring() {
// PR #110 round 2, finding 3: a config typo assigning a STRING
// where the set table belongs must behave as an empty set — not
// throw from the after-edit callback on every keystroke.
let mut s = editor_with("");
exec(&s, "pmacs.pair.sets.default = \"()\"");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "(", "a non-table set pairs nothing");
let log = s.lua_host.errors_buffer_text();
assert!(
!log.contains("pair"),
"the pairing callback must not error over a config typo; *errors*:\n{log}"
);
}
#[test]
fn non_table_language_set_falls_back_to_default() {
// The language entry being junk falls back to `default` (the
// buffer still deserves pairing), and nothing throws.
let mut s = editor_visiting("a.rs", "");
exec(&s, "pmacs.pair.sets.rust = 42");
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"()",
"a junk language entry falls back to the default set"
);
let log = s.lua_host.errors_buffer_text();
assert!(
!log.contains("pair"),
"the pairing callback must not error over a config typo; *errors*:\n{log}"
);
}
#[test]
fn scratch_buffer_pairs_the_default_set() {
let mut s = editor_with("");
type_str(&mut s, "{");
assert_eq!(
buffer_text(&s),
"{}",
"language-less buffers pair the default set"
);
let mut s2 = editor_with("");
type_str(&mut s2, "'");
assert_eq!(
buffer_text(&s2),
"'",
"no apostrophe pairing in the default set"
);
let mut s3 = editor_with("");
type_str(&mut s3, "`");
assert_eq!(
buffer_text(&s3),
"`",
"no backtick pairing in the default set"
);
}
// ---------------------------------------------------------------------------
// Non-typed provenance (Q#AP9): no record, no reaction — with the
// after-edit callback actually exercised in every case.
// ---------------------------------------------------------------------------
#[test]
fn paste_of_opener_does_not_pair() {
let mut s = editor_with("");
exec(&s, "pmacs.pair._capture_records = true");
// A prior self-insert, so a heuristic keyed only on buffer text or
// `char_before` would be primed to misfire. (It also proves the
// capture seam live: the nil assertion below is a transition from
// this keystroke's captured record, not an unset field.)
type_str(&mut s, "a");
let primed: bool = eval(&s, "return pmacs.pair._last_record ~= nil");
assert!(primed, "the typed `a` captured a record");
// The daemon's unified inbound-paste route, faithfully: break the
// source's command chain, insert, fire the after-edit hook
// (`handle_inbound_paste` + `with_after_edit_check`).
s.core.borrow_mut().break_command_chain(FrontendId::LOCAL);
s.core.borrow_mut().paste_inbound(b"(").unwrap();
s.lua_host
.run_hook("buffer.after-edit", mlua::MultiValue::new());
assert_eq!(buffer_text(&s), "a(", "a pasted opener stays lone");
let record_nil: bool = eval(&s, "return pmacs.pair._last_record == nil");
assert!(record_nil, "paste must arm no typed-edit record");
}
#[test]
fn programmatic_insert_with_stale_this_command_does_not_pair() {
let mut s = editor_with("");
exec(&s, "pmacs.pair._capture_records = true");
// Type 'a' so `this_command` is (and stays) "buffer.self-insert" —
// the deliberately stale signal the provenance gate must ignore.
type_str(&mut s, "a");
let stale: String = eval(&s, "return pmacs.editor.this_command()");
assert_eq!(stale, "buffer.self-insert");
exec(&s, "pmacs.window.buffer():insert(1, \"(\")");
exec(&s, "pmacs.hook.run(\"buffer.after-edit\")");
assert_eq!(
buffer_text(&s),
"a(",
"programmatic insert + manual hook run must not pair, even with \
this_command still reading buffer.self-insert"
);
let record_nil: bool = eval(&s, "return pmacs.pair._last_record == nil");
assert!(record_nil, "manual hook run must observe no record");
}
#[test]
fn command_invoke_self_insert_does_not_pair() {
let s = editor_with("");
exec(&s, "pmacs.pair._capture_records = true");
// Plain `pmacs.command.invoke` is the programmatic API: it stamps
// no boundary and arms no record.
exec(&s, "pmacs.command.invoke(\"buffer.self-insert\", 40)"); // '('
exec(&s, "pmacs.hook.run(\"buffer.after-edit\")");
assert_eq!(buffer_text(&s), "(", "invoked self-insert stays lone");
let record_nil: bool = eval(&s, "return pmacs.pair._last_record == nil");
assert!(record_nil);
}
// ---------------------------------------------------------------------------
// Type-over composition (Q#AP6, dispatch route)
// ---------------------------------------------------------------------------
#[test]
fn opener_over_region_type_overs_then_pairs() {
let mut s = editor_with("abc");
// Shift+Right x3: region [0, 3).
for _ in 0..3 {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT));
}
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"()",
"the region is consumed by type-over, then the predicate pairs at EOB"
);
assert_eq!(cursor(&s), 1);
let region_nil: bool = eval(&s, "return pmacs.editor.region() == nil");
assert!(region_nil, "selection cleared");
}
// ---------------------------------------------------------------------------
// Undo grain (Q#AP5, daemon history in the non-replica harness)
// ---------------------------------------------------------------------------
#[test]
fn pair_is_two_adjacent_undo_steps_and_two_redo_steps() {
let mut s = editor_with("");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "()");
undo(&mut s);
assert_eq!(
buffer_text(&s),
"(",
"first undo removes the reaction closer"
);
undo(&mut s);
assert_eq!(buffer_text(&s), "", "second undo removes the typed opener");
redo(&mut s);
assert_eq!(buffer_text(&s), "(", "first redo restores the opener");
redo(&mut s);
assert_eq!(buffer_text(&s), "()", "second redo restores the closer");
}
#[test]
fn skip_undo_restores_the_swallowed_duplicate() {
let mut s = editor_with("");
type_str(&mut s, "()");
assert_eq!(buffer_text(&s), "()");
undo(&mut s);
assert_eq!(
buffer_text(&s),
"())",
"undoing the skip's delete restores the typed duplicate"
);
}
// ---------------------------------------------------------------------------
// Reaction intercept outcomes (Q#AP3/Q#AP4): rejected vs transformed
// ---------------------------------------------------------------------------
#[test]
fn rejected_closer_leaves_opener_alone_and_reports() {
let mut s = editor_with("");
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "insert" and op.bytes == ")" then
error("rejected by test intercept")
end
return nil
end)
"#,
);
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"(",
"nothing landed; the opener stands alone"
);
assert_eq!(cursor(&s), 1);
assert!(
status(&s).contains("auto-pair closer rejected"),
"got: {:?}",
status(&s)
);
}
#[test]
fn relocated_closer_lands_where_the_intercept_put_it_cursor_translated() {
let mut s = editor_with("ab");
exec(&s, "pmacs.editor.goto_byte(2)");
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "insert" and op.bytes == ")" then
return { kind = "insert", pos = 0, bytes = op.bytes }
end
return nil
end)
"#,
);
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
")ab(",
"the intercept's positional result stands"
);
assert!(
status(&s).contains("auto-pair closer altered"),
"got: {:?}",
status(&s)
);
assert_eq!(
cursor(&s),
4,
"pre-edit cursor 3 right-gravity-translated through the insert at 0 — \
translated, not teleported to the relocated closer"
);
}
#[test]
fn rejected_skip_delete_keeps_the_duplicate() {
let mut s = editor_with("");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "()");
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then error("rejected by test intercept") end
return nil
end)
"#,
);
type_str(&mut s, ")");
assert_eq!(buffer_text(&s), "())", "the typed duplicate stays");
assert_eq!(cursor(&s), 2);
assert!(
status(&s).contains("auto-pair skip rejected"),
"got: {:?}",
status(&s)
);
}
#[test]
fn expanded_skip_delete_lands_reported_and_cursor_clamped() {
let mut s = editor_with("");
type_str(&mut s, "(");
exec(&s, "pmacs.window.buffer():insert(2, \"x\")"); // "()x"
exec(&s, "pmacs.editor.goto_byte(1)");
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then
return { kind = "delete", start = op.start, ["end"] = op["end"] + 1 }
end
return nil
end)
"#,
);
type_str(&mut s, ")");
assert_eq!(
buffer_text(&s),
"()",
"the expanded delete swallowed the duplicate AND the x — it stands"
);
assert!(
status(&s).contains("auto-pair skip altered"),
"got: {:?}",
status(&s)
);
assert_eq!(cursor(&s), 2, "translate-and-clamp repair");
}
// ---------------------------------------------------------------------------
// Source self-insert intercepts (Q#AP9): the reaction fails closed
// ---------------------------------------------------------------------------
#[test]
fn post_insert_mutation_by_the_command_kills_the_record() {
// PR #110 round 1, finding 1: a redefined `buffer.self-insert`
// that inserts the char and then REPLACES it — leaving the cursor
// untouched — must not pair off the stale record. The record pins
// the buffer revision after the completing edit; any further edit
// by the same command kills it before the fan-out.
let mut s = editor_with("");
exec(
&s,
r#"
pmacs.command.unregister("buffer.self-insert")
pmacs.command.define {
name = "buffer.self-insert",
description = "test override: insert, then replace the typed char",
fn = function(cp)
pmacs.editor.insert_char_over_region(cp)
pmacs.window.buffer():replace(0, 1, "[")
end,
}
"#,
);
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"[",
"the typed `(` no longer exists; a `)` reaction would produce `[)`"
);
assert_eq!(cursor(&s), 1);
assert!(
!status(&s).contains("auto-pair"),
"a dead record is a silent non-event; got: {:?}",
status(&s)
);
}
#[test]
fn transformed_non_pair_char_stays_silent() {
// PR #110 round 1, finding 2: pairing has no interest in `a`; an
// intercept relocating it must not draw an auto-pair report.
let mut s = editor_with("xy");
exec(&s, "pmacs.editor.goto_byte(2)");
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "insert" and op.bytes == "a" then
return { kind = "insert", pos = 0, bytes = op.bytes }
end
return nil
end)
"#,
);
type_str(&mut s, "a");
assert_eq!(buffer_text(&s), "axy");
assert!(
!status(&s).contains("auto-pair"),
"chars outside the active pair set must stay silent; got: {:?}",
status(&s)
);
}
#[test]
fn relocated_opener_gets_no_pair_reaction() {
let mut s = editor_with("ab");
exec(&s, "pmacs.editor.goto_byte(2)");
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "insert" and op.bytes == "(" then
return { kind = "insert", pos = 0, bytes = op.bytes }
end
return nil
end)
"#,
);
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"(ab",
"exactly the intercept's positional result — no closer anywhere"
);
assert!(
status(&s).contains("auto-pair skipped: source self-insert transformed"),
"got: {:?}",
status(&s)
);
}
#[test]
fn transformed_type_over_gets_no_pair_reaction() {
let mut s = editor_with("abcd");
for _ in 0..2 {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT));
}
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "replace" then
return { kind = "replace", start = op.start,
["end"] = op["end"] + 1, bytes = op.bytes }
end
return nil
end)
"#,
);
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"(d",
"the expanded type-over stands as the intercept produced it"
);
assert!(
status(&s).contains("auto-pair skipped: source self-insert transformed"),
"got: {:?}",
status(&s)
);
}
#[test]
fn source_context_switch_fails_closed() {
// A context-switching INTERCEPT cannot exist on the dispatch
// self-insert path (the core borrow is held across it; the
// three-phase borrow-released discipline is the Lua-mutator
// path's). The legal producer of a context-switched source
// self-insert is a user-redefined `buffer.self-insert` command
// that switches after inserting — the record still completes for
// the exact typed codepoint, and the hook then runs in the new
// context, where pairing must fail closed.
let dir = fresh_state_dir();
let mut s = editor(&dir);
let other = write_file(&dir, "other.txt", "z");
exec(&s, "_G.scratch = pmacs.window.buffer()");
exec(&s, &format!("pmacs.buffer.find_or_open({other:?})"));
exec(&s, "_G.other = pmacs.window.buffer()");
exec(&s, "pmacs.window.switch_buffer(_G.scratch)");
// Bump the scratch revision past other.txt's so the active-buffer
// revision compare still fires the hook after the switch (the
// buffer-aware edit epoch is a named substrate deferral).
type_str(&mut s, "ab");
exec(
&s,
r#"
pmacs.command.unregister("buffer.self-insert")
pmacs.command.define {
name = "buffer.self-insert",
description = "test override: insert, then switch context",
fn = function(cp)
pmacs.editor.insert_char_over_region(cp)
pmacs.window.switch_buffer(_G.other)
end,
}
"#,
);
type_str(&mut s, "(");
assert!(
status(&s).contains("auto-pair skipped: source context changed"),
"got: {:?}",
status(&s)
);
let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())");
assert_eq!(
scratch_text, "ab(",
"the opener landed in scratch, no closer"
);
let other_text: String = eval(&s, "return _G.other:slice(0, _G.other:len())");
assert_eq!(other_text, "z", "the switched-to buffer is untouched");
}
#[test]
fn context_switch_relevance_is_the_source_buffers_rust_to_python_is_silent() {
// PR #110 round 2, finding 2: `'` typed in Rust is not a pair
// char THERE — that the context-switching command lands in a
// Python buffer (where `''` pairs) must not conjure an irrelevant
// "source context changed" report. Relevance and reporting are
// attributed to the buffer the record names.
let dir = fresh_state_dir();
let mut s = editor(&dir);
let py = write_file(&dir, "b.py", "");
let rs = write_file(&dir, "a.rs", "");
exec(&s, &format!("pmacs.buffer.find_or_open({py:?})"));
exec(&s, "_G.py = pmacs.window.buffer()");
exec(&s, &format!("pmacs.buffer.find_or_open({rs:?})"));
exec(&s, "_G.rs = pmacs.window.buffer()");
// Revision skew so the fan-out runs after the switch (the
// buffer-aware edit epoch is a named substrate deferral).
type_str(&mut s, "xy");
exec(
&s,
r#"
pmacs.command.unregister("buffer.self-insert")
pmacs.command.define {
name = "buffer.self-insert",
description = "test override: insert, then switch context",
fn = function(cp)
pmacs.editor.insert_char_over_region(cp)
pmacs.window.switch_buffer(_G.py)
end,
}
"#,
);
type_str(&mut s, "'");
assert!(
!status(&s).contains("auto-pair"),
"`'` is outside the SOURCE (rust) set; got: {:?}",
status(&s)
);
let rs_text: String = eval(&s, "return _G.rs:slice(0, _G.rs:len())");
assert_eq!(
rs_text, "xy'",
"the quote landed in the rust buffer, no pair"
);
let py_text: String = eval(&s, "return _G.py:slice(0, _G.py:len())");
assert_eq!(py_text, "", "the python buffer is untouched");
}
#[test]
fn context_switch_relevance_is_the_source_buffers_python_to_rust_reports() {
// The inverse route: `'` typed in Python IS a pair char there, so
// the context-change report must fire even though the destination
// (rust) set would have suppressed it under active-buffer lookup.
let dir = fresh_state_dir();
let mut s = editor(&dir);
let rs = write_file(&dir, "a.rs", "");
let py = write_file(&dir, "b.py", "");
exec(&s, &format!("pmacs.buffer.find_or_open({rs:?})"));
exec(&s, "_G.rs = pmacs.window.buffer()");
exec(&s, &format!("pmacs.buffer.find_or_open({py:?})"));
exec(&s, "_G.py = pmacs.window.buffer()");
type_str(&mut s, "xy");
exec(
&s,
r#"
pmacs.command.unregister("buffer.self-insert")
pmacs.command.define {
name = "buffer.self-insert",
description = "test override: insert, then switch context",
fn = function(cp)
pmacs.editor.insert_char_over_region(cp)
pmacs.window.switch_buffer(_G.rs)
end,
}
"#,
);
type_str(&mut s, "'");
assert!(
status(&s).contains("auto-pair skipped: source context changed"),
"`'` is in the SOURCE (python) set, so the report fires; got: {:?}",
status(&s)
);
let py_text: String = eval(&s, "return _G.py:slice(0, _G.py:len())");
assert_eq!(
py_text, "xy'",
"the quote landed in the python buffer, no pair"
);
let rs_text: String = eval(&s, "return _G.rs:slice(0, _G.rs:len())");
assert_eq!(rs_text, "", "the rust buffer is untouched");
}
#[test]
fn source_context_switch_with_equal_revisions_fails_closed_silently() {
// PR #110 round 1, finding 5: the twin of the test above WITHOUT
// the revision bump. Dispatch's active-buffer revision compare
// (pre = scratch@0, post = other@0) sees no delta, so the
// after-edit fan-out never runs: no reaction anywhere, and no
// context-change report either — the record dies un-armed. The
// report is best-effort until the buffer-aware edit epoch lands
// (named substrate deferral); failing closed is unconditional.
let dir = fresh_state_dir();
let mut s = editor(&dir);
let other = write_file(&dir, "other.txt", "z");
exec(&s, "_G.scratch = pmacs.window.buffer()");
exec(&s, &format!("pmacs.buffer.find_or_open({other:?})"));
exec(&s, "_G.other = pmacs.window.buffer()");
exec(&s, "pmacs.window.switch_buffer(_G.scratch)");
exec(
&s,
r#"
pmacs.command.unregister("buffer.self-insert")
pmacs.command.define {
name = "buffer.self-insert",
description = "test override: insert, then switch context",
fn = function(cp)
pmacs.editor.insert_char_over_region(cp)
pmacs.window.switch_buffer(_G.other)
end,
}
"#,
);
type_str(&mut s, "(");
let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())");
assert_eq!(scratch_text, "(", "the opener landed in scratch, no closer");
let other_text: String = eval(&s, "return _G.other:slice(0, _G.other:len())");
assert_eq!(other_text, "z", "the switched-to buffer is untouched");
assert!(
!status(&s).contains("auto-pair"),
"no fan-out ran, so no report is possible; got: {:?}",
status(&s)
);
let take_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil");
assert!(take_nil, "the record was never armed");
}
// ---------------------------------------------------------------------------
// Context-switching REACTION intercept: repair skipped, deferral pinned
// ---------------------------------------------------------------------------
#[test]
fn context_switching_reaction_intercept_skips_repair_and_later_callbacks_observe_it() {
let dir = fresh_state_dir();
let mut s = editor(&dir);
let other = write_file(&dir, "other.txt", "z");
exec(&s, "_G.scratch = pmacs.window.buffer()");
exec(&s, &format!("pmacs.buffer.find_or_open({other:?})"));
exec(&s, "_G.other = pmacs.window.buffer()");
exec(&s, "pmacs.window.switch_buffer(_G.scratch)");
type_str(&mut s, "ab");
// A probe registered AFTER pair.lua (and every builtin): it
// observes whatever context the fan-out is in when it runs —
// explicitly pinning, not concealing, the origin-context deferral.
exec(
&s,
r#"
_G.probe_buf = nil
pmacs.hook.add("buffer.after-edit", function()
_G.probe_buf = tostring(pmacs.window.buffer())
end)
pmacs.buffer.add_intercept(_G.scratch, function(op)
if op.kind == "insert" and op.bytes == ")" then
pmacs.window.switch_buffer(_G.other)
return { kind = "insert", pos = 0, bytes = op.bytes }
end
return nil
end)
"#,
);
type_str(&mut s, "(");
assert!(
status(&s).contains("auto-pair closer altered"),
"got: {:?}",
status(&s)
);
let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())");
assert_eq!(
scratch_text, ")ab(",
"the relocated closer landed in the scratch buffer as the intercept wrote it"
);
let other_text: String = eval(&s, "return _G.other:slice(0, _G.other:len())");
assert_eq!(
other_text, "z",
"pair.lua never touched the new context's text"
);
assert_eq!(
cursor(&s),
0,
"no cursor repair in the switched-to context (switch_buffer's own \
cursor reset stands untouched)"
);
let probe_saw_other: bool = eval(&s, "return _G.probe_buf == tostring(_G.other)");
assert!(
probe_saw_other,
"a later callback observes the switched context — the origin-pinned \
fan-out deferral, pinned"
);
}
// ---------------------------------------------------------------------------
// Hook fan-out: one fire per keystroke, the reaction edit doesn't re-fire
// ---------------------------------------------------------------------------
#[test]
fn after_edit_fires_once_per_pairing_keystroke() {
let mut s = editor_with("");
exec(
&s,
r#"
_G.fires = 0
pmacs.hook.add("buffer.after-edit", function() _G.fires = _G.fires + 1 end)
"#,
);
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "()");
let fires: i64 = eval(&s, "return _G.fires");
assert_eq!(
fires, 1,
"the closer edit must not re-fire buffer.after-edit"
);
}
// ---------------------------------------------------------------------------
// Typed-edit record lifecycle (Q#AP9)
// ---------------------------------------------------------------------------
#[test]
fn typed_edit_record_is_exact_and_one_shot() {
let mut s = editor_with("");
exec(
&s,
r#"
pmacs.pair._capture_records = true
_G.second_take = "unset"
pmacs.hook.add("buffer.after-edit", function()
_G.second_take = pmacs.editor.take_typed_edit()
end)
"#,
);
type_str(&mut s, "(");
// pair.lua (first registrant) consumed the record and published it
// on the test seam: exact codepoint + effective triple.
let (cp, ch, clean, es, ee, il, pc): (i64, String, bool, i64, i64, i64, i64) = eval(
&s,
"
local r = pmacs.pair._last_record
return r.codepoint, r.char, r.clean, r.effective_start,
r.effective_end, r.inserted_len, r.post_cursor
",
);
assert_eq!(cp, 40, "exact codepoint for '('");
assert_eq!(ch, "(");
assert!(clean);
assert_eq!(
(es, ee, il),
(0, 0, 1),
"effective triple of the opener insert"
);
assert_eq!(pc, 1);
// A second take — from a later callback in the SAME fan-out — is nil.
let second_nil: bool = eval(&s, "return _G.second_take == nil");
assert!(second_nil, "the record is consumable exactly once");
// Outside any fan-out the slot is empty.
let outside_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil");
assert!(outside_nil, "no record outside the after-edit fan-out");
}
#[test]
fn nested_manual_after_edit_run_sees_no_record() {
let mut s = editor_with("");
exec(
&s,
r#"
pmacs.pair._capture_records = true
_G.outer = nil
_G.ran_nested = false
pmacs.hook.add("buffer.after-edit", function()
if _G.ran_nested then return end -- the nested run reaches this callback too
_G.ran_nested = true
_G.outer = pmacs.pair._last_record
pmacs.hook.run("buffer.after-edit")
end)
"#,
);
type_str(&mut s, "(");
let outer_seen: bool = eval(&s, "return _G.outer ~= nil");
assert!(outer_seen, "the outer fan-out carried a record");
// The nested run re-entered pair.lua, which took nil and published
// nil on the seam — proving the nested run observed no record.
let nested_nil: bool = eval(&s, "return pmacs.pair._last_record == nil");
assert!(nested_nil, "a nested manual re-run must see nil");
assert_eq!(buffer_text(&s), "()", "and must insert no second closer");
}
#[test]
fn record_capture_is_off_by_default() {
// PR #110 round 1, finding 4: without the explicit test facility,
// no consumed record is retained anywhere — the one-shot take API
// is the only access, and it is empty after the fan-out.
let mut s = editor_with("");
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "()");
let leaked: bool = eval(&s, "return pmacs.pair._last_record ~= nil");
assert!(!leaked, "production keystrokes must retain no record");
}
#[test]
fn rejected_self_insert_leaves_no_record() {
let mut s = editor_with("");
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
error("rejected by test intercept")
end)
"#,
);
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), "", "nothing landed");
let take_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil");
assert!(take_nil, "a rejecting edit must arm no record");
}
#[test]
fn frontends_cannot_consume_each_others_slot() {
let s = editor_with("");
let (buffer, window) = {
let core = s.core.borrow();
(core.active_buffer_id(), core.active_window_id())
};
let record = pmacs::editor_core::TypedEditRecord {
buffer,
window,
codepoint: '(',
requested_start: 0,
requested_end: 0,
effective_start: 0,
effective_end: 0,
inserted_len: 1,
post_cursor: 1,
clean: true,
revision: 0,
};
let a = FrontendId::LOCAL;
let b = FrontendId(a.0 + 1);
let mut core = s.core.borrow_mut();
core.typed_edit_set_armed(a, record);
core.active_frontend = b;
assert!(
core.take_typed_edit().is_none(),
"frontend B must not see frontend A's record"
);
core.active_frontend = a;
assert!(
core.take_typed_edit().is_some(),
"the slot survives a foreign take attempt for its owner"
);
assert!(
core.take_typed_edit().is_none(),
"one-shot for the owner too"
);
}
// ---------------------------------------------------------------------------
// Signature help + first-didChange ordering (Q#AP7 / Q#AP8)
// ---------------------------------------------------------------------------
fn fake_lsp_path() -> String {
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
}
fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool {
let deadline = Instant::now() + Duration::from_secs(secs);
loop {
state.tick_processes();
state.tick_lsp();
state.tick_async();
let done: bool = state
.lua_host
.lua()
.load(format!("return ({flag}) == true"))
.eval()
.unwrap_or(false);
if done {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
}
/// Editor visiting a `.rs` file attached to the fake LSP in `sighelp`
/// mode, with the document-sync sink at `sink`. Returns after the
/// server initialized.
fn sighelp_editor(dir: &std::path::Path, sink: &std::path::Path, body: &str) -> EditorState {
let mut s = editor(dir);
let fake = fake_lsp_path();
let sink_disp = sink.display().to_string();
exec(
&s,
&format!(
"pmacs.lsp.config.rust = {{
command = '{fake}',
env = {{
PMACS_FAKE_LSP_MODE = 'sighelp',
PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}',
}},
}}"
),
);
let f = write_file(dir, "a.rs", body);
exec(&s, &format!("pmacs.buffer.find_or_open({f:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
let initialized = "(function() \
for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end \
return false \
end)()";
assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init");
s
}
/// The `text` of every `textDocument/didChange` line in the sink, in
/// arrival order.
fn did_change_texts(sink: &std::path::Path) -> Vec<String> {
let Ok(raw) = std::fs::read_to_string(sink) else {
return Vec::new();
};
raw.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange"))
.filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned))
.collect()
}
#[test]
fn first_did_change_after_opener_carries_the_pair() {
let dir = fresh_state_dir();
let sink = dir.join("changes.jsonl");
let mut s = sighelp_editor(&dir, &sink, "\n");
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"()\n",
"pairing is active in the attached buffer"
);
// The signature auto-trigger's synchronous flush sends the first
// didChange from inside the SAME fan-out; pump until the fake
// server has written it to the sink.
let deadline = Instant::now() + Duration::from_secs(5);
let changes = loop {
s.tick_processes();
s.tick_lsp();
s.tick_async();
let c = did_change_texts(&sink);
if !c.is_empty() {
break c;
}
assert!(
Instant::now() < deadline,
"no didChange reached the fake server"
);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(
changes[0], "()\n",
"the FIRST didChange after `(` carries the closer — pair.lua ran \
before lsp.lua's synchronous flush (Q#AP7 ordering observable)"
);
// And the auto-trigger itself still fired with pairing active
// (Q#AP8): the fake's signature label reaches the status line.
let deadline = Instant::now() + Duration::from_secs(5);
let mut saw = false;
while Instant::now() < deadline {
s.tick_processes();
s.tick_lsp();
s.tick_async();
if status(&s).contains("fn echo(") {
saw = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
saw,
"signature help must still auto-trigger with pairing active"
);
}
#[test]
fn relocated_closer_first_did_change_carries_the_complete_effective_text() {
let dir = fresh_state_dir();
let sink = dir.join("changes.jsonl");
let mut s = sighelp_editor(&dir, &sink, "\n");
// Context-preserving position transform: the closer lands at 0.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "insert" and op.bytes == ")" then
return { kind = "insert", pos = 0, bytes = op.bytes }
end
return nil
end)
"#,
);
type_str(&mut s, "(");
assert_eq!(buffer_text(&s), ")(\n");
let deadline = Instant::now() + Duration::from_secs(5);
let changes = loop {
s.tick_processes();
s.tick_lsp();
s.tick_async();
let c = did_change_texts(&sink);
if !c.is_empty() {
break c;
}
assert!(
Instant::now() < deadline,
"no didChange reached the fake server"
);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(
changes[0], ")(\n",
"a position-only closer transform still sends the complete effective \
text in the first didChange — never an opener-only intermediate"
);
}
// Isolated bootstrap storage roots (see the module docs): an
// integration test is compiled without `cfg(test)`, so a raw
// `EditorState::new()` would read the developer's real `init.lua` and
// write into their real data root.
#[path = "common/iso.rs"]
mod iso;