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, Hash, 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, Hash, 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, Hash, 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 let filtered = filtered.as_ref();
194
195 // When filtering was a no-op – the filtered text is byte-identical to
196 // the source span – borrow the span rather than allocating an owned
197 // copy of text we already hold.
198 let rendered = if filtered == span.data() {
199 CowStr::Borrowed(span.data())
200 } else {
201 filtered.to_string().into()
202 };
203
204 Self {
205 original: span,
206 rendered,
207 source_lines: None,
208 deferred: None,
209 }
210 }
211
212 /// Returns a fully-owned snapshot of this content's rendered text and
213 /// deferred cross-references, for a title that must outlive its source
214 /// borrow (see [`OwnedTitle`]).
215 pub(crate) fn to_owned_title(&self) -> OwnedTitle {
216 OwnedTitle {
217 rendered: self.rendered.as_ref().to_string(),
218 deferred: self
219 .deferred
220 .as_ref()
221 .map(|d| (d.template.clone(), d.xrefs.clone())),
222 }
223 }
224
225 /// Reconstitutes a [`Content`] from an [`OwnedTitle`] snapshot, anchored at
226 /// `span`. The deferred cross-references (when present) are restored, so
227 /// the document-order title pass can still resolve them.
228 pub(crate) fn from_owned_title(span: Span<'src>, title: OwnedTitle) -> Self {
229 Self {
230 original: span,
231 rendered: title.rendered.into(),
232 source_lines: None,
233 deferred: title
234 .deferred
235 .map(|(template, xrefs)| Box::new(DeferredContent { template, xrefs })),
236 }
237 }
238
239 /// Constructs a `Content` from a source `Span` and the per-line filtered
240 /// view of that source, retaining the source `Span` of each surviving line.
241 ///
242 /// `line_spans` must contain one entry per line of `filtered_lines`, in the
243 /// same order; each entry is the source span whose text is that filtered
244 /// line (i.e. after any leading-indent stripping and trailing-whitespace
245 /// trimming the caller applied). The retained spans let the
246 /// attribute-references substitution report an `attribute-missing=warn`
247 /// warning at the precise source offset of the offending reference; see
248 /// [`apply_attributes`](crate::content::substitution_step).
249 pub(crate) fn from_filtered_lines(
250 span: Span<'src>,
251 filtered_lines: &[&'src str],
252 line_spans: Vec<Span<'src>>,
253 ) -> Self {
254 // One source span is required per filtered line; the default
255 // `debug_assert_eq!` message reports both counts if this is ever broken.
256 debug_assert_eq!(filtered_lines.len(), line_spans.len());
257
258 // A single surviving line needs no join: it is already a contiguous
259 // `'src` slice, so borrow it rather than allocating an owned copy. This
260 // is the common plain-prose paragraph case; only a genuinely multi-line
261 // block pays for the `join`.
262 let rendered = match filtered_lines {
263 [only] => CowStr::Borrowed(only),
264 _ => filtered_lines.join("\n").into(),
265 };
266
267 Self {
268 original: span,
269 rendered,
270 source_lines: Some(line_spans.into_boxed_slice()),
271 deferred: None,
272 }
273 }
274
275 /// Returns the original span from which this [`Content`] was derived.
276 ///
277 /// This is the source text before any substitions have been applied.
278 pub fn original(&self) -> Span<'src> {
279 self.original
280 }
281
282 /// Returns the source `Span` of each line that survived construction
283 /// filtering, in rendered-line order, when they were retained (see
284 /// [`from_filtered_lines`](Self::from_filtered_lines)).
285 ///
286 /// Used only by the attribute-references substitution to locate
287 /// `attribute-missing=warn` warnings precisely.
288 pub(crate) fn source_lines(&self) -> Option<&[Span<'src>]> {
289 self.source_lines.as_deref()
290 }
291
292 /// Returns the final text after all substitutions have been applied.
293 pub fn rendered(&'src self) -> &'src str {
294 self.rendered.as_ref()
295 }
296
297 /// Returns the final rendered text, borrowed for the duration of `&self`
298 /// rather than for `'src`.
299 ///
300 /// [`rendered`](Self::rendered) ties its result to `'src`, which a block's
301 /// `title(&self)` accessor cannot provide. This shorter-lived borrow lets a
302 /// block expose its title `Content`'s rendered text through the `&self`
303 /// accessor.
304 pub(crate) fn rendered_str(&self) -> &str {
305 self.rendered.as_ref()
306 }
307
308 /// Returns an owned copy of the final text after all substitutions have
309 /// been applied.
310 ///
311 /// Unlike [`rendered()`](Self::rendered), this does not tie the returned
312 /// value to the `'src` lifetime, so it can be called on a short-lived
313 /// `Content` built solely to render a fragment (e.g. a block's attribution
314 /// or citation text).
315 pub(crate) fn rendered_owned(&self) -> String {
316 self.rendered.as_ref().to_string()
317 }
318
319 /// Returns `true` if `self` contains no text.
320 pub fn is_empty(&self) -> bool {
321 self.rendered.as_ref().is_empty()
322 }
323
324 /// Removes the [`FOOTNOTE_MARKER_START`]/[`FOOTNOTE_MARKER_END`] sentinels
325 /// bracketing each footnote marker, *keeping* the marker itself, so the
326 /// content renders normally. Called after a section title's reference text
327 /// and ID have been derived (via [`strip_footnote_marker_spans`], which
328 /// needs the sentinels to locate the markers). The sentinels are removed
329 /// from the deferred template too, so a later cross-reference resolution
330 /// rebuild does not reintroduce them.
331 pub(crate) fn remove_footnote_marker_sentinels(&mut self) {
332 if !self.rendered.as_ref().contains(FOOTNOTE_MARKER_START) {
333 return;
334 }
335
336 self.rendered = self
337 .rendered
338 .as_ref()
339 .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "")
340 .into();
341
342 if let Some(deferred) = self.deferred.as_mut() {
343 deferred.template = deferred
344 .template
345 .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "");
346 }
347 }
348
349 /// Returns the deferred cross-reference template and segments, if this
350 /// content carries any.
351 ///
352 /// The template is the placeholder-bearing text captured by
353 /// [`finalize_deferred`](Self::finalize_deferred); the segments are the
354 /// cross-references in placeholder order. Used by the document-order title
355 /// resolution pass, which re-renders a title's cross-references with
356 /// cross-title (including circular) coordination that the per-content
357 /// [`resolve_references`](Self::resolve_references) cannot provide.
358 pub(crate) fn deferred_parts(&self) -> Option<(&str, &[XrefSegment])> {
359 self.deferred
360 .as_ref()
361 .map(|d| (d.template.as_str(), d.xrefs.as_slice()))
362 }
363
364 /// Overwrites the rendered text directly.
365 ///
366 /// Used by the document-order title resolution pass, which computes a
367 /// title's final rendering (coordinating cross-title references) and
368 /// installs it here, in place of the per-content resolution that cannot see
369 /// other titles.
370 pub(crate) fn set_rendered(&mut self, rendered: String) {
371 self.rendered = rendered.into();
372 }
373
374 /// Returns `true` if this content contains one or more cross-references
375 /// that have not yet been resolved to a destination.
376 pub fn has_unresolved_refs(&self) -> bool {
377 self.deferred
378 .as_ref()
379 .is_some_and(|d| d.xrefs.iter().any(|x| x.resolved.is_none()))
380 }
381
382 /// Records the cross-references discovered for this content during the
383 /// macros substitution step. The placeholder tokens for these references
384 /// must already have been written into [`Content::rendered`], in the same
385 /// order as `xrefs`.
386 ///
387 /// This must be called at most once per `Content`: the placeholder indices
388 /// already embedded in [`Content::rendered`] are positions into this single
389 /// `xrefs` vector. The macros substitution runs once per content, so this
390 /// holds in practice; the assertion guards against a future caller breaking
391 /// it.
392 pub(crate) fn set_deferred_xrefs(&mut self, xrefs: Vec<XrefSegment>) {
393 if xrefs.is_empty() {
394 return;
395 }
396
397 debug_assert!(
398 self.deferred.is_none(),
399 "set_deferred_xrefs must be called at most once per Content"
400 );
401
402 self.deferred = Some(Box::new(DeferredContent {
403 template: String::new(),
404 xrefs,
405 }));
406 }
407
408 /// Returns the placeholder token for the cross-reference at `index`.
409 pub(crate) fn xref_placeholder(index: usize) -> String {
410 format!("{XREF_PLACEHOLDER_START}{index}{XREF_PLACEHOLDER_END}")
411 }
412
413 /// Finalizes any deferred cross-references at the end of substitution.
414 ///
415 /// At this point [`Content::rendered`] holds the placeholder-bearing text;
416 /// it is captured as the template and `rendered` is rebuilt as the
417 /// unresolved fallback so it is immediately clean for callers that read it
418 /// before resolution.
419 pub(crate) fn finalize_deferred(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
420 if self.deferred.is_none() {
421 return;
422 }
423
424 let template = self.rendered.as_ref().to_string();
425
426 if let Some(deferred) = self.deferred.as_mut() {
427 deferred.template = template;
428 }
429
430 self.rebuild_rendered(renderer);
431 }
432
433 /// Applies `restore` to the explicit text of every deferred
434 /// cross-reference.
435 ///
436 /// A deferred reference's text is captured out of the main rendered string
437 /// during macro substitution, so passthrough placeholders inside it are not
438 /// reached by the ordinary restore pass. This lets that pass reach them.
439 pub(crate) fn restore_deferred_xref_passthroughs(
440 &mut self,
441 mut restore: impl FnMut(&mut String),
442 ) {
443 if let Some(deferred) = self.deferred.as_mut() {
444 for xref in &mut deferred.xrefs {
445 if let Some(text) = xref.provided_text.as_mut() {
446 restore(text);
447 }
448 }
449 }
450 }
451
452 /// Resolves any deferred cross-references using `resolver`, then rebuilds
453 /// the rendered text.
454 ///
455 /// This is non-destructive: the placeholder template is retained, so a
456 /// document may be resolved more than once (e.g. for incremental builds or
457 /// multiple output targets).
458 ///
459 /// Any target that the resolver cannot resolve is reported in `warnings`.
460 pub(crate) fn resolve_references(
461 &mut self,
462 resolver: &dyn ReferenceResolver,
463 renderer: &dyn InlineSubstitutionRenderer,
464 warnings: &mut ReferenceWarnings<'src>,
465 ) {
466 let source = self.original;
467
468 if let Some(deferred) = self.deferred.as_mut() {
469 let DeferredContent { template, xrefs } = deferred.as_mut();
470
471 // A `deferred` block always holds at least one xref placeholder, so
472 // its finalized template is never empty. An empty template here
473 // means `finalize_deferred` was skipped (a future-refactor hazard);
474 // the `template.contains` guard below would then silently suppress
475 // every unresolved-ref warning, so catch that invariant break in
476 // debug builds.
477 debug_assert!(!template.is_empty());
478
479 for (index, xref) in xrefs.iter_mut().enumerate() {
480 xref.resolved = resolver.resolve(&ResolutionContext {
481 target: &xref.target,
482 provided_text: xref.provided_text.as_deref(),
483 derived: xref.derived.as_ref(),
484 });
485
486 // A reference whose placeholder is no longer in the template was
487 // re-homed into a footnote (see `rehome_xref_placeholders`); the
488 // footnote resolves and reports it, so it is not reported here.
489 // A target that names a document is never reported: it
490 // carries its own destination, so there was nothing here to
491 // resolve.
492 if xref.resolved.is_none()
493 && xref.derived.is_none()
494 && template.contains(&Content::xref_placeholder(index))
495 {
496 warnings.unresolved(&xref.target, source);
497 }
498 }
499 }
500
501 self.rebuild_rendered(renderer);
502 }
503
504 /// Rebuilds [`Content::rendered`] from the deferred template and the
505 /// current (resolved or unresolved) state of its cross-references.
506 fn rebuild_rendered(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
507 let Some(deferred) = self.deferred.as_ref() else {
508 return;
509 };
510
511 self.rendered = render_template(&deferred.template, &deferred.xrefs, renderer).into();
512 }
513}
514
515/// Re-homes the cross-reference placeholders found in `text` into a
516/// self-contained (template, xrefs) pair.
517///
518/// When the cross-reference substitution runs before footnotes, a footnote's
519/// text may carry placeholder tokens whose [`XrefSegment`]s live in the
520/// enclosing block's cross-reference list (`all`). Because a footnote's text is
521/// extracted out of the block, it needs its own copy of just those segments,
522/// renumbered so its template is independent. This scans `text` for placeholder
523/// tokens, clones the referenced segments into a fresh vector (in first-seen
524/// order), and rewrites the tokens to the new local indices.
525///
526/// Text with no placeholders returns unchanged alongside an empty vector.
527pub(crate) fn rehome_xref_placeholders(
528 text: &str,
529 all: &[XrefSegment],
530) -> (String, Vec<XrefSegment>) {
531 let mut local: Vec<XrefSegment> = vec![];
532
533 if !text.contains(XREF_PLACEHOLDER_START) {
534 return (text.to_string(), local);
535 }
536
537 let mut out = String::with_capacity(text.len());
538 let mut rest = text;
539
540 while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
541 out.push_str(&rest[..start]);
542 let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
543
544 let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
545 out.push(XREF_PLACEHOLDER_START);
546 rest = after;
547 continue;
548 };
549
550 let body = &after[..end];
551 rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
552
553 match body.parse::<usize>().ok().and_then(|index| all.get(index)) {
554 Some(segment) => {
555 let local_index = local.len();
556 local.push(segment.clone());
557 out.push_str(&Content::xref_placeholder(local_index));
558 }
559
560 None => {
561 out.push(XREF_PLACEHOLDER_START);
562 out.push_str(body);
563 out.push(XREF_PLACEHOLDER_END);
564 }
565 }
566 }
567
568 out.push_str(rest);
569 (out, local)
570}
571
572/// Splices resolved (or fallback) cross-reference renderings into a placeholder
573/// template, producing the final rendered text.
574///
575/// This is the seam used by the document-order title resolution pass: it hands
576/// in a title's captured template together with a set of [`XrefSegment`]s whose
577/// [`resolved`](XrefSegment::resolved) fields it has filled in with cross-title
578/// (including circular) coordination, and receives the final rendered title.
579pub(crate) fn render_xref_template(
580 template: &str,
581 xrefs: &[XrefSegment],
582 renderer: &dyn InlineSubstitutionRenderer,
583) -> String {
584 render_template(template, xrefs, renderer)
585}
586
587/// Splices resolved (or fallback) cross-reference renderings into a placeholder
588/// template, producing the final rendered text.
589fn render_template(
590 template: &str,
591 xrefs: &[XrefSegment],
592 renderer: &dyn InlineSubstitutionRenderer,
593) -> String {
594 let mut out = String::with_capacity(template.len());
595 let mut rest = template;
596
597 while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
598 out.push_str(&rest[..start]);
599 let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
600
601 let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
602 // Malformed placeholder; emit the sentinel literally and continue.
603 out.push(XREF_PLACEHOLDER_START);
604 rest = after;
605 continue;
606 };
607
608 let body = &after[..end];
609 rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
610
611 match body
612 .parse::<usize>()
613 .ok()
614 .and_then(|index| xrefs.get(index))
615 {
616 Some(xref) => {
617 renderer.render_xref(
618 &XrefRenderParams {
619 target: &xref.target,
620 provided_text: xref.provided_text.as_deref(),
621 window: xref.window.as_deref(),
622 roles: &xref.roles,
623 xrefstyle: xref.xrefstyle,
624 derived: xref.derived.as_ref(),
625 resolved: xref.resolved.as_ref(),
626 },
627 &mut out,
628 );
629 }
630
631 None => {
632 // Unreachable while `template` and `xrefs` come from the same
633 // `Content` (indices are assigned sequentially). If that
634 // invariant is ever broken, emit the raw placeholder rather than
635 // silently dropping the span, so the breakage is visible.
636 debug_assert!(false, "xref placeholder index {body:?} out of range");
637 out.push(XREF_PLACEHOLDER_START);
638 out.push_str(body);
639 out.push(XREF_PLACEHOLDER_END);
640 }
641 }
642 }
643
644 out.push_str(rest);
645 out
646}
647
648/// The deferred cross-references carried by a footnote's text.
649///
650/// A footnote's text is extracted out of the flow of the block during the
651/// macros substitution step, so any cross-reference (`<<id>>`, `xref:id[…]`)
652/// inside it cannot be resolved by the document-level pass that resolves
653/// references in block content. Instead, the footnote captures its
654/// cross-references here – as a placeholder template plus the references in
655/// placeholder order – and they are resolved alongside the block references
656/// (see [`Footnote::resolve_references`]).
657///
658/// [`Footnote::resolve_references`]: crate::document::Footnote::resolve_references
659#[derive(Clone, Eq, PartialEq)]
660pub(crate) struct FootnoteDeferred {
661 /// The footnote text with opaque placeholder tokens marking where each
662 /// cross-reference will be spliced in.
663 template: String,
664
665 /// The cross-references, in placeholder order.
666 xrefs: Vec<XrefSegment>,
667}
668
669impl FootnoteDeferred {
670 /// Constructs a footnote's deferred cross-reference state from the
671 /// placeholder-bearing `template` and its `xrefs` (in placeholder order).
672 pub(crate) fn new(template: String, xrefs: Vec<XrefSegment>) -> Self {
673 Self { template, xrefs }
674 }
675
676 /// Renders the footnote text from the template and the current (resolved or
677 /// unresolved) state of its cross-references.
678 pub(crate) fn render(&self, renderer: &dyn InlineSubstitutionRenderer) -> String {
679 render_template(&self.template, &self.xrefs, renderer)
680 }
681
682 /// Resolves the footnote's cross-references using `resolver`, reporting any
683 /// unresolved target in `warnings` against `source`. Rendering the resolved
684 /// text is left to the caller (via [`render`](Self::render)).
685 pub(crate) fn resolve<'src>(
686 &mut self,
687 resolver: &dyn ReferenceResolver,
688 warnings: &mut ReferenceWarnings<'src>,
689 source: Span<'src>,
690 ) {
691 for xref in self.xrefs.iter_mut() {
692 xref.resolved = resolver.resolve(&ResolutionContext {
693 target: &xref.target,
694 provided_text: xref.provided_text.as_deref(),
695 derived: xref.derived.as_ref(),
696 });
697
698 if xref.resolved.is_none() && xref.derived.is_none() {
699 warnings.unresolved(&xref.target, source);
700 }
701 }
702 }
703}
704
705impl std::fmt::Debug for FootnoteDeferred {
706 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
707 f.debug_struct("FootnoteDeferred")
708 .field("template", &self.template)
709 .field("xrefs", &self.xrefs)
710 .finish()
711 }
712}
713
714impl<'src> From<Span<'src>> for Content<'src> {
715 fn from(span: Span<'src>) -> Self {
716 Self {
717 original: span,
718 rendered: CowStr::from(span.data()),
719 source_lines: None,
720 deferred: None,
721 }
722 }
723}
724
725impl std::fmt::Debug for Content<'_> {
726 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
727 // The deferred cross-reference state is an internal implementation
728 // detail. It is omitted from the debug output unless present, so that
729 // the (very common) cross-reference-free content debugs identically to
730 // a plain `original` + `rendered` pair.
731 let mut s = f.debug_struct("Content");
732 s.field("original", &self.original);
733 s.field("rendered", &self.rendered);
734
735 if let Some(deferred) = self.deferred.as_ref() {
736 s.field("deferred", deferred);
737 }
738
739 s.finish()
740 }
741}
742
743#[cfg(test)]
744mod tests {
745 #![allow(clippy::unwrap_used)]
746
747 mod is_empty {
748 #[test]
749 fn basic_empty_span() {
750 let content = crate::content::Content::from(crate::Span::default());
751 assert!(content.is_empty());
752 }
753
754 #[test]
755 fn basic_non_empty_span() {
756 let content = crate::content::Content::from(crate::Span::new("blah"));
757 assert!(!content.is_empty());
758 }
759 }
760
761 mod from_filtered {
762 use crate::{Span, content::Content, strings::CowStr};
763
764 #[test]
765 fn borrows_source_when_filter_is_a_no_op() {
766 // A filtered view byte-identical to the source span is borrowed from
767 // the span rather than allocating an owned copy of text we already
768 // hold.
769 let content = Content::from_filtered(Span::new("plain text"), "plain text");
770
771 assert!(matches!(content.rendered, CowStr::Borrowed(_)));
772 assert_eq!(content.rendered.as_ref(), "plain text");
773 }
774
775 #[test]
776 fn owns_rendered_text_when_filter_changes_it() {
777 // A filtered view that differs from the source is materialized as an
778 // owned copy.
779 let content = Content::from_filtered(Span::new("a|b"), "ab");
780
781 assert!(matches!(content.rendered, CowStr::Boxed(_)));
782 assert_eq!(content.rendered.as_ref(), "ab");
783 }
784 }
785
786 mod strip_footnote_marker_spans {
787 use super::super::{
788 FOOTNOTE_MARKER_END, FOOTNOTE_MARKER_START, strip_footnote_marker_spans,
789 };
790
791 fn marked(marker: &str) -> String {
792 format!("{FOOTNOTE_MARKER_START}{marker}{FOOTNOTE_MARKER_END}")
793 }
794
795 #[test]
796 fn leaves_unmarked_text_unchanged() {
797 assert_eq!(strip_footnote_marker_spans("Plain title"), "Plain title");
798 }
799
800 #[test]
801 fn removes_a_marker_span_and_its_sentinels() {
802 let input = format!("Title{}", marked("[1]"));
803 assert_eq!(strip_footnote_marker_spans(&input), "Title");
804 }
805
806 #[test]
807 fn removes_multiple_spans_keeping_surrounding_text() {
808 let input = format!("a{}b{}c", marked("[1]"), marked("[2]"));
809 assert_eq!(strip_footnote_marker_spans(&input), "abc");
810 }
811
812 #[test]
813 fn a_start_without_an_end_drops_the_remainder() {
814 // Defensive: the substitution always emits balanced sentinels, but a
815 // lone start must not leak the sentinel into the output.
816 let input = format!("Title{FOOTNOTE_MARKER_START}dangling");
817 assert_eq!(strip_footnote_marker_spans(&input), "Title");
818 }
819 }
820
821 mod footnote_deferred {
822 use super::super::{
823 FootnoteDeferred, XREF_PLACEHOLDER_END, XREF_PLACEHOLDER_START, XrefSegment,
824 rehome_xref_placeholders,
825 };
826
827 fn segment(target: &str) -> XrefSegment {
828 XrefSegment {
829 target: target.to_string(),
830 provided_text: None,
831 window: None,
832 roles: vec![],
833 xrefstyle: None,
834 derived: None,
835 resolved: None,
836 }
837 }
838
839 #[test]
840 fn rehomes_a_placeholder_into_a_local_segment() {
841 let all = vec![segment("a"), segment("b")];
842
843 // Reference only the second segment; it becomes local index 0.
844 let text = format!("see {XREF_PLACEHOLDER_START}1{XREF_PLACEHOLDER_END} here");
845
846 let (template, local) = rehome_xref_placeholders(&text, &all);
847
848 assert_eq!(local.len(), 1);
849 assert_eq!(local.first().unwrap().target, "b");
850 assert_eq!(
851 template,
852 format!("see {XREF_PLACEHOLDER_START}0{XREF_PLACEHOLDER_END} here")
853 );
854 }
855
856 #[test]
857 fn text_without_placeholders_is_returned_unchanged() {
858 let (template, local) = rehome_xref_placeholders("plain text", &[segment("a")]);
859 assert_eq!(template, "plain text");
860 assert!(local.is_empty());
861 }
862
863 #[test]
864 fn malformed_placeholders_are_passed_through_literally() {
865 // A non-numeric index and an unterminated placeholder are both left
866 // as-is (these cannot arise in practice, but the fallback is exercised).
867 let bad_index = format!("a{XREF_PLACEHOLDER_START}xyz{XREF_PLACEHOLDER_END}b");
868 let (template, local) = rehome_xref_placeholders(&bad_index, &[]);
869 assert_eq!(template, bad_index);
870 assert!(local.is_empty());
871
872 let unterminated = format!("a{XREF_PLACEHOLDER_START}0 no end");
873 let (template, local) = rehome_xref_placeholders(&unterminated, &[]);
874 assert_eq!(template, unterminated);
875 assert!(local.is_empty());
876 }
877
878 #[test]
879 fn out_of_range_placeholder_index_is_passed_through() {
880 // An index with no matching segment in `all` is left literal.
881 let text = format!("x{XREF_PLACEHOLDER_START}9{XREF_PLACEHOLDER_END}y");
882 let (template, local) = rehome_xref_placeholders(&text, &[segment("a")]);
883 assert_eq!(template, text);
884 assert!(local.is_empty());
885 }
886
887 #[test]
888 fn debug_includes_template_and_xrefs() {
889 let deferred = FootnoteDeferred::new("t".to_string(), vec![segment("a")]);
890 let rendered = format!("{deferred:?}");
891 assert!(rendered.contains("FootnoteDeferred"));
892 assert!(rendered.contains("template"));
893 }
894 }
895}