lex_babel/common/splice.rs
1//! State machine for splicing handler-rendered raw passthrough content
2//! into a format's event walk in place of the default annotation rendering.
3//!
4//! Every serialization-oriented format on our horizon already has a
5//! native concept of "raw passthrough content" (Markdown's
6//! `HtmlBlock`, HTML's raw text, Pandoc's `RawBlock`, RFC-XML's
7//! `<raw>`, LaTeX's verbatim string append). This module factors the
8//! splice book-keeping — annotation indexing, depth-tracking,
9//! skip-until-EndAnnotation logic — out of every format adapter so
10//! each one only needs to supply a one-line "emit raw passthrough"
11//! callback for its native mechanism.
12//!
13//! `dispatch_render` walks the IR and `tree_to_events` walks the same
14//! IR, so both visit body annotations in matching document order. A
15//! cursor over the plan advances only when the event walker's label
16//! matches the next plan entry — that way an unregistered annotation
17//! (no plan entry) leaves the cursor parked instead of shifting past
18//! the next registered entry. When the matched plan entry has output,
19//! `advance_at_start` returns it and enters skip-state so the
20//! annotation's children — including any inner labelled annotations —
21//! are suppressed (the handler owns the entire subtree).
22//!
23//! ## HTML and sentinel substitution
24//!
25//! Markdown can splice raw passthrough directly via `HtmlBlock`. HTML's
26//! DOM library (`markup5ever_rcdom`) has no raw-text node, so the HTML
27//! adapter still emits a sentinel comment into the DOM and substitutes
28//! it after serialization. The [`SentinelBuffer`] helper here owns the
29//! sentinel-encoding contract so the substitution logic lives in one
30//! place instead of being re-implemented in every DOM-based format.
31
32use crate::render_dispatch::RenderedNode;
33
34/// Splice state machine. Construct one per serialization pass with the
35/// `body_plan` slice (the dispatch plan's body entries, post any
36/// doc-scope prefix the format consumes separately). The event walk
37/// calls [`advance_at_start`](Self::advance_at_start) on every
38/// `StartAnnotation`, [`advance_at_end`](Self::advance_at_end) on every
39/// `EndAnnotation`, and [`should_skip`](Self::should_skip) on every
40/// other event to decide whether the event falls inside a
41/// handler-owned subtree.
42pub struct SpliceState<'a> {
43 plan: Option<&'a [RenderedNode]>,
44 annotation_idx: usize,
45 skip_depth: usize,
46}
47
48impl<'a> SpliceState<'a> {
49 /// New splice state. `None` means "no splicing" — every
50 /// `advance_at_start` returns `None` and `should_skip` is always
51 /// `false`, so the event walk emits its default rendering for
52 /// every annotation. `Some(plan)` engages splicing against the
53 /// supplied plan.
54 pub fn new(plan: Option<&'a [RenderedNode]>) -> Self {
55 Self {
56 plan,
57 annotation_idx: 0,
58 skip_depth: 0,
59 }
60 }
61
62 /// Call on every `Event::StartAnnotation`. Returns `Some(rendered)`
63 /// if this annotation should be replaced by raw passthrough content
64 /// (and enters skip-state so the body events emit nothing).
65 /// Returns `None` to fall through to default rendering.
66 ///
67 /// The cursor advances only when the next plan entry's label matches
68 /// the event's label. An unregistered annotation arriving from the
69 /// event walker (one without a schema, so no plan entry) leaves the
70 /// cursor untouched — otherwise an `unknown.label` arriving before a
71 /// registered `acme.task` would shift the cursor past the
72 /// `acme.task` entry and the handler output would never splice.
73 ///
74 /// Inside an active splice (skip-depth > 0), nested annotations
75 /// with their own plan entries (the dispatch walker visits every
76 /// annotation regardless of nesting) also advance the cursor so
77 /// the post-splice cursor lands on the next outer entry.
78 ///
79 /// Known limitation: renderable verbatim plan entries don't
80 /// correspond to a `StartAnnotation` event, so they aren't advanced
81 /// past by this method. The HTML and markdown event walkers don't
82 /// splice verbatim renders today either, so the limitation is
83 /// confined to scenarios with both a renderable verbatim and a
84 /// downstream registered annotation in the same document — a
85 /// pattern not exercised by any current test.
86 pub fn advance_at_start(&mut self, label: &str) -> Option<&str> {
87 let plan = self.plan?;
88
89 if self.skip_depth > 0 {
90 // Inside a handler-owned subtree. The dispatch walker still
91 // pushes a plan entry for nested annotations it visits, so
92 // consume the cursor entry that matches this inner label so
93 // it doesn't shadow the next outer annotation after the
94 // splice closes.
95 if let Some(entry) = plan.get(self.annotation_idx) {
96 if entry.label == label {
97 self.annotation_idx += 1;
98 }
99 }
100 self.skip_depth += 1;
101 return None;
102 }
103
104 let entry = plan.get(self.annotation_idx)?;
105 if entry.label != label {
106 // Walker is ahead of (or out of sync with) the plan — most
107 // commonly an unregistered annotation that left no plan
108 // entry. Leave the cursor parked so the next event still
109 // sees this entry.
110 return None;
111 }
112 self.annotation_idx += 1;
113 let content = entry.output.as_deref()?;
114 self.skip_depth = 1;
115 Some(content)
116 }
117
118 /// True if the current event arrives inside a handler-owned
119 /// subtree and should be suppressed. Inspect `Start/EndAnnotation`
120 /// even when this is true (they advance the depth counter); skip
121 /// every other event.
122 pub fn should_skip(&self) -> bool {
123 self.skip_depth > 0
124 }
125
126 /// Call on every `Event::EndAnnotation`. Pops one level of
127 /// skip-depth; the splice region ends when this reaches zero.
128 pub fn advance_at_end(&mut self) {
129 if self.skip_depth > 0 {
130 self.skip_depth -= 1;
131 }
132 }
133}
134
135/// Sentinel buffer for DOM-based formats that can't inject raw content
136/// directly (HTML's `markup5ever_rcdom`). The format embeds the string
137/// returned by [`push`](Self::push) at the splice site, and
138/// [`replace`](Self::replace) substitutes each sentinel for its
139/// recorded content after DOM serialization.
140///
141/// Markdown and other formats whose DOM can carry raw passthrough
142/// natively (Comrak's `NodeValue::HtmlBlock`) don't need this — the
143/// splice content from [`SpliceState::advance_at_start`] goes straight
144/// into the format's native raw-passthrough node.
145#[derive(Default)]
146pub struct SentinelBuffer {
147 outputs: Vec<String>,
148}
149
150impl SentinelBuffer {
151 /// The literal prefix used inside the sentinel comment. Public for
152 /// formats that want to assert sentinels don't leak into output.
153 pub const PREFIX: &'static str = "LEX-RENDER-SPLICE:";
154
155 pub fn new() -> Self {
156 Self::default()
157 }
158
159 /// Record `content` and return the sentinel's inner text (`PREFIX +
160 /// N`). The caller embeds it inside a comment-style node — for
161 /// rcdom that's `NodeData::Comment { contents: <returned string> }`,
162 /// which the DOM serializer wraps in `<!--` ... `-->`. The returned
163 /// string is the inner text only so callers don't accidentally
164 /// double-wrap the markers.
165 pub fn push(&mut self, content: String) -> String {
166 let idx = self.outputs.len();
167 self.outputs.push(content);
168 format!("{}{}", Self::PREFIX, idx)
169 }
170
171 /// True if no sentinels were recorded — the caller can skip the
172 /// substitution pass entirely.
173 pub fn is_empty(&self) -> bool {
174 self.outputs.is_empty()
175 }
176
177 /// Substitute every `<!--LEX-RENDER-SPLICE:N-->` comment in `html`
178 /// with `outputs[N]`. Tolerates trailing whitespace inside the
179 /// sentinel (DOM serializers sometimes normalize comment text).
180 /// Non-numeric or out-of-range indices are left in place — a
181 /// programming bug surfaces as a visible sentinel in the output
182 /// instead of silent corruption.
183 pub fn replace(&self, html: &str) -> String {
184 if self.outputs.is_empty() {
185 return html.to_string();
186 }
187 let mut out = String::with_capacity(html.len());
188 let mut remaining = html;
189 let pattern_open = format!("<!--{}", Self::PREFIX);
190 while let Some(start) = remaining.find(&pattern_open) {
191 out.push_str(&remaining[..start]);
192 let after_prefix = &remaining[start + pattern_open.len()..];
193 let Some(end_marker) = after_prefix.find("-->") else {
194 out.push_str(&remaining[start..]);
195 remaining = "";
196 break;
197 };
198 let id_str = after_prefix[..end_marker].trim();
199 match id_str.parse::<usize>() {
200 Ok(idx) if idx < self.outputs.len() => {
201 out.push_str(&self.outputs[idx]);
202 }
203 _ => {
204 out.push_str(&remaining[start..start + pattern_open.len() + end_marker + 3]);
205 }
206 }
207 remaining = &after_prefix[end_marker + 3..];
208 }
209 out.push_str(remaining);
210 out
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn rendered(label: &str, output: Option<&str>) -> RenderedNode {
219 RenderedNode {
220 label: label.into(),
221 output: output.map(str::to_string),
222 diagnostic: None,
223 }
224 }
225
226 #[test]
227 fn no_plan_never_splices() {
228 let mut s = SpliceState::new(None);
229 assert!(s.advance_at_start("acme.task").is_none());
230 assert!(!s.should_skip());
231 s.advance_at_end();
232 }
233
234 #[test]
235 fn plan_entry_without_output_falls_through() {
236 let plan = [rendered("acme.task", None)];
237 let mut s = SpliceState::new(Some(&plan));
238 assert!(s.advance_at_start("acme.task").is_none());
239 assert!(!s.should_skip());
240 }
241
242 #[test]
243 fn plan_entry_with_output_returns_content_and_skips_body() {
244 let plan = [rendered("acme.task", Some("<RENDERED/>"))];
245 let mut s = SpliceState::new(Some(&plan));
246 assert_eq!(s.advance_at_start("acme.task"), Some("<RENDERED/>"));
247 assert!(s.should_skip());
248 s.advance_at_end();
249 assert!(!s.should_skip());
250 }
251
252 #[test]
253 fn label_mismatch_falls_through() {
254 // Plan and event walk diverged — defensive fall-through to
255 // default rendering rather than splicing the wrong output.
256 let plan = [rendered("acme.task", Some("<X/>"))];
257 let mut s = SpliceState::new(Some(&plan));
258 assert!(s.advance_at_start("other.label").is_none());
259 assert!(!s.should_skip());
260 }
261
262 #[test]
263 fn nested_annotations_inside_splice_keep_depth() {
264 let plan = [rendered("outer", Some("<OUTER/>"))];
265 let mut s = SpliceState::new(Some(&plan));
266 assert_eq!(s.advance_at_start("outer"), Some("<OUTER/>"));
267 assert!(s.should_skip());
268 // Inner annotation inside the spliced subtree — counter
269 // advances, skip-depth nests.
270 assert!(s.advance_at_start("inner").is_none());
271 assert!(s.should_skip());
272 s.advance_at_end(); // close inner
273 assert!(s.should_skip());
274 s.advance_at_end(); // close outer
275 assert!(!s.should_skip());
276 }
277
278 #[test]
279 fn counter_advances_across_unspliced_annotations() {
280 let plan = [
281 rendered("a", None),
282 rendered("b", Some("<B/>")),
283 rendered("c", None),
284 ];
285 let mut s = SpliceState::new(Some(&plan));
286 assert!(s.advance_at_start("a").is_none());
287 s.advance_at_end();
288 assert_eq!(s.advance_at_start("b"), Some("<B/>"));
289 s.advance_at_end();
290 assert!(s.advance_at_start("c").is_none());
291 s.advance_at_end();
292 }
293
294 #[test]
295 fn sentinel_buffer_round_trips_single_splice() {
296 let mut buf = SentinelBuffer::new();
297 let sentinel = buf.push("<DIV/>".into());
298 let html = format!("<p><!--{sentinel}--></p>");
299 assert_eq!(buf.replace(&html), "<p><DIV/></p>");
300 }
301
302 #[test]
303 fn sentinel_buffer_round_trips_multiple_in_order() {
304 let mut buf = SentinelBuffer::new();
305 let s0 = buf.push("ZERO".into());
306 let s1 = buf.push("ONE".into());
307 let html = format!("a<!--{s0}-->b<!--{s1}-->c");
308 assert_eq!(buf.replace(&html), "aZERObONEc");
309 }
310
311 #[test]
312 fn sentinel_buffer_empty_is_no_op() {
313 let buf = SentinelBuffer::new();
314 assert!(buf.is_empty());
315 assert_eq!(buf.replace("plain html"), "plain html");
316 }
317
318 #[test]
319 fn sentinel_buffer_leaves_unknown_index_visible() {
320 // Seed the buffer so `replace` doesn't early-return on an empty
321 // outputs vec — otherwise the out-of-range branch isn't
322 // exercised at all (Copilot review on PR #625).
323 let mut buf = SentinelBuffer::new();
324 buf.push("recorded".to_string());
325 let html = "<!--LEX-RENDER-SPLICE:7-->";
326 assert_eq!(
327 buf.replace(html),
328 html,
329 "out-of-range index must stay visible even when other sentinels are recorded"
330 );
331 }
332
333 /// Regression for Copilot's review on PR #625: an unregistered
334 /// annotation arriving from the event walker before a registered
335 /// one must not shift the cursor — otherwise the registered
336 /// annotation's handler output never splices because the plan
337 /// entry it points at has the wrong label.
338 #[test]
339 fn unregistered_annotation_before_registered_keeps_cursor_aligned() {
340 let plan = [rendered("acme.task", Some("<HANDLER/>"))];
341 let mut s = SpliceState::new(Some(&plan));
342 // Unregistered annotation event: no plan entry, must not consume.
343 assert!(s.advance_at_start("unknown.label").is_none());
344 s.advance_at_end();
345 // Registered annotation event: must match plan[0] and splice.
346 assert_eq!(s.advance_at_start("acme.task"), Some("<HANDLER/>"));
347 s.advance_at_end();
348 }
349
350 /// Regression for Copilot's review on PR #625: a nested registered
351 /// annotation inside a handler-owned splice must consume its plan
352 /// entry so subsequent outer annotations align correctly.
353 #[test]
354 fn nested_registered_annotation_inside_splice_consumes_its_plan_entry() {
355 let plan = [
356 rendered("outer", Some("<OUTER/>")),
357 rendered("inner", Some("<INNER_UNUSED/>")),
358 rendered("after", Some("<AFTER/>")),
359 ];
360 let mut s = SpliceState::new(Some(&plan));
361 // Outer fires and enters skip-state.
362 assert_eq!(s.advance_at_start("outer"), Some("<OUTER/>"));
363 assert!(s.should_skip());
364 // Inner is registered (has a plan entry) but inside the splice
365 // — must consume its cursor entry so the next outer is aligned.
366 assert!(s.advance_at_start("inner").is_none());
367 s.advance_at_end(); // close inner
368 s.advance_at_end(); // close outer
369 // After the splice closes, the next outer annotation aligns
370 // with plan[2], not plan[1].
371 assert_eq!(s.advance_at_start("after"), Some("<AFTER/>"));
372 s.advance_at_end();
373 }
374}