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