// 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 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::>() ); } /// 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" ); } /// 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(); // 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)" ); }