asciidoc_parser/document/catalog.rs
1use std::collections::HashMap;
2
3use crate::{content::FootnoteDeferred, internal::debug::DebugHashMapFrom, parser::XrefSignifier};
4
5/// Document catalog for tracking referenceable elements.
6///
7/// The catalog maintains a registry of all elements that can be referenced
8/// via cross-references, including anchors, sections, and bibliography entries.
9/// It provides functionality for registering new references, resolving
10/// reference text to IDs, and detecting duplicate IDs.
11#[derive(Clone, Eq, PartialEq)]
12pub struct Catalog {
13 /// Primary registry mapping IDs to reference entries.
14 pub(crate) refs: HashMap<String, RefEntry>,
15
16 /// Reverse lookup cache: reftext -> ID.
17 pub(crate) reftext_to_id: HashMap<String, String>,
18
19 /// Footnotes registered (in document order) while substituting inline
20 /// macros. Each entry corresponds to a `footnote:[…]` macro that *defined*
21 /// a footnote; subsequent references to an existing footnote (via a
22 /// repeated ID) reuse an entry rather than adding a new one.
23 ///
24 /// A nested document (an AsciiDoc table cell) keeps its own footnote list:
25 /// footnotes defined inside a cell are *not* shared with the main document.
26 pub(crate) footnotes: Vec<Footnote>,
27
28 /// Images referenced by `image:`/`image::` macros, recorded in document
29 /// order while substituting inline macros – but only when the parser was
30 /// configured with
31 /// [`with_catalog_assets(true)`](crate::Parser::with_catalog_assets)
32 /// (Asciidoctor's `catalog_assets` API option). Empty otherwise.
33 pub(crate) images: Vec<ImageReference>,
34
35 /// Link targets referenced by `link:`/`mailto:` macros and by bare URL and
36 /// email autolinks, recorded in document order while substituting inline
37 /// macros – but only when the parser was configured with
38 /// [`with_catalog_assets(true)`](crate::Parser::with_catalog_assets)
39 /// (Asciidoctor's `catalog_assets` API option). Empty otherwise.
40 ///
41 /// Each entry is the final link target as it appears in the rendered `href`
42 /// (e.g. `https://example.org`, `mailto:fred@example.com`), matching
43 /// Asciidoctor's `catalog[:links]`.
44 pub(crate) links: Vec<String>,
45
46 /// AsciiDoc files that were included into this document, keyed by the
47 /// include target relative to the outermost document with its AsciiDoc
48 /// extension removed (e.g. `other-chapters` for
49 /// `include::other-chapters.adoc[]`). The value records whether the file
50 /// was ever included *in full*: `true` when at least one include merged the
51 /// whole file, `false` when every include of it selected only a
52 /// `lines`/`tag(s)` portion.
53 ///
54 /// The preprocessor records each include while it expands `include::`
55 /// directives (before parsing); `Parser::parse_deferred` folds those into
56 /// this map via [`register_include`](Self::register_include), and it
57 /// survives into the document's catalog. It lets an
58 /// inter-document cross reference whose target names an included file
59 /// collapse to a same-document reference — the target's anchors are now
60 /// part of *this* document — but only when the file was included in
61 /// full, since a partial include may not have carried the referenced
62 /// anchor across. See [`interpret_xref_target`](crate::content).
63 pub(crate) includes: HashMap<String, bool>,
64}
65
66impl Default for Catalog {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72impl Catalog {
73 pub(crate) fn new() -> Self {
74 Self {
75 refs: HashMap::new(),
76 reftext_to_id: HashMap::new(),
77 footnotes: Vec::new(),
78 images: Vec::new(),
79 links: Vec::new(),
80 includes: HashMap::new(),
81 }
82 }
83
84 /// Register a new referenceable element in the catalog.
85 ///
86 /// # Arguments
87 /// * `id` - The unique identifier for the element
88 /// * `reftext` - Optional reference text for the element
89 /// * `ref_type` - Type of referenceable element
90 ///
91 /// # Returns
92 /// * `Ok(())` if the element was successfully registered
93 /// * `Err(DuplicateIdError)` if the ID is already in use
94 pub(crate) fn register_ref(
95 &mut self,
96 id: &str,
97 reftext: Option<&str>,
98 ref_type: RefType,
99 ) -> Result<(), DuplicateIdError> {
100 if self.refs.contains_key(id) {
101 return Err(DuplicateIdError(id.to_string()));
102 }
103
104 let entry = RefEntry {
105 id: id.to_string(),
106 reftext: reftext.map(|s| s.to_owned()),
107 ref_type,
108 signifier: None,
109 };
110
111 self.refs.insert(id.to_string(), entry);
112
113 if let Some(reftext) = reftext {
114 self.reftext_to_id
115 .entry(reftext.to_string())
116 .or_insert_with(|| id.to_string());
117 }
118
119 Ok(())
120 }
121
122 /// Generate a unique ID based on a base ID and register it in the catalog.
123 ///
124 /// If the base ID is not in use, it is returned as-is. Otherwise, numeric
125 /// suffixes are appended until a unique ID is found. The generated ID is
126 /// then registered in the catalog with the provided parameters.
127 ///
128 /// # Arguments
129 /// * `base_id` - The base identifier to use
130 /// * `reftext` - Optional reference text for the element
131 /// * `ref_type` - Type of referenceable element
132 ///
133 /// # Returns
134 /// The unique ID that was generated and registered.
135 pub(crate) fn generate_and_register_unique_id(
136 &mut self,
137 base_id: &str,
138 reftext: Option<&str>,
139 ref_type: RefType,
140 separator: &str,
141 ) -> String {
142 let unique_id = if !self.contains_id(base_id) {
143 base_id.to_string()
144 } else {
145 let mut counter = 2;
146 loop {
147 let candidate = format!("{base_id}{separator}{counter}");
148 if !self.contains_id(&candidate) {
149 break candidate;
150 }
151 counter += 1;
152 }
153 };
154
155 // Register the generated unique ID.
156 let entry = RefEntry {
157 id: unique_id.clone(),
158 reftext: reftext.map(|s| s.to_owned()),
159 ref_type,
160 signifier: None,
161 };
162
163 self.refs.insert(unique_id.clone(), entry);
164
165 if let Some(reftext) = reftext {
166 self.reftext_to_id
167 .entry(reftext.to_string())
168 .or_insert_with(|| unique_id.clone());
169 }
170
171 unique_id
172 }
173
174 /// Returns a reference entry by ID, if it exists.
175 pub fn get_ref(&self, id: &str) -> Option<&RefEntry> {
176 self.refs.get(id)
177 }
178
179 /// Returns `true` if an ID is already registered in the catalog.
180 pub fn contains_id(&self, id: &str) -> bool {
181 self.refs.contains_key(id)
182 }
183
184 /// Resolve reference text to an ID, if possible.
185 pub fn resolve_id(&self, reftext: &str) -> Option<String> {
186 self.reftext_to_id.get(reftext).cloned()
187 }
188
189 /// Attaches an [`XrefSignifier`] to an already-registered element, so a
190 /// cross-reference to it can build `full`/`short`
191 /// [`xrefstyle`](crate::parser::XrefStyle) text. A no-op if `id` is not
192 /// registered.
193 pub(crate) fn set_signifier(&mut self, id: &str, signifier: XrefSignifier) {
194 if let Some(entry) = self.refs.get_mut(id) {
195 entry.signifier = Some(signifier);
196 }
197 }
198
199 /// Returns an iterator over all registered reference IDs, in an
200 /// unspecified order.
201 ///
202 /// This lets a multi-document pipeline enumerate a document's anchors and
203 /// section IDs (for example, to build a global cross-reference index)
204 /// without re-walking the block tree.
205 pub fn ids(&self) -> impl Iterator<Item = &str> {
206 self.refs.keys().map(String::as_str)
207 }
208
209 /// Returns an iterator over all registered reference entries, in an
210 /// unspecified order.
211 ///
212 /// Each item pairs an ID with its [`RefEntry`] (which also carries the
213 /// entry's reftext and [`RefType`]).
214 pub fn entries(&self) -> impl Iterator<Item = (&str, &RefEntry)> {
215 self.refs.iter().map(|(id, entry)| (id.as_str(), entry))
216 }
217
218 /// Returns the number of registered references.
219 pub fn len(&self) -> usize {
220 self.refs.len()
221 }
222
223 /// Returns `true` if the catalog contains no registered references.
224 pub fn is_empty(&self) -> bool {
225 self.refs.is_empty()
226 }
227
228 /// Returns the footnotes registered in this document, in document order.
229 pub fn footnotes(&self) -> &[Footnote] {
230 &self.footnotes
231 }
232
233 /// Registers a newly-defined [`Footnote`].
234 pub(crate) fn register_footnote(&mut self, footnote: Footnote) {
235 self.footnotes.push(footnote);
236 }
237
238 /// Returns the registered footnote with the given ID, if one exists.
239 pub(crate) fn footnote_with_id(&self, id: &str) -> Option<&Footnote> {
240 self.footnotes.iter().find(|f| f.id.as_deref() == Some(id))
241 }
242
243 /// Returns the images referenced in this document, in document order.
244 ///
245 /// This list is populated only when the parser was configured with
246 /// [`with_catalog_assets(true)`](crate::Parser::with_catalog_assets); it is
247 /// empty otherwise.
248 pub fn images(&self) -> &[ImageReference] {
249 &self.images
250 }
251
252 /// Records a referenced image (an `image:`/`image::` macro target) in
253 /// document order.
254 pub(crate) fn register_image(&mut self, target: String, imagesdir: Option<String>) {
255 self.images.push(ImageReference { target, imagesdir });
256 }
257
258 /// Returns the link targets referenced in this document, in document order.
259 ///
260 /// This list is populated only when the parser was configured with
261 /// [`with_catalog_assets(true)`](crate::Parser::with_catalog_assets); it is
262 /// empty otherwise.
263 pub fn links(&self) -> &[String] {
264 &self.links
265 }
266
267 /// Records a referenced link target (a `link:`/`mailto:` macro or an
268 /// autolinked bare URL or email address) in document order.
269 pub(crate) fn register_link(&mut self, target: String) {
270 self.links.push(target);
271 }
272
273 /// Records that the AsciiDoc file named by `key` was included into this
274 /// document.
275 ///
276 /// `key` is the include target relative to the outermost document, with its
277 /// AsciiDoc extension removed (e.g. `other-chapters`). `full` is `true`
278 /// when the entire file was included and `false` when only a
279 /// `lines`/`tag(s)` selection of it was.
280 ///
281 /// A file included in full at least once is recorded as full even if it was
282 /// also included partially (a full include always carries every anchor
283 /// across), matching Asciidoctor.
284 pub(crate) fn register_include(&mut self, key: &str, full: bool) {
285 self.includes
286 .entry(key.to_string())
287 .and_modify(|existing| *existing |= full)
288 .or_insert(full);
289 }
290
291 /// Returns `true` if the file named by `key` (an include target relative to
292 /// the outermost document, without its AsciiDoc extension) was included
293 /// into this document *in full* — i.e. at least one `include::`
294 /// directive merged the whole file, rather than only a `lines`/`tag(s)`
295 /// portion of it.
296 ///
297 /// Returns `false` if the file was only ever partially included, or was not
298 /// included at all.
299 pub fn include_is_full(&self, key: &str) -> bool {
300 self.includes.get(key).copied().unwrap_or(false)
301 }
302
303 /// Returns `true` if the file named by `key` (an include target relative to
304 /// the outermost document, without its AsciiDoc extension) was included
305 /// into this document, whether in full or only partially.
306 pub fn was_included(&self, key: &str) -> bool {
307 self.includes.contains_key(key)
308 }
309
310 /// Removes and returns the current footnote list, leaving an empty list
311 /// behind. Used to give a nested document (an AsciiDoc table cell) its own
312 /// footnote registry so its footnotes are not shared with the enclosing
313 /// document.
314 pub(crate) fn take_footnotes(&mut self) -> Vec<Footnote> {
315 std::mem::take(&mut self.footnotes)
316 }
317
318 /// Restores a previously-[taken](Self::take_footnotes) footnote list,
319 /// discarding any footnotes registered in the meantime.
320 pub(crate) fn restore_footnotes(&mut self, footnotes: Vec<Footnote>) {
321 self.footnotes = footnotes;
322 }
323}
324
325/// A footnote registered while substituting the inline `footnote:[…]` macro.
326///
327/// A footnote is defined at the location of its reference, but its text is
328/// extracted to an item in the document's footnote list. The same footnote can
329/// be referenced from multiple locations by assigning it an ID at the first
330/// occurrence and repeating that ID (with empty text) afterward; only the
331/// defining occurrence produces a `Footnote` entry.
332#[derive(Clone, Eq, PartialEq)]
333pub struct Footnote {
334 /// The footnote's number, assigned in document order via the
335 /// `footnote-number` counter. Normally a consecutive integer (`1`, `2`, …),
336 /// but stored as a string because the counter honors any seed the document
337 /// sets (e.g. `:footnote-number: z` yields `aa`, `ab`, … as Asciidoctor
338 /// does).
339 pub index: String,
340
341 /// The optional ID assigned to this footnote (the target of the macro, e.g.
342 /// `disclaimer` in `footnote:disclaimer[…]`). `None` for an anonymous
343 /// footnote.
344 pub id: Option<String>,
345
346 /// The already-substituted text of the footnote. When the footnote contains
347 /// cross-references, this reflects the unresolved fallback rendering until
348 /// the document's references are resolved, after which it reflects the
349 /// resolved links; it is always clean, user-facing text.
350 pub text: String,
351
352 /// Deferred cross-references discovered in the footnote text, awaiting
353 /// resolution. `None` for the common case of a footnote with no
354 /// cross-references.
355 pub(crate) deferred: Option<Box<FootnoteDeferred>>,
356
357 /// The location of this footnote's defining occurrence, as a
358 /// `(byte offset, byte length)` pair into the document source, used to
359 /// anchor a cross-reference warning at the footnote rather than at the
360 /// whole document. The range spans the enclosing content the footnote was
361 /// written in (paragraph granularity, matching how a non-footnote
362 /// reference is anchored at its `Content`).
363 ///
364 /// `None` when the defining occurrence is not locatable in the document
365 /// source: a footnote defined while substituting a privately-owned
366 /// sub-source (a Markdown-style blockquote, an AsciiDoc table cell) indexes
367 /// that owned source, which is not contiguous in the document, so storing
368 /// its offset would misplace the warning. Resolution falls back to the
369 /// whole-document span in that case.
370 pub(crate) location: Option<(usize, usize)>,
371}
372
373impl Footnote {
374 /// Resolves any cross-references embedded in this footnote's text using
375 /// `resolver`, then rebuilds [`text`](Self::text) from the resolved state.
376 /// Any unresolved target is reported in `warnings`.
377 ///
378 /// A footnote's text is extracted out of the block it was defined in, so
379 /// the warning is anchored using the footnote's recorded
380 /// [`location`](Self::location) — the enclosing content it was written in —
381 /// reconstructed as a sub-span of `document_source`. When no location was
382 /// recorded (a footnote defined inside an owned sub-source, whose offset
383 /// does not map to the document), the warning falls back to the whole
384 /// `document_source` span.
385 ///
386 /// A footnote with no cross-references is left untouched.
387 pub(crate) fn resolve_references<'src>(
388 &mut self,
389 resolver: &dyn crate::parser::ReferenceResolver,
390 renderer: &dyn crate::parser::InlineSubstitutionRenderer,
391 warnings: &mut crate::parser::ReferenceWarnings<'src>,
392 document_source: crate::Span<'src>,
393 ) {
394 if let Some(deferred) = self.deferred.as_mut() {
395 let source = match self.location {
396 Some((offset, len)) => document_source.slice(offset..offset + len),
397 None => document_source,
398 };
399 deferred.resolve(resolver, warnings, source);
400 self.text = deferred.render(renderer);
401 }
402 }
403}
404
405impl std::fmt::Debug for Footnote {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 // The deferred cross-reference state is an internal implementation
408 // detail, omitted unless present so that the (very common)
409 // cross-reference-free footnote debugs as a plain field set.
410 let mut s = f.debug_struct("Footnote");
411 s.field("index", &self.index);
412 s.field("id", &self.id);
413 s.field("text", &self.text);
414
415 if let Some(deferred) = self.deferred.as_ref() {
416 s.field("deferred", deferred);
417 }
418
419 s.finish()
420 }
421}
422
423impl std::fmt::Debug for Catalog {
424 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
425 f.debug_struct("Catalog")
426 .field("refs", &DebugHashMapFrom(&self.refs))
427 .field("reftext_to_id", &DebugHashMapFrom(&self.reftext_to_id))
428 .field("footnotes", &self.footnotes)
429 .field("images", &self.images)
430 .field("links", &self.links)
431 .field("includes", &DebugHashMapFrom(&self.includes))
432 .finish()
433 }
434}
435
436/// A reference to an image asset recorded in the document
437/// [`Catalog`](Catalog::images) when `catalog_assets` is enabled.
438///
439/// Mirrors Asciidoctor's `Document::ImageReference`: it pairs the image
440/// [`target`](Self::target) with the value of the `imagesdir` attribute in
441/// effect where the image was referenced.
442#[derive(Clone, Debug, Eq, PartialEq)]
443pub struct ImageReference {
444 /// The image target as written in the macro, after attribute references in
445 /// the target have been substituted (e.g. `fixtures/dot.gif`).
446 pub target: String,
447
448 /// The value of the `imagesdir` document attribute at the point of
449 /// reference, or `None` when it was unset.
450 pub imagesdir: Option<String>,
451}
452
453impl std::fmt::Display for ImageReference {
454 /// Displays the image reference as its [`target`](Self::target), mirroring
455 /// Asciidoctor's `ImageReference#to_s`.
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 f.write_str(&self.target)
458 }
459}
460
461/// Type of referenceable element in the document.
462#[derive(Clone, PartialEq, Eq)]
463pub enum RefType {
464 /// Standard anchor element (`[[id]]` or `[[id,reftext]]`).
465 Anchor,
466
467 /// Section heading that can be referenced.
468 Section,
469
470 /// Bibliography reference (`[[[id]]]` or `[[[id,reftext]]]`).
471 Bibliography,
472}
473
474impl std::fmt::Debug for RefType {
475 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476 match self {
477 Self::Anchor => f.write_str("RefType::Anchor"),
478 Self::Section => f.write_str("RefType::Section"),
479 Self::Bibliography => f.write_str("RefType::Bibliography"),
480 }
481 }
482}
483
484/// Entry in the document catalog representing a referenceable element.
485#[derive(Clone, Debug, Eq, PartialEq)]
486pub struct RefEntry {
487 /// The unique identifier for this element.
488 pub id: String,
489
490 /// Reference text for this element (explicit or computed).
491 pub reftext: Option<String>,
492
493 /// Type of referenceable element.
494 pub ref_type: RefType,
495
496 /// The signifier and number used to build `full`/`short`
497 /// [`xrefstyle`](crate::parser::XrefStyle) cross-reference text for this
498 /// target. Present only for a numbered section or captioned block that has
499 /// no explicit reftext; `None` for every other element (plain anchors,
500 /// bibliography entries, unnumbered sections, and targets carrying an
501 /// explicit reftext, for which `xrefstyle` formatting does not apply).
502 pub signifier: Option<XrefSignifier>,
503}
504
505/// Error that occurs when attempting to register a duplicate ID.
506#[derive(Clone, Debug, Eq, PartialEq)]
507pub(crate) struct DuplicateIdError(pub(crate) String);
508
509impl std::fmt::Display for DuplicateIdError {
510 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511 write!(f, "ID '{}' already registered", self.0)
512 }
513}
514
515impl std::error::Error for DuplicateIdError {}
516
517#[cfg(test)]
518mod tests {
519 #![allow(clippy::indexing_slicing, clippy::unwrap_used)]
520
521 use super::*;
522
523 #[test]
524 fn new_catalog_is_empty() {
525 let catalog = Catalog::new();
526 assert!(catalog.is_empty());
527 assert_eq!(catalog.len(), 0);
528 }
529
530 #[test]
531 fn register_ref_success() {
532 let mut catalog = Catalog::new();
533
534 let result = catalog.register_ref("test-id", Some("Test Reference"), RefType::Anchor);
535
536 assert!(result.is_ok());
537 assert_eq!(catalog.len(), 1);
538 assert!(catalog.contains_id("test-id"));
539 }
540
541 #[test]
542 fn register_duplicate_id_fails() {
543 let mut catalog = Catalog::new();
544
545 // Register first reference.
546 catalog
547 .register_ref("test-id", Some("First"), RefType::Anchor)
548 .unwrap();
549
550 // Attempt to register duplicate.
551 let result = catalog.register_ref("test-id", Some("Second"), RefType::Section);
552
553 let error = result.unwrap_err();
554 assert_eq!(error.0, "test-id");
555 }
556
557 #[test]
558 fn generate_and_register_unique_id() {
559 let mut catalog = Catalog::new();
560
561 // Test with available ID.
562 let id1 = catalog.generate_and_register_unique_id(
563 "available",
564 Some("Available Ref"),
565 RefType::Anchor,
566 "-",
567 );
568 assert_eq!(id1, "available");
569 assert!(catalog.contains_id("available"));
570 assert_eq!(
571 catalog.resolve_id("Available Ref"),
572 Some("available".to_string())
573 );
574
575 // Test with taken IDs.
576 catalog
577 .register_ref("taken", None, RefType::Anchor)
578 .unwrap();
579 catalog
580 .register_ref("taken-2", None, RefType::Anchor)
581 .unwrap();
582
583 let id2 = catalog.generate_and_register_unique_id("taken", None, RefType::Section, "-");
584 assert_eq!(id2, "taken-3");
585 assert!(catalog.contains_id("taken-3"));
586 }
587
588 #[test]
589 fn get_ref() {
590 let mut catalog = Catalog::new();
591
592 catalog
593 .register_ref("test-id", Some("Test Reference"), RefType::Bibliography)
594 .unwrap();
595
596 let entry = catalog.get_ref("test-id").unwrap();
597 assert_eq!(entry.id, "test-id");
598 assert_eq!(entry.reftext, Some("Test Reference".to_string()));
599 assert_eq!(entry.ref_type, RefType::Bibliography);
600
601 assert!(catalog.get_ref("nonexistent").is_none());
602 }
603
604 #[test]
605 fn enumerate_ids_and_entries() {
606 let mut catalog = Catalog::new();
607
608 catalog
609 .register_ref("intro", Some("Introduction"), RefType::Section)
610 .unwrap();
611 catalog
612 .register_ref("fig-1", None, RefType::Anchor)
613 .unwrap();
614
615 // `ids()` enumerates every registered ID (order is unspecified).
616 let mut ids: Vec<&str> = catalog.ids().collect();
617 ids.sort_unstable();
618 assert_eq!(ids, vec!["fig-1", "intro"]);
619
620 // `entries()` pairs each ID with its full entry.
621 let entries: Vec<(&str, &RefEntry)> = catalog.entries().collect();
622 assert_eq!(entries.len(), 2);
623
624 let (fig_id, fig_entry) = entries.iter().find(|(id, _)| *id == "fig-1").unwrap();
625 assert_eq!(*fig_id, "fig-1");
626 assert_eq!(fig_entry.id, "fig-1");
627 assert_eq!(fig_entry.reftext, None);
628 assert_eq!(fig_entry.ref_type, RefType::Anchor);
629
630 let (_, intro_entry) = entries.iter().find(|(id, _)| *id == "intro").unwrap();
631 assert_eq!(intro_entry.reftext, Some("Introduction".to_string()));
632 assert_eq!(intro_entry.ref_type, RefType::Section);
633 }
634
635 #[test]
636 fn resolve_id() {
637 let mut catalog = Catalog::new();
638
639 catalog
640 .register_ref("anchor1", Some("Reference Text"), RefType::Anchor)
641 .unwrap();
642
643 catalog
644 .register_ref("anchor2", Some("Another Reference"), RefType::Section)
645 .unwrap();
646
647 assert_eq!(
648 catalog.resolve_id("Reference Text"),
649 Some("anchor1".to_string())
650 );
651 assert_eq!(
652 catalog.resolve_id("Another Reference"),
653 Some("anchor2".to_string())
654 );
655 assert_eq!(catalog.resolve_id("Nonexistent"), None);
656 }
657
658 #[test]
659 fn resolve_id_first_wins_on_duplicates() {
660 let mut catalog = Catalog::new();
661
662 // Register two different IDs with same reftext.
663 catalog
664 .register_ref("first", Some("Same Text"), RefType::Anchor)
665 .unwrap();
666
667 catalog
668 .register_ref("second", Some("Same Text"), RefType::Section)
669 .unwrap();
670
671 assert_eq!(catalog.resolve_id("Same Text"), Some("first".to_string()));
672 }
673
674 #[test]
675 fn register_include_records_full_and_partial() {
676 let mut catalog = Catalog::new();
677
678 // An unregistered file is neither included nor full.
679 assert!(!catalog.was_included("tigers"));
680 assert!(!catalog.include_is_full("tigers"));
681
682 catalog.register_include("tigers", false);
683 assert!(catalog.was_included("tigers"));
684 assert!(!catalog.include_is_full("tigers"));
685
686 catalog.register_include("lions", true);
687 assert!(catalog.was_included("lions"));
688 assert!(catalog.include_is_full("lions"));
689 }
690
691 #[test]
692 fn a_full_include_wins_over_a_partial_one_in_either_order() {
693 // partial then full → full
694 let mut catalog = Catalog::new();
695 catalog.register_include("tigers", false);
696 catalog.register_include("tigers", true);
697 assert!(catalog.include_is_full("tigers"));
698
699 // full then partial → still full
700 let mut catalog = Catalog::new();
701 catalog.register_include("tigers", true);
702 catalog.register_include("tigers", false);
703 assert!(catalog.include_is_full("tigers"));
704
705 // partial then partial → partial
706 let mut catalog = Catalog::new();
707 catalog.register_include("tigers", false);
708 catalog.register_include("tigers", false);
709 assert!(catalog.was_included("tigers"));
710 assert!(!catalog.include_is_full("tigers"));
711 }
712
713 #[test]
714 fn register_image_records_in_document_order() {
715 let mut catalog = Catalog::new();
716 assert!(catalog.images().is_empty());
717
718 catalog.register_image("fixtures/dot.gif".to_string(), None);
719 catalog.register_image("logo.png".to_string(), Some("images".to_string()));
720
721 let images = catalog.images();
722 assert_eq!(images.len(), 2);
723
724 // The first image carries no `imagesdir`; `to_string`/`Display` yields
725 // the bare target.
726 assert_eq!(images[0].target, "fixtures/dot.gif");
727 assert_eq!(images[0].imagesdir, None);
728 assert_eq!(images[0].to_string(), "fixtures/dot.gif");
729
730 // The second records the `imagesdir` in effect at the reference.
731 assert_eq!(images[1].target, "logo.png");
732 assert_eq!(images[1].imagesdir, Some("images".to_string()));
733 assert_eq!(images[1].to_string(), "logo.png");
734 }
735
736 #[test]
737 fn register_link_records_in_document_order() {
738 let mut catalog = Catalog::new();
739 assert!(catalog.links().is_empty());
740
741 catalog.register_link("https://example.org".to_string());
742 catalog.register_link("mailto:fred@example.com".to_string());
743
744 assert_eq!(
745 catalog.links(),
746 ["https://example.org", "mailto:fred@example.com"]
747 );
748 }
749
750 #[test]
751 fn duplicate_id_error_impl_display() {
752 let did_error = DuplicateIdError("foo".to_string());
753 assert_eq!(did_error.to_string(), "ID 'foo' already registered");
754 }
755
756 #[test]
757 fn ref_type_impl_debug() {
758 assert_eq!(format!("{:#?}", RefType::Anchor), "RefType::Anchor");
759 assert_eq!(format!("{:#?}", RefType::Section), "RefType::Section");
760
761 assert_eq!(
762 format!("{:#?}", RefType::Bibliography),
763 "RefType::Bibliography"
764 );
765 }
766}