asciidoc_parser/content/content.rs
1//! Describes the content of a non-compound block after any relevant
2//! [substitutions] have been performed.
3//!
4//! [substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/
5
6use crate::{
7 Span,
8 parser::{
9 InlineSubstitutionRenderer, ReferenceResolver, ReferenceWarning, ReferenceWarningKind,
10 ResolutionContext, XrefRenderParams,
11 },
12 strings::CowStr,
13};
14
15/// Describes the annotated content of a block after any relevant
16/// [substitutions] have been performed.
17///
18/// This is typically used to represent the main body of block types that don't
19/// contain other blocks, such as [`SimpleBlock`] or [`RawDelimitedBlock`].
20///
21/// # Deferred cross-references
22///
23/// Cross-references (`<<id>>`, `xref:id[…]`) cannot be resolved while a block
24/// is being parsed, because their target may be defined later in the document
25/// (or, for multi-document workflows, in another document entirely). The
26/// macros substitution therefore records each cross-reference in a deferred
27/// form and leaves an opaque placeholder in the rendered text. The
28/// references are resolved in a later pass — see
29/// [`Document::resolve_references`] — at which point [`rendered()`] reflects
30/// the resolved links. Until then, [`rendered()`] shows an unresolved fallback,
31/// so it always returns clean text.
32///
33/// [substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/
34/// [`SimpleBlock`]: crate::blocks::SimpleBlock
35/// [`RawDelimitedBlock`]: crate::blocks::RawDelimitedBlock
36/// [`Document::resolve_references`]: crate::Document::resolve_references
37/// [`rendered()`]: Self::rendered
38#[derive(Clone, Eq, PartialEq)]
39pub struct Content<'src> {
40 /// The original [`Span`] from which this content was derived.
41 original: Span<'src>,
42
43 /// The possibly-modified text after substititions have been performed.
44 ///
45 /// This is always clean, user-facing text: when cross-references are still
46 /// unresolved it holds the unresolved fallback rendering, and after
47 /// resolution it holds the resolved rendering.
48 pub(crate) rendered: CowStr<'src>,
49
50 /// 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 resolved destination, filled in by resolution; `None` until then.
112 pub(crate) resolved: Option<crate::parser::ResolvedReference>,
113}
114
115/// Sentinel codepoints (Unicode Private Use Area) bracketing a placeholder
116/// index in [`DeferredContent::template`]. These cannot collide with user text
117/// and are inert to the remaining substitution steps.
118const XREF_PLACEHOLDER_START: char = '\u{E000}';
119const XREF_PLACEHOLDER_END: char = '\u{E001}';
120
121/// Sentinel codepoints (Unicode Private Use Area) bracketing a footnote's
122/// rendered inline marker while a section title is being substituted. Like the
123/// cross-reference placeholders above, these cannot collide with user text and
124/// are inert to the remaining substitution steps.
125///
126/// A footnote in a section title is a real, document-order footnote, but its
127/// marker must be kept out of the section's reference text and auto-generated
128/// ID. Marking the marker in a single render (rather than re-rendering the
129/// title with footnotes suppressed) means stateful substitutions — counters,
130/// attribute references that expand into footnotes — run exactly once. See
131/// [`strip_footnote_marker_spans`] and
132/// [`Content::remove_footnote_marker_sentinels`].
133pub(crate) const FOOTNOTE_MARKER_START: char = '\u{E002}';
134pub(crate) const FOOTNOTE_MARKER_END: char = '\u{E003}';
135
136/// Removes each footnote marker span — a [`FOOTNOTE_MARKER_START`] …
137/// [`FOOTNOTE_MARKER_END`] region and everything between, i.e. the sentinels
138/// *and* the marker they bracket — leaving footnote-free text suitable for a
139/// section's reference text and auto-generated ID.
140pub(crate) fn strip_footnote_marker_spans(s: &str) -> String {
141 let mut out = String::with_capacity(s.len());
142 let mut rest = s;
143
144 while let Some(start) = rest.find(FOOTNOTE_MARKER_START) {
145 out.push_str(&rest[..start]);
146 rest = &rest[start + FOOTNOTE_MARKER_START.len_utf8()..];
147
148 // Drop through the matching end sentinel (the marker text). A start
149 // without an end cannot occur — the substitution always emits both — but
150 // if it somehow did, drop the remainder rather than reintroduce the
151 // stray sentinel.
152 rest = match rest.find(FOOTNOTE_MARKER_END) {
153 Some(end) => &rest[end + FOOTNOTE_MARKER_END.len_utf8()..],
154 None => "",
155 };
156 }
157
158 out.push_str(rest);
159 out
160}
161
162impl<'src> Content<'src> {
163 /// Constructs a `Content` from a source `Span` and a potentially-filtered
164 /// view of that source text.
165 pub(crate) fn from_filtered<T: AsRef<str>>(span: Span<'src>, filtered: T) -> Self {
166 Self {
167 original: span,
168 rendered: filtered.as_ref().to_string().into(),
169 source_lines: None,
170 deferred: None,
171 }
172 }
173
174 /// Constructs a `Content` from a source `Span` and the per-line filtered
175 /// view of that source, retaining the source `Span` of each surviving line.
176 ///
177 /// `line_spans` must contain one entry per line of `filtered_lines`, in the
178 /// same order; each entry is the source span whose text is that filtered
179 /// line (i.e. after any leading-indent stripping and trailing-whitespace
180 /// trimming the caller applied). The retained spans let the
181 /// attribute-references substitution report an `attribute-missing=warn`
182 /// warning at the precise source offset of the offending reference; see
183 /// [`apply_attributes`](crate::content::substitution_step).
184 pub(crate) fn from_filtered_lines(
185 span: Span<'src>,
186 filtered_lines: &[&str],
187 line_spans: Vec<Span<'src>>,
188 ) -> Self {
189 // One source span is required per filtered line; the default
190 // `debug_assert_eq!` message reports both counts if this is ever broken.
191 debug_assert_eq!(filtered_lines.len(), line_spans.len());
192
193 Self {
194 original: span,
195 rendered: filtered_lines.join("\n").into(),
196 source_lines: Some(line_spans.into_boxed_slice()),
197 deferred: None,
198 }
199 }
200
201 /// Returns the original span from which this [`Content`] was derived.
202 ///
203 /// This is the source text before any substitions have been applied.
204 pub fn original(&self) -> Span<'src> {
205 self.original
206 }
207
208 /// Returns the source `Span` of each line that survived construction
209 /// filtering, in rendered-line order, when they were retained (see
210 /// [`from_filtered_lines`](Self::from_filtered_lines)).
211 ///
212 /// Used only by the attribute-references substitution to locate
213 /// `attribute-missing=warn` warnings precisely.
214 pub(crate) fn source_lines(&self) -> Option<&[Span<'src>]> {
215 self.source_lines.as_deref()
216 }
217
218 /// Returns the final text after all substitutions have been applied.
219 pub fn rendered(&'src self) -> &'src str {
220 self.rendered.as_ref()
221 }
222
223 /// Returns an owned copy of the final text after all substitutions have
224 /// been applied.
225 ///
226 /// Unlike [`rendered()`](Self::rendered), this does not tie the returned
227 /// value to the `'src` lifetime, so it can be called on a short-lived
228 /// `Content` built solely to render a fragment (e.g. a block's attribution
229 /// or citation text).
230 pub(crate) fn rendered_owned(&self) -> String {
231 self.rendered.as_ref().to_string()
232 }
233
234 /// Returns `true` if `self` contains no text.
235 pub fn is_empty(&self) -> bool {
236 self.rendered.as_ref().is_empty()
237 }
238
239 /// Removes the [`FOOTNOTE_MARKER_START`]/[`FOOTNOTE_MARKER_END`] sentinels
240 /// bracketing each footnote marker, *keeping* the marker itself, so the
241 /// content renders normally. Called after a section title's reference text
242 /// and ID have been derived (via [`strip_footnote_marker_spans`], which
243 /// needs the sentinels to locate the markers). The sentinels are removed
244 /// from the deferred template too, so a later cross-reference resolution
245 /// rebuild does not reintroduce them.
246 pub(crate) fn remove_footnote_marker_sentinels(&mut self) {
247 if !self.rendered.as_ref().contains(FOOTNOTE_MARKER_START) {
248 return;
249 }
250
251 self.rendered = self
252 .rendered
253 .as_ref()
254 .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "")
255 .into();
256
257 if let Some(deferred) = self.deferred.as_mut() {
258 deferred.template = deferred
259 .template
260 .replace([FOOTNOTE_MARKER_START, FOOTNOTE_MARKER_END], "");
261 }
262 }
263
264 /// Returns `true` if this content contains one or more cross-references
265 /// that have not yet been resolved to a destination.
266 pub fn has_unresolved_refs(&self) -> bool {
267 self.deferred
268 .as_ref()
269 .is_some_and(|d| d.xrefs.iter().any(|x| x.resolved.is_none()))
270 }
271
272 /// Records the cross-references discovered for this content during the
273 /// macros substitution step. The placeholder tokens for these references
274 /// must already have been written into [`Content::rendered`], in the same
275 /// order as `xrefs`.
276 ///
277 /// This must be called at most once per `Content`: the placeholder indices
278 /// already embedded in [`Content::rendered`] are positions into this single
279 /// `xrefs` vector. The macros substitution runs once per content, so this
280 /// holds in practice; the assertion guards against a future caller breaking
281 /// it.
282 pub(crate) fn set_deferred_xrefs(&mut self, xrefs: Vec<XrefSegment>) {
283 if xrefs.is_empty() {
284 return;
285 }
286
287 debug_assert!(
288 self.deferred.is_none(),
289 "set_deferred_xrefs must be called at most once per Content"
290 );
291
292 self.deferred = Some(Box::new(DeferredContent {
293 template: String::new(),
294 xrefs,
295 }));
296 }
297
298 /// Returns the placeholder token for the cross-reference at `index`.
299 pub(crate) fn xref_placeholder(index: usize) -> String {
300 format!("{XREF_PLACEHOLDER_START}{index}{XREF_PLACEHOLDER_END}")
301 }
302
303 /// Finalizes any deferred cross-references at the end of substitution.
304 ///
305 /// At this point [`Content::rendered`] holds the placeholder-bearing text;
306 /// it is captured as the template and `rendered` is rebuilt as the
307 /// unresolved fallback so it is immediately clean for callers that read it
308 /// before resolution.
309 pub(crate) fn finalize_deferred(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
310 if self.deferred.is_none() {
311 return;
312 }
313
314 let template = self.rendered.as_ref().to_string();
315
316 if let Some(deferred) = self.deferred.as_mut() {
317 deferred.template = template;
318 }
319
320 self.rebuild_rendered(renderer);
321 }
322
323 /// Resolves any deferred cross-references using `resolver`, then rebuilds
324 /// the rendered text.
325 ///
326 /// This is non-destructive: the placeholder template is retained, so a
327 /// document may be resolved more than once (e.g. for incremental builds or
328 /// multiple output targets).
329 ///
330 /// Any target that the resolver cannot resolve is reported in `warnings`.
331 pub(crate) fn resolve_references(
332 &mut self,
333 resolver: &dyn ReferenceResolver,
334 renderer: &dyn InlineSubstitutionRenderer,
335 warnings: &mut Vec<ReferenceWarning>,
336 ) {
337 if let Some(deferred) = self.deferred.as_mut() {
338 let DeferredContent { template, xrefs } = deferred.as_mut();
339
340 // A `deferred` block always holds at least one xref placeholder, so
341 // its finalized template is never empty. An empty template here
342 // means `finalize_deferred` was skipped (a future-refactor hazard);
343 // the `template.contains` guard below would then silently suppress
344 // every unresolved-ref warning, so catch that invariant break in
345 // debug builds.
346 debug_assert!(!template.is_empty());
347
348 for (index, xref) in xrefs.iter_mut().enumerate() {
349 xref.resolved = resolver.resolve(&ResolutionContext {
350 target: &xref.target,
351 provided_text: xref.provided_text.as_deref(),
352 });
353
354 // A reference whose placeholder is no longer in the template was
355 // re-homed into a footnote (see `rehome_xref_placeholders`); the
356 // footnote resolves and reports it, so it is not reported here.
357 if xref.resolved.is_none() && template.contains(&Content::xref_placeholder(index)) {
358 warnings.push(ReferenceWarning {
359 target: xref.target.clone(),
360 kind: ReferenceWarningKind::Unresolved,
361 });
362 }
363 }
364 }
365
366 self.rebuild_rendered(renderer);
367 }
368
369 /// Rebuilds [`Content::rendered`] from the deferred template and the
370 /// current (resolved or unresolved) state of its cross-references.
371 fn rebuild_rendered(&mut self, renderer: &dyn InlineSubstitutionRenderer) {
372 let Some(deferred) = self.deferred.as_ref() else {
373 return;
374 };
375
376 self.rendered = render_template(&deferred.template, &deferred.xrefs, renderer).into();
377 }
378}
379
380/// Re-homes the cross-reference placeholders found in `text` into a
381/// self-contained (template, xrefs) pair.
382///
383/// When the cross-reference substitution runs before footnotes, a footnote's
384/// text may carry placeholder tokens whose [`XrefSegment`]s live in the
385/// enclosing block's cross-reference list (`all`). Because a footnote's text is
386/// extracted out of the block, it needs its own copy of just those segments,
387/// renumbered so its template is independent. This scans `text` for placeholder
388/// tokens, clones the referenced segments into a fresh vector (in first-seen
389/// order), and rewrites the tokens to the new local indices.
390///
391/// Text with no placeholders returns unchanged alongside an empty vector.
392pub(crate) fn rehome_xref_placeholders(
393 text: &str,
394 all: &[XrefSegment],
395) -> (String, Vec<XrefSegment>) {
396 let mut local: Vec<XrefSegment> = vec![];
397
398 if !text.contains(XREF_PLACEHOLDER_START) {
399 return (text.to_string(), local);
400 }
401
402 let mut out = String::with_capacity(text.len());
403 let mut rest = text;
404
405 while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
406 out.push_str(&rest[..start]);
407 let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
408
409 let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
410 out.push(XREF_PLACEHOLDER_START);
411 rest = after;
412 continue;
413 };
414
415 let body = &after[..end];
416 rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
417
418 match body.parse::<usize>().ok().and_then(|index| all.get(index)) {
419 Some(segment) => {
420 let local_index = local.len();
421 local.push(segment.clone());
422 out.push_str(&Content::xref_placeholder(local_index));
423 }
424
425 None => {
426 out.push(XREF_PLACEHOLDER_START);
427 out.push_str(body);
428 out.push(XREF_PLACEHOLDER_END);
429 }
430 }
431 }
432
433 out.push_str(rest);
434 (out, local)
435}
436
437/// Splices resolved (or fallback) cross-reference renderings into a placeholder
438/// template, producing the final rendered text.
439fn render_template(
440 template: &str,
441 xrefs: &[XrefSegment],
442 renderer: &dyn InlineSubstitutionRenderer,
443) -> String {
444 let mut out = String::with_capacity(template.len());
445 let mut rest = template;
446
447 while let Some(start) = rest.find(XREF_PLACEHOLDER_START) {
448 out.push_str(&rest[..start]);
449 let after = &rest[start + XREF_PLACEHOLDER_START.len_utf8()..];
450
451 let Some(end) = after.find(XREF_PLACEHOLDER_END) else {
452 // Malformed placeholder; emit the sentinel literally and continue.
453 out.push(XREF_PLACEHOLDER_START);
454 rest = after;
455 continue;
456 };
457
458 let body = &after[..end];
459 rest = &after[end + XREF_PLACEHOLDER_END.len_utf8()..];
460
461 match body
462 .parse::<usize>()
463 .ok()
464 .and_then(|index| xrefs.get(index))
465 {
466 Some(xref) => {
467 renderer.render_xref(
468 &XrefRenderParams {
469 target: &xref.target,
470 provided_text: xref.provided_text.as_deref(),
471 window: xref.window.as_deref(),
472 roles: &xref.roles,
473 xrefstyle: xref.xrefstyle,
474 resolved: xref.resolved.as_ref(),
475 },
476 &mut out,
477 );
478 }
479
480 None => {
481 // Unreachable while `template` and `xrefs` come from the same
482 // `Content` (indices are assigned sequentially). If that
483 // invariant is ever broken, emit the raw placeholder rather than
484 // silently dropping the span, so the breakage is visible.
485 debug_assert!(false, "xref placeholder index {body:?} out of range");
486 out.push(XREF_PLACEHOLDER_START);
487 out.push_str(body);
488 out.push(XREF_PLACEHOLDER_END);
489 }
490 }
491 }
492
493 out.push_str(rest);
494 out
495}
496
497/// The deferred cross-references carried by a footnote's text.
498///
499/// A footnote's text is extracted out of the flow of the block during the
500/// macros substitution step, so any cross-reference (`<<id>>`, `xref:id[…]`)
501/// inside it cannot be resolved by the document-level pass that resolves
502/// references in block content. Instead, the footnote captures its
503/// cross-references here — as a placeholder template plus the references in
504/// placeholder order — and they are resolved alongside the block references
505/// (see [`Footnote::resolve_references`]).
506///
507/// [`Footnote::resolve_references`]: crate::document::Footnote::resolve_references
508#[derive(Clone, Eq, PartialEq)]
509pub(crate) struct FootnoteDeferred {
510 /// The footnote text with opaque placeholder tokens marking where each
511 /// cross-reference will be spliced in.
512 template: String,
513
514 /// The cross-references, in placeholder order.
515 xrefs: Vec<XrefSegment>,
516}
517
518impl FootnoteDeferred {
519 /// Constructs a footnote's deferred cross-reference state from the
520 /// placeholder-bearing `template` and its `xrefs` (in placeholder order).
521 pub(crate) fn new(template: String, xrefs: Vec<XrefSegment>) -> Self {
522 Self { template, xrefs }
523 }
524
525 /// Renders the footnote text from the template and the current (resolved or
526 /// unresolved) state of its cross-references.
527 pub(crate) fn render(&self, renderer: &dyn InlineSubstitutionRenderer) -> String {
528 render_template(&self.template, &self.xrefs, renderer)
529 }
530
531 /// Resolves the footnote's cross-references using `resolver`, reporting any
532 /// unresolved target in `warnings`. Rendering the resolved text is left to
533 /// the caller (via [`render`](Self::render)).
534 pub(crate) fn resolve(
535 &mut self,
536 resolver: &dyn ReferenceResolver,
537 warnings: &mut Vec<ReferenceWarning>,
538 ) {
539 for xref in self.xrefs.iter_mut() {
540 xref.resolved = resolver.resolve(&ResolutionContext {
541 target: &xref.target,
542 provided_text: xref.provided_text.as_deref(),
543 });
544
545 if xref.resolved.is_none() {
546 warnings.push(ReferenceWarning {
547 target: xref.target.clone(),
548 kind: ReferenceWarningKind::Unresolved,
549 });
550 }
551 }
552 }
553}
554
555impl std::fmt::Debug for FootnoteDeferred {
556 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
557 f.debug_struct("FootnoteDeferred")
558 .field("template", &self.template)
559 .field("xrefs", &self.xrefs)
560 .finish()
561 }
562}
563
564impl<'src> From<Span<'src>> for Content<'src> {
565 fn from(span: Span<'src>) -> Self {
566 Self {
567 original: span,
568 rendered: CowStr::from(span.data()),
569 source_lines: None,
570 deferred: None,
571 }
572 }
573}
574
575impl std::fmt::Debug for Content<'_> {
576 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577 // The deferred cross-reference state is an internal implementation
578 // detail. It is omitted from the debug output unless present, so that
579 // the (very common) cross-reference-free content debugs identically to
580 // a plain `original` + `rendered` pair.
581 let mut s = f.debug_struct("Content");
582 s.field("original", &self.original);
583 s.field("rendered", &self.rendered);
584
585 if let Some(deferred) = self.deferred.as_ref() {
586 s.field("deferred", deferred);
587 }
588
589 s.finish()
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 #![allow(clippy::unwrap_used)]
596
597 mod is_empty {
598 #[test]
599 fn basic_empty_span() {
600 let content = crate::content::Content::from(crate::Span::default());
601 assert!(content.is_empty());
602 }
603
604 #[test]
605 fn basic_non_empty_span() {
606 let content = crate::content::Content::from(crate::Span::new("blah"));
607 assert!(!content.is_empty());
608 }
609 }
610
611 mod strip_footnote_marker_spans {
612 use super::super::{
613 FOOTNOTE_MARKER_END, FOOTNOTE_MARKER_START, strip_footnote_marker_spans,
614 };
615
616 fn marked(marker: &str) -> String {
617 format!("{FOOTNOTE_MARKER_START}{marker}{FOOTNOTE_MARKER_END}")
618 }
619
620 #[test]
621 fn leaves_unmarked_text_unchanged() {
622 assert_eq!(strip_footnote_marker_spans("Plain title"), "Plain title");
623 }
624
625 #[test]
626 fn removes_a_marker_span_and_its_sentinels() {
627 let input = format!("Title{}", marked("[1]"));
628 assert_eq!(strip_footnote_marker_spans(&input), "Title");
629 }
630
631 #[test]
632 fn removes_multiple_spans_keeping_surrounding_text() {
633 let input = format!("a{}b{}c", marked("[1]"), marked("[2]"));
634 assert_eq!(strip_footnote_marker_spans(&input), "abc");
635 }
636
637 #[test]
638 fn a_start_without_an_end_drops_the_remainder() {
639 // Defensive: the substitution always emits balanced sentinels, but a
640 // lone start must not leak the sentinel into the output.
641 let input = format!("Title{FOOTNOTE_MARKER_START}dangling");
642 assert_eq!(strip_footnote_marker_spans(&input), "Title");
643 }
644 }
645
646 mod footnote_deferred {
647 use super::super::{
648 FootnoteDeferred, XREF_PLACEHOLDER_END, XREF_PLACEHOLDER_START, XrefSegment,
649 rehome_xref_placeholders,
650 };
651
652 fn segment(target: &str) -> XrefSegment {
653 XrefSegment {
654 target: target.to_string(),
655 provided_text: None,
656 window: None,
657 roles: vec![],
658 xrefstyle: None,
659 resolved: None,
660 }
661 }
662
663 #[test]
664 fn rehomes_a_placeholder_into_a_local_segment() {
665 let all = vec![segment("a"), segment("b")];
666 // Reference only the second segment; it becomes local index 0.
667 let text = format!("see {XREF_PLACEHOLDER_START}1{XREF_PLACEHOLDER_END} here");
668
669 let (template, local) = rehome_xref_placeholders(&text, &all);
670
671 assert_eq!(local.len(), 1);
672 assert_eq!(local.first().unwrap().target, "b");
673 assert_eq!(
674 template,
675 format!("see {XREF_PLACEHOLDER_START}0{XREF_PLACEHOLDER_END} here")
676 );
677 }
678
679 #[test]
680 fn text_without_placeholders_is_returned_unchanged() {
681 let (template, local) = rehome_xref_placeholders("plain text", &[segment("a")]);
682 assert_eq!(template, "plain text");
683 assert!(local.is_empty());
684 }
685
686 #[test]
687 fn malformed_placeholders_are_passed_through_literally() {
688 // A non-numeric index and an unterminated placeholder are both left
689 // as-is (these cannot arise in practice, but the fallback is exercised).
690 let bad_index = format!("a{XREF_PLACEHOLDER_START}xyz{XREF_PLACEHOLDER_END}b");
691 let (template, local) = rehome_xref_placeholders(&bad_index, &[]);
692 assert_eq!(template, bad_index);
693 assert!(local.is_empty());
694
695 let unterminated = format!("a{XREF_PLACEHOLDER_START}0 no end");
696 let (template, local) = rehome_xref_placeholders(&unterminated, &[]);
697 assert_eq!(template, unterminated);
698 assert!(local.is_empty());
699 }
700
701 #[test]
702 fn out_of_range_placeholder_index_is_passed_through() {
703 // An index with no matching segment in `all` is left literal.
704 let text = format!("x{XREF_PLACEHOLDER_START}9{XREF_PLACEHOLDER_END}y");
705 let (template, local) = rehome_xref_placeholders(&text, &[segment("a")]);
706 assert_eq!(template, text);
707 assert!(local.is_empty());
708 }
709
710 #[test]
711 fn debug_includes_template_and_xrefs() {
712 let deferred = FootnoteDeferred::new("t".to_string(), vec![segment("a")]);
713 let rendered = format!("{deferred:?}");
714 assert!(rendered.contains("FootnoteDeferred"));
715 assert!(rendered.contains("template"));
716 }
717 }
718}