Skip to main content

asciidoc_parser/content/
content.rs

1//! Describes the content of a non-compound block after any relevant
2//! [substitutions] have been performed.
3//!
4//! [substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/
5
6use crate::{
7    Span,
8    parser::{
9        InlineSubstitutionRenderer, ReferenceResolver, ReferenceWarning, ReferenceWarningKind,
10        ResolutionContext, XrefRenderParams,
11    },
12    strings::CowStr,
13};
14
15/// Describes the annotated content of a block after any relevant
16/// [substitutions] have been performed.
17///
18/// This is typically used to represent the main body of block types that don't
19/// contain other blocks, such as [`SimpleBlock`] or [`RawDelimitedBlock`].
20///
21/// # Deferred cross-references
22///
23/// Cross-references (`<<id>>`, `xref:id[…]`) cannot be resolved while a block
24/// is being parsed, because their target may be defined later in the document
25/// (or, for multi-document workflows, in another document entirely). The
26/// macros substitution therefore records each cross-reference in a deferred
27/// form and leaves an opaque placeholder in the rendered text. The
28/// references are resolved in a later pass — see
29/// [`Document::resolve_references`] — at which point [`rendered()`] reflects
30/// the resolved links. Until then, [`rendered()`] shows an unresolved fallback,
31/// so it always returns clean text.
32///
33/// [substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/
34/// [`SimpleBlock`]: crate::blocks::SimpleBlock
35/// [`RawDelimitedBlock`]: crate::blocks::RawDelimitedBlock
36/// [`Document::resolve_references`]: crate::Document::resolve_references
37/// [`rendered()`]: Self::rendered
38#[derive(Clone, Eq, PartialEq)]
39pub struct Content<'src> {
40    /// The original [`Span`] from which this content was derived.
41    original: Span<'src>,
42
43    /// The possibly-modified text after substititions have been performed.
44    ///
45    /// This is always clean, user-facing text: when cross-references are still
46    /// unresolved it holds the unresolved fallback rendering, and after
47    /// resolution it holds the resolved rendering.
48    pub(crate) rendered: CowStr<'src>,
49
50    /// Source [`Span`] of each line that survived construction filtering, in
51    /// the same order as the lines of [`rendered`](Self::rendered) at
52    /// construction time.
53    ///
54    /// This is retained only so the attribute-references substitution can
55    /// locate an `attribute-missing=warn` warning at the precise source
56    /// offset of the offending `{name}` reference, rather than at the
57    /// whole-content span. See
58    /// [`apply_attributes`](crate::content::substitution_step) for the
59    /// rationale and the correlation it performs.
60    ///
61    /// `None` when the content was not built line-by-line from document source
62    /// (e.g. [`From<Span>`] or a table cell's pre-filtered value), in which
63    /// case such warnings fall back to the whole-content span.
64    source_lines: Option<Box<[Span<'src>]>>,
65
66    /// Deferred cross-references discovered during substitution, awaiting
67    /// resolution against a (possibly cross-document) catalog.
68    ///
69    /// `None` for the overwhelming majority of content, which contains no
70    /// cross-references.
71    deferred: Option<Box<DeferredContent>>,
72}
73
74/// The deferred (cross-reference-bearing) portion of a [`Content`].
75#[derive(Clone, Debug, Eq, PartialEq)]
76struct DeferredContent {
77    /// The locally-substituted text with opaque placeholder tokens marking
78    /// where each cross-reference will be spliced in. This is the source of
79    /// truth from which [`Content::rendered`] is (re)built, so resolution is
80    /// non-destructive and may be repeated.
81    template: String,
82
83    /// The cross-references, in placeholder order.
84    xrefs: Vec<XrefSegment>,
85}
86
87/// A single deferred cross-reference.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub(crate) struct XrefSegment {
90    /// The raw, uninterpreted target as written in the source.
91    pub(crate) target: String,
92
93    /// Explicit link text supplied in the cross-reference, if any.
94    pub(crate) provided_text: Option<String>,
95
96    /// Target window selection, from a `window` attribute on the `xref:` macro
97    /// (e.g. `_blank`). `None` for the shorthand form, which has no attribute
98    /// list.
99    pub(crate) window: Option<String>,
100
101    /// Roles supplied via a `role` attribute on the `xref:` macro, if any.
102    pub(crate) roles: Vec<String>,
103
104    /// The cross-reference text style in effect for this reference: the
105    /// `xrefstyle=` attribute on the `xref:` macro if given, otherwise the
106    /// document-wide `xrefstyle` at the reference's location. `None` when
107    /// `xrefstyle` is unset, in which case the target's reftext is used
108    /// verbatim.
109    pub(crate) xrefstyle: Option<crate::parser::XrefStyle>,
110
111    /// The resolved destination, filled in by resolution; `None` until then.
112    pub(crate) resolved: Option<crate::parser::ResolvedReference>,
113}
114
115/// Sentinel codepoints (Unicode Private Use Area) bracketing a placeholder
116/// index in [`DeferredContent::template`]. These cannot collide with user text
117/// and are inert to the remaining substitution steps.
118const XREF_PLACEHOLDER_START: char = '\u{E000}';
119const XREF_PLACEHOLDER_END: char = '\u{E001}';
120
121/// Sentinel codepoints (Unicode Private Use Area) bracketing a footnote's
122/// rendered inline marker while a section title is being substituted. Like the
123/// cross-reference placeholders above, these cannot collide with user text and
124/// are inert to the remaining substitution steps.
125///
126/// A footnote in a section title is a real, document-order footnote, but its
127/// marker must be kept out of the section's reference text and auto-generated
128/// ID. Marking the marker in a single render (rather than re-rendering the
129/// title with footnotes suppressed) means stateful substitutions — counters,
130/// attribute references that expand into footnotes — run exactly once. See
131/// [`strip_footnote_marker_spans`] and
132/// [`Content::remove_footnote_marker_sentinels`].
133pub(crate) const FOOTNOTE_MARKER_START: char = '\u{E002}';
134pub(crate) const FOOTNOTE_MARKER_END: char = '\u{E003}';
135
136/// Removes each footnote marker span — a [`FOOTNOTE_MARKER_START`] …
137/// [`FOOTNOTE_MARKER_END`] region and everything between, i.e. the sentinels
138/// *and* the marker they bracket — leaving footnote-free text suitable for a
139/// section's reference text and auto-generated ID.
140pub(crate) fn strip_footnote_marker_spans(s: &str) -> String {
141    let mut out = String::with_capacity(s.len());
142    let mut rest = s;
143
144    while let Some(start) = rest.find(FOOTNOTE_MARKER_START) {
145        out.push_str(&rest[..start]);
146        rest = &rest[start + FOOTNOTE_MARKER_START.len_utf8()..];
147
148        // Drop through the matching end sentinel (the marker text). A start
149        // without an end cannot occur — the substitution always emits both — but
150        // if it somehow did, drop the remainder rather than reintroduce the
151        // stray sentinel.
152        rest = match rest.find(FOOTNOTE_MARKER_END) {
153            Some(end) => &rest[end + FOOTNOTE_MARKER_END.len_utf8()..],
154            None => "",
155        };
156    }
157
158    out.push_str(rest);
159    out
160}
161
162impl<'src> Content<'src> {
163    /// Constructs a `Content` from a source `Span` and a potentially-filtered
164    /// view of that source text.
165    pub(crate) fn from_filtered<T: AsRef<str>>(span: Span<'src>, filtered: T) -> Self {
166        Self {
167            original: span,
168            rendered: filtered.as_ref().to_string().into(),
169            source_lines: None,
170            deferred: None,
171        }
172    }
173
174    /// Constructs a `Content` from a source `Span` and the per-line filtered
175    /// view of that source, retaining the source `Span` of each surviving line.
176    ///
177    /// `line_spans` must contain one entry per line of `filtered_lines`, in the
178    /// same order; each entry is the source span whose text is that filtered
179    /// line (i.e. after any leading-indent stripping and trailing-whitespace
180    /// trimming the caller applied). The retained spans let the
181    /// attribute-references substitution report an `attribute-missing=warn`
182    /// warning at the precise source offset of the offending reference; see
183    /// [`apply_attributes`](crate::content::substitution_step).
184    pub(crate) fn from_filtered_lines(
185        span: Span<'src>,
186        filtered_lines: &[&str],
187        line_spans: Vec<Span<'src>>,
188    ) -> Self {
189        // One source span is required per filtered line; the default
190        // `debug_assert_eq!` message reports both counts if this is ever broken.
191        debug_assert_eq!(filtered_lines.len(), line_spans.len());
192
193        Self {
194            original: span,
195            rendered: filtered_lines.join("\n").into(),
196            source_lines: Some(line_spans.into_boxed_slice()),
197            deferred: None,
198        }
199    }
200
201    /// Returns the original span from which this [`Content`] was derived.
202    ///
203    /// This is the source text before any substitions have been applied.
204    pub fn original(&self) -> Span<'src> {
205        self.original
206    }
207
208    /// Returns the source `Span` of each line that survived construction
209    /// filtering, in rendered-line order, when they were retained (see
210    /// [`from_filtered_lines`](Self::from_filtered_lines)).
211    ///
212    /// Used only by the attribute-references substitution to locate
213    /// `attribute-missing=warn` warnings precisely.
214    pub(crate) fn source_lines(&self) -> Option<&[Span<'src>]> {
215        self.source_lines.as_deref()
216    }
217
218    /// Returns the final text after all substitutions have been applied.
219    pub fn rendered(&'src self) -> &'src str {
220        self.rendered.as_ref()
221    }
222
223    /// Returns an owned copy of the final text after all substitutions have
224    /// been applied.
225    ///
226    /// Unlike [`rendered()`](Self::rendered), this does not tie the returned
227    /// value to the `'src` lifetime, so it can be called on a short-lived
228    /// `Content` built solely to render a fragment (e.g. a block's attribution
229    /// or citation text).
230    pub(crate) fn rendered_owned(&self) -> String {
231        self.rendered.as_ref().to_string()
232    }
233
234    /// Returns `true` if `self` contains no text.
235    pub fn is_empty(&self) -> bool {
236        self.rendered.as_ref().is_empty()
237    }
238
239    /// Removes the [`FOOTNOTE_MARKER_START`]/[`FOOTNOTE_MARKER_END`] sentinels
240    /// bracketing each footnote marker, *keeping* the marker itself, so the
241    /// content renders normally. Called after a section title's reference text
242    /// and ID have been derived (via [`strip_footnote_marker_spans`], which
243    /// needs the sentinels to locate the markers). The sentinels are removed
244    /// from the deferred template too, so a later cross-reference resolution
245    /// rebuild does not reintroduce them.
246    pub(crate) fn remove_footnote_marker_sentinels(&mut self) {
247        if !self.rendered.as_ref().contains(FOOTNOTE_MARKER_START) {
248            return;
249        }
250
251        self.rendered = self
252            .rendered
253            .as_ref()
254            .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "")
255            .into();
256
257        if let Some(deferred) = self.deferred.as_mut() {
258            deferred.template = deferred
259                .template
260                .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "");
261        }
262    }
263
264    /// Returns `true` if this content contains one or more cross-references
265    /// that have not yet been resolved to a destination.
266    pub fn has_unresolved_refs(&self) -> bool {
267        self.deferred
268            .as_ref()
269            .is_some_and(|d| d.xrefs.iter().any(|x| x.resolved.is_none()))
270    }
271
272    /// Records the cross-references discovered for this content during the
273    /// macros substitution step. The placeholder tokens for these references
274    /// must already have been written into [`Content::rendered`], in the same
275    /// order as `xrefs`.
276    ///
277    /// This must be called at most once per `Content`: the placeholder indices
278    /// already embedded in [`Content::rendered`] are positions into this single
279    /// `xrefs` vector. The macros substitution runs once per content, so this
280    /// holds in practice; the assertion guards against a future caller breaking
281    /// it.
282    pub(crate) fn set_deferred_xrefs(&mut self, xrefs: Vec<XrefSegment>) {
283        if xrefs.is_empty() {
284            return;
285        }
286
287        debug_assert!(
288            self.deferred.is_none(),
289            "set_deferred_xrefs must be called at most once per Content"
290        );
291
292        self.deferred = Some(Box::new(DeferredContent {
293            template: String::new(),
294            xrefs,
295        }));
296    }
297
298    /// Returns the placeholder token for the cross-reference at `index`.
299    pub(crate) fn xref_placeholder(index: usize) -> String {
300        format!("{XREF_PLACEHOLDER_START}{index}{XREF_PLACEHOLDER_END}")
301    }
302
303    /// Finalizes any deferred cross-references at the end of substitution.
304    ///
305    /// At this point [`Content::rendered`] holds the placeholder-bearing text;
306    /// it is captured as the template and `rendered` is rebuilt as the
307    /// unresolved fallback so it is immediately clean for callers that read it
308    /// before resolution.
309    pub(crate) fn finalize_deferred(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
310        if self.deferred.is_none() {
311            return;
312        }
313
314        let template = self.rendered.as_ref().to_string();
315
316        if let Some(deferred) = self.deferred.as_mut() {
317            deferred.template = template;
318        }
319
320        self.rebuild_rendered(renderer);
321    }
322
323    /// Resolves any deferred cross-references using `resolver`, then rebuilds
324    /// the rendered text.
325    ///
326    /// This is non-destructive: the placeholder template is retained, so a
327    /// document may be resolved more than once (e.g. for incremental builds or
328    /// multiple output targets).
329    ///
330    /// Any target that the resolver cannot resolve is reported in `warnings`.
331    pub(crate) fn resolve_references(
332        &mut self,
333        resolver: &dyn ReferenceResolver,
334        renderer: &dyn InlineSubstitutionRenderer,
335        warnings: &mut Vec<ReferenceWarning>,
336    ) {
337        if let Some(deferred) = self.deferred.as_mut() {
338            let DeferredContent { template, xrefs } = deferred.as_mut();
339
340            // A `deferred` block always holds at least one xref placeholder, so
341            // its finalized template is never empty. An empty template here
342            // means `finalize_deferred` was skipped (a future-refactor hazard);
343            // the `template.contains` guard below would then silently suppress
344            // every unresolved-ref warning, so catch that invariant break in
345            // debug builds.
346            debug_assert!(!template.is_empty());
347
348            for (index, xref) in xrefs.iter_mut().enumerate() {
349                xref.resolved = resolver.resolve(&ResolutionContext {
350                    target: &xref.target,
351                    provided_text: xref.provided_text.as_deref(),
352                });
353
354                // A reference whose placeholder is no longer in the template was
355                // re-homed into a footnote (see `rehome_xref_placeholders`); the
356                // footnote resolves and reports it, so it is not reported here.
357                if xref.resolved.is_none() && template.contains(&Content::xref_placeholder(index)) {
358                    warnings.push(ReferenceWarning {
359                        target: xref.target.clone(),
360                        kind: ReferenceWarningKind::Unresolved,
361                    });
362                }
363            }
364        }
365
366        self.rebuild_rendered(renderer);
367    }
368
369    /// Rebuilds [`Content::rendered`] from the deferred template and the
370    /// current (resolved or unresolved) state of its cross-references.
371    fn rebuild_rendered(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
372        let Some(deferred) = self.deferred.as_ref() else {
373            return;
374        };
375
376        self.rendered = render_template(&deferred.template, &deferred.xrefs, renderer).into();
377    }
378}
379
380/// Re-homes the cross-reference placeholders found in `text` into a
381/// self-contained (template, xrefs) pair.
382///
383/// When the cross-reference substitution runs before footnotes, a footnote's
384/// text may carry placeholder tokens whose [`XrefSegment`]s live in the
385/// enclosing block's cross-reference list (`all`). Because a footnote's text is
386/// extracted out of the block, it needs its own copy of just those segments,
387/// renumbered so its template is independent. This scans `text` for placeholder
388/// tokens, clones the referenced segments into a fresh vector (in first-seen
389/// order), and rewrites the tokens to the new local indices.
390///
391/// Text with no placeholders returns unchanged alongside an empty vector.
392pub(crate) fn rehome_xref_placeholders(
393    text: &str,
394    all: &[XrefSegment],
395) -> (String, Vec<XrefSegment>) {
396    let mut local: Vec<XrefSegment> = vec![];
397
398    if !text.contains(XREF_PLACEHOLDER_START) {
399        return (text.to_string(), local);
400    }
401
402    let mut out = String::with_capacity(text.len());
403    let mut rest = text;
404
405    while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
406        out.push_str(&rest[..start]);
407        let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
408
409        let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
410            out.push(XREF_PLACEHOLDER_START);
411            rest = after;
412            continue;
413        };
414
415        let body = &after[..end];
416        rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
417
418        match body.parse::<usize>().ok().and_then(|index| all.get(index)) {
419            Some(segment) => {
420                let local_index = local.len();
421                local.push(segment.clone());
422                out.push_str(&Content::xref_placeholder(local_index));
423            }
424
425            None => {
426                out.push(XREF_PLACEHOLDER_START);
427                out.push_str(body);
428                out.push(XREF_PLACEHOLDER_END);
429            }
430        }
431    }
432
433    out.push_str(rest);
434    (out, local)
435}
436
437/// Splices resolved (or fallback) cross-reference renderings into a placeholder
438/// template, producing the final rendered text.
439fn render_template(
440    template: &str,
441    xrefs: &[XrefSegment],
442    renderer: &dyn InlineSubstitutionRenderer,
443) -> String {
444    let mut out = String::with_capacity(template.len());
445    let mut rest = template;
446
447    while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
448        out.push_str(&rest[..start]);
449        let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
450
451        let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
452            // Malformed placeholder; emit the sentinel literally and continue.
453            out.push(XREF_PLACEHOLDER_START);
454            rest = after;
455            continue;
456        };
457
458        let body = &after[..end];
459        rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
460
461        match body
462            .parse::<usize>()
463            .ok()
464            .and_then(|index| xrefs.get(index))
465        {
466            Some(xref) => {
467                renderer.render_xref(
468                    &XrefRenderParams {
469                        target: &xref.target,
470                        provided_text: xref.provided_text.as_deref(),
471                        window: xref.window.as_deref(),
472                        roles: &xref.roles,
473                        xrefstyle: xref.xrefstyle,
474                        resolved: xref.resolved.as_ref(),
475                    },
476                    &mut out,
477                );
478            }
479
480            None => {
481                // Unreachable while `template` and `xrefs` come from the same
482                // `Content` (indices are assigned sequentially). If that
483                // invariant is ever broken, emit the raw placeholder rather than
484                // silently dropping the span, so the breakage is visible.
485                debug_assert!(false, "xref placeholder index {body:?} out of range");
486                out.push(XREF_PLACEHOLDER_START);
487                out.push_str(body);
488                out.push(XREF_PLACEHOLDER_END);
489            }
490        }
491    }
492
493    out.push_str(rest);
494    out
495}
496
497/// The deferred cross-references carried by a footnote's text.
498///
499/// A footnote's text is extracted out of the flow of the block during the
500/// macros substitution step, so any cross-reference (`<<id>>`, `xref:id[…]`)
501/// inside it cannot be resolved by the document-level pass that resolves
502/// references in block content. Instead, the footnote captures its
503/// cross-references here — as a placeholder template plus the references in
504/// placeholder order — and they are resolved alongside the block references
505/// (see [`Footnote::resolve_references`]).
506///
507/// [`Footnote::resolve_references`]: crate::document::Footnote::resolve_references
508#[derive(Clone, Eq, PartialEq)]
509pub(crate) struct FootnoteDeferred {
510    /// The footnote text with opaque placeholder tokens marking where each
511    /// cross-reference will be spliced in.
512    template: String,
513
514    /// The cross-references, in placeholder order.
515    xrefs: Vec<XrefSegment>,
516}
517
518impl FootnoteDeferred {
519    /// Constructs a footnote's deferred cross-reference state from the
520    /// placeholder-bearing `template` and its `xrefs` (in placeholder order).
521    pub(crate) fn new(template: String, xrefs: Vec<XrefSegment>) -> Self {
522        Self { template, xrefs }
523    }
524
525    /// Renders the footnote text from the template and the current (resolved or
526    /// unresolved) state of its cross-references.
527    pub(crate) fn render(&self, renderer: &dyn InlineSubstitutionRenderer) -> String {
528        render_template(&self.template, &self.xrefs, renderer)
529    }
530
531    /// Resolves the footnote's cross-references using `resolver`, reporting any
532    /// unresolved target in `warnings`. Rendering the resolved text is left to
533    /// the caller (via [`render`](Self::render)).
534    pub(crate) fn resolve(
535        &mut self,
536        resolver: &dyn ReferenceResolver,
537        warnings: &mut Vec<ReferenceWarning>,
538    ) {
539        for xref in self.xrefs.iter_mut() {
540            xref.resolved = resolver.resolve(&ResolutionContext {
541                target: &xref.target,
542                provided_text: xref.provided_text.as_deref(),
543            });
544
545            if xref.resolved.is_none() {
546                warnings.push(ReferenceWarning {
547                    target: xref.target.clone(),
548                    kind: ReferenceWarningKind::Unresolved,
549                });
550            }
551        }
552    }
553}
554
555impl std::fmt::Debug for FootnoteDeferred {
556    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
557        f.debug_struct("FootnoteDeferred")
558            .field("template", &self.template)
559            .field("xrefs", &self.xrefs)
560            .finish()
561    }
562}
563
564impl<'src> From<Span<'src>> for Content<'src> {
565    fn from(span: Span<'src>) -> Self {
566        Self {
567            original: span,
568            rendered: CowStr::from(span.data()),
569            source_lines: None,
570            deferred: None,
571        }
572    }
573}
574
575impl std::fmt::Debug for Content<'_> {
576    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577        // The deferred cross-reference state is an internal implementation
578        // detail. It is omitted from the debug output unless present, so that
579        // the (very common) cross-reference-free content debugs identically to
580        // a plain `original` + `rendered` pair.
581        let mut s = f.debug_struct("Content");
582        s.field("original", &self.original);
583        s.field("rendered", &self.rendered);
584
585        if let Some(deferred) = self.deferred.as_ref() {
586            s.field("deferred", deferred);
587        }
588
589        s.finish()
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    #![allow(clippy::unwrap_used)]
596
597    mod is_empty {
598        #[test]
599        fn basic_empty_span() {
600            let content = crate::content::Content::from(crate::Span::default());
601            assert!(content.is_empty());
602        }
603
604        #[test]
605        fn basic_non_empty_span() {
606            let content = crate::content::Content::from(crate::Span::new("blah"));
607            assert!(!content.is_empty());
608        }
609    }
610
611    mod strip_footnote_marker_spans {
612        use super::super::{
613            FOOTNOTE_MARKER_END, FOOTNOTE_MARKER_START, strip_footnote_marker_spans,
614        };
615
616        fn marked(marker: &str) -> String {
617            format!("{FOOTNOTE_MARKER_START}{marker}{FOOTNOTE_MARKER_END}")
618        }
619
620        #[test]
621        fn leaves_unmarked_text_unchanged() {
622            assert_eq!(strip_footnote_marker_spans("Plain title"), "Plain title");
623        }
624
625        #[test]
626        fn removes_a_marker_span_and_its_sentinels() {
627            let input = format!("Title{}", marked("[1]"));
628            assert_eq!(strip_footnote_marker_spans(&input), "Title");
629        }
630
631        #[test]
632        fn removes_multiple_spans_keeping_surrounding_text() {
633            let input = format!("a{}b{}c", marked("[1]"), marked("[2]"));
634            assert_eq!(strip_footnote_marker_spans(&input), "abc");
635        }
636
637        #[test]
638        fn a_start_without_an_end_drops_the_remainder() {
639            // Defensive: the substitution always emits balanced sentinels, but a
640            // lone start must not leak the sentinel into the output.
641            let input = format!("Title{FOOTNOTE_MARKER_START}dangling");
642            assert_eq!(strip_footnote_marker_spans(&input), "Title");
643        }
644    }
645
646    mod footnote_deferred {
647        use super::super::{
648            FootnoteDeferred, XREF_PLACEHOLDER_END, XREF_PLACEHOLDER_START, XrefSegment,
649            rehome_xref_placeholders,
650        };
651
652        fn segment(target: &str) -> XrefSegment {
653            XrefSegment {
654                target: target.to_string(),
655                provided_text: None,
656                window: None,
657                roles: vec![],
658                xrefstyle: None,
659                resolved: None,
660            }
661        }
662
663        #[test]
664        fn rehomes_a_placeholder_into_a_local_segment() {
665            let all = vec![segment("a"), segment("b")];
666            // Reference only the second segment; it becomes local index 0.
667            let text = format!("see {XREF_PLACEHOLDER_START}1{XREF_PLACEHOLDER_END} here");
668
669            let (template, local) = rehome_xref_placeholders(&text, &all);
670
671            assert_eq!(local.len(), 1);
672            assert_eq!(local.first().unwrap().target, "b");
673            assert_eq!(
674                template,
675                format!("see {XREF_PLACEHOLDER_START}0{XREF_PLACEHOLDER_END} here")
676            );
677        }
678
679        #[test]
680        fn text_without_placeholders_is_returned_unchanged() {
681            let (template, local) = rehome_xref_placeholders("plain text", &[segment("a")]);
682            assert_eq!(template, "plain text");
683            assert!(local.is_empty());
684        }
685
686        #[test]
687        fn malformed_placeholders_are_passed_through_literally() {
688            // A non-numeric index and an unterminated placeholder are both left
689            // as-is (these cannot arise in practice, but the fallback is exercised).
690            let bad_index = format!("a{XREF_PLACEHOLDER_START}xyz{XREF_PLACEHOLDER_END}b");
691            let (template, local) = rehome_xref_placeholders(&bad_index, &[]);
692            assert_eq!(template, bad_index);
693            assert!(local.is_empty());
694
695            let unterminated = format!("a{XREF_PLACEHOLDER_START}0 no end");
696            let (template, local) = rehome_xref_placeholders(&unterminated, &[]);
697            assert_eq!(template, unterminated);
698            assert!(local.is_empty());
699        }
700
701        #[test]
702        fn out_of_range_placeholder_index_is_passed_through() {
703            // An index with no matching segment in `all` is left literal.
704            let text = format!("x{XREF_PLACEHOLDER_START}9{XREF_PLACEHOLDER_END}y");
705            let (template, local) = rehome_xref_placeholders(&text, &[segment("a")]);
706            assert_eq!(template, text);
707            assert!(local.is_empty());
708        }
709
710        #[test]
711        fn debug_includes_template_and_xrefs() {
712            let deferred = FootnoteDeferred::new("t".to_string(), vec![segment("a")]);
713            let rendered = format!("{deferred:?}");
714            assert!(rendered.contains("FootnoteDeferred"));
715            assert!(rendered.contains("template"));
716        }
717    }
718}