pmacs/tests/compile_mode_acceptance.rs

1927 lines
64 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 {
Path::new(&format!("/proc/{pid}")).exists()
}
/// 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())
}
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"
);
}
#[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 pidfile = dir.path().join("pid");
let mut s = editor();
compile_run(
&s,
&format!(
"( trap '' TERM; exec >/dev/null 2>&1; sleep 30 ) & echo $! > {}",
pidfile.display()
),
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();
compile_run(&s, "( trap '' TERM; sleep 30 ) & echo started", 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)"
);
}