pmacs/tests/editops_acceptance.rs

1328 lines
42 KiB
Rust

//! Editing-conveniences acceptance (docs/editing-conveniences-framing.md).
//!
//! Dispatch-driven where a binding exists (`pmacs.command.invoke`
//! bypasses dispatch — a dead binding would pass vacuously), with
//! minibuffer sessions completed by DISPATCHING RET / C-g: the Lua
//! lifecycle `accept()` bypasses `with_after_edit_check`, a path
//! interactive key input never takes. M-x-only commands go through
//! the real M-x minibuffer. The origin/silent-replacement matrices
//! ride the same unregistered-second-`FrontendId` shape as the
//! kill-ring suite.
use crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
const B: FrontendId = FrontendId(9);
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 alt(s: &mut EditorState, c: char) {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
}
fn alt_code(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::ALT));
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn press_as(s: &mut EditorState, fid: FrontendId, code: KeyCode) {
s.dispatch_key(fid, 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),
);
}
}
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 lua_str(text: &str) -> String {
let mut out = String::from("\"");
for c in text.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out.push('"');
out
}
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 cursor_line(s: &EditorState) -> i64 {
eval(s, "return pmacs.editor.cursor_line()")
}
fn region_is_nil(s: &EditorState) -> bool {
eval(s, "return pmacs.editor.region() == nil")
}
fn ring(s: &EditorState) -> Vec<String> {
eval(s, "return pmacs.killring.list()")
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// Fresh editor whose scratch buffer holds `text` (seeded directly —
/// typing RET would route through edit.newline-and-indent and clone
/// leading whitespace), cursor at 0.
fn editor_with(text: &str) -> EditorState {
let s = EditorState::new_with_roots(&crate::iso::roots());
exec(
&s,
&format!(
"local b = pmacs.window.buffer(); b:replace(0, b:len(), {})",
lua_str(text)
),
);
exec(&s, "pmacs.editor.goto_byte(0)");
s
}
/// Complete the open minibuffer session: seed contents directly, then
/// DISPATCH RET (the framing's accept-path requirement).
fn accept_minibuffer(s: &mut EditorState, contents: &str) {
exec(
s,
&format!("pmacs.minibuffer.set_contents({})", lua_str(contents)),
);
press(s, KeyCode::Enter);
}
fn m_x(s: &mut EditorState, name: &str) {
alt(s, 'x');
type_str(s, name);
press(s, KeyCode::Enter);
}
fn zap(s: &mut EditorState, ch: &str) {
alt(s, 'z');
accept_minibuffer(s, ch);
}
fn undo(s: &mut EditorState) {
ctrl(s, '/');
}
fn click(s: &mut EditorState) {
let term = pmacs::cell::CellSize::new(24, 80);
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
},
term,
);
}
// ---------------------------------------------------------------------------
// Boundary-state pin (the Q#EC6 substrate observation, asserted directly)
// ---------------------------------------------------------------------------
#[test]
fn minibuffer_accept_preserves_the_invoking_commands_boundary() {
let mut s = editor_with("one\ntwo\n");
exec(
&s,
r#"
pmacs.command.define {
name = "test.boundary-probe",
description = "capture boundary state inside on_accept",
fn = function()
pmacs.minibuffer.read {
prompt = "p: ",
on_accept = function()
PROBE_THIS = tostring(pmacs.editor.this_command())
PROBE_LAST = tostring(pmacs.editor.last_command())
end,
}
end,
}
pmacs.keymap.bind { scope = "global", sequence = "C-c q", command = "test.boundary-probe" }
"#,
);
ctrl(&mut s, 'k'); // predecessor: edit.kill-line
ctrl(&mut s, 'c');
press(&mut s, KeyCode::Char('q'));
press(&mut s, KeyCode::Enter);
let this: String = eval(&s, "return PROBE_THIS");
let last: String = eval(&s, "return PROBE_LAST");
assert_eq!(
this, "test.boundary-probe",
"this_command survives the prompt"
);
assert_eq!(
last, "edit.kill-line",
"last_command is the pre-prompt predecessor"
);
}
// ---------------------------------------------------------------------------
// goto-line (Q#EC3)
// ---------------------------------------------------------------------------
#[test]
fn goto_line_moves_and_pushes_a_jump() {
let mut s = editor_with("a\nb\nc\nd\ne");
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('g'));
accept_minibuffer(&mut s, "4");
assert_eq!(cursor_line(&s), 3, "1-based input, 0-based line");
let back: bool = eval(&s, "return pmacs.editor.jump_back()");
assert!(back, "goto-line pushed a jump");
assert_eq!(cursor_line(&s), 0);
}
#[test]
fn goto_line_binds_the_double_alt_form_too() {
let mut s = editor_with("a\nb\nc");
alt(&mut s, 'g');
alt(&mut s, 'g');
accept_minibuffer(&mut s, "2");
assert_eq!(cursor_line(&s), 1);
}
#[test]
fn goto_line_zero_clamps_to_the_first_line() {
let mut s = editor_with("a\nb\nc");
exec(&s, "pmacs.editor.move_to_line(2)");
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('g'));
accept_minibuffer(&mut s, "0");
assert_eq!(cursor_line(&s), 0, "Emacs clamps line 0 to the first line");
}
#[test]
fn goto_line_huge_decimal_clamps_to_the_last_line() {
let mut s = editor_with("a\nb\nc\nd\ne");
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('g'));
accept_minibuffer(&mut s, "9999999999999999999999999");
assert_eq!(cursor_line(&s), 4, "clamps, never errors");
}
#[test]
fn goto_line_rejects_non_numeric_without_touching_the_jump_stack() {
let mut s = editor_with("a\nb\nc");
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('g'));
accept_minibuffer(&mut s, "abc");
assert!(status(&s).contains("enter a line number"));
assert_eq!(cursor_line(&s), 0, "no motion");
let back: bool = eval(&s, "return pmacs.editor.jump_back()");
assert!(!back, "nothing pushed before validation");
}
// ---------------------------------------------------------------------------
// Case ops (Q#EC4)
// ---------------------------------------------------------------------------
#[test]
fn upcase_region_clears_the_selection() {
let mut s = editor_with("foo bar");
exec(&s, "pmacs.editor.begin_selection(0)");
exec(&s, "pmacs.editor.goto_byte(3)");
alt(&mut s, 'u');
assert_eq!(buffer_text(&s), "FOO bar");
assert!(region_is_nil(&s), "selection cleared after the edit");
}
#[test]
fn upcase_mid_word_transforms_the_remainder_and_moves_the_cursor() {
let mut s = editor_with("foo bar");
exec(&s, "pmacs.editor.goto_byte(1)");
alt(&mut s, 'u');
assert_eq!(buffer_text(&s), "fOO bar", "Emacs mid-word remainder");
assert_eq!(cursor(&s), 3, "cursor at the span end");
}
#[test]
fn downcase_from_a_separator_takes_the_next_word() {
let mut s = editor_with("foo BAR baz");
exec(&s, "pmacs.editor.goto_byte(3)");
alt(&mut s, 'l');
assert_eq!(buffer_text(&s), "foo bar baz");
assert_eq!(cursor(&s), 7);
}
#[test]
fn capitalize_word_and_region() {
let mut s = editor_with("hELLO");
alt(&mut s, 'c');
assert_eq!(buffer_text(&s), "Hello");
let mut s = editor_with("hello WORLD");
exec(&s, "pmacs.editor.begin_selection(0)");
exec(&s, "pmacs.editor.goto_byte(11)");
alt(&mut s, 'c');
assert_eq!(
buffer_text(&s),
"Hello World",
"per-word capitalize across the region (Emacs capitalize-region)"
);
}
#[test]
fn capitalize_is_per_word_with_the_packs_word_class() {
// Emacs 30.2 parity rows: a digit-led word keeps its letters
// lowercase; a letter after a digit is not a word start.
let mut s = editor_with("9abc a9bc");
exec(&s, "pmacs.editor.begin_selection(0)");
exec(&s, "pmacs.editor.goto_byte(9)");
alt(&mut s, 'c');
assert_eq!(buffer_text(&s), "9abc A9bc");
// The named deviation: `_` is a word constituent in this pack's
// ASCII class (Emacs's symbol-syntax `_` would give "Foo_Bar").
let mut s = editor_with("foo_bar baz");
exec(&s, "pmacs.editor.begin_selection(0)");
exec(&s, "pmacs.editor.goto_byte(11)");
alt(&mut s, 'c');
assert_eq!(buffer_text(&s), "Foo_bar Baz");
}
#[test]
fn case_ops_report_when_no_word_follows() {
let mut s = editor_with("foo ");
exec(&s, "pmacs.editor.goto_byte(4)");
alt(&mut s, 'u');
assert_eq!(buffer_text(&s), "foo ");
assert!(status(&s).contains("no word after the cursor"));
}
#[test]
fn case_ops_leave_non_ascii_bytes_identical() {
// é is not in the ASCII conversion ranges: byte-identical in any
// process locale, while its ASCII neighbors flip.
let mut s = editor_with("aéb");
exec(&s, "pmacs.editor.begin_selection(0)");
exec(&s, &format!("pmacs.editor.goto_byte({})", "aéb".len()));
alt(&mut s, 'u');
assert_eq!(buffer_text(&s), "AéB");
}
// ---------------------------------------------------------------------------
// Transpose-chars (Q#EC5)
// ---------------------------------------------------------------------------
#[test]
fn transpose_chars_swaps_and_advances() {
let mut s = editor_with("abc");
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "bac");
assert_eq!(cursor(&s), 2, "Emacs drag-forward");
undo(&mut s);
assert_eq!(buffer_text(&s), "abc", "one undo step");
}
#[test]
fn transpose_chars_at_eol_swaps_the_two_before() {
let mut s = editor_with("ab\ncd");
exec(&s, "pmacs.editor.goto_byte(2)");
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "ba\ncd");
assert_eq!(cursor(&s), 2, "cursor stays at EOL");
}
#[test]
fn transpose_chars_at_eof_swaps_the_two_before() {
let mut s = editor_with("ab");
exec(&s, "pmacs.editor.goto_byte(2)");
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "ba");
}
#[test]
fn transpose_chars_no_ops_at_bob_and_on_single_chars() {
let mut s = editor_with("ab");
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "ab");
assert!(status(&s).contains("not enough characters"));
let mut s = editor_with("a");
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "a");
}
#[test]
fn transpose_chars_swaps_whole_codepoints() {
let mut s = editor_with("éx");
exec(&s, "pmacs.editor.goto_byte(2)"); // between é and x
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "", "intact UTF-8, é dragged forward");
assert_eq!(cursor(&s), 3);
let mut s = editor_with("");
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "éx");
assert_eq!(cursor(&s), 3);
}
#[test]
fn transpose_chars_swaps_across_a_newline() {
let mut s = editor_with("a\nb");
exec(&s, "pmacs.editor.goto_byte(2)");
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "ab\n");
}
#[test]
fn transpose_chars_fails_closed_on_a_continuation_byte() {
let mut s = editor_with("éx");
exec(&s, "pmacs.editor.goto_byte(1)"); // inside é
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "éx", "no edit");
assert!(status(&s).contains("multi-byte"));
}
/// Seed raw (possibly malformed) bytes via Lua \x escapes and compare
/// byte-identically Lua-side (`from_utf8_lossy` would mask differences).
fn seed_raw(s: &EditorState, lua_bytes: &str) {
exec(
s,
&format!("local b = pmacs.window.buffer(); b:replace(0, b:len(), \"{lua_bytes}\")"),
);
exec(s, "pmacs.editor.goto_byte(0)");
}
fn raw_equals(s: &EditorState, lua_bytes: &str) -> bool {
eval(
s,
&format!("local b = pmacs.window.buffer(); return b:slice(0, b:len()) == \"{lua_bytes}\""),
)
}
#[test]
fn transpose_chars_fails_closed_on_invalid_trailing_bytes() {
// A valid lead (C3) followed by a non-continuation byte: the
// scalar at the cursor must be validated whole, not length-only.
let mut s = editor_with("");
seed_raw(&s, "a\\xC3xb");
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 't');
assert!(raw_equals(&s, "a\\xC3xb"), "no edit on a malformed scalar");
assert!(status(&s).contains("malformed UTF-8 at the cursor"));
}
#[test]
fn transpose_chars_fails_closed_on_invalid_scalars_behind_the_cursor() {
// Length-consistent but scalar-invalid spans behind the cursor:
// an overlong three-byte encoding and a beyond-U+10FFFF four-byte
// encoding. Both scan back cleanly (lead + continuations) and
// must still fail closed.
let cases = [("\\xE0\\x80\\x80b", 3i64), ("\\xF4\\x90\\x80\\x80b", 4)];
for (bytes, cursor_pos) in cases {
let mut s = editor_with("");
seed_raw(&s, bytes);
exec(&s, &format!("pmacs.editor.goto_byte({cursor_pos})"));
ctrl(&mut s, 't');
assert!(raw_equals(&s, bytes), "no edit for {bytes}");
assert!(
status(&s).contains("malformed UTF-8 before the cursor"),
"fail-closed status for {bytes}"
);
}
}
// ---------------------------------------------------------------------------
// Transpose-words: the nine-position Emacs 30.2 table (Q#EC5)
// ---------------------------------------------------------------------------
/// (0-based byte cursor, expected text, expected cursor) — mutating
/// rows of the framing's table ("one two three"; Emacs points are
/// these offsets + 1).
#[test]
fn transpose_words_matches_the_emacs_table() {
let mutating: [(i64, &str, i64); 7] = [
(0, "two one three", 7),
(1, "two one three", 7),
(3, "two one three", 7),
(4, "two one three", 7),
(5, "one three two", 13),
(7, "one three two", 13),
(8, "one three two", 13),
];
for (pos, want, want_cursor) in mutating {
let mut s = editor_with("one two three");
exec(&s, &format!("pmacs.editor.goto_byte({pos})"));
alt(&mut s, 't');
assert_eq!(buffer_text(&s), want, "cursor byte {pos}");
assert_eq!(cursor(&s), want_cursor, "cursor byte {pos}");
}
// No-successor rows: no edit, and — the named deviation from
// Emacs — NO cursor motion.
for pos in [10i64, 13] {
let mut s = editor_with("one two three");
exec(&s, &format!("pmacs.editor.goto_byte({pos})"));
alt(&mut s, 't');
assert_eq!(buffer_text(&s), "one two three", "cursor byte {pos}");
assert_eq!(cursor(&s), pos, "no cursor motion on the no-op");
assert!(status(&s).contains("no following word"));
}
}
#[test]
fn transpose_words_preserves_separator_bytes_and_undoes_in_one_step() {
let mut s = editor_with("aa,, bb");
exec(&s, "pmacs.editor.goto_byte(3)");
alt(&mut s, 't');
assert_eq!(buffer_text(&s), "bb,, aa", "separators verbatim");
undo(&mut s);
assert_eq!(buffer_text(&s), "aa,, bb", "one undo step");
}
// ---------------------------------------------------------------------------
// Zap (Q#EC6)
// ---------------------------------------------------------------------------
#[test]
fn zap_to_char_kills_through_the_target_onto_the_ring() {
let mut s = editor_with("foo(bar");
zap(&mut s, "(");
assert_eq!(buffer_text(&s), "bar");
assert_eq!(cursor(&s), 0);
assert_eq!(ring(&s), vec!["foo("]);
let slot: String = eval(&s, "return pmacs.editor.clipboard_get()");
assert_eq!(slot, "foo(", "clipboard mirrors the kill");
// The boundary after a completed zap still names the zap.
let this: String = eval(&s, "return tostring(pmacs.editor.this_command())");
assert_eq!(this, "edit.zap-to-char");
}
#[test]
fn zap_up_to_char_leaves_the_target() {
let mut s = editor_with("abc");
m_x(&mut s, "edit.zap-up-to-char");
accept_minibuffer(&mut s, "c");
assert_eq!(buffer_text(&s), "c");
assert_eq!(ring(&s), vec!["ab"]);
}
#[test]
fn zap_up_to_char_at_the_target_is_a_zero_length_no_op() {
let mut s = editor_with("abc");
m_x(&mut s, "edit.zap-up-to-char");
accept_minibuffer(&mut s, "a");
assert_eq!(buffer_text(&s), "abc");
assert!(status(&s).contains("already at"));
}
#[test]
fn zap_rejects_multi_char_input_and_missing_targets() {
let mut s = editor_with("abc");
zap(&mut s, "xy");
assert_eq!(buffer_text(&s), "abc");
assert!(status(&s).contains("single character"));
let mut s = editor_with("abc");
zap(&mut s, "Q");
assert_eq!(buffer_text(&s), "abc");
assert!(status(&s).contains("no 'Q' after the cursor"));
}
#[test]
fn zap_fires_after_edit_exactly_once() {
let mut s = editor_with("foo(bar");
exec(
&s,
"EDITS = 0; pmacs.hook.add('buffer.after-edit', function() EDITS = EDITS + 1 end)",
);
zap(&mut s, "(");
let edits: i64 = eval(&s, "return EDITS");
assert_eq!(edits, 1, "the RET-dispatch wrapper fires once");
}
// ---- chain matrix ----------------------------------------------------------
#[test]
fn zap_after_a_kill_appends() {
let mut s = editor_with("abc\nd(e");
ctrl(&mut s, 'k'); // "abc"
zap(&mut s, "(");
assert_eq!(ring(&s), vec!["abc\nd("], "one appended entry");
}
#[test]
fn a_kill_after_a_zap_appends() {
let mut s = editor_with("a(bc\nd");
zap(&mut s, "(");
ctrl(&mut s, 'k'); // "bc"
assert_eq!(ring(&s), vec!["a(bc"], "one appended entry");
}
#[test]
fn consecutive_zaps_append() {
let mut s = editor_with("a(b(c");
zap(&mut s, "(");
zap(&mut s, "(");
assert_eq!(ring(&s), vec!["a(b("]);
}
#[test]
fn cancelled_zap_breaks_the_chain() {
let mut s = editor_with("abc\nx(y");
ctrl(&mut s, 'k');
alt(&mut s, 'z');
ctrl(&mut s, 'g'); // cancel the prompt
ctrl(&mut s, 'k'); // kills "\n"
assert_eq!(ring(&s), vec!["\n", "abc"], "two entries, no append");
}
#[test]
fn invalid_zap_input_breaks_the_chain() {
let mut s = editor_with("abc\nx(y");
ctrl(&mut s, 'k');
zap(&mut s, "xy");
ctrl(&mut s, 'k');
assert_eq!(ring(&s), vec!["\n", "abc"]);
}
#[test]
fn no_match_zap_breaks_the_chain() {
let mut s = editor_with("abc\nxyz");
ctrl(&mut s, 'k');
zap(&mut s, "Q");
ctrl(&mut s, 'k');
assert_eq!(ring(&s), vec!["\n", "abc"]);
}
#[test]
fn zero_length_up_to_zap_breaks_the_chain() {
let mut s = editor_with("ab\n(cd");
ctrl(&mut s, 'k'); // "ab"; buffer "\n(cd", cursor 0
exec(&s, "pmacs.editor.goto_byte(1)"); // programmatic: chain intact, cursor ON '('
m_x(&mut s, "edit.zap-up-to-char");
accept_minibuffer(&mut s, "(");
assert!(status(&s).contains("already at"));
ctrl(&mut s, 'k'); // kills "(cd"
assert_eq!(
ring(&s),
vec!["(cd", "ab"],
"no append across the zero-length no-op"
);
}
// ---- origin-guard matrix (multi-frontend) ----------------------------------
#[test]
fn accept_from_another_frontend_aborts_and_breaks_the_origin_chain() {
let mut s = editor_with("xx\na(b");
ctrl(&mut s, 'k'); // A kills "xx"
alt(&mut s, 'z'); // A opens the zap prompt
exec(&s, "pmacs.minibuffer.set_contents('(')");
press_as(&mut s, B, KeyCode::Enter); // B completes it
assert_eq!(buffer_text(&s), "\na(b", "no edit on either frontend");
assert!(status(&s).contains("origin changed"));
ctrl(&mut s, 'k'); // A's next kill: fresh
assert_eq!(ring(&s), vec!["\n", "xx"], "A's chain was broken");
}
#[test]
fn cancel_from_another_frontend_breaks_the_origin_chain() {
let mut s = editor_with("xx\na(b");
ctrl(&mut s, 'k');
alt(&mut s, 'z');
s.dispatch_key(B, key(KeyCode::Char('g'), KeyModifiers::CONTROL)); // B cancels
ctrl(&mut s, 'k');
assert_eq!(ring(&s), vec!["\n", "xx"], "two entries, no append");
}
#[test]
fn pointer_click_mid_prompt_prevents_a_false_append() {
let mut s = editor_with("xx\na(b");
ctrl(&mut s, 'k'); // pre-zap kill: last_kill_id armed
alt(&mut s, 'z');
click(&mut s); // breaks the boundary; the prompt stays open
accept_minibuffer(&mut s, "(");
assert_eq!(buffer_text(&s), "\na(b", "no kill ran");
assert!(status(&s).contains("origin changed"));
// The click legitimately moved the cursor, so the next C-k's text
// depends on the click position; the pin is that it is a FRESH
// entry — never an append onto the pre-zap "xx".
ctrl(&mut s, 'k');
let r = ring(&s);
assert_eq!(r.len(), 2, "fresh entry, no append");
assert_eq!(r[1], "xx", "the pre-zap kill is intact");
}
#[test]
fn goto_line_ignores_an_accept_from_another_frontend() {
let mut s = editor_with("a\nb\nc\nd");
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('g'));
exec(&s, "pmacs.minibuffer.set_contents('3')");
press_as(&mut s, B, KeyCode::Enter);
assert_eq!(cursor_line(&s), 0, "no motion");
let back: bool = eval(&s, "return pmacs.editor.jump_back()");
assert!(!back, "nothing on the jump stack");
}
// ---- silent-replacement matrix (the R3 blocker) -----------------------------
#[test]
fn silent_session_replacement_forces_the_next_kill_fresh() {
let mut s = editor_with("abc\nx(y");
ctrl(&mut s, 'k'); // "abc"
alt(&mut s, 'z'); // zap prompt armed
// A package replaces the session; zap's on_cancel never runs.
exec(
&s,
"pmacs.minibuffer.read { prompt = 'r: ', on_accept = function() end }",
);
press(&mut s, KeyCode::Enter); // close the replacement
ctrl(&mut s, 'k'); // would falsely append without the marker
assert_eq!(
ring(&s),
vec!["\n", "abc"],
"the uncommitted marker forces a fresh entry"
);
let cleared: bool = eval(
&s,
"return pmacs.killring._debug_state(pmacs.frontend.id()).pending_kill_prompt == nil",
);
assert!(cleared, "the marker was consumed by the fail-safe");
}
#[test]
fn second_zap_after_a_silent_replacement_does_not_append() {
let mut s = editor_with("abc\nx(y");
ctrl(&mut s, 'k'); // "abc"
alt(&mut s, 'z');
exec(
&s,
"pmacs.minibuffer.read { prompt = 'r: ', on_accept = function() end }",
);
press(&mut s, KeyCode::Enter);
zap(&mut s, "("); // completes cleanly — kills "\nx("
assert_eq!(
ring(&s),
vec!["\nx(", "abc"],
"arm-time abandoned-marker break: fresh, not appended"
);
}
#[test]
fn consumed_marker_aborts_the_zap_fail_closed() {
let mut s = editor_with("a(b");
alt(&mut s, 'z');
let was: bool = eval(&s, "return pmacs.killring.commit_kill_prompt()");
assert!(was, "public Lua consumed the armed marker");
accept_minibuffer(&mut s, "(");
assert_eq!(buffer_text(&s), "a(b", "no kill");
assert!(status(&s).contains("prompt state consumed"));
assert_eq!(ring(&s).len(), 0);
}
// ---- killring API validation ------------------------------------------------
#[test]
fn kill_range_validates_before_mutating() {
let s = editor_with("abcdef");
for bad in [
"pmacs.killring.kill_range(-1, 2)",
"pmacs.killring.kill_range(0, 0)",
"pmacs.killring.kill_range(1.5, 3)",
"pmacs.killring.kill_range(0, 99)",
"pmacs.killring.kill_range('a', 2)",
] {
let ok: bool = eval(&s, &format!("return (pcall(function() {bad} end))"));
assert!(!ok, "{bad} must error");
}
assert_eq!(buffer_text(&s), "abcdef", "no mutation");
assert_eq!(ring(&s).len(), 0, "no ring entry");
}
#[test]
fn break_chain_validates_its_frontend_argument() {
let s = editor_with("abc");
for bad in [
"pmacs.killring.break_chain(-1)",
"pmacs.killring.break_chain(1.5)",
"pmacs.killring.break_chain('x')",
] {
let ok: bool = eval(&s, &format!("return (pcall(function() {bad} end))"));
assert!(!ok, "{bad} must error");
}
exec(&s, "pmacs.killring.break_chain()"); // acting frontend: fine
exec(&s, "pmacs.killring.break_chain(0)"); // explicit: fine
}
#[test]
fn kill_range_rejected_and_transformed_leave_the_ring_alone() {
let s = editor_with("abcdef");
exec(
&s,
r#"
REJECT = true
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if REJECT then error("nope") end
return { kind = "delete", start = 0, ["end"] = 4 }
end)
"#,
);
let (ok, why): (bool, String) = eval(&s, "return pmacs.killring.kill_range(0, 2)");
assert!(!ok);
assert_eq!(why, "rejected");
assert_eq!(buffer_text(&s), "abcdef");
exec(&s, "REJECT = false");
let (ok, why): (bool, String) = eval(&s, "return pmacs.killring.kill_range(0, 2)");
assert!(!ok);
assert_eq!(why, "transformed");
assert_eq!(buffer_text(&s), "ef", "the intercept's delete stands");
assert_eq!(ring(&s).len(), 0, "nothing pushed for a deviating kill");
}
// ---------------------------------------------------------------------------
// Line ops (Q#EC7)
// ---------------------------------------------------------------------------
#[test]
fn move_line_down_and_up_round_trip() {
let mut s = editor_with("aa\nbb\ncc");
exec(&s, "pmacs.editor.goto_byte(1)");
alt_code(&mut s, KeyCode::Down);
assert_eq!(buffer_text(&s), "bb\naa\ncc");
assert_eq!(cursor(&s), 4, "cursor rides the moved line, same column");
alt_code(&mut s, KeyCode::Up);
assert_eq!(buffer_text(&s), "aa\nbb\ncc");
assert_eq!(cursor(&s), 1);
}
#[test]
fn move_line_no_ops_at_the_edges() {
let mut s = editor_with("aa\nbb");
alt_code(&mut s, KeyCode::Up);
assert_eq!(buffer_text(&s), "aa\nbb");
assert!(status(&s).contains("first line"));
exec(&s, "pmacs.editor.goto_byte(4)");
alt_code(&mut s, KeyCode::Down);
assert_eq!(buffer_text(&s), "aa\nbb");
assert!(status(&s).contains("last line"));
}
#[test]
fn move_line_preserves_a_missing_trailing_newline() {
let mut s = editor_with("aa\nbb");
alt_code(&mut s, KeyCode::Down);
assert_eq!(buffer_text(&s), "bb\naa", "no trailing newline appears");
undo(&mut s);
assert_eq!(buffer_text(&s), "aa\nbb", "one undo step");
exec(&s, "pmacs.editor.goto_byte(4)");
alt_code(&mut s, KeyCode::Up);
assert_eq!(buffer_text(&s), "bb\naa");
}
#[test]
fn duplicate_line_copies_below_with_the_cursor_on_the_copy() {
let mut s = editor_with("aa\nbb");
exec(&s, "pmacs.editor.goto_byte(1)");
m_x(&mut s, "edit.duplicate-line");
assert_eq!(buffer_text(&s), "aa\naa\nbb");
assert_eq!(cursor(&s), 4, "same column on the copy");
// Final line without a trailing newline.
let mut s = editor_with("aa\nbb");
exec(&s, "pmacs.editor.goto_byte(4)");
m_x(&mut s, "edit.duplicate-line");
assert_eq!(buffer_text(&s), "aa\nbb\nbb", "invariant preserved");
assert_eq!(cursor(&s), 7);
}
#[test]
fn join_line_collapses_the_junction_to_one_space() {
let mut s = editor_with("foo \n bar");
exec(&s, "pmacs.editor.goto_byte(9)");
alt(&mut s, '^');
assert_eq!(buffer_text(&s), "foo bar");
assert_eq!(cursor(&s), 3, "cursor at the junction");
undo(&mut s);
assert_eq!(buffer_text(&s), "foo \n bar", "one undo step");
}
#[test]
fn join_line_uses_no_space_when_a_side_is_empty() {
let mut s = editor_with("\nbar");
exec(&s, "pmacs.editor.goto_byte(2)");
alt(&mut s, '^');
assert_eq!(buffer_text(&s), "bar", "blank previous line: no space");
let mut s = editor_with("foo\n ");
exec(&s, "pmacs.editor.goto_byte(5)");
alt(&mut s, '^');
assert_eq!(
buffer_text(&s),
"foo",
"whitespace-only current line: no space"
);
}
#[test]
fn join_line_no_ops_on_the_first_line() {
let mut s = editor_with("foo\nbar");
alt(&mut s, '^');
assert_eq!(buffer_text(&s), "foo\nbar");
assert!(status(&s).contains("first line"));
}
// ---------------------------------------------------------------------------
// Region line ops (Q#EC8)
// ---------------------------------------------------------------------------
fn select_range(s: &EditorState, start: i64, end: i64) {
exec(s, &format!("pmacs.editor.begin_selection({start})"));
exec(s, &format!("pmacs.editor.goto_byte({end})"));
}
#[test]
fn sort_lines_uses_byte_order_in_any_locale() {
let mut s = editor_with("b\nA\na\nB\n");
select_range(&s, 0, 8);
m_x(&mut s, "edit.sort-lines");
assert_eq!(
buffer_text(&s),
"A\nB\na\nb\n",
"explicit byte comparator: uppercase before lowercase"
);
assert!(region_is_nil(&s));
assert_eq!(cursor(&s), 0);
undo(&mut s);
assert_eq!(buffer_text(&s), "b\nA\na\nB\n", "one undo step");
}
#[test]
fn sort_lines_excludes_a_line_when_the_region_ends_at_its_bol() {
let mut s = editor_with("c\nb\na\n");
select_range(&s, 0, 4); // ends exactly at the BOL of "a"
m_x(&mut s, "edit.sort-lines");
assert_eq!(buffer_text(&s), "b\nc\na\n", "the third line is untouched");
}
#[test]
fn sort_lines_preserves_a_missing_final_newline() {
let mut s = editor_with("b\na");
select_range(&s, 0, 3);
m_x(&mut s, "edit.sort-lines");
assert_eq!(buffer_text(&s), "a\nb");
}
#[test]
fn reverse_and_dedupe_lines() {
let mut s = editor_with("a\nb\nc\n");
select_range(&s, 0, 6);
m_x(&mut s, "edit.reverse-lines");
assert_eq!(buffer_text(&s), "c\nb\na\n");
let mut s = editor_with("x\ny\nx\nz\n");
select_range(&s, 0, 8);
m_x(&mut s, "edit.delete-duplicate-lines");
assert_eq!(buffer_text(&s), "x\ny\nz\n", "first occurrence kept");
assert!(status(&s).contains("1 removed"));
}
#[test]
fn region_ops_require_a_region() {
let mut s = editor_with("b\na\n");
m_x(&mut s, "edit.sort-lines");
assert_eq!(buffer_text(&s), "b\na\n");
assert!(status(&s).contains("no active region"));
}
// ---------------------------------------------------------------------------
// Intercept discipline (Q#EC2)
// ---------------------------------------------------------------------------
#[test]
fn rejecting_intercept_leaves_everything_alone() {
let mut s = editor_with("abc");
exec(
&s,
"pmacs.buffer.add_intercept(pmacs.window.buffer(), function() error('ro') end)",
);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 't');
assert_eq!(buffer_text(&s), "abc");
assert_eq!(cursor(&s), 1, "no cursor motion");
assert!(status(&s).contains("rejected by buffer intercept"));
}
#[test]
fn transforming_intercept_translates_and_clamps_the_cursor() {
// Expanding replace that shrinks the buffer below the old cursor:
// dedupe of three identical lines, with the intercept widening the
// replace to the whole buffer. Unrepaired, the cursor (9) would
// strand past the new length (3).
let mut s = editor_with("aa\naa\naa\nbb");
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "replace" then
local b = pmacs.window.buffer()
return { kind = "replace", start = 0, ["end"] = b:len() }
end
end)
"#,
);
select_range(&s, 0, 9);
m_x(&mut s, "edit.delete-duplicate-lines");
assert!(status(&s).contains("altered by buffer intercept"));
assert_eq!(buffer_text(&s), "aa\n", "the intercept's replace stands");
assert_eq!(cursor(&s), 3, "translated and clamped, never past len");
assert!(region_is_nil(&s), "stale selection cleared");
}
#[test]
fn context_switching_intercept_skips_all_fixup() {
let mut s = editor_with("abc");
exec(
&s,
r#"
OTHER = pmacs.buffer.create("*editops-other*")
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
pmacs.window.switch_buffer(OTHER)
end)
"#,
);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 't');
assert!(status(&s).contains("context changed"));
let name: String = eval(
&s,
"return pmacs.describe.buffer(pmacs.window.buffer()).name",
);
assert_eq!(name, "*editops-other*", "the switch stands");
assert_eq!(cursor(&s), 0, "the switched-to context is untouched");
let other_len: i64 = eval(&s, "return OTHER:len()");
assert_eq!(other_len, 0);
}
#[test]
fn zero_length_anchor_does_not_reactivate_as_a_selection() {
// The command must MOVE the cursor for this pin to be non-vacuous:
// mid-word upcase travels to the word end.
let mut s = editor_with("foo bar");
exec(&s, "pmacs.editor.begin_selection(0)");
alt(&mut s, 'u');
assert_eq!(buffer_text(&s), "FOO bar");
assert_eq!(cursor(&s), 3);
assert!(
region_is_nil(&s),
"the dormant anchor must not span the cursor's travel"
);
}
// ---------------------------------------------------------------------------
// delete-trailing-whitespace (Q#EC9)
// ---------------------------------------------------------------------------
#[test]
fn trim_command_trims_and_translates_the_cursor() {
let mut s = editor_with("ab \ncd\t\t\nef");
exec(&s, "pmacs.editor.goto_byte(3)"); // inside the first run
m_x(&mut s, "edit.delete-trailing-whitespace");
assert_eq!(buffer_text(&s), "ab\ncd\nef");
assert_eq!(cursor(&s), 2, "inside a trimmed run: its start");
assert!(status(&s).contains("trimmed 2 lines"));
}
#[test]
fn trim_shifts_a_cursor_after_the_runs() {
let mut s = editor_with("ab \ncd");
exec(&s, "pmacs.editor.goto_byte(6)"); // 'c'
m_x(&mut s, "edit.delete-trailing-whitespace");
assert_eq!(buffer_text(&s), "ab\ncd");
assert_eq!(cursor(&s), 3, "shifted left through the deleted run");
}
#[test]
fn trim_undo_grain_is_one_step_per_line() {
let mut s = editor_with("a \nb \n");
m_x(&mut s, "edit.delete-trailing-whitespace");
assert_eq!(buffer_text(&s), "a\nb\n");
// Applied bottom-up, so line 1's run was deleted LAST and is
// restored by the FIRST undo: one step per trimmed line.
undo(&mut s);
assert_eq!(buffer_text(&s), "a \nb\n");
undo(&mut s);
assert_eq!(buffer_text(&s), "a \nb \n");
}
#[test]
fn trim_partial_sweep_stops_and_still_translates() {
let mut s = editor_with("a \nb \nc");
exec(&s, "pmacs.editor.goto_byte(6)"); // 'c'
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" and op.start < 3 then error("no") end
end)
"#,
);
m_x(&mut s, "edit.delete-trailing-whitespace");
assert_eq!(
buffer_text(&s),
"a \nb\nc",
"line 2 trimmed, line 1 rejected"
);
assert!(status(&s).contains("line 1 rejected"));
assert_eq!(cursor(&s), 5, "translated through the one landed delete");
}
#[test]
fn trim_mid_sweep_context_switch_stops_the_sweep() {
let mut s = editor_with("a \nb \nc");
exec(
&s,
r#"
OTHER = pmacs.buffer.create("*trim-other*")
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then pmacs.window.switch_buffer(OTHER) end
end)
"#,
);
m_x(&mut s, "edit.delete-trailing-whitespace");
assert!(status(&s).contains("context changed"));
let name: String = eval(
&s,
"return pmacs.describe.buffer(pmacs.window.buffer()).name",
);
assert_eq!(name, "*trim-other*");
assert_eq!(cursor(&s), 0, "no fix-up in the switched-to context");
// The bottom-most run was deleted (cleanly) before the guard
// tripped; the earlier line's run must be untouched.
let orig: String = eval(
&s,
"return (function() local b for _, id in ipairs(pmacs.buffer.list()) do local d = pmacs.describe.buffer(id) if d.name ~= '*trim-other*' then b = id end end return b:slice(0, b:len()) end)()",
);
assert_eq!(orig, "a \nb\nc", "sweep stopped at the switch");
}
// ---- trim-on-save -----------------------------------------------------------
fn temp_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("pmacs-editops-{}-{}", std::process::id(), name));
p
}
#[test]
fn trim_on_save_defaults_off_and_writes_untouched_bytes() {
let path = temp_path("off.txt");
std::fs::write(&path, "x \ny\t\n").unwrap();
let mut s = EditorState::new_with_roots(&crate::iso::roots());
let on: bool = eval(&s, "return pmacs.editops.trim_on_save()");
assert!(!on, "default off");
exec(
&s,
&format!(
"pmacs.buffer.from_file({})",
lua_str(path.to_str().unwrap())
),
);
ctrl(&mut s, 'x');
ctrl(&mut s, 's');
assert_eq!(std::fs::read_to_string(&path).unwrap(), "x \ny\t\n");
let _ = std::fs::remove_file(&path);
}
#[test]
fn trim_on_save_trims_the_written_bytes_before_later_callbacks() {
let path = temp_path("on.txt");
std::fs::write(&path, "x \ny\t\n").unwrap();
let mut s = EditorState::new_with_roots(&crate::iso::roots());
exec(&s, "pmacs.editops.trim_on_save(true)");
// A callback registered AFTER editops' (load-time) hook observes
// the fan-out order saveplace sees: post-trim text.
exec(
&s,
r#"
SEEN = nil
pmacs.hook.add("buffer.before-save", function()
local b = pmacs.window.buffer()
SEEN = b:slice(0, b:len())
end)
"#,
);
exec(
&s,
&format!(
"pmacs.buffer.from_file({})",
lua_str(path.to_str().unwrap())
),
);
ctrl(&mut s, 'x');
ctrl(&mut s, 's');
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"x\ny\n",
"disk trimmed"
);
let seen: String = eval(&s, "return SEEN");
assert_eq!(
seen, "x\ny\n",
"later before-save callbacks see post-trim text"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn trim_on_save_failure_never_vetoes_the_save() {
let path = temp_path("veto-immune.txt");
std::fs::write(&path, "x \n").unwrap();
let mut s = EditorState::new_with_roots(&crate::iso::roots());
exec(&s, "pmacs.editops.trim_on_save(true)");
exec(
&s,
&format!(
"pmacs.buffer.from_file({})",
lua_str(path.to_str().unwrap())
),
);
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then error("ro") end
end)
"#,
);
ctrl(&mut s, 'x');
ctrl(&mut s, 's');
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"x \n",
"the save proceeded (with the untrimmed bytes)"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn trim_on_save_unexpected_error_reports_and_still_saves() {
let path = temp_path("unexpected.txt");
std::fs::write(&path, "x \n").unwrap();
let mut s = EditorState::new_with_roots(&crate::iso::roots());
exec(&s, "pmacs.editops.trim_on_save(true)");
// Capture the pmacs.error log (the m9_6 stub pattern — the
// `if pmacs.error` branch is a no-op without it).
exec(
&s,
r"
PMACS_ERROR_LOG = {}
pmacs.error = function(msg)
PMACS_ERROR_LOG[#PMACS_ERROR_LOG + 1] = msg
end
",
);
exec(
&s,
&format!(
"pmacs.buffer.from_file({})",
lua_str(path.to_str().unwrap())
),
);
// Force an error INSIDE trim, past the per-edit pcalls: trim's
// context snapshot reads pmacs.window.current(), which nothing
// else on the save path touches.
exec(
&s,
"TRIM_ORIG_WC = pmacs.window.current; pmacs.window.current = function() error('boom') end",
);
ctrl(&mut s, 'x');
ctrl(&mut s, 's');
exec(&s, "pmacs.window.current = TRIM_ORIG_WC");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"x \n",
"the save proceeded (trim aborted before any edit)"
);
let logged: String = eval(&s, "return PMACS_ERROR_LOG[1] or ''");
assert!(
logged.contains("delete-trailing-whitespace (on save) failed:"),
"the unexpected error reached the pmacs.error log, got: {logged}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn another_callbacks_veto_is_not_masked_by_trim() {
let path = temp_path("veto.txt");
std::fs::write(&path, "x \n").unwrap();
let mut s = EditorState::new_with_roots(&crate::iso::roots());
exec(&s, "pmacs.editops.trim_on_save(true)");
exec(
&s,
&format!(
"pmacs.buffer.from_file({})",
lua_str(path.to_str().unwrap())
),
);
exec(
&s,
"pmacs.hook.add('buffer.before-save', function() return false end)",
);
ctrl(&mut s, 'x');
ctrl(&mut s, 's');
assert!(status(&s).contains("vetoed"));
// A wrongly-unvetoed save would have written the TRIMMED bytes
// (trim ran before the veto and edited the buffer).
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"x \n",
"the veto still blocked the write"
);
let _ = std::fs::remove_file(&path);
}
// 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;