pmacs/tests/injection_acceptance.rs

315 lines
11 KiB
Rust

// injection_acceptance.rs --- multi-language injection acceptance gates.
//! Multi-language injection acceptance gates that need the public API
//! surface (the Lua async path and a settle-time perf budget). The
//! layer-structure, alias-resolution, producer, and GPU gates live next to
//! the code they exercise (`syntax.rs` / `semantic_render.rs` /
//! `highlight.rs` / `pmacs-gpu`), where `run_parse`, `scoped_style_spans`,
//! and `source_color_at` are reachable. See the framing acceptance list in
//! `docs/multi-language-injections-framing.md`.
use std::fmt::Write as _;
use std::sync::Arc;
use std::time::{Duration, Instant};
use pmacs::buffer::{Buffer, BufferId, EditOp};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::BufferIdLua;
use pmacs::rope::Range;
use pmacs::syntax::{self, ParseView, SyntaxRegistry};
/// Drive `tick_async` until `predicate` holds or a deadline passes.
fn pump_async<F: Fn(&EditorState) -> bool>(state: &mut EditorState, predicate: F) {
let deadline = Instant::now() + Duration::from_secs(3);
while !predicate(state) {
assert!(Instant::now() < deadline, "async pump deadline exceeded");
state.tick_async();
std::thread::sleep(Duration::from_millis(2));
}
}
/// Framing acceptance #5 (full Lua async path): an alias added from Lua via
/// `pmacs.parse.injection_aliases` must reach the parse worker through the
/// dispatch snapshot, so an *asynchronously* dispatched injected parse
/// resolves a fence named by the new alias. Testing only the static
/// resolver would leave the Lua write-through + snapshot bridge unproven.
#[test]
fn lua_alias_override_resolves_on_async_parse() {
let mut state = EditorState::new_with_roots(&crate::iso::roots());
// Add a bespoke fence alias from Lua (write-through to the registry).
state
.lua_host
.lua()
.load(r#"pmacs.parse.injection_aliases.mydsl = "rust""#)
.exec()
.expect("set injection alias from Lua");
// A markdown buffer whose fence uses the new alias.
let src = b"# Doc\n\n```mydsl\nfn injected() { let x = 1; }\n```\n";
let buf_id = state
.lua_host
.registry()
.borrow_mut()
.create_from_bytes("doc.md".to_owned(), src);
state
.lua_host
.lua()
.globals()
.set("BUF", BufferIdLua(buf_id))
.expect("bind BUF");
// Dispatch asynchronously (the wrapped `_dispatch` records the job; the
// per-tick settle path installs the resolved bundle).
state
.lua_host
.lua()
.load("pmacs.parse._dispatch(BUF, 'markdown')")
.exec()
.expect("async dispatch");
pump_async(&mut state, |s| {
s.syntax_registry
.view(buf_id)
.and_then(|h| h.current())
.is_some()
});
let bundle = state
.syntax_registry
.view(buf_id)
.and_then(|h| h.current())
.expect("settled bundle");
assert!(
bundle.layers.iter().any(|l| l.language_name == "rust"),
"the Lua-set `mydsl` alias resolved the fence to a rust child layer; \
layers: {:?}",
bundle
.layers
.iter()
.map(|l| l.language_name.as_str())
.collect::<Vec<_>>()
);
}
/// Round-2 finding: the synchronous parse path (`_parse_now`) must snapshot
/// injection aliases too — otherwise a `py` fence (or a Lua-added alias)
/// injects asynchronously but not synchronously. The default `py`→python
/// alias discriminates the fix: with the empty map it would not resolve.
#[test]
fn sync_parse_now_resolves_alias() {
let state = EditorState::new_with_roots(&crate::iso::roots());
let src = b"# Doc\n\n```py\nx = 1\n```\n";
let buf_id = state
.lua_host
.registry()
.borrow_mut()
.create_from_bytes("doc.md".to_owned(), src);
state
.lua_host
.lua()
.globals()
.set("BUF", BufferIdLua(buf_id))
.expect("bind BUF");
state
.lua_host
.lua()
.load("pmacs.parse._parse_now(BUF, 'markdown')")
.exec()
.expect("synchronous parse");
let bundle = state
.syntax_registry
.view(buf_id)
.and_then(|h| h.current())
.expect("installed bundle");
assert!(
bundle.layers.iter().any(|l| l.language_name == "python"),
"the `py` fence resolved to python on the synchronous `_parse_now` path"
);
}
/// Round-2 finding: the injection layer cap must be *observably* surfaced,
/// not merely flagged. Drives the real Lua settle path (`syntax.lua`'s tick
/// → `_injection_capped` → `pmacs.error`) and asserts the three behaviors:
/// surfaced once, suppressed on an unchanged re-parse, and re-armed after
/// the file drops below the cap and exceeds it again.
#[test]
fn injection_cap_surfaced_once_and_rearms_via_lua() {
let mut state = EditorState::new_with_roots(&crate::iso::roots());
// Capture pmacs.error messages into a Lua global.
state
.lua_host
.lua()
.load("_CAP = {}\npmacs.error = function(msg) _CAP[#_CAP + 1] = tostring(msg) end")
.exec()
.expect("install error capture");
let capping: String = "```rust\nx\n```\n\n".repeat(4096 + 8);
let buf_id = state
.lua_host
.registry()
.borrow_mut()
.create_from_bytes("big.md".to_owned(), capping.as_bytes());
state
.lua_host
.lua()
.globals()
.set("BUF", BufferIdLua(buf_id))
.expect("bind BUF");
let dispatch = |state: &EditorState| {
state
.lua_host
.lua()
.load("pmacs.parse._dispatch(BUF, 'markdown')")
.exec()
.expect("dispatch");
};
let cap_count = |state: &EditorState| -> usize {
state.lua_host.lua().load("return #_CAP").eval().unwrap()
};
let current =
|state: &EditorState| state.syntax_registry.view(buf_id).and_then(|h| h.current());
let replace_all = |state: &EditorState, bytes: &[u8]| {
let core = state.core.borrow();
let mut reg = core.registry.borrow_mut();
let buf = reg.get_mut(buf_id).expect("buffer");
let len = buf.len();
buf.apply_edit(EditOp::Replace {
range: Range::new(0, len),
bytes,
})
.expect("replace");
};
// 1) First settle → surfaced exactly once, message names the cap.
dispatch(&state);
pump_async(&mut state, |s| current(s).is_some());
assert_eq!(cap_count(&state), 1, "cap surfaced once on first settle");
let msg: String = state.lua_host.lua().load("return _CAP[1]").eval().unwrap();
assert!(
msg.contains("injection layer cap"),
"message names the cap: {msg}"
);
// 2) Re-dispatch with no change → suppressed (still once).
let b1 = current(&state).unwrap();
dispatch(&state);
pump_async(&mut state, |s| {
current(s).is_some_and(|b| !Arc::ptr_eq(&b1, &b))
});
assert_eq!(
cap_count(&state),
1,
"once-per-buffer: no re-warn without change"
);
// 3) Shrink below the cap → warned flag clears, no new error.
replace_all(&state, b"# small\n\n```rust\nx\n```\n");
let b2 = current(&state).unwrap();
dispatch(&state);
pump_async(&mut state, |s| {
current(s).is_some_and(|b| !Arc::ptr_eq(&b2, &b))
});
assert_eq!(
cap_count(&state),
1,
"dropping below the cap surfaces no new error"
);
// 4) Grow back above the cap → re-armed, warns once more.
replace_all(&state, capping.as_bytes());
let b3 = current(&state).unwrap();
dispatch(&state);
pump_async(&mut state, |s| {
current(s).is_some_and(|b| !Arc::ptr_eq(&b3, &b))
});
assert_eq!(
cap_count(&state),
2,
"re-armed: exceeding the cap again warns once more"
);
}
/// Framing acceptance #12: a large all-inline markdown buffer settles
/// (root + one cold inline layer per paragraph) within a comfortable
/// budget, and the FINAL paragraph still receives an inline layer — the
/// tail is not silently dropped. This is the measured guard that keeps
/// child-incrementality (Q#IJ8) out of v1.
#[test]
fn many_paragraph_settle_under_budget_with_tail_covered() {
let reg = SyntaxRegistry::new();
let n = 200usize;
let mut src = String::new();
for i in 0..n {
// A blank line separates paragraphs; each carries emphasis + a link
// so the block grammar injects a markdown_inline layer for it.
writeln!(
src,
"Paragraph {i} with *emphasis* and a [link](http://x).\n"
)
.expect("write");
}
// A distinctly-marked final paragraph.
let marker = "FINALPARAGRAPH";
writeln!(src, "{marker} with *stress*.\n").expect("write");
let language = reg.language("markdown").expect("markdown grammar");
let mut buf = Buffer::new(BufferId::next(), "big.md");
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: src.as_bytes(),
})
.expect("seed markdown");
let view = ParseView::new(&buf, language, "markdown".to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let mut req = handle.make_request();
req.injection_aliases = reg.injection_alias_snapshot();
let start = Instant::now();
let bundle = syntax::run_parse(req).expect("layered markdown parse");
let elapsed = start.elapsed();
// Catastrophic-regression guard: cold-parsing ~200 tiny inline layers
// is milliseconds of work; a generous ceiling avoids debug/CI flakiness
// while still catching a blow-up (e.g. accidental O(n^2) layer work).
assert!(
elapsed < Duration::from_secs(2),
"many-paragraph settle took {elapsed:?}, exceeds the budget"
);
let inline_count = bundle
.layers
.iter()
.filter(|l| l.language_name == "markdown_inline")
.count();
assert!(
inline_count >= 100,
"most paragraphs produce an inline layer; got {inline_count}"
);
// Tail coverage: an inline layer's tree spans the final paragraph.
let marker_off = src.find(marker).expect("marker present");
let tail_covered = bundle
.layers
.iter()
.filter(|l| l.language_name == "markdown_inline")
.any(|l| {
let r = l.tree.root_node();
(r.start_byte()..r.end_byte()).contains(&marker_off)
});
assert!(
tail_covered,
"the final paragraph still receives an inline layer (no tail loss)"
);
}
// 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;