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}