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}