Skip to main content

asciidoc_parser/content/
content.rs

1//! Describes the content of a non-compound block after any relevant
2//! [substitutions] have been performed.
3//!
4//! [substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/
5
6use crate::{
7    Span,
8    parser::{
9        InlineSubstitutionRenderer, ReferenceResolver, ReferenceWarning, ReferenceWarningKind,
10        ResolutionContext, XrefRenderParams,
11    },
12    strings::CowStr,
13};
14
15/// Describes the annotated content of a block after any relevant
16/// [substitutions] have been performed.
17///
18/// This is typically used to represent the main body of block types that don't
19/// contain other blocks, such as [`SimpleBlock`] or [`RawDelimitedBlock`].
20///
21/// # Deferred cross-references
22///
23/// Cross-references (`<<id>>`, `xref:id[…]`) cannot be resolved while a block
24/// is being parsed, because their target may be defined later in the document
25/// (or, for multi-document workflows, in another document entirely). The
26/// macros substitution therefore records each cross-reference in a deferred
27/// form and leaves an opaque placeholder in the rendered text. The
28/// references are resolved in a later pass — see
29/// [`Document::resolve_references`] — at which point [`rendered()`] reflects
30/// the resolved links. Until then, [`rendered()`] shows an unresolved fallback,
31/// so it always returns clean text.
32///
33/// [substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/
34/// [`SimpleBlock`]: crate::blocks::SimpleBlock
35/// [`RawDelimitedBlock`]: crate::blocks::RawDelimitedBlock
36/// [`Document::resolve_references`]: crate::Document::resolve_references
37/// [`rendered()`]: Self::rendered
38#[derive(Clone, Eq, PartialEq)]
39pub struct Content<'src> {
40    /// The original [`Span`] from which this content was derived.
41    original: Span<'src>,
42
43    /// The possibly-modified text after substititions have been performed.
44    ///
45    /// This is always clean, user-facing text: when cross-references are still
46    /// unresolved it holds the unresolved fallback rendering, and after
47    /// resolution it holds the resolved rendering.
48    pub(crate) rendered: CowStr<'src>,
49
50    /// Deferred cross-references discovered during substitution, awaiting
51    /// resolution against a (possibly cross-document) catalog.
52    ///
53    /// `None` for the overwhelming majority of content, which contains no
54    /// cross-references.
55    deferred: Option<Box<DeferredContent>>,
56}
57
58/// The deferred (cross-reference-bearing) portion of a [`Content`].
59#[derive(Clone, Debug, Eq, PartialEq)]
60struct DeferredContent {
61    /// The locally-substituted text with opaque placeholder tokens marking
62    /// where each cross-reference will be spliced in. This is the source of
63    /// truth from which [`Content::rendered`] is (re)built, so resolution is
64    /// non-destructive and may be repeated.
65    template: String,
66
67    /// The cross-references, in placeholder order.
68    xrefs: Vec<XrefSegment>,
69}
70
71/// A single deferred cross-reference.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub(crate) struct XrefSegment {
74    /// The raw, uninterpreted target as written in the source.
75    pub(crate) target: String,
76
77    /// Explicit link text supplied in the cross-reference, if any.
78    pub(crate) provided_text: Option<String>,
79
80    /// Target window selection, from a `window` attribute on the `xref:` macro
81    /// (e.g. `_blank`). `None` for the shorthand form, which has no attribute
82    /// list.
83    pub(crate) window: Option<String>,
84
85    /// Roles supplied via a `role` attribute on the `xref:` macro, if any.
86    pub(crate) roles: Vec<String>,
87
88    /// The resolved destination, filled in by resolution; `None` until then.
89    pub(crate) resolved: Option<crate::parser::ResolvedReference>,
90}
91
92/// Sentinel codepoints (Unicode Private Use Area) bracketing a placeholder
93/// index in [`DeferredContent::template`]. These cannot collide with user text
94/// and are inert to the remaining substitution steps.
95const XREF_PLACEHOLDER_START: char = '\u{E000}';
96const XREF_PLACEHOLDER_END: char = '\u{E001}';
97
98impl<'src> Content<'src> {
99    /// Constructs a `Content` from a source `Span` and a potentially-filtered
100    /// view of that source text.
101    pub(crate) fn from_filtered<T: AsRef<str>>(span: Span<'src>, filtered: T) -> Self {
102        Self {
103            original: span,
104            rendered: filtered.as_ref().to_string().into(),
105            deferred: None,
106        }
107    }
108
109    /// Returns the original span from which this [`Content`] was derived.
110    ///
111    /// This is the source text before any substitions have been applied.
112    pub fn original(&self) -> Span<'src> {
113        self.original
114    }
115
116    /// Returns the final text after all substitutions have been applied.
117    pub fn rendered(&'src self) -> &'src str {
118        self.rendered.as_ref()
119    }
120
121    /// Returns an owned copy of the final text after all substitutions have
122    /// been applied.
123    ///
124    /// Unlike [`rendered()`](Self::rendered), this does not tie the returned
125    /// value to the `'src` lifetime, so it can be called on a short-lived
126    /// `Content` built solely to render a fragment (e.g. a block's attribution
127    /// or citation text).
128    pub(crate) fn rendered_owned(&self) -> String {
129        self.rendered.as_ref().to_string()
130    }
131
132    /// Returns `true` if `self` contains no text.
133    pub fn is_empty(&self) -> bool {
134        self.rendered.as_ref().is_empty()
135    }
136
137    /// Returns `true` if this content contains one or more cross-references
138    /// that have not yet been resolved to a destination.
139    pub fn has_unresolved_refs(&self) -> bool {
140        self.deferred
141            .as_ref()
142            .is_some_and(|d| d.xrefs.iter().any(|x| x.resolved.is_none()))
143    }
144
145    /// Records the cross-references discovered for this content during the
146    /// macros substitution step. The placeholder tokens for these references
147    /// must already have been written into [`Content::rendered`], in the same
148    /// order as `xrefs`.
149    ///
150    /// This must be called at most once per `Content`: the placeholder indices
151    /// already embedded in [`Content::rendered`] are positions into this single
152    /// `xrefs` vector. The macros substitution runs once per content, so this
153    /// holds in practice; the assertion guards against a future caller breaking
154    /// it.
155    pub(crate) fn set_deferred_xrefs(&mut self, xrefs: Vec<XrefSegment>) {
156        if xrefs.is_empty() {
157            return;
158        }
159
160        debug_assert!(
161            self.deferred.is_none(),
162            "set_deferred_xrefs must be called at most once per Content"
163        );
164
165        self.deferred = Some(Box::new(DeferredContent {
166            template: String::new(),
167            xrefs,
168        }));
169    }
170
171    /// Returns the placeholder token for the cross-reference at `index`.
172    pub(crate) fn xref_placeholder(index: usize) -> String {
173        format!("{XREF_PLACEHOLDER_START}{index}{XREF_PLACEHOLDER_END}")
174    }
175
176    /// Finalizes any deferred cross-references at the end of substitution.
177    ///
178    /// At this point [`Content::rendered`] holds the placeholder-bearing text;
179    /// it is captured as the template and `rendered` is rebuilt as the
180    /// unresolved fallback so it is immediately clean for callers that read it
181    /// before resolution.
182    pub(crate) fn finalize_deferred(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
183        if self.deferred.is_none() {
184            return;
185        }
186
187        let template = self.rendered.as_ref().to_string();
188
189        if let Some(deferred) = self.deferred.as_mut() {
190            deferred.template = template;
191        }
192
193        self.rebuild_rendered(renderer);
194    }
195
196    /// Resolves any deferred cross-references using `resolver`, then rebuilds
197    /// the rendered text.
198    ///
199    /// This is non-destructive: the placeholder template is retained, so a
200    /// document may be resolved more than once (e.g. for incremental builds or
201    /// multiple output targets).
202    ///
203    /// Any target that the resolver cannot resolve is reported in `warnings`.
204    pub(crate) fn resolve_references(
205        &mut self,
206        resolver: &dyn ReferenceResolver,
207        renderer: &dyn InlineSubstitutionRenderer,
208        warnings: &mut Vec<ReferenceWarning>,
209    ) {
210        if let Some(deferred) = self.deferred.as_mut() {
211            let DeferredContent { template, xrefs } = deferred.as_mut();
212
213            // A `deferred` block always holds at least one xref placeholder, so
214            // its finalized template is never empty. An empty template here
215            // means `finalize_deferred` was skipped (a future-refactor hazard);
216            // the `template.contains` guard below would then silently suppress
217            // every unresolved-ref warning, so catch that invariant break in
218            // debug builds.
219            debug_assert!(!template.is_empty());
220
221            for (index, xref) in xrefs.iter_mut().enumerate() {
222                xref.resolved = resolver.resolve(&ResolutionContext {
223                    target: &xref.target,
224                    provided_text: xref.provided_text.as_deref(),
225                });
226
227                // A reference whose placeholder is no longer in the template was
228                // re-homed into a footnote (see `rehome_xref_placeholders`); the
229                // footnote resolves and reports it, so it is not reported here.
230                if xref.resolved.is_none() && template.contains(&Content::xref_placeholder(index)) {
231                    warnings.push(ReferenceWarning {
232                        target: xref.target.clone(),
233                        kind: ReferenceWarningKind::Unresolved,
234                    });
235                }
236            }
237        }
238
239        self.rebuild_rendered(renderer);
240    }
241
242    /// Rebuilds [`Content::rendered`] from the deferred template and the
243    /// current (resolved or unresolved) state of its cross-references.
244    fn rebuild_rendered(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
245        let Some(deferred) = self.deferred.as_ref() else {
246            return;
247        };
248
249        self.rendered = render_template(&deferred.template, &deferred.xrefs, renderer).into();
250    }
251}
252
253/// Re-homes the cross-reference placeholders found in `text` into a
254/// self-contained (template, xrefs) pair.
255///
256/// When the cross-reference substitution runs before footnotes, a footnote's
257/// text may carry placeholder tokens whose [`XrefSegment`]s live in the
258/// enclosing block's cross-reference list (`all`). Because a footnote's text is
259/// extracted out of the block, it needs its own copy of just those segments,
260/// renumbered so its template is independent. This scans `text` for placeholder
261/// tokens, clones the referenced segments into a fresh vector (in first-seen
262/// order), and rewrites the tokens to the new local indices.
263///
264/// Text with no placeholders returns unchanged alongside an empty vector.
265pub(crate) fn rehome_xref_placeholders(
266    text: &str,
267    all: &[XrefSegment],
268) -> (String, Vec<XrefSegment>) {
269    let mut local: Vec<XrefSegment> = vec![];
270
271    if !text.contains(XREF_PLACEHOLDER_START) {
272        return (text.to_string(), local);
273    }
274
275    let mut out = String::with_capacity(text.len());
276    let mut rest = text;
277
278    while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
279        out.push_str(&rest[..start]);
280        let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
281
282        let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
283            out.push(XREF_PLACEHOLDER_START);
284            rest = after;
285            continue;
286        };
287
288        let body = &after[..end];
289        rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
290
291        match body.parse::<usize>().ok().and_then(|index| all.get(index)) {
292            Some(segment) => {
293                let local_index = local.len();
294                local.push(segment.clone());
295                out.push_str(&Content::xref_placeholder(local_index));
296            }
297
298            None => {
299                out.push(XREF_PLACEHOLDER_START);
300                out.push_str(body);
301                out.push(XREF_PLACEHOLDER_END);
302            }
303        }
304    }
305
306    out.push_str(rest);
307    (out, local)
308}
309
310/// Splices resolved (or fallback) cross-reference renderings into a placeholder
311/// template, producing the final rendered text.
312fn render_template(
313    template: &str,
314    xrefs: &[XrefSegment],
315    renderer: &dyn InlineSubstitutionRenderer,
316) -> String {
317    let mut out = String::with_capacity(template.len());
318    let mut rest = template;
319
320    while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
321        out.push_str(&rest[..start]);
322        let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
323
324        let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
325            // Malformed placeholder; emit the sentinel literally and continue.
326            out.push(XREF_PLACEHOLDER_START);
327            rest = after;
328            continue;
329        };
330
331        let body = &after[..end];
332        rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
333
334        match body
335            .parse::<usize>()
336            .ok()
337            .and_then(|index| xrefs.get(index))
338        {
339            Some(xref) => {
340                renderer.render_xref(
341                    &XrefRenderParams {
342                        target: &xref.target,
343                        provided_text: xref.provided_text.as_deref(),
344                        window: xref.window.as_deref(),
345                        roles: &xref.roles,
346                        resolved: xref.resolved.as_ref(),
347                    },
348                    &mut out,
349                );
350            }
351
352            None => {
353                // Unreachable while `template` and `xrefs` come from the same
354                // `Content` (indices are assigned sequentially). If that
355                // invariant is ever broken, emit the raw placeholder rather than
356                // silently dropping the span, so the breakage is visible.
357                debug_assert!(false, "xref placeholder index {body:?} out of range");
358                out.push(XREF_PLACEHOLDER_START);
359                out.push_str(body);
360                out.push(XREF_PLACEHOLDER_END);
361            }
362        }
363    }
364
365    out.push_str(rest);
366    out
367}
368
369/// The deferred cross-references carried by a footnote's text.
370///
371/// A footnote's text is extracted out of the flow of the block during the
372/// macros substitution step, so any cross-reference (`<<id>>`, `xref:id[…]`)
373/// inside it cannot be resolved by the document-level pass that resolves
374/// references in block content. Instead, the footnote captures its
375/// cross-references here — as a placeholder template plus the references in
376/// placeholder order — and they are resolved alongside the block references
377/// (see [`Footnote::resolve_references`]).
378///
379/// [`Footnote::resolve_references`]: crate::document::Footnote::resolve_references
380#[derive(Clone, Eq, PartialEq)]
381pub(crate) struct FootnoteDeferred {
382    /// The footnote text with opaque placeholder tokens marking where each
383    /// cross-reference will be spliced in.
384    template: String,
385
386    /// The cross-references, in placeholder order.
387    xrefs: Vec<XrefSegment>,
388}
389
390impl FootnoteDeferred {
391    /// Constructs a footnote's deferred cross-reference state from the
392    /// placeholder-bearing `template` and its `xrefs` (in placeholder order).
393    pub(crate) fn new(template: String, xrefs: Vec<XrefSegment>) -> Self {
394        Self { template, xrefs }
395    }
396
397    /// Renders the footnote text from the template and the current (resolved or
398    /// unresolved) state of its cross-references.
399    pub(crate) fn render(&self, renderer: &dyn InlineSubstitutionRenderer) -> String {
400        render_template(&self.template, &self.xrefs, renderer)
401    }
402
403    /// Resolves the footnote's cross-references using `resolver`, reporting any
404    /// unresolved target in `warnings`. Rendering the resolved text is left to
405    /// the caller (via [`render`](Self::render)).
406    pub(crate) fn resolve(
407        &mut self,
408        resolver: &dyn ReferenceResolver,
409        warnings: &mut Vec<ReferenceWarning>,
410    ) {
411        for xref in self.xrefs.iter_mut() {
412            xref.resolved = resolver.resolve(&ResolutionContext {
413                target: &xref.target,
414                provided_text: xref.provided_text.as_deref(),
415            });
416
417            if xref.resolved.is_none() {
418                warnings.push(ReferenceWarning {
419                    target: xref.target.clone(),
420                    kind: ReferenceWarningKind::Unresolved,
421                });
422            }
423        }
424    }
425}
426
427impl std::fmt::Debug for FootnoteDeferred {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        f.debug_struct("FootnoteDeferred")
430            .field("template", &self.template)
431            .field("xrefs", &self.xrefs)
432            .finish()
433    }
434}
435
436impl<'src> From<Span<'src>> for Content<'src> {
437    fn from(span: Span<'src>) -> Self {
438        Self {
439            original: span,
440            rendered: CowStr::from(span.data()),
441            deferred: None,
442        }
443    }
444}
445
446impl std::fmt::Debug for Content<'_> {
447    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        // The deferred cross-reference state is an internal implementation
449        // detail. It is omitted from the debug output unless present, so that
450        // the (very common) cross-reference-free content debugs identically to
451        // a plain `original` + `rendered` pair.
452        let mut s = f.debug_struct("Content");
453        s.field("original", &self.original);
454        s.field("rendered", &self.rendered);
455
456        if let Some(deferred) = self.deferred.as_ref() {
457            s.field("deferred", deferred);
458        }
459
460        s.finish()
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    #![allow(clippy::unwrap_used)]
467
468    mod is_empty {
469        #[test]
470        fn basic_empty_span() {
471            let content = crate::content::Content::from(crate::Span::default());
472            assert!(content.is_empty());
473        }
474
475        #[test]
476        fn basic_non_empty_span() {
477            let content = crate::content::Content::from(crate::Span::new("blah"));
478            assert!(!content.is_empty());
479        }
480    }
481
482    mod footnote_deferred {
483        use super::super::{
484            FootnoteDeferred, XREF_PLACEHOLDER_END, XREF_PLACEHOLDER_START, XrefSegment,
485            rehome_xref_placeholders,
486        };
487
488        fn segment(target: &str) -> XrefSegment {
489            XrefSegment {
490                target: target.to_string(),
491                provided_text: None,
492                window: None,
493                roles: vec![],
494                resolved: None,
495            }
496        }
497
498        #[test]
499        fn rehomes_a_placeholder_into_a_local_segment() {
500            let all = vec![segment("a"), segment("b")];
501            // Reference only the second segment; it becomes local index 0.
502            let text = format!("see {XREF_PLACEHOLDER_START}1{XREF_PLACEHOLDER_END} here");
503
504            let (template, local) = rehome_xref_placeholders(&text, &all);
505
506            assert_eq!(local.len(), 1);
507            assert_eq!(local.first().unwrap().target, "b");
508            assert_eq!(
509                template,
510                format!("see {XREF_PLACEHOLDER_START}0{XREF_PLACEHOLDER_END} here")
511            );
512        }
513
514        #[test]
515        fn text_without_placeholders_is_returned_unchanged() {
516            let (template, local) = rehome_xref_placeholders("plain text", &[segment("a")]);
517            assert_eq!(template, "plain text");
518            assert!(local.is_empty());
519        }
520
521        #[test]
522        fn malformed_placeholders_are_passed_through_literally() {
523            // A non-numeric index and an unterminated placeholder are both left
524            // as-is (these cannot arise in practice, but the fallback is exercised).
525            let bad_index = format!("a{XREF_PLACEHOLDER_START}xyz{XREF_PLACEHOLDER_END}b");
526            let (template, local) = rehome_xref_placeholders(&bad_index, &[]);
527            assert_eq!(template, bad_index);
528            assert!(local.is_empty());
529
530            let unterminated = format!("a{XREF_PLACEHOLDER_START}0 no end");
531            let (template, local) = rehome_xref_placeholders(&unterminated, &[]);
532            assert_eq!(template, unterminated);
533            assert!(local.is_empty());
534        }
535
536        #[test]
537        fn out_of_range_placeholder_index_is_passed_through() {
538            // An index with no matching segment in `all` is left literal.
539            let text = format!("x{XREF_PLACEHOLDER_START}9{XREF_PLACEHOLDER_END}y");
540            let (template, local) = rehome_xref_placeholders(&text, &[segment("a")]);
541            assert_eq!(template, text);
542            assert!(local.is_empty());
543        }
544
545        #[test]
546        fn debug_includes_template_and_xrefs() {
547            let deferred = FootnoteDeferred::new("t".to_string(), vec![segment("a")]);
548            let rendered = format!("{deferred:?}");
549            assert!(rendered.contains("FootnoteDeferred"));
550            assert!(rendered.contains("template"));
551        }
552    }
553}