pmacs/tests/compile_mode_acceptance.rs

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