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    /// The resolved destination, filled in by resolution; `None` until then.
81    pub(crate) resolved: Option<crate::parser::ResolvedReference>,
82}
83
84/// Sentinel codepoints (Unicode Private Use Area) bracketing a placeholder
85/// index in [`DeferredContent::template`]. These cannot collide with user text
86/// and are inert to the remaining substitution steps.
87const XREF_PLACEHOLDER_START: char = '\u{E000}';
88const XREF_PLACEHOLDER_END: char = '\u{E001}';
89
90impl<'src> Content<'src> {
91    /// Constructs a `Content` from a source `Span` and a potentially-filtered
92    /// view of that source text.
93    pub(crate) fn from_filtered<T: AsRef<str>>(span: Span<'src>, filtered: T) -> Self {
94        Self {
95            original: span,
96            rendered: filtered.as_ref().to_string().into(),
97            deferred: None,
98        }
99    }
100
101    /// Returns the original span from which this [`Content`] was derived.
102    ///
103    /// This is the source text before any substitions have been applied.
104    pub fn original(&self) -> Span<'src> {
105        self.original
106    }
107
108    /// Returns the final text after all substitutions have been applied.
109    pub fn rendered(&'src self) -> &'src str {
110        self.rendered.as_ref()
111    }
112
113    /// Returns an owned copy of the final text after all substitutions have
114    /// been applied.
115    ///
116    /// Unlike [`rendered()`](Self::rendered), this does not tie the returned
117    /// value to the `'src` lifetime, so it can be called on a short-lived
118    /// `Content` built solely to render a fragment (e.g. a block's attribution
119    /// or citation text).
120    pub(crate) fn rendered_owned(&self) -> String {
121        self.rendered.as_ref().to_string()
122    }
123
124    /// Returns `true` if `self` contains no text.
125    pub fn is_empty(&self) -> bool {
126        self.rendered.as_ref().is_empty()
127    }
128
129    /// Returns `true` if this content contains one or more cross-references
130    /// that have not yet been resolved to a destination.
131    pub fn has_unresolved_refs(&self) -> bool {
132        self.deferred
133            .as_ref()
134            .is_some_and(|d| d.xrefs.iter().any(|x| x.resolved.is_none()))
135    }
136
137    /// Records the cross-references discovered for this content during the
138    /// macros substitution step. The placeholder tokens for these references
139    /// must already have been written into [`Content::rendered`], in the same
140    /// order as `xrefs`.
141    ///
142    /// This must be called at most once per `Content`: the placeholder indices
143    /// already embedded in [`Content::rendered`] are positions into this single
144    /// `xrefs` vector. The macros substitution runs once per content, so this
145    /// holds in practice; the assertion guards against a future caller breaking
146    /// it.
147    pub(crate) fn set_deferred_xrefs(&mut self, xrefs: Vec<XrefSegment>) {
148        if xrefs.is_empty() {
149            return;
150        }
151
152        debug_assert!(
153            self.deferred.is_none(),
154            "set_deferred_xrefs must be called at most once per Content"
155        );
156
157        self.deferred = Some(Box::new(DeferredContent {
158            template: String::new(),
159            xrefs,
160        }));
161    }
162
163    /// Returns the placeholder token for the cross-reference at `index`.
164    pub(crate) fn xref_placeholder(index: usize) -> String {
165        format!("{XREF_PLACEHOLDER_START}{index}{XREF_PLACEHOLDER_END}")
166    }
167
168    /// Finalizes any deferred cross-references at the end of substitution.
169    ///
170    /// At this point [`Content::rendered`] holds the placeholder-bearing text;
171    /// it is captured as the template and `rendered` is rebuilt as the
172    /// unresolved fallback so it is immediately clean for callers that read it
173    /// before resolution.
174    pub(crate) fn finalize_deferred(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
175        if self.deferred.is_none() {
176            return;
177        }
178
179        let template = self.rendered.as_ref().to_string();
180
181        if let Some(deferred) = self.deferred.as_mut() {
182            deferred.template = template;
183        }
184
185        self.rebuild_rendered(renderer);
186    }
187
188    /// Resolves any deferred cross-references using `resolver`, then rebuilds
189    /// the rendered text.
190    ///
191    /// This is non-destructive: the placeholder template is retained, so a
192    /// document may be resolved more than once (e.g. for incremental builds or
193    /// multiple output targets).
194    ///
195    /// Any target that the resolver cannot resolve is reported in `warnings`.
196    pub(crate) fn resolve_references(
197        &mut self,
198        resolver: &dyn ReferenceResolver,
199        renderer: &dyn InlineSubstitutionRenderer,
200        warnings: &mut Vec<ReferenceWarning>,
201    ) {
202        if let Some(deferred) = self.deferred.as_mut() {
203            for xref in deferred.xrefs.iter_mut() {
204                xref.resolved = resolver.resolve(&ResolutionContext {
205                    target: &xref.target,
206                    provided_text: xref.provided_text.as_deref(),
207                });
208
209                if xref.resolved.is_none() {
210                    warnings.push(ReferenceWarning {
211                        target: xref.target.clone(),
212                        kind: ReferenceWarningKind::Unresolved,
213                    });
214                }
215            }
216        }
217
218        self.rebuild_rendered(renderer);
219    }
220
221    /// Rebuilds [`Content::rendered`] from the deferred template and the
222    /// current (resolved or unresolved) state of its cross-references.
223    fn rebuild_rendered(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
224        let Some(deferred) = self.deferred.as_ref() else {
225            return;
226        };
227
228        self.rendered = render_template(&deferred.template, &deferred.xrefs, renderer).into();
229    }
230}
231
232/// Splices resolved (or fallback) cross-reference renderings into a placeholder
233/// template, producing the final rendered text.
234fn render_template(
235    template: &str,
236    xrefs: &[XrefSegment],
237    renderer: &dyn InlineSubstitutionRenderer,
238) -> String {
239    let mut out = String::with_capacity(template.len());
240    let mut rest = template;
241
242    while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
243        out.push_str(&rest[..start]);
244        let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
245
246        let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
247            // Malformed placeholder; emit the sentinel literally and continue.
248            out.push(XREF_PLACEHOLDER_START);
249            rest = after;
250            continue;
251        };
252
253        let body = &after[..end];
254        rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
255
256        match body
257            .parse::<usize>()
258            .ok()
259            .and_then(|index| xrefs.get(index))
260        {
261            Some(xref) => {
262                renderer.render_xref(
263                    &XrefRenderParams {
264                        target: &xref.target,
265                        provided_text: xref.provided_text.as_deref(),
266                        resolved: xref.resolved.as_ref(),
267                    },
268                    &mut out,
269                );
270            }
271
272            None => {
273                // Unreachable while `template` and `xrefs` come from the same
274                // `Content` (indices are assigned sequentially). If that
275                // invariant is ever broken, emit the raw placeholder rather than
276                // silently dropping the span, so the breakage is visible.
277                debug_assert!(false, "xref placeholder index {body:?} out of range");
278                out.push(XREF_PLACEHOLDER_START);
279                out.push_str(body);
280                out.push(XREF_PLACEHOLDER_END);
281            }
282        }
283    }
284
285    out.push_str(rest);
286    out
287}
288
289impl<'src> From<Span<'src>> for Content<'src> {
290    fn from(span: Span<'src>) -> Self {
291        Self {
292            original: span,
293            rendered: CowStr::from(span.data()),
294            deferred: None,
295        }
296    }
297}
298
299impl std::fmt::Debug for Content<'_> {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        // The deferred cross-reference state is an internal implementation
302        // detail. It is omitted from the debug output unless present, so that
303        // the (very common) cross-reference-free content debugs identically to
304        // a plain `original` + `rendered` pair.
305        let mut s = f.debug_struct("Content");
306        s.field("original", &self.original);
307        s.field("rendered", &self.rendered);
308
309        if let Some(deferred) = self.deferred.as_ref() {
310            s.field("deferred", deferred);
311        }
312
313        s.finish()
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    #![allow(clippy::unwrap_used)]
320
321    mod is_empty {
322        #[test]
323        fn basic_empty_span() {
324            let content = crate::content::Content::from(crate::Span::default());
325            assert!(content.is_empty());
326        }
327
328        #[test]
329        fn basic_non_empty_span() {
330            let content = crate::content::Content::from(crate::Span::new("blah"));
331            assert!(!content.is_empty());
332        }
333    }
334}