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