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