513 lines
17 KiB
Rust
513 lines
17 KiB
Rust
//! Auto-indent acceptance (Arc 2, docs/auto-indent-framing.md).
|
|
//!
|
|
//! Dispatch-driven: RET through `dispatch_key`, `M-x` through the real
|
|
//! minibuffer. Auto-indent is language-agnostic (Q#AI3 copies bytes),
|
|
//! so buffers are plain in-memory scratch buffers — no files, no
|
|
//! language detection, no `StateDir`.
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
|
use pmacs::editor::EditorState;
|
|
use pmacs::protocol::FrontendId;
|
|
|
|
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 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),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn m_x(s: &mut EditorState, name: &str) {
|
|
alt(s, 'x');
|
|
type_str(s, name);
|
|
press(s, KeyCode::Enter);
|
|
}
|
|
|
|
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 editor whose active scratch buffer holds `body`, cursor at 0.
|
|
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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The indent copy (Q#AI3)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ret_at_eol_carries_the_space_indent() {
|
|
let mut s = editor_with(" foo\nbar\n");
|
|
exec(&s, "pmacs.editor.goto_byte(7)"); // end of " foo"
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), " foo\n \nbar\n");
|
|
assert_eq!(cursor(&s), 12, "cursor lands after the carried indent");
|
|
}
|
|
|
|
#[test]
|
|
fn tab_and_mixed_indents_round_trip_verbatim() {
|
|
let mut s = editor_with("\tfoo");
|
|
exec(&s, "pmacs.editor.goto_byte(4)");
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), "\tfoo\n\t", "a tab indent copies as a tab");
|
|
assert_eq!(cursor(&s), 6);
|
|
|
|
let mut s = editor_with("\t foo");
|
|
exec(&s, "pmacs.editor.goto_byte(6)");
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(
|
|
buffer_text(&s),
|
|
"\t foo\n\t ",
|
|
"mixed tab+space indents copy byte-for-byte"
|
|
);
|
|
assert_eq!(cursor(&s), 10);
|
|
}
|
|
|
|
#[test]
|
|
fn mid_line_split_carries_the_tail_onto_the_indented_line() {
|
|
let mut s = editor_with(" foobar");
|
|
exec(&s, "pmacs.editor.goto_byte(7)"); // between "foo" and "bar"
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), " foo\n bar");
|
|
assert_eq!(cursor(&s), 12, "cursor sits before the carried tail");
|
|
}
|
|
|
|
#[test]
|
|
fn split_inside_the_leading_whitespace_does_not_double_indent() {
|
|
// Q#AI3 clip rule: `··|··foo` → `··` / `····foo` — the carried
|
|
// text keeps its TOTAL indentation (4), instead of gaining the
|
|
// full 4-wide indent on top of its remaining 2 spaces.
|
|
let mut s = editor_with(" foo");
|
|
exec(&s, "pmacs.editor.goto_byte(2)");
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), " \n foo");
|
|
assert_eq!(cursor(&s), 5, "cursor after the clipped indent");
|
|
}
|
|
|
|
#[test]
|
|
fn zero_indent_and_empty_buffer_match_plain_newline() {
|
|
let mut s = editor_with("");
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), "\n");
|
|
assert_eq!(cursor(&s), 1);
|
|
|
|
let mut s = editor_with("foo");
|
|
exec(&s, "pmacs.editor.goto_byte(3)");
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), "foo\n");
|
|
assert_eq!(cursor(&s), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn giant_minified_line_splits_correctly() {
|
|
// PR #109 round 1 finding 4: the indent scan is forward-chunked
|
|
// and stops at the first non-whitespace byte, so Enter at the end
|
|
// of a huge unindented line never materializes the line. This
|
|
// pins the behavior; boundedness is by construction.
|
|
let long = "x".repeat(64 * 1024);
|
|
let mut s = editor_with(&long);
|
|
exec(&s, &format!("pmacs.editor.goto_byte({})", long.len()));
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), format!("{long}\n"));
|
|
assert_eq!(cursor(&s) as usize, long.len() + 1);
|
|
|
|
// And an indented giant line still carries exactly its indent.
|
|
let body = format!(" {long}");
|
|
let mut s = editor_with(&body);
|
|
exec(&s, &format!("pmacs.editor.goto_byte({})", body.len()));
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), format!("{body}\n "));
|
|
}
|
|
|
|
#[test]
|
|
fn whitespace_only_line_copies_and_the_abandoned_line_keeps_its_whitespace() {
|
|
// Named non-goal (Q#AI3): no trailing-whitespace cleanup on the
|
|
// line being left behind.
|
|
let mut s = editor_with(" ");
|
|
exec(&s, "pmacs.editor.goto_byte(4)");
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), " \n ");
|
|
assert_eq!(cursor(&s), 9);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Region type-over + selections (Q#AI4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn region_ret_is_one_replace_one_undo_step_and_clears_the_selection() {
|
|
let mut s = editor_with(" hello world");
|
|
exec(
|
|
&s,
|
|
"pmacs.editor.begin_selection(4); pmacs.editor.goto_byte(15)",
|
|
);
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(
|
|
buffer_text(&s),
|
|
" \n ",
|
|
"the region is replaced by newline+indent in one edit"
|
|
);
|
|
assert_eq!(cursor(&s), 9);
|
|
let region_active: bool = eval(&s, "return pmacs.editor.region() ~= nil");
|
|
assert!(!region_active, "selection clears after a region RET");
|
|
ctrl(&mut s, '/'); // buffer.undo, exactly once
|
|
assert_eq!(
|
|
buffer_text(&s),
|
|
" hello world",
|
|
"one undo restores the whole type-over"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plain_ret_is_one_undo_step() {
|
|
let mut s = editor_with(" ab");
|
|
exec(&s, "pmacs.editor.goto_byte(4)");
|
|
press(&mut s, KeyCode::Enter);
|
|
assert_eq!(buffer_text(&s), " ab\n ");
|
|
ctrl(&mut s, '/');
|
|
assert_eq!(buffer_text(&s), " ab");
|
|
}
|
|
|
|
#[test]
|
|
fn zero_length_selection_does_not_type_over_the_fresh_newline() {
|
|
// Q#AI4: an armed anchor at the cursor reports no region; the RET
|
|
// moves the cursor off it, so without the unconditional clear the
|
|
// next self-insert would replace the newline ("S-Left at BOF,
|
|
// RET, x" → "x").
|
|
let mut s = editor_with("");
|
|
exec(&s, "pmacs.editor.begin_selection(0)");
|
|
press(&mut s, KeyCode::Enter);
|
|
type_str(&mut s, "x");
|
|
assert_eq!(buffer_text(&s), "\nx", "the newline survives the 'x'");
|
|
|
|
// Q#AI9: the retained plain-newline escape hatch (through the
|
|
// fixed core arm) behaves the same.
|
|
let mut s = editor_with("");
|
|
exec(&s, "pmacs.editor.begin_selection(0)");
|
|
m_x(&mut s, "buffer.newline");
|
|
type_str(&mut s, "x");
|
|
assert_eq!(buffer_text(&s), "\nx");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Intercept discipline (Q#AI5)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn rejecting_intercept_reports_without_throwing_or_mutating() {
|
|
let mut s = editor_with(" a");
|
|
exec(&s, "pmacs.editor.goto_byte(3)");
|
|
exec(
|
|
&s,
|
|
r#"
|
|
_G.reject_once = true
|
|
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
|
|
if _G.reject_once then
|
|
_G.reject_once = false
|
|
error("rejected by test intercept")
|
|
end
|
|
return nil
|
|
end)
|
|
"#,
|
|
);
|
|
press(&mut s, KeyCode::Enter);
|
|
assert!(status(&s).contains("rejected"), "got: {:?}", status(&s));
|
|
assert_eq!(buffer_text(&s), " a");
|
|
assert_eq!(cursor(&s), 3, "no cursor motion on a rejected RET");
|
|
press(&mut s, KeyCode::Enter); // allowed again: works
|
|
assert_eq!(buffer_text(&s), " a\n ");
|
|
}
|
|
|
|
#[test]
|
|
fn relocating_intercept_moves_the_payload_but_does_not_teleport_the_cursor() {
|
|
// The only transform an insert admits (M6.4): moving its `pos`.
|
|
// The payload lands where the intercept sent it; the cursor is
|
|
// translated through the edit, NOT jumped to the remote site.
|
|
let mut s = editor_with(" abc");
|
|
exec(&s, "pmacs.editor.goto_byte(5)");
|
|
exec(
|
|
&s,
|
|
r#"
|
|
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
|
|
if op.kind == "insert" then
|
|
return { kind = "insert", pos = 0, bytes = op.bytes }
|
|
end
|
|
return nil
|
|
end)
|
|
"#,
|
|
);
|
|
press(&mut s, KeyCode::Enter);
|
|
assert!(status(&s).contains("altered"), "got: {:?}", status(&s));
|
|
assert_eq!(
|
|
buffer_text(&s),
|
|
"\n abc",
|
|
"the newline+indent payload landed at the intercept's position"
|
|
);
|
|
assert_eq!(
|
|
cursor(&s),
|
|
8,
|
|
"cursor shifted right by the inserted length (5+3), not teleported to the edit"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shrinking_intercept_leaves_a_valid_cursor_and_no_selection() {
|
|
// Only a replace can shrink the buffer (M6.4): the intercept
|
|
// expands the replaced range past the payload. The cursor must be
|
|
// right-gravity-translated into the shrunken buffer — validity,
|
|
// not immobility — and the selection cleared.
|
|
let mut s = editor_with(" hello world wide");
|
|
exec(
|
|
&s,
|
|
"pmacs.editor.begin_selection(2); pmacs.editor.goto_byte(7)",
|
|
);
|
|
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"] + 8,
|
|
bytes = op.bytes,
|
|
}
|
|
end
|
|
return nil
|
|
end)
|
|
"#,
|
|
);
|
|
press(&mut s, KeyCode::Enter);
|
|
assert!(status(&s).contains("altered"), "got: {:?}", status(&s));
|
|
assert_eq!(buffer_text(&s), " \n ide", "the expanded replace stands");
|
|
let len: i64 = eval(&s, "return pmacs.window.buffer():len()");
|
|
assert_eq!(cursor(&s), 5, "cursor translated to the edit's new end");
|
|
assert!(cursor(&s) <= len, "cursor within the shrunken buffer");
|
|
let region_active: bool = eval(&s, "return pmacs.editor.region() ~= nil");
|
|
assert!(!region_active, "selection cleared under the same guard");
|
|
}
|
|
|
|
#[test]
|
|
fn context_switching_intercept_skips_fixup_and_leaves_the_new_context_alone() {
|
|
// Q#AI5 guard: an intercept may switch the active window/buffer
|
|
// (the registry borrow is released). The fix-up must not touch
|
|
// whatever is active afterwards. This proves the NEW context is
|
|
// untouched; the original window's state after such an intercept
|
|
// is the substrate-reconciliation deferral's territory.
|
|
let mut s = editor_with(" a");
|
|
exec(&s, "pmacs.editor.goto_byte(3)");
|
|
exec(
|
|
&s,
|
|
r#"
|
|
_G.orig = pmacs.window.buffer()
|
|
_G.other = pmacs.buffer.create("*other*")
|
|
pmacs.buffer.add_intercept(_G.orig, function(_op)
|
|
pmacs.window.switch_buffer(_G.other)
|
|
return nil
|
|
end)
|
|
"#,
|
|
);
|
|
press(&mut s, KeyCode::Enter);
|
|
assert!(
|
|
status(&s).contains("context changed"),
|
|
"got: {:?}",
|
|
status(&s)
|
|
);
|
|
let name: String = eval(&s, "return pmacs.window.buffer():name()");
|
|
assert_eq!(name, "*other*", "the intercept's buffer switch stands");
|
|
assert_eq!(cursor(&s), 0, "the new context's cursor is untouched");
|
|
let orig: mlua::String = eval(&s, "return _G.orig:slice(0, _G.orig:len())");
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&orig.as_bytes()),
|
|
" a\n ",
|
|
"the edit itself landed in the original buffer"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search staleness through RET (Q#AI8)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn accepted_search_navigation_fails_closed_after_ret() {
|
|
let mut s = editor_with("ind ind ind");
|
|
ctrl(&mut s, 's');
|
|
type_str(&mut s, "ind");
|
|
press(&mut s, KeyCode::Enter); // accept: matches stay until an edit
|
|
exec(&s, "pmacs.editor.goto_byte(11)");
|
|
press(&mut s, KeyCode::Enter); // auto-indent RET marks them stale
|
|
assert_eq!(buffer_text(&s), "ind ind ind\n");
|
|
let at = cursor(&s);
|
|
exec(&s, "pmacs.editor.search_step(true)");
|
|
assert_eq!(
|
|
cursor(&s),
|
|
at,
|
|
"post-accept navigation is a no-op once RET staled the matches"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_lua_edit_stales_accepted_search_navigation() {
|
|
let mut s = editor_with("foo foo");
|
|
ctrl(&mut s, 's');
|
|
type_str(&mut s, "foo");
|
|
press(&mut s, KeyCode::Enter); // accept
|
|
exec(&s, "pmacs.window.buffer():insert(0, \"zz\")"); // notify path
|
|
let at = cursor(&s);
|
|
exec(&s, "pmacs.editor.search_step(true)");
|
|
assert_eq!(
|
|
cursor(&s),
|
|
at,
|
|
"a direct buf:insert must stale the matches like any edit"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Substrate plumbing (Q#AI7)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn after_edit_fires_exactly_once_per_ret_keybound_and_m_x() {
|
|
let mut s = editor_with(" a");
|
|
exec(&s, "pmacs.editor.goto_byte(3)");
|
|
exec(
|
|
&s,
|
|
"_G.ae = 0; pmacs.hook.add('buffer.after-edit', function() _G.ae = _G.ae + 1 end)",
|
|
);
|
|
press(&mut s, KeyCode::Enter);
|
|
let n: i64 = eval(&s, "return _G.ae");
|
|
assert_eq!(n, 1, "keybound RET fires after-edit once");
|
|
m_x(&mut s, "edit.newline-and-indent");
|
|
assert_eq!(buffer_text(&s), " a\n \n ");
|
|
let n: i64 = eval(&s, "return _G.ae");
|
|
assert_eq!(n, 2, "M-x RET fires after-edit exactly once more");
|
|
}
|
|
|
|
#[test]
|
|
fn ret_between_kills_breaks_the_kill_chain() {
|
|
let mut s = editor_with("one\ntwo\nthree\n");
|
|
ctrl(&mut s, 'k'); // kills "one"; line now blank, cursor 0
|
|
press(&mut s, KeyCode::Enter); // rotates the command boundary
|
|
ctrl(&mut s, 'k'); // kills "\n" — must push fresh, not append
|
|
let ring: Vec<String> = eval(&s, "return pmacs.killring.list()");
|
|
assert_eq!(
|
|
ring,
|
|
vec!["\n", "one"],
|
|
"C-k, RET, C-k yields two ring entries (chain broken)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn this_command_during_ret_is_the_new_command() {
|
|
let mut s = editor_with("");
|
|
exec(
|
|
&s,
|
|
"pmacs.hook.add('buffer.after-edit', function() _G.tc = pmacs.editor.this_command() end)",
|
|
);
|
|
press(&mut s, KeyCode::Enter);
|
|
let tc: String = eval(&s, "return _G.tc");
|
|
assert_eq!(tc, "edit.newline-and-indent");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Contexts RET must not disturb (ground truth: consumed before the keymap)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn minibuffer_and_buffer_list_ret_are_unaffected() {
|
|
// The m_x helper itself proves minibuffer accept (used throughout
|
|
// this suite). The classic buffer list's RET is a buffer-local
|
|
// binding through normal dispatch (ground truth) — it must visit,
|
|
// not newline-and-indent into the list.
|
|
let mut s = editor_with("hello");
|
|
ctrl(&mut s, 'x');
|
|
ctrl(&mut s, 'b');
|
|
let name: String = eval(&s, "return pmacs.window.buffer():name()");
|
|
assert_eq!(name, "*buffer-list*");
|
|
let listed = buffer_text(&s);
|
|
exec(&s, "_G.list = pmacs.window.buffer()");
|
|
press(&mut s, KeyCode::Enter); // buffer-local RET: visit
|
|
let name: String = eval(&s, "return pmacs.window.buffer():name()");
|
|
assert_ne!(name, "*buffer-list*", "RET visits instead of inserting");
|
|
let list_after: String = eval(
|
|
&s,
|
|
"if not _G.list:is_valid() then return \"<gone>\" end \
|
|
return _G.list:slice(0, _G.list:len())",
|
|
);
|
|
assert!(
|
|
list_after == "<gone>" || list_after == listed,
|
|
"no newline landed in the buffer list"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn isearch_ret_accepts_instead_of_inserting() {
|
|
let mut s = editor_with("abc abc");
|
|
ctrl(&mut s, 's');
|
|
type_str(&mut s, "abc");
|
|
press(&mut s, KeyCode::Enter); // isearch accept, consumed pre-keymap
|
|
assert_eq!(
|
|
buffer_text(&s),
|
|
"abc abc",
|
|
"RET during isearch accepts; no newline is inserted"
|
|
);
|
|
}
|
|
|
|
// 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;
|