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, ReferenceWarnings, ResolutionContext,
10        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 destination derived from the target itself, for a target that
112    /// names a document. Computed during substitution, since it depends on the
113    /// path attributes in effect at the reference; it is the fallback when
114    /// [`resolved`](Self::resolved) is `None`.
115    pub(crate) derived: Option<crate::parser::DerivedReference>,
116
117    /// The resolved destination, filled in by resolution; `None` until then.
118    pub(crate) resolved: Option<crate::parser::ResolvedReference>,
119}
120
121/// Sentinel codepoints (Unicode Private Use Area) bracketing a placeholder
122/// index in [`DeferredContent::template`]. These cannot collide with user text
123/// and are inert to the remaining substitution steps.
124const XREF_PLACEHOLDER_START: char = '\u{E000}';
125const XREF_PLACEHOLDER_END: char = '\u{E001}';
126
127/// Sentinel codepoints (Unicode Private Use Area) bracketing a footnote's
128/// rendered inline marker while a section title is being substituted. Like the
129/// cross-reference placeholders above, these cannot collide with user text and
130/// are inert to the remaining substitution steps.
131///
132/// A footnote in a section title is a real, document-order footnote, but its
133/// marker must be kept out of the section's reference text and auto-generated
134/// ID. Marking the marker in a single render (rather than re-rendering the
135/// title with footnotes suppressed) means stateful substitutions — counters,
136/// attribute references that expand into footnotes — run exactly once. See
137/// [`strip_footnote_marker_spans`] and
138/// [`Content::remove_footnote_marker_sentinels`].
139pub(crate) const FOOTNOTE_MARKER_START: char = '\u{E002}';
140pub(crate) const FOOTNOTE_MARKER_END: char = '\u{E003}';
141
142/// Removes each footnote marker span — a [`FOOTNOTE_MARKER_START`] …
143/// [`FOOTNOTE_MARKER_END`] region and everything between, i.e. the sentinels
144/// *and* the marker they bracket — leaving footnote-free text suitable for a
145/// section's reference text and auto-generated ID.
146pub(crate) fn strip_footnote_marker_spans(s: &str) -> String {
147    let mut out = String::with_capacity(s.len());
148    let mut rest = s;
149
150    while let Some(start) = rest.find(FOOTNOTE_MARKER_START) {
151        out.push_str(&rest[..start]);
152        rest = &rest[start + FOOTNOTE_MARKER_START.len_utf8()..];
153
154        // Drop through the matching end sentinel (the marker text). A start
155        // without an end cannot occur — the substitution always emits both — but
156        // if it somehow did, drop the remainder rather than reintroduce the
157        // stray sentinel.
158        rest = match rest.find(FOOTNOTE_MARKER_END) {
159            Some(end) => &rest[end + FOOTNOTE_MARKER_END.len_utf8()..],
160            None => "",
161        };
162    }
163
164    out.push_str(rest);
165    out
166}
167
168/// A fully-owned snapshot of a rendered title, including any deferred
169/// cross-references it carries.
170///
171/// A block title stashed across a section heading (see
172/// `Parser::pending_block_title`) cannot keep its borrowed [`Content`] — the
173/// parser it rides on has no `'src` lifetime — so the title travels in this
174/// owned form and is rebuilt into a [`Content`] (via
175/// [`Content::from_owned_title`]) when the next block claims it. Carrying the
176/// deferred template and cross-references along means an embedded `<<id>>`
177/// still resolves once the catalog is complete.
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub(crate) struct OwnedTitle {
180    /// The rendered title text (the unresolved-fallback rendering when
181    /// cross-references are present).
182    rendered: String,
183
184    /// The placeholder template and cross-references, when the title carries
185    /// any; `None` for the (overwhelmingly common) cross-reference-free title.
186    deferred: Option<(String, Vec<XrefSegment>)>,
187}
188
189impl<'src> Content<'src> {
190    /// Constructs a `Content` from a source `Span` and a potentially-filtered
191    /// view of that source text.
192    pub(crate) fn from_filtered<T: AsRef<str>>(span: Span<'src>, filtered: T) -> Self {
193        Self {
194            original: span,
195            rendered: filtered.as_ref().to_string().into(),
196            source_lines: None,
197            deferred: None,
198        }
199    }
200
201    /// Returns a fully-owned snapshot of this content's rendered text and
202    /// deferred cross-references, for a title that must outlive its source
203    /// borrow (see [`OwnedTitle`]).
204    pub(crate) fn to_owned_title(&self) -> OwnedTitle {
205        OwnedTitle {
206            rendered: self.rendered.as_ref().to_string(),
207            deferred: self
208                .deferred
209                .as_ref()
210                .map(|d| (d.template.clone(), d.xrefs.clone())),
211        }
212    }
213
214    /// Reconstitutes a [`Content`] from an [`OwnedTitle`] snapshot, anchored at
215    /// `span`. The deferred cross-references (when present) are restored, so
216    /// the document-order title pass can still resolve them.
217    pub(crate) fn from_owned_title(span: Span<'src>, title: OwnedTitle) -> Self {
218        Self {
219            original: span,
220            rendered: title.rendered.into(),
221            source_lines: None,
222            deferred: title
223                .deferred
224                .map(|(template, xrefs)| Box::new(DeferredContent { template, xrefs })),
225        }
226    }
227
228    /// Constructs a `Content` from a source `Span` and the per-line filtered
229    /// view of that source, retaining the source `Span` of each surviving line.
230    ///
231    /// `line_spans` must contain one entry per line of `filtered_lines`, in the
232    /// same order; each entry is the source span whose text is that filtered
233    /// line (i.e. after any leading-indent stripping and trailing-whitespace
234    /// trimming the caller applied). The retained spans let the
235    /// attribute-references substitution report an `attribute-missing=warn`
236    /// warning at the precise source offset of the offending reference; see
237    /// [`apply_attributes`](crate::content::substitution_step).
238    pub(crate) fn from_filtered_lines(
239        span: Span<'src>,
240        filtered_lines: &[&str],
241        line_spans: Vec<Span<'src>>,
242    ) -> Self {
243        // One source span is required per filtered line; the default
244        // `debug_assert_eq!` message reports both counts if this is ever broken.
245        debug_assert_eq!(filtered_lines.len(), line_spans.len());
246
247        Self {
248            original: span,
249            rendered: filtered_lines.join("\n").into(),
250            source_lines: Some(line_spans.into_boxed_slice()),
251            deferred: None,
252        }
253    }
254
255    /// Returns the original span from which this [`Content`] was derived.
256    ///
257    /// This is the source text before any substitions have been applied.
258    pub fn original(&self) -> Span<'src> {
259        self.original
260    }
261
262    /// Returns the source `Span` of each line that survived construction
263    /// filtering, in rendered-line order, when they were retained (see
264    /// [`from_filtered_lines`](Self::from_filtered_lines)).
265    ///
266    /// Used only by the attribute-references substitution to locate
267    /// `attribute-missing=warn` warnings precisely.
268    pub(crate) fn source_lines(&self) -> Option<&[Span<'src>]> {
269        self.source_lines.as_deref()
270    }
271
272    /// Returns the final text after all substitutions have been applied.
273    pub fn rendered(&'src self) -> &'src str {
274        self.rendered.as_ref()
275    }
276
277    /// Returns the final rendered text, borrowed for the duration of `&self`
278    /// rather than for `'src`.
279    ///
280    /// [`rendered`](Self::rendered) ties its result to `'src`, which a block's
281    /// `title(&self)` accessor cannot provide. This shorter-lived borrow lets a
282    /// block expose its title `Content`'s rendered text through the `&self`
283    /// accessor.
284    pub(crate) fn rendered_str(&self) -> &str {
285        self.rendered.as_ref()
286    }
287
288    /// Returns an owned copy of the final text after all substitutions have
289    /// been applied.
290    ///
291    /// Unlike [`rendered()`](Self::rendered), this does not tie the returned
292    /// value to the `'src` lifetime, so it can be called on a short-lived
293    /// `Content` built solely to render a fragment (e.g. a block's attribution
294    /// or citation text).
295    pub(crate) fn rendered_owned(&self) -> String {
296        self.rendered.as_ref().to_string()
297    }
298
299    /// Returns `true` if `self` contains no text.
300    pub fn is_empty(&self) -> bool {
301        self.rendered.as_ref().is_empty()
302    }
303
304    /// Removes the [`FOOTNOTE_MARKER_START`]/[`FOOTNOTE_MARKER_END`] sentinels
305    /// bracketing each footnote marker, *keeping* the marker itself, so the
306    /// content renders normally. Called after a section title's reference text
307    /// and ID have been derived (via [`strip_footnote_marker_spans`], which
308    /// needs the sentinels to locate the markers). The sentinels are removed
309    /// from the deferred template too, so a later cross-reference resolution
310    /// rebuild does not reintroduce them.
311    pub(crate) fn remove_footnote_marker_sentinels(&mut self) {
312        if !self.rendered.as_ref().contains(FOOTNOTE_MARKER_START) {
313            return;
314        }
315
316        self.rendered = self
317            .rendered
318            .as_ref()
319            .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "")
320            .into();
321
322        if let Some(deferred) = self.deferred.as_mut() {
323            deferred.template = deferred
324                .template
325                .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "");
326        }
327    }
328
329    /// Returns the deferred cross-reference template and segments, if this
330    /// content carries any.
331    ///
332    /// The template is the placeholder-bearing text captured by
333    /// [`finalize_deferred`](Self::finalize_deferred); the segments are the
334    /// cross-references in placeholder order. Used by the document-order title
335    /// resolution pass, which re-renders a title's cross-references with
336    /// cross-title (including circular) coordination that the per-content
337    /// [`resolve_references`](Self::resolve_references) cannot provide.
338    pub(crate) fn deferred_parts(&self) -> Option<(&str, &[XrefSegment])> {
339        self.deferred
340            .as_ref()
341            .map(|d| (d.template.as_str(), d.xrefs.as_slice()))
342    }
343
344    /// Overwrites the rendered text directly.
345    ///
346    /// Used by the document-order title resolution pass, which computes a
347    /// title's final rendering (coordinating cross-title references) and
348    /// installs it here, in place of the per-content resolution that cannot see
349    /// other titles.
350    pub(crate) fn set_rendered(&mut self, rendered: String) {
351        self.rendered = rendered.into();
352    }
353
354    /// Returns `true` if this content contains one or more cross-references
355    /// that have not yet been resolved to a destination.
356    pub fn has_unresolved_refs(&self) -> bool {
357        self.deferred
358            .as_ref()
359            .is_some_and(|d| d.xrefs.iter().any(|x| x.resolved.is_none()))
360    }
361
362    /// Records the cross-references discovered for this content during the
363    /// macros substitution step. The placeholder tokens for these references
364    /// must already have been written into [`Content::rendered`], in the same
365    /// order as `xrefs`.
366    ///
367    /// This must be called at most once per `Content`: the placeholder indices
368    /// already embedded in [`Content::rendered`] are positions into this single
369    /// `xrefs` vector. The macros substitution runs once per content, so this
370    /// holds in practice; the assertion guards against a future caller breaking
371    /// it.
372    pub(crate) fn set_deferred_xrefs(&mut self, xrefs: Vec<XrefSegment>) {
373        if xrefs.is_empty() {
374            return;
375        }
376
377        debug_assert!(
378            self.deferred.is_none(),
379            "set_deferred_xrefs must be called at most once per Content"
380        );
381
382        self.deferred = Some(Box::new(DeferredContent {
383            template: String::new(),
384            xrefs,
385        }));
386    }
387
388    /// Returns the placeholder token for the cross-reference at `index`.
389    pub(crate) fn xref_placeholder(index: usize) -> String {
390        format!("{XREF_PLACEHOLDER_START}{index}{XREF_PLACEHOLDER_END}")
391    }
392
393    /// Finalizes any deferred cross-references at the end of substitution.
394    ///
395    /// At this point [`Content::rendered`] holds the placeholder-bearing text;
396    /// it is captured as the template and `rendered` is rebuilt as the
397    /// unresolved fallback so it is immediately clean for callers that read it
398    /// before resolution.
399    pub(crate) fn finalize_deferred(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
400        if self.deferred.is_none() {
401            return;
402        }
403
404        let template = self.rendered.as_ref().to_string();
405
406        if let Some(deferred) = self.deferred.as_mut() {
407            deferred.template = template;
408        }
409
410        self.rebuild_rendered(renderer);
411    }
412
413    /// Applies `restore` to the explicit text of every deferred
414    /// cross-reference.
415    ///
416    /// A deferred reference's text is captured out of the main rendered string
417    /// during macro substitution, so passthrough placeholders inside it are not
418    /// reached by the ordinary restore pass. This lets that pass reach them.
419    pub(crate) fn restore_deferred_xref_passthroughs(
420        &mut self,
421        mut restore: impl FnMut(&mut String),
422    ) {
423        if let Some(deferred) = self.deferred.as_mut() {
424            for xref in &mut deferred.xrefs {
425                if let Some(text) = xref.provided_text.as_mut() {
426                    restore(text);
427                }
428            }
429        }
430    }
431
432    /// Resolves any deferred cross-references using `resolver`, then rebuilds
433    /// the rendered text.
434    ///
435    /// This is non-destructive: the placeholder template is retained, so a
436    /// document may be resolved more than once (e.g. for incremental builds or
437    /// multiple output targets).
438    ///
439    /// Any target that the resolver cannot resolve is reported in `warnings`.
440    pub(crate) fn resolve_references(
441        &mut self,
442        resolver: &dyn ReferenceResolver,
443        renderer: &dyn InlineSubstitutionRenderer,
444        warnings: &mut ReferenceWarnings<'src>,
445    ) {
446        let source = self.original;
447
448        if let Some(deferred) = self.deferred.as_mut() {
449            let DeferredContent { template, xrefs } = deferred.as_mut();
450
451            // A `deferred` block always holds at least one xref placeholder, so
452            // its finalized template is never empty. An empty template here
453            // means `finalize_deferred` was skipped (a future-refactor hazard);
454            // the `template.contains` guard below would then silently suppress
455            // every unresolved-ref warning, so catch that invariant break in
456            // debug builds.
457            debug_assert!(!template.is_empty());
458
459            for (index, xref) in xrefs.iter_mut().enumerate() {
460                xref.resolved = resolver.resolve(&ResolutionContext {
461                    target: &xref.target,
462                    provided_text: xref.provided_text.as_deref(),
463                    derived: xref.derived.as_ref(),
464                });
465
466                // A reference whose placeholder is no longer in the template was
467                // re-homed into a footnote (see `rehome_xref_placeholders`); the
468                // footnote resolves and reports it, so it is not reported here.
469                // A target that names a document is never reported: it
470                // carries its own destination, so there was nothing here to
471                // resolve.
472                if xref.resolved.is_none()
473                    && xref.derived.is_none()
474                    && template.contains(&Content::xref_placeholder(index))
475                {
476                    warnings.unresolved(&xref.target, source);
477                }
478            }
479        }
480
481        self.rebuild_rendered(renderer);
482    }
483
484    /// Rebuilds [`Content::rendered`] from the deferred template and the
485    /// current (resolved or unresolved) state of its cross-references.
486    fn rebuild_rendered(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
487        let Some(deferred) = self.deferred.as_ref() else {
488            return;
489        };
490
491        self.rendered = render_template(&deferred.template, &deferred.xrefs, renderer).into();
492    }
493}
494
495/// Re-homes the cross-reference placeholders found in `text` into a
496/// self-contained (template, xrefs) pair.
497///
498/// When the cross-reference substitution runs before footnotes, a footnote's
499/// text may carry placeholder tokens whose [`XrefSegment`]s live in the
500/// enclosing block's cross-reference list (`all`). Because a footnote's text is
501/// extracted out of the block, it needs its own copy of just those segments,
502/// renumbered so its template is independent. This scans `text` for placeholder
503/// tokens, clones the referenced segments into a fresh vector (in first-seen
504/// order), and rewrites the tokens to the new local indices.
505///
506/// Text with no placeholders returns unchanged alongside an empty vector.
507pub(crate) fn rehome_xref_placeholders(
508    text: &str,
509    all: &[XrefSegment],
510) -> (String, Vec<XrefSegment>) {
511    let mut local: Vec<XrefSegment> = vec![];
512
513    if !text.contains(XREF_PLACEHOLDER_START) {
514        return (text.to_string(), local);
515    }
516
517    let mut out = String::with_capacity(text.len());
518    let mut rest = text;
519
520    while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
521        out.push_str(&rest[..start]);
522        let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
523
524        let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
525            out.push(XREF_PLACEHOLDER_START);
526            rest = after;
527            continue;
528        };
529
530        let body = &after[..end];
531        rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
532
533        match body.parse::<usize>().ok().and_then(|index| all.get(index)) {
534            Some(segment) => {
535                let local_index = local.len();
536                local.push(segment.clone());
537                out.push_str(&Content::xref_placeholder(local_index));
538            }
539
540            None => {
541                out.push(XREF_PLACEHOLDER_START);
542                out.push_str(body);
543                out.push(XREF_PLACEHOLDER_END);
544            }
545        }
546    }
547
548    out.push_str(rest);
549    (out, local)
550}
551
552/// Splices resolved (or fallback) cross-reference renderings into a placeholder
553/// template, producing the final rendered text.
554///
555/// This is the seam used by the document-order title resolution pass: it hands
556/// in a title's captured template together with a set of [`XrefSegment`]s whose
557/// [`resolved`](XrefSegment::resolved) fields it has filled in with cross-title
558/// (including circular) coordination, and receives the final rendered title.
559pub(crate) fn render_xref_template(
560    template: &str,
561    xrefs: &[XrefSegment],
562    renderer: &dyn InlineSubstitutionRenderer,
563) -> String {
564    render_template(template, xrefs, renderer)
565}
566
567/// Splices resolved (or fallback) cross-reference renderings into a placeholder
568/// template, producing the final rendered text.
569fn render_template(
570    template: &str,
571    xrefs: &[XrefSegment],
572    renderer: &dyn InlineSubstitutionRenderer,
573) -> String {
574    let mut out = String::with_capacity(template.len());
575    let mut rest = template;
576
577    while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
578        out.push_str(&rest[..start]);
579        let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
580
581        let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
582            // Malformed placeholder; emit the sentinel literally and continue.
583            out.push(XREF_PLACEHOLDER_START);
584            rest = after;
585            continue;
586        };
587
588        let body = &after[..end];
589        rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
590
591        match body
592            .parse::<usize>()
593            .ok()
594            .and_then(|index| xrefs.get(index))
595        {
596            Some(xref) => {
597                renderer.render_xref(
598                    &XrefRenderParams {
599                        target: &xref.target,
600                        provided_text: xref.provided_text.as_deref(),
601                        window: xref.window.as_deref(),
602                        roles: &xref.roles,
603                        xrefstyle: xref.xrefstyle,
604                        derived: xref.derived.as_ref(),
605                        resolved: xref.resolved.as_ref(),
606                    },
607                    &mut out,
608                );
609            }
610
611            None => {
612                // Unreachable while `template` and `xrefs` come from the same
613                // `Content` (indices are assigned sequentially). If that
614                // invariant is ever broken, emit the raw placeholder rather than
615                // silently dropping the span, so the breakage is visible.
616                debug_assert!(false, "xref placeholder index {body:?} out of range");
617                out.push(XREF_PLACEHOLDER_START);
618                out.push_str(body);
619                out.push(XREF_PLACEHOLDER_END);
620            }
621        }
622    }
623
624    out.push_str(rest);
625    out
626}
627
628/// The deferred cross-references carried by a footnote's text.
629///
630/// A footnote's text is extracted out of the flow of the block during the
631/// macros substitution step, so any cross-reference (`<<id>>`, `xref:id[…]`)
632/// inside it cannot be resolved by the document-level pass that resolves
633/// references in block content. Instead, the footnote captures its
634/// cross-references here — as a placeholder template plus the references in
635/// placeholder order — and they are resolved alongside the block references
636/// (see [`Footnote::resolve_references`]).
637///
638/// [`Footnote::resolve_references`]: crate::document::Footnote::resolve_references
639#[derive(Clone, Eq, PartialEq)]
640pub(crate) struct FootnoteDeferred {
641    /// The footnote text with opaque placeholder tokens marking where each
642    /// cross-reference will be spliced in.
643    template: String,
644
645    /// The cross-references, in placeholder order.
646    xrefs: Vec<XrefSegment>,
647}
648
649impl FootnoteDeferred {
650    /// Constructs a footnote's deferred cross-reference state from the
651    /// placeholder-bearing `template` and its `xrefs` (in placeholder order).
652    pub(crate) fn new(template: String, xrefs: Vec<XrefSegment>) -> Self {
653        Self { template, xrefs }
654    }
655
656    /// Renders the footnote text from the template and the current (resolved or
657    /// unresolved) state of its cross-references.
658    pub(crate) fn render(&self, renderer: &dyn InlineSubstitutionRenderer) -> String {
659        render_template(&self.template, &self.xrefs, renderer)
660    }
661
662    /// Resolves the footnote's cross-references using `resolver`, reporting any
663    /// unresolved target in `warnings` against `source`. Rendering the resolved
664    /// text is left to the caller (via [`render`](Self::render)).
665    pub(crate) fn resolve<'src>(
666        &mut self,
667        resolver: &dyn ReferenceResolver,
668        warnings: &mut ReferenceWarnings<'src>,
669        source: Span<'src>,
670    ) {
671        for xref in self.xrefs.iter_mut() {
672            xref.resolved = resolver.resolve(&ResolutionContext {
673                target: &xref.target,
674                provided_text: xref.provided_text.as_deref(),
675                derived: xref.derived.as_ref(),
676            });
677
678            if xref.resolved.is_none() && xref.derived.is_none() {
679                warnings.unresolved(&xref.target, source);
680            }
681        }
682    }
683}
684
685impl std::fmt::Debug for FootnoteDeferred {
686    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
687        f.debug_struct("FootnoteDeferred")
688            .field("template", &self.template)
689            .field("xrefs", &self.xrefs)
690            .finish()
691    }
692}
693
694impl<'src> From<Span<'src>> for Content<'src> {
695    fn from(span: Span<'src>) -> Self {
696        Self {
697            original: span,
698            rendered: CowStr::from(span.data()),
699            source_lines: None,
700            deferred: None,
701        }
702    }
703}
704
705impl std::fmt::Debug for Content<'_> {
706    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
707        // The deferred cross-reference state is an internal implementation
708        // detail. It is omitted from the debug output unless present, so that
709        // the (very common) cross-reference-free content debugs identically to
710        // a plain `original` + `rendered` pair.
711        let mut s = f.debug_struct("Content");
712        s.field("original", &self.original);
713        s.field("rendered", &self.rendered);
714
715        if let Some(deferred) = self.deferred.as_ref() {
716            s.field("deferred", deferred);
717        }
718
719        s.finish()
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    #![allow(clippy::unwrap_used)]
726
727    mod is_empty {
728        #[test]
729        fn basic_empty_span() {
730            let content = crate::content::Content::from(crate::Span::default());
731            assert!(content.is_empty());
732        }
733
734        #[test]
735        fn basic_non_empty_span() {
736            let content = crate::content::Content::from(crate::Span::new("blah"));
737            assert!(!content.is_empty());
738        }
739    }
740
741    mod strip_footnote_marker_spans {
742        use super::super::{
743            FOOTNOTE_MARKER_END, FOOTNOTE_MARKER_START, strip_footnote_marker_spans,
744        };
745
746        fn marked(marker: &str) -> String {
747            format!("{FOOTNOTE_MARKER_START}{marker}{FOOTNOTE_MARKER_END}")
748        }
749
750        #[test]
751        fn leaves_unmarked_text_unchanged() {
752            assert_eq!(strip_footnote_marker_spans("Plain title"), "Plain title");
753        }
754
755        #[test]
756        fn removes_a_marker_span_and_its_sentinels() {
757            let input = format!("Title{}", marked("[1]"));
758            assert_eq!(strip_footnote_marker_spans(&input), "Title");
759        }
760
761        #[test]
762        fn removes_multiple_spans_keeping_surrounding_text() {
763            let input = format!("a{}b{}c", marked("[1]"), marked("[2]"));
764            assert_eq!(strip_footnote_marker_spans(&input), "abc");
765        }
766
767        #[test]
768        fn a_start_without_an_end_drops_the_remainder() {
769            // Defensive: the substitution always emits balanced sentinels, but a
770            // lone start must not leak the sentinel into the output.
771            let input = format!("Title{FOOTNOTE_MARKER_START}dangling");
772            assert_eq!(strip_footnote_marker_spans(&input), "Title");
773        }
774    }
775
776    mod footnote_deferred {
777        use super::super::{
778            FootnoteDeferred, XREF_PLACEHOLDER_END, XREF_PLACEHOLDER_START, XrefSegment,
779            rehome_xref_placeholders,
780        };
781
782        fn segment(target: &str) -> XrefSegment {
783            XrefSegment {
784                target: target.to_string(),
785                provided_text: None,
786                window: None,
787                roles: vec![],
788                xrefstyle: None,
789                derived: None,
790                resolved: None,
791            }
792        }
793
794        #[test]
795        fn rehomes_a_placeholder_into_a_local_segment() {
796            let all = vec![segment("a"), segment("b")];
797            // Reference only the second segment; it becomes local index 0.
798            let text = format!("see {XREF_PLACEHOLDER_START}1{XREF_PLACEHOLDER_END} here");
799
800            let (template, local) = rehome_xref_placeholders(&text, &all);
801
802            assert_eq!(local.len(), 1);
803            assert_eq!(local.first().unwrap().target, "b");
804            assert_eq!(
805                template,
806                format!("see {XREF_PLACEHOLDER_START}0{XREF_PLACEHOLDER_END} here")
807            );
808        }
809
810        #[test]
811        fn text_without_placeholders_is_returned_unchanged() {
812            let (template, local) = rehome_xref_placeholders("plain text", &[segment("a")]);
813            assert_eq!(template, "plain text");
814            assert!(local.is_empty());
815        }
816
817        #[test]
818        fn malformed_placeholders_are_passed_through_literally() {
819            // A non-numeric index and an unterminated placeholder are both left
820            // as-is (these cannot arise in practice, but the fallback is exercised).
821            let bad_index = format!("a{XREF_PLACEHOLDER_START}xyz{XREF_PLACEHOLDER_END}b");
822            let (template, local) = rehome_xref_placeholders(&bad_index, &[]);
823            assert_eq!(template, bad_index);
824            assert!(local.is_empty());
825
826            let unterminated = format!("a{XREF_PLACEHOLDER_START}0 no end");
827            let (template, local) = rehome_xref_placeholders(&unterminated, &[]);
828            assert_eq!(template, unterminated);
829            assert!(local.is_empty());
830        }
831
832        #[test]
833        fn out_of_range_placeholder_index_is_passed_through() {
834            // An index with no matching segment in `all` is left literal.
835            let text = format!("x{XREF_PLACEHOLDER_START}9{XREF_PLACEHOLDER_END}y");
836            let (template, local) = rehome_xref_placeholders(&text, &[segment("a")]);
837            assert_eq!(template, text);
838            assert!(local.is_empty());
839        }
840
841        #[test]
842        fn debug_includes_template_and_xrefs() {
843            let deferred = FootnoteDeferred::new("t".to_string(), vec![segment("a")]);
844            let rendered = format!("{deferred:?}");
845            assert!(rendered.contains("FootnoteDeferred"));
846            assert!(rendered.contains("template"));
847        }
848    }
849}