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