pmacs/tests/injection_acceptance.rs

203 lines
7.0 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::time::{Duration, Instant};
use pmacs::buffer::{Buffer, BufferId, EditOp};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::BufferIdLua;
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();
// 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();
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"
);
}
/// 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)"
);
}