Skip to main content

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}