//! Compile-mode acceptance (Arc 5 stage 1, //! docs/compile-mode-framing.md, items 1–33; 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(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::() { 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)> { 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 { 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]"; // --------------------------------------------------------------------------- // 1–4: 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" ); } // --------------------------------------------------------------------------- // 5–9: 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:?}" ); } // --------------------------------------------------------------------------- // 10–14: 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) ); } // --------------------------------------------------------------------------- // 15–18: 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)" ); } // --------------------------------------------------------------------------- // 19–22: 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" ); } // --------------------------------------------------------------------------- // 23–25: 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")); } // --------------------------------------------------------------------------- // 28–30: 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")); } // --------------------------------------------------------------------------- // 31–33: 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, ®, &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" ); } // --------------------------------------------------------------------------- // PR #113 round 8 — direct review fixes // --------------------------------------------------------------------------- #[test] fn r8f1_dispose_is_atomic_and_retryable_under_reentrant_borrows() { // dispose() is callable from Lua callbacks that may still be // running under an EditorCore or BufferRegistry RefCell borrow. // It must return a pointed error before changing the shared // disposed state or removing either view, then succeed when // retried after that callback completes. let s = editor(); exec( &s, r#" _G.r8_buf = pmacs.buffer.create("*r8-dispose*") pmacs.window.switch_buffer(_G.r8_buf) _G.r8_ov = pmacs.buffer.add_style_overlay(_G.r8_buf) "#, ); let (ok_core, err_core): (bool, String) = { let _core_borrow = s.core.borrow_mut(); eval( &s, "local ok, err = pcall(_G.r8_ov.dispose, _G.r8_ov); \ return ok, tostring(err)", ) }; assert!(!ok_core, "dispose under a core borrow must fail cleanly"); assert!( err_core.contains("defer dispose()"), "core-borrow error must name the recovery; got: {err_core}" ); let (still_attached, can_attach): (i64, bool) = eval( &s, r#" local n = 0 for _, kind in ipairs(pmacs.window._overlay_kinds()) do if kind == "buffer_style_overlay" then n = n + 1 end end return n, pcall(pmacs.buffer.attach_style_overlay, _G.r8_buf, _G.r8_ov) "#, ); assert_eq!( still_attached, 1, "failed disposal must not remove render views" ); assert!( can_attach, "failed disposal must not mark the handle disposed" ); let registry = s.core.borrow().registry.clone(); let (ok_registry, err_registry): (bool, String) = { let _registry_borrow = registry.borrow_mut(); eval( &s, "local ok, err = pcall(_G.r8_ov.dispose, _G.r8_ov); \ return ok, tostring(err)", ) }; assert!( !ok_registry, "dispose under a registry borrow must fail cleanly" ); assert!( err_registry.contains("defer dispose()"), "registry-borrow error must name the recovery; got: {err_registry}" ); let (still_attached, can_attach): (i64, bool) = eval( &s, r#" local n = 0 for _, kind in ipairs(pmacs.window._overlay_kinds()) do if kind == "buffer_style_overlay" then n = n + 1 end end return n, pcall(pmacs.buffer.attach_style_overlay, _G.r8_buf, _G.r8_ov) "#, ); assert_eq!( still_attached, 1, "registry conflict must not partially dispose" ); assert!(can_attach, "registry conflict must leave the handle live"); let (ok_final, remaining): (bool, i64) = eval( &s, r#" local ok = pcall(_G.r8_ov.dispose, _G.r8_ov) local n = 0 for _, kind in ipairs(pmacs.window._overlay_kinds()) do if kind == "buffer_style_overlay" then n = n + 1 end end return ok, n "#, ); assert!(ok_final, "dispose must be retryable after the borrow ends"); assert_eq!(remaining, 0, "successful retry removes the render view"); } #[test] fn r8f2_attach_rejects_a_handle_whose_owner_buffer_was_removed() { // BufferId equality alone does not prove that the recorded owner // is still live. Without a registry resolution this returned // success after the buffer (and its translator) had been removed. let s = editor(); let (ok, err): (bool, String) = eval( &s, r#" local buf = pmacs.buffer.create("*stale-overlay-owner*") local ov = pmacs.buffer.add_style_overlay(buf) pmacs.buffer.remove(buf) local ok, err = pcall(pmacs.buffer.attach_style_overlay, buf, ov) return ok, tostring(err) "#, ); assert!(!ok, "attachment for a removed owner must be rejected"); assert!( err.contains("stale buffer handle"), "stale-owner error must identify the invalid handle; got: {err}" ); } #[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:?}" ); }