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