pmacs/tests/compile_mode_acceptance.rs

2628 lines
90 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Compile-mode acceptance (Arc 5 stage 1,
//! docs/compile-mode-framing.md, items 133; item 34 lives as unit
//! tests in src/process.rs, item 35 in
//! `tests/compile_mode_crdt_acceptance.rs`).
//!
//! Dispatch-driven: every keybinding claim is exercised through
//! `dispatch_key` (never `pmacs.command.invoke`), per the standing
//! discipline — a dead binding must fail these tests. Process output
//! is pumped through `tick_processes` (the production
//! `process.after-tick` path); grep streams through `tick_async`.
//! Fixtures are `/bin/sh` scripts materialized in tempdirs.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
use std::path::Path;
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
// Harness (auto_pair_acceptance conventions + m6_5 pump pattern)
// ---------------------------------------------------------------------------
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 ctrl_shift(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(
KeyCode::Char(c),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
);
}
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 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 status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
fn errors_buffer(s: &EditorState) -> String {
s.lua_host.errors_buffer_text()
}
/// Fresh editor with LSP spawning disabled (language detection still
/// works; the after-load hook must not exec real servers).
fn editor() -> EditorState {
let s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
s
}
fn write_script(dir: &Path, name: &str, body: &str) -> String {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
p.display().to_string()
}
/// Text of the buffer named `name`, or empty when absent.
fn named_text(s: &EditorState, name: &str) -> String {
let b: mlua::String = eval(
s,
&format!(
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == {name:?} then
return id:slice(0, id:len())
end
end
return ""
"#
),
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
fn active_buffer_name(s: &EditorState) -> String {
eval(
s,
"return pmacs.describe.buffer(pmacs.window.buffer()).name",
)
}
fn compilation_text(s: &EditorState) -> String {
named_text(s, "*compilation*")
}
fn process_count(s: &EditorState) -> i64 {
eval(s, "return #pmacs.process.list()")
}
/// Drive frames until `pred` holds. Pumps both the process
/// supervisor (compile/shell) and the async runtime (grep workers).
fn pump_until(
s: &mut EditorState,
timeout_ms: u64,
mut pred: impl FnMut(&EditorState) -> bool,
) -> bool {
let stop = Instant::now() + Duration::from_millis(timeout_ms);
loop {
if pred(s) {
return true;
}
if Instant::now() >= stop {
return false;
}
s.tick_processes();
s.tick_async();
std::thread::sleep(Duration::from_millis(5));
}
}
/// Start a compile run programmatically with an explicit cwd.
fn compile_run(s: &EditorState, cmdline: &str, cwd: &Path) {
exec(
s,
&format!(
"pmacs.compile.run({cmdline:?}, {{ cwd = {:?} }})",
cwd.display().to_string()
),
);
}
/// Run `cmdline` and pump to its exit marker. Panics on timeout.
fn compile_and_finish(s: &mut EditorState, cmdline: &str, cwd: &Path) {
compile_run(s, cmdline, cwd);
assert!(
pump_until(s, 10_000, |s| compilation_text(s).contains("[compile ")),
"compile run must reach its exit marker; buffer:\n{}",
compilation_text(s)
);
}
/// Poll `path` for a pid the fixture script wrote there.
fn wait_pidfile(s: &mut EditorState, path: &Path) -> i32 {
let mut pid = None;
pump_until(s, 5_000, |_| {
if let Ok(body) = std::fs::read_to_string(path)
&& let Ok(p) = body.trim().parse::<i32>()
{
pid = Some(p);
return true;
}
false
});
pid.expect("fixture pidfile must appear")
}
fn pid_alive(pid: i32) -> bool {
// `kill -0` probe via /bin/kill: portable across Linux and macOS
// (a /proc existence check has no macOS equivalent and would
// make every "descendant is dead" assertion vacuously true
// there).
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.is_ok_and(|o| o.status.success())
}
/// Errors getter marshalled to a comparable Rust shape (encoded as
/// one line per entry — mlua tuples don't implement `FromLua`).
fn compile_errors(s: &EditorState) -> Vec<(String, i64, i64, Option<String>)> {
let encoded: String = eval(
s,
r#"
local out = {}
for _, e in ipairs(pmacs.compile.errors()) do
out[#out + 1] = string.format("%s|%d|%d|%s",
e.file, e.line, e.col, e.severity or "-")
end
return table.concat(out, "\n")
"#,
);
encoded
.lines()
.map(|l| {
let mut parts = l.split('|');
let file = parts.next().unwrap().to_owned();
let line = parts.next().unwrap().parse().unwrap();
let col = parts.next().unwrap().parse().unwrap();
let sev = match parts.next().unwrap() {
"-" => None,
s => Some(s.to_owned()),
};
(file, line, col, sev)
})
.collect()
}
/// Rendered cells of the active window (copied from the
/// `m4_acceptance` grid helper — cross-crate test code can't import).
fn render_active_window_to_grid(
state: &mut EditorState,
rows: u32,
cols: u32,
) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid, CellSize};
use pmacs::view::{View, Viewport};
use pmacs::window::Rect;
let mut core = state.core.borrow_mut();
let active = core.active_window_id();
let registry = core.registry.clone();
let win = core.windows.get_mut(&active).expect("active window");
let rect = Rect::new(0, 0, rows, cols);
let cell_count = (rect.size.rows * rect.size.cols) as usize;
let mut backing = vec![Cell::default(); cell_count];
let reg = registry.borrow();
let buf = reg.get(win.buffer_id).expect("buffer in registry");
let viewport = Viewport {
buffer_start: 0,
buffer_end: buf.len(),
cell_origin: rect.origin,
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
gutter_w: 0,
};
let mut grid = CellGrid {
cells: &mut backing,
stride: rect.size.cols,
size: CellSize::new(rect.size.rows, rect.size.cols),
};
win.text_view.render(buf, viewport, &mut grid);
for overlay in &mut win.overlays {
overlay.render(buf, viewport, &mut grid);
}
backing
}
fn any_styled_cell(cells: &[pmacs::cell::Cell]) -> bool {
cells
.iter()
.any(|c| c.style != pmacs::cell::Style::default())
}
/// `(glyph, fg)` for the first `n` cells of the grid row whose glyphs
/// spell `prefix`; panics if no row matches. Round-5 tests pin exact
/// per-cell colors — `any_styled_cell` cannot see a wrong color on
/// the right glyph.
fn styled_row(
cells: &[pmacs::cell::Cell],
rows: u32,
cols: u32,
prefix: &str,
n: usize,
) -> Vec<(char, pmacs::cell::Color)> {
let glyph_at = |r: u32, c: u32| match cells[(r * cols + c) as usize].glyph {
pmacs::cell::Glyph::Char(ch) => ch,
_ => ' ',
};
for r in 0..rows {
let line: String = (0..cols).map(|c| glyph_at(r, c)).collect();
if line.starts_with(prefix) {
return (0..n)
.map(|i| {
let cell = &cells[(r * cols) as usize + i];
(glyph_at(r, i as u32), cell.style.fg)
})
.collect();
}
}
panic!("no rendered row starts with {prefix:?}");
}
const DESYNC: &str = "[output desynced by external edit]";
// ---------------------------------------------------------------------------
// 14: spawn shape, read-only, merged interleaving, EOF
// ---------------------------------------------------------------------------
#[test]
fn acc01_spawn_streams_header_output_and_exit_marker() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_and_finish(&mut s, "printf 'hello\\nworld\\n'", dir.path());
let text = compilation_text(&s);
assert!(
text.starts_with("$ printf"),
"header leads with the command; got:\n{text}"
);
assert!(
text.contains(&format!("Directory: {}", dir.path().display())),
"header names the resolved cwd; got:\n{text}"
);
assert!(text.contains("hello\nworld\n"), "output streamed:\n{text}");
assert!(
text.contains("[compile exited with code 0]"),
"exit marker with code:\n{text}"
);
assert!(
status(&s).contains("finished"),
"completion status; got: {}",
status(&s)
);
assert_eq!(active_buffer_name(&s), "*compilation*", "switch-in-place");
}
#[test]
fn acc02_buffer_is_read_only_under_dispatch() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_and_finish(&mut s, "echo out", dir.path());
let before = compilation_text(&s);
type_str(&mut s, "x");
assert_eq!(
compilation_text(&s),
before,
"dispatched typing must be rejected by the read-only intercept"
);
}
#[test]
fn acc03_stderr_interleaves_in_emission_order() {
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"mix.sh",
"echo out1\necho err1 >&2\necho out2\necho err2 >&2\n",
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let text = compilation_text(&s);
assert!(
text.contains("out1\nerr1\nout2\nerr2\n"),
"child-boundary merge preserves emission order (per-tick \
stdout-then-stderr coalescing would reorder); got:\n{text}"
);
}
#[test]
fn acc04_stdin_eof_lets_cat_terminate() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
let t0 = Instant::now();
compile_and_finish(&mut s, "cat; echo done", dir.path());
assert!(
compilation_text(&s).contains("\ndone\n"),
"cat must see EOF and fall through (line-start match — the \
header echoes the command and would match bare 'done')"
);
assert!(
compilation_text(&s).contains("exited with code 0"),
"clean exit"
);
assert!(
t0.elapsed() < Duration::from_secs(5),
"must not hang on piped stdin"
);
}
// ---------------------------------------------------------------------------
// 59: group lifecycle through the editor surface
// ---------------------------------------------------------------------------
#[test]
fn acc05_kill_reaps_backgrounded_descendant() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("pid");
let mut s = editor();
compile_run(
&s,
&format!("sleep 30 & echo $! > {}; wait", pidfile.display()),
dir.path(),
);
let pid = wait_pidfile(&mut s, &pidfile);
ctrl(&mut s, 'c');
ctrl(&mut s, 'k');
assert!(
pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile killed by")),
"kill must produce a signaled exit marker; buffer:\n{}",
compilation_text(&s)
);
assert!(
pump_until(&mut s, 3_000, |_| !pid_alive(pid)),
"group-directed kill must reap the backgrounded descendant \
(positive-pid SIGTERM would strand it)"
);
assert!(
pump_until(&mut s, 3_000, |s| process_count(s) == 0),
"process list returns to baseline"
);
}
#[test]
fn acc06_leader_exit_without_wait_completes_promptly_and_reaps() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("pid");
let mut s = editor();
let t0 = Instant::now();
compile_run(
&s,
&format!("sleep 30 & echo $! > {}", pidfile.display()),
dir.path(),
);
assert!(
pump_until(&mut s, 5_000, |s| compilation_text(s)
.contains("[compile exited")),
"leader exit must be observed without waiting on the descendant"
);
assert!(
t0.elapsed() < Duration::from_millis(2500),
"the run must not ride the 2s drain timeout; took {:?}",
t0.elapsed()
);
let pid = wait_pidfile(&mut s, &pidfile);
assert!(
pump_until(&mut s, 3_000, |_| !pid_alive(pid)),
"leader-exit reap must kill the pipe-holding descendant"
);
assert!(
pump_until(&mut s, 3_000, |s| process_count(s) == 0),
"process list returns to baseline"
);
}
#[test]
fn acc07_term_trapping_child_falls_to_sigkill() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_run(&s, "trap '' TERM; echo ready; sleep 30", dir.path());
// "\nready\n" — the OUTPUT line, not the header's echo of the
// command (matching the header raced the kill ahead of the trap
// installation and let plain SIGTERM win).
assert!(
pump_until(&mut s, 5_000, |s| compilation_text(s).contains("\nready\n")),
"trap must be installed before we kill"
);
let t0 = Instant::now();
ctrl(&mut s, 'c');
ctrl(&mut s, 'k');
assert!(
pump_until(&mut s, 5_000, |s| compilation_text(s)
.contains("killed by SIGKILL")),
"TERM-trapping child must fall to the ledger's SIGKILL; buffer:\n{}",
compilation_text(&s)
);
assert!(
t0.elapsed() < Duration::from_secs(2),
"escalation lands near the 500ms grace, not the drain timeout; took {:?}",
t0.elapsed()
);
assert!(
pump_until(&mut s, 3_000, |s| process_count(s) == 0),
"baseline restored"
);
}
/// Fixture: background a TERM-ignoring survivor; the leader exits
/// only after the survivor's trap is INSTALLED (readiness file).
/// Without the gate, a slow scheduler (macOS CI, observed) delivers
/// the leader-exit group-TERM before the subshell's `trap` runs and
/// kills the "survivor" — making these assertions vacuous. Mirrors
/// the process.rs unit fixture.
fn survivor_cmdline(dir: &Path, redirect: bool) -> (String, std::path::PathBuf) {
let pidfile = dir.join("pid");
let ready = dir.join("ready");
let redirect_part = if redirect {
"exec >/dev/null 2>&1; "
} else {
""
};
let cmdline = format!(
"( trap '' TERM; : > {ready}; {redirect_part}sleep 30 ) & echo $! > {pid}; \
while [ ! -e {ready} ]; do sleep 0.01; done",
ready = ready.display(),
pid = pidfile.display(),
);
(cmdline, pidfile)
}
#[test]
fn acc08_ledger_reaps_term_ignoring_redirected_survivor() {
// The bite: the survivor ignores TERM and sheds its output, so
// the terminal event arrives AND the readers finish — only the
// liveness probe can catch it.
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
let (cmdline, pidfile) = survivor_cmdline(dir.path(), true);
compile_run(&s, &cmdline, dir.path());
assert!(
pump_until(&mut s, 5_000, |s| compilation_text(s)
.contains("exited with code 0")),
"leader exits cleanly"
);
let pid = wait_pidfile(&mut s, &pidfile);
assert!(
pump_until(&mut s, 3_000, |_| !pid_alive(pid)),
"the kill(-pgid, 0) probe must reap the redirected survivor \
(leader- or reader-conditioned escalation never fires here)"
);
}
#[test]
fn acc09_pipe_holding_survivor_bounded_tick_latency() {
// Non-redirected twin of acc08: the descendant KEEPS fd1, so the
// readers stay alive — in-drain ledger enforcement must SIGKILL
// at the grace bound instead of blocking ~2s.
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
let (cmdline, _pidfile) = survivor_cmdline(dir.path(), false);
compile_run(&s, &cmdline, dir.path());
let stop = Instant::now() + Duration::from_secs(6);
let mut max_tick = Duration::ZERO;
let mut done = false;
while Instant::now() < stop {
let t = Instant::now();
s.tick_processes();
max_tick = max_tick.max(t.elapsed());
if compilation_text(&s).contains("[compile exited") {
done = true;
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert!(done, "run must complete; buffer:\n{}", compilation_text(&s));
assert!(
max_tick < Duration::from_millis(1200),
"blocking tick bounded by ~grace + 2 poll intervals, not the \
2s drain timeout; max tick {max_tick:?}"
);
}
// ---------------------------------------------------------------------------
// 1014: error parsing
// ---------------------------------------------------------------------------
#[test]
fn acc10_starter_rules_parse_and_normalize() {
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"diag.sh",
concat!(
"printf 'error[E0308]: mismatched types\\n'\n",
"printf ' --> src/foo.rs:3:5\\n'\n",
"printf 'foo.c:7:2: warning: unused variable\\n'\n",
"printf 'Traceback (most recent call last):\\n'\n",
"printf ' File \"bar.py\", line 9\\n'\n",
),
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let errors = compile_errors(&s);
assert_eq!(
errors,
vec![
// rustc arrow: 1-based 3:5 → 0-based (2,4); the arrow
// line carries no severity token → nil (navigable but
// uncolored, per Q#CM4).
("src/foo.rs".to_owned(), 2, 4, None),
// gcc-style colocates the keyword → sniffed severity.
("foo.c".to_owned(), 6, 1, Some("warning".to_owned())),
// Python frame: no column → col 0; no severity token.
("bar.py".to_owned(), 8, 0, None),
],
"starter-rule parse + 0-based normalization"
);
}
#[test]
fn acc11_sub_one_coordinates_fail_closed() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_and_finish(&mut s, "printf 'foo.rs:0:0: error: boom\\n'", dir.path());
assert!(
compile_errors(&s).is_empty(),
"a 0:0 capture must be discarded, not stored as -1; got {:?}",
compile_errors(&s)
);
}
#[test]
fn acc12_custom_rule_severity_override_and_malformed_rejection() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
exec(
&s,
r#"
pmacs.compile.rules = {
{ pattern = "x", file = 1, line = 1, severity = "fatal" },
{ pattern = "(z%.txt):(%d+):", file = 1, line = 2, severity = "warning" },
}
"#,
);
// The skip note is a transient status set at run start; capture
// it before the completion status overwrites it.
compile_run(&s, "printf 'z.txt:5: error: boom\\n'", dir.path());
assert!(
status(&s).contains("skipped 1 malformed"),
"the severity=\"fatal\" entry is rejected and counted; got: {}",
status(&s)
);
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
let errors = compile_errors(&s);
assert_eq!(
errors,
vec![("z.txt".to_owned(), 4, 0, Some("warning".to_owned()))],
"the severity field overrides the sniffed 'error' keyword"
);
}
#[test]
fn acc13_unterminated_final_line_still_parses() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
// No trailing newline: complete at EOF, parsed at the terminal
// event (bite: fails without the finalization pass).
compile_and_finish(&mut s, "printf 'x.c:3:1: error: no newline'", dir.path());
assert_eq!(
compile_errors(&s),
vec![("x.c".to_owned(), 2, 0, Some("error".to_owned()))],
"final unterminated diagnostic must not be dropped"
);
}
#[test]
fn acc14_malformed_rule_containers_fail_closed() {
let dir = tempfile::tempdir().unwrap();
// (a) top-level non-table degrades to the built-in defaults.
let mut s = editor();
exec(&s, "pmacs.compile.rules = 42");
compile_run(&s, "printf 'a.c:1:1: error: e\\n'", dir.path());
assert!(
status(&s).contains("not a table"),
"one degradation note at run start; got: {}",
status(&s)
);
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
assert_eq!(
compile_errors(&s).len(),
1,
"built-in defaults still parse under a non-table container"
);
assert!(
errors_buffer(&s).is_empty(),
"no error spam: {}",
errors_buffer(&s)
);
// (b) an invalid-pattern entry is skipped; a later valid entry
// still matches; one status note counts the skip.
let mut s = editor();
exec(
&s,
r#"
pmacs.compile.rules = {
{ pattern = "([", file = 1, line = 2 },
{ pattern = "(b%.c):(%d+):", file = 1, line = 2 },
}
"#,
);
compile_run(&s, "printf 'b.c:4: error: e\\n'", dir.path());
assert!(
status(&s).contains("skipped 1 malformed"),
"one status note at run start; got: {}",
status(&s)
);
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
assert_eq!(
compile_errors(&s),
vec![("b.c".to_owned(), 3, 0, Some("error".to_owned()))],
"the valid entry still matches after the malformed one"
);
assert!(
errors_buffer(&s).is_empty(),
"no error spam: {}",
errors_buffer(&s)
);
}
// ---------------------------------------------------------------------------
// 1518: navigation
// ---------------------------------------------------------------------------
/// Fixture: a target file plus a compile run reporting one error at
/// target.c:3:2. Returns the editor, finished, in *compilation*.
fn error_fixture(dir: &Path) -> EditorState {
std::fs::write(dir.join("target.c"), "l1\nl2\nl3 body\nl4\n").unwrap();
let mut s = editor();
compile_and_finish(&mut s, "printf 'target.c:3:2: error: boom\\n'", dir);
s
}
#[test]
fn acc15_ret_visits_error_and_jump_back_returns() {
let dir = tempfile::tempdir().unwrap();
let mut s = error_fixture(dir.path());
// Cursor starts on the header (row 0): RET there reports and
// stays.
press(&mut s, KeyCode::Enter);
assert_eq!(status(&s), "no error on this line");
assert_eq!(active_buffer_name(&s), "*compilation*");
// n lands on the diagnostic row; RET visits at 0-based (2,1).
press(&mut s, KeyCode::Char('n'));
press(&mut s, KeyCode::Enter);
assert!(
active_buffer_name(&s).ends_with("target.c"),
"RET visits the file; active: {}",
active_buffer_name(&s)
);
let line: i64 = eval(&s, "return pmacs.editor.cursor_line()");
let col: i64 = eval(&s, "return pmacs.editor.cursor_col()");
assert_eq!((line, col), (2, 1), "0-based landing from 1-based 3:2");
// M-, returns to the compilation buffer (jump ring).
alt(&mut s, ',');
assert_eq!(active_buffer_name(&s), "*compilation*");
}
#[test]
fn acc16_n_p_walk_error_lines_without_wrap() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_and_finish(
&mut s,
"printf 'a.c:1:1: error: one\\nplain line\\nb.c:2:2: error: two\\n'",
dir.path(),
);
let row_of = |s: &EditorState| -> i64 { eval(s, "return pmacs.editor.cursor_line()") };
press(&mut s, KeyCode::Char('n'));
let first = row_of(&s);
press(&mut s, KeyCode::Char('n'));
let second = row_of(&s);
assert!(second > first, "n walks forward between error lines");
press(&mut s, KeyCode::Char('n'));
assert_eq!(status(&s), "no more errors", "no wrap at the end");
assert_eq!(row_of(&s), second, "cursor stays");
press(&mut s, KeyCode::Char('p'));
assert_eq!(row_of(&s), first, "p walks back");
press(&mut s, KeyCode::Char('p'));
assert_eq!(status(&s), "no more errors", "no wrap at the start");
}
#[test]
fn acc17_chords_walk_compile_errors_across_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("one.c"), "a\nb\n").unwrap();
std::fs::write(dir.path().join("two.c"), "c\nd\ne\n").unwrap();
let mut s = editor();
compile_and_finish(
&mut s,
"printf 'one.c:1:1: error: e1\\ntwo.c:3:1: error: e2\\n'",
dir.path(),
);
// M-g n visits the first error, then the second, then reports.
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('n'));
assert!(active_buffer_name(&s).ends_with("one.c"), "first error");
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('n'));
assert!(active_buffer_name(&s).ends_with("two.c"), "second error");
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('n'));
assert_eq!(status(&s), "no more errors", "no wrap past the last");
assert!(active_buffer_name(&s).ends_with("two.c"), "stays put");
// M-g p walks back.
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('p'));
assert!(active_buffer_name(&s).ends_with("one.c"), "previous error");
// C-x ` is the classic chord for the same dispatcher.
ctrl(&mut s, 'x');
press(&mut s, KeyCode::Char('`'));
assert!(
active_buffer_name(&s).ends_with("two.c"),
"C-x ` = error.next"
);
}
#[test]
fn acc18_dispatcher_falls_back_to_diagnostics_without_a_claim() {
let mut s = editor();
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('n'));
assert_eq!(
status(&s),
"diag: no LSP server for active buffer",
"with no compile/grep claim, M-g n must reach diag.next \
(today's behavior preserved exactly)"
);
}
// ---------------------------------------------------------------------------
// 1922: recompile, q-target, kill, supersede
// ---------------------------------------------------------------------------
#[test]
fn acc19_g_recompiles_and_clears_overlay_spans() {
let dir = tempfile::tempdir().unwrap();
let counter = dir.path().join("count");
let mut s = editor();
// First run emits a severity-colored diagnostic (overlay span);
// each run appends to the counter file.
compile_and_finish(
&mut s,
&format!(
"echo run >> {}; printf 'a.c:1:1: error: colored\\n'",
counter.display()
),
dir.path(),
);
assert!(any_styled_cell(&render_active_window_to_grid(
&mut s, 8, 60
)));
assert_eq!(
std::fs::read_to_string(&counter).unwrap().lines().count(),
1
);
// g re-runs the stored command.
press(&mut s, KeyCode::Char('g'));
assert!(
pump_until(&mut s, 10_000, |_| {
std::fs::read_to_string(&counter).is_ok_and(|c| c.lines().count() == 2)
}),
"recompile must actually re-execute the command"
);
// The rerun executes the SAME stored command, so its output
// carries the same diagnostic; per-run reset is pinned by
// checking the fresh run reached its own marker with
// exactly one diagnostic parsed (not accumulated).
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
assert_eq!(
compile_errors(&s).len(),
1,
"error list resets per run (not accumulated)"
);
}
#[test]
fn acc20_compile_g_q_restores_the_original_buffer() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("origin.txt"), "home\n").unwrap();
let mut s = editor();
exec(
&s,
&format!(
"pmacs.buffer.find_or_open({:?})",
dir.path().join("origin.txt").display().to_string()
),
);
compile_and_finish(&mut s, "echo one", dir.path());
press(&mut s, KeyCode::Char('g'));
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
press(&mut s, KeyCode::Char('q'));
assert!(
active_buffer_name(&s).ends_with("origin.txt"),
"q restores the pre-compile buffer even after g (q-target \
not re-captured); active: {}",
active_buffer_name(&s)
);
}
#[test]
fn acc21_kill_produces_signaled_marker() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_run(&s, "echo running; sleep 30", dir.path());
assert!(pump_until(&mut s, 5_000, |s| compilation_text(s)
.contains("\nrunning\n")));
ctrl(&mut s, 'c');
ctrl(&mut s, 'k');
assert!(
pump_until(&mut s, 5_000, |s| compilation_text(s)
.contains("[compile killed by SIGTERM]")),
"plain kill: SIGTERM marker; buffer:\n{}",
compilation_text(&s)
);
}
#[test]
fn acc22_supersede_resets_and_returns_to_baseline() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
assert_eq!(process_count(&s), 0);
compile_run(&s, "echo first-run-output; sleep 30", dir.path());
assert!(pump_until(&mut s, 5_000, |s| compilation_text(s)
.contains("\nfirst-run-output\n")));
compile_run(&s, "echo second-run-output", dir.path());
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited with code 0]")));
let text = compilation_text(&s);
assert!(
!text.contains("\nfirst-run-output\n"),
"old-run output must not land after the reset:\n{text}"
);
assert!(
!text.contains("killed by"),
"the superseded run's exit marker must not land either:\n{text}"
);
assert!(
text.contains("\nsecond-run-output\n"),
"new run streams:\n{text}"
);
assert!(
pump_until(&mut s, 5_000, |s| process_count(s) == 0),
"both generations forgotten once drained"
);
}
// ---------------------------------------------------------------------------
// 2325: undo surfaces and the revision guard
// ---------------------------------------------------------------------------
#[test]
fn acc23_all_seven_undo_redo_chords_are_status_noops() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_and_finish(&mut s, "echo out", dir.path());
let before = compilation_text(&s);
let check = |s: &mut EditorState, label: &str| {
assert_eq!(
status(s),
"generated buffer: undo disabled",
"{label} must reach the buffer-local no-op"
);
assert_eq!(compilation_text(s), before, "{label} must not edit");
exec(s, "pmacs.editor.set_status('')");
};
ctrl(&mut s, '/');
check(&mut s, "C-/");
ctrl(&mut s, '_');
check(&mut s, "C-_");
ctrl(&mut s, '4');
check(&mut s, "C-4 (raw-terminal single-key undo)");
ctrl(&mut s, 'x');
press(&mut s, KeyCode::Char('u'));
check(&mut s, "C-x u");
ctrl(&mut s, '?');
check(&mut s, "C-? (redo)");
ctrl_shift(&mut s, '_');
check(&mut s, "C-S-_ (redo)");
ctrl(&mut s, 'x');
press(&mut s, KeyCode::Char('r'));
check(&mut s, "C-x r (redo)");
}
#[test]
fn acc24_command_path_undo_after_completed_run_recovers_immediately() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
exec(
&s,
&format!(
"pmacs.shell.command('echo shell-out', {{ cwd = {:?} }})",
dir.path().display().to_string()
),
);
assert!(pump_until(&mut s, 10_000, |s| named_text(
s,
"*shell-command*"
)
.contains("[shell exited with code 0]")));
// M-x buffer.undo: the command path rebinding cannot reach. No
// pump event will ever arrive — recovery must come from the
// buffer.after-edit subscription, synchronously.
alt(&mut s, 'x');
type_str(&mut s, "buffer.undo");
press(&mut s, KeyCode::Enter);
let text = named_text(&s, "*shell-command*");
assert!(
text.contains(DESYNC),
"desync marker must appear immediately (bite: fails when \
recovery only runs at pump/anchor time); buffer:\n{text}"
);
assert!(
errors_buffer(&s).is_empty(),
"clean *errors*: {}",
errors_buffer(&s)
);
}
#[test]
fn acc25a_no_hook_shrink_mid_stream_recovers_and_reanchors() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("b.c"), "x\ny\nz\n").unwrap();
let script = write_script(
dir.path(),
"slow.sh",
"printf 'a.c:1:1: error: one\\n'\nsleep 1\nprintf 'b.c:2:1: error: two\\n'\n",
);
let mut s = editor();
compile_run(&s, &format!("sh {script}"), dir.path());
assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains(": one")));
let first_row: i64 = {
// The first diagnostic's row, while its anchor is live.
press(&mut s, KeyCode::Char('n'));
eval(&s, "return pmacs.editor.cursor_line()")
};
// Harness-eval mutation: outside dispatch, outside
// with_after_edit_check — the actual no-hook producer the pump
// guard owns.
exec(
&s,
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == "*compilation*" then
id:delete(id:len() - 3, id:len(), { bypass_intercept = true })
end
end
"#,
);
assert!(
pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")),
"the pump must survive and finish; buffer:\n{}",
compilation_text(&s)
);
let text = compilation_text(&s);
assert!(
text.contains(&format!("\n{DESYNC}\n")),
"newline-delimited marker; buffer:\n{text}"
);
assert!(
text.find(DESYNC).unwrap() < text.find("two").unwrap(),
"streaming continued after the marker:\n{text}"
);
assert!(
errors_buffer(&s).is_empty(),
"no spam: {}",
errors_buffer(&s)
);
// Pre-marker anchor dropped: RET on the old row reports.
exec(&s, "pmacs.editor.goto_byte(0)");
let target = first_row;
exec(
&s,
&format!("for _ = 1, {target} do pmacs.editor.move_down() end"),
);
press(&mut s, KeyCode::Enter);
assert_eq!(status(&s), "no error on this line", "stale anchor dropped");
// Fresh epoch: the post-marker diagnostic gets a working anchor.
exec(&s, "pmacs.editor.goto_byte(0)");
press(&mut s, KeyCode::Char('n'));
press(&mut s, KeyCode::Enter);
assert!(
active_buffer_name(&s).ends_with("b.c"),
"post-marker diagnostic navigates; active: {}",
active_buffer_name(&s)
);
}
#[test]
fn acc25b_same_length_newline_moving_replace_is_caught() {
// The length-guard killer: content changes, length doesn't, and
// a newline moves so every anchor stays in bounds but rows lie.
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"slow.sh",
"printf 'a.c:1:1: error: one\\n'\nsleep 1\nprintf 'done\\n'\n",
);
let mut s = editor();
compile_run(&s, &format!("sh {script}"), dir.path());
assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains(": one")));
// Replace "one\n" with "one!" — same length, newline moved.
exec(
&s,
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == "*compilation*" then
local text = id:slice(0, id:len())
local at = text:find("one\n", 1, true)
id:replace(at - 1, at + 3, "one!", { bypass_intercept = true })
end
end
"#,
);
assert!(
pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")),
"pump survives; buffer:\n{}",
compilation_text(&s)
);
assert!(
compilation_text(&s).contains(DESYNC),
"revision guard catches what a length guard provably misses:\n{}",
compilation_text(&s)
);
}
// ---------------------------------------------------------------------------
// 26: ANSI + rendered styling + M-, retention
// ---------------------------------------------------------------------------
#[test]
fn acc26_ansi_renders_styled_cells_and_survives_jump_back() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("x.c"), "a\nb\n").unwrap();
let script = write_script(
dir.path(),
"color.sh",
concat!(
"printf '\\033[31mredtext\\033[0m plain\\n'\n",
"printf 'progress 1\\rprogress 2\\n'\n",
"printf 'x.c:1:1: error: e\\n'\n",
),
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let text = compilation_text(&s);
assert!(!text.contains('\u{1b}'), "no escape bytes:\n{text}");
assert!(text.contains("redtext"), "SGR text survives:\n{text}");
assert!(
text.contains("progress 2") && !text.contains("progress 1"),
"CR progress collapsed in place:\n{text}"
);
// Attachment proven: a rendered cell in the ACTIVE WINDOW
// carries style (handle spans alone would pass even when
// attach_style_overlay was never called).
assert!(
any_styled_cell(&render_active_window_to_grid(&mut s, 10, 60)),
"rendered TUI cell must carry the SGR span's style"
);
// RET to the file and M-, back must retain styling (rides the
// jump_back after-switch parity + re-attach subscription).
press(&mut s, KeyCode::Char('n'));
press(&mut s, KeyCode::Enter);
assert!(active_buffer_name(&s).ends_with("x.c"));
alt(&mut s, ',');
assert_eq!(active_buffer_name(&s), "*compilation*");
assert!(
any_styled_cell(&render_active_window_to_grid(&mut s, 10, 60)),
"styling must survive the RET → M-, round trip"
);
}
// ---------------------------------------------------------------------------
// 27: killed buffer mid-run
// ---------------------------------------------------------------------------
#[test]
fn acc27_killed_buffer_terminates_run_and_recreates() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_run(&s, "echo alive; sleep 30", dir.path());
assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains("\nalive\n")));
exec(
&s,
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == "*compilation*" then
pmacs.buffer.remove(id)
end
end
"#,
);
assert!(
pump_until(&mut s, 5_000, |s| process_count(s) == 0),
"run terminated and forgotten after buffer death"
);
assert!(
errors_buffer(&s).is_empty(),
"no spam: {}",
errors_buffer(&s)
);
// The next run recreates the buffer and completes.
compile_and_finish(&mut s, "echo reborn", dir.path());
assert!(compilation_text(&s).contains("reborn"));
}
// ---------------------------------------------------------------------------
// 2830: grep-mode
// ---------------------------------------------------------------------------
fn grep_fixture(dir: &Path) {
std::fs::create_dir_all(dir.join(".git")).unwrap();
std::fs::write(
dir.join("f.txt"),
"top\nzqxvbn_needle_77 here\nzqxvbn_needle_77 again\n",
)
.unwrap();
}
fn search(s: &EditorState, query: &str, root: &Path) {
exec(
s,
&format!(
"pmacs.project.search({query:?}, {{ root = {:?} }})",
root.display().to_string()
),
);
}
fn search_done(s: &EditorState) -> bool {
named_text(s, "*search-results*").contains("-- search ")
}
#[test]
fn acc28_grep_panel_is_a_locations_buffer() {
let dir = tempfile::tempdir().unwrap();
grep_fixture(dir.path());
let mut s = editor();
search(&s, "zqxvbn_needle_77", dir.path());
assert!(pump_until(&mut s, 10_000, search_done), "search completes");
assert_eq!(active_buffer_name(&s), "*search-results*");
let text = named_text(&s, "*search-results*");
assert!(
text.contains("f.txt:2:0:"),
"structured match line:\n{text}"
);
// Read-only under dispatch.
type_str(&mut s, "x");
assert_eq!(named_text(&s, "*search-results*"), text, "read-only");
// RET visits the first match (line 2 → 0-based 1; col 0).
press(&mut s, KeyCode::Char('n'));
press(&mut s, KeyCode::Enter);
assert!(active_buffer_name(&s).ends_with("f.txt"));
let line: i64 = eval(&s, "return pmacs.editor.cursor_line()");
assert_eq!(line, 1, "grep 1-based line normalized");
// The search claimed the error source: M-g n continues the walk
// (RET re-seated the index at match 1, so next = match 2).
alt(&mut s, ',');
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('n'));
assert!(
active_buffer_name(&s).ends_with("f.txt"),
"M-g n walks grep matches (source claimed)"
);
let line: i64 = eval(&s, "return pmacs.editor.cursor_line()");
assert_eq!(line, 2, "M-g n advanced to the second match");
// A second search supersedes: fresh page.
alt(&mut s, ',');
search(&s, "top", dir.path());
assert!(pump_until(&mut s, 10_000, search_done));
let text2 = named_text(&s, "*search-results*");
assert!(
text2.contains("Searching for: top") && !text2.contains("zqxvbn_needle_77 here"),
"supersede gives a fresh page:\n{text2}"
);
}
#[test]
fn acc29_grep_kill_mid_search_is_safe_and_masking_is_prevented() {
// A wide fixture keeps the stream alive across many ticks so a
// mid-stream window deterministically exists.
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
for i in 0..2000 {
std::fs::write(
dir.path().join(format!("f{i:04}.txt")),
"zqxvbn_needle_77\n",
)
.unwrap();
}
// (a) masking prevention: a no-hook edit between producer writes
// is detected by the NEXT producer write (a later batch or the
// close trailer), not silently absorbed.
let mut s = editor();
search(&s, "zqxvbn_needle_77", dir.path());
assert!(
pump_until(&mut s, 10_000, |s| {
let t = named_text(s, "*search-results*");
t.contains(":1:0:") && !t.contains("-- search ")
}),
"must observe a mid-stream state (matches landed, not closed)"
);
exec(
&s,
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == "*search-results*" then
id:insert(id:len(), "INTRUDER", { bypass_intercept = true })
end
end
"#,
);
assert!(pump_until(&mut s, 15_000, search_done));
let text = named_text(&s, "*search-results*");
let marker_at = text.find(DESYNC);
let trailer_at = text.find("-- search ").unwrap();
assert!(
marker_at.is_some() && marker_at.unwrap() < trailer_at,
"the next producer write must mark the mismatch before \
appending (not mask it); tail:\n…{}",
&text[text.len().saturating_sub(400)..]
);
assert!(
errors_buffer(&s).is_empty(),
"no spam: {}",
errors_buffer(&s)
);
// (b) killing the panel mid-search: no stale-handle writes, and
// the next search recreates the buffer.
let mut s = editor();
search(&s, "zqxvbn_needle_77", dir.path());
assert!(pump_until(&mut s, 10_000, |s| named_text(
s,
"*search-results*"
)
.contains(":1:0:")));
exec(
&s,
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == "*search-results*" then
pmacs.buffer.remove(id)
end
end
"#,
);
// Drain whatever the worker still delivers.
let _ = pump_until(&mut s, 1_000, |_| false);
assert!(
errors_buffer(&s).is_empty(),
"no spam: {}",
errors_buffer(&s)
);
search(&s, "zqxvbn_needle_77", dir.path());
assert!(
pump_until(&mut s, 15_000, |s| named_text(s, "*search-results*")
.contains(":1:0:")),
"a subsequent search recreates the panel and works"
);
}
#[test]
fn acc30_grep_root_retained_across_interactive_supersede() {
let dir = tempfile::tempdir().unwrap();
grep_fixture(dir.path());
let mut s = editor();
// First search from a project file: root comes from
// pmacs.project.detect (the .git marker).
exec(
&s,
&format!(
"pmacs.buffer.find_or_open({:?})",
dir.path().join("f.txt").display().to_string()
),
);
exec(&s, "pmacs.project.search('zqxvbn_needle_77')");
assert!(pump_until(&mut s, 10_000, |s| named_text(
s,
"*search-results*"
)
.contains("f.txt:2:0:")));
// Second search issued from inside the pathless panel, no
// opts.root: the panel's stored root must be reused (the "."
// fallback would search the test process's cwd and find
// nothing).
exec(&s, "pmacs.project.search('zqxvbn_needle_77')");
assert!(
pump_until(&mut s, 10_000, |s| {
let t = named_text(s, "*search-results*");
t.contains("f.txt:2:0:") && t.contains("-- search ")
}),
"second interactive search must run with the first search's \
root; panel:\n{}",
named_text(&s, "*search-results*")
);
// And RET still resolves against that root.
press(&mut s, KeyCode::Char('n'));
press(&mut s, KeyCode::Enter);
assert!(active_buffer_name(&s).ends_with("f.txt"));
}
// ---------------------------------------------------------------------------
// 3133: shell-command, q, round-trip flag
// ---------------------------------------------------------------------------
#[test]
fn acc31_shell_command_via_m_bang_does_not_claim_errors() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("one.c"), "a\n").unwrap();
let mut s = editor();
// Compile first so the error source is claimed by compile.
compile_and_finish(&mut s, "printf 'one.c:1:1: error: e\\n'", dir.path());
// M-! prompts; type the command; RET runs it.
alt(&mut s, '!');
type_str(&mut s, "echo shellout");
press(&mut s, KeyCode::Enter);
assert!(
pump_until(&mut s, 10_000, |s| named_text(s, "*shell-command*")
.contains("[shell exited with code 0]")),
"M-! output + exit marker; buffer:\n{}",
named_text(&s, "*shell-command*")
);
assert!(named_text(&s, "*shell-command*").contains("\nshellout\n"));
// The shell run must NOT have stolen the claim: M-g n still
// walks the compile errors.
alt(&mut s, 'g');
press(&mut s, KeyCode::Char('n'));
assert!(
active_buffer_name(&s).ends_with("one.c"),
"M-g n after M-! still walks the prior compile; active: {}",
active_buffer_name(&s)
);
}
#[test]
fn acc32_q_restores_previous_buffer_from_all_three() {
let dir = tempfile::tempdir().unwrap();
grep_fixture(dir.path());
std::fs::write(dir.path().join("home.txt"), "hi\n").unwrap();
let mut s = editor();
let open_home = format!(
"pmacs.buffer.find_or_open({:?})",
dir.path().join("home.txt").display().to_string()
);
exec(&s, &open_home);
// *compilation*
compile_and_finish(&mut s, "echo x", dir.path());
press(&mut s, KeyCode::Char('q'));
assert!(
active_buffer_name(&s).ends_with("home.txt"),
"q from compile"
);
// *shell-command*
exec(
&s,
&format!(
"pmacs.shell.command('echo y', {{ cwd = {:?} }})",
dir.path().display().to_string()
),
);
assert!(pump_until(&mut s, 10_000, |s| named_text(
s,
"*shell-command*"
)
.contains("[shell exited")));
press(&mut s, KeyCode::Char('q'));
assert!(active_buffer_name(&s).ends_with("home.txt"), "q from shell");
// *search-results*
search(&s, "zqxvbn_needle_77", dir.path());
assert!(pump_until(&mut s, 10_000, search_done));
press(&mut s, KeyCode::Char('q'));
assert!(
active_buffer_name(&s).ends_with("home.txt"),
"q from search"
);
}
#[test]
fn acc33_round_trip_input_is_set_on_generated_buffers() {
let dir = tempfile::tempdir().unwrap();
grep_fixture(dir.path());
let mut s = editor();
compile_and_finish(&mut s, "echo x", dir.path());
assert!(
s.core.borrow().active_buffer_round_trips(),
"*compilation* must round-trip (semantic frontends gate their \
optimistic path on this)"
);
exec(
&s,
&format!(
"pmacs.shell.command('echo y', {{ cwd = {:?} }})",
dir.path().display().to_string()
),
);
assert!(
s.core.borrow().active_buffer_round_trips(),
"*shell-command*"
);
search(&s, "zqxvbn_needle_77", dir.path());
assert!(
s.core.borrow().active_buffer_round_trips(),
"*search-results*"
);
}
// ---------------------------------------------------------------------------
// PR #113 round 1 — bite tests (one per finding; each observed
// failing against the pre-fix tree via scripts/bite)
// ---------------------------------------------------------------------------
#[test]
fn r1f1_non_finite_coordinates_fail_closed() {
// A 400-digit line capture tonumbers to math.huge; pre-fix it
// was stored and any visit walked forever.
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
let digits = "9".repeat(400);
compile_and_finish(
&mut s,
&format!("printf 'h.c:{digits}:1: error: e\\n'"),
dir.path(),
);
assert!(
compile_errors(&s).is_empty(),
"a non-finite line coordinate must be discarded; got {:?}",
compile_errors(&s)
);
}
#[test]
fn r1f1_beyond_eol_column_clamps_to_the_target_row() {
// Column past EOL: pre-fix the walk marched move_right across
// newlines and landed rows away from the diagnostic's line (and
// an astronomical value walked effectively forever).
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("w.c"), "ab\ncd\nef\n").unwrap();
let mut s = editor();
compile_and_finish(&mut s, "printf 'w.c:1:500: error: e\\n'", dir.path());
press(&mut s, KeyCode::Char('n'));
press(&mut s, KeyCode::Enter);
assert!(active_buffer_name(&s).ends_with("w.c"));
let line: i64 = eval(&s, "return pmacs.editor.cursor_line()");
assert_eq!(
line, 0,
"the column walk must clamp at the target row's EOL, not run \
onto later rows"
);
}
#[test]
fn r1f2_grep_command_path_undo_after_completed_search_recovers() {
let dir = tempfile::tempdir().unwrap();
grep_fixture(dir.path());
let mut s = editor();
search(&s, "zqxvbn_needle_77", dir.path());
assert!(pump_until(&mut s, 10_000, search_done), "search completes");
// M-x buffer.undo in the completed panel: no producer write or
// navigation may ever come — recovery must be immediate via the
// buffer.after-edit subscription.
alt(&mut s, 'x');
type_str(&mut s, "buffer.undo");
press(&mut s, KeyCode::Enter);
let text = named_text(&s, "*search-results*");
assert!(
text.contains(DESYNC),
"desync marker must appear immediately in the grep panel; \
buffer:\n{text}"
);
assert!(
errors_buffer(&s).is_empty(),
"clean *errors*: {}",
errors_buffer(&s)
);
}
#[test]
fn r1f3_rustc_arrow_paths_with_spaces_parse() {
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"space.sh",
"printf ' --> /tmp/my dir/foo.rs:12:4\\n'\n",
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
assert_eq!(
compile_errors(&s),
vec![("/tmp/my dir/foo.rs".to_owned(), 11, 3, None)],
"the rustc rule must capture space-containing paths whole \
(pre-fix the two-part fallback recorded arrow junk at col 0)"
);
}
#[test]
fn r1f4_capture_indexes_above_three_and_absent_columns() {
let dir = tempfile::tempdir().unwrap();
// (a) a valid four-capture rule with col = 4 must store the
// fourth capture, not silently column 0.
let mut s = editor();
exec(
&s,
r#"
pmacs.compile.rules = {
{ pattern = "(q%.c):(%d+):(x):(%d+)", file = 1, line = 2, col = 4 },
}
"#,
);
compile_and_finish(&mut s, "printf 'q.c:7:x:9 error\\n'", dir.path());
assert_eq!(
compile_errors(&s),
vec![("q.c".to_owned(), 6, 8, Some("error".to_owned()))],
"capture index 4 must be honored"
);
// (b) fractional indexes are malformed; (c) a rule that names a
// column its match didn't produce rejects the match.
let mut s = editor();
exec(
&s,
r#"
pmacs.compile.rules = {
{ pattern = "(a%.c):(%d+):", file = 1, line = 2, col = 1.5 },
{ pattern = "(r%.c):(%d+):?(%d*)", file = 1, line = 2, col = 3 },
}
"#,
);
compile_run(&s, "printf 'a.c:3: e\\nr.c:5: e\\n'", dir.path());
assert!(
status(&s).contains("skipped 1 malformed"),
"the fractional index is rejected at validation; got: {}",
status(&s)
);
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
assert!(
compile_errors(&s).is_empty(),
"an empty column capture under a col-naming rule must reject \
the match, not store column 0; got {:?}",
compile_errors(&s)
);
}
#[test]
fn r1f5_user_global_cannot_shadow_the_marker_helper() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
// A hostile (or merely colliding) user global with the helper's
// old name: pre-fix this replaced the module's function and its
// error consumed the terminal event before forget ran.
exec(&s, "_G.emit_text_raw = function() error('shadowed') end");
compile_and_finish(&mut s, "echo fine", dir.path());
assert!(
compilation_text(&s).contains("[compile exited with code 0]"),
"the exit marker must come from the module's local helper"
);
assert!(
errors_buffer(&s).is_empty(),
"no error spam: {}",
errors_buffer(&s)
);
assert!(
pump_until(&mut s, 3_000, |s| process_count(s) == 0),
"terminal-event cleanup (forget) must have run"
);
}
#[test]
fn r1f6_wrong_spec_types_error_instead_of_defaulting() {
let s = editor();
let (ok, err): (bool, String) = eval(
&s,
r#"
local ok, err = pcall(pmacs.process.spawn,
{ label = "t", command = "/bin/true", stdin = true })
return ok, tostring(err)
"#,
);
assert!(!ok, "stdin = true (boolean) must be a hard error");
assert!(err.contains("stdin must be"), "pointed message; got: {err}");
let (ok, err): (bool, String) = eval(
&s,
r#"
local ok, err = pcall(pmacs.process.spawn,
{ label = "t", command = "/bin/true", group = "true" })
return ok, tostring(err)
"#,
);
assert!(!ok, "group = \"true\" (string) must be a hard error");
assert!(
err.contains("group must be a boolean"),
"pointed message; got: {err}"
);
}
#[test]
fn r1f7_resync_invalidates_the_public_byte_anchor() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_and_finish(&mut s, "printf 'a.c:1:1: error: e\\n'", dir.path());
let has_anchor: bool = eval(
&s,
"return pmacs.compile.errors()[1].line_start_byte ~= nil",
);
assert!(has_anchor, "pre-desync entries carry the byte anchor");
// Trigger the guard through the command path.
alt(&mut s, 'x');
type_str(&mut s, "buffer.undo");
press(&mut s, KeyCode::Enter);
assert!(compilation_text(&s).contains(DESYNC), "marker appended");
let has_anchor: bool = eval(
&s,
"return pmacs.compile.errors()[1].line_start_byte ~= nil",
);
assert!(
!has_anchor,
"total pre-marker anchor invalidation includes the public \
line_start_byte, not just the display row"
);
}
#[test]
fn r1f8_inherited_cwd_resolves_to_the_daemon_working_directory() {
// Pathless scratch buffer, no opts.cwd, no project: the header
// must print the real working directory, and relative error
// paths must resolve against it explicitly.
let mut s = editor();
exec(&s, "pmacs.compile.run('echo hi')");
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
let cwd = std::env::current_dir().unwrap().display().to_string();
let text = compilation_text(&s);
assert!(
text.contains(&format!("Directory: {cwd}")),
"the header must name the daemon's actual working directory; \
got:\n{text}"
);
assert!(
!text.contains("(inherited)") && !text.contains("(unknown)"),
"no placeholder when the identity API is available:\n{text}"
);
}
#[test]
fn r1f9_truncated_utf8_at_eof_becomes_the_replacement_character() {
let dir = tempfile::tempdir().unwrap();
// \303 (0xC3) opens a two-byte sequence that never completes:
// the parser's cross-feed buffer holds it, and only the new
// stream-end finish() can flush it as U+FFFD.
let script = write_script(dir.path(), "trunc.sh", "printf 'abc\\303'\n");
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let text = compilation_text(&s);
assert!(
text.contains("abc\u{FFFD}"),
"the truncated sequence must surface as U+FFFD, not vanish; \
buffer:\n{text}"
);
}
// ---------------------------------------------------------------------------
// PR #113 round 2 — bite tests
// ---------------------------------------------------------------------------
#[test]
fn r2f1_rule_validation_is_a_stable_snapshot() {
// Mutating the user's rule object AFTER compile.run() must not
// alter the in-flight run: validation copies scalar fields into
// per-run plain tables.
let dir = tempfile::tempdir().unwrap();
let script = write_script(dir.path(), "slow.sh", "sleep 0.5\nprintf 'm.c:3: e\\n'\n");
let mut s = editor();
exec(
&s,
r#"pmacs.compile.rules = { { pattern = "(m%.c):(%d+):", file = 1, line = 2 } }"#,
);
compile_run(&s, &format!("sh {script}"), dir.path());
// The output hasn't arrived yet; sabotage the live rule object.
exec(&s, "pmacs.compile.rules[1].pattern = 'nevermatch'");
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
assert_eq!(
compile_errors(&s),
vec![("m.c".to_owned(), 2, 0, None)],
"the run must parse with its validated snapshot, not the \
mutated object"
);
}
#[test]
fn r2f1_metatable_backed_rules_cannot_raise_through_the_pump() {
// A rule whose field reads raise (hostile __index) and a
// container whose traversal raises: both must degrade cleanly —
// no error thrown through the per-frame pump, terminal cleanup
// intact.
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
exec(
&s,
r#"
local hostile = setmetatable({}, { __index = function() error("boom") end })
pmacs.compile.rules = { hostile,
{ pattern = "(k%.c):(%d+):", file = 1, line = 2 } }
"#,
);
compile_run(&s, "printf 'k.c:4: e\\n'", dir.path());
assert!(
status(&s).contains("skipped 1 malformed"),
"the hostile entry is a counted skip; got: {}",
status(&s)
);
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited with code 0]")));
assert_eq!(
compile_errors(&s),
vec![("k.c".to_owned(), 3, 0, None)],
"the valid entry still parses"
);
assert!(
errors_buffer(&s).is_empty(),
"nothing raised through the pump: {}",
errors_buffer(&s)
);
assert!(
pump_until(&mut s, 3_000, |s| process_count(s) == 0),
"terminal cleanup ran"
);
// Hostile CONTAINER whose traversal raises. Flavor-dependent by
// Lua semantics: 5.2+ `ipairs` consults __index (the raise fires
// and the pcall degrades to defaults with a note); LuaJIT/5.1
// reads raw (the container is simply empty — no rules, no note).
// Both flavors must complete cleanly with nothing thrown
// through the pump.
let mut s = editor();
exec(
&s,
r#"
pmacs.compile.rules = setmetatable({}, {
__index = function() error("container boom") end,
})
"#,
);
compile_run(&s, "printf 'a.c:1:1: error: e\\n'", dir.path());
let is_lua54: bool = eval(&s, "return _VERSION ~= 'Lua 5.1'");
if is_lua54 {
assert!(
status(&s).contains("raised during traversal"),
"degradation note under 5.2+ ipairs semantics; got: {}",
status(&s)
);
}
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
if is_lua54 {
assert_eq!(
compile_errors(&s).len(),
1,
"built-in defaults still parse after container degradation"
);
} else {
assert!(
compile_errors(&s).is_empty(),
"under raw-ipairs flavors the hostile container reads as \
an (empty) rule table"
);
}
assert!(
errors_buffer(&s).is_empty(),
"no spam: {}",
errors_buffer(&s)
);
}
#[test]
fn r2f2_infinite_capture_index_is_counted_malformed() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
exec(
&s,
r#"
pmacs.compile.rules = {
{ pattern = "(z%.c):(%d+):", file = 1, line = 2, col = math.huge },
}
"#,
);
compile_run(&s, "printf 'z.c:2: e\\n'", dir.path());
assert!(
status(&s).contains("skipped 1 malformed"),
"math.huge is not a capture index (floor(huge) == huge, so \
integrality alone passes it); got: {}",
status(&s)
);
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
}
#[test]
fn r2f3_shell_command_ignores_the_compile_rule_table() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
// Both degradation shapes at once: a non-table container would
// warn, a raising container would abort — shell-command performs
// no parsing and must see neither.
exec(&s, "pmacs.compile.rules = 42");
exec(
&s,
&format!(
"pmacs.shell.command('echo shellok', {{ cwd = {:?} }})",
dir.path().display().to_string()
),
);
assert!(
!status(&s).contains("not a table"),
"no compile-rule warning on a shell run; got: {}",
status(&s)
);
assert!(
pump_until(&mut s, 10_000, |s| named_text(s, "*shell-command*")
.contains("[shell exited with code 0]")),
"shell-command runs regardless of rule-table state"
);
}
#[test]
fn r2f4_parser_finish_resets_for_a_fresh_stream() {
// Lua-driven twin of the ansi.rs units (which live inside the
// file a scripts/bite swap replaces): after finish(), a feed
// must parse a NEW stream — not continue a pre-EOF escape, not
// stay alt-screen-suppressed.
let s = editor();
let (after_csi, after_alt): (String, String) = eval(
&s,
r#"
local function text_of(evs)
local out = {}
for _, ev in ipairs(evs) do
if ev.kind == "text" then out[#out + 1] = ev.text end
end
return table.concat(out)
end
local p = pmacs.ansi.parser()
p:feed("\27[3") -- incomplete CSI at stream end
p:finish()
local a = text_of(p:feed("plain"))
local q = pmacs.ansi.parser()
q:feed("\27[?1049hhidden") -- alt screen active at stream end
q:finish()
local b = text_of(q:feed("visible"))
return a, b
"#,
);
assert_eq!(
after_csi, "plain",
"post-finish feed must not continue the pre-EOF CSI"
);
assert_eq!(
after_alt, "visible",
"stream end must end alt-screen suppression"
);
}
#[test]
fn r1f10_builtin_default_rules_survive_in_place_mutation() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
// Corrupt an entry IN PLACE, then degrade the container: the
// "built-in defaults" fallback must be a true copy, not an alias
// of the mutated table.
exec(
&s,
"pmacs.compile.rules[2] = 'junk'; pmacs.compile.rules = 42",
);
compile_and_finish(&mut s, "printf 'foo.c:7:2: warning: w\\n'", dir.path());
assert_eq!(
compile_errors(&s),
vec![("foo.c".to_owned(), 6, 1, Some("warning".to_owned()))],
"the gcc three-part rule from the TRUE defaults must match \
(the aliased pre-fix table lost it and the two-part fallback \
stored column 0)"
);
}
// ---------------------------------------------------------------------------
// PR #113 round 3 — bite tests
// ---------------------------------------------------------------------------
#[test]
fn r3f1_cr_and_backspace_are_utf8_safe() {
// Overwrites must consume whole existing codepoints in one
// atomic replace, and backspace must step to the previous UTF-8
// boundary. Pre-fix, é\rX left "X\xA9" (malformed) and é\bX left
// "\xC3X"; under CRDT the mid-codepoint edit rejects and aborts
// the pump (see the CRDT twin).
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"uni.sh",
concat!(
"printf '\\303\\251\\rX\\n'\n", // é\rX → X
"printf 'X\\r\\303\\251\\n'\n", // X\ré → é
"printf '\\303\\251\\bX\\n'\n", // é\bX → X
),
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let text = compilation_text(&s);
assert!(
text.contains("\nX\n\u{e9}\nX\n"),
"each overwrite must yield exactly the replacing character, \
valid UTF-8, no residue; buffer:\n{text:?}"
);
assert!(
text.contains("[compile exited with code 0]"),
"terminal event must survive the unicode batch:\n{text}"
);
assert!(
errors_buffer(&s).is_empty(),
"no pump aborts: {}",
errors_buffer(&s)
);
assert!(
pump_until(&mut s, 3_000, |s| process_count(s) == 0),
"process record forgotten (cleanup ran)"
);
}
#[test]
fn r3f2_parser_finish_emits_balancing_state_events() {
// Consumers mirror parser state from the event stream alone: an
// unclosed alt-screen enter must be balanced by an exit, and a
// non-default running style by a default SetStyle — applied to
// consumer state, not just observed as later text.
let s = editor();
let (alt_balanced, style_reset): (bool, bool) = eval(
&s,
r#"
local p = pmacs.ansi.parser()
p:feed("\27[31mred")
p:feed("\27[?1049h") -- enter alt screen, never exited
local alt = true -- consumer mirror of the enter
local style = { fg = 1 }
for _, ev in ipairs(p:finish()) do
if ev.kind == "alt_screen_exit" then alt = false end
if ev.kind == "set_style" then style = ev.style end
end
local style_is_default = style.fg == "default"
and style.bg == "default" and not style.bold
return alt == false, style_is_default
"#,
);
assert!(
alt_balanced,
"finish must emit alt_screen_exit for an unclosed enter"
);
assert!(
style_reset,
"finish must emit a default set_style when the running style \
was non-default"
);
}
#[test]
fn r3f3_spec_fields_are_raw_reads_metatables_not_honored() {
let s = editor();
// A metatable that provides group = true: honoring it would be
// silent spec-by-metatable; raw reads ignore it (the compile.lua
// rawget posture), so the child must NOT lead its own group.
let pid: i64 = eval(
&s,
r#"
local spec = setmetatable(
{ label = "mt", command = "/bin/sh", args = { "-c", "sleep 30" } },
{ __index = function(_, k)
if k == "group" then return true end
return nil
end })
local id = pmacs.process.spawn(spec)
for _, row in ipairs(pmacs.process.list()) do
if row.state and row.state.pid then return row.state.pid end
end
return -1
"#,
);
assert!(pid > 0, "metatable-backed spec must spawn");
let out = std::process::Command::new("ps")
.args(["-o", "pgid=", "-p", &pid.to_string()])
.output()
.expect("ps");
let pgid: i64 = String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.expect("pgid");
assert_ne!(
pgid, pid,
"metatable-provided group=true must not be honored (raw reads)"
);
// A RAISING __index must not be silently absorbed either — with
// raw reads it simply never fires; the spawn succeeds cleanly.
let ok: bool = eval(
&s,
r#"
local spec = setmetatable(
{ label = "mt2", command = "/bin/sh", args = { "-c", "exit 0" } },
{ __index = function() error("hostile spec metatable") end })
local ok = pcall(pmacs.process.spawn, spec)
return ok
"#,
);
assert!(ok, "raw reads must not trip a raising __index");
exec(
&s,
r"
for _, row in ipairs(pmacs.process.list()) do
pcall(pmacs.process.terminate, row.id)
end
",
);
}
// ---------------------------------------------------------------------------
// PR #113 round 4 — bite tests
// ---------------------------------------------------------------------------
#[test]
fn r4f1_cr_rewrites_are_column_based_not_byte_based() {
// PR #113 round-4 finding 1: the round-3 renderer preserved
// UTF-8 validity but still counted the overwrite in BYTES.
// `abcdef\rX\n` wrote "X\n" over "ab", splitting the line and
// leaving "cdef" as a ghost line the parser saw again at EOF;
// `abc\ré` overwrote TWO ASCII characters because é is two
// bytes. Overwrites are column-counted (codepoints), and LF is
// not an overwrite column: the newline drops to a fresh line and
// the stale remainder survives in place (terminal semantics).
// CRLF (a CR event followed by a text event starting "\n") is
// the same rule and used to corrupt the same way.
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"cols.sh",
concat!(
"printf 'abcdef\\rX\\n'\n", // → Xbcdef
"printf 'abc\\r\\303\\251\\n'\n", // → ébc
"printf 'one\\r\\ntwo\\r\\n'\n", // CRLF → one / two
),
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let text = compilation_text(&s);
assert!(
text.contains("\nXbcdef\n\u{e9}bc\none\ntwo\n"),
"shorter rewrites keep the line whole (no ghost line), a \
multibyte overwrite consumes one COLUMN, and CRLF is a \
plain line break; buffer:\n{text:?}"
);
assert!(
text.contains("[compile exited with code 0]"),
"run completes: {text}"
);
assert!(
errors_buffer(&s).is_empty(),
"no error spam: {}",
errors_buffer(&s)
);
}
#[test]
fn r4f1_split_feed_rewrites_and_split_codepoints() {
// The same rewrites with the CR, the overwrite text, and even
// the é's two bytes arriving in SEPARATE pump batches: the
// buffer-scanned line start and the parser's cross-feed UTF-8
// buffer must compose with the column-based overwrite.
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"split.sh",
concat!(
"printf 'abcdef'\n",
"sleep 0.3\n",
"printf '\\rX\\n'\n",
"printf 'abc\\r\\303'\n", // é's lead byte ends the batch
"sleep 0.3\n",
"printf '\\251\\n'\n",
),
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let text = compilation_text(&s);
assert!(
text.contains("\nXbcdef\n\u{e9}bc\n"),
"cross-batch rewrites must match the single-batch results; \
buffer:\n{text:?}"
);
assert!(
errors_buffer(&s).is_empty(),
"no error spam: {}",
errors_buffer(&s)
);
}
#[test]
fn r4f2_alt_screen_style_desync_resynced_on_exit_and_finish() {
// Round-4 finding 2, Lua twin (the in-crate Rust units vanish
// with an ansi.rs swap; this one bites): a consumer mirroring
// style from the event stream must be resynchronized when SGR
// changes were suppressed inside the alternate screen — on
// ordinary exit AND at finish(). The finish() half is the
// internal-comparison trap: the suppressed reset makes the
// PARSER's style default, so only the emitted-style comparison
// sees anything to balance.
let s = editor();
let (exit_resynced, finish_resynced): (bool, bool) = eval(
&s,
r#"
local function last_style(evs, style)
for _, ev in ipairs(evs) do
if ev.kind == "set_style" then style = ev.style end
end
return style
end
local function is_default(style)
return style.fg == "default" and style.bg == "default"
and not style.bold
end
-- Ordinary exit: red before enter, SGR reset inside
-- (suppressed), explicit CSI ?1049l.
local p = pmacs.ansi.parser()
local evs = p:feed("\27[31mred\27[?1049h\27[0m\27[?1049l")
local a = is_default(last_style(evs, {}))
-- finish(): the same drift, closed by stream end instead.
local q = pmacs.ansi.parser()
local style = last_style(q:feed("\27[31mred\27[?1049h\27[0m"), {})
local b = is_default(last_style(q:finish(), style))
return a, b
"#,
);
assert!(
exit_resynced,
"ordinary alt-screen exit must resync the suppressed SGR reset"
);
assert!(
finish_resynced,
"finish must emit the default SetStyle the CONSUMER needs even \
though the internal style is already default"
);
}
// ---------------------------------------------------------------------------
// PR #113 round 5 — bite tests
// ---------------------------------------------------------------------------
#[test]
fn r5f1_span_translation_is_exactly_once_for_the_active_buffer() {
use pmacs::cell::Color;
// Red 'a', blue 'bc', then CR and a red 2-byte é overwriting the
// 'a' (length delta +1): the blue span must shift exactly ONCE
// (round-5 finding 1). Pre-fix every attached view translated
// the shared store — the duplicate attachment made it twice on
// the normal path, splits multiplied it further — leaving 'b'
// unstyled and the span end past the buffer.
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"spans.sh",
"printf '\\033[31ma\\033[34mbc\\r\\033[31m\\303\\251\\n'\n",
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let cells = render_active_window_to_grid(&mut s, 12, 60);
let row = styled_row(&cells, 12, 60, "\u{e9}bc", 3);
assert_eq!(
row,
vec![
('\u{e9}', Color::Indexed(1)),
('b', Color::Indexed(4)),
('c', Color::Indexed(4)),
],
"the blue span shifts exactly once past the 1-to-2-byte rewrite"
);
}
#[test]
fn r5f1_hidden_buffer_spans_still_translate() {
use pmacs::cell::Color;
// The compile buffer sits in NO window while the rewrite
// arrives: pre-fix, no view received on_edit and the spans were
// never adjusted at all. Translation is buffer-level now,
// independent of visibility.
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"hidden.sh",
concat!(
"printf '\\033[31ma\\033[34mbc'\n",
"sleep 0.5\n",
"printf '\\r\\033[31m\\303\\251\\n'\n",
),
);
let mut s = editor();
compile_run(&s, &format!("sh {script}"), dir.path());
assert!(
pump_until(&mut s, 5_000, |s| compilation_text(s).contains("abc")),
"styled prefix lands first"
);
exec(
&s,
r#"pmacs.window.switch_buffer(pmacs.buffer.create("*elsewhere*"))"#,
);
assert!(
pump_until(&mut s, 10_000, |s| named_text(s, "*compilation*")
.contains("[compile ")),
"run finishes while hidden"
);
// Switch back; the after-switch hook re-attaches the render view.
exec(
&s,
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == "*compilation*" then
pmacs.window.switch_buffer(id)
end
end
"#,
);
let cells = render_active_window_to_grid(&mut s, 12, 60);
let row = styled_row(&cells, 12, 60, "\u{e9}bc", 3);
assert_eq!(
row,
vec![
('\u{e9}', Color::Indexed(1)),
('b', Color::Indexed(4)),
('c', Color::Indexed(4)),
],
"spans shifted while hidden; the re-shown buffer renders true colors"
);
}
#[test]
fn r5f2_partial_rewrite_preserves_untouched_styling() {
use pmacs::cell::Color;
// SGR red; abc; SGR reset; CR; X — the default-styled X
// overwrites only 'a'; 'bc' keeps its red (round-5 finding 2).
// Pre-fix any overlap dropped the WHOLE span (bc lost its
// color); with no translation at all the stale span painted the
// default X red instead. Exact per-cell colors on both sides.
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"frag.sh",
"printf '\\033[31mabc\\033[0m\\rX\\n'\n",
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let cells = render_active_window_to_grid(&mut s, 12, 60);
let row = styled_row(&cells, 12, 60, "Xbc", 3);
assert_eq!(
row,
vec![
('X', Color::Default),
('b', Color::Indexed(1)),
('c', Color::Indexed(1)),
],
"the rewritten cell is default-styled; the untouched suffix keeps red"
);
}
// ---------------------------------------------------------------------------
// PR #113 round 6 — bite tests
// ---------------------------------------------------------------------------
/// Count of `buffer_style_overlay` render views on the ACTIVE window.
fn active_style_overlay_count(s: &EditorState) -> i64 {
eval(
s,
r#"
local n = 0
for _, k in ipairs(pmacs.window._overlay_kinds()) do
if k == "buffer_style_overlay" then n = n + 1 end
end
return n
"#,
)
}
#[test]
fn r6f1_split_panes_stay_styled_with_one_attachment_each() {
use pmacs::cell::Color;
// A styled compilation split into two panes: the split copies
// the render view (splits fire no switch hook and pre-fix left
// the new pane unstyled), and repeated switches of one pane must
// not stack duplicates on the passive pane (pre-fix every
// re-attach blindly pushed to EVERY matching window — unbounded
// render cost). Round-6 finding 1.
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"red.sh",
"printf '\\033[31mhello\\033[0m world\\n'\n",
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
exec(&s, "pmacs.window.split_horizontal()");
// IMMEDIATELY after the split — before any switch could heal it
// through the attach-to-all-matching-windows path — both panes
// must be styled with one attachment each. This is the split
// half of the finding: pre-fix the new pane had NO render view.
for pane in 0..2 {
assert_eq!(
active_style_overlay_count(&s),
1,
"pane {pane} post-split: exactly one render attachment"
);
let cells = render_active_window_to_grid(&mut s, 12, 60);
let row = styled_row(&cells, 12, 60, "hello world", 5);
assert!(
row.iter().all(|(_, fg)| *fg == Color::Indexed(1)),
"pane {pane} post-split: 'hello' renders red; got {row:?}"
);
exec(&s, "pmacs.window.focus_next()");
}
// Bounce the ACTIVE pane away and back three times; the passive
// pane keeps showing *compilation* through every re-attach. This
// is the accumulation half: pre-fix every re-attach blindly
// pushed another render view onto the passive pane.
exec(&s, r#"bounce_buf = pmacs.buffer.create("*bounce*")"#);
for _ in 0..3 {
exec(&s, "pmacs.window.switch_buffer(bounce_buf)");
exec(
&s,
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == "*compilation*" then
pmacs.window.switch_buffer(id)
end
end
"#,
);
}
for pane in 0..2 {
assert_eq!(
active_style_overlay_count(&s),
1,
"pane {pane} post-bounce: exactly one render attachment"
);
let cells = render_active_window_to_grid(&mut s, 12, 60);
let row = styled_row(&cells, 12, 60, "hello world", 5);
assert!(
row.iter().all(|(_, fg)| *fg == Color::Indexed(1)),
"pane {pane} post-bounce: 'hello' renders red; got {row:?}"
);
exec(&s, "pmacs.window.focus_next()");
}
}
#[test]
fn r6f2_noop_edits_do_not_fragment_spans() {
// Buffers deliberately broadcast no-op edits; the translator
// must ignore them (round-6 finding 2). Pre-fix each interior
// no-op split the containing span into two adjacent fragments —
// unbounded growth, and position 3 (the é's continuation byte)
// minted a mid-codepoint span boundary.
let s = editor();
let (count, start, end): (i64, i64, i64) = eval(
&s,
r#"
local buf = pmacs.buffer.create("*noop-spans*")
local ov = pmacs.buffer.add_style_overlay(buf)
buf:insert(0, "ab\195\169def")
ov:add(0, 7, { fg = 1 })
for _, pos in ipairs({ 1, 2, 3, 4, 5 }) do
buf:insert(pos, "")
buf:delete(pos, pos)
end
local spans = ov:spans()
return #spans, spans[1].start, spans[1]["end"]
"#,
);
assert_eq!(
(count, start, end),
(1, 0, 7),
"no-op edits must neither fragment nor move spans"
);
}
#[test]
fn r6f3_handle_dispose_detaches_translator_and_render_views() {
// Teardown path (round-6 finding 3): dispose() detaches the
// buffer-attached translator (later edits stop translating) and
// removes every window render view over the handle's store;
// calling it twice is safe. Pre-fix a dropped handle's
// translator lived until the buffer died.
let s = editor();
let (live, stale, render_views): (i64, i64, i64) = eval(
&s,
r#"
local buf = pmacs.buffer.create("*disposable*")
pmacs.window.switch_buffer(buf)
local ov = pmacs.buffer.add_style_overlay(buf)
buf:insert(0, "abcdef")
ov:add(0, 6, { fg = 1 })
buf:insert(0, "xx") -- live translator: span shifts
local live = ov:spans()[1].start
ov:dispose()
buf:insert(0, "yy") -- detached: span must NOT move
local stale = ov:spans()[1].start
ov:dispose() -- idempotent
local n = 0
for _, k in ipairs(pmacs.window._overlay_kinds()) do
if k == "buffer_style_overlay" then n = n + 1 end
end
return live, stale, n
"#,
);
assert_eq!(live, 2, "pre-dispose edits translate the span");
assert_eq!(stale, 2, "post-dispose edits must not reach the store");
assert_eq!(render_views, 0, "dispose removes the window render views");
}
// ---------------------------------------------------------------------------
// PR #113 round 7 — bite tests
// ---------------------------------------------------------------------------
#[test]
fn r7f1_attach_validates_buffer_identity_and_disposed_state() {
// Round-7 finding 1: a handle's translator follows edits to ITS
// buffer only, so attaching the handle to another buffer showed
// spans nobody maintains; attaching after dispose() resurrected
// rendering without the translator. Both now fail clearly, with
// the message pointing at add_style_overlay.
let s = editor();
let (ok_cross, err_cross, ok_same, ok_after, err_after): (bool, String, bool, bool, String) =
eval(
&s,
r#"
local a = pmacs.buffer.create("*ov-a*")
local b = pmacs.buffer.create("*ov-b*")
local ov = pmacs.buffer.add_style_overlay(a)
local ok_cross, err_cross = pcall(pmacs.buffer.attach_style_overlay, b, ov)
local ok_same = pcall(pmacs.buffer.attach_style_overlay, a, ov)
ov:dispose()
local ok_after, err_after = pcall(pmacs.buffer.attach_style_overlay, a, ov)
return ok_cross, tostring(err_cross), ok_same, ok_after, tostring(err_after)
"#,
);
assert!(!ok_cross, "cross-buffer attachment must be rejected");
assert!(
err_cross.contains("belongs to buffer") && err_cross.contains("add_style_overlay"),
"pointed message naming the fix; got: {err_cross}"
);
assert!(ok_same, "same-buffer attachment stays valid");
assert!(!ok_after, "attachment after dispose must be rejected");
assert!(
err_after.contains("disposed") && err_after.contains("add_style_overlay"),
"pointed message naming the fix; got: {err_after}"
);
}
#[test]
fn r7f2_dispose_detaches_translator_in_a_headless_host() {
// Round-7 finding 2, acceptance twin (the in-crate unit vanishes
// with a mod.rs swap; this one bites): an install-only host
// registers the buffer registry as app data but NO editor core.
// Pre-fix, dispose() did all cleanup inside the optional
// SharedCore branch — returning success while the translator
// stayed attached for the buffer's lifetime.
use pmacs::lua_bindings::{
SharedCommandRegistry, SharedHookRegistry, SharedKeymapStack, SharedMenuRegistry,
SharedRegistry, install,
};
use std::cell::RefCell;
use std::rc::Rc;
let lua = mlua::Lua::new();
let reg: SharedRegistry = Rc::new(RefCell::new(pmacs::buffer_registry::BufferRegistry::new()));
let cmds: SharedCommandRegistry = Rc::new(RefCell::new(pmacs::command::CommandRegistry::new()));
let kms: SharedKeymapStack = Rc::new(RefCell::new(pmacs::keymap_stack::KeymapStack::new()));
let mns: SharedMenuRegistry = Rc::new(RefCell::new(pmacs::menu::MenuRegistry::new()));
let hks: SharedHookRegistry = Rc::new(RefCell::new(pmacs::hook::HookRegistry::new()));
install(&lua, &reg, &cmds, &kms, &mns, &hks).expect("install-only host");
lua.load(
r#"
_G.hbuf = pmacs.buffer.create("headless")
_G.hov = pmacs.buffer.add_style_overlay(_G.hbuf)
"#,
)
.exec()
.expect("create + overlay");
let id = reg
.borrow()
.find_by_name("headless")
.expect("buffer exists");
let with_translator = reg.borrow().get(id).unwrap().view_count();
lua.load("_G.hov:dispose()").exec().expect("dispose");
assert_eq!(
reg.borrow().get(id).unwrap().view_count(),
with_translator - 1,
"dispose must detach the translator without a core registered"
);
}
#[test]
fn r5f3_tracked_line_start_matches_the_scan_across_transitions() {
// Round-5 finding 3 is a performance fix — the per-CR/BS/erase
// whole-prefix scan became a tracked byte. These are the
// correctness pins for that tracked value across every
// transition: a multi-line append, CR after a batch boundary,
// repeated CR on one line, erase-line, and a fresh line after
// each. Behavior must be identical to the old scan (this test
// passes on both by design — the perf win is measured, not
// asserted; a timing bound here would flake on slow CI).
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"track.sh",
concat!(
"printf 'l1\\nl2 partial'\n",
"sleep 0.3\n",
"printf '\\rL2 done!!!\\n'\n",
"printf 'p 1\\rp 2\\rp 22\\n'\n",
"printf 'erase me\\033[2K'\n",
"printf 'clean\\n'\n",
),
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let text = compilation_text(&s);
assert!(
text.contains("\nl1\nL2 done!!!\np 22\nclean\n"),
"every rewind lands at the tracked line start; buffer:\n{text:?}"
);
}