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}