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 content::Passthrough,
9 parser::{
10 InlineSubstitutionRenderer, ReferenceResolver, ReferenceWarnings, ResolutionContext,
11 XrefRenderParams,
12 },
13 strings::CowStr,
14};
15
16/// Describes the annotated content of a block after any relevant
17/// [substitutions] have been performed.
18///
19/// This is typically used to represent the main body of block types that don't
20/// contain other blocks, such as [`SimpleBlock`] or [`RawDelimitedBlock`].
21///
22/// # Deferred cross-references
23///
24/// Cross-references (`<<id>>`, `xref:id[…]`) cannot be resolved while a block
25/// is being parsed, because their target may be defined later in the document
26/// (or, for multi-document workflows, in another document entirely). The
27/// macros substitution therefore records each cross-reference in a deferred
28/// form and leaves an opaque placeholder in the rendered text. The
29/// references are resolved in a later pass – see
30/// [`Document::resolve_references`] – at which point [`rendered()`] reflects
31/// the resolved links. Until then, [`rendered()`] shows an unresolved fallback,
32/// so it always returns clean text.
33///
34/// [substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/
35/// [`SimpleBlock`]: crate::blocks::SimpleBlock
36/// [`RawDelimitedBlock`]: crate::blocks::RawDelimitedBlock
37/// [`Document::resolve_references`]: crate::Document::resolve_references
38/// [`rendered()`]: Self::rendered
39#[derive(Clone, Eq, Hash, PartialEq)]
40pub struct Content<'src> {
41 /// The original [`Span`] from which this content was derived.
42 original: Span<'src>,
43
44 /// The possibly-modified text after substititions have been performed.
45 ///
46 /// This is always clean, user-facing text: when cross-references are still
47 /// unresolved it holds the unresolved fallback rendering, and after
48 /// resolution it holds the resolved rendering.
49 pub(crate) rendered: CowStr<'src>,
50
51 /// Source [`Span`] of each line that survived construction filtering, in
52 /// the same order as the lines of [`rendered`](Self::rendered) at
53 /// construction time.
54 ///
55 /// This is retained only so the attribute-references substitution can
56 /// locate an `attribute-missing=warn` warning at the precise source
57 /// offset of the offending `{name}` reference, rather than at the
58 /// whole-content span. See
59 /// [`apply_attributes`](crate::content::substitution_step) for the
60 /// rationale and the correlation it performs.
61 ///
62 /// `None` when the content was not built line-by-line from document source
63 /// (e.g. [`From<Span>`] or a table cell's pre-filtered value), in which
64 /// case such warnings fall back to the whole-content span.
65 source_lines: Option<Box<[Span<'src>]>>,
66
67 /// Deferred cross-references discovered during substitution, awaiting
68 /// resolution against a (possibly cross-document) catalog.
69 ///
70 /// `None` for the overwhelming majority of content, which contains no
71 /// cross-references.
72 deferred: Option<Box<DeferredContent>>,
73
74 /// Inline passthroughs (`+++…+++`, `++…++`, `$$…$$`, `pass:[…]`, inline
75 /// STEM macros) extracted from this content during substitution, in the
76 /// order they were extracted.
77 ///
78 /// The substitution pipeline pulls each passthrough out before running the
79 /// other substitutions and splices it back in afterward; this retains the
80 /// collection so it is observable after the fact (see
81 /// [`passthroughs`](Self::passthroughs)), analogous to Asciidoctor's
82 /// internal `@passthroughs` array. Empty for the common case of content
83 /// with no passthroughs, and for content whose substitution group does not
84 /// extract them.
85 passthroughs: Vec<Passthrough>,
86}
87
88/// The deferred (cross-reference-bearing) portion of a [`Content`].
89#[derive(Clone, Debug, Eq, Hash, PartialEq)]
90struct DeferredContent {
91 /// The locally-substituted text with opaque placeholder tokens marking
92 /// where each cross-reference will be spliced in. This is the source of
93 /// truth from which [`Content::rendered`] is (re)built, so resolution is
94 /// non-destructive and may be repeated.
95 template: String,
96
97 /// The cross-references, in placeholder order.
98 xrefs: Vec<XrefSegment>,
99}
100
101/// A single deferred cross-reference.
102#[derive(Clone, Debug, Eq, Hash, PartialEq)]
103pub(crate) struct XrefSegment {
104 /// The raw, uninterpreted target as written in the source.
105 pub(crate) target: String,
106
107 /// Explicit link text supplied in the cross-reference, if any.
108 pub(crate) provided_text: Option<String>,
109
110 /// Target window selection, from a `window` attribute on the `xref:` macro
111 /// (e.g. `_blank`). `None` for the shorthand form, which has no attribute
112 /// list.
113 pub(crate) window: Option<String>,
114
115 /// Roles supplied via a `role` attribute on the `xref:` macro, if any.
116 pub(crate) roles: Vec<String>,
117
118 /// The cross-reference text style in effect for this reference: the
119 /// `xrefstyle=` attribute on the `xref:` macro if given, otherwise the
120 /// document-wide `xrefstyle` at the reference's location. `None` when
121 /// `xrefstyle` is unset, in which case the target's reftext is used
122 /// verbatim.
123 pub(crate) xrefstyle: Option<crate::parser::XrefStyle>,
124
125 /// The destination derived from the target itself, for a target that
126 /// names a document. Computed during substitution, since it depends on the
127 /// path attributes in effect at the reference; it is the fallback when
128 /// [`resolved`](Self::resolved) is `None`.
129 pub(crate) derived: Option<crate::parser::DerivedReference>,
130
131 /// The resolved destination, filled in by resolution; `None` until then.
132 pub(crate) resolved: Option<crate::parser::ResolvedReference>,
133}
134
135/// Sentinel codepoints (Unicode Private Use Area) bracketing a placeholder
136/// index in [`DeferredContent::template`]. These cannot collide with user text
137/// and are inert to the remaining substitution steps.
138const XREF_PLACEHOLDER_START: char = '\u{E000}';
139const XREF_PLACEHOLDER_END: char = '\u{E001}';
140
141/// Sentinel codepoints (Unicode Private Use Area) bracketing a footnote's
142/// rendered inline marker while a section title is being substituted. Like the
143/// cross-reference placeholders above, these cannot collide with user text and
144/// are inert to the remaining substitution steps.
145///
146/// A footnote in a section title is a real, document-order footnote, but its
147/// marker must be kept out of the section's reference text and auto-generated
148/// ID. Marking the marker in a single render (rather than re-rendering the
149/// title with footnotes suppressed) means stateful substitutions – counters,
150/// attribute references that expand into footnotes – run exactly once. See
151/// [`strip_footnote_marker_spans`] and
152/// [`Content::remove_footnote_marker_sentinels`].
153pub(crate) const FOOTNOTE_MARKER_START: char = '\u{E002}';
154pub(crate) const FOOTNOTE_MARKER_END: char = '\u{E003}';
155
156/// Removes each footnote marker span – a [`FOOTNOTE_MARKER_START`] …
157/// [`FOOTNOTE_MARKER_END`] region and everything between, i.e. the sentinels
158/// *and* the marker they bracket – leaving footnote-free text suitable for a
159/// section's reference text and auto-generated ID.
160pub(crate) fn strip_footnote_marker_spans(s: &str) -> String {
161 let mut out = String::with_capacity(s.len());
162 let mut rest = s;
163
164 while let Some(start) = rest.find(FOOTNOTE_MARKER_START) {
165 out.push_str(&rest[..start]);
166 rest = &rest[start + FOOTNOTE_MARKER_START.len_utf8()..];
167
168 // Drop through the matching end sentinel (the marker text). A start
169 // without an end cannot occur – the substitution always emits both – but
170 // if it somehow did, drop the remainder rather than reintroduce the
171 // stray sentinel.
172 rest = match rest.find(FOOTNOTE_MARKER_END) {
173 Some(end) => &rest[end + FOOTNOTE_MARKER_END.len_utf8()..],
174 None => "",
175 };
176 }
177
178 out.push_str(rest);
179 out
180}
181
182/// A fully-owned snapshot of a rendered title, including any deferred
183/// cross-references it carries.
184///
185/// A block title stashed across a section heading (see
186/// `Parser::pending_block_title`) cannot keep its borrowed [`Content`] – the
187/// parser it rides on has no `'src` lifetime – so the title travels in this
188/// owned form and is rebuilt into a [`Content`] (via
189/// [`Content::from_owned_title`]) when the next block claims it. Carrying the
190/// deferred template and cross-references along means an embedded `<<id>>`
191/// still resolves once the catalog is complete.
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub(crate) struct OwnedTitle {
194 /// The rendered title text (the unresolved-fallback rendering when
195 /// cross-references are present).
196 rendered: String,
197
198 /// The placeholder template and cross-references, when the title carries
199 /// any; `None` for the (overwhelmingly common) cross-reference-free title.
200 deferred: Option<(String, Vec<XrefSegment>)>,
201}
202
203impl<'src> Content<'src> {
204 /// Constructs a `Content` from a source `Span` and a potentially-filtered
205 /// view of that source text.
206 pub(crate) fn from_filtered<T: AsRef<str>>(span: Span<'src>, filtered: T) -> Self {
207 let filtered = filtered.as_ref();
208
209 // When filtering was a no-op – the filtered text is byte-identical to
210 // the source span – borrow the span rather than allocating an owned
211 // copy of text we already hold.
212 let rendered = if filtered == span.data() {
213 CowStr::Borrowed(span.data())
214 } else {
215 filtered.to_string().into()
216 };
217
218 Self {
219 original: span,
220 rendered,
221 source_lines: None,
222 deferred: None,
223 passthroughs: Vec::new(),
224 }
225 }
226
227 /// Returns a fully-owned snapshot of this content's rendered text and
228 /// deferred cross-references, for a title that must outlive its source
229 /// borrow (see [`OwnedTitle`]).
230 pub(crate) fn to_owned_title(&self) -> OwnedTitle {
231 OwnedTitle {
232 rendered: self.rendered.as_ref().to_string(),
233 deferred: self
234 .deferred
235 .as_ref()
236 .map(|d| (d.template.clone(), d.xrefs.clone())),
237 }
238 }
239
240 /// Reconstitutes a [`Content`] from an [`OwnedTitle`] snapshot, anchored at
241 /// `span`. The deferred cross-references (when present) are restored, so
242 /// the document-order title pass can still resolve them.
243 pub(crate) fn from_owned_title(span: Span<'src>, title: OwnedTitle) -> Self {
244 Self {
245 original: span,
246 rendered: title.rendered.into(),
247 source_lines: None,
248 deferred: title
249 .deferred
250 .map(|(template, xrefs)| Box::new(DeferredContent { template, xrefs })),
251 passthroughs: Vec::new(),
252 }
253 }
254
255 /// Constructs a `Content` from a source `Span` and the per-line filtered
256 /// view of that source, retaining the source `Span` of each surviving line.
257 ///
258 /// `line_spans` must contain one entry per line of `filtered_lines`, in the
259 /// same order; each entry is the source span whose text is that filtered
260 /// line (i.e. after any leading-indent stripping and trailing-whitespace
261 /// trimming the caller applied). The retained spans let the
262 /// attribute-references substitution report an `attribute-missing=warn`
263 /// warning at the precise source offset of the offending reference; see
264 /// [`apply_attributes`](crate::content::substitution_step).
265 pub(crate) fn from_filtered_lines(
266 span: Span<'src>,
267 filtered_lines: &[&'src str],
268 line_spans: Vec<Span<'src>>,
269 ) -> Self {
270 // One source span is required per filtered line; the default
271 // `debug_assert_eq!` message reports both counts if this is ever broken.
272 debug_assert_eq!(filtered_lines.len(), line_spans.len());
273
274 // A single surviving line needs no join: it is already a contiguous
275 // `'src` slice, so borrow it rather than allocating an owned copy. This
276 // is the common plain-prose paragraph case; only a genuinely multi-line
277 // block pays for the `join`.
278 let rendered = match filtered_lines {
279 [only] => CowStr::Borrowed(only),
280 _ => filtered_lines.join("\n").into(),
281 };
282
283 Self {
284 original: span,
285 rendered,
286 source_lines: Some(line_spans.into_boxed_slice()),
287 deferred: None,
288 passthroughs: Vec::new(),
289 }
290 }
291
292 /// Returns the original span from which this [`Content`] was derived.
293 ///
294 /// This is the source text before any substitions have been applied.
295 pub fn original(&self) -> Span<'src> {
296 self.original
297 }
298
299 /// Returns the source `Span` of each line that survived construction
300 /// filtering, in rendered-line order, when they were retained (see
301 /// [`from_filtered_lines`](Self::from_filtered_lines)).
302 ///
303 /// Used only by the attribute-references substitution to locate
304 /// `attribute-missing=warn` warnings precisely.
305 pub(crate) fn source_lines(&self) -> Option<&[Span<'src>]> {
306 self.source_lines.as_deref()
307 }
308
309 /// Returns the final text after all substitutions have been applied.
310 pub fn rendered(&'src self) -> &'src str {
311 self.rendered.as_ref()
312 }
313
314 /// Returns the final rendered text, borrowed for the duration of `&self`
315 /// rather than for `'src`.
316 ///
317 /// [`rendered`](Self::rendered) ties its result to `'src`, which a block's
318 /// `title(&self)` accessor cannot provide. This shorter-lived borrow lets a
319 /// block expose its title `Content`'s rendered text through the `&self`
320 /// accessor.
321 pub(crate) fn rendered_str(&self) -> &str {
322 self.rendered.as_ref()
323 }
324
325 /// Returns an owned copy of the final text after all substitutions have
326 /// been applied.
327 ///
328 /// Unlike [`rendered()`](Self::rendered), this does not tie the returned
329 /// value to the `'src` lifetime, so it can be called on a short-lived
330 /// `Content` built solely to render a fragment (e.g. a block's attribution
331 /// or citation text).
332 pub(crate) fn rendered_owned(&self) -> String {
333 self.rendered.as_ref().to_string()
334 }
335
336 /// Returns `true` if `self` contains no text.
337 pub fn is_empty(&self) -> bool {
338 self.rendered.as_ref().is_empty()
339 }
340
341 /// Returns the inline passthroughs extracted from this content during
342 /// substitution, in extraction order.
343 ///
344 /// An inline passthrough (`+++…+++`, `++…++`, `$$…$$`, `pass:[…]`, or an
345 /// inline STEM macro) is pulled out of the text before the other
346 /// substitutions run and spliced back in afterward. This exposes that
347 /// collection – each entry's stored [`text`](Passthrough::text) and
348 /// resolved [`subs`](Passthrough::subs) – for inspection, analogous to
349 /// Asciidoctor's internal `@passthroughs` array.
350 ///
351 /// The slice is empty when the content has no passthroughs, and when the
352 /// content's substitution group does not extract passthroughs at all (only
353 /// groups that include the
354 /// [macros](crate::content::SubstitutionStep::Macros) step, or the
355 /// header group, do).
356 pub fn passthroughs(&self) -> &[Passthrough] {
357 &self.passthroughs
358 }
359
360 /// Records the inline passthroughs extracted from this content during
361 /// substitution, so they remain observable via
362 /// [`passthroughs`](Self::passthroughs) after the restore pass has spliced
363 /// them back in.
364 pub(crate) fn set_passthroughs(&mut self, passthroughs: Vec<Passthrough>) {
365 self.passthroughs = passthroughs;
366 }
367
368 /// Removes the [`FOOTNOTE_MARKER_START`]/[`FOOTNOTE_MARKER_END`] sentinels
369 /// bracketing each footnote marker, *keeping* the marker itself, so the
370 /// content renders normally. Called after a section title's reference text
371 /// and ID have been derived (via [`strip_footnote_marker_spans`], which
372 /// needs the sentinels to locate the markers). The sentinels are removed
373 /// from the deferred template too, so a later cross-reference resolution
374 /// rebuild does not reintroduce them.
375 pub(crate) fn remove_footnote_marker_sentinels(&mut self) {
376 if !self.rendered.as_ref().contains(FOOTNOTE_MARKER_START) {
377 return;
378 }
379
380 self.rendered = self
381 .rendered
382 .as_ref()
383 .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "")
384 .into();
385
386 if let Some(deferred) = self.deferred.as_mut() {
387 deferred.template = deferred
388 .template
389 .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "");
390 }
391 }
392
393 /// Returns the deferred cross-reference template and segments, if this
394 /// content carries any.
395 ///
396 /// The template is the placeholder-bearing text captured by
397 /// [`finalize_deferred`](Self::finalize_deferred); the segments are the
398 /// cross-references in placeholder order. Used by the document-order title
399 /// resolution pass, which re-renders a title's cross-references with
400 /// cross-title (including circular) coordination that the per-content
401 /// [`resolve_references`](Self::resolve_references) cannot provide.
402 pub(crate) fn deferred_parts(&self) -> Option<(&str, &[XrefSegment])> {
403 self.deferred
404 .as_ref()
405 .map(|d| (d.template.as_str(), d.xrefs.as_slice()))
406 }
407
408 /// Overwrites the rendered text directly.
409 ///
410 /// Used by the document-order title resolution pass, which computes a
411 /// title's final rendering (coordinating cross-title references) and
412 /// installs it here, in place of the per-content resolution that cannot see
413 /// other titles.
414 pub(crate) fn set_rendered(&mut self, rendered: String) {
415 self.rendered = rendered.into();
416 }
417
418 /// Returns `true` if this content contains one or more cross-references
419 /// that have not yet been resolved to a destination.
420 pub fn has_unresolved_refs(&self) -> bool {
421 self.deferred
422 .as_ref()
423 .is_some_and(|d| d.xrefs.iter().any(|x| x.resolved.is_none()))
424 }
425
426 /// Records the cross-references discovered for this content during the
427 /// macros substitution step. The placeholder tokens for these references
428 /// must already have been written into [`Content::rendered`], in the same
429 /// order as `xrefs`.
430 ///
431 /// This must be called at most once per `Content`: the placeholder indices
432 /// already embedded in [`Content::rendered`] are positions into this single
433 /// `xrefs` vector. The macros substitution runs once per content, so this
434 /// holds in practice; the assertion guards against a future caller breaking
435 /// it.
436 pub(crate) fn set_deferred_xrefs(&mut self, xrefs: Vec<XrefSegment>) {
437 if xrefs.is_empty() {
438 return;
439 }
440
441 debug_assert!(
442 self.deferred.is_none(),
443 "set_deferred_xrefs must be called at most once per Content"
444 );
445
446 self.deferred = Some(Box::new(DeferredContent {
447 template: String::new(),
448 xrefs,
449 }));
450 }
451
452 /// Returns the placeholder token for the cross-reference at `index`.
453 pub(crate) fn xref_placeholder(index: usize) -> String {
454 format!("{XREF_PLACEHOLDER_START}{index}{XREF_PLACEHOLDER_END}")
455 }
456
457 /// Finalizes any deferred cross-references at the end of substitution.
458 ///
459 /// At this point [`Content::rendered`] holds the placeholder-bearing text;
460 /// it is captured as the template and `rendered` is rebuilt as the
461 /// unresolved fallback so it is immediately clean for callers that read it
462 /// before resolution.
463 pub(crate) fn finalize_deferred(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
464 if self.deferred.is_none() {
465 return;
466 }
467
468 let template = self.rendered.as_ref().to_string();
469
470 if let Some(deferred) = self.deferred.as_mut() {
471 deferred.template = template;
472 }
473
474 self.rebuild_rendered(renderer);
475 }
476
477 /// Applies `restore` to the explicit text of every deferred
478 /// cross-reference.
479 ///
480 /// A deferred reference's text is captured out of the main rendered string
481 /// during macro substitution, so passthrough placeholders inside it are not
482 /// reached by the ordinary restore pass. This lets that pass reach them.
483 pub(crate) fn restore_deferred_xref_passthroughs(
484 &mut self,
485 mut restore: impl FnMut(&mut String),
486 ) {
487 if let Some(deferred) = self.deferred.as_mut() {
488 for xref in &mut deferred.xrefs {
489 if let Some(text) = xref.provided_text.as_mut() {
490 restore(text);
491 }
492 }
493 }
494 }
495
496 /// Resolves any deferred cross-references using `resolver`, then rebuilds
497 /// the rendered text.
498 ///
499 /// This is non-destructive: the placeholder template is retained, so a
500 /// document may be resolved more than once (e.g. for incremental builds or
501 /// multiple output targets).
502 ///
503 /// Any target that the resolver cannot resolve is reported in `warnings`.
504 pub(crate) fn resolve_references(
505 &mut self,
506 resolver: &dyn ReferenceResolver,
507 renderer: &dyn InlineSubstitutionRenderer,
508 warnings: &mut ReferenceWarnings<'src>,
509 ) {
510 let source = self.original;
511
512 if let Some(deferred) = self.deferred.as_mut() {
513 let DeferredContent { template, xrefs } = deferred.as_mut();
514
515 // A `deferred` block always holds at least one xref placeholder, so
516 // its finalized template is never empty. An empty template here
517 // means `finalize_deferred` was skipped (a future-refactor hazard);
518 // the `template.contains` guard below would then silently suppress
519 // every unresolved-ref warning, so catch that invariant break in
520 // debug builds.
521 debug_assert!(!template.is_empty());
522
523 for (index, xref) in xrefs.iter_mut().enumerate() {
524 xref.resolved = resolver.resolve(&ResolutionContext {
525 target: &xref.target,
526 provided_text: xref.provided_text.as_deref(),
527 derived: xref.derived.as_ref(),
528 });
529
530 // A reference whose placeholder is no longer in the template was
531 // re-homed into a footnote (see `rehome_xref_placeholders`); the
532 // footnote resolves and reports it, so it is not reported here.
533 // A target that names a document is never reported: it
534 // carries its own destination, so there was nothing here to
535 // resolve.
536 if xref.resolved.is_none()
537 && xref.derived.is_none()
538 && template.contains(&Content::xref_placeholder(index))
539 {
540 warnings.unresolved(&xref.target, source);
541 }
542 }
543 }
544
545 self.rebuild_rendered(renderer);
546 }
547
548 /// Rebuilds [`Content::rendered`] from the deferred template and the
549 /// current (resolved or unresolved) state of its cross-references.
550 fn rebuild_rendered(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
551 let Some(deferred) = self.deferred.as_ref() else {
552 return;
553 };
554
555 self.rendered = render_template(&deferred.template, &deferred.xrefs, renderer).into();
556 }
557}
558
559/// Re-homes the cross-reference placeholders found in `text` into a
560/// self-contained (template, xrefs) pair.
561///
562/// When the cross-reference substitution runs before footnotes, a footnote's
563/// text may carry placeholder tokens whose [`XrefSegment`]s live in the
564/// enclosing block's cross-reference list (`all`). Because a footnote's text is
565/// extracted out of the block, it needs its own copy of just those segments,
566/// renumbered so its template is independent. This scans `text` for placeholder
567/// tokens, clones the referenced segments into a fresh vector (in first-seen
568/// order), and rewrites the tokens to the new local indices.
569///
570/// Text with no placeholders returns unchanged alongside an empty vector.
571pub(crate) fn rehome_xref_placeholders(
572 text: &str,
573 all: &[XrefSegment],
574) -> (String, Vec<XrefSegment>) {
575 let mut local: Vec<XrefSegment> = vec![];
576
577 if !text.contains(XREF_PLACEHOLDER_START) {
578 return (text.to_string(), local);
579 }
580
581 let mut out = String::with_capacity(text.len());
582 let mut rest = text;
583
584 while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
585 out.push_str(&rest[..start]);
586 let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
587
588 let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
589 out.push(XREF_PLACEHOLDER_START);
590 rest = after;
591 continue;
592 };
593
594 let body = &after[..end];
595 rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
596
597 match body.parse::<usize>().ok().and_then(|index| all.get(index)) {
598 Some(segment) => {
599 let local_index = local.len();
600 local.push(segment.clone());
601 out.push_str(&Content::xref_placeholder(local_index));
602 }
603
604 None => {
605 out.push(XREF_PLACEHOLDER_START);
606 out.push_str(body);
607 out.push(XREF_PLACEHOLDER_END);
608 }
609 }
610 }
611
612 out.push_str(rest);
613 (out, local)
614}
615
616/// Splices resolved (or fallback) cross-reference renderings into a placeholder
617/// template, producing the final rendered text.
618///
619/// This is the seam used by the document-order title resolution pass: it hands
620/// in a title's captured template together with a set of [`XrefSegment`]s whose
621/// [`resolved`](XrefSegment::resolved) fields it has filled in with cross-title
622/// (including circular) coordination, and receives the final rendered title.
623pub(crate) fn render_xref_template(
624 template: &str,
625 xrefs: &[XrefSegment],
626 renderer: &dyn InlineSubstitutionRenderer,
627) -> String {
628 render_template(template, xrefs, renderer)
629}
630
631/// Splices resolved (or fallback) cross-reference renderings into a placeholder
632/// template, producing the final rendered text.
633fn render_template(
634 template: &str,
635 xrefs: &[XrefSegment],
636 renderer: &dyn InlineSubstitutionRenderer,
637) -> String {
638 let mut out = String::with_capacity(template.len());
639 let mut rest = template;
640
641 while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
642 out.push_str(&rest[..start]);
643 let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
644
645 let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
646 // Malformed placeholder; emit the sentinel literally and continue.
647 out.push(XREF_PLACEHOLDER_START);
648 rest = after;
649 continue;
650 };
651
652 let body = &after[..end];
653 rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
654
655 match body
656 .parse::<usize>()
657 .ok()
658 .and_then(|index| xrefs.get(index))
659 {
660 Some(xref) => {
661 renderer.render_xref(
662 &XrefRenderParams {
663 target: &xref.target,
664 provided_text: xref.provided_text.as_deref(),
665 window: xref.window.as_deref(),
666 roles: &xref.roles,
667 xrefstyle: xref.xrefstyle,
668 derived: xref.derived.as_ref(),
669 resolved: xref.resolved.as_ref(),
670 },
671 &mut out,
672 );
673 }
674
675 None => {
676 // Unreachable while `template` and `xrefs` come from the same
677 // `Content` (indices are assigned sequentially). If that
678 // invariant is ever broken, emit the raw placeholder rather than
679 // silently dropping the span, so the breakage is visible.
680 debug_assert!(false, "xref placeholder index {body:?} out of range");
681 out.push(XREF_PLACEHOLDER_START);
682 out.push_str(body);
683 out.push(XREF_PLACEHOLDER_END);
684 }
685 }
686 }
687
688 out.push_str(rest);
689 out
690}
691
692/// The deferred cross-references carried by a footnote's text.
693///
694/// A footnote's text is extracted out of the flow of the block during the
695/// macros substitution step, so any cross-reference (`<<id>>`, `xref:id[…]`)
696/// inside it cannot be resolved by the document-level pass that resolves
697/// references in block content. Instead, the footnote captures its
698/// cross-references here – as a placeholder template plus the references in
699/// placeholder order – and they are resolved alongside the block references
700/// (see [`Footnote::resolve_references`]).
701///
702/// [`Footnote::resolve_references`]: crate::document::Footnote::resolve_references
703#[derive(Clone, Eq, Hash, PartialEq)]
704pub(crate) struct FootnoteDeferred {
705 /// The footnote text with opaque placeholder tokens marking where each
706 /// cross-reference will be spliced in.
707 template: String,
708
709 /// The cross-references, in placeholder order.
710 xrefs: Vec<XrefSegment>,
711}
712
713impl FootnoteDeferred {
714 /// Constructs a footnote's deferred cross-reference state from the
715 /// placeholder-bearing `template` and its `xrefs` (in placeholder order).
716 pub(crate) fn new(template: String, xrefs: Vec<XrefSegment>) -> Self {
717 Self { template, xrefs }
718 }
719
720 /// Renders the footnote text from the template and the current (resolved or
721 /// unresolved) state of its cross-references.
722 pub(crate) fn render(&self, renderer: &dyn InlineSubstitutionRenderer) -> String {
723 render_template(&self.template, &self.xrefs, renderer)
724 }
725
726 /// Resolves the footnote's cross-references using `resolver`, reporting any
727 /// unresolved target in `warnings` against `source`. Rendering the resolved
728 /// text is left to the caller (via [`render`](Self::render)).
729 pub(crate) fn resolve<'src>(
730 &mut self,
731 resolver: &dyn ReferenceResolver,
732 warnings: &mut ReferenceWarnings<'src>,
733 source: Span<'src>,
734 ) {
735 for xref in self.xrefs.iter_mut() {
736 xref.resolved = resolver.resolve(&ResolutionContext {
737 target: &xref.target,
738 provided_text: xref.provided_text.as_deref(),
739 derived: xref.derived.as_ref(),
740 });
741
742 if xref.resolved.is_none() && xref.derived.is_none() {
743 warnings.unresolved(&xref.target, source);
744 }
745 }
746 }
747}
748
749impl std::fmt::Debug for FootnoteDeferred {
750 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
751 f.debug_struct("FootnoteDeferred")
752 .field("template", &self.template)
753 .field("xrefs", &self.xrefs)
754 .finish()
755 }
756}
757
758impl<'src> From<Span<'src>> for Content<'src> {
759 fn from(span: Span<'src>) -> Self {
760 Self {
761 original: span,
762 rendered: CowStr::from(span.data()),
763 source_lines: None,
764 deferred: None,
765 passthroughs: Vec::new(),
766 }
767 }
768}
769
770impl std::fmt::Debug for Content<'_> {
771 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
772 // The deferred cross-reference state is an internal implementation
773 // detail. It is omitted from the debug output unless present, so that
774 // the (very common) cross-reference-free content debugs identically to
775 // a plain `original` + `rendered` pair.
776 let mut s = f.debug_struct("Content");
777 s.field("original", &self.original);
778 s.field("rendered", &self.rendered);
779
780 if let Some(deferred) = self.deferred.as_ref() {
781 s.field("deferred", deferred);
782 }
783
784 s.finish()
785 }
786}
787
788#[cfg(test)]
789mod tests {
790 #![allow(clippy::unwrap_used)]
791
792 mod is_empty {
793 #[test]
794 fn basic_empty_span() {
795 let content = crate::content::Content::from(crate::Span::default());
796 assert!(content.is_empty());
797 }
798
799 #[test]
800 fn basic_non_empty_span() {
801 let content = crate::content::Content::from(crate::Span::new("blah"));
802 assert!(!content.is_empty());
803 }
804 }
805
806 mod from_filtered {
807 use crate::{Span, content::Content, strings::CowStr};
808
809 #[test]
810 fn borrows_source_when_filter_is_a_no_op() {
811 // A filtered view byte-identical to the source span is borrowed from
812 // the span rather than allocating an owned copy of text we already
813 // hold.
814 let content = Content::from_filtered(Span::new("plain text"), "plain text");
815
816 assert!(matches!(content.rendered, CowStr::Borrowed(_)));
817 assert_eq!(content.rendered.as_ref(), "plain text");
818 }
819
820 #[test]
821 fn owns_rendered_text_when_filter_changes_it() {
822 // A filtered view that differs from the source is materialized as an
823 // owned copy.
824 let content = Content::from_filtered(Span::new("a|b"), "ab");
825
826 assert!(matches!(content.rendered, CowStr::Boxed(_)));
827 assert_eq!(content.rendered.as_ref(), "ab");
828 }
829 }
830
831 mod strip_footnote_marker_spans {
832 use super::super::{
833 FOOTNOTE_MARKER_END, FOOTNOTE_MARKER_START, strip_footnote_marker_spans,
834 };
835
836 fn marked(marker: &str) -> String {
837 format!("{FOOTNOTE_MARKER_START}{marker}{FOOTNOTE_MARKER_END}")
838 }
839
840 #[test]
841 fn leaves_unmarked_text_unchanged() {
842 assert_eq!(strip_footnote_marker_spans("Plain title"), "Plain title");
843 }
844
845 #[test]
846 fn removes_a_marker_span_and_its_sentinels() {
847 let input = format!("Title{}", marked("[1]"));
848 assert_eq!(strip_footnote_marker_spans(&input), "Title");
849 }
850
851 #[test]
852 fn removes_multiple_spans_keeping_surrounding_text() {
853 let input = format!("a{}b{}c", marked("[1]"), marked("[2]"));
854 assert_eq!(strip_footnote_marker_spans(&input), "abc");
855 }
856
857 #[test]
858 fn a_start_without_an_end_drops_the_remainder() {
859 // Defensive: the substitution always emits balanced sentinels, but a
860 // lone start must not leak the sentinel into the output.
861 let input = format!("Title{FOOTNOTE_MARKER_START}dangling");
862 assert_eq!(strip_footnote_marker_spans(&input), "Title");
863 }
864 }
865
866 mod footnote_deferred {
867 use super::super::{
868 FootnoteDeferred, XREF_PLACEHOLDER_END, XREF_PLACEHOLDER_START, XrefSegment,
869 rehome_xref_placeholders,
870 };
871
872 fn segment(target: &str) -> XrefSegment {
873 XrefSegment {
874 target: target.to_string(),
875 provided_text: None,
876 window: None,
877 roles: vec![],
878 xrefstyle: None,
879 derived: None,
880 resolved: None,
881 }
882 }
883
884 #[test]
885 fn rehomes_a_placeholder_into_a_local_segment() {
886 let all = vec![segment("a"), segment("b")];
887
888 // Reference only the second segment; it becomes local index 0.
889 let text = format!("see {XREF_PLACEHOLDER_START}1{XREF_PLACEHOLDER_END} here");
890
891 let (template, local) = rehome_xref_placeholders(&text, &all);
892
893 assert_eq!(local.len(), 1);
894 assert_eq!(local.first().unwrap().target, "b");
895 assert_eq!(
896 template,
897 format!("see {XREF_PLACEHOLDER_START}0{XREF_PLACEHOLDER_END} here")
898 );
899 }
900
901 #[test]
902 fn text_without_placeholders_is_returned_unchanged() {
903 let (template, local) = rehome_xref_placeholders("plain text", &[segment("a")]);
904 assert_eq!(template, "plain text");
905 assert!(local.is_empty());
906 }
907
908 #[test]
909 fn malformed_placeholders_are_passed_through_literally() {
910 // A non-numeric index and an unterminated placeholder are both left
911 // as-is (these cannot arise in practice, but the fallback is exercised).
912 let bad_index = format!("a{XREF_PLACEHOLDER_START}xyz{XREF_PLACEHOLDER_END}b");
913 let (template, local) = rehome_xref_placeholders(&bad_index, &[]);
914 assert_eq!(template, bad_index);
915 assert!(local.is_empty());
916
917 let unterminated = format!("a{XREF_PLACEHOLDER_START}0 no end");
918 let (template, local) = rehome_xref_placeholders(&unterminated, &[]);
919 assert_eq!(template, unterminated);
920 assert!(local.is_empty());
921 }
922
923 #[test]
924 fn out_of_range_placeholder_index_is_passed_through() {
925 // An index with no matching segment in `all` is left literal.
926 let text = format!("x{XREF_PLACEHOLDER_START}9{XREF_PLACEHOLDER_END}y");
927 let (template, local) = rehome_xref_placeholders(&text, &[segment("a")]);
928 assert_eq!(template, text);
929 assert!(local.is_empty());
930 }
931
932 #[test]
933 fn debug_includes_template_and_xrefs() {
934 let deferred = FootnoteDeferred::new("t".to_string(), vec![segment("a")]);
935 let rendered = format!("{deferred:?}");
936 assert!(rendered.contains("FootnoteDeferred"));
937 assert!(rendered.contains("template"));
938 }
939 }
940}