1381 lines
45 KiB
Rust
1381 lines
45 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();
|
|
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();
|
|
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"
|
|
);
|
|
}
|